…less definitely withdrawn (#6734)
* fix(policy): refuse a policy whose prohibition has a degraded constraint
The unsatisfiable guard fails closed on a permission but open on a prohibition: the
prohibition never fires, so a sibling permission grants what the author forbade. Refuse
the policy when any prohibition constraint, atomic or inside a compound, degraded to it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1gMf8RJim1LzuKapZNpZR
* fix(policy): three-valued constraint evaluation; a prohibition fires unless definitely withdrawn
Every constraint is True, False or Unknown. Missing evidence, the parser's guard for an
unsupported or malformed constraint, an odrl:unit, and an incomparable pair (mismatched
types, an unparseable dateTime, typed set membership) are Unknown, and and/or/xone
propagate it. A permission grants only on True; a prohibition fires on True or Unknown.
A constrained duty is never treated as discharged by its action alone. Refinements on a
rule's action, target or assignee, and blank-node prohibition heads, refuse the policy.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1gMf8RJim1LzuKapZNpZR
* fix(policy): ValidatedPolicy boundary and one decide() that issues the Permit
Every decision entry point (decide/evaluate, matched_prohibition,
prohibition_status, evaluate_and_exercise) now takes a ValidatedPolicy,
which only Policy::validate builds. Validation refuses empty and/or/xone,
a guarded (degraded) prohibition and a blank-node prohibition head, so
typed construction and ledger replay pass the same checks as parsing.
decide() applies the declared conflict strategy, the three-valued
prohibitions and the duty rule, and a grant carries a Permit with a
private constructor (Decision is non_exhaustive).
Also: xone is False once two operands are True; a typed set member
degrades the set to Unknown; static containment claims an implication
only for a pair the evaluator can decide.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1gMf8RJim1LzuKapZNpZR
* fix(policy): containment proves only what the evaluator decides
outer_admits_value now asks the evaluator's own three-valued comparison
(atomic_status) and claims admission only on a definite True, so a
mixed-offset dateTime spelling or an incomparable pair is never proven.
A negative operator on a recipient, purpose or spatial dimension is not
claimed (the request's membership or taxonomy evidence can place the
value inside the exclusion). An inclusive inner bound implies a strict
outer one only when strictly inside it. The module-local copies of the
comparison helpers are gone.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1gMf8RJim1LzuKapZNpZR
* fix(policy,solid): store only grants decide settled for good (#6737)
* fix(solid): every ODRL bridge grant comes from sparq-policy's decision
One-shot, policy and counted allows (and the counted refresh) are emitted
only from the Permit decide() issues. The conditional ACP allow is
emitted only from a ConditionalPermit, which the new decide_conditional
issues after settling the conflict strategy, prohibitions, action,
target and duties (a constrained duty is never discharged). The N3 path
writes a derived grant only when it is exactly the decide() permit's.
New tests/odrl_decision_entry_points.rs runs every undecidable shape
through each grant entry point and a ledger replay.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1gMf8RJim1LzuKapZNpZR
* fix(solid): conditional grants only when no prohibition reaches another session
A conditional grant is re-checked per session, so it is emitted only when every
prohibition is withdrawn for every session (wrong action, wrong target, or a fixed
constraint that is definitely false). Otherwise the bridge falls back to a one-shot
grant for the deciding party. An exclusion-only grant now heads on
auth:Authenticated, so an anonymous session (which could be the excluded party)
is denied.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1gMf8RJim1LzuKapZNpZR
* fix(solid,policy): store only grants decide() settled for good
The bridge no longer persists recipient, assignee or dateTime constraints as
re-checked conditional grant heads, and the conditional materialiser's
fallback is gone: materialize_permission_conditional stores the same one-shot
grant as materialize_permission. decide_conditional and ConditionalPermit are
removed; conditional denies are unchanged.
Every allow comes from the Permit decide() issued for that exact request. The
emitter refuses a party that is not a single agent, a permit whose checked
recipient is not the party, and a permit that is not lasting(): the
permission's clock constraints must all be lower bounds and every prohibition
must be withdrawn for good. decide() denies a request whose party changed
after Request::by. contains() claims nothing when either policy's conflict
strategy is refused.
Recipient-scoped and time-windowed grants for other sessions are tracked in
#6743 (per-request evaluation through decide()).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1gMf8RJim1LzuKapZNpZR
* fix(policy,solid): lasting only on fixed operands; refused counted grants spend nothing
Permit::lasting() is now an allow-list: a permission's constraints must be on
the party's identity, the request's purpose or place, or a clock lower bound,
and a prohibition counts as withdrawn for good only on a definitely false
constraint over those operands or a closed lt/lteq clock window. Elapsed
time, counters and any other operand are never lasting.
The counted bridge checks the base decision's permit for storability before
the atomic exercise, so a grant the bridge would refuse (not lasting, a
wildcard party, a recipient mismatch) spends no usage unit.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1gMf8RJim1LzuKapZNpZR
* fix(policy,solid): never store purpose-, place- or count-limited grants
A stored allow records only party, mode and target, so a grant decided for one
purpose or place, or under a usage count, would let later requests through that
the policy does not allow.
- Purpose and spatial come off the lasting allow-list, for permissions and for
prohibition withdrawal alike.
- `base_decision` is the count-free decision `evaluate_and_exercise` grants on; a
grant from a count-limited permission is marked not lasting there, so the
counted bridge refuses it before spending budget. Counted access goes through
`evaluate_and_exercise` per request.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1gMf8RJim1LzuKapZNpZR
* fix(policy,solid): store only unconstrained grants to the named party
A stored allow records party, mode and target, and is never re-checked, so
`Permit::lasting` now holds for exactly one shape: a permission with no
constraints, logical constraints or duties, assigned to exactly the requesting
party (not a declared party collection), targeting exactly the requested asset
or every asset, decided without membership evidence, in a policy with no
prohibitions. Assignee context, clock lower bounds and membership-dependent
matches or withdrawals no longer count as lasting.
`Policy::validate` refuses two rules sharing an id, since decisions and the
count guard name a rule by id.
The window oracle corpus now expects carol to be denied at every instant, and
the server ODRL lane's oracle is a lasting `decide` grant.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1gMf8RJim1LzuKapZNpZR
* test(solid): the two-operand N3 regression expects the parse refusal
#6657 made sparq-policy refuse a constraint node with several distinct left
operands, so the Rust reference no longer parses this policy. The test now
asserts that refusal instead of unwrapping the parse, and still checks the N3
path refuses and materializes nothing.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1gMf8RJim1LzuKapZNpZR
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(policy,solid): N3 path keeps unproven prohibitions; duties block containment
The N3 rules read a prohibition with no evidence for its constraint as not
applying, so an existing grant survived where the reference path denies. The
N3 entry point now also materializes the reference prohibition deny.
contains() ignored outer duties; an outer permission with a duty the inner
one does not share (or any constrained duty) is now undecided.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1gMf8RJim1LzuKapZNpZR
* fix(policy): containment and conflicts decide only what they can prove
A request's membership evidence can make any party or asset IRI a collection
another is part of, so unequal targets or assignees no longer prove two rules
disjoint, and a conflict is Certain only when the prohibition names every
target and assignee the permission does. contains() returns Unknown when
either side declares a party collection or carries a constrained duty (its
permission grants nothing, so any witness built from it is false). Action
subsumption now follows Action::permits, so use no longer covers sell.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1gMf8RJim1LzuKapZNpZR
* fix(policy): containment verdicts are witness-backed or narrowly proven
NotContained now needs a concrete request, built from an unconstrained,
duty-free inner permission, that decide grants under inner and not under
outer. Contains is claimed only when neither side has a party collection,
duty or compound constraint, and each inner permission has an outer one
whose action permits it, whose target and assignee are open or equal, and
whose constraints all appear identically on it. A Certain conflict needs the
prohibition's action to cover the permission's and its constraints to appear
identically on the permission. contains() now takes ValidatedPolicy.
tests/odrl_compare_witness.rs checks every definite verdict against decide
over generated policy pairs and sampled requests.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1gMf8RJim1LzuKapZNpZR
* fix(bench): AC drivers build against ValidatedPolicy and the stricter bridge
The live and overhead drivers now hold ValidatedPolicy. Overhead lane A
expects only the bare permission to be stored; permission+prohibition,
recipient-constrained and counted policies are refused, so their rows time
the refusal and assert no access. Lane B drops the conditional regime, since
the bridge no longer stores conditional grants. Lockfiles pick up the
current workspace dependencies.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1gMf8RJim1LzuKapZNpZR
* fix(solid): conditional denies never narrow what decide denies
An odrl:assignee constraint has no evidence in a request, so decide keeps
the prohibition in force for every party; the conditional path mapped it to
a head denying the named assignee only, leaving other parties' grants
standing. Such a rule now takes the one-shot path, and the conditional path
always materializes the reference deny for the materializing request too.
tests/odrl_deny_superset.rs generates prohibitions over every left operand
and assignee shape, layers them over a public WAC grant through both deny
entry points, and checks every party decide denies is denied, after
materialize and after a ledger refresh.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1gMf8RJim1LzuKapZNpZR
* Make the conditional deny independent of who materialized it
Party-only prohibitions (rule-level assignee IRI, recipient eq/neq/isAnyOf/
isNoneOf over plain IRIs) get heads the session layer re-checks for whoever
asks, plus a deny for anonymous sessions. Every other prohibition gets an
unconditional deny on its target and mode. The one-shot deny for the
materializing request is kept as well.
odrl_deny_superset now materializes as one party and checks every session
decide denies (other parties, an unseen party, anonymous) after materialize,
refresh and a ledger replay.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1gMf8RJim1LzuKapZNpZR
* Deny every session on a prohibited asset until per-request decide lands
Each prohibition covering the request's action and target (the asset, an
asset collection the request's evidence puts it in, or no target) now stores
one unconditional deny on auth:Public, with no per-session or anonymous
heads, so the stored deny covers every session decide denies by
construction. A party-scoped prohibition over-denies other parties on that
asset until #6743 re-checks the party per request. An anonymous one-shot
request stores the same unconditional deny, on materialize and on re-emit.
odrl_deny_superset now also covers unseen, collection and wildcard
assignees, collection, absent and unrelated targets, and anonymous
materializers.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C1gMf8RJim1LzuKapZNpZR
---------
Co-authored-by: Claude <noreply@anthropic.com>
Summary
Stacked on #6734 (review that first; this PR's diff is the bridge and the decision's grant token).
Before: sparq-solid wrote grants the evaluator had not settled.
auth:ConditionalGrantheads re-checked per session. A head re-checks identity and clock but not the rest of the decision, so it admitted sessionsdecidedenies: a prohibition on another party or at a later time, an assignee and a recipient that must both hold, two recipient constraints read as alternatives, and a duty with its own constraint counted as discharged by action alone.auth:Publicas the request party), or a party other than the recipient the decision checked.dateTime lteqprohibition with no clock).After: the bridge stores only grants that depend on nothing that can change. This is fail-closed and drops opt-in
odrl-bridgebehaviour, which no published artifact contains: any constrained grant (recipient, assignee context, time window, purpose, place, count), any grant from a policy that has a prohibition, and any grant decided with membership evidence is no longer stored. Counted access stays available per request throughevaluate_and_exercise. The server's ODRL query lane and the wac-oracle window corpus deny those cases now, as intended. Restoring them soundly needs per-request evaluation throughdecidein the enforcement path, tracked in #6743.Permitdecideissued for that exact request, and the triple carries exactly its party, mapped action and target. One-shot, policy, counted, refresh and N3 allows all go through one emitter.auth:Public,auth:Authenticated, a reserved encoding), a permit whose checked recipient is not the party, and a permit that is notlasting().lasting()holds for exactly one shape: a permission with no constraints, logical constraints or duties, assigned to exactly the requesting party (a named IRI, not a declared party collection), targeting exactly the requested asset or every asset, decided without party or asset membership evidence, in a policy with no prohibitions.Policy::validaterefuses two rules sharing an id, so the count guard and a permit always name one rule.base_decision(count feature) is the count-free decisionevaluate_and_exercisegrants on; a grant from a count-limited permission is not lasting there, so the counted bridge refuses it before spending budget.decidedenies a request whosepartywas changed afterRequest::by, so a permit never names a party other than the one whose recipient evidence was checked.materialize_permission_conditionalnow stores the same one-shot grant. The conditional allow code,decide_conditionalandConditionalPermitare removed. Conditional denies are unchanged.containsreturnsUnknownwhen either policy's conflict strategy is onedeciderefuses.odrl:conflictdefault stays deny-overrides, as decided in odrl-bridge: unset odrl:conflict defaults to prohibit, not the ODRL spec default of invalid (follow-up to sq-ihqbl) #1375; the skill now states that this lets an uncontested permission in an otherwise conflicting policy grant, where ODRL'sinvalidwould void it.Base gate (always required)
cargo build --workspacesucceeds.cargo clippy -p sparq-policy -p sparq-solid -p sparq-lws-core -p sparq-server --all-features --all-targets -- -D warningsis clean.cargo testpasses for sparq-policy (all features), sparq-solid (default,odrl-bridge, all features), sparq-lws-core, sparq-trust, sparq-wac-oracle and sparq-server.odrl_n3_failopen_regression::defect1_two_operand…(failing on main since fix(policy,reason): fail closed on ambiguous ODRL constraints and hostile RIF/XML input #6657 made the parser refuse the node) now asserts that refusal. Known failure, identical on main: sparq-lws-coreredis_replay::mark_fails_closed_when_redis_errors(needs a Redis service).tests/odrl_decision_entry_points.rsruns 9 undecidable shapes × permission/prohibition × atomic/and/or/xonethrough every grant entry point (evaluate, the one-shot, policy and conditional materialisers, N3, and a ledger replay of a grant live before the policy changed), plus the constrained-duty and no-clock-prohibition counterexamples and a decidable control.only_an_unconstrained_grant_to_the_named_party_is_lastingenumerates every ODRL left operand (atomic, insideor, and on a prohibition), every prohibition shape, every assignee shape (none, membership, declared collection, membership evidence, asset membership) and a discharged duty, and only the one shape is lasting;duplicate_rule_ids_are_refused; the permit binds the checked recipient; containment under a refused conflict strategy. Bridge tests: constrained, purpose-scoped, windowed and prohibition-carrying policies store nothing for anyone; bridge/decide parity per agent with an unconstrained control; a wildcard party is never granted. Count tests: a count-limited grant is never stored and spends nothing, whileevaluate_and_exercisestill grants N times. N3 differential grant floors are the exact counts (N3 3, Rust 4). Serverodrl_authzlane equals a lastingdecidegrant and is never wider thanevaluate; wac-oracle window corpus expects carol denied at every instant.Targeted re-evaluation (check the rows that apply to your change)
decideadds the party/recipient consistency check and thelastingflag; no other rule changes.Ratchets and conventions
Security
🤖 Generated with Claude Code
https://claude.ai/code/session_01C1gMf8RJim1LzuKapZNpZR
Generated by Claude Code