← Về thư mục
📄 / / root / ceo / academic-research-skills / docs / design / 2026-05-15-issue-103-claim-alignment-audit-spec.md

ARS v3.8 — Issue #103 claim_ref_alignment_audit_agent Implementation Spec

Date: 2026-05-15 Issue: #103 Companion decision doc: 2026-05-15-issue-103-claim-alignment-audit-decision.md Target release: v3.8

This spec implements the eight decisions in the companion decision doc. Read the decision doc first — it carries the load-bearing reasons; this spec carries the executable surface.


1. In-scope deliverables

Numbered list aligned to the implementation surfaces in #103 issue body + decision-doc adjustments.

  1. academic-pipeline/agents/claim_ref_alignment_audit_agent.md — new agent prompt. Estimated 350-500 lines.
  2. shared/contracts/passport/claim_audit_result.schema.json — per-claim audit result entry schema (judge-evaluated citation-bound findings).
  3. shared/contracts/passport/claim_intent_manifest.schema.json — per-agent-invocation manifest entry schema. 3a. shared/contracts/passport/uncited_assertion.schema.json — per uncited-sentence finding entry schema (separate from claim_audit_result because there's no ref_slug to bind). 3b. shared/contracts/passport/claim_drift.schema.json — per claim-intent-drift finding entry schema (separate from claim_audit_result because drift is detected by manifest set-diff, not by judge invocation; see §3.4 rationale). 3c. shared/contracts/passport/constraint_violation.schema.json — per uncited-claim-violates-constraint finding entry schema (separate from claim_audit_result because no ref_slug exists, but HIGH-WARN gate-refuse semantics differ from LOW-WARN uncited_assertion; see §3.5 rationale).
  4. academic-pipeline/agents/pipeline_orchestrator_agent.md — new §3.6 "Claim-Faithfulness Audit Gate (v3.8)". Dispatch wiring for the new agent. Finalizer integration extended to 8-row + advisory tier.
  5. academic-paper/agents/formatter_agent.md — Cite-Time Provenance Hard Gate extended with FIVE new HIGH-WARN refusal classes mirroring §5 finalizer matrix (8-row matrix + constraint_violations[] aggregate): HIGH-WARN-CLAIM-NOT-SUPPORTED, HIGH-WARN-NEGATIVE-CONSTRAINT-VIOLATION, HIGH-WARN-FABRICATED-REFERENCE, HIGH-WARN-CLAIM-AUDIT-ANCHORLESS, HIGH-WARN-CONSTRAINT-VIOLATION-UNCITED. The formatter's existing v3.7.1/v3.7.3 refusal rules remain unchanged; new classes append to the existing REFUSE list. §5 matrix + constraint_violations[] are the source of truth — any new HIGH-WARN class added in future revisions MUST be mirrored here, enforced by a §6 lint rule.
  6. academic-paper/agents/draft_writer_agent.md + deep-research/agents/synthesis_agent.md + deep-research/agents/report_compiler_agent.md — new "Claim Intent Manifest Emission (v3.8)" sibling heading following the existing v3.7.3 "Three-Layer Citation Emission" heading. PATTERN PROTECTION (v3.6.7) blocks stay byte-equivalent.
  7. academic-pipeline/references/claim_audit_calibration_protocol.md — new file (modeled on shared/contracts/reviewer/ calibration convention).
  8. scripts/check_claim_audit_consistency.py — new lint enforcing per-claim invariants (anchor presence, defect_stage presence, precedence rules, audit_status/defect_stage coherence).
  9. CI wiring — extend .github/workflows/spec-consistency.yml (or matching workflow) to call the new lint.
  10. Testsscripts/test_claim_audit_schema.py covers both schema validation and check_claim_audit_consistency.py lint coverage via subprocess pattern (T-S1..T-S8 invariants); plus per-stage unittest modules (scripts/test_claim_audit_pipeline.py, scripts/test_uncited_assertion.py, scripts/test_claim_intent_manifest.py, scripts/test_claim_audit_finalizer.py, scripts/test_e2e_claim_audit.py for the 5-citation end-to-end synthetic-paper test, scripts/test_claim_audit_calibration.py).
  11. CHANGELOG entry + ROADMAP §3.8 anchor + decision-log entry.

2. Out of scope

Restated from decision doc §4, for cross-reference:

3. Schemas

3.1 claim_audit_result.schema.json

Per-claim audit result. One entry per audited citation in the passport claim_audit_results[] aggregate array.

Required fields:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://github.com/Imbad0202/academic-research-skills/shared/contracts/passport/claim_audit_result.schema.json",
  "title": "Material Passport Claim Audit Result Entry",
  "type": "object",
  "additionalProperties": false,
  "required": [
    "claim_id",
    "scoped_manifest_id",
    "claim_text",
    "ref_slug",
    "anchor_kind",
    "anchor_value",
    "judgment",
    "audit_status",
    "defect_stage",
    "rationale",
    "judge_model",
    "judge_run_at",
    "ref_retrieval_method"
  ],
  "properties": {
    "claim_id": { "type": "string", "pattern": "^C-[0-9]{3,}$" },
    "scoped_manifest_id": {
      "type": "string",
      "pattern": "^M-[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z-[0-9a-f]{4}$",
      "description": "Points to the owning claim_intent_manifest.manifest_id. The (scoped_manifest_id, claim_id) pair uniquely identifies the claim, since C-001 may collide across manifests in the same run. Enforced by INV-15 (cross-array integrity). For audits running in MANIFEST-MISSING fallback (no manifest present), scoped_manifest_id is the sentinel `M-0000-00-00T00:00:00Z-0000`."
    },
    "claim_text": { "type": "string", "minLength": 1, "maxLength": 2000 },
    "ref_slug": { "type": "string", "minLength": 1 },
    "anchor_kind": { "enum": ["quote", "page", "section", "paragraph", "none"] },
    "anchor_value": { "type": "string" },
    "judgment": { "enum": ["SUPPORTED", "UNSUPPORTED", "AMBIGUOUS", "RETRIEVAL_FAILED"] },
    "audit_status": { "enum": ["completed", "inconclusive"] },
    "defect_stage": {
      "comment": "Three categories of finding are intentionally NOT defect_stages of claim_audit_result and use their own §3.3/§3.4/§3.5 entry-type schemas: (a) uncited_assertion findings — no ref_slug to evaluate, see uncited_assertion.schema.json (§3.3); (b) claim_intent drift findings — detected by manifest set-diff not by judge, see claim_drift.schema.json (§3.4) (drift never produces a claim_audit_result row because the judge is never invoked for these — the only signal is intended ≠ emitted manifest-set difference); (c) uncited constraint violations — claim has no ref_slug AND violates an MNC/NC rule, see constraint_violation.schema.json (§3.5) (HIGH-WARN gate-refuse but no ref to bind into a claim_audit_result row). 8 values listed below.",
      "enum": [
        "retrieval_existence",
        "metadata",
        "source_description",
        "citation_anchor",
        "synthesis_overclaim",
        "negative_constraint_violation",
        "not_applicable",
        null
      ]
    },
    "rationale": { "type": "string", "minLength": 1, "maxLength": 2000 },
    "judge_model": { "type": "string", "minLength": 1 },
    "judge_run_at": { "type": "string", "format": "date-time" },
    "ref_retrieval_method": { "enum": ["api", "manual_pdf", "failed", "not_attempted", "not_found", "audit_tool_failure"] },
    "upstream_owner_agent": {
      "enum": [
        "synthesis_agent",
        "draft_writer_agent",
        "report_compiler_agent",
        null
      ]
    },
    "violated_constraint_id": {
      "type": ["string", "null"],
      "pattern": "^(NC-C[0-9]{3,}-[0-9]+|MNC-[0-9]+)$"
    },
    "upstream_dispute": {
      "type": ["string", "null"],
      "maxLength": 1000
    },
    "audit_run_id": {
      "type": "string",
      "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z-[0-9a-f]{4}$"
    }
  }
}

Allowed (judgment, audit_status, defect_stage) matrix (enforced in check_claim_audit_consistency.py, NOT in schema). Any combination outside the table is a lint violation:

judgment audit_status defect_stage Notes
SUPPORTED completed null INV-1
AMBIGUOUS completed source_description, citation_anchor, synthesis_overclaim, or null drift never AMBIGUOUS; constraints binary
UNSUPPORTED completed source_description wrong paraphrase of source content
UNSUPPORTED completed metadata reference exists but author/year/title wrong
UNSUPPORTED completed citation_anchor source correct, anchor points to wrong passage
UNSUPPORTED completed synthesis_overclaim source correct, draft over-strengthens claim
UNSUPPORTED completed negative_constraint_violation INV-8, requires violated_constraint_id
RETRIEVAL_FAILED completed retrieval_existence reference genuinely does not exist (fabricated)
RETRIEVAL_FAILED inconclusive not_applicable covers (a) anchor=none INV-6 and (b) paywalled INV-10

uncited_assertion is NOT a claim_audit_result row — uncited claims have no ref_slug to populate the required field. They emit into a separate aggregate uncited_assertions[] (schema in §3.3 below) and feed the same Stage 4→5 finalizer integration. This sidesteps a schema-vs-required-field deadlock: claim_audit_result.ref_slug stays required for citation-bound audits; uncited_assertions[] carries its own entry-type schema with no ref_slug field.

Cross-field invariants:

3.2 claim_intent_manifest.schema.json

One entry per generating-agent invocation. Emitted by synthesis_agent / draft_writer_agent / report_compiler_agent after paper-visible context loads but before prose generation.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://github.com/Imbad0202/academic-research-skills/shared/contracts/passport/claim_intent_manifest.schema.json",
  "title": "Material Passport Claim Intent Manifest Entry",
  "type": "object",
  "additionalProperties": false,
  "required": [
    "manifest_version",
    "manifest_id",
    "emitted_by",
    "emitted_at",
    "claims",
    "manifest_negative_constraints"
  ],
  "properties": {
    "manifest_version": { "const": "1.0" },
    "manifest_id": {
      "type": "string",
      "pattern": "^M-[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z-[0-9a-f]{4}$",
      "description": "Discriminator scoping all claim_id values inside this manifest. Format: M-<ISO-8601-Z>-<4-hex>. Required because a single passport may contain multiple claim_intent_manifests[] entries (e.g., one from synthesis_agent and one from draft_writer_agent on the same run); bare C-001 alone would collide. The pair (manifest_id, claim_id) is the joinable key — see claim_audit_result.scoped_manifest_id + INV-15 / D-INV-2 cross-array integrity."
    },
    "emitted_by": { "enum": ["synthesis_agent", "draft_writer_agent", "report_compiler_agent"] },
    "emitted_at": { "type": "string", "format": "date-time" },
    "session_id": { "type": "string" },
    "claims": {
      "type": "array",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["claim_id", "claim_text", "intended_evidence_kind", "planned_refs"],
        "properties": {
          "claim_id": { "type": "string", "pattern": "^C-[0-9]{3,}$" },
          "claim_text": { "type": "string", "minLength": 1, "maxLength": 2000 },
          "intended_evidence_kind": { "enum": ["empirical", "theoretical", "definitional", "normative"] },
          "planned_refs": { "type": "array", "items": { "type": "string" }, "minItems": 0 },
          "negative_constraints": {
            "type": "array",
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": ["constraint_id", "rule"],
              "properties": {
                "constraint_id": { "type": "string", "pattern": "^NC-C[0-9]{3,}-[0-9]+$" },
                "rule": { "type": "string", "minLength": 1, "maxLength": 500 }
              }
            }
          }
        }
      }
    },
    "manifest_negative_constraints": {
      "type": "array",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["constraint_id", "rule"],
        "properties": {
          "constraint_id": { "type": "string", "pattern": "^MNC-[0-9]+$" },
          "rule": { "type": "string", "minLength": 1, "maxLength": 500 }
        }
      }
    }
  }
}

Cross-field invariants (lint-enforced): - M-INV-1: claim_id uniqueness within ONE manifest (scoped by manifest_id). Cross-manifest collision (C-001 in both manifest A and manifest B) is permitted — the joinable discriminator is the (manifest_id, claim_id) pair. - M-INV-2: constraint_id of NC-C{n}-{m} form MUST appear under claims[] entry where claim_id=C-{n} (i.e., claim-level constraint scoping) - M-INV-3: MNC-{m} constraints in manifest_negative_constraints are globally applied; cannot be overridden by claim-level NC (claim-level can ADD, never DROP global) - M-INV-4: manifest_id uniqueness across ALL claim_intent_manifests[] in one passport. Two manifests sharing the same manifest_id is a lint violation — the orchestrator must allocate fresh M-* identifiers per agent invocation.

3.3 uncited_assertion.schema.json

Per uncited-assertion finding. One entry per sentence in the draft that the D4-c three-condition token rule flagged. Aggregated as uncited_assertions[] in the orchestrator passport-tracking, parallel to claim_audit_results[].

The separate schema exists because uncited_assertion findings have no ref_slug to fill — they describe sentences that should have a citation but don't. Embedding them in claim_audit_result would either force a sentinel ref_slug value or relax the required-field rule, both of which fight the schema's grain.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://github.com/Imbad0202/academic-research-skills/shared/contracts/passport/uncited_assertion.schema.json",
  "title": "Material Passport Uncited Assertion Entry",
  "type": "object",
  "additionalProperties": false,
  "required": [
    "finding_id",
    "sentence_text",
    "section_path",
    "trigger_tokens",
    "detected_at",
    "rule_version"
  ],
  "properties": {
    "finding_id": { "type": "string", "pattern": "^UA-[0-9]{3,}$" },
    "sentence_text": { "type": "string", "minLength": 1, "maxLength": 2000 },
    "section_path": { "type": "string", "minLength": 1, "description": "Hierarchical path from document root to the section containing the sentence, e.g. '2. Methods > 2.3 Sampling'" },
    "trigger_tokens": {
      "type": "array",
      "minItems": 1,
      "items": { "type": "string" },
      "description": "Concrete tokens that matched D4-c condition 1 (quantifiers or empirical-claim verbs). E.g. ['67%', 'showed']."
    },
    "detected_at": { "type": "string", "format": "date-time" },
    "rule_version": { "const": "D4-c-v1" },
    "upstream_owner_agent": {
      "enum": ["synthesis_agent", "draft_writer_agent", "report_compiler_agent", null]
    },
    "manifest_claim_id": {
      "type": ["string", "null"],
      "pattern": "^C-[0-9]{3,}$",
      "description": "When the uncited sentence corresponds to a claim_id in the active claim_intent_manifest. Per D4-c last paragraph: manifest membership does NOT exempt a sentence from being flagged. When present, MUST be paired with scoped_manifest_id to disambiguate against C-001 collision across manifests."
    },
    "scoped_manifest_id": {
      "type": ["string", "null"],
      "pattern": "^M-[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z-[0-9a-f]{4}$",
      "description": "Points to the claim_intent_manifest.manifest_id owning the referenced manifest_claim_id. The (scoped_manifest_id, manifest_claim_id) pair uniquely identifies which manifest's claim this uncited finding corresponds to, since C-001 may collide across manifests in the same passport. Required when manifest_claim_id ≠ null (U-INV-4 cross-array integrity). Null when manifest_claim_id is null (the uncited sentence does not correspond to any manifest claim)."
    }
  }
}

Cross-field invariants (lint-enforced in check_claim_audit_consistency.py): - U-INV-1: finding_id uniqueness across uncited_assertions[] in one passport - U-INV-2: trigger_tokens non-empty (rule fires only when condition 1 matches) - U-INV-3: rule_version must equal D4-c-v1 for v3.8.0 release; future rule revisions bump the const and require re-lint - U-INV-4: When manifest_claim_id ≠ null, scoped_manifest_id ≠ null AND the (scoped_manifest_id, manifest_claim_id) pair MUST match some claim_intent_manifests[].manifest_id == scoped_manifest_id whose claims[].claim_id contains the manifest_claim_id (cross-array consistency). When manifest_claim_id = null, scoped_manifest_id MUST also = null (no orphan manifest pointer).

3.4 claim_drift.schema.json

Per claim-intent-drift finding. One entry per emitted claim that the manifest set-diff in §4 step 5 flagged as having drifted away from the claim_intent_manifests[] baseline. Aggregated as claim_drifts[] in the orchestrator passport-tracking, parallel to claim_audit_results[] and uncited_assertions[].

The separate schema exists because claim-intent drift is detected by manifest set-diff, not by judge invocation — no judgment / audit_status / rationale field would carry meaning for these entries, and the judge is never run for the drift detection path. Embedding drift findings as a defect_stage of claim_audit_result would force judgment=UNSUPPORTED to be recorded without the judge ever evaluating that claim, contaminating both the schema semantics (UNSUPPORTED means "judge said UNSUPPORTED") and the calibration FNR/FPR metrics (which are scoped to the judge's own SUPPORTED/UNSUPPORTED/AMBIGUOUS judgments).

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://github.com/Imbad0202/academic-research-skills/shared/contracts/passport/claim_drift.schema.json",
  "title": "Material Passport Claim Drift Entry",
  "type": "object",
  "additionalProperties": false,
  "required": [
    "finding_id",
    "drift_kind",
    "claim_text",
    "detected_at",
    "rule_version"
  ],
  "properties": {
    "finding_id": { "type": "string", "pattern": "^CD-[0-9]{3,}$" },
    "drift_kind": {
      "enum": ["EMITTED_NOT_INTENDED", "INTENDED_NOT_EMITTED"],
      "description": "EMITTED_NOT_INTENDED: an emitted claim that was not in the manifest (the drafted prose introduced a claim the writer did not pre-commit to). INTENDED_NOT_EMITTED: a manifest claim that did not appear in the emitted prose (the writer dropped a pre-committed claim during drafting). Both are advisory — gate-refuse is reserved for negative_constraint_violation."
    },
    "claim_text": { "type": "string", "minLength": 1, "maxLength": 2000, "description": "For EMITTED_NOT_INTENDED, the emitted sentence; for INTENDED_NOT_EMITTED, the manifest claim_text." },
    "manifest_claim_id": {
      "type": ["string", "null"],
      "pattern": "^C-[0-9]{3,}$",
      "description": "For INTENDED_NOT_EMITTED, the dropped manifest claim_id (REQUIRED, conditional on drift_kind — enforced by D-INV-2). For EMITTED_NOT_INTENDED, null (drifted claim has no manifest_claim_id since it was never in the manifest). When present, MUST be paired with scoped_manifest_id."
    },
    "scoped_manifest_id": {
      "type": ["string", "null"],
      "pattern": "^M-[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z-[0-9a-f]{4}$",
      "description": "Points to the claim_intent_manifest.manifest_id owning the referenced manifest_claim_id. Required when drift_kind=INTENDED_NOT_EMITTED (paired with manifest_claim_id per D-INV-2 cross-array integrity, disambiguating C-001 collision across manifests). Null when drift_kind=EMITTED_NOT_INTENDED (the drifted claim has no manifest origin)."
    },
    "section_path": {
      "type": ["string", "null"],
      "minLength": 1,
      "description": "For EMITTED_NOT_INTENDED, hierarchical path from document root to the section containing the drifted sentence (mirrors uncited_assertion.section_path). Null for INTENDED_NOT_EMITTED (dropped claim has no draft location)."
    },
    "detected_at": { "type": "string", "format": "date-time" },
    "rule_version": { "const": "D4-a-v1" },
    "upstream_owner_agent": {
      "enum": ["synthesis_agent", "draft_writer_agent", "report_compiler_agent", null]
    }
  }
}

Cross-field invariants (lint-enforced in check_claim_audit_consistency.py): - D-INV-1: finding_id uniqueness across claim_drifts[] in one passport. - D-INV-2: drift_kind=INTENDED_NOT_EMITTEDmanifest_claim_id ≠ null AND scoped_manifest_id ≠ null AND the (scoped_manifest_id, manifest_claim_id) pair MUST match some claim_intent_manifests[].manifest_id == scoped_manifest_id whose claims[].claim_id contains the manifest_claim_id (cross-array integrity, disambiguates C-001 collision across manifests). drift_kind=EMITTED_NOT_INTENDEDmanifest_claim_id = null AND scoped_manifest_id = null AND section_path ≠ null. - D-INV-3: rule_version must equal D4-a-v1 for v3.8.0 release; future rule revisions bump the const and require re-lint. - D-INV-4: A given emitted sentence may produce AT MOST ONE finding across uncited_assertions[] and claim_drifts[] combined. When a sentence is both uncited AND drifted, the uncited_assertions[] entry takes precedence (per §5 finalizer precedence rule 3 restated from issue body) — no companion claim_drifts[] entry emits for the same sentence.

3.5 constraint_violation.schema.json

Per uncited claim that violates a manifest negative constraint. One entry per emitted sentence WITHOUT a <!--ref:slug--> marker that triggers VIOLATED from the negative-constraint judge prompt. Aggregated as constraint_violations[] in the orchestrator passport-tracking, parallel to claim_audit_results[] / uncited_assertions[] / claim_drifts[].

The separate schema exists because constraint violations on uncited claims are real HIGH-WARN gate-refuse blockers, but claim_audit_result.ref_slug is required and uncited_assertion is LOW-WARN advisory only. A claim like "we observed causality" with no citation, against an MNC rule "will NOT claim causality without RCT evidence", is a genuine MUST-NOT violation the user explicitly declared. Routing it through uncited_assertions[] would silently downgrade a HIGH-WARN signal to LOW-WARN; routing it through claim_audit_result would require a sentinel ref_slug value that doesn't exist. A dedicated entry-type preserves both the HIGH-WARN severity and the schema integrity of each existing aggregate.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://github.com/Imbad0202/academic-research-skills/shared/contracts/passport/constraint_violation.schema.json",
  "title": "Material Passport Constraint Violation Entry",
  "type": "object",
  "additionalProperties": false,
  "required": [
    "finding_id",
    "claim_text",
    "section_path",
    "violated_constraint_id",
    "scoped_manifest_id",
    "judge_verdict",
    "rationale",
    "judge_model",
    "judge_run_at",
    "rule_version"
  ],
  "properties": {
    "finding_id": { "type": "string", "pattern": "^CV-[0-9]{3,}$" },
    "claim_text": { "type": "string", "minLength": 1, "maxLength": 2000 },
    "section_path": { "type": "string", "minLength": 1, "description": "Hierarchical path from document root to the section containing the offending sentence." },
    "violated_constraint_id": { "type": "string", "pattern": "^(NC-C[0-9]{3,}-[0-9]+|MNC-[0-9]+)$" },
    "scoped_manifest_id": {
      "type": "string",
      "pattern": "^M-[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z-[0-9a-f]{4}$",
      "description": "Points to the claim_intent_manifest.manifest_id owning the violated constraint. Required (no MANIFEST-MISSING sentinel admitted — constraints require an active manifest to exist)."
    },
    "manifest_claim_id": {
      "type": ["string", "null"],
      "pattern": "^C-[0-9]{3,}$",
      "description": "For NC-C{n}-{m} (claim-level constraint), the parent claim_id from the manifest. For MNC-{m} (global), null."
    },
    "judge_verdict": { "const": "VIOLATED" },
    "rationale": { "type": "string", "minLength": 1, "maxLength": 2000 },
    "judge_model": { "type": "string", "minLength": 1 },
    "judge_run_at": { "type": "string", "format": "date-time" },
    "rule_version": { "const": "D4-a-v1" },
    "upstream_owner_agent": {
      "enum": ["synthesis_agent", "draft_writer_agent", "report_compiler_agent", null]
    }
  }
}

Cross-field invariants (lint-enforced in check_claim_audit_consistency.py): - CV-INV-1: finding_id uniqueness across constraint_violations[] in one passport. - CV-INV-2: (scoped_manifest_id, violated_constraint_id) MUST resolve in some claim_intent_manifests[] entry. For MNC-* ids, the matching manifest's manifest_negative_constraints[] must contain it. For NC-C{n}-{m} ids, the matching manifest's claims[] entry with claim_id=C-{n} must contain a negative_constraints[].constraint_id matching the NC- id, AND manifest_claim_id MUST equal C-{n}. - CV-INV-3: When violated_constraint_id starts with MNC-, manifest_claim_id = null; when it starts with NC-, manifest_claim_id MUST equal the C-{n} extracted from the NC- id. - CV-INV-4: An uncited sentence MAY appear in BOTH uncited_assertions[] AND constraint_violations[] simultaneously — these surface different aspects (advisory uncited token-rule + HIGH-WARN constraint violation by judge) and don't trip D-INV-4-style exclusivity. However, a SINGLE sentence MUST NOT appear in constraint_violations[] more than once per (scoped_manifest_id, violated_constraint_id) — i.e. the lint dedup key is (scoped_manifest_id, section_path, claim_text_hash, violated_constraint_id). Per M-INV-4 each manifest_id is unique across the passport but constraint ids (MNC-* / NC-*) are only unique WITHIN a manifest, so two manifests in the same passport may legitimately carry colliding constraint ids — the same sentence text may then violate both, and the dedupe must respect manifest scope to preserve both findings (v3.8.1).

3.6 uncited_audit_failure.schema.json

Per uncited-sentence × manifest pair where the constraint judge raised a JudgeInvocationError during D6 stream (d) judging. One entry per (sentence, manifest) failure. Aggregated as uncited_audit_failures[] in the orchestrator passport-tracking, parallel to claim_audit_results[] / uncited_assertions[] / claim_drifts[] / constraint_violations[].

The separate schema exists because the cited-path INV-14 audit_tool_failure row (a claim_audit_result entry carrying ref_retrieval_method=audit_tool_failure) cannot be reused on the uncited path — claim_audit_result.ref_slug is required, and the uncited path has no ref to bind. Without a dedicated surface, a transient judge outage on a constraint check would either be silently swallowed (the pre-v3.8.2 bug — NOT_VIOLATED substituted, HIGH-WARN constraint check suppressed) or would force the entire audit pass to abort (dropping coverage). This entry-type mirrors INV-14 semantics on the uncited path: MED-WARN advisory at finalizer, retry-next-pass remediation, surfaces the infrastructure failure distinctly from a substantive non-violation. Routing through uncited_assertions[] would conflate D4-c token-rule advisory signal with audit-time infrastructure failure (different rule_version, different fault model, different annotation tier).

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://github.com/Imbad0202/academic-research-skills/shared/contracts/passport/uncited_audit_failure.schema.json",
  "title": "Material Passport Uncited Audit Failure Entry",
  "type": "object",
  "additionalProperties": false,
  "required": [
    "finding_id",
    "claim_text",
    "section_path",
    "scoped_manifest_id",
    "fault_class",
    "rationale",
    "judge_model",
    "judge_run_at",
    "rule_version"
  ],
  "properties": {
    "finding_id": { "type": "string", "pattern": "^UAF-[0-9]{3,}$" },
    "claim_text": { "type": "string", "minLength": 1, "maxLength": 2000 },
    "section_path": { "type": "string", "minLength": 1, "description": "Hierarchical path from document root to the section containing the offending sentence (mirrors uncited_assertion.section_path)." },
    "scoped_manifest_id": {
      "type": "string",
      "pattern": "^M-[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z-[0-9a-f]{4}$",
      "description": "Points to the claim_intent_manifest.manifest_id whose MNC/NC-C set was being judged when the failure occurred. Required — no MANIFEST-MISSING sentinel admitted; UAF emission requires an active manifest scope."
    },
    "manifest_claim_id": {
      "type": ["string", "null"],
      "pattern": "^C-[0-9]{3,}$",
      "description": "When the sentence was bound to a manifest claim (sentence carries `manifest_claim_id` per §4 step 5 stream (d) NC-C judging path), points to that claim. Null when the judge call was against MNCs only (manifest-wide constraints, no claim binding). Mirrors constraint_violation.manifest_claim_id polarity."
    },
    "fault_class": {
      "enum": [
        "judge_timeout",
        "judge_api_error",
        "judge_parse_error",
        "cache_corruption",
        "retrieval_api_error",
        "retrieval_timeout",
        "retrieval_network_error"
      ],
      "description": "Same closed enum as INV-14 fault-class taxonomy on the cited path. Sourced from JudgeInvocationError.fault_class at the raise site."
    },
    "rationale": { "type": "string", "minLength": 1, "maxLength": 2000, "description": "MUST begin with the fault_class tag followed by `: ` and a free-form detail. Mirrors INV-14 rationale format. Example: `judge_timeout: judge timed out after 30s`." },
    "judge_model": { "type": "string", "minLength": 1 },
    "judge_run_at": { "type": "string", "format": "date-time" },
    "rule_version": { "const": "D4-c-v1-uaf-v1", "description": "UAF surface version. Distinguishes from `D4-c-v1` (uncited_assertion D4-c detector) and `D4-a-v1` (constraint_violation). The `-uaf-v1` suffix marks this as a separate surface, not a D4-c revision." },
    "upstream_owner_agent": {
      "enum": ["synthesis_agent", "draft_writer_agent", "report_compiler_agent", null]
    }
  }
}

Cross-field invariants (lint-enforced in check_claim_audit_consistency.py): - UAF-INV-1: finding_id uniqueness across uncited_audit_failures[] in one passport. - UAF-INV-2: scoped_manifest_id MUST resolve in some claim_intent_manifests[] entry (cross-array reference integrity). - UAF-INV-3: When manifest_claim_id ≠ null, the (scoped_manifest_id, manifest_claim_id) pair MUST match some claims[].claim_id in the referenced manifest. When manifest_claim_id = null, the failure was against MNCs only (no claim binding required). Mirrors U-INV-4 / CV-INV-2 cross-array integrity pattern. - UAF-INV-4: Per-(sentence, manifest) dedup. The tuple (scoped_manifest_id, section_path, claim_text_hash) MUST be unique across the aggregate. A sentence judged once per manifest produces at most one UAF row per (sentence, manifest). Two manifests both failing on the same sentence emit two distinct rows (legitimate per-manifest scope, mirrors CV-INV-4 cross-manifest reasoning). - UAF-INV-5: rationale MUST begin with the row's own fault_class value followed by ":" (and a " " plus free-form detail when a detail is available — the trailing space is omitted when detail is empty so the rationale stays minLength: 1-valid). Mirrors INV-14 rationale prefix requirement on the cited path; the prefix must match this row's fault_class field, not just any known tag. Example with detail: "judge_timeout: judge timed out after 30s". Example without detail: "judge_timeout:" (a 14-character minimum form when the upstream JudgeInvocationError.detail is empty). - UAF-INV-6: Cross-aggregate exclusivity. A sentence MUST NOT appear in both uncited_audit_failures[] AND constraint_violations[] for the same (scoped_manifest_id, section_path, claim_text_hash) — VIOLATED and audit_tool_failure are mutually exclusive verdict states at per-(sentence, manifest) level (one is a positive verdict, the other is no verdict at all). Co-existence with uncited_assertions[] IS permitted (D4-c detector positives and audit-time judge failure are independent signals).

4. Agent prompt structure: claim_ref_alignment_audit_agent.md

Sections (in order):

  1. Purpose & v3.8 placement — single paragraph naming L3 audit role, dependency on v3.7.3 anchor input, audit-not-arbitration boundary.
  2. PATTERN PROTECTION (v3.6.7) — byte-equivalent block to existing audited-agents pattern protection convention. Prevents cascading edits.
  3. Input contract — exact passport fields read; claim_audit_config keys consumed (max_claims_per_paper, judge_model, gold_set_path, cache_dir).

Sampling behavior when citation count N > max_claims_per_paper: the agent MUST emit a single audit_sampling_summary entry (one per audit run) into the passport audit_sampling_summaries[] aggregate, schema below. The agent selects a stratified sample — divide the N citations into k buckets (where k = min(max_claims_per_paper, N)) and pick one citation from each bucket in document order. Other citations are not audited but the summary entry surfaces the skip rate so users see how many citations went unaudited. No silent skipping per D3(a). When N ≤ max_claims_per_paper, no summary entry is needed (or equivalently, an entry with audited_count == total_citation_count may be emitted for telemetry).

audit_sampling_summary entry schema (minimal, no separate §3.x — small enough to inline here): json { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "additionalProperties": false, "required": ["audit_run_id", "max_claims_per_paper", "total_citation_count", "audited_count", "audited_indices", "sampling_strategy", "emitted_at"], "properties": { "audit_run_id": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z-[0-9a-f]{4}$" }, "max_claims_per_paper": { "type": "integer", "minimum": 1 }, "total_citation_count": { "type": "integer", "minimum": 0 }, "audited_count": { "type": "integer", "minimum": 0 }, "audited_indices": { "type": "array", "items": { "type": "integer", "minimum": 0 }, "description": "0-based document-order indices of the audited citations." }, "sampling_strategy": { "const": "stratified_buckets_v1" }, "emitted_at": { "type": "string", "format": "date-time" } } }

Sampling invariants: - S-INV-1: audited_count == |audited_indices|. - S-INV-2: audited_count ≤ max_claims_per_paper AND audited_count ≤ total_citation_count. - S-INV-3: When audited_count < total_citation_count, the finalizer MUST emit a paper-level [CLAIM-AUDIT-SAMPLED — k/N audited] annotation in the AI Self-Reflection Report appendix. - S-INV-4: audited_indices are strictly ascending (no duplicates, document order).

Lint rule 4c: validate audit_sampling_summaries[] against the inline schema + S-INV-1..S-INV-4. 4. Audit pipeline (6 steps): - Step 1 — Anchor presence check (D1, INV-6 firm rule). - Step 2 — Reference retrieval (apimanual_pdffailed/not_found). LOW-WARN on failed (D2 paywall). Sets ref_retrieval_method + carries retrieved_excerpt forward. - Step 3 — Cache lookup keyed by (claim_text_hash, ref_slug, anchor_kind, anchor_value_hash, retrieved_excerpt_hash, active_constraints_hash, judge_model). The active_constraints_hash is SHA-256 over the JCS-encoded set of manifest constraints applicable to this claim at audit time. Selection is scoped by (scoped_manifest_id, claim_id), NOT bare claim_id — per M-INV-1, cross-manifest C-001 collision is permitted, so selecting by bare claim_id would pick constraints from the wrong manifest. The set: top-level manifest_negative_constraints[] of the specific manifest whose manifest_id == scoped_manifest_id ∪ that manifest's claims[].negative_constraints[] entry whose claim_id matches the current claim, sorted by constraint_id. Lookup runs AFTER retrieval so the cached judgment is bound to the exact source text the judge will see, AND to the exact constraint set the judge would evaluate — if the user re-runs after uploading a manual PDF, correcting the corpus entry, OR adding/changing a negative constraint, the relevant hash changes and the cache miss forces fresh judging.

 **The cache stores only judge-verdict + source-bound fields, never run-local identifiers.** Cached fields: `judgment`, `audit_status`, `defect_stage`, `rationale`, `judge_model`, `judge_run_at`, `ref_retrieval_method`, `violated_constraint_id`. Excluded (must be rebuilt from current-run context on replay): `claim_id` (new manifest position), `audit_run_id` (new run), `upstream_owner_agent` (different emitting agent on a different draft), `upstream_dispute` (run-specific dispute log), `anchor_value` (already keyed but re-emitted from current marker). This separation is what allows a verdict for the same `(claim_text, ref, anchor, source-excerpt, constraint-set, judge_model)` to be reused across different drafts / manifests without misattribution.

 On hit: load cached judge-verdict + source-bound block; assemble a complete `claim_audit_result` by joining with current-run identifiers (current `claim_id`, current `audit_run_id`, current `upstream_owner_agent`). On miss: proceed to Step 4-5; write only the judge-verdict + source-bound block into the cache; emit the joined entry. The filesystem KV is `${ARS_CACHE_DIR}/claim_audit_v1/<cache_key_sha256>.json`. Cache-side metadata (mtime) lives on the filesystem, never inside the JSON body.

Judge prompt template (canonical form, embedded in agent prompt):

Given this claim from a paper draft and this excerpt from the cited reference, does the reference support the claim?

CLAIM: {claim_text} CITED REFERENCE EXCERPT: {retrieved_excerpt} ANCHOR KIND: {anchor_kind} ANCHOR VALUE: {anchor_value}

Output ONE of: - SUPPORTED — the reference directly supports the claim - UNSUPPORTED — the reference does NOT support the claim (the cited source says something different or contradictory) - AMBIGUOUS — the reference is related but does not clearly support or contradict the claim

Then output ONE SENTENCE rationale.

Format: JUDGMENT: <one-of>\nRATIONALE: <one sentence>

Negative-constraint judge prompt template (extended form):

Given this claim and the author's declared negative constraint, does the claim violate the constraint?

CLAIM: {claim_text} CONSTRAINT: {constraint_rule}

Output ONE of: VIOLATED, NOT_VIOLATED Then output ONE SENTENCE rationale.

VIOLATED → judgment=UNSUPPORTED, defect_stage=negative_constraint_violation, violated_constraint_id={constraint_id} per INV-8.

5. Orchestrator integration: pipeline_orchestrator_agent.md §3.6

New section "Claim-Faithfulness Audit Gate (v3.8)". Mirrors §3.5 Audit Artifact Gate structure but for claim-level audit.

Trigger boundary: Stage 4 → Stage 5 transition, in the same handoff slot as the v3.7.1 Cite-Time Provenance Finalizer. The audit dispatches AFTER the Cite-Time Provenance Finalizer pass (which resolves anchor-presence per v3.7.3 §3.1 and the 5-cell matrix) and BEFORE formatter_agent runs its hard gate at the start of Stage 5. This ordering mirrors §3.5 v3.6.7 audit gate (audit between deliverable completion and downstream consumption).

Why not Stage 5→6: formatter_agent's terminal hard gate runs during Stage 5 (per orchestrator §"Cite-Time Provenance Finalizer (v3.7.1)" — formatter consumes finalizer output and refuses on [UNVERIFIED CITATION ...]). If the claim audit dispatches at Stage 5→6, claim_audit_results[] would be produced after the terminal gate has already passed; HIGH-WARN-CLAIM-NOT-SUPPORTED could not block output. The Stage 4→5 slot is the only place where (a) the draft prose with v3.7.3 anchors exists, (b) the cite finalizer has run so anchor presence is settled, and (c) the formatter hard gate has NOT yet run.

The audit agent receives: - All in-text citations with their resolved <!--ref:slug ...--> + <!--anchor:...--> marker pairs (post-finalizer) - The claim_intent_manifests[] aggregate from the writing-stage agents - The literature_corpus[] aggregate (for retrieval)

Outputs feeding formatter hard gate (same Stage 5 pass): - claim_audit_results[] array (one per audited citation) — drives the 8-row matrix annotations - constraint_violations[] array (one per uncited-but-violates-MNC/NC sentence) — drives [HIGH-WARN-CONSTRAINT-VIOLATION-UNCITED ({violated_constraint_id})] annotation. MUST be passed to formatter alongside claim_audit_results[] — without this, uncited HIGH-WARN gate-refuse path silently disappears since no claim_audit_result row exists for uncited constraint violations (per §3.5 split). The formatter's REFUSE list per §1 deliverable 5 includes HIGH-WARN-CONSTRAINT-VIOLATION-UNCITED — this handoff is what makes that REFUSE check observable. - uncited_assertions[] array — drives [UNCITED-ASSERTION] LOW-WARN advisory annotation (formatter renders, does NOT refuse). - uncited_audit_failures[] array (v3.8.2 / #118 — one per uncited sentence × manifest where the constraint judge raised JudgeInvocationError) — drives [CLAIM-AUDIT-TOOL-FAILURE-UNCITED — <fault-class>] MED-WARN advisory annotation (formatter renders, does NOT refuse). Mirrors the cited-path INV-14 row but uses a dedicated aggregate per §3.6 because claim_audit_result.ref_slug is required. - claim_drifts[] array — drives [LOW-WARN-CLAIM-DRIFT — kind=...] LOW-WARN advisory annotation (formatter renders, does NOT refuse). - audit_sampling_summaries[] array — drives paper-level [CLAIM-AUDIT-SAMPLED — k/N audited] annotation when audited_count < total_citation_count (formatter renders in the AI Self-Reflection Report appendix; does NOT refuse). - Per-citation/per-sentence annotations injected adjacent to the existing v3.7.1 finalizer annotations (HIGH-WARN classes block; MED/LOW-WARN advisory passes).

Outputs feeding Stage 6 self-reflection: - Per-stage defect_stage histogram appendix (renders when ≥ 5 completed entries) — added to the existing Stage 6 AI Self-Reflection Report after gate pass

Finalizer matrix extension (8-row):

Existing v3.7.3 5-cell matrix (anchor presence + 4-cell trust state) gains a new finalizer pass that overlays per-citation audit annotations from claim_audit_results[]. The matrix discriminates the previously-conflated paywall vs anchorless cases by reading ref_retrieval_method alongside (judgment, defect_stage). Rows are evaluated top-to-bottom, first match wins:

judgment defect_stage ref_retrieval_method Annotation Severity Tier Gate behavior
SUPPORTED null (any) (no annotation) pass
AMBIGUOUS source_description / citation_anchor / synthesis_overclaim / null (any) [CLAIM-AUDIT-AMBIGUOUS] LOW-WARN advisory pass
UNSUPPORTED source_description / metadata / citation_anchor / synthesis_overclaim (any) [HIGH-WARN-CLAIM-NOT-SUPPORTED] HIGH-WARN gate-refuse
UNSUPPORTED negative_constraint_violation (any) [HIGH-WARN-NEGATIVE-CONSTRAINT-VIOLATION ({violated_constraint_id})] HIGH-WARN gate-refuse (per D4-a — explicit author rules are HIGH-WARN; drift findings emit into claim_drifts[] at LOW-WARN advisory tier — see below)
RETRIEVAL_FAILED retrieval_existence not_found [HIGH-WARN-FABRICATED-REFERENCE] HIGH-WARN gate-refuse (retrieval-side detection — the cited reference does not exist in the retrieval API; fabrication is a retrieval finding, not a bibliographic-metadata finding)
RETRIEVAL_FAILED not_applicable not_attempted [HIGH-WARN-CLAIM-AUDIT-ANCHORLESS — v3.7.3 R-L3-1-A VIOLATION REACHED AUDIT] HIGH-WARN gate-refuse (anchor=none should have been blocked by v3.7.3 finalizer; this row is a defense-in-depth surface against finalizer skip/stale paths)
RETRIEVAL_FAILED not_applicable failed [CLAIM-AUDIT-UNVERIFIED — REFERENCE FULL-TEXT NOT RETRIEVABLE] LOW-WARN advisory pass (paywall — D2)
RETRIEVAL_FAILED not_applicable audit_tool_failure [CLAIM-AUDIT-TOOL-FAILURE — <fault-class>] MED-WARN advisory pass (audit infrastructure failure after retrieval succeeded; surfaced distinctly from paywall per INV-14; retry next pipeline pass)

Why three rows for (RETRIEVAL_FAILED, not_applicable): anchor=none (INV-6/INV-11), paywall (INV-10), and audit-tool failure (INV-14) all emit this (judgment, defect_stage) pair, but mean three very different things — anchorless is a contract violation that already should have been gate-refused upstream by v3.7.3 (defense-in-depth row HIGH-WARN gate-refuse); paywall is a stable access restriction (legitimate tool/access failure, LOW-WARN advisory pass); audit_tool_failure is a transient infrastructure outage (judge timeout, retrieval 5xx, network error — MED-WARN advisory pass with retry-next-pass remediation). The ref_retrieval_method field discriminates them and their finalizer outcomes: not_attempted → HIGH-WARN gate-refuse; failed → LOW-WARN advisory; audit_tool_failure → MED-WARN advisory. INV-10 / INV-11 / INV-14 jointly enforce that these three are the only (not_applicable) paths AND they're mutually exclusive on ref_retrieval_method (each (not_applicable) entry MUST carry exactly one of the three method values).

uncited_assertion entries (separate aggregate uncited_assertions[]) emit at LOW-WARN tier with annotation [UNCITED-ASSERTION] next to the offending sentence. Always advisory; gate-refuse reserved for citation-level defects. See §3.3 for entry schema.

constraint_violation entries (separate aggregate constraint_violations[]) emit at HIGH-WARN tier with annotation [HIGH-WARN-CONSTRAINT-VIOLATION-UNCITED ({violated_constraint_id})] next to the offending sentence. Gate-refuse — explicit author MUST NOT rules block regardless of citation presence (parallels the gate-refuse behavior of cited constraint violations in the 8-row matrix; the entry-type split is purely a schema-integrity artifact, not a severity downgrade). The formatter hard gate MUST refuse output on this annotation alongside the four other HIGH-WARN classes (per §1 deliverable 5). See §3.5 for entry schema.

uncited_audit_failure entries (separate aggregate uncited_audit_failures[], v3.8.2 / #118) emit at MED-WARN advisory tier with annotation [CLAIM-AUDIT-TOOL-FAILURE-UNCITED — <fault-class>] next to the offending sentence. Always advisory; gate passes — retry on next pipeline pass is the remediation. Mirrors INV-14 semantics on the uncited path: a transient judge outage on a constraint check is operational signal distinct from a substantive non-violation. Pre-v3.8.2 behavior synthesised a NOT_VIOLATED verdict on JudgeInvocationError and silently suppressed HIGH-WARN constraint checks; v3.8.2 routes these failures through this aggregate so the operational signal surfaces without dropping audit coverage (option 4 — re-raise and abort — was rejected for that exact coverage reason). The formatter's REFUSE list is unchanged — UAF is advisory and does not enter REFUSE. See §3.6 for entry schema.

Why claim_drifts[] is separate from the 8-row matrix (per D4-a): The decision doc rejected "manifest authority blocking" because normal drafting routinely refines claims away from manifest, and gate-refusing on drift would block valid revision passes. The §3.4 schema houses drift findings; they emit a [LOW-WARN-CLAIM-DRIFT — kind={EMITTED_NOT_INTENDED|INTENDED_NOT_EMITTED}] annotation next to the offending sentence (for EMITTED_NOT_INTENDED) or in the manifest-coverage appendix (for INTENDED_NOT_EMITTED). Always advisory; never gate-refusing. Source-level defects (source_description / metadata / citation_anchor / synthesis_overclaim) remain HIGH-WARN in the matrix because they indicate the prose is misrepresenting the cited source — the L3 faithfulness failure the audit exists to catch. Constraint violations remain HIGH-WARN because the author explicitly declared "MUST NOT".

Uncited-assertion results emit at LOW-WARN tier with annotation [UNCITED-ASSERTION] next to the offending sentence. Always advisory; gate-refuse reserved for citation-level defects.

/ars-mark-read behavior: Does NOT acknowledge HIGH-WARN-CLAIM-NOT-SUPPORTED or HIGH-WARN-NEGATIVE-CONSTRAINT-VIOLATION. Remediation: user fixes the prose (re-cites, drops claim, revises). Mirrors v3.7.3 R-L3-1-A asymmetry (locator is structural, not evidence-state).

Mode flag: Audit agent dispatch is opt-in per pipeline run, configurable in academic-pipeline/SKILL.md mode flags. Default OFF for v3.8.0; ramp-on plan deferred to post-calibration calibration evidence.

6. Lint: scripts/check_claim_audit_consistency.py

Coverage:

  1. Schema validationclaim_audit_result.schema.json, claim_intent_manifest.schema.json, uncited_assertion.schema.json, claim_drift.schema.json, constraint_violation.schema.json all valid JSON Schema; sample passports validate. Inline audit_sampling_summary entry schema (§4 step 3) also validated.
  2. Cross-field invariants INV-1 through INV-18 — one test case per invariant, each with positive + negative fixture.
  3. Manifest invariants M-INV-1 through M-INV-4 — including M-INV-4 manifest_id uniqueness across passport (duplicate manifest_id rejected).
  4. Uncited-assertion invariants U-INV-1 through U-INV-4 — including cross-array (scoped_manifest_id, manifest_claim_id) integrity (uncited entry's referenced (M-, C-) pair must match some active manifest's claim). 4a. Claim-drift invariants D-INV-1 through D-INV-4 — including cross-array integrity for drift_kind=INTENDED_NOT_EMITTED (the (scoped_manifest_id, manifest_claim_id) pair must match some claim_intent_manifests[] entry) and exclusivity rule D-INV-4 (a sentence cannot appear in both uncited_assertions[] and claim_drifts[]). 4b. Constraint-violation invariants CV-INV-1 through CV-INV-4 — including cross-array integrity (CV-INV-2: (scoped_manifest_id, violated_constraint_id) MUST resolve in some active manifest entry; CV-INV-3: manifest_claim_id polarity must match constraint id prefix MNC/NC) and per-(manifest, sentence, constraint) dedup (CV-INV-4 — see §3.5 for the full dedup key shape (scoped_manifest_id, section_path, claim_text_hash, violated_constraint_id)). 4c. Audit-sampling invariants S-INV-1 through S-INV-4 — including audited_count/audited_indices coherence (S-INV-1), cap respect (S-INV-2), finalizer annotation requirement when sampled (S-INV-3), and audited_indices ascending uniqueness (S-INV-4). Validates audit_sampling_summaries[] aggregate. 4d. Uncited-audit-failure invariants UAF-INV-1 through UAF-INV-6 (v3.8.2 / #118) — schema validation against uncited_audit_failure.schema.json; finding_id uniqueness (UAF-INV-1); scoped_manifest_id cross-array integrity (UAF-INV-2); (scoped_manifest_id, manifest_claim_id) pair integrity (UAF-INV-3); per-(sentence, manifest) dedup with key (scoped_manifest_id, section_path, claim_text_hash) (UAF-INV-4); rationale fault_class prefix (UAF-INV-5); cross-aggregate exclusivity with constraint_violations[] (UAF-INV-6). Validates uncited_audit_failures[] aggregate.
  5. Allowed-matrix coverage — every (judgment, audit_status, defect_stage) triple outside §3.1 table rejected; representative disallowed combinations (≥ 5) tested explicitly.
  6. Precedence rules — negative_constraint_violation (HIGH-WARN claim_audit_result) > claim_drift (LOW-WARN claim_drifts[] entry) — per issue body rule 1; citation_anchor distinct from source_description (rule 2); uncited-sentence cases produce uncited_assertions[] entry, not a claim_audit_result row (rule 3 — uncited has no ref to evaluate). D-INV-4 also enforces: a sentence that's both uncited AND drifted emits only the uncited_assertion entry, no companion claim_drift entry. UAF-INV-6 (v3.8.2 / #118) enforces cross-aggregate exclusivity between uncited_audit_failures[] and constraint_violations[] — see §3.6 for the full rule.
  7. Acceptance check — for any passport with ≥ 1 completed claim_audit_result whose judgment=UNSUPPORTED, ALL such rows must emit a defect_stage ≠ null AND ≠ not_applicable (100% emission per #103 acceptance criterion). AMBIGUOUS-with-null is explicitly permitted per INV-3 (judge unable to classify a related-but-unclear support level is a valid outcome); the issue body's "100% non-SUPPORTED" intent is narrower than literal reading suggests — it targets the UNSUPPORTED rows because those are the gate-refusing path, not the advisory tier.
  8. Coverage check — sample passport with full 8-row finalizer matrix coverage (per §5); each annotation tier exercised at least once.

Lint exit codes: 0 (pass), 1 (one or more invariant violations; prints which + offending entry).

CI: invoked from .github/workflows/spec-consistency.yml (or matching workflow). Failure blocks merge.

7. TDD test plan

Tests written BEFORE production code per superpowers:test-driven-development. Order:

7.1 Schema validation tests (tests/test_claim_audit_schema.py)

7.2 Audit-pipeline unit tests (tests/test_claim_audit_pipeline.py)

7.3 Manifest tests (tests/test_claim_intent_manifest.py)

7.4 Uncited-assertion tests (tests/test_uncited_assertion.py)

7.5 Finalizer integration tests (tests/test_claim_audit_finalizer.py)

7.6 End-to-end test (tests/test_e2e_claim_audit.py)

Synthetic 5-citation paper: - Citation 1: real, SUPPORTED → no annotation - Citation 2: real, AMBIGUOUS → LOW-WARN - Citation 3: real but misused (source says inverse) → HIGH-WARN-CLAIM-NOT-SUPPORTED, gate refuses - Citation 4: paywalled, retrieval fails → LOW-WARN-UNVERIFIED, passes gate - Citation 5: violates declared negative constraint → HIGH-WARN-NEGATIVE-CONSTRAINT-VIOLATION, gate refuses

Test asserts: gate refuses output; only citations 3+5 are blockers; correcting them clears refusal.

7.7 Calibration mode test (tests/test_claim_audit_calibration.py)

Synthetic 20-tuple gold set covering alignment judgments (SUPPORTED/UNSUPPORTED/AMBIGUOUS/RETRIEVAL_FAILED) AND constraint judgments (VIOLATED/NOT_VIOLATED). Gold tuple shape per decision doc D3(c): {tuple_kind, claim_text, ref_text_excerpt, anchor, expected_judgment, ...} discriminated by tuple_kind ∈ {alignment, constraint}. For tuple_kind=constraint, REQUIRED fields: constraint_under_test_id AND (constraint_under_test_rule_text OR manifest_fixture_path). NOT_VIOLATED tuples MUST appear in the gold set (≥3 tuples; without them constraint FPR is unmeasurable). Suggested gold-set split: ~12 alignment tuples (covering 4 judgments) + ~8 constraint tuples (≥3 VIOLATED + ≥3 NOT_VIOLATED + remainder edge cases). Three-tier assertion:

Why three tiers: T-C2 catches infrastructure bugs (calibration script doesn't compute / doesn't write report); T-C3 catches gold-set authoring bugs (missing required rule text); T-C1 catches model/judge quality regression. All three must pass.

7.8 Regression test

Run existing 967+ test baseline (1107 unittest + 201 pytest adapters per session handoff). Zero regression required.

8. Cascade impact assessment

Files that may need touch:

File Why Risk
academic-pipeline/agents/pipeline_orchestrator_agent.md New §3.6 dispatch wiring HIGH — already 712 lines; PATTERN PROTECTION block must stay byte-equivalent
academic-paper/agents/formatter_agent.md Gate matrix extended to 8-row + HIGH-WARN classes MED — 785 lines; v3.7.3 anchor logic preserved
deep-research/agents/synthesis_agent.md New "Claim Intent Manifest Emission" sibling heading MED — 220 lines; v3.7.3 Three-Layer heading stays
deep-research/agents/report_compiler_agent.md Same MED
academic-paper/agents/draft_writer_agent.md Same MED — 520 lines
shared/contracts/passport/audit_artifact_entry.schema.json NO TOUCH (D5)
shared/contracts/material_passport* No root schema exists; aggregate referenced through orchestrator
shared/sprint_contract.schema.json (Schema 13.1) NO TOUCH (D6 zero-touch)
scripts/check_audit_artifact_consistency.py NO TOUCH (D5 — separate lint)
README.md + README.zh-TW.md v3.8 anchor + Zhao 2026 + RubricEM cite LOW
CHANGELOG.md v3.8 entry LOW
MODE_REGISTRY.md New mode flag for opt-in audit LOW

Boundary preservation lints (run as part of PR checks): - scripts/check_v3_6_7_pattern_protection.py — verify PATTERN PROTECTION blocks unchanged - git diff main..HEAD -- shared/sprint_contract.schema.json MUST be empty (v3.6.6 zero-touch) - git diff main..HEAD -- shared/contracts/passport/audit_artifact_entry.schema.json MUST be empty (D5)

9. Acceptance criteria

Issue body acceptance + decision-doc-derived additions:

10. Risks and open questions

The decision doc closed 8 OQs. Spec-level OQs (resolve during codex rounds, NOT before TDD):

11. Convergence cost projection

Per session handoff harness data: - doc-only PR = 1 round - plumbing PR = 3-4 rounds - new tool + IO PR = 5 rounds - scope-frozen follow-up = 3 rounds

103 is new tool + IO + new agent + 2 new schemas + new lint + 6 prompt edits, larger than #105 (which was "new tool + IO migration"). Expected codex rounds: 5-7.

Strategy: split into two PRs if Round-5 still has open P1/P2: - PR-A: schemas + lint + agent prompt + tests (no orchestrator/formatter/synthesis_agent touch yet) - PR-B: orchestrator §3.6 + finalizer 8-row + downstream agent integration

This mirrors #105 → #115 split pattern (production module first, integration follow-up).

12. Memory anchors

After ship, update:

13. Implementation order (TDD-driven)

  1. Write all 5 schema files up front (§3.1 claim_audit_result + §3.2 claim_intent_manifest + §3.3 uncited_assertion + §3.4 claim_drift + §3.5 constraint_violation) plus the inline audit_sampling_summary schema (§4 step 3). All five + inline are referenced by §6 schema-validation lint rule 1 + §7.1 schema tests; writing only §3.1/§3.2 first would make T-S1..T-S8 fail with "schema not found" diagnostics for the missing 3.
  2. Write tests/test_claim_audit_schema.py — failing because schema not yet validated by lint
  3. Write scripts/check_claim_audit_consistency.py — minimal code to pass schema tests
  4. Write tests/test_claim_audit_pipeline.py (T-P1..T-P11) — failing, no agent yet
  5. Write claim_ref_alignment_audit_agent.md Steps 1-6 — minimal text to pass pipeline tests via fixture-driven dispatch
  6. Write tests/test_uncited_assertion.py + token-rule detector module
  7. Write tests/test_claim_intent_manifest.py + emission helpers
  8. Write tests/test_claim_audit_finalizer.py + orchestrator §3.6 + formatter 8-row extension
  9. Write tests/test_e2e_claim_audit.py + synthetic 5-citation paper fixture
  10. Write tests/test_claim_audit_calibration.py + calibration protocol doc
  11. Regression run on full baseline; zero failures
  12. /simplify parallel (reuse + quality + efficiency); fix findings
  13. /codex review --base=main; iterate to 0 P1/P2
  14. gemini cross-model round (verify Codex 0.130 docs-heavy caveat first per feedback_codex_0_130_docs_review_broken.md)
  15. Public-repo boundary scan
  16. Squash merge

Steps 4-5 are the highest-risk: agent prompt + pipeline are the load-bearing intersection. Plan for 2-3 codex rounds focused there.