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.
The v3.9.0 spec §2.3 forward-scope table (and the #127 issue body that mirrors it) names:
triangulation_policy passport field with enum advisory / strict / strict_articles_onlyA first-party audit on 2026-05-31 surfaced two collisions the planning checkpoint missed:
triangulation_policy field-name collisionThe 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_policyfield gainsstrict_temporalenum value. When set, Mode 1 / Mode 2 promoted toHIGH-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.
R-L3-2-A rule-ID collisionR-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.
Three load-bearing design decisions, each consulted with codex before reaching the user, each ruled by the user:
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
contamination_triangulation carries the full v3.9.0 enum and is wired to behavior in v3.10 (this spec).temporal_integrity is declared with only advisory accepted. A strict value for temporal is NOT an accepted enum value in v3.10 — per codex, a schema-accepted strict with no bound behavior is a false-safety bug. Wiring temporal strict is a v3.9.4 follow-up, not #127.triangulation_policy framing never shipped, so this is a pre-implementation framing fix, not a breaking change.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.
terminal_blockers[] in return contracts, routing through the #127 evaluator — but is not the source of truth.HIGH-BLOCK refusal rule ("refuse on any unresolved HIGH-BLOCK terminal-policy marker"), NOT a hardcoded list of every contamination/temporal subtype. This keeps the gate forward-compatible with temporal strict (v3.9.4 follow-up) without re-touching the formatter.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).
shared/references/firm_rules.md carrying the canonical contamination R-L3-2-A..E blocks (anchored, ID'd).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.
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:
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):deep-research/agents/synthesis_agent.md (lines ~271-273)deep-research/agents/report_compiler_agent.md (lines ~321-323)academic-paper/agents/draft_writer_agent.md (lines ~571-573)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.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.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.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.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.
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.
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.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).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).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.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.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.--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).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.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.check_v3_10_policy.py covers all §3 PR-B rule 14 items, ≥1 fixture each.triangulation_policy field — replaced by namespaced terminal_policies.advisory in v3.10 — no unwired strict.venue_type adapter-declared only — no inference from free-form venue or from OpenAlex/Crossref type fields (R-L3-2-D).strict-promoted markers refuse.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.firm_rules.md; mirrors sync-linted.terminal_policies schema home is standalone (terminal_policies.schema.json), NEVER inside literature_corpus_entry.schema.json (entry-level ≠ passport-level).<!--ref:...--> — bare-prose HIGH-BLOCK never refuses; emitted terminal marker has one canonical grammar.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).trusted_source_declared cannot name a k=3-failed index — no laundering the forbidden inference one indirection removed.temporal_integrity: strict → Mode 1/2 HIGH-BLOCK). v3.9.4 follow-up. v3.10 only reserves the namespace + adopts the broad R-L3-2-A wording.docs/design/2026-05-17-ars-v3.9.0-cross-index-triangulation-measurement-spec.md §2.3 (superseded framing).docs/design/2026-05-18-ars-v3.9.4-temporal-verification-spec.md Forward Scope (superseded framing for the shared-field row).docs/design/2026-05-15-issue-103-claim-alignment-audit-spec.md (claim-manifest rules use INV- / R-L3-1- — the R-L3-2 labels in the 3 prompts were a borrow, now R-CIM-*).feedback_codex_consult_before_user_options, feedback_plain_language_for_decisions, feedback_defining_term_change_grep_all_occurrences, feedback_verify_root_premise_before_patching_rounds, feedback_new_enforcement_must_inherit_existing_guardrails, feedback_schema_mutation_test_for_constraints, feedback_reverse_invariant_writer_boundary_pin.End of v3.10 policy-layer rescope draft.