Skip to content

fix(solid): route every ODRL bridge grant through sparq-policy's decision - #6737

Merged
jeswr merged 8 commits into
fix/policy-degraded-prohibitionfrom
fix/policy-bridge-decide
Oct 9, 2026
Merged

jeswr merged 8 commits into
fix/policy-degraded-prohibitionfrom
fix/policy-bridge-decide

Conversation

@jeswr

@jeswr jeswr commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

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.

  • The conditional ACP materialiser persisted recipient, assignee and dateTime constraints as auth:ConditionalGrant heads re-checked per session. A head re-checks identity and clock but not the rest of the decision, so it admitted sessions decide denies: 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.
  • Its one-shot fallback, and the one-shot path itself, stored a triple that outlived the decision: a permission valid until a date, or a prohibition that opens later, still granted after that time.
  • A grant could name a wildcard principal (auth:Public as the request party), or a party other than the recipient the decision checked.
  • The N3 materialiser granted when a prohibition lacked evidence (a dateTime lteq prohibition 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-bridge behaviour, 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 through evaluate_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 through decide in the enforcement path, tracked in #6743.

  • Every allow comes from the Permit decide issued 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.
  • The emitter refuses a party that is not a single agent (auth:Public, auth:Authenticated, a reserved encoding), a permit whose checked recipient is not the party, and a permit that is not lasting(). 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::validate refuses two rules sharing an id, so the count guard and a permit always name one rule.
  • base_decision (count feature) is the count-free decision evaluate_and_exercise grants on; a grant from a count-limited permission is not lasting there, so the counted bridge refuses it before spending budget.
  • The counted path checks the base permit for storability before the atomic exercise, so a grant the bridge refuses spends no usage unit.
  • decide denies a request whose party was changed after Request::by, so a permit never names a party other than the one whose recipient evidence was checked.
  • materialize_permission_conditional now stores the same one-shot grant. The conditional allow code, decide_conditional and ConditionalPermit are removed. Conditional denies are unchanged.
  • contains returns Unknown when either policy's conflict strategy is one decide refuses.
  • The N3 path keeps a derived grant only when it is exactly the permit's.
  • The unset odrl:conflict default 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's invalid would void it.

Base gate (always required)

  • cargo build --workspace succeeds.
  • cargo clippy -p sparq-policy -p sparq-solid -p sparq-lws-core -p sparq-server --all-features --all-targets -- -D warnings is clean.
  • The touched lines are formatted.
  • cargo test passes 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-core redis_replay::mark_fails_closed_when_redis_errors (needs a Redis service).
  • tests/odrl_decision_entry_points.rs runs 9 undecidable shapes × permission/prohibition × atomic/and/or/xone through 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.
  • Policy tests: only_an_unconstrained_grant_to_the_named_party_is_lasting enumerates every ODRL left operand (atomic, inside or, 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, while evaluate_and_exercise still grants N times. N3 differential grant floors are the exact counts (N3 3, Rust 4). Server odrl_authz lane equals a lasting decide grant and is never wider than evaluate; wac-oracle window corpus expects carol denied at every instant.
  • Tests for the removed conditional-allow heads are deleted; the deny tests that layered on a public allow now get it from a static WAC rule.

Targeted re-evaluation (check the rows that apply to your change)

  • Policy evaluation: decide adds the party/recipient consistency check and the lasting flag; no other rule changes.

Ratchets and conventions

  • I did not lower any ratchet. The N3 differential sweep's N3-grant non-vacuity floor moves from 20 to 10 because three clock-bounded shape families (nine cases) no longer store a grant; the Rust-grant, deny, refusal and total floors are unchanged.
  • No hard-coded performance numbers added to markdown.

Security

  • Closes the grant-widening paths in the ODRL bridge listed above. All changes are toward deny.

🤖 Generated with Claude Code

https://claude.ai/code/session_01C1gMf8RJim1LzuKapZNpZR


Generated by Claude Code

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
@jeswr

jeswr commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

🔎 Codex reviewer — gpt-6.1-sol

Automated review by the Codex reviewer (OpenAI gpt-6.1-sol via Codex CLI) of head 574e6c2268af. Scope: correctness, security, soundness and design; style nits omitted. A new head gets a fresh review.

Findings

  1. [high] ConditionalPermit does not settle prohibitions across sessions — crates/sparq-policy/src/eval.rs:630
    The new API checks prohibitions against the materialization request, then issues a permit whose identity and time remain open. For example, a policy permitting everyone to read a target but prohibiting Bob passes decide_conditional for Alice. The bridge emits a public conditional allow, and Bob subsequently reads the target: the session check contains no prohibition, although decide for Bob denies. Similarly, a future dateTime prohibition can become applicable after materialization without invalidating the allow.

    The bridge had analogous behavior on the base; the new design problem is certifying this request-specific result as a ConditionalPermit with prohibitions settled. Require prohibition non-applicability across every admitted identity/time, or retain and re-evaluate the relevant prohibitions per session; otherwise use a one-shot permit.

Requested checks

  1. Construction: Neither permit has public mutable fields, Default, deserialization, conversion constructors, or exposed test constructors. Cloning preserves their contents. ValidatedPolicy::default() is publicly available but produces an empty, denying policy; editing requires extracting the model and validating again. These guarantees concern safe Rust—privacy cannot prevent arbitrary unsafe memory manipulation.

  2. Grant routing: One-shot, policy, counted and counted-refresh allows use Permit; N3 grants must equal its triple. Conditional allows require ConditionalPermit, subject to finding 1. The inspected server, built-in LWS gate and wasm policy evaluator route through the bridge or evaluate/decide. Independent WAC/ACP authorization and the injectable LWS gate remain separate authorization mechanisms.

  3. Session re-check: Persisted permission windows reject missing or unparseable clocks, and the cache key includes identity and now. Clock accuracy remains caller-supplied. These checks do not re-evaluate prohibitions whose applicability changes with identity or time.

  4. Conflict strategy: Both decision functions check strategy admissibility before prohibitions. This implements the documented subset: perm is refused, prohibit uses deny-overrides, and explicit invalid refuses detected conflicts. The unset default remains deny-overrides, differing from ODRL 2.2’s invalid default. ODRL conflict semantics.

  5. Earlier highs: The constrained-duty conditional bypass and N3 missing-prohibition-evidence bypass are closed for the evaluated request. The base’s typed-set guards and mandatory validation for direct construction and ledger replay remain intact.

  6. Logic and coverage: xone returns False for at least two True operands, True for exactly one True without Unknown, and Unknown when unresolved; zero True without Unknown returns False. The incomparable contains() implication has a comparability guard, but general containment soundness is not established: unchanged code claims inner lteq 5 implies outer lt 5. The new test matrix covers the requested materializers and three replay kinds for admitted policies, but checks only the original Alice session and omits counted paths.

Tests were inspected, not executed in this read-only checkout.

Verdict: Resolve the new conditional-permit soundness guarantee before merging as is.

claude added 2 commits October 9, 2026 03:06
…er 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
@jeswr

jeswr commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

🔎 Codex reviewer — gpt-6.1-sol

Automated review by the Codex reviewer (OpenAI gpt-6.1-sol via Codex CLI) of head 128f3fe0ebdb. Scope: correctness, security, soundness and design; style nits omitted. A new head gets a fresh review.

Findings

  1. [high] The fallback grant survives into a prohibited time — crates/sparq-solid/src/odrl_bridge.rs:1151
    With permission to read and a prohibition beginning 2027-01-01, materializing for Alice in June 2026 falls back to a persistent Alice auth:read target triple. Alice’s session in 2027 still reads: ordinary triples ignore Session::now. refresh_odrl_grants() also replays the stored 2026 request, preserving the grant. The new future-prohibition test never checks Alice after the boundary.
    Make fallback authorization request-scoped, or retain and re-evaluate its temporal restrictions during enforcement.

  2. [high] Conditional grants still discard identity conjunctions — crates/sparq-solid/src/odrl_bridge.rs:1376
    A permission with odrl:assignee Alice and recipient eq Bob receives a ConditionalPermit, but condition_agents uses Bob’s recipient head and ignores Alice’s assignee restriction. Bob therefore reads although decide() denies his request. Similarly, separate atomic recipient constraints are accumulated as alternative heads although the evaluator requires their conjunction. These inherited mapper defects remain reachable through the new permit boundary.
    Intersect all identity restrictions, including the assignee property; reject mappings that cannot preserve the evaluator’s conjunction.

  3. [medium] A supposedly concrete fallback party can become a wildcard principal — crates/sparq-solid/src/odrl_bridge.rs:405
    emit_allow checks only that the party exists. For an unrestricted permission and a prohibition assigned to Bob, a request with .by("https://sparq.dev/ns/auth#Public") passes decide() and the conditional fallback writes a public allow. Bob and anonymous sessions then match that triple. Using auth:Authenticated similarly grants every authenticated agent. The Permit preserves the string but does not make it an exact WebID principal.
    Reject authorization wildcard and encoded principal values when emitting grants intended for one concrete party.

  4. [medium] The unset conflict strategy can grant from a policy ODRL considers void — crates/sparq-policy/src/compare.rs:189
    This inherited divergence remains in the combined head. A policy with conflicting read permission/prohibition on asset A and an uncontested read permission on asset B grants B when conflict is unset. ODRL 2.2 §2.10 defaults to invalid, which voids the entire conflicting policy. Deny-overrides therefore is not universally conservative relative to that default.
    Apply invalid when unset, or require explicit prohibit for this behavior and qualify the fail-closed claims accordingly.

Requested checks

Reviewed both the PR-only diff and the combined head against origin/main. This was a static review; tests were not run in the read-only checkout.

  1. Earlier highs: N3 unknown-prohibition grants and constrained-duty discharge are closed. Simple recipient exclusions now deny anonymous sessions. The conditional prohibition check considers other identities and times, but its fallback retains the temporal bypass in finding 1.
  2. Fallback binding and replay: Normal WebIDs remain scoped to their agent and target, with action mapped to a Solid mode. Client, issuer and time are not retained. Ledger and counted refresh replay stored requests rather than the accessing session; wildcard party strings also widen access as described above.
  3. Authenticated exclusion heads: A simple recipient neq Bob correctly excludes Bob, and .by(WebID) supplies default recipient evidence. This does not establish faithful enforcement of all identity restrictions: assignee mismatches and atomic conjunctions remain vulnerable.
  4. Default conflict strategy: The specified default is invalid; the documented divergence can fail open on uncontested rules in an otherwise void policy.
  5. Public Decision fields and construction: Callers can alter Decision.allow, but bridge grant paths evaluate internally and require a Permit or ConditionalPermit. Safe external code cannot forge either permit or bypass policy validation. ValidatedPolicy::default() does publicly construct the valid empty policy; Policy::validate() constructs other admitted policies.

Verdict: Request changes; the stack still permits authorization widening.

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

jeswr commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

On the medium about the unset odrl:conflict strategy (finding 4 at 128f3fe): this stays deny-overrides by the maintainer's decision in #1375 ("keep prohibit as the default; document the divergence"). A prohibition still always wins wherever it applies; the gap is only that an uncontested permission in an otherwise conflicting policy grants, where ODRL 2.2's invalid would void the whole policy. skills/usage-control-policy/SKILL.md now says exactly that, and that declaring odrl:conflict odrl:invalid gets the void-the-policy behaviour. The other findings are addressed at 06fcbd3 (see the PR body).


Generated by Claude Code

@jeswr

jeswr commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

🔎 Codex reviewer — gpt-6.1-sol

Automated review by the Codex reviewer (OpenAI gpt-6.1-sol via Codex CLI) of head 06fcbd33e93c. Scope: correctness, security, soundness and design; style nits omitted. A new head gets a fresh review.

Findings

  1. [high] lasting() certifies advancing non-dateTime constraints as permanent — crates/sparq-policy/src/eval.rs:618

    Every operand other than odrl:dateTime is treated as stable. An admitted permission with elapsedTime lt 10, evaluated with Value::Num(1.0) as elapsed-time evidence, receives lasting() == true and becomes an unconditional stored grant. At elapsed time 11, decide denies, but accessible/query_as still grant access. Similarly, withdrawn_later treats a currently false elapsedTime gteq 10 prohibition as withdrawn forever.

    Certify only dimensions whose stability is established. Reject durable materialization of other temporal or mutable constraints until enforcement re-evaluates them per request.

  2. [medium] Newly rejected counted grants still consume usage budget — crates/sparq-solid/src/odrl_bridge.rs:1926

    evaluate_and_exercise consumes budget before emit_allow checks the new persistence and identity restrictions. For a permission combining count lteq 1 with a currently satisfied dateTime deadline, the bridge returns granted == false because the permit is not lasting, but consumes the sole unit. Subsequent count-aware exercises deny despite no access having been granted. Recipient mismatch and wildcard-party rejection can also burn budget.

    Check bridge eligibility using a non-consuming decision before the atomic consume operation, while retaining permit-only grant emission.

Requested checks

Reviewed both the PR-only diff and the combined stack against origin/main. This was source review; builds and tests were not run in the read-only environment.

  1. Allow paths: No stored ODRL allow bypass found. One-shot, policy, conditional, counted, refresh and N3 grants converge on emit_allow(Permit). Server and GUI callers use those paths. LWS also has a direct per-request evaluator gate; it does not persist grants. No additional wasm bridge writer found.

  2. Persistence soundness: Future dateTime starts and permission lt/lteq deadlines are rejected correctly. Constrained duties, including deadlines, cannot discharge. Elapsed-time constraints remain unsafe as described above. Counted permits inherit lasting from the count-stripped policy, and stored access still relies on explicit refresh rather than per-query counting. Policy revocation likewise requires updating the ledger policy and refreshing; lasting() does not guarantee validity after revocation.

  3. Identity binding: Party/recipient matching is exact by string. Changed .by() parties, auth-namespace wildcard parties and reserved encodings are rejected. No normalization-based wildcard widening found. Blank-node-like strings are not IRI-validated, but remain exact-match heads rather than wildcard principals.

  4. Non-vacuity floor: Static enumeration gives N3 grants 21 → 12: three removed clock-containing shape families each contributed three cases. The 10 floor still detects losing an entire surviving three-case family. Rust grants become 40 → 20, so lowering its floor to 10 is unnecessary and weakens coverage.

  5. Earlier highs: The specific dateTime fallback, assignee/recipient restriction, identity conjunction, wildcard party, N3 unknown-evidence and constrained-duty counterexamples are closed. The broader persistence guarantee remains incomplete for advancing non-dateTime evidence and count-sensitive grants.

Verdict: Request changes before merging; persistence certification and rejected-grant budget consumption remain incorrect.

…ants 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
@jeswr

jeswr commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

🔎 Codex reviewer — gpt-6.1-sol

Automated review by the Codex reviewer (OpenAI gpt-6.1-sol via Codex CLI) of head de53c7a671c8. Scope: correctness, security, soundness and design; style nits omitted. A new head gets a fresh review.

Findings

  1. [high] Purpose and location constraints become unrestricted stored grants — crates/sparq-policy/src/eval.rs:619

    STABLE_LEFT_OPERANDS treats purpose and spatial evidence as fixed, but the emitted grant contains only party, mode and target. For example, Alice materializes a research-only permission with purpose research; subsequent accessible/query_as calls admit Alice for marketing or without purpose evidence. Session carries neither purpose nor location, so enforcement cannot re-check either. Similarly, a prohibition on marketing is certified permanently withdrawn during a research request, letting later marketing requests bypass it.

    Remove purpose and spatial from the lasting allow-list for both permissions and prohibition withdrawal. Restore them only with enforcement that scopes or re-evaluates each actual request.

  2. [high] Count stripping certifies an exhaustible grant as lasting — crates/sparq-policy/src/count.rs:391

    evaluate_and_exercise evaluates a policy with permission count constraints removed, then forwards that decision’s Permit, including lasting=true. The counted bridge consequently passes allow_binding and stores an unrestricted allow. With count lteq 1, the first materialization exhausts the budget, yet subsequent query_as calls still succeed; another counted materialization denies but leaves the existing grant intact. Retraction requires an explicit refresh, and ordinary accesses never consume units.

    Preserve the original count restriction when determining permit storability and refuse persistent counted grants, or enforce atomic consumption on every actual access.

Requested checks

  1. Allow paths: All ODRL auth-view allow writers reviewed route through emit_allow(Permit), including policy, counted, refresh, N3, server and GUI paths. The native LWS gate evaluates each request directly; the wasm policy probe returns decisions rather than storing grants. Conditional deny heads remain.

  2. lasting() soundness: Purpose and spatial must come off the list: later requests inherit the stored grant without those constraints being checked. Future dateTime gteq prohibitions, permission lt/lteq deadlines, elapsed time and arbitrary operands are otherwise conservatively rejected. Constrained duties, including deadlines, cannot be discharged by action alone. Counted grants remain unsound as described above. Policy revocation requires refresh_odrl_grant with the updated policy; lasting() does not establish validity across policy changes.

  3. Party/recipient binding: Party changes after .by() deny, explicit recipient mismatches refuse materialization, and auth-namespace/reserved principals are rejected. Matching is byte-for-byte; wildcard aliases are not normalized into public grants. Blank-node-style party strings are not rejected as invalid IRIs because emission uses NamedNode::new_unchecked, but they do not expand into wildcard principals.

  4. N3 floor: Static enumeration supports the reduction: three permission shape families lose three N3 grants each, reducing 21 grant cases to 12. The removed families are the future upper bound, the clock-bearing or, and the clock-bearing and. A floor of 10 still detects losing any complete remaining three-case family. The Rust floor remains 20.

  5. Earlier findings: Cross-party conditional heads, dropped assignee restrictions, identity conjunctions, wildcard fallback parties, unknown-evidence N3 grants and constrained-duty discharge are addressed. The elapsed-time/arbitrary-operand defect and rejected-counted-grant budget consumption are closed. The broader stored-grant soundness requirement remains unresolved by Findings 1–2.

Reviewed the PR diff and combined ODRL state against origin/main. No files were modified; tests were not rerun.

Verdict: Unsafe to merge as is because stored grants still widen authorization beyond the evaluated request.

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
@jeswr

jeswr commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

🔎 Codex reviewer — gpt-6.1-sol

Automated review by the Codex reviewer (OpenAI gpt-6.1-sol via Codex CLI) of head 797aee653fbe. Scope: correctness, security, soundness and design; style nits omitted. A new head gets a fresh review.

Findings

  1. [high] Assignee context is incorrectly certified as immutable — crates/sparq-policy/src/eval.rs:626
    An odrl:assignee constraint reads Request.context, unlike the rule’s assignee property. A permission constrained by assignee eq urn:alice grants and becomes lasting() when Alice supplies that context value. The bridge then stores an unconditional grant for Alice; later requests without that context, or with a different value, receive access although decide denies them. The same mistake can certify a context-dependent prohibition as permanently withdrawn.

    Remove assignee constraints from the stable allow-list unless their evaluated value is enforced as the stored party’s identity. Apply that restriction to prohibition withdrawal too.

  2. [high] Collection membership can invalidate a supposedly lasting grant — crates/sparq-policy/src/eval.rs:643
    withdrawn_later treats an assignee/target mismatch as permanent, but party_matches and asset_matches depend on request-supplied collection membership. For example, an unrestricted permission plus a prohibition assigned to a declared group produces a lasting grant for Alice before she joins that group. After membership evidence places Alice in the group, decide denies her while the stored grant still permits access. Permissions and recipient constraints satisfied through membership likewise survive removal of that membership because holds_later never checks this dependency.

    Only certify membership-independent decisions as lasting, or bind stored grants to membership-state invalidation and re-evaluate before access.

  3. [high] Duplicate rule IDs bypass the count-limited storage guard — crates/sparq-policy/src/count.rs:439
    Policy::validate accepts duplicate permission IDs, but base_decision identifies the granting rule using the first matching ID. A hand-built policy with an uncounted Bob-only rule followed by an Alice-only count lteq 0 rule, both sharing an ID, grants Alice after count stripping. This lookup finds Bob’s uncounted rule and leaves Alice’s permit lasting. evaluate_and_exercise repeats the same lookup, skips consumption, and the counted bridge stores unlimited access despite a zero allowance.

    Reject duplicate permission IDs during validation, or carry an unambiguous rule identity through evaluation and use it for both count enforcement and lasting certification.

  4. [medium] Removing conditional allows breaks the feature-enabled oracle corpus — crates/sparq-solid/src/odrl_bridge.rs:971
    sparq-wac-oracle::build_window_store still calls this entry point with Alice to create Carol’s conditional time-window grant. Delegating to the one-shot evaluator now returns no grant, so the builder returns Err. Three window-corpus tests consequently fail in the registered sparq-wac-oracle (odrl-bridge) feature-matrix leg.

    Update the corpus alongside the feature removal. Preserve coverage of the remaining AuthIndex window behavior using an appropriate trusted fixture, or explicitly retire the obsolete bridge-dependent corpus.

Requested checks

  • Reviewed the PR’s base-to-head diff and the combined policy/bridge stack against origin/main.
  • All ODRL stored allow paths traced—including one-shot, policy, conditional, counted, refresh/replay and N3—reach emit_allow(Permit). Server and GUI use these bridges; LWS evaluates per request, and the wasm policy probe stores no grants.
  • Purpose/spatial, elapsed-time, clock upper bounds and future-start prohibitions are rejected for storage as intended. Constrained duties also fail closed. However, lasting() remains unsound through findings 1–2. Policy revocation still requires an explicit ledger update and refresh.
  • Party mutation after .by(), different checked recipients, auth:Public/Authenticated and reserved principal encodings are blocked. Matching uses exact strings without alias normalization. Ordinary Rust bridge paths still use unchecked IRI construction, so blank-node-like strings are not explicitly rejected.
  • Counts anywhere in nested and/or/xone prevent lasting certification because every atom is checked. Unsupported rule-head refinements are refused. An unidentified rule produces a transient permit that the emitter rejects; duplicate IDs remain the bypass in finding 3.
  • The N3 floor reduction is justified by the removed behavior: three generated shape families × three admitted conflict settings remove nine grants, reducing the source-enumerated N3 grant cases from 21 to 12.
  • The named fallback, conjunction, wildcard, unknown-evidence, constrained-duty and purpose/spatial counterexamples are closed. Rejected counted grants no longer spend budget. Ordinary count stripping is fixed, but duplicate IDs defeat that fix.
  • Tests were not executed in the read-only checkout.

Verdict: Unsafe to merge as is because lasting certification and count-rule identification still permit authorization widening.

claude added 2 commits October 9, 2026 04:31
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
#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
@jeswr

jeswr commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator Author

🔎 Codex reviewer — gpt-6.1-sol

Automated review by the Codex reviewer (OpenAI gpt-6.1-sol via Codex CLI) of head 667a9c2808d8. Scope: correctness, security, soundness and design; style nits omitted. A new head gets a fresh review.

Findings

No correctness, security, soundness or design problems found.

Requested checks

Reviewed both ecb05657..HEAD and the combined stack against origin/main.

  1. Nonstatic grant paths: No bypass found. One-shot, conditional, policy, counted, refresh, ledger replay and N3 grants reach the emitter’s lasting check. Write-through rematerialization replays those same paths. Count-limited permits are refused before consumption.

  2. Recipient versus assignee: An explicit recipient context can differ from the assigned party even on a lasting permit. The emitter rejects that mismatch, so it cannot produce a stored grant.

  3. Three previous highs: All closed. Constraints on assignee context prevent lasting classification; declared party collections and any membership evidence prevent it; duplicate rule IDs across permissions and prohibitions fail validation before count-rule lookup.

  4. Oracle/differential fallout: The changed expectations follow the intended removal of constrained, clock-dependent, collection-dependent, unassigned/public and prohibition-carrying stored grants. The window corpus denies Carol at every instant while preserving Alice’s static ACP access. Count tests retain per-request exercises, deny tests retain static WAC controls, and the two-operand regression now expects the existing parser refusal. No additional regression was identified.

Tests were not rerun in the read-only workspace.

Verdict: Safe to merge as is based on this static review.

@jeswr
jeswr marked this pull request as ready for review October 9, 2026 04:43
@jeswr
jeswr merged commit 7072abd into fix/policy-degraded-prohibition Oct 9, 2026
4 of 12 checks passed
@jeswr
jeswr deleted the fix/policy-bridge-decide branch October 9, 2026 04:43
jeswr added a commit that referenced this pull request Oct 9, 2026
…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>
jeswr added a commit that referenced this pull request Oct 11, 2026
* chore(release): prepare v0.1.5 fix-forward

Move the workspace, desktop and public npm package versions to 0.1.5,
with every path-dependency requirement and lockfile record following.
Add the 0.1.5 changelog section (Windows digest fix #6717, ODRL
prohibition fixes #6734/#6737) and note the v0.1.4 fix-forward in
docs/release.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184oZyLYQNVPp7HYBAr6uYz

* docs(changelog): drop the doubled blank line in the 0.1.5 section

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184oZyLYQNVPp7HYBAr6uYz

* chore(release): leave the pinned zk chain-root lock untouched

The chain-root lock is the #6647 pin, so the version bump must not edit it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184oZyLYQNVPp7HYBAr6uYz

* docs(release): neutral ODRL changelog line; 0.1.5 install block

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184oZyLYQNVPp7HYBAr6uYz

* docs(changelog): note the sparq-solid update fix (#6763)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184oZyLYQNVPp7HYBAr6uYz

* chore(release): bump the sparq-core dev-dependency main added to 0.1.5

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184oZyLYQNVPp7HYBAr6uYz

* chore(release): v0.1.5 date, #6742/#6759/#6773 changelog lines, proof-methods lock

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184oZyLYQNVPp7HYBAr6uYz

* docs(changelog): cover the user-visible changes since v0.1.4

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184oZyLYQNVPp7HYBAr6uYz

* docs(changelog): ClosureLimits bounds rounds and facts, not memory

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184oZyLYQNVPp7HYBAr6uYz

* chore(release): re-record the ZK native lock pin for 0.1.5

The bump moves sparq-canon and sparq-core to 0.1.5 in the native lock. Re-record
candidate_lock_sha256 and since_chain_root with verify.py's lock_delta(); no
third-party package changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184oZyLYQNVPp7HYBAr6uYz

* docs(changelog): cover the LWS and ZK changes merged since the last review

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0184oZyLYQNVPp7HYBAr6uYz

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants