Skip to content

fix(policy,reason): fail closed on ambiguous ODRL constraints and hostile RIF/XML input - #6657

Merged
jeswr merged 6 commits into
mainfrom
fix/issues-policy-rif
Oct 5, 2026
Merged

jeswr merged 6 commits into
mainfrom
fix/issues-policy-rif

Conversation

@jeswr

@jeswr jeswr commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Requested by Jesse · project thread

Summary

Input-robustness fixes for sparq-policy and sparq-reason. Each one now fails closed on malformed or hostile input.

sparq-policy (parse.rs)

sparq-reason

Skills updated: skills/usage-control-policy/SKILL.md and skills/inference/SKILL.md now list the new refusals.

Closes #3359
Closes #3360
Closes #3804
Closes #3832
Closes #3982

Base gate (always required)

  • cargo build --workspace succeeds. (Left to CI; only crate-scoped builds were run here.)
  • cargo clippy --workspace --exclude sparq-py --all-targets -- -D warnings is clean. (Left to CI. Crate-scoped cargo clippy -p sparq-reason -p sparq-policy --all-targets --all-features -- -D warnings is clean.)
  • The code this PR touches is formatted (matching the surrounding committed style).
  • cargo test passes for every crate this PR touches (-p sparq-policy, -p sparq-reason default + --all-features, and -p sparq-conformance --all-features --test rif_wg_core_suite).

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

  • Reasoner: only the RIF/XML importer front-end and one time: builtin changed; the rule engine is untouched. The crate's tests and the W3C RIF-WG core suite runner were re-run. The LUBM tier was not.

Ratchets and conventions

  • I did not lower any conformance / perf / coverage ratchet.
  • No hard-coded performance numbers added to markdown.
  • Follow-up / discovered work is captured as beads, not as TODO/FIXME markers.
  • If this change makes a doc statement false, I updated that doc in the same change.

Security

  • This change does not introduce a security regression. It closes a DoS (stack-overflow abort) in the RIF/XML importer and two order-dependent or fail-open shapes in the ODRL reference evaluator.

Performance check (local, before vs after main)

Non-canonical, shared-box measurements: 4 cores, other jobs running, release profile. Each binary was built from origin/main and from this branch in separate target dirs. The runs alternate main/PR to cancel drift. Noise band is the p10–p90 spread of the per-run times, relative to the median.

Workload main (median ms) PR (median ms) Δ median (Δ best) Noise band
sparq-policy parse_policy_str, 400 ODRL policies (isAnyOf list, isNoneOf multi-object, dateTime, prohibition); 20 runs × median of 7 189.8 189.6 −0.1% (−1.0%) ±15%
sparq-policy evaluate, the 400 policies × 34 requests × 5 rounds 52.7 50.9 −3.4% (−5.4%) ±11–15%
sparq-reason rif_xml::import, 2,000-rule RIF-Core document (1.6 MB, nested And/Frame bodies); 20 runs × median of 9 27.6 27.6 +0.1% (+1.2%) ±65% (tail outliers; p10 ±2%)
sparq-reason N3 time:inSeconds / time:dayOfWeek over 3,000 dateTimes (reason_n3_terms); 20 runs × median of 7 36.1 35.8 −0.8% (−3.0%) ±30%

Verdict: no regression beyond noise. The conflict flags in absorb and the nested-list check in fold_rights run once per constraint at parse time. The RIF depth and namespace checks add one length compare per element plus a root-only attribute scan.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ScyGGohDhirnbLbrUSnA5n


Generated by Claude Code

claude added 3 commits October 5, 2026 19:10
…tile RIF/XML input

sparq-policy:
- RawConstraint refuses a constraint node carrying several distinct
  odrl:leftOperand / odrl:operator objects instead of first-binding-wins
  (unstable under result ordering) (#3832).
- fold_rights refuses a nested-list member of an odrl:rightOperand
  collection, mirroring fold_list_operands; an unmatchable member silently
  dropped isNoneOf exclusions (#3982).

sparq-reason:
- rif_xml caps element nesting at MAX_XML_DEPTH (256) so deeply nested
  And/Or/Exists is refused instead of overflowing the stack (#3359).
- rif_xml requires the Document root to be in the RIF namespace
  (default or prefixed), failing closed otherwise (#3360).
- time:inSeconds / time:dayOfWeek decline impossible calendar dates
  (2026-02-29, month 13, day 0) instead of rolling over, matching
  sparq-policy parse_instant (#3804).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScyGGohDhirnbLbrUSnA5n
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScyGGohDhirnbLbrUSnA5n
@jeswr jeswr self-assigned this Oct 5, 2026
@jeswr
jeswr marked this pull request as ready for review October 5, 2026 19:34
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScyGGohDhirnbLbrUSnA5n
@jeswr

jeswr commented Oct 5, 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 640530fcb53d. Scope: correctness, security, soundness and design; style nits omitted. A new head gets a fresh review.

Findings

  1. [medium] Namespace validation can be bypassed below the root — crates/sparq-reason/src/rif_xml.rs:462
    Adding xmlns="urn:foreign" to <payload> in an otherwise accepted document passes the new root check. Descendants still lose their namespaces through local_name(), so foreign Group, Frame and other elements are interpreted as RIF rules and can produce conclusions. The RIF-Core schema requires these elements to be namespace-qualified.

    Resolve and validate each element’s namespace, including inherited declarations and prefix rebinding, before interpreting its local name.

  2. [medium] Invalid date fields become defaults before validation — crates/sparq-reason/src/n3/mod.rs:3285
    The new calendar check validates values produced by parse().ok().unwrap_or(1). Consequently, "2026-02-29Z"^^xsd:date becomes February 1 and still binds both time builtins. An overflowing day in "2026-02-9223372036854775808T00:00:00Z"^^xsd:dateTime follows the same path. Impossible dates therefore still generate inferred facts.

    Default only genuinely absent components; reject parsing failures in supplied fields. Separate date timezone suffixes before parsing and validating the calendar components.

Verdict: Address these validation bypasses before merging.

…fields

RIF/XML: parse with quick-xml's NsReader and require every element (not
only the root) to resolve to the RIF namespace before its local name is
interpreted. A descendant foreign default namespace, xmlns="", a rebound
prefix or an undeclared prefix is refused as MalformedXml. Prefixed
attributes and xmlns declarations are no longer read by local name.

N3 time:inSeconds / time:dayOfWeek: only absent components take the cwm
defaults. A supplied field that fails to parse or is out of range now
declines instead of defaulting to 1, the date timezone suffix is split
off before the calendar fields are parsed (and applied), and the year is
bounded so the epoch arithmetic cannot overflow.

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

jeswr commented Oct 5, 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 14036cd9482a. Scope: correctness, security, soundness and design; style nits omitted. A new head gets a fresh review.

Findings

  1. [low] Valid escaped RIF namespaces are rejected — crates/sparq-reason/src/rif_xml.rs:415
    Quick-xml returns namespace bytes without expanding character references, so the raw comparison rejects valid input such as <Document xmlns="http://www.w3.org/2007/rif&#35;"><payload><Group/></payload></Document>. This previously imported successfully and denotes the RIF namespace under XML namespace normalization rules.

    Normalize and unescape the resolved namespace value before comparing it, and add regression tests for escaped default and prefixed declarations.

Verdict: Fix the namespace normalization regression before merging as is.

quick-xml returns declared namespace values raw, so a valid escaped
spelling such as xmlns="http://www.w3.org/2007/rif&#35;" was refused.
Unescape the resolved namespace with quick-xml's unescape (character and
predefined entity references) before comparing it to the RIF namespace;
refuse values that are not UTF-8 or fail to unescape.

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

jeswr commented Oct 5, 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 06a4fd028380. 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.

Verdict: The PR appears safe to merge as is.

jeswr commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Local ci-fast gate (GitHub Actions outage; Jesse approved local-gate merges). PR head 06a4fd02 merged with main bb7c6044:

  • clippy -D warnings (core crates, all targets): pass
  • nextest (core crates, ci profile): pass
  • doctests (core crates): pass
  • W3C SPARQL conformance 1229/1229 (ratchet 1229): pass

Squash-merging under the local-gate rule.


Generated by Claude Code

@jeswr
jeswr merged commit 6c7c3a3 into main Oct 5, 2026
6 checks passed
@jeswr
jeswr deleted the fix/issues-policy-rif branch October 5, 2026 23:37
jeswr pushed a commit that referenced this pull request Oct 9, 2026
#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 added a commit that referenced this pull request Oct 9, 2026
* 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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment