diff --git a/package.json b/package.json index a0dd19d..4767cc3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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.", "license": "ISC", "author": "", diff --git a/src/authorization/AuthorizationChecker.js b/src/authorization/AuthorizationChecker.js index 940039c..2ab87bb 100644 --- a/src/authorization/AuthorizationChecker.js +++ b/src/authorization/AuthorizationChecker.js @@ -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_false') reason = 'values_compared_comparison_false'; 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) { maxAllow = resAllowPossibility; diff --git a/src/authorization/RuleEvaluator.js b/src/authorization/RuleEvaluator.js index 77f017b..4bda1f9 100644 --- a/src/authorization/RuleEvaluator.js +++ b/src/authorization/RuleEvaluator.js @@ -34,7 +34,17 @@ export class RuleEvaluator { // Convert string keys to numeric IDs if needed 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 ? true diff --git a/src/authorization/rules/LogicalOperators.js b/src/authorization/rules/LogicalOperators.js index de22b5b..4f0f10c 100644 --- a/src/authorization/rules/LogicalOperators.js +++ b/src/authorization/rules/LogicalOperators.js @@ -805,6 +805,7 @@ export class LogicalOperators extends BaseRule { return { possibility: 0, reliability, + reason: 'never_rule_triggered', collectedValues: allCollectedValues, meta: { ...resultMeta, @@ -856,11 +857,29 @@ export class LogicalOperators extends BaseRule { 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 { - possibility: Math.max(0, Math.min(1, possibility)), + possibility: finalPossibility, reliability, - validity: buildValidity('product', [], [], 2, Math.max(0, Math.min(1, possibility))), + reason, + validity: buildValidity('product', [], [], 2, finalPossibility), collectedValues: allCollectedValues, meta: { ...resultMeta,