← Về thư mục
📄 / / academic-research-skills / docs / design / 2026-05-31-ars-v3.10-policy-layer-rescope-spec.md

ARS v3.10 — Triangulation Policy Layer (Rescoped) Spec

Status: Design draft (pre-dual-track-review) Date: 2026-05-31 Target release: v3.10 Originating issue: GitHub #127 ("v3.10: triangulation policy layer (strict modes + venue_type)") Supersedes (framing only): v3.9.0 spec §2.3 "v3.10 Forward Scope" table; v3.9.4 spec "Forward Scope" triangulation_policy + strict_temporal row.

Scope: Ship the contamination-triangulation policy layer deferred by v3.9.0 (#102) — opt-in strict modes that promote the advisory k=3 triangulation signal to a terminal HIGH-BLOCK. Rescoped from the original §2.3 acceptance table after a first-party spec-collision audit (2026-05-31) found that two shipped specs stake conflicting claims on the same triangulation_policy field name and the same R-L3-2-A firm-rule wording.


1. Why this is a rescope, not a straight implementation of v3.9.0 §2.3

The v3.9.0 spec §2.3 forward-scope table (and the #127 issue body that mirrors it) names:

A first-party audit on 2026-05-31 surfaced two collisions the planning checkpoint missed:

1.1 triangulation_policy field-name collision

The shipped v3.9.4 temporal-verification spec (docs/design/2026-05-18-ars-v3.9.4-temporal-verification-spec.md, issue #135, tagged v3.9.4) independently stakes the same triangulation_policy field:

"Hard-block policy | triangulation_policy field gains strict_temporal enum value. When set, Mode 1 / Mode 2 promoted to HIGH-BLOCK. R-L3-2-A amended to explicit user-opt-in policy clause."

And it rewords R-L3-2-A more broadly than v3.9.0 does:

"By default, contamination AND temporal signals never block emission on their own; user-enabled strict policies may promote specified signals to terminal blockers."

v3.9.4 shipped advisory-only — it deferred all hard-block / triangulation_policy / R-L3-2-A-reword work, asserting (spec line 795) that "#134 active conductor will subsume hard-block policy under structured task envelopes in v3.10."

But #134's actual issue body does NOT claim that scope. It is purely about active-conductor architecture (routing / dispatch / task-envelope / return-contract). It never mentions triangulation_policy, venue_type, HIGH-BLOCK, contamination, or #127. v3.9.4's "#134 will subsume" is a dangling assertion — #134 never authored the scope.

Net: triangulation_policy has two pending, unshipped definitions wanting the same field — v3.9.0's contamination enum and v3.9.4's +strict_temporal. Both hard-blocks are unbuilt. This is the only moment the field model can be fixed before either ships.

1.2 R-L3-2-A rule-ID collision

R-L3-2-A (and R-L3-2-B, R-L3-2-C) is already overloaded by two unrelated rule families in this checkout:

Family Where Meaning
Contamination (original, v3.7.3 §3.2 L3-2) formatter_agent.md, crossref_api_protocol.md, openalex_api_protocol.md, bibliography_agent.md, pipeline_orchestrator_agent.md, CHANGELOG, CLAUDE.md, 4× README R-L3-2-A = "Contamination signals never block emission on their own" (advisory). R-L3-2-B = no retroactive computation. R-L3-2-C/D/E = triangulation count / no API-inferred classification / refusal-list-unchanged.
Claim-manifest (borrowed, from #103 claim-alignment spec) synthesis_agent.md, report_compiler_agent.md, draft_writer_agent.md (the ## Claim Intent Manifest Emission (v3.8) section only) R-L3-2-A = one-shot pre-commitment. R-L3-2-B = no audit responsibility. R-L3-2-C = no frontmatter reading.

A blind grep -r "R-L3-2-A" replace of the wording — which the original plan assumed — would corrupt the claim-manifest pre-commitment rule. The ID must be disambiguated before any wording change.

Ownership analysis: contamination is the original holder — v3.7.3 spec §3.2 formally defines R-L3-2-A under the "L3-2" layer (L3 = claim faithfulness, layer 2 = contaminated-source); the ID is spec-anchored and spread across 13 files. The claim-manifest labels appear only in 3 agent prompts and are NOT defined in the #103 spec body (which uses INV-* / R-L3-1-*). The claim-manifest prompts borrowed an already-occupied ID. Therefore: rename the claim-manifest set, keep the contamination set.


2. Decisions (codex consult 2026-05-31, gpt-5.5 xhigh + user ruling)

Three load-bearing design decisions, each consulted with codex before reaching the user, each ruled by the user:

D1 — Namespaced terminal-policy object (NOT shared triangulation_policy)

Replace the single shared triangulation_policy field with a namespaced object:

terminal_policies:
  contamination_triangulation: advisory | strict | strict_articles_only   # default advisory
  temporal_integrity:          advisory                                    # default advisory — strict NOT wired in v3.10

D2 — #127 stays independent of #134; hard-block lives at the emission boundary

Hard-block is an emission-boundary / terminal-gate concern, not an active-conductor concern. If hard-block lived only inside #134's dispatch model, direct formatter_agent use, phase-by-phase mode, and resume_from_passport would all bypass it.

D3 — Canonical firm-rules file + mirrored prompt copies + sync lint

R-L3-2-A wording is duplicated verbatim across agent prompts and has provably drifted (v3.9.0 contamination-only wording vs v3.9.4 contamination-AND-temporal wording).


3. PR split

Two independently shippable PRs. PR-A is a pure-refactor / zero-behavior-change clean base; PR-B builds the policy layer on it. Each gets its own dual-track review (codex gpt-5.5 xhigh + Gemini 3.1-pro-preview), 0 P0/P1 to merge.

PR-A — ID disambiguation + canonical firm-rules + sync lint (zero behavior change)

Goal: eliminate the R-L3-2-A/B/C overload and stand up the canonical firm-rules infrastructure, with NO change to any runtime behavior or any contamination rule wording.

Changes:

  1. Rename the claim-manifest rule IDs R-L3-2-A/B/C → R-CIM-A/B/C (CIM = Claim Intent Manifest, matching the ## Claim Intent Manifest Emission (v3.8) section heading) in four files (dual-track review R1 P0-A correction — the original "3 prompts only, verified" claim was WRONG; a first-party re-grep found a fourth reference in the schema):
  2. deep-research/agents/synthesis_agent.md (lines ~271-273)
  3. deep-research/agents/report_compiler_agent.md (lines ~321-323)
  4. academic-paper/agents/draft_writer_agent.md (lines ~571-573)
  5. shared/contracts/passport/claim_intent_manifest.schema.json (line 41 description: "MUST list at least one substantive claim per R-L3-2-A …" → R-CIM-A) — missed in the original spec; renaming the prompts without this would leave the schema description pointing at the contamination R-L3-2-A.
  6. Wording of each rule is byte-identical except the ID token. First-party exhaustive grep of R-L3-2-[ABC] across *.md / *.py / *.json confirms every OTHER occurrence (contamination_signals.py, migrate_literature_corpus_to_v3_7_3.py, the test files, v3.7.3-contamination-signals-backfill.md, formatter/orchestrator/bibliography/crossref/openalex/README/CHANGELOG) is contamination context and stays unchanged. So the claim-manifest rename touches exactly these 4 files.
  7. Create shared/references/firm_rules.md carrying the canonical contamination R-L3-2-A / R-L3-2-B / R-L3-2-C / R-L3-2-D / R-L3-2-E blocks, copied verbatim from v3.7.3 spec §3.2 + v3.9.0 spec §3.3 (the current, pre-reword wording — PR-A does NOT reword; that is PR-B). Each block carries an anchor ID and a "mirrored in:" list of the agent prompts that copy it.
  8. Add scripts/check_firm_rules_sync.py — parses the canonical blocks from firm_rules.md, parses the mirrored copies from the listed agent prompts, asserts byte-equality (after normalizing the agent self-reference token where applicable). Fails on drift. Register in .github/workflows/spec-consistency.yml.
  9. Add a collision-guard assertion in the sync lint (R1 P0-A: scope includes schema files, not only prompt sections): assert that contamination R-L3-2-A/B/C do NOT appear inside any ## Claim Intent Manifest Emission section or inside claim_intent_manifest.schema.json, and that R-CIM-A/B/C do NOT appear inside any contamination section/file. Pins the disambiguation against regression on both prompt and schema surfaces.

Acceptance (PR-A): - grep -rn "R-L3-2-[A-Z]" inside the 3 manifest prompt sections and claim_intent_manifest.schema.json returns zero hits; grep -rn "R-CIM-[A-Z]" returns exactly the 3 renamed rules per prompt + the 1 schema reference. - firm_rules.md exists; check_firm_rules_sync.py passes on the verbatim-copied state; mutation test (drift one mirror by one word) → lint FAILS. - Full pytest manifest unchanged: 1771 passed / 3 skipped (no behavior change). - check_domain_evidence_profile.py + boundary scan + check_version_consistency.py clean.

PR-B — Policy layer (terminal_policies + venue_type + HIGH-BLOCK + R-L3-2-A reword + migration + adapters)

Built on PR-A's clean base.

Schema home (R1 P0-B — terminal_policies is passport-level; literature_corpus_entry.schema.json is entry-level and MUST NOT host it):

The repo has no root-passport schema today (confirmed: Material Passport is an aggregate; each entry type has its own *_entry.schema.json; the orchestrator carries aggregate arrays). So: 1. New shared/contracts/passport/terminal_policies.schema.json — a standalone object schema for the passport-level terminal_policies block. contamination_triangulation (closed enum advisory/strict/strict_articles_only); temporal_integrity (closed enum — advisory only, single accepted value, forward-reserved namespace). Per-key absence = advisory (R1 P1: JSON-Schema default is non-operational; the evaluator treats a missing key as advisory, NOT a schema default). Absent whole object = all-advisory (backward compat, byte-equivalent v3.9.0). CI wiring (R2-P1 — not an orphan schema): extend the already-CI-run scripts/check_literature_corpus_schema.py to also parse the passport root wrapper and validate any top-level terminal_policies block against terminal_policies.schema.json BEFORE iterating literature_corpus[] entries. This reuses the existing pytest.yml / spec-consistency.yml invocation — no new uninvoked validator. Do NOT add terminal_policies to literature_corpus_entry.schema.json (entry-level ≠ passport-level).

Entry-level schema (literature_corpus_entry.schema.json — venue fields only): 2. Add venue_type (closed enum: journal-article/conference-paper/book/chapter/dissertation/preprint/report/dataset/other/unknown) — adapter-declared only (R1 P0-D: unknown is now an explicit enum member so adapters told to emit unknown validate; "absence-means-unknown" rejected as harder to test). Absence still allowed (legacy entries). 3. Add venue_type_provenance (closed enum: adapter_declared/user_declared/trusted_source_declared/unknown). Reject openalex_inferred/crossref_inferred (R-L3-2-D). Pair dependency (R1 P0-D + R2-P0 data-loss fix + R2-P2 bidirectional): - venue_type present ⟹ venue_type_provenance present (no orphan type without provenance). - venue_type_provenance present ⟹ venue_type present (no orphan provenance without type, R2-P2). - venue_type == unknown ⟹ venue_type_provenance == unknown (a literal-unknown type can only carry unknown provenance). One-way, NOT biconditional (R2-P0): a KNOWN venue_type (e.g. journal-article) MAY carry venue_type_provenance: unknown — an adapter that reliably parses the type but cannot establish provenance keeps the type and declares unknown provenance, rather than throwing the type away. strict_articles_only already requires provenance ∈ {declared} to hard-block (§ Finalizer), so a known-type + unknown-provenance entry safely stays advisory without data loss. - absent-both = legacy unknown (allowed). 4. trusted_source_declared allowed-sources definition (R1 P1 + R2-P1 required-source + R2-P1 contradiction fix): add a venue_type_source string, REQUIRED iff venue_type_provenance == trusted_source_declared (closes the optional-bypass: a trusted_source_declared with no named source FAILS lint). It records WHICH trusted source declared the type. The trusted source MUST be a matched-record declared-metadata source (a publisher feed, a registry record the entry positively matched) — NOT an inference from any index's type field. Explicitly forbidden (R-L3-2-D, R2-P1 contradiction resolution): (a) wrapping a k=3-FAILED index's classification as trusted_source_declared — the three lookup indexes that produced the k=3 unmatched signal CANNOT be the trusted source for the same entry's venue_type; (b) deriving venue_type from OpenAlex primary_location.source.type / Crossref type fields at all (the §5-invariant ban stands — these are inference, not declared metadata, even on a matched record). Lint asserts venue_type_source ∉ {the three lookup index names} and is non-empty when provenance is trusted_source_declared. 5. Mutation test on every new enum (trivial accept-all replacement → violation FAILs), per feedback_schema_mutation_test_for_constraints.

HIGH-BLOCK marker grammar (R1 P0-E + R2-P0 co-emission slot — ONE canonical syntax WITH a legal advisory-suffix position): 6. The terminal-policy block is emitted ONLY inside a ref marker comment, never as bare prose. Under a non-advisory passport the finalizer stamps policy_hash on every marker it emits (terminal or not, R2-P0 freshness); under an all-advisory passport it emits NO stamp (PR-B dual-track amendment — byte-equivalent v3.9.0, Invariant 7; the stampless marker IS the advisory state). Two ordered grammar shapes (R3-P1: the non-terminal shape was previously undefined): - Terminal shape (entry hit a HIGH-BLOCK under strict policy): <!--ref:<slug> <base-status> [<advisory-suffix>] TERMINAL-BLOCK severity=HIGH-BLOCK policy=<contamination_triangulation|temporal_integrity> reason=<reason-token> mode=<strict|strict_articles_only> policy_hash=<hash>--> - Non-terminal (advisory-or-clean) shape (every other finalized marker — policy_hash is appended at the END, after any advisory suffix, with NO TERMINAL-BLOCK token): <!--ref:<slug> <base-status> [<advisory-suffix>] policy_hash=<hash>--> - <base-status> ∈ {ok, LOW-WARN} (the v3.7.3 5-cell base resolution). - [<advisory-suffix>] is the OPTIONAL v3.9.0 contamination suffix (CONTAMINATED-TRIANGULATION-UNMATCHED, CONTAMINATED-PREPRINT+TRIANGULATION-UNMATCHED, etc.) — present iff the entry fired an advisory signal. One advisory-suffix token max, drawn from the v3.9.0 allowlist. - TERMINAL-BLOCK severity=HIGH-BLOCK ... is the terminal token sequence; the literal TERMINAL-BLOCK sentinel distinguishes the two shapes unambiguously (codex R3: base-status / allowlisted suffix / TERMINAL-BLOCK are all mutually distinguishable by a parser). - policy_hash=<hash> binds the marker to the terminal_policies state it was evaluated under (see §10). Implementation amendment (PR-B dual-track review): under an all-advisory passport NO stamp is emitted — the marker is the bare v3.9.0 shape, and the absence of a stamp is the advisory signal. The original draft proposed a fixed advisory sentinel stamp on every marker, but that would make the default path NOT byte-equivalent to v3.9.0 (the marker bytes would differ), violating Invariant 7. The stamp is therefore added ONLY when the passport carries a non-advisory policy. The formatter's missing-stamp-under-advisory → PASS rule (§ Formatter Gate 1) is what makes a stampless v3.9.0 draft and a stampless v3.10 default-advisory draft interchangeable. - Legacy (v3.9.0) markers carry NO policy_hash — see §10 legacy transition rule; they are NOT malformed, they are version-distinguished by the absence of the stamp. - Token regexes + parser fixtures (terminal co-emitted form, non-terminal advisory form, non-terminal clean form, legacy-no-stamp form, bare-prose-HIGH-BLOCK-does-not-refuse form) are required in check_v3_10_policy.py. Formatter parses severity=HIGH-BLOCK ONLY within a <!--ref:...--> comment. A HIGH-BLOCK token in plain prose is NOT a refusal trigger (anti-false-refuse). reason carries the typed payload that preserves remediation context (R1 P1) — e.g. reason=k3_all_indexes_unmatched.

Finalizer (academic-pipeline/agents/pipeline_orchestrator_agent.md Cite-Time Provenance Finalizer): 7. The finalizer is the sole policy evaluator and, under a non-advisory passport, stamps every ref marker with policy_hash=<hash of the non-advisory terminal_policies keys> (R2-P0 freshness). Under an all-advisory passport it emits NO stamp (PR-B dual-track amendment — byte-equivalent v3.9.0, Invariant 7; see item 6). When terminal_policies.contamination_triangulation == strict AND k=3 → emit the §6 ... [advisory-suffix] TERMINAL-BLOCK severity=HIGH-BLOCK policy=contamination_triangulation reason=k3_all_indexes_unmatched mode=strict policy_hash=<h> marker. Co-emitted with (not replacing) the advisory suffix (R1 P1 strict×advisory interaction): the existing CONTAMINATED-TRIANGULATION-UNMATCHED / CONTAMINATED-PREPRINT+TRIANGULATION-UNMATCHED suffix stays in the marker's advisory slot so the "why" survives; the terminal block is an ADDITIONAL token. Audit-trail counting gains a terminal_blocked[] bucket. Non-additive (R2-P2): a single strict k=3 ref increments BOTH its advisory-signal count AND the terminal_blocked[] bucket, but remains ONE unique affected ref — downstream aggregate "total affected refs" MUST dedupe by ref slug across buckets, never sum them. Manual-entry exemption preserved (manual entries carry no *_unmatched, so k=3 is structurally unreachable). 8. When == strict_articles_only → terminal block only when (DOI present ∧ venue_type ∈ {journal-article, conference-paper} ∧ venue_type_provenance ∈ {adapter_declared, user_declared, trusted_source_declared}). This is a deliberate PRECISION mode (R1 P0-F, user-ruled 2026-05-31): a fabricated DOI-less or unknown-venue journal article STAYS ADVISORY by design — in the target humanities/non-English/regional-journal corpus, "journal + no-DOI + k=3" is overwhelmingly a legitimate coverage gap, not fabrication. Users wanting comprehensive hard-block use strict (no venue/DOI scoping). The recall limit is documented in user-facing docs + a by-design false-negative fixture (DOI-absent + unknown-venue + k=3 → stays advisory). 9. HIGH-BLOCK is not /ars-mark-read ack-able (terminal). Advisory tiers remain ack-able.

Formatter (academic-paper/agents/formatter_agent.md) — R1 P0-C + R2-P0 stamp-only (no re-evaluation in formatter): 10. Add refusal rule 11 (per D2, generic severity): refuse on any unresolved severity=HIGH-BLOCK token inside a <!--ref:...--> marker. Freshness guard — STAMP-ONLY (R2-P0: the formatter is a dumb gate; it MUST NOT re-evaluate strict_articles_only DOI/venue/provenance logic, which would duplicate the finalizer and invite drift): - The finalizer is the SOLE policy evaluator. Under a non-advisory passport it stamps every ref marker it finalizes with policy_hash=<slug of the non-advisory terminal_policies keys> (per the §6 grammar), whether or not the entry hit a terminal block. Under an all-advisory passport it emits no stamp (PR-B dual-track amendment; the stampless marker IS the advisory state, byte-equivalent v3.9.0). The formatter passes a stampless marker under an all-advisory passport and refuses it (stale) only under a non-advisory passport. - The formatter computes the hash of the passport's CURRENT terminal_policies and compares to each marker's policy_hash. - Two independent gates, evaluated in order, NEVER short-circuited (R4-P1 — a pass of gate 1 is NOT a pass of the formatter): - Gate 1 — freshness guard. Decides only whether the marker's policy evaluation is fresh. Stamp present + MISMATCH ⇒ REFUSE [STALE-POLICY-EVALUATION: re-run finalizer under current terminal_policies]. Stamp missing (legacy v3.9.0 marker): REFUSE [STALE-POLICY-EVALUATION] when the passport's current terminal_policies requests a non-advisory mode (contamination_triangulation ∈ {strict, strict_articles_only}) — the user opted into hard-block, the legacy draft must be re-finalized; PASS-GATE-1 when the passport has NO terminal_policies OR all keys are advisory (legacy/default state, Invariant 7 byte-equivalence). Stamp present + MATCH ⇒ PASS-GATE-1. - Gate 2 — HIGH-BLOCK refusal (rule 11), applied to EVERY marker that passes gate 1, including legacy missing-stamp-under-advisory markers (R4-P1 bypass fix): refuse iff a severity=HIGH-BLOCK token is present inside the <!--ref:...-->. A stale/hand-edited marker that had its policy_hash stripped but still carries a literal TERMINAL-BLOCK severity=HIGH-BLOCK token is STILL refused here — passing gate 1 (legacy-under-advisory) does NOT exempt it from gate 2. Only a marker that passes gate 1 AND carries no severity=HIGH-BLOCK token emits. - The generic refusal echoes the co-emitted advisory suffix + reason token to the user so remediation context is preserved (R1 P1). The v3.9.0 contamination pass-through allowlist (advisory suffixes) is unchanged — an advisory-only marker with no HIGH-BLOCK token (matching stamp, or legacy-under-advisory) passes both gates.

Firm rule (per D3) — R1 P1 over-promise fix: 11. Reword R-L3-2-A to the broad form in firm_rules.md canonical + all mirrors atomically (sync lint enforces): "By default, contamination and temporal-integrity signals never block emission on their own; in a namespace that accepts a strict value, a user-enabled strict terminal policy may promote that namespace's specified signals to non-acknowledgeable terminal blockers. v3.10 accepts a strict value for contamination_triangulation only; temporal_integrity accepts advisory only (no temporal strict path exists yet)." This defines the default + extension mechanism without advertising a temporal strict capability the schema rejects. Update R-L3-2-E / refusal-list discussion to note the generic HIGH-BLOCK rule + freshness guard.

Migration (scripts/migrate_literature_corpus_to_v3_10.py): 12. v3.9.0 → v3.10: seed passport-level terminal_policies. Deep-merge, not overwrite (R1 P1): write each default-advisory key ONLY if absent. A passport already carrying a partial terminal_policies (e.g. user pre-set contamination_triangulation: strict) keeps its existing keys untouched; only missing keys get the advisory default. Re-run idempotent (no key re-written once present). Forbidden: backfill venue_type from free-form venue (fake-precision, per issue body + R-L3-2-D — adapter-declared only). Dry-run mode. Manual-skip scope clarified (R1 P2): the entry-level manual-skip applies to lookup/venue backfill ONLY; passport-level terminal_policies seeding is global and does NOT vary by per-entry manual status. Mixed-vintage daisy-chain (v3.7.3→v3.9.0→v3.10): tool targets v3.9.0-onward passports; pre-v3.9.0 must run the v3.9.0 migration first (explicit scope boundary, restated so mixed-vintage corpora are not silently skipped).

Adapters (scripts/adapters/{folder_scan,zotero,obsidian}.py): 13. Start declaring venue_type + venue_type_provenance where the source carries reliable type info (Zotero item type → venue_type with adapter_declared; folder_scan / obsidian → venue_type: unknown + venue_type_provenance: unknown unless a frontmatter field declares it — honoring the §3-PR-B-4 pair invariant). NEVER infer from free-form text.

Lint (scripts/check_v3_10_policy.py, alongside check_v3_9_0_triangulation.py — NOT a rename, per checkpoint open decision resolved to "alongside"): 14. venue_type + venue_type_provenance schema fields exist (incl. unknown member); no _inferred provenance accepted; pair dependency enforced (type⟹provenance, provenance⟹type, venue_type==unknown ⟹ provenance==unknown one-way, known-type+unknown-provenance ALLOWED); trusted_source_declared ⟹ venue_type_source required AND ∉ {three lookup index names} (laundering guard); terminal_policies schema home is the standalone file NOT the entry schema; marker grammar: severity=HIGH-BLOCK parsed ONLY inside <!--ref:...-->, co-emitted advisory-suffix slot well-formed, policy_hash present; generic-rule shape (not per-subtype); formatter stamp-only freshness guard present (no policy re-evaluation in formatter); terminal_policies enum closed; temporal_integrity accepts only advisory. 14b. CI registration (R2-P1): register check_v3_10_policy.py + its test file in .github/workflows/spec-consistency.yml AND scripts/_ci_pytest_manifest.toml (mirror the v3.9.0 lint's wiring), so the new lint actually runs in CI rather than existing as a dormant script.

Docs: 15. CHANGELOG ## [Unreleased] v3.10 entry; .claude/CLAUDE.md ## v3.10 Key Additions; README version badge + 4 README locale variants; v3.9.0 spec §2.3 + v3.9.4 forward-scope "superseded" notes; this spec committed.

Acceptance (PR-B): see §4.


4. Acceptance Criteria (PR-B)

  1. Schema home: terminal_policies.schema.json exists as a standalone passport-level schema; literature_corpus_entry.schema.json does NOT contain terminal_policies (R1 P0-B). Passport with terminal_policies.temporal_integrity: strict FAILS (only advisory accepted); passport with no terminal_policies passes (backward compat = all advisory); passport with partial terminal_policies (only contamination_triangulation set) passes, missing key treated as advisory by the evaluator.
  2. Venue schema validates (R2-P0 one-way pair): venue_type: journal-article + venue_type_provenance: adapter_declared passes; venue_type_provenance: openalex_inferred FAILS; venue_type: unknown + venue_type_provenance: adapter_declared FAILS (unknown-type ⟹ unknown-provenance); venue_type: journal-article + venue_type_provenance: unknown PASSES (R2-P0 data-loss fix — known type may carry unknown provenance); venue_type present + venue_type_provenance absent FAILS (type⟹provenance); venue_type_provenance present + venue_type absent FAILS (provenance⟹type, R2-P2); absent-both passes (legacy).
  3. Strict behavior + co-emission (R1 P1 + R2-P0 grammar + R2-P2 non-additive): k=3 entry + contamination_triangulation: strict → ONE well-formed marker carrying the advisory suffix in its optional slot AND the TERMINAL-BLOCK severity=HIGH-BLOCK ... policy_hash=<h> token, parseable by the §6 grammar fixture; same entry + advisory (or absent policy) → advisory suffix only, NO policy_hash stamp (PR-B dual-track amendment — the stampless marker is the advisory state, byte-equivalent v3.9.0). Audit-trail emits terminal_blocked[]; an aggregate "total affected refs" dedupes the ref slug across advisory + terminal buckets (non-additive).
  4. strict_articles_only precision mode (R1 P0-F, by-design false-negative): k=3 + DOI + venue_type: journal-article + declared provenance → HIGH-BLOCK; k=3 + venue_type: dissertation → stays advisory; k=3 + venue_type: journal-article + DOI ABSENT → stays advisory (documented by-design FN); k=3 + venue_type: journal-article + venue_type_provenance: unknown → stays advisory (provenance not declared); k=3 + venue_type: unknown → stays advisory. Fixture covers all five.
  5. Marker grammar (R1 P0-E + R2-P0 co-emission): a HIGH-BLOCK token in plain prose (outside any <!--ref:...-->) does NOT trigger refusal; a well-formed co-emitted <!--ref:slug ok CONTAMINATED-TRIANGULATION-UNMATCHED TERMINAL-BLOCK severity=HIGH-BLOCK policy=contamination_triangulation reason=k3_all_indexes_unmatched mode=strict policy_hash=<h>--> parses cleanly AND refuses; a terminal-only (no advisory suffix) marker also parses.
  6. Formatter stamp-only freshness + legacy transition (R1 P0-C + R2-P0 + R3-P0): draft with a well-formed HIGH-BLOCK marker + matching policy_hash → REFUSES; draft with a non-terminal marker stamped with the matching non-advisory policy_hash → emits; draft finalized under one non-advisory policy (e.g. strict) but the passport is now a DIFFERENT non-advisory policy → policy_hash MISMATCH → REFUSES with [STALE-POLICY-EVALUATION] (formatter does NOT re-run policy logic). (PR-B dual-track amendment: an advisory draft carries NO stamp, so the advisory→strict transition is handled by the stamp-missing-under-non-advisory legacy rule below, not by a stamp mismatch.) Legacy (R3-P0): a v3.9.0 advisory marker (NO policy_hash, NO HIGH-BLOCK token) + passport absent/all-advisory → PASSES (byte-equivalent v3.9.0, Inv. 7); same legacy marker + passport set strict/strict_articles_only → REFUSES [STALE-POLICY-EVALUATION]. Two-gate bypass guard (R4-P1): a stripped-stamp marker that STILL carries a severity=HIGH-BLOCK token + advisory passport → passes gate 1 (legacy-under-advisory) but REFUSES at gate 2 (rule 11 applies to every gate-1-passing marker). /ars-mark-read does NOT clear HIGH-BLOCK. Refusal echoes the reason token.
  7. Migration deep-merge (R1 P1): --dry-run reports seed without writing; partial pre-existing terminal_policies (e.g. contamination_triangulation: strict) preserved on run; missing keys filled advisory; re-run byte-stable; no venue_type backfill attempted (forbidden).
  8. R-L3-2-A reword atomic + no over-promise (R1 P1): canonical firm_rules.md + all mirrors carry the broad wording WITH the "v3.10 accepts strict for contamination_triangulation only; temporal_integrity advisory only" clause; check_firm_rules_sync.py passes; mutation (revert one mirror to contamination-only) → FAILS.
  9. trusted_source laundering guard (R1 P1 + R2-P1 required-source): venue_type_provenance: trusted_source_declared with venue_type_source naming one of the three lookup indexes → lint FAILS; same provenance with venue_type_source ABSENT → lint FAILS (required-iff); same provenance with a legitimate publisher/registry source → passes.
  10. Lint: check_v3_10_policy.py covers all §3 PR-B rule 14 items, ≥1 fixture each.
  11. No regression: PR-A baseline (1771 passed / 3 skipped) + new PR-B tests all pass.
  12. Cross-model review: dual-track codex + Gemini, 0 P0/P1 both tracks.

5. Invariants

  1. Contamination R-L3-2-A/B/C/D/E IDs preserved (original holder); claim-manifest renamed to R-CIM-A/B/C.
  2. No shared triangulation_policy field — replaced by namespaced terminal_policies.
  3. temporal_integrity accepts only advisory in v3.10 — no unwired strict.
  4. Hard-block source of truth = formatter terminal gate, generic rule, not #134-internal-only.
  5. venue_type adapter-declared only — no inference from free-form venue or from OpenAlex/Crossref type fields (R-L3-2-D).
  6. Advisory pass-through (v3.9.0) unchanged — only strict-promoted markers refuse.
  7. Backward compat: absent terminal_policies = all advisory = byte-equivalent v3.9.0 behavior. A legacy v3.9.0 marker (no policy_hash) PASSES the formatter when the passport policy is absent/all-advisory (R3-P0 legacy transition); it only refuses on missing stamp when the user has opted into a non-advisory mode.
  8. Manual-entry exemption preserved across policy modes (k=3 structurally unreachable for manual entries).
  9. Firm-rule wording single-sourced in firm_rules.md; mirrors sync-linted.
  10. R-L3-2-A reword scoped contamination AND temporal (adopt v3.9.4's broader wording now), but temporal strict behavior NOT implemented in v3.10; the wording explicitly states no temporal strict path exists (no over-promise).
  11. terminal_policies schema home is standalone (terminal_policies.schema.json), NEVER inside literature_corpus_entry.schema.json (entry-level ≠ passport-level).
  12. HIGH-BLOCK parsed only inside <!--ref:...--> — bare-prose HIGH-BLOCK never refuses; emitted terminal marker has one canonical grammar.
  13. Formatter is STAMP-CHECK ONLY (R2-P0 + R3 freshness residue fix) — it compares policy_hash and NEVER re-runs policy logic (no DOI/venue/provenance evaluation in the formatter); the finalizer is the sole evaluator. Stamp mismatch ⇒ refuse; missing stamp ⇒ refuse only under a non-advisory policy (legacy transition, Inv. 7).
  14. Strict co-emits, not replaces — advisory suffix stays alongside the terminal token so remediation context survives.
  15. trusted_source_declared cannot name a k=3-failed index — no laundering the forbidden inference one indirection removed.

6. Out of scope (v3.10)


7. Cross-references


End of v3.10 policy-layer rescope draft.