# 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 single `triangulation_policy` passport field with enum `advisory / strict / strict_articles_only`
- an R-L3-2-A reword scoped to **contamination only**

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:

```yaml
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.
- v3.9.0 spec §2.3 and v3.9.4 forward scope get a "superseded — see this spec" note. The old shared-`triangulation_policy` framing never shipped, so this is a pre-implementation framing fix, not a breaking change.

### 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.

- **#127** owns policy semantics + the formatter terminal refusal.
- **#134** (later) may carry policy settings in task envelopes and surface `terminal_blockers[]` in return contracts, routing **through** the #127 evaluator — but is not the source of truth.
- The formatter gains a **generic** `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.

### 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).

- Create `shared/references/firm_rules.md` carrying the **canonical** contamination R-L3-2-A..E blocks (anchored, ID'd).
- Agent prompts keep a **mirrored** copy (self-contained for context isolation) but add a sync-lint that fails on drift between the canonical block and the mirrors.
- Disambiguate the ID collision (§1.2) **first** so the sync-lint and any wording change operate on the right rule.

---

## 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):
   - `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.
   - 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.
2. **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.
3. **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`.
4. **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)

- **Temporal strict wiring** (`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.
- **#134 active conductor** routing through the policy evaluator. Separate issue; #127 ships the evaluator + gate, #134 later routes through it.
- **Additional bibliographic indexes / API-key auth** — per v3.9.0 spec §6, unchanged.

---

## 7. Cross-references

- v3.9.0 spec `docs/design/2026-05-17-ars-v3.9.0-cross-index-triangulation-measurement-spec.md` §2.3 (superseded framing).
- v3.9.4 spec `docs/design/2026-05-18-ars-v3.9.4-temporal-verification-spec.md` Forward Scope (superseded framing for the shared-field row).
- v3.7.3 spec §3.2 (contamination R-L3-2-A origin).
- #103 claim-alignment spec `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-*).
- Memory discipline: `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.**
