fix: defeasible reason codes, _subjectAsObject unary semantics, checker reason whitelist
CI / test (push) Successful in 6m16s
CI / benchmark (push) Successful in 37s
CI / publish (push) Successful in 10s

- LogicalOperators normal mode now emits top-level reason codes
  (never_rule_triggered / requirements_not_met / defeated_by_unless) when a
  defeasible rule resolves to 0, instead of degrading to no_matching_rule.
- RuleEvaluator honors rule._subjectAsObject: unary DSL predicate calls inside
  binary evidence (banned(user) within can_open(user, doc)) check the relation
  on the subject itself, matching the unary fact's self-edge.
- AuthorizationChecker reason whitelist now preserves the defeasible reason
  codes so the checker reports why a defeasible rule denied.
This commit is contained in:
John Dvorak
2026-08-03 10:49:20 -07:00
parent 41d2547bc8
commit aa4ceff30c
4 changed files with 40 additions and 5 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@arbiter/core", "name": "@arbiter/core",
"version": "1.0.1", "version": "1.0.2",
"description": "Arbiter core engine: graph indices, relation/reachability, authorization rule evaluator, DSL/AST, condensed & sharded snapshots, and evidence fusion.", "description": "Arbiter core engine: graph indices, relation/reachability, authorization rule evaluator, DSL/AST, condensed & sharded snapshots, and evidence fusion.",
"license": "ISC", "license": "ISC",
"author": "", "author": "",
@@ -507,6 +507,12 @@ export class AuthorizationChecker {
if (res.reason === 'values_compared_comparison_true') reason = 'values_compared_comparison_true'; if (res.reason === 'values_compared_comparison_true') reason = 'values_compared_comparison_true';
if (res.reason === 'values_compared_comparison_false') reason = 'values_compared_comparison_false'; if (res.reason === 'values_compared_comparison_false') reason = 'values_compared_comparison_false';
if (res.reason === 'values_compared_comparison_insufficient') reason = 'values_compared_comparison_insufficient'; if (res.reason === 'values_compared_comparison_insufficient') reason = 'values_compared_comparison_insufficient';
// Add defeasible logic reasons (normal mode surfaces these when a
// defeasible rule resolves to 0 — never, requirements, or defeaters).
if (res.reason === 'defeated_by_unless') reason = 'defeated_by_unless';
if (res.reason === 'never_rule_triggered') reason = 'never_rule_triggered';
if (res.reason === 'requirements_not_met') reason = 'requirements_not_met';
if (res.reason === 'strict_rule_failed') reason = 'strict_rule_failed';
if (resAllowPossibility > maxAllow) { if (resAllowPossibility > maxAllow) {
maxAllow = resAllowPossibility; maxAllow = resAllowPossibility;
+11 -1
View File
@@ -34,7 +34,17 @@ export class RuleEvaluator {
// Convert string keys to numeric IDs if needed // Convert string keys to numeric IDs if needed
const numericUserId = typeof userId === 'string' ? this.arbiter.resolveNodeId(userId, options) : userId; const numericUserId = typeof userId === 'string' ? this.arbiter.resolveNodeId(userId, options) : userId;
const numericObjectId = typeof objectId === 'string' ? this.arbiter.resolveNodeId(objectId, options) : objectId; let numericObjectId = typeof objectId === 'string' ? this.arbiter.resolveNodeId(objectId, options) : objectId;
// Unary / subject-scoped rules (e.g. a DSL predicate call `banned(user)`
// inside a binary evidence) check the relation on the SUBJECT itself — the
// object is the user. The DSL generator marks these with _subjectAsObject;
// the evaluator rewrites the object to the subject so (u, banned, u) matches
// the unary fact's self-edge instead of (u, banned, object).
if (rule._subjectAsObject) {
numericObjectId = numericUserId;
objectKey = userKey;
}
const needsValueContext = valueContext !== null && valueContext !== undefined const needsValueContext = valueContext !== null && valueContext !== undefined
? true ? true
+22 -3
View File
@@ -805,6 +805,7 @@ export class LogicalOperators extends BaseRule {
return { return {
possibility: 0, possibility: 0,
reliability, reliability,
reason: 'never_rule_triggered',
collectedValues: allCollectedValues, collectedValues: allCollectedValues,
meta: { meta: {
...resultMeta, ...resultMeta,
@@ -856,11 +857,29 @@ export class LogicalOperators extends BaseRule {
resultMeta = { ...defeatersResult.meta, defeatedBy: defeatersResult.meta }; resultMeta = { ...defeatersResult.meta, defeatedBy: defeatersResult.meta };
} }
} }
const finalPossibility = Math.max(0, Math.min(1, possibility));
// Surface a top-level reason so the AuthorizationChecker reports why a
// defeasible rule resolved to 0 instead of a generic 'no_matching_rule'.
// Ordering mirrors precedence: requirements beat defeaters; a defeater
// reason is only claimed when the base (when/strict) leg was actually
// active — otherwise the rule simply did not match.
let reason;
if (finalPossibility === 0) {
const baseWasActive = (defeasibleResult?.possibility || 0) > 0 || (strictResult?.possibility || 0) > 0;
if (requiresResult && requiresResult.possibility < 0.5) {
reason = 'requirements_not_met';
} else if (defeatersResult && defeatersResult.possibility > 0.5 && baseWasActive) {
reason = 'defeated_by_unless';
}
}
return { return {
possibility: Math.max(0, Math.min(1, possibility)), possibility: finalPossibility,
reliability, reliability,
validity: buildValidity('product', [], [], 2, Math.max(0, Math.min(1, possibility))), reason,
validity: buildValidity('product', [], [], 2, finalPossibility),
collectedValues: allCollectedValues, collectedValues: allCollectedValues,
meta: { meta: {
...resultMeta, ...resultMeta,