# ARS v3.9.0 — Cross-Index Triangulation Measurement Layer Spec

**Status:** Design draft (pre-codex-review)
**Date:** 2026-05-17
**Target release:** v3.9.0
**Scope:** Extend v3.7.3 L3-2 contamination_signals from single-index (Semantic Scholar) to three-index triangulation (Semantic Scholar + OpenAlex + Crossref) as **advisory evidence only**. Terminal gate behavior is unchanged from v3.7.3. Policy-layer behaviors (strict mode, hard-block tier, venue-type-scoped strict) are explicitly out of v3.9.0 scope and deferred to v3.10.
**Precedent doc:** `2026-05-12-ars-v3.7.3-claim-faithfulness-and-contaminated-source-spec.md` (§3.2 L3-2)
**Originating issue:** GitHub #102 (renamed from "v3.7.4" to v3.9.0 because main shipped v3.8.2 on the parallel claim-faithfulness audit track first; v3.7.x branch is closed).
**External motivation:** Zhao, Wang, Stuart, De Vaan, Ginsparg, Yin (2026-05) "LLM hallucinations in the wild" (arXiv:2605.07723) §3 — cross-index triangulation across multiple bibliographic indexes is identified as a **viable false-positive-reduction strategy** for hallucinated-citation detection. Not ground truth.

---

## 1. Background

v3.7.3 §3.2 shipped two contamination signal vectors:

- **Vector 1: `preprint_post_llm_inflection`** — heuristic (year ≥ 2024 ∧ venue ∈ closed 10-server preprint list)
- **Vector 2: `semantic_scholar_unmatched`** — single-index lookup against the Semantic Scholar API (per `deep-research/references/semantic_scholar_api_protocol.md`)

The Vector 2 design is structurally limited:

**(a) Coverage asymmetry.** Semantic Scholar coverage is uneven across humanities, social sciences, non-English venues, regional journals, conference proceedings, dissertations, and grey literature. A legitimate reference can be `semantic_scholar_unmatched = true` simply because S2 has not indexed it. The v3.7.3 spec acknowledges this with the LOW-WARN annotation tier — strong enough to surface, weak enough to acknowledge.

**(b) False-positive inseparability.** Single-index unmatched cannot distinguish "fabricated reference S2 correctly fails to find" from "real reference S2 has not indexed". The two failure modes look identical in v3.7.3's data model.

**(c) External corroboration absent.** Zhao et al. arXiv:2605.07723 §3 explicitly argues that hallucinated-citation detection benefits from triangulation across multiple bibliographic indexes — the more indexes return "not found," the stronger the non-match / verification-risk signal. The Zhao framing names the prior on fabrication; v3.9.0 adopts the weaker, observable framing — "non-match across N indexes" — because the three public indexes (S2 / OpenAlex / Crossref) share DOI / title metadata pipelines and are not fully independent (codex R1 consult, 2026-05-17). Single-index detection is a weaker version of the same instrument; the marker name `CONTAMINATED-TRIANGULATION-UNMATCHED` reflects the observation rather than the inferred cause.

v3.7.3 §6 Out-of-Scope already named this as v3.7.4 future work:

> "v3.7.4 retrieval-side hardening: OpenAlex + Crossref triangulation as a second contamination signal. ARS today depends on Semantic Scholar only; adding a second independent index closes the single-source-of-truth gap on L3-2 Vector 2."

v3.9.0 closes that scope. The renaming from v3.7.4 to v3.9.0 reflects that main shipped v3.8.0 / v3.8.1 / v3.8.2 (claim-faithfulness audit agent track) before this work landed; v3.7.x is closed.

---

## 2. Goal & Non-Goals

### 2.1 Goal

Add two new ingestion-time contamination signal fields, two new API protocol references, and one extended finalizer annotation matrix. **All v3.9.0 additions are advisory-only.** The v3.7.3 R-L3-2-A firm rule ("Contamination signals never block emission on their own") is preserved verbatim.

Concretely:

1. **Schema additions.** `literature_corpus_entry.schema.json` gains two optional boolean fields inside the existing `contamination_signals` object: `openalex_unmatched` and `crossref_unmatched`. Both follow v3.7.3's omit-on-API-degradation discipline (absent ≠ false). Total `contamination_signals` field count rises from 2 to 4.

2. **bibliography_agent extension.** A new `## Contamination Signal Computation (v3.9.0 Triangulation Extension)` subsection adds OpenAlex and Crossref lookups alongside the existing Semantic Scholar check. Per-API graceful degradation. Manual entry exemption (`obtained_via='manual'`) preserved.

3. **Two new API protocol references.** `deep-research/references/openalex_api_protocol.md` and `deep-research/references/crossref_api_protocol.md` mirror the structure of `semantic_scholar_api_protocol.md`. Each documents query patterns, matching rules, rate limits, degradation handling, and a `*_unmatched` boolean derivation rule.

4. **Finalizer 4-tier advisory annotation.** The v3.7.3 single-vector CONTAMINATED-UNMATCHED suffix becomes a triangulation-count-aware suffix family. The number of indexes returning unmatched (call it `k`, computed over present fields only) maps to a tier of advisory severity. **All four tiers stay advisory** — the terminal gate decision is unchanged.

5. **Migration tool.** A `scripts/migrate_literature_corpus_to_v3_9_0.py` script walks an existing `literature_corpus[]`, calls OpenAlex and Crossref for each entry whose `contamination_signals.semantic_scholar_unmatched` is already set, and adds the two new boolean fields. Idempotent. Dry-run mode. Manual entries skipped. API degradation tolerant.

Target outcome: a corpus that today carries only `semantic_scholar_unmatched` flags surfaces a richer triangulation signal at cite-time. References that are S2-unmatched but found by OpenAlex / Crossref drop from CONTAMINATED-UNMATCHED to CONTAMINATED-COVERAGE-NOISE (k=1, lowest advisory tier). References missing from all three indexes carry the new CONTAMINATED-TRIANGULATION-UNMATCHED (k=3) advisory marker — surfaced clearly, ack-able via `/ars-mark-read`. **The user retains discretion across all tiers.**

### 2.2 Non-Goals (explicitly deferred to v3.10)

The following are out of v3.9.0 scope:

- **No `triangulation_policy` passport field.** Adding a policy field with only one valid value ("advisory") is meaningless. v3.10 introduces the field when multiple policies exist.
- **No `venue_type` schema field.** v3.7.3 already carries a free-form `venue` string. Inferring a structured `venue_type` enum from free-form text or from OpenAlex `primary_location.source.type` / Crossref `type` fields creates **fake precision** — particularly fragile in the k=3 case, where OpenAlex / Crossref are themselves the unmatched indexes whose classification is therefore unavailable or untrusted. v3.10 will add `venue_type` as an explicit adapter-declared field with provenance tracking.
- **No `venue_type_provenance` schema field.** Same reasoning — without an underlying `venue_type` field, provenance has nothing to track.
- **No hard-block tier.** The proposed `CONTAMINATED-TRIANGULATION-UNMATCHED` annotation in v3.9.0 stays advisory (`HIGH-ADVISORY` register, not `HIGH-BLOCK`). The terminal gate refusal list is not extended. The R-L3-2-A firm rule from v3.7.3 §3.2 is preserved without exception.
- **No strict mode.** No `strict`, no `strict_articles_only`, no opt-in policy switch. v3.10 introduces these once `venue_type` + `venue_type_provenance` are in place.
- **No `/ars-mark-read` behavior change.** The acknowledge command continues to apply across all advisory contamination tiers as in v3.7.3. No new escape hatch required because no new block is introduced.
- **No retroactive computation in production runs.** bibliography_agent computes signals at ingest time for new entries. Legacy entries are backfilled via the standalone migration tool, not auto-promoted in the cite-time pipeline.

### 2.3 v3.10 Forward Scope (acceptance criteria for the policy layer)

> **Superseded (framing only) by `docs/design/2026-05-31-ars-v3.10-policy-layer-rescope-spec.md`.** A first-party spec-collision audit (2026-05-31) found the single shared `triangulation_policy` field named below collides with the shipped v3.9.4 temporal spec's same-named field, and the `R-L3-2-A` rule ID was overloaded by an unrelated claim-manifest rule family. The v3.10 rescope replaces the shared `triangulation_policy` field with a namespaced `terminal_policies` object (`contamination_triangulation` + forward-reserved `temporal_integrity`) and disambiguated the rule IDs (PR-A). The acceptance items below still describe v3.10's *intent* accurately (venue_type enum, no-`_inferred` provenance, strict / strict_articles_only behavior, R-L3-2-A reword) — only the field-name framing changed. See the rescope spec §1 for the collision analysis and §3-5 for the shipped contract.

To allow v3.9.0 to ship a stable measurement baseline without leaving the policy story open-ended, the following becomes the explicit v3.10 scope:

| Item | v3.10 acceptance criterion |
|---|---|
| `venue_type` schema enum | Closed enum (journal-article / conference-paper / book / chapter / dissertation / preprint / report / dataset / other). Adapter-declared. |
| `venue_type_provenance` enum | adapter_declared / user_declared / trusted_source_declared / unknown. **OpenAlex/Crossref-inferred provenance is NOT a v3.10 acceptance value** — k=3 case makes those indexes' classification untrusted by definition. |
| `triangulation_policy` passport field | Enum: advisory (default, current v3.9.0 behavior) / strict / strict_articles_only. |
| Strict mode behavior | k=3 promoted to `HIGH-BLOCK` (terminal gate refuses, NOT `/ars-mark-read` ack-able). Manual-entry exemption preserved. |
| `strict_articles_only` behavior | k=3 promoted to `HIGH-BLOCK` only when (DOI present ∧ venue_type ∈ {journal-article, conference-paper} ∧ venue_type_provenance ∈ {adapter_declared, user_declared, trusted_source_declared}). Unknown / conflict / out-of-scope stay advisory. |
| R-L3-2-A amendment | Reword from "Contamination signals never block emission on their own" to "By default, contamination signals never block emission on their own; user-enabled strict policies may promote specified signals to terminal blockers." |

v3.9.0 spec includes these acceptance criteria above (§2.3 itself, plus §6 Out-of-Scope) so v3.10 work has a concrete target.

---

## 3. Design

### 3.1 Schema extension

`shared/contracts/passport/literature_corpus_entry.schema.json` `contamination_signals` object expands from 2 to 4 optional booleans:

```yaml
contamination_signals:
  preprint_post_llm_inflection: true | false   # v3.7.3 — year >= 2024 AND venue is preprint server
  semantic_scholar_unmatched:   true | false   # v3.7.3 — S2 returned no match
  openalex_unmatched:           true | false   # v3.9.0 — OpenAlex returned no match
  crossref_unmatched:           true | false   # v3.9.0 — Crossref returned no match
```

All four fields are optional within the object. The entire `contamination_signals` object is optional at the entry level.

**Field semantics (v3.7.3 inherited, restated for v3.9.0 cross-reference):**

- **Field absent at object level:** signals not yet computed. No advisory fires. Default state for legacy entries.
- **Field absent within object (e.g., `openalex_unmatched` not set but `semantic_scholar_unmatched` set):** that specific index's check was not run, OR the API was degraded at ingest time and the field was omitted by graceful-degradation. **Absence ≠ false.** The triangulation count k is computed over present fields only.
- **Field present and `false`:** the index was queried and returned a match. Positive evidence that the reference exists in that index.
- **Field present and `true`:** the index was queried and returned no match per the matching rule.

The exemption rule from v3.7.3 §3.2 carries over and extends symmetrically: when `obtained_via='manual'`, bibliography_agent SHALL NOT run any of the three lookup checks (Semantic Scholar / OpenAlex / Crossref) and SHALL NOT set any of the three `*_unmatched` fields (the user has vouched for the entry; no triangulation check fires). **However, `preprint_post_llm_inflection` IS still computed for manual entries** — it is a pure heuristic over `year` + `venue`, not a lookup, and v3.7.3 schema already treats it as exempt from the manual-entry exemption (per the v3.7.3 schema description: only `semantic_scholar_unmatched` carries the manual-exemption clause; `preprint_post_llm_inflection` does not). v3.9.0 preserves this asymmetry: manual entries may carry `contamination_signals: { preprint_post_llm_inflection: true|false }` but MUST NOT carry any `*_unmatched` field.

### 3.2 Triangulation count derivation

The cite-time finalizer computes `k` as:

```
k = count of *_unmatched fields where value is `true`, over fields that are present (not absent)
```

`k` operates only on the three lookup fields (`semantic_scholar_unmatched`, `openalex_unmatched`, `crossref_unmatched`). `preprint_post_llm_inflection` is a heuristic, not a lookup; it stays a separate annotation suffix per v3.7.3 §3.2.

`k_max` (the number of present lookup fields) ranges from 0 to 3:

- `k_max = 0`: no lookup fields present (all three APIs were degraded or skipped at ingest, OR the entry pre-dates v3.7.3). No triangulation annotation fires. The entry behaves as v3.6.8 / pre-contamination would.
- `k_max = 1`: one lookup present (typically v3.7.3 legacy entries with only `semantic_scholar_unmatched`, or new entries where 2 APIs were degraded). k ∈ {0, 1}.
- `k_max = 2`: two lookups present. k ∈ {0, 1, 2}.
- `k_max = 3`: all three lookups present (normal v3.9.0 ingest path). k ∈ {0, 1, 2, 3}.

The annotation tier is determined by k AND k_max — see §3.3.

### 3.3 Finalizer 4-tier advisory annotation matrix

When the v3.7.3 5-cell base matrix (anchor / source_acquired / source_verified / human_read) resolves to `ok` or LOW-WARN, AND the entry's `contamination_signals` has **at least one signal active** (i.e., `preprint_post_llm_inflection: true` OR any of `semantic_scholar_unmatched` / `openalex_unmatched` / `crossref_unmatched` set to `true`), the finalizer adds an annotation suffix. Entries with the object present but all fields `false` (computed-and-clean) produce no suffix — the v3.7.3 "signals computed, no contamination evidence" case is preserved. v3.9.0 extends the v3.7.3 suffix table:

**v3.7.3 suffix table (preserved):**

| Base | preprint_post_llm_inflection | semantic_scholar_unmatched | Suffix |
|---|---|---|---|
| ok / LOW-WARN | true | absent or false | `CONTAMINATED-PREPRINT` |
| ok / LOW-WARN | false or absent | true (single-index, v3.7.3 only — see migration note) | `CONTAMINATED-UNMATCHED` (legacy) |
| ok / LOW-WARN | true | true | `CONTAMINATED-PREPRINT+UNMATCHED` |

**v3.9.0 triangulation suffix table (extends v3.7.3):**

| Base | k (triangulation count) | k_max | Suffix (literal marker text) | Tier label (documentation category) |
|---|---|---|---|---|
| ok / LOW-WARN | 0 | 0 | no triangulation suffix | — |
| ok / LOW-WARN | 0 | 1–3 | no triangulation suffix | — |
| ok / LOW-WARN | 1 | 1, present field = `semantic_scholar_unmatched` | `CONTAMINATED-UNMATCHED` (v3.7.3 legacy preserved) | LOW-ADVISORY |
| ok / LOW-WARN | 1 | 1, present field ∈ {`openalex_unmatched`, `crossref_unmatched`} | `CONTAMINATED-COVERAGE-NOISE` (new v3.9.0 degraded-S2 case) | LOW-ADVISORY |
| ok / LOW-WARN | 1 | 2–3 | `CONTAMINATED-COVERAGE-NOISE` | LOW-ADVISORY |
| ok / LOW-WARN | 2 | 2–3 | `CONTAMINATED-PARTIAL-UNMATCH` | MED-ADVISORY |
| ok / LOW-WARN | 3 | 3 | `CONTAMINATED-TRIANGULATION-UNMATCHED` | HIGH-ADVISORY |

**Marker-vs-label semantics:** the **Suffix** column shows the **literal token appended to the marker** (e.g., `<!--ref:slug LOW-WARN CONTAMINATED-TRIANGULATION-UNMATCHED-->`). The **Tier label** column is a **documentation/spec category for grouping** — it does NOT appear in the marker text and is NOT emitted by the finalizer. Tier labels exist to discuss severity-equivalence between markers in spec text, lint, and migration log output; they are conceptual scaffolding, not runtime artifacts.

**Critical naming rule (codex consult, 2026-05-17):** the v3.9.0 tier labels (`LOW-ADVISORY` / `MED-ADVISORY` / `HIGH-ADVISORY`) deliberately drop the `WARN` token. v3.7.3's base resolution names (`LOW-WARN` / `MED-WARN` / `HIGH-WARN`) imply gate-blocking semantics in the 5-cell base matrix; reusing the `-WARN` root for v3.9.0's advisory triangulation tiers would conflate two distinct mechanisms. v3.10's strict mode will introduce `HIGH-BLOCK` as the gate-blocking counterpart, leaving `HIGH-ADVISORY` as the default tier and preserving a clean `WARN` (base) ↔ `ADVISORY` (contamination) ↔ `BLOCK` (strict policy) lexical separation.

**Preprint suffix interaction (v3.7.3 + v3.9.0 composition):** when `preprint_post_llm_inflection: true` AND triangulation tier fires, the suffix concatenates:

| Preprint flag | Triangulation tier | Combined suffix |
|---|---|---|
| true | k=1 (any k_max) | `CONTAMINATED-PREPRINT+COVERAGE-NOISE` (or `CONTAMINATED-PREPRINT+UNMATCHED` for k_max=1 legacy when present field = `semantic_scholar_unmatched`) |
| true | k=2 | `CONTAMINATED-PREPRINT+PARTIAL-UNMATCH` |
| true | k=3 | `CONTAMINATED-PREPRINT+TRIANGULATION-UNMATCHED` |

The order is fixed: PREPRINT first, triangulation second, joined by `+`. **The ordering rule is anchored to an explicit ordered token list** (not to JSON Schema declaration order, which is not semantically guaranteed):

```
CONTAMINATION_SUFFIX_TOKEN_ORDER = [
  "PREPRINT",
  "UNMATCHED" | "COVERAGE-NOISE" | "PARTIAL-UNMATCH" | "TRIANGULATION-UNMATCHED",
]
```

The three lookup fields (`semantic_scholar_unmatched`, `openalex_unmatched`, `crossref_unmatched`) are aggregated into a single triangulation token via the §3.3 (k, k_max) tier table, so no internal order among them is exposed. Any future fifth contamination field MUST be added to `CONTAMINATION_SUFFIX_TOKEN_ORDER` by explicit spec amendment (appended at end by default, but order is the spec contract — not a positional default). This composes cleanly with v3.7.3's existing `PREPRINT+UNMATCHED` shape.

**Firm rules (extending v3.7.3 §3.2):**

- **R-L3-2-A (advisory only) — PRESERVED VERBATIM:** Contamination signals never block emission on their own. The user retains discretion. This follows the v3.5 Collaboration Depth Observer + v3.6.8 LOW-WARN precedent. Hard-gating on contamination would amount to refusing to cite mid-2024+ preprints en masse, which is too coarse.
- **R-L3-2-B (no retroactive computation) — PRESERVED VERBATIM:** bibliography_agent computes contamination_signals at ingest time, not at audit time. Re-running the check post-hoc on existing entries is a separate batch operation (deferred to user invocation; not part of cite-time finalizer).
- **R-L3-2-C (triangulation count over present fields, v3.9.0 NEW):** k is computed over `*_unmatched` fields that are present. Absent fields are excluded from the count and do not default to either `true` or `false`. k_max reflects how many lookups were successfully run; the (k, k_max) pair together determines the annotation tier.
- **R-L3-2-D (no API-inferred classification, v3.9.0 NEW):** OpenAlex's `primary_location.source.type` and Crossref's `type` fields, even when returned by the APIs for matched entries, MUST NOT be used to derive any classification (venue_type, scope category, hard-block eligibility) within v3.9.0. The k=3 case makes those classifications structurally unavailable; including them in any v3.9.0 logic creates fake precision. v3.10 will introduce adapter-declared `venue_type` with explicit provenance tracking.
- **R-L3-2-E (gate refusal list unchanged, v3.9.0 NEW):** All v3.9.0 triangulation annotations are advisory. The terminal gate **refusal list** is NOT extended — no new marker shape causes refusal. However, the gate's **advisory pass-through allowlist** MUST be extended to include the new v3.9.0 contamination suffixes (`CONTAMINATED-COVERAGE-NOISE`, `CONTAMINATED-PARTIAL-UNMATCH`, `CONTAMINATED-TRIANGULATION-UNMATCHED`, and their three preprint composition shapes); without this extension, formatter_agent's existing v3.7.3 pass-through rule (line 295 — explicitly allowlists only `CONTAMINATED-PREPRINT`, `CONTAMINATED-UNMATCHED`, `CONTAMINATED-PREPRINT+UNMATCHED`) would route unknown v3.9.0 suffixes through formatter rule 4 ("status neither `ok` nor LOW-WARN-acknowledged") and accidentally gate-refuse them — the exact opposite of advisory-only intent. The fix is pass-through-list expansion, not refusal-list change. `/ars-mark-read` semantics are unchanged. Any future hard-block behavior is v3.10 scope and requires a separate spec.

### 3.4 OpenAlex API protocol

New file: `deep-research/references/openalex_api_protocol.md`. Mirrors the structure of `semantic_scholar_api_protocol.md`. Key elements:

- **API base:** `https://api.openalex.org`
- **Rate limit:** 10 requests/second (polite pool, with email param); 1 request/second (anonymous)
- **API key:** OpenAlex uses the "polite pool" pattern — pass `mailto=<user_email>` query param for higher rate limits. Env var: `OPENALEX_POLITE_EMAIL` (optional).

**Query patterns:**

1. **DOI lookup (primary when DOI is available):**
   `GET /works/doi:{doi}?select=id,title,authorships,publication_year,doi,primary_location` — exact match on DOI.

2. **Title search (fallback):**
   `GET /works?search={url_encoded_title}&per-page=5&select=id,title,authorships,publication_year,doi` — full-text search. Apply Levenshtein similarity ≥ 0.70 against canonical title (consistent with semantic_scholar_api_protocol.md threshold).

**Matching rule:** identical Levenshtein threshold (0.70) and prefer-matching-year tiebreaker as the S2 protocol. If multiple candidates ≥ 0.70, prefer matching year; if still tied, prefer the entry with a populated DOI.

**DOI cross-check rule (mirrors S2 protocol §3 `DOI_MISMATCH` pattern):** when DOI lookup returns a hit, the returned `title` field MUST pass the same Levenshtein 0.70 threshold against the entry's canonical title. If DOI hit succeeds but title cross-check fails (the known hallucination pattern where a fabricated DOI resolves to an unrelated paper), the DOI hit is treated as a `DOI_MISMATCH` and falls through to title search; `openalex_unmatched=false` is set only when **either** DOI hit passes title cross-check **or** title search finds a match.

**`openalex_unmatched` derivation:** `true` if and only if (a) when DOI is present: DOI lookup either misses or fails title cross-check, AND title search returns no match meeting the threshold; OR (b) when DOI is absent: title search alone returns no match meeting the threshold. The check fires only when `obtained_via != 'manual'`.

**Degradation handling:** mirrors S2 protocol exactly — HTTP 429 (rate limit): back off 2 seconds, retry up to 3 times. HTTP 5xx or timeout: skip OpenAlex for this entry. After exhausting retries, bibliography_agent omits `openalex_unmatched` from the entry. Absence ≠ false. The `[CORPUS API DEGRADATION]` note is logged in the ingest report. Other indexes continue independently.

**Discovery semantics — v3.9.0 explicit boundary:** OpenAlex returns `primary_location.source.type` for matched entries. v3.9.0 ignores this field per R-L3-2-D. It is not stored on the entry, not surfaced to the finalizer, and not used in any derivation.

### 3.5 Crossref API protocol

New file: `deep-research/references/crossref_api_protocol.md`. Mirrors the same structure. Key elements:

- **API base:** `https://api.crossref.org`
- **Rate limit:** 50 requests/second (polite pool); ~5 requests/second (anonymous, varies)
- **Etiquette:** pass `User-Agent: ARS-bibliography_agent/v3.9.0 (mailto:<email>)` per Crossref polite-pool convention. Env var: `CROSSREF_POLITE_EMAIL` (optional).

**Query patterns:**

1. **DOI lookup (primary when DOI is available):**
   `GET /works/{doi}` — exact DOI resolution. Crossref is the registry of record for DOIs.

2. **Title search (fallback):**
   `GET /works?query.title={url_encoded_title}&rows=5` — Crossref's structured title search. Apply Levenshtein similarity ≥ 0.70 against the canonical title.

**Matching rule:** identical threshold (0.70) and prefer-matching-year tiebreaker as S2 / OpenAlex.

**DOI cross-check rule (mirrors §3.4 + S2 protocol DOI_MISMATCH pattern):** DOI hits MUST pass Levenshtein 0.70 title cross-check before counting as a match. DOI hit with failed title check falls through to title search.

**`crossref_unmatched` derivation:** `true` if and only if (a) when DOI is present: DOI lookup either misses or fails title cross-check, AND title search returns no match meeting the threshold; OR (b) when DOI is absent: title search alone returns no match meeting the threshold. Same manual-exemption rule as §3.4.

**Degradation handling:** mirrors S2 protocol exactly — HTTP 429: 2s back off, retry up to 3 times. HTTP 5xx or timeout: skip Crossref for this entry. After retries exhausted, `crossref_unmatched` is omitted.

**Crossref-specific caveat:** Crossref's coverage is strongest for journal articles with DOIs. Coverage of monographs / chapters is partial (depends on publisher participation in DOI registration). This is a known limitation, not a bug. v3.9.0 surfaces `crossref_unmatched` as one of three signals; the user retains discretion. R-L3-2-D explicitly forbids using Crossref's coverage profile to derive a venue_type classification.

### 3.6 bibliography_agent extension

`deep-research/agents/bibliography_agent.md` gains a new subsection within the existing `## Contamination Signal Computation (v3.7.3)` heading, titled `### Triangulation Extension (v3.9.0)`:

- Adds two new ingest-time steps (OpenAlex lookup, Crossref lookup) per the protocols in §3.4 / §3.5.
- The three lookups (S2 / OpenAlex / Crossref) run **in parallel** when possible (one outbound HTTP request per index, results joined locally). If parallelism is not available in the runtime, run sequentially in S2 → OpenAlex → Crossref order. Order does not affect the final field values.
- Each lookup's `*_unmatched` field is set independently. Degradation of one index does not affect the others.
- The manual-entry exemption (R-L3-2 inherit, v3.9.0 extended per §3.1) skips all three lookup checks; the entry exits ingest with the three `*_unmatched` fields absent. `preprint_post_llm_inflection` is still computed (pure heuristic, no lookup) — so a manual entry may exit with `contamination_signals: { preprint_post_llm_inflection: true }` and no `*_unmatched` fields.
- bibliography_agent emits a per-entry ingest log line summarizing which indexes were queried, which matched, and which were degraded.

### 3.7 Migration tool

New file: `scripts/migrate_literature_corpus_to_v3_9_0.py`.

Purpose: walks an existing `literature_corpus[]` (typically loaded from a passport YAML), finds entries where `contamination_signals.semantic_scholar_unmatched` is set (i.e., v3.7.3-generated entries) but `openalex_unmatched` / `crossref_unmatched` are absent, and backfills the two new fields by calling the OpenAlex and Crossref APIs.

**Scope boundary:** the migration tool targets **v3.7.3-onward entries only** — entries with `contamination_signals.semantic_scholar_unmatched` already set. Pre-v3.7.3 entries (lacking the entire `contamination_signals` object) are out of v3.9.0 migration scope; users wanting to bring those entries forward must first run the v3.7.3 backfill tool (`scripts/migrate_literature_corpus_to_v3_7_3.py`, per `contamination_signals_backfilled_at` schema field) and then re-run this v3.9.0 tool. Daisy-chained migration keeps each version's contract well-bounded. This boundary is explicit so users with mixed-vintage corpora are not surprised by silently-skipped pre-v3.7.3 entries.

**Behavior contract:**

- **Stable-fields idempotency.** Already-populated fields are stable across re-runs — never re-queried, never overwritten. **Absent-due-to-degradation fields remain eligible for later fill** — running the tool again after API recovery WILL set previously-absent fields without disturbing already-populated ones. This is intentional: "idempotent" here means "no destructive overwrite," not "no observable change." Partial backfill + re-run on API recovery is the explicit happy path, not a violation.
- **Dry-run mode** (`--dry-run`) — prints what would change without modifying the file. In dry-run, the report distinguishes "would-add" (eligible for fill) from "would-skip" (already populated or manual).
- **Manual-entry skip.** Entries with `obtained_via='manual'` are skipped entirely for the lookup checks. The tool does NOT recompute `preprint_post_llm_inflection` for any entry (that field is owned by bibliography_agent at ingest time, not by the migration tool).
- **API degradation tolerant.** If either API is unavailable, the corresponding field stays absent (per R-L3-2-C — absent ≠ false). Tool exits 0 — partial backfill is not failure. A re-run after API recovery will fill in the remaining gaps.
- **No venue_type, no derived fields.** Per R-L3-2-D, the tool only touches the two new boolean fields (`openalex_unmatched`, `crossref_unmatched`). No extra schema-extension scope creeps in.
- **Output report.** Summarizes (per index) how many entries newly carry `*_unmatched: true`, how many carry `false`, how many remained absent due to degradation, and how many were skipped (manual or already-populated). Per-entry log line on degradation: `[CORPUS MIGRATION INCOMPLETE: <index>]` (aligned with §10 OQ-2 unified logging contract).

### 3.8 Lint additions

`scripts/check_v3_9_0_triangulation.py` — validates the v3.9.0 annotation marker shapes against the finalizer output AND verifies the formatter pass-through allowlist statically. Specifically:

1. Marker syntax: `CONTAMINATED-COVERAGE-NOISE` / `CONTAMINATED-PARTIAL-UNMATCH` / `CONTAMINATED-TRIANGULATION-UNMATCHED` strings appear in finalizer output only when (k, k_max) match the §3.3 table.
2. Preprint composition order: `CONTAMINATED-PREPRINT+<triangulation>` not `<triangulation>+PREPRINT`.
3. v3.7.3 legacy compatibility: an entry with only `semantic_scholar_unmatched: true` (k=1, k_max=1) still produces `CONTAMINATED-UNMATCHED` (legacy suffix), not `CONTAMINATED-COVERAGE-NOISE`. Backward-compat preserved.
4. No `HIGH-BLOCK` / `BLOCK` shapes appear in v3.9.0 output. Catches accidental introduction of policy-layer behavior.
5. **Formatter pass-through allowlist verification (R-L3-2-E lint):** parse `academic-paper/agents/formatter_agent.md` and locate the "Contamination annotations ... DO NOT trigger refusal" paragraph (anchored at line ~295). Extract the backtick-quoted suffix tokens from that paragraph as a set, then assert set equality against the canonical 9-suffix set: 3 v3.7.3 legacy (`CONTAMINATED-PREPRINT`, `CONTAMINATED-UNMATCHED`, `CONTAMINATED-PREPRINT+UNMATCHED`) + 6 v3.9.0 (`CONTAMINATED-COVERAGE-NOISE`, `CONTAMINATED-PARTIAL-UNMATCH`, `CONTAMINATED-TRIANGULATION-UNMATCHED`, `CONTAMINATED-PREPRINT+COVERAGE-NOISE`, `CONTAMINATED-PREPRINT+PARTIAL-UNMATCH`, `CONTAMINATED-PREPRINT+TRIANGULATION-UNMATCHED`). Fails on missing token, on extra token (catches accidental introduction of non-v3.9.0 markers), or on substring collision (e.g., `CONTAMINATED-PREPRINT` matched inside `CONTAMINATED-PREPRINT+...`). Substring matching is rejected by construction because tokens are parsed from backtick-delimited code spans.
6. **Refusal-list-unchanged guard:** parse the formatter refusal rules block (the numbered list at lines ~273-285 starting with "1. ... 2. ... etc."). Extract each rule's quoted/code-spanned token. Assert that no `CONTAMINATED-*` token appears in any refusal rule. Catches accidental promotion of advisory tier to gate-refusal.
7. CI integration: added to `.github/workflows/spec-consistency.yml` per v3.7.3 precedent.

### 3.9 Interaction with v3.7.3 / v3.8.x

- **v3.7.3 5-cell base matrix:** unchanged. The triangulation annotation suffix lives within the existing `ok` / LOW-WARN annotation channel.
- **v3.7.3 R-L3-2-A:** preserved verbatim. Quoted in §3.3 above.
- **v3.7.3 `CONTAMINATED-UNMATCHED` legacy suffix:** preserved as the marker for v3.7.3-era entries where only Semantic Scholar was queried. Migration tool can promote these to k_max=3 markers post-backfill.
- **v3.8.0 / v3.8.1 / v3.8.2 claim-faithfulness audit track:** orthogonal. v3.8.x adds claim ↔ reference faithfulness audits (LLM-as-judge over (claim, ref) pairs); v3.9.0 hardens reference existence at the ingest layer. No shared surfaces. v3.8.2's `uncited_audit_failures[]` is unrelated to triangulation counts.
- **v3.6.5 corpus-consumer Iron Rules:** unaffected. `contamination_signals` remains an AI-owned field per existing convention. Adapters MAY set the new fields but are not required to.
- **v3.6.7 PATTERN PROTECTION:** unaffected. v3.9.0 does not touch any of the 17 hardened patterns.

---

## 4. Implementation Steps

### Step T-1 — Schema + lint

- `shared/contracts/passport/literature_corpus_entry.schema.json` — three changes:
  1. Add two optional booleans (`openalex_unmatched`, `crossref_unmatched`) inside `contamination_signals.properties`. Update inline description with cross-reference to this spec doc.
  2. **Extend the existing manual-entry not-rule (v3.7.3 line 206-222)** — currently `not: { required: ["semantic_scholar_unmatched"] }`. Extend to forbid any of the three lookup fields: use `not: { anyOf: [{ required: ["semantic_scholar_unmatched"] }, { required: ["openalex_unmatched"] }, { required: ["crossref_unmatched"] }] }`. Manual entries that carry `preprint_post_llm_inflection` continue to pass (preprint is heuristic, not a lookup — see §3.1).
  3. Optional: extend the description text on `contamination_signals` to enumerate all four fields and note the manual-entry exemption applies to the three lookup fields only.
- `scripts/check_literature_corpus_schema.py` — accept the two new fields (additive; existing fixtures stay valid). Add fixtures covering: manual entry with `preprint_post_llm_inflection: true` (must pass), manual entry with any `*_unmatched` field set (must fail), non-manual entry with arbitrary combinations (must pass).
- `scripts/check_v3_9_0_triangulation.py` — new lint per §3.8.
- `.github/workflows/spec-consistency.yml` — register the new lint.

### Step T-2 — API protocols

- `deep-research/references/openalex_api_protocol.md` — new file per §3.4.
- `deep-research/references/crossref_api_protocol.md` — new file per §3.5.

### Step T-3 — Agent extension

- `deep-research/agents/bibliography_agent.md` — extend `## Contamination Signal Computation (v3.7.3)` with `### Triangulation Extension (v3.9.0)` subsection per §3.6. Existing v3.7.3 content stays byte-equivalent.

### Step T-4 — Finalizer annotation + formatter pass-through

**Phantom-file correction:** earlier drafts of this spec referenced `deep-research/agents/cite_provenance_finalizer_agent.md` and `cite_provenance_terminal_gate_agent.md`. Neither file exists in the repo — the v3.7.3 spec also mistakenly referenced them and that error was inherited. The actual finalizer + gate surfaces are:

- **Finalizer logic** lives in `academic-pipeline/agents/pipeline_orchestrator_agent.md` (the 5-cell base matrix + v3.7.3 contamination annotation derivation lives in this file's "Cite-Time Provenance Finalizer" section, around lines 640-780).
- **Terminal gate** lives in `academic-paper/agents/formatter_agent.md` (the refusal-list + contamination pass-through allowlist lives around lines 260-300).

v3.9.0 changes:

- `academic-pipeline/agents/pipeline_orchestrator_agent.md` — extend the Cite-Time Provenance Finalizer section to compute k (per R-L3-2-C) and emit the v3.9.0 (k, k_max) suffix shapes per §3.3 table. v3.7.3 legacy `CONTAMINATED-UNMATCHED` suffix preserved for k=1 k_max=1 case where the present field is `semantic_scholar_unmatched` (per §3.3 row). **Audit-trail / reporting columns within this finalizer section (if it currently enumerates only v3.7.3 contamination shapes) MUST be extended to include the 6 new v3.9.0 shapes, otherwise downstream reporting silently drops or misclassifies v3.9.0 markers.** This is a content-level edit, not just a logic-level edit.
- `academic-paper/agents/formatter_agent.md` — **extend the contamination pass-through allowlist** (the v3.7.3 line ~295 "Contamination annotations ... DO NOT trigger refusal" sentence). Allowlist additions: `CONTAMINATED-COVERAGE-NOISE`, `CONTAMINATED-PARTIAL-UNMATCH`, `CONTAMINATED-TRIANGULATION-UNMATCHED`, `CONTAMINATED-PREPRINT+COVERAGE-NOISE`, `CONTAMINATED-PREPRINT+PARTIAL-UNMATCH`, `CONTAMINATED-PREPRINT+TRIANGULATION-UNMATCHED`. The refusal list (rules 1-10 at lines 273-279) is NOT extended. This is what R-L3-2-E enforces: gate refusal list unchanged; gate pass-through allowlist must grow to accommodate the new advisory suffixes.

The v3.7.3 spec target `deep-research/agents/cite_provenance_finalizer_agent.md` is a documentation artifact that survived from a design draft never reified as a separate file. A future cleanup PR could rename the references in v3.7.3 spec for accuracy, but that is out of v3.9.0 scope.

### Step T-5 — Migration tool

- `scripts/migrate_literature_corpus_to_v3_9_0.py` — new file per §3.7.

### Step T-6 — Documentation

- `CHANGELOG.md` — add v3.9.0 entry under `## [Unreleased]`.
- `.claude/CLAUDE.md` — add `## v3.9.0 Key Additions` section.
- Spec doc (this file) — committed in same PR.

---

## 5. Acceptance Criteria

ARS v3.9.0 ships when:

1. **Schema validates (additive + extended manual not-rule):**
   - synthetic `literature_corpus_entry` with `contamination_signals: {openalex_unmatched: true, crossref_unmatched: false}` passes `check_literature_corpus_schema.py`.
   - Entry with only v3.7.3 fields also passes (backward compat).
   - **Manual entry with any of `{semantic_scholar_unmatched, openalex_unmatched, crossref_unmatched}` set (true or false) FAILS schema validation** — the extended not-rule `not: { anyOf: [...] }` triggers. Three negative fixtures cover the three fields.
   - Manual entry with `contamination_signals: {preprint_post_llm_inflection: true}` (and no lookup fields) PASSES — preprint flag is not subject to manual exemption.

1b. **DOI cross-check fixture (mirrors S2 DOI_MISMATCH pattern, §3.4 / §3.5):** mock OpenAlex (and separately Crossref) to return a DOI hit whose `title` field fails Levenshtein 0.70 against the entry's canonical title. Verify: `openalex_unmatched` (or `crossref_unmatched`) is set to `true` (the failed cross-check falls through to title search, which also misses). Conversely: DOI hit + matching title → `false`. Conversely: DOI absent + title search hits → `false`.

2. **Finalizer annotation correctness:**
   - Entry with (S2: true, OpenAlex: true, Crossref: true) AND base LOW-WARN produces `<!--ref:slug LOW-WARN CONTAMINATED-TRIANGULATION-UNMATCHED-->` marker.
   - Entry with (S2: true, OpenAlex: false, Crossref: false) AND base LOW-WARN produces `<!--ref:slug LOW-WARN CONTAMINATED-COVERAGE-NOISE-->`.
   - Entry with (S2: true, OpenAlex: absent, Crossref: absent) AND base LOW-WARN produces `<!--ref:slug LOW-WARN CONTAMINATED-UNMATCHED-->` (v3.7.3 legacy preserved).
   - Entry with (S2: true, OpenAlex: true, Crossref: absent) AND base LOW-WARN produces `<!--ref:slug LOW-WARN CONTAMINATED-PARTIAL-UNMATCH-->` (k=2, k_max=2).
   - Entry with preprint_post_llm_inflection=true AND k=3 produces `<!--ref:slug LOW-WARN CONTAMINATED-PREPRINT+TRIANGULATION-UNMATCHED-->`.
   - Audit-trail / reporting columns at the finalizer section emit per-suffix counts for the 6 new v3.9.0 shapes (`CONTAMINATED-COVERAGE-NOISE`, `CONTAMINATED-PARTIAL-UNMATCH`, `CONTAMINATED-TRIANGULATION-UNMATCHED`, plus three preprint composition variants), not just the 3 v3.7.3 legacy shapes. At least one fixture covers a synthetic corpus exercising all 6 shapes.

3. **Terminal gate unchanged:** synthetic corpus with one entry carrying `CONTAMINATED-TRIANGULATION-UNMATCHED` advisory marker AND base resolution `ok` — terminal gate emits the output (advisory does not block). Same input under v3.7.3 produces equivalent behavior; v3.9.0 changes annotation richness, not gate decision.

4. **API protocol completeness:** `openalex_api_protocol.md` and `crossref_api_protocol.md` each document query patterns, matching rule, rate limit, degradation handling, and `*_unmatched` derivation — structurally equivalent to `semantic_scholar_api_protocol.md`.

5. **bibliography_agent contract:** the new `### Triangulation Extension (v3.9.0)` subsection documents the parallel-lookup pattern, per-API degradation, manual exemption.

6. **Migration tool (stable-fields idempotency per §3.7):** running `migrate_literature_corpus_to_v3_9_0.py --dry-run` against a fixture corpus reports the expected backfill diff without writing. Running without `--dry-run` produces no destructive overwrite — populated fields stay byte-stable across re-runs. **Absent-due-to-degradation fields remain fillable on later re-runs.** The fixture-based AC test MUST cover two scenarios: (a) full-API-success run → re-run produces identical file (stable case); (b) partial-degradation run → mock API recovery → re-run fills previously absent fields without touching already-populated ones (eligibility case).

7. **Lint:** `check_v3_9_0_triangulation.py` 15+ tests covering: marker syntax (§3.8 rule 1), preprint composition order (rule 2), legacy compat (rule 3), no-HIGH-BLOCK (rule 4), **formatter pass-through allowlist contains all 9 suffixes** (rule 5, R-L3-2-E lint), refusal-list-unchanged (rule 6). At least one fixture per rule.

8. **No regression:** v3.7.3's 967-test baseline (per `.claude/CLAUDE.md` v3.7.3 status) all PASS. v3.6.7 PATTERN PROTECTION 12/12 + 58/58 mutation tests unchanged. v3.7.1 / v3.7.2 lints unchanged.

9. **Cross-model review:** dual-track codex (gpt-5.5 xhigh) + Gemini 3.1-pro-preview, target convergence within 4 rounds → 0 P1 + 0 P2 on both tracks (per `feedback_cross_model_review_r1_r4_trajectory.md` schema-level pattern: R1 4-7 → R4 0 ship). v3.9.0 spec scope (two bool fields, no gate change, no new agent) is smaller than v3.7.3 (11 rounds) and v3.8.0 (8 rounds), so the 4-round target reflects scope-adjusted expectation rather than v3.7.3's empirical count. Round budget may stretch if codex 0.130 docs-review unstable behavior (per `feedback_codex_0_130_docs_review_broken.md`) fires; in that case fall back to Gemini-only convergence with codex as insurance.

---

## 6. Out-of-Scope (Future Work)

- **v3.10 policy layer** (see §2.3 forward scope) — venue_type + venue_type_provenance + triangulation_policy + strict / strict_articles_only modes + HIGH-BLOCK tier + R-L3-2-A amendment.
- **L3 full audit (v3.8 track continuation).** v3.8.0–v3.8.2 shipped claim ↔ reference faithfulness audits. v3.9.0 is orthogonal infrastructure. Future v3.8.x improvements stay on that track.
- **Additional bibliographic indexes.** PubMed, DBLP, ACM Digital Library could plausibly be added as 4th / 5th triangulation signals in a future release. Cost-benefit not assessed yet; v3.9.0 stops at three indexes (S2 / OpenAlex / Crossref) because they are the canonical free public APIs and adding more without evidence-based selection inflates noise without adding signal.
- **Adapter-side triangulation.** v3.9.0 has bibliography_agent perform all three lookups at ingest. An alternative is for adapters (zotero, obsidian, folder-scan) to perform lookups themselves and pre-populate `contamination_signals`. Defer — current AI-owned-field convention is simpler and per-entry latency is not currently a bottleneck.
- **API key authentication.** OpenAlex / Crossref polite-pool patterns are sufficient for v3.9.0 ingest volumes. If volume grows past polite-pool rate limits, structured API key handling can be added later.

---

## 7. Cross-References

### External motivation

- Zhao, Wang, Stuart, De Vaan, Ginsparg, Yin. "LLM hallucinations in the wild: Large-scale evidence from non-existent citations." arXiv:2605.07723v1 (2026-05). §3 Detection Methodology — cross-index triangulation as viable false-positive-reduction.
- v3.7.3 spec §3.2 — Vector 1 + Vector 2 design, R-L3-2-A / R-L3-2-B firm rules.

### ARS prior art this builds on

- v3.7.3 spec (`docs/design/2026-05-12-ars-v3.7.3-claim-faithfulness-and-contaminated-source-spec.md`) — §3.2 contamination_signals object, finalizer annotation channel, manual-entry exemption, omit-on-API-degradation discipline.
- v3.6.8 trust-provenance spec — 4-cell finalizer matrix that v3.7.3 extended to 5-cell; v3.9.0 leaves the base matrix unchanged.
- `deep-research/references/semantic_scholar_api_protocol.md` — structural template for `openalex_api_protocol.md` and `crossref_api_protocol.md`.

### Memory / discipline

- `feedback_codex_consult_before_user_options.md` (2026-05-17, NEW) — design/option questions go through codex consult before reaching the user. v3.9.0 brainstorm validated this rule: codex 3-round consult shifted scope from (B hard-block proposal in issue #102) → (C opt-in profile) → (β measurement-only, v3.10 policy split). Each round caught a Claude-side blind spot.
- `feedback_codex_workflow_consolidated.md` — codex 9 rules total reference.
- `feedback_cross_model_review_r1_r4_trajectory.md` — dual-track schema-level PR convergence pattern (R1 4-7 → R4 0).
- `feedback_complex_spec_review_inventory_pattern.md` — invariants + state inventory discipline for review rounds (this spec's §8).
- `feedback_invariants_self_check_before_writing_examples.md` — INVARIANTS 12-check before example values (this spec's §8 self-check passes).
- `feedback_writing_plans_grep_verify_real_api.md` — Tier 1–3 verification before plan code skeletons; applied to OpenAlex / Crossref protocol docs.
- `feedback_ars_public_repo_boundary.md` — public-repo PII scan before push (this spec passes the personal-boundary deny-list scan; sensitive internal/public-repo-boundary tokens reviewed and not present in normative text).
- `feedback_test_time_bomb_freeze_clock.md` — no hardcoded current-date "past time" tests in v3.9.0 test surface.

---

## 8. Invariants + State Inventory (self-review per `feedback_complex_spec_review_inventory_pattern.md`)

### 8.1 Invariants

1. **R-L3-2-A preserved.** Contamination signals never block emission on their own. (§3.3, §3.9)
2. **R-L3-2-B preserved.** No retroactive computation in cite-time finalizer. (§3.3)
3. **R-L3-2-C consistent.** k computed over present fields only; absent ≠ false. (§3.2)
4. **R-L3-2-D applied.** No OpenAlex `primary_location.source.type` / Crossref `type` field used for v3.9.0 classification logic. (§3.4, §3.5)
5. **R-L3-2-E applied.** Refusal list untouched; formatter_agent pass-through allowlist extends from 3 v3.7.3 suffixes to 9 (3 legacy + 6 v3.9.0). No new marker shape triggers refusal. (§3.3 R-L3-2-E, §4 Step T-4) Audit-trail / reporting columns within the Cite-Time Provenance Finalizer section of `pipeline_orchestrator_agent.md` also extend to surface the 6 new v3.9.0 shapes (per §4 Step T-4).
6. **Manual exemption preserved across all three lookups.** (§3.4, §3.5, §3.6, §3.7)
7. **Backward compat:** v3.7.3 single-vector entries produce `CONTAMINATED-UNMATCHED` legacy suffix. (§3.3, §3.8 lint #3)
8. **No HIGH-BLOCK introduced.** All v3.9.0 markers stay advisory. (§3.3 naming, §3.8 lint #4)
9. **No `venue_type` schema field.** v3.10 scope. (§2.2, §2.3)
10. **No `triangulation_policy` schema field.** v3.10 scope. (§2.2)
11. **Omit-on-degradation discipline.** Absence ≠ false across all three indexes. (§3.4, §3.5, §3.7)
12. **CI lint registered.** New v3.9.0 lint in `.github/workflows/spec-consistency.yml`. (§4 Step T-1)

### 8.2 State Inventory — what v3.9.0 introduces, modifies, leaves untouched

**INTRODUCED (new state):**

| Surface | New element |
|---|---|
| Schema | 2 optional boolean fields (`openalex_unmatched`, `crossref_unmatched`) |
| API protocols | 2 new reference files (`openalex_api_protocol.md`, `crossref_api_protocol.md`) |
| Agent docs | `### Triangulation Extension (v3.9.0)` subsection in `bibliography_agent.md` |
| Finalizer | 3 new suffix shapes (`CONTAMINATED-COVERAGE-NOISE`, `CONTAMINATED-PARTIAL-UNMATCH`, `CONTAMINATED-TRIANGULATION-UNMATCHED`) + 3 preprint composition shapes |
| Firm rules | R-L3-2-C / R-L3-2-D / R-L3-2-E (new) |
| Lint | `check_v3_9_0_triangulation.py` (new file). CI registration is a modification to existing `.github/workflows/spec-consistency.yml` — see MODIFIED table. |
| Migration tool | `migrate_literature_corpus_to_v3_9_0.py` |
| Spec doc | this file |

**MODIFIED (existing state extended, semantics preserved):**

| Surface | Modification |
|---|---|
| `contamination_signals` object | Optional field count 2 → 4 |
| `academic-pipeline/agents/pipeline_orchestrator_agent.md` (Cite-Time Provenance Finalizer section) | Suffix derivation logic extends from 3 legacy shapes (`UNMATCHED`, `PREPRINT`, `PREPRINT+UNMATCHED`) to 3 legacy + 6 new = **9 total runtime suffix shapes** (legacy preserved). Audit-trail / reporting columns in this file may need parallel extension to surface the new shapes correctly — see §4 T-4. |
| `academic-paper/agents/formatter_agent.md` (contamination pass-through allowlist, line ~295) | Allowlist 3 v3.7.3 suffixes → 9 suffixes (3 v3.7.3 legacy + 6 v3.9.0). Refusal list (rules 1-10) NOT touched. |
| `.github/workflows/spec-consistency.yml` | Registers the new `check_v3_9_0_triangulation.py` lint step alongside existing v3.7.3 lint. |
| Schema not-rule (manual-entry exemption) | Extends from "MUST NOT carry `semantic_scholar_unmatched`" to "MUST NOT carry any of `semantic_scholar_unmatched` / `openalex_unmatched` / `crossref_unmatched`" |
| `check_literature_corpus_schema.py` | Accepts 2 new optional fields (additive) + tests extended not-rule |
| `CHANGELOG.md` | v3.9.0 entry |
| `.claude/CLAUDE.md` | New `## v3.9.0 Key Additions` section |

**EXPLICITLY UNCHANGED (preserved per invariants):**

| Surface | Preserved |
|---|---|
| `academic-paper/agents/formatter_agent.md` refusal list (rules 1-10) | NOT extended — no new marker shape triggers refusal |
| 5-cell base matrix (anchor / source_acquired / source_verified / human_read) | unchanged |
| `/ars-mark-read` semantics | unchanged |
| R-L3-2-A wording | preserved verbatim (§3.3) |
| R-L3-2-B wording | preserved verbatim (§3.3) |
| v3.7.3 `CONTAMINATED-UNMATCHED` legacy suffix | preserved for backward compat |
| v3.7.3 `CONTAMINATED-PREPRINT` legacy suffix | preserved |
| v3.6.7 PATTERN PROTECTION | byte-equivalent |
| v3.7.1 / v3.7.2 lints | unchanged |
| v3.8.x audit track | orthogonal |

### 8.3 Example-value self-check (per `feedback_invariants_self_check_before_writing_examples.md`)

Each illustrative example below was verified against the §3.3 table:

- Example "entry with (S2: true, OpenAlex: true, Crossref: true) → TRIANGULATION-UNMATCHED": k=3, k_max=3, row matches.
- Example "entry with (S2: true, OpenAlex: false, Crossref: false) → COVERAGE-NOISE": k=1, k_max=3, row matches.
- Example "entry with (S2: true, OpenAlex: absent, Crossref: absent) → UNMATCHED legacy": k=1, k_max=1, row matches.
- Example "entry with (S2: true, OpenAlex: true, Crossref: absent) → PARTIAL-UNMATCH": k=2, k_max=2, row matches.
- Example "preprint=true AND k=3 → `CONTAMINATED-PREPRINT+TRIANGULATION-UNMATCHED`": preprint suffix table row matches; composition order PREPRINT-first respected.

---

## 9. Risks & Mitigations

| Risk | Mitigation |
|---|---|
| Three APIs queried per entry inflates ingest latency. | Parallel lookups (§3.6). Polite-pool email param for higher rate limits. Per-entry latency dominated by network RTT, not by additional indexes — adding two more parallel queries is small. Migration tool serializes by default (`--max-concurrency` flag tunable) to avoid burning polite-pool budget on large legacy corpora. |
| Three indexes are not fully independent — they share DOI/title metadata pipelines. | Acknowledged in spec. Codex consult (2026-05-17, round 1) raised this explicitly; R1 dual-track review surfaced this again as the strongest design assumption. k=3 marker is `CONTAMINATED-TRIANGULATION-UNMATCHED` — describes the observable condition (all three lookup indexes failed to match), not the inferred cause (fabrication). This naming avoids over-claiming for humanities / non-English / dissertation references that legitimately fail triangulation due to shared coverage gaps. The user retains discretion (R-L3-2-A). v3.10 strict mode adds DOI + venue_type scoping precisely to mitigate genre-asymmetric false-positive risk — but v3.9.0 stays advisory across all genres. |
| Legitimate humanities monographs / non-English journals / dissertations trigger k=3. | This is the intended behavior at the advisory tier — surface the signal, let the user decide. Acknowledgeable via `/ars-mark-read`. v3.10 strict_articles_only mode will scope hard-block to article-class works (when venue_type is adapter-declared). v3.9.0 does not introduce hard-block at all, so false-positive cost is bounded by user attention. |
| Migration tool burns polite-pool budget on large corpora. | Tool serializes by default. Documented `--max-concurrency=1` recommendation for first run on 1000+ entry corpora. Per-API retry policy (2s backoff, up to 3 retries on 429; skip on 5xx/timeout — aligned to S2 protocol per §3.4 / §3.5) prevents pool exhaustion. |
| v3.7.3 entries (k_max=1, only S2) get misleading "COVERAGE-NOISE" annotation. | Spec §3.3 row explicitly handles k=1, k_max=1 case → keeps `CONTAMINATED-UNMATCHED` legacy suffix. Lint test #3 enforces this. Migration tool offers a path to promote legacy entries to k_max=3 markers. |
| v3.9.0 ships and v3.10 policy layer is never delivered, leaving an advisory-only baseline indefinitely. | §2.3 forward scope codifies v3.10 acceptance criteria so the policy gap is explicit and tracked. Worst case the advisory baseline is itself a valid permanent state — the v3.7.3 R-L3-2-A philosophy explicitly supports advisory-only as a coherent design. Not delivering v3.10 is not a regression. |
| OpenAlex / Crossref API contracts change. | Each protocol doc explicitly documents the dependent endpoints and field shapes. Breaking changes from the upstream API surface as graceful degradation (omit-on-failure) — bibliography_agent logs but does not abort. Long-term fix is a protocol-doc update + regression test refresh. |
| `triangulation_unmatched_count` computed in finalizer creates derived state not persisted in passport. | Acceptable — k is computed deterministically from the 3 booleans at cite-time. No persistence needed. If future tooling wants persistent k, v3.10 can add an optional `triangulation_count` field. v3.9.0 stays minimal. |
| Codex review round count exceeds budget (v3.7.3 needed 11 rounds). | Spec §8 invariants + state inventory provide an explicit anchor for round-by-round drift checks. Dual-track codex + Gemini per `feedback_cross_model_review_r1_r4_trajectory.md` should converge in 4-6 rounds for this scope. |

---

## 10. Open Questions

The following remain genuinely open for the spec review phase:

- **OQ-1:** Should the `triangulation_unmatched_count` (k) be exposed as an optional persistent field in `contamination_signals`, or stay finalizer-derived? Codex consult (2026-05-17, round 3) noted the choice depends on whether ARS persists derived annotations elsewhere. Default in this draft: derived, not persisted. Revisit during codex review.
- **OQ-4:** v3.7.3 has a closed 10-venue preprint list. Should OpenAlex/Crossref-known preprint repositories (e.g., HAL, ChinaXiv, IndiaRxiv) be folded into the list under v3.9.0, or remain v3.7.3-scoped? Default: deferred to a separate preprint-list-maintenance update (v3.7.3 §6 already named this as recurring maintenance). v3.9.0 does not touch the list.
- **OQ-5:** Should the migration tool emit a v3.7.3 → v3.9.0 schema-version marker somewhere on the entry (e.g., `contamination_signals_schema_version: "v3.9.0"`)? Default: no — keeping the schema additive without version fields preserves the v3.6.4 design philosophy. Revisit if downstream tooling needs to distinguish.

### 10.1 Resolved during review

The following were originally listed as OQ but resolved during R1 / R2:

- **OQ-2 (resolved R1):** migration tool log shape. Final: per-entry INFO line `[CORPUS MIGRATION INCOMPLETE: <index>]` + aggregate summary at end of run. §3.7, Appendix A.3, and check_v3_9_0_triangulation.py all use the same prefix.
- **OQ-3 (resolved R2):** Sequential query order when bibliography_agent runs lookups serially. Final: S2 → OpenAlex → Crossref per §3.6. No sequencing dependency exists — each lookup is independent, results join locally; order chosen for consistency with §3.4 / §3.5 spec ordering. Codex R2 noted this was already implicitly resolved by §3.6 default.

---

## Appendix A: Migration scenarios

### Scenario A1: legacy v3.7.3 corpus with `obtained_via='zotero-bbt-export'`

Before:
```yaml
contamination_signals:
  preprint_post_llm_inflection: false
  semantic_scholar_unmatched: true
```

After running migration tool (assuming OpenAlex match, Crossref no match):
```yaml
contamination_signals:
  preprint_post_llm_inflection: false
  semantic_scholar_unmatched: true
  openalex_unmatched: false
  crossref_unmatched: true
```

Finalizer behavior change: `CONTAMINATED-UNMATCHED` (legacy, k_max=1) → `CONTAMINATED-PARTIAL-UNMATCH` (k=2, k_max=3). Marker is more informative but still advisory. Gate unchanged.

### Scenario A2: legacy entry with `obtained_via='manual'`

Migration tool skips entirely. Entry unchanged.

### Scenario A3: legacy entry where OpenAlex API is down during migration

Migration tool runs Crossref (assume matches), omits `openalex_unmatched`, exits 0 with `[CORPUS MIGRATION INCOMPLETE: openalex]` per-entry log line.

Before:
```yaml
contamination_signals:
  preprint_post_llm_inflection: false
  semantic_scholar_unmatched: true
```

After (partial backfill):
```yaml
contamination_signals:
  preprint_post_llm_inflection: false
  semantic_scholar_unmatched: true
  crossref_unmatched: false
```

Finalizer: k=1, k_max=2 → `CONTAMINATED-COVERAGE-NOISE`. User retains discretion. Tool can be re-run later when OpenAlex API recovers (idempotent — semantic_scholar / crossref fields unchanged on second run).

### Scenario A4: new entry ingested via bibliography_agent under v3.9.0

bibliography_agent runs all three lookups in parallel. If all three succeed, all three `*_unmatched` fields set. If any fails after 3 retries, that field omitted. Manual entries: all three skipped.

---

## Appendix B: Review trail

This spec passed through dual-track review (codex gpt-5.5 xhigh + Gemini 3.1-pro-preview) with the following round-by-round finding counts. Specific finding IDs are kept here rather than in normative spec text so the contract sections remain durable across future amendments.

### R1 (commit `d9280bf`) — 15 unique findings closed

- **P0 (3):** phantom-file refs (codex F-CDX-1); formatter unchanged claim unsafe (codex F-CDX-2); tier-label vs marker semantics (Gemini F-GEM-1).
- **P1 (8):** k=1 k_max=1 legacy over-broad (overlap F-OV-1); finalizer trigger wording (codex F-CDX-4); manual exemption asymmetry (codex F-CDX-5); schema not-rule extension (codex F-CDX-6); DOI title cross-check (codex F-CDX-7); Crossref DOI-absent fallback (codex F-CDX-8); retry/backoff S2 alignment (codex F-CDX-9); migration idempotency redefinition (codex F-CDX-10).
- **P2 (4):** suffix-order explicit list (codex F-CDX-11); §2.3 cross-reference (codex F-CDX-12); log-prefix unification (codex F-CDX-13); preprint typo (Gemini F-GEM-4).
- **(D) Cross-track design challenge (resolved separately):** k=3 marker renamed `LIKELY-FABRICATED` → `TRIANGULATION-UNMATCHED` via codex consult round 2.

### R2 (this commit) — 12 unique findings closed

- **P1 (3):** invariant 5 contradiction (overlap R2-OV-1); formatter allowlist lint gap (overlap R2-OV-2); AC coverage gap including AC#6 idempotency wording (codex R2-F3 + R2-F4, codex F-CDX-10 partial).
- **P2 (9):** suffix count typo (codex R2-F5); audit-trail column extension (codex R2-F6); pre-v3.7.3 migration scope (codex R2-F7); exponential backoff residue (codex R2-F8); OQ-2 / OQ-3 resolution (codex R2-F9); fabrication-prior wording (codex R2-F10); inline R1 ID noise (codex R2-F11 + Gemini R2-F5); spec-consistency.yml location (Gemini R2-F4).

### Ship signal (post-R3)

R1 (commit `d9280bf`) and R2 (commit `7d51215`) both returned NEEDS-R3.
R3 (this post-Task-1 commit) returned 0 P0 / 0 P1 / 4 P2 unique findings on both codex (gpt-5.5 xhigh) and Gemini (3.1-pro-preview) tracks. Both tracks declared READY-FOR-IMPL. The 4 P2 findings are addressed by Task 1 of this implementation plan (no R4 review round). Trajectory: R1 15 → R2 12 → R3 4 = converged per `feedback_cross_model_review_r1_r4_trajectory.md` schema-level pattern.

---

**End of v3.9.0 design draft.**
