fix: defeasible reason codes, _subjectAsObject unary semantics, checker reason whitelist
- 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:
+1
-1
@@ -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": "",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user