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",
|
"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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -857,10 +858,28 @@ export class LogicalOperators extends BaseRule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user