feat(integrity): #512 PDF read-integrity preflight for locally-extracted page anchors (#566)

* feat(integrity): #512 PDF read-integrity preflight for locally-extracted page anchors

Closes the local-extraction-channel gap between v3.7.3 locator presence
and the #182 existence gate: a page anchor derived from a silently
truncated/mispaginated PDF read passes every existing gate.

- scripts/pdf_read_preflight.py: three independent page-count signals
  (raw root /Count, own cycle-guarded /Kids walk, pypdf page list) ->
  PASS/FAIL/UNAVAILABLE JSON sidecar with file sha256 + parser warnings.
  pypdf-backed with the verify_submission_package ImportError precedent.
- R-L3-1-D firm rule in the three v3.7.3 emitters (+ agents/ mirrors):
  local-PDF page anchors require a PASS sidecar in context, else
  anchor:none or an independently-visible locator + explicit warning.
- claim_ref_alignment_audit_agent Step 4: precondition bound to
  ref_retrieval_method == manual_pdf, sidecars joined on ref_slug
  (sha256 confirmatory until #513), [pdf_read_integrity_unverified]
  advisory tag - never UNSUPPORTED on this basis alone.
- pipeline_orchestrator_agent §3.6: preflight once per locally-read
  corpus PDF, upstream of the writers; #528 content lock re-pinned.
- 15-test synthetic-PDF suite (in-test assembly, no binary fixtures).

Closes #512

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EA3EvegVqKrkM62u7k9PHF

* fix(integrity): #512 round-1 cross-model review closures (3 P1 + 2 P2)

- preflight: trailing-data-after-final-%%EOF veto (truncated incremental
  update otherwise PASSes on the older revision's agreeing counts);
  parser warnings survive early exits (appended in capture finally)
- orchestrator: preflight moved to Stage 1 corpus intake, independent of
  the opt-in audit mode (audit-gated preflight left default runs
  sidecar-less at R-L3-1-D, gate-refusing valid citations); #528 content
  lock re-pinned
- executable path: run_audit_pipeline(pdf_preflight_sidecars=...) tags
  manual_pdf page-anchor rows at the Step-6 emission point after cache
  resolution (cache hits cannot bypass; tag never enters cache body)
- finalizer: [LOW-WARN-PDF-READ-INTEGRITY-UNVERIFIED] advisory on
  SUPPORTED rows carrying the tag (content-based-fallback support no
  longer renders the advisory invisible)
- tests: preflight 15→18, +8 pipeline, +3 finalizer

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EA3EvegVqKrkM62u7k9PHF

* fix(integrity): #512 round-2 cross-model review closures (7 P1)

- preflight: xref-coverage cross-check (stale startxref pointing at a
  previous revision's xref with its own %%EOF now vetoes PASS); /Count
  must be a real integer object (float/string coercion rejected)
- R-L3-1-D: FAIL vs UNAVAILABLE split - positive truncation evidence
  refuses the page anchor; absence of verification (standalone dispatch,
  no-Python installs, unpreflighted files) is an explicit-warning
  advisory, never a manufactured refusal (mirrors detection-vs-
  terminality precedent)
- pipeline: retrieve_fn receives pdf_preflight_verdict on page-anchor
  citations so passage selection can go content-based BEFORE the judge
  reads a page-scoped passage; freshness (sha256 re-check) documented as
  the orchestrator's contract
- orchestrator: sha256 re-check before dispatch; cross-runtime coverage
  (standalone deep-research/academic-paper dispatch, skipped Stage 1);
  tagged-SUPPORTED row added to the operational finalizer matrix;
  content lock re-pinned
- tests: preflight 18→20, pipeline +1 (verdict-passing contract)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EA3EvegVqKrkM62u7k9PHF

* fix(integrity): #512 round-3 cross-model review closures (2 P1)

- xref-coverage: redefined-object variant caught - the newest raw copy
  of every directly-stored object must be the copy the active xref
  chain references (calibration guard skips offset-shifted files
  rather than mass-flagging)
- trailing-data predicate uses ISO 32000 PDF whitespace: NUL padding
  after %%EOF passes, vertical tab is data
- tests: preflight 20→23

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EA3EvegVqKrkM62u7k9PHF

* fix(integrity): #512 round-4 cross-model review closure (1 P1)

Object-header scan recognizes bare-CR line boundaries (ISO 32000 permits
CR-only line endings; Python's (?m)^ does not treat CR as a line start,
blinding both xref-coverage checks on CR-only files). Tests 23→25.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EA3EvegVqKrkM62u7k9PHF

* fix(integrity): #512 round-5 cross-model review closures (2 P1)

- header scan boundary/separator class extended to full ISO 32000
  whitespace (NUL-preceded replacement headers are seen)
- compressed-object variant: a direct raw replacement of an object whose
  active copy lives in an object stream, appended after its container
  with a stale startxref, vetoes PASS; a raw copy before the container
  (legitimate superseded-into-objstm update) stays clean
- new ObjStm + cross-reference-stream fixture; tests 25→28

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EA3EvegVqKrkM62u7k9PHF

* fix(integrity): #512 round-6 cross-model review closure (1 P1)

Header scan accepts ten-digit object numbers (\d{1,9} blinded the
coverage checks to replacements with object IDs >= 1e9). Tests 28→29.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EA3EvegVqKrkM62u7k9PHF

* fix(integrity): #512 round-7 cross-model review closure (1 P1)

Header-scan separators implement the full ISO 32000 lexer model:
%-comments-to-EOL are token separators, so comment-obfuscated headers
(e.g. '2 0%note\nobj') no longer hide from the coverage checks. This
closes the lexical-variant family structurally rather than per-case.
Tests 29→30.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EA3EvegVqKrkM62u7k9PHF

* fix(integrity): #512 round-8 cross-model review closure (1 P1)

Header-scan numeric tokens implement the full ISO 32000 integer form:
optional sign and leading-zero padding (pypdf coerces via int(), so
'+2 0 obj' / '00000000002 0 obj' are valid headers). Together with r7's
separator model, both halves of the lexer are now structurally complete.
Tests 30→31.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EA3EvegVqKrkM62u7k9PHF

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Edward Cheng-I Wu
2026-07-20 06:50:06 +08:00
committed by GitHub
parent 640d8384f9
commit 7f878f7ad2
16 changed files with 1194 additions and 8 deletions
+4
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -437,11 +437,12 @@ Anchor kinds (closed enum):
Full example: `Smith (2024) <!--ref:smith2024--><!--anchor:page:14-->`.
Three firm rules:
Four firm rules:
- **R-L3-1-A (production-mandatory locator):** During drafting, every visible citation MUST carry an anchor with `<kind>``none`. The finalizer treats `<!--anchor:none:-->` as MED-WARN-NO-LOCATOR (gate-refused). Emitting `none` does NOT bypass the gate — it triggers it. Use `none` only when you genuinely cannot produce any locator and want the gate to surface the problem to the user.
- **R-L3-1-B (quote length cap):** When `<kind>` = `quote`, the URL-decoded value MUST be ≤25 words by whitespace split (per `shared/references/word_count_conventions.md`). Quotes exceeding 25 words MUST be replaced by `page` or `section` locator.
- **R-L3-1-C (no anchor reading by emitting agents):** Generate the `<!--anchor:...-->` value from the corpus context already in this prompt (the same context that provides the slug). You MUST NOT read entry frontmatter to discover anchor candidates — that breaks the v3.6.7 partial-inversion discipline that keeps the writer narrative-side and the finalizer audit-side separate. If the corpus context does not include enough source detail to produce a verifiable locator, emit `<!--anchor:none:-->` and let the gate surface it.
- **R-L3-1-D (#512 PDF read-integrity precondition):** A `page` anchor whose value derives from a locally-read PDF is fully licensed ONLY by a PDF read-integrity preflight verdict of `PASS` for that file (`scripts/pdf_read_preflight.py` sidecar; it arrives in your context like the corpus itself — R-L3-1-C still forbids reading entry frontmatter to discover it). Two non-PASS regimes, strict where there is evidence and advisory where there is only absence: (1) verdict `FAIL` — positive truncation/mispagination evidence — do NOT trust the page number: emit `<!--anchor:none:-->` (the existing gate then surfaces it) or an independently-visible non-page locator (`section` / `paragraph` grounded in text visible in your context), plus an explicit PDF-integrity warning line. (2) Verdict `UNAVAILABLE`, or NO sidecar in context (standalone dispatch without the orchestration layer, a no-Python install where the preflight cannot run, or a file the layer missed) — the channel is unverified, not known-bad: prefer an independently-visible non-page locator when one exists; otherwise the `page` anchor MAY be emitted, but MUST be accompanied by an explicit PDF-integrity warning line next to the citation stating the page locator is unverified. Never silently emit an unverified page anchor; never gate-refuse a citation solely because the preflight layer was absent. Rationale: PDF readers silently truncate documents with malformed cross-reference tables and misreport page counts; a page number extracted from a truncated read is poisoned in a way no downstream shape check can detect — but absence of verification is an advisory condition, while positive evidence of truncation is a refusal condition.
URL-encoding for `quote:` values uses standard percent-encoding (`%20` for space, `%2C` for comma, `%3A` for colon, etc.) **AND additionally percent-encodes any consecutive run of two or more hyphen characters: `--` MUST be written as `%2D%2D`** (and `---` as `%2D%2D%2D`, etc.). Standard RFC 3986 encoding treats `-` as an unreserved character and does NOT encode it, but a quote containing `--` (e.g., from an em-dash, a divider, or a nested HTML comment opener) would leave a literal `--` in the anchor value that prematurely closes the HTML comment. A single hyphen between word characters (e.g., `AI-generated`, `well-known`) is safe and may remain raw. Always percent-encode space, comma, colon, AND any consecutive-hyphen run. Never rely on the absence of `-->` in the quoted text. v3.7.3 gemini review F1 + codex round-6 F15 closure (prompt-vs-lint alignment).
@@ -163,6 +163,8 @@ Use `anchor_value` to locate the relevant passage inside `retrieved_excerpt`:
The located passage is what the judge sees. If `quote` mode fails to locate the exact substring, fall back to passing the full retrieved excerpt with a `[anchor_quote_unlocated]` rationale tag — do NOT mark the citation UNSUPPORTED on a locator miss alone.
**PDF read-integrity precondition for `page` anchors (#512):** applies to rows whose Step 2 `ref_retrieval_method` is `manual_pdf` — the machine-readable "locally-read PDF" signal; do NOT re-infer the channel from prose context. For those rows, the orchestrator supplies `scripts/pdf_read_preflight.py` sidecars keyed by `ref_slug`; the sidecar's `sha256` is confirmatory when the corpus entry's `source_pointer` resolves to a hashable file, not the primary join key (until the #513 read-ledger lands, no anchor-side field carries a file hash to match against). Treat the page number as a trustworthy retrieval scope only when the row's sidecar verdict is `PASS`. On a missing sidecar or a `FAIL` / `UNAVAILABLE` verdict, do NOT mark the citation UNSUPPORTED on this basis alone: locate the passage by content instead of by the untrusted page number, and tag the audit row's rationale with `[pdf_read_integrity_unverified]` so the finding surfaces downstream (advisory — terminality stays with the existing formatter-gate machinery). In the executable pipeline this is enforced in code, not prose: `run_audit_pipeline(pdf_preflight_sidecars=...)` (`scripts/claim_audit_pipeline.py`, keyed by `ref_slug`) appends the tag at the Step-6 emission point for every completed `manual_pdf` page-anchor row without a `PASS` sidecar — after cache resolution, so cache hits cannot bypass it — and the finalizer surfaces `[LOW-WARN-PDF-READ-INTEGRITY-UNVERIFIED]` on SUPPORTED rows carrying the tag (`scripts/claim_audit_finalizer.py`), so the advisory is visible even when content-based fallback finds support. Rationale: PDF readers silently truncate documents with malformed cross-reference tables; a page number from a truncated or mispaginated read can look perfectly well-formed.
### Step 5 — Judge invocation
The judge is invoked ONCE per citation with both the alignment question and the active-constraints set in the same call. The unified contract produces a single verdict in `{SUPPORTED, UNSUPPORTED, AMBIGUOUS, PARTIAL, VIOLATED}` so the pipeline can dispatch on it without a second round-trip.
@@ -429,6 +429,7 @@ The cost is multiplicative: a 10-stage pipeline with cross-model enabled produce
- All in-text citations with their resolved `<!--ref:slug ...-->` + `<!--anchor:...-->` marker pairs (post-finalizer)
- The `claim_intent_manifests[]` aggregate from the writing-stage agents (per spec §3.2 + the v3.8 "Claim Intent Manifest Emission" sibling sections on `synthesis_agent` / `draft_writer_agent` / `report_compiler_agent`)
- The `literature_corpus[]` aggregate (retrieval input)
- **PDF read-integrity sidecars (#512).** The orchestrator runs `python scripts/pdf_read_preflight.py <pdf> --output <sidecar>.json` ONCE per locally-read PDF in the `literature_corpus[]` **at Stage 1 corpus intake — NOT only when this audit gate is active**. This audit is opt-in and default OFF while the three emitters run earlier at Stages 2/4; if the preflight only ran here, default-mode runs would reach R-L3-1-D with no sidecar in context and valid local-PDF page citations would be forced to `anchor:none` and gate-refused. Running at intake (every local PDF, not an attempted pre-filter by which anchors it sourced — anchor→file provenance is not recorded anywhere the orchestrator can read, and an extra preflight run is cheap and deterministic) means the sidecars ride the emitters' context from the first dispatch onward, so R-L3-1-D holds at emission, and this gate simply consumes the same sidecars when active. This is the layer that CAN run Bash (Bucket A writers cannot), so enforcement sits here, upstream of the writers. Pass the sidecars keyed by `ref_slug` (verdict `PASS`/`FAIL`/`UNAVAILABLE` + file `sha256` + declared/enumerated/reader page counts; the hash is confirmatory, and the natural #513 join key later): `PASS` licenses page-scoped retrieval; `FAIL`/`UNAVAILABLE`/missing routes the row to the `[pdf_read_integrity_unverified]` advisory path. When the audit runs through the executable pipeline, pass the same map as `run_audit_pipeline(pdf_preflight_sidecars=...)` so the tag is applied on the executable path too, cache hits included. **Freshness:** before every dispatch that consumes sidecars, re-hash each PDF and compare against its sidecar `sha256`; on mismatch (file replaced since intake) re-run the preflight — a stale `PASS` must never license new bytes. **Coverage beyond this pipeline:** when the emitters are dispatched standalone by `deep-research` / `academic-paper`, or Stage 1 is skipped for user-supplied research, whatever layer ingests local PDFs runs the same preflight before the first emitter dispatch; where no layer can run it (e.g. a no-Python install), R-L3-1-D's no-sidecar regime applies (advisory warning, never a refusal manufactured by the missing layer).
- **The Stage 4 draft sentence stream — all uncited sentences with `sentence_text` + `section_path` + optional `adjacent_text`** (the surrounding 13 clauses for context). Required for the §4 step 5 stream (d) `constraint_violations[]` HIGH-WARN path (any uncited sentence whose scope matches an MNC/NC rule + judge returns VIOLATED) AND the §4 step 6 `uncited_assertions[]` LOW-WARN advisory path. Without this stream the `[HIGH-WARN-CONSTRAINT-VIOLATION-UNCITED]` gate-refuse annotation cannot fire for author-declared MUST-NOT violations that carry no citation. See `claim_ref_alignment_audit_agent.md` Input contract for the full schema.
**Outputs feeding formatter hard gate (same Stage 5 pass).**
@@ -447,10 +448,11 @@ The cost is multiplicative: a 10-stage pipeline with cross-model enabled produce
- Per-stage `defect_stage` histogram appendix (renders when ≥5 completed entries via `scripts/claim_audit_finalizer.py:render_stage6_histogram`) — added to the existing Stage 6 AI Self-Reflection Report after gate pass.
**Finalizer matrix (8-row).** 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. Spec source-of-truth: §5. Implementation: `scripts/claim_audit_finalizer.py:classify_claim_audit_result`.
**Finalizer matrix (8-row + one #512 conditional).** 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. Spec source-of-truth: §5 (+ the #512 spec for the tagged-SUPPORTED row). Implementation: `scripts/claim_audit_finalizer.py:classify_claim_audit_result`.
| `judgment` | `defect_stage` | `ref_retrieval_method` | Annotation | Severity Tier | Gate behavior |
|---|---|---|---|---|---|
| SUPPORTED, rationale contains `[pdf_read_integrity_unverified]` (#512) | `null` | (any) | `[LOW-WARN-PDF-READ-INTEGRITY-UNVERIFIED]` | LOW-WARN | pass |
| SUPPORTED | `null` | (any) | (no annotation) | — | pass |
| AMBIGUOUS | source_description / citation_anchor / synthesis_overclaim / null | (any) | `[CLAIM-AUDIT-AMBIGUOUS]` | LOW-WARN | pass |
| UNSUPPORTED | source_description / metadata / citation_anchor / synthesis_overclaim | (any) | `[HIGH-WARN-CLAIM-NOT-SUPPORTED]` | HIGH-WARN | gate-refuse |
+2 -1
View File
@@ -243,11 +243,12 @@ Anchor kinds (closed enum):
Full example: `Smith (2024) <!--ref:smith2024--><!--anchor:page:14-->`.
Three firm rules:
Four firm rules:
- **R-L3-1-A (production-mandatory locator):** During compilation, every visible citation MUST carry an anchor with `<kind>``none`. The finalizer treats `<!--anchor:none:-->` as MED-WARN-NO-LOCATOR (gate-refused). Emitting `none` does NOT bypass the gate — it triggers it. Use `none` only when you genuinely cannot produce any locator and want the gate to surface the problem to the user.
- **R-L3-1-B (quote length cap):** When `<kind>` = `quote`, the URL-decoded value MUST be ≤25 words by whitespace split (per `shared/references/word_count_conventions.md`). Quotes exceeding 25 words MUST be replaced by `page` or `section` locator.
- **R-L3-1-C (no anchor reading by emitting agents):** Generate the `<!--anchor:...-->` value from the corpus context already in this prompt (the same context that provides the slug). You MUST NOT read entry frontmatter to discover anchor candidates — that breaks the v3.6.7 partial-inversion discipline that keeps the compiler narrative-side and the finalizer audit-side separate. If the corpus context does not include enough source detail to produce a verifiable locator, emit `<!--anchor:none:-->` and let the gate surface it.
- **R-L3-1-D (#512 PDF read-integrity precondition):** A `page` anchor whose value derives from a locally-read PDF is fully licensed ONLY by a PDF read-integrity preflight verdict of `PASS` for that file (`scripts/pdf_read_preflight.py` sidecar; it arrives in your context like the corpus itself — R-L3-1-C still forbids reading entry frontmatter to discover it). Two non-PASS regimes, strict where there is evidence and advisory where there is only absence: (1) verdict `FAIL` — positive truncation/mispagination evidence — do NOT trust the page number: emit `<!--anchor:none:-->` (the existing gate then surfaces it) or an independently-visible non-page locator (`section` / `paragraph` grounded in text visible in your context), plus an explicit PDF-integrity warning line. (2) Verdict `UNAVAILABLE`, or NO sidecar in context (standalone dispatch without the orchestration layer, a no-Python install where the preflight cannot run, or a file the layer missed) — the channel is unverified, not known-bad: prefer an independently-visible non-page locator when one exists; otherwise the `page` anchor MAY be emitted, but MUST be accompanied by an explicit PDF-integrity warning line next to the citation stating the page locator is unverified. Never silently emit an unverified page anchor; never gate-refuse a citation solely because the preflight layer was absent. Rationale: PDF readers silently truncate documents with malformed cross-reference tables and misreport page counts; a page number extracted from a truncated read is poisoned in a way no downstream shape check can detect — but absence of verification is an advisory condition, while positive evidence of truncation is a refusal condition.
URL-encoding for `quote:` values uses standard percent-encoding (`%20` for space, `%2C` for comma, `%3A` for colon, etc.) **AND additionally percent-encodes any consecutive run of two or more hyphen characters: `--` MUST be written as `%2D%2D`** (and `---` as `%2D%2D%2D`, etc.). Standard RFC 3986 encoding treats `-` as an unreserved character and does NOT encode it, but a quote containing `--` (e.g., from an em-dash, a divider, or a nested HTML comment opener) would leave a literal `--` in the anchor value that prematurely closes the HTML comment. A single hyphen between word characters (e.g., `AI-generated`, `well-known`) is safe and may remain raw. Always percent-encode space, comma, colon, AND any consecutive-hyphen run. Never rely on the absence of `-->` in the quoted text. v3.7.3 gemini review F1 + codex round-6 F15 closure (prompt-vs-lint alignment).
+2 -1
View File
@@ -289,11 +289,12 @@ Anchor kinds (closed enum):
Full example: `Smith (2024) <!--ref:smith2024--><!--anchor:page:14-->`.
Three firm rules:
Four firm rules:
- **R-L3-1-A (production-mandatory locator):** During synthesis emission, every visible citation MUST carry an anchor with `<kind>``none`. The finalizer treats `<!--anchor:none:-->` as MED-WARN-NO-LOCATOR (gate-refused). Emitting `none` does NOT bypass the gate — it triggers it. Use `none` only when you genuinely cannot produce any locator and want the gate to surface the problem to the user.
- **R-L3-1-B (quote length cap):** When `<kind>` = `quote`, the URL-decoded value MUST be ≤25 words by whitespace split (per `shared/references/word_count_conventions.md`). Quotes exceeding 25 words MUST be replaced by `page` or `section` locator.
- **R-L3-1-C (no anchor reading by emitting agents):** Generate the `<!--anchor:...-->` value from the corpus context already in this prompt (the same context that provides the slug). You MUST NOT read entry frontmatter to discover anchor candidates — that breaks the v3.6.7 partial-inversion discipline that keeps the agent narrative-side and the finalizer audit-side separate. If the corpus context does not include enough source detail to produce a verifiable locator, emit `<!--anchor:none:-->` and let the gate surface it.
- **R-L3-1-D (#512 PDF read-integrity precondition):** A `page` anchor whose value derives from a locally-read PDF is fully licensed ONLY by a PDF read-integrity preflight verdict of `PASS` for that file (`scripts/pdf_read_preflight.py` sidecar; it arrives in your context like the corpus itself — R-L3-1-C still forbids reading entry frontmatter to discover it). Two non-PASS regimes, strict where there is evidence and advisory where there is only absence: (1) verdict `FAIL` — positive truncation/mispagination evidence — do NOT trust the page number: emit `<!--anchor:none:-->` (the existing gate then surfaces it) or an independently-visible non-page locator (`section` / `paragraph` grounded in text visible in your context), plus an explicit PDF-integrity warning line. (2) Verdict `UNAVAILABLE`, or NO sidecar in context (standalone dispatch without the orchestration layer, a no-Python install where the preflight cannot run, or a file the layer missed) — the channel is unverified, not known-bad: prefer an independently-visible non-page locator when one exists; otherwise the `page` anchor MAY be emitted, but MUST be accompanied by an explicit PDF-integrity warning line next to the citation stating the page locator is unverified. Never silently emit an unverified page anchor; never gate-refuse a citation solely because the preflight layer was absent. Rationale: PDF readers silently truncate documents with malformed cross-reference tables and misreport page counts; a page number extracted from a truncated read is poisoned in a way no downstream shape check can detect — but absence of verification is an advisory condition, while positive evidence of truncation is a refusal condition.
URL-encoding for `quote:` values uses standard percent-encoding (`%20` for space, `%2C` for comma, `%3A` for colon, etc.) **AND additionally percent-encodes any consecutive run of two or more hyphen characters: `--` MUST be written as `%2D%2D`** (and `---` as `%2D%2D%2D`, etc.). Standard RFC 3986 encoding treats `-` as an unreserved character and does NOT encode it, but a quote containing `--` (e.g., from an em-dash, a divider, or a nested HTML comment opener) would leave a literal `--` in the anchor value that prematurely closes the HTML comment. A single hyphen between word characters (e.g., `AI-generated`, `well-known`) is safe and may remain raw. Always percent-encode space, comma, colon, AND any consecutive-hyphen run. Never rely on the absence of `-->` in the quoted text. v3.7.3 gemini review F1 + codex round-6 F15 closure (prompt-vs-lint alignment).
@@ -243,11 +243,12 @@ Anchor kinds (closed enum):
Full example: `Smith (2024) <!--ref:smith2024--><!--anchor:page:14-->`.
Three firm rules:
Four firm rules:
- **R-L3-1-A (production-mandatory locator):** During compilation, every visible citation MUST carry an anchor with `<kind>``none`. The finalizer treats `<!--anchor:none:-->` as MED-WARN-NO-LOCATOR (gate-refused). Emitting `none` does NOT bypass the gate — it triggers it. Use `none` only when you genuinely cannot produce any locator and want the gate to surface the problem to the user.
- **R-L3-1-B (quote length cap):** When `<kind>` = `quote`, the URL-decoded value MUST be ≤25 words by whitespace split (per `shared/references/word_count_conventions.md`). Quotes exceeding 25 words MUST be replaced by `page` or `section` locator.
- **R-L3-1-C (no anchor reading by emitting agents):** Generate the `<!--anchor:...-->` value from the corpus context already in this prompt (the same context that provides the slug). You MUST NOT read entry frontmatter to discover anchor candidates — that breaks the v3.6.7 partial-inversion discipline that keeps the compiler narrative-side and the finalizer audit-side separate. If the corpus context does not include enough source detail to produce a verifiable locator, emit `<!--anchor:none:-->` and let the gate surface it.
- **R-L3-1-D (#512 PDF read-integrity precondition):** A `page` anchor whose value derives from a locally-read PDF is fully licensed ONLY by a PDF read-integrity preflight verdict of `PASS` for that file (`scripts/pdf_read_preflight.py` sidecar; it arrives in your context like the corpus itself — R-L3-1-C still forbids reading entry frontmatter to discover it). Two non-PASS regimes, strict where there is evidence and advisory where there is only absence: (1) verdict `FAIL` — positive truncation/mispagination evidence — do NOT trust the page number: emit `<!--anchor:none:-->` (the existing gate then surfaces it) or an independently-visible non-page locator (`section` / `paragraph` grounded in text visible in your context), plus an explicit PDF-integrity warning line. (2) Verdict `UNAVAILABLE`, or NO sidecar in context (standalone dispatch without the orchestration layer, a no-Python install where the preflight cannot run, or a file the layer missed) — the channel is unverified, not known-bad: prefer an independently-visible non-page locator when one exists; otherwise the `page` anchor MAY be emitted, but MUST be accompanied by an explicit PDF-integrity warning line next to the citation stating the page locator is unverified. Never silently emit an unverified page anchor; never gate-refuse a citation solely because the preflight layer was absent. Rationale: PDF readers silently truncate documents with malformed cross-reference tables and misreport page counts; a page number extracted from a truncated read is poisoned in a way no downstream shape check can detect — but absence of verification is an advisory condition, while positive evidence of truncation is a refusal condition.
URL-encoding for `quote:` values uses standard percent-encoding (`%20` for space, `%2C` for comma, `%3A` for colon, etc.) **AND additionally percent-encodes any consecutive run of two or more hyphen characters: `--` MUST be written as `%2D%2D`** (and `---` as `%2D%2D%2D`, etc.). Standard RFC 3986 encoding treats `-` as an unreserved character and does NOT encode it, but a quote containing `--` (e.g., from an em-dash, a divider, or a nested HTML comment opener) would leave a literal `--` in the anchor value that prematurely closes the HTML comment. A single hyphen between word characters (e.g., `AI-generated`, `well-known`) is safe and may remain raw. Always percent-encode space, comma, colon, AND any consecutive-hyphen run. Never rely on the absence of `-->` in the quoted text. v3.7.3 gemini review F1 + codex round-6 F15 closure (prompt-vs-lint alignment).
+2 -1
View File
@@ -289,11 +289,12 @@ Anchor kinds (closed enum):
Full example: `Smith (2024) <!--ref:smith2024--><!--anchor:page:14-->`.
Three firm rules:
Four firm rules:
- **R-L3-1-A (production-mandatory locator):** During synthesis emission, every visible citation MUST carry an anchor with `<kind>``none`. The finalizer treats `<!--anchor:none:-->` as MED-WARN-NO-LOCATOR (gate-refused). Emitting `none` does NOT bypass the gate — it triggers it. Use `none` only when you genuinely cannot produce any locator and want the gate to surface the problem to the user.
- **R-L3-1-B (quote length cap):** When `<kind>` = `quote`, the URL-decoded value MUST be ≤25 words by whitespace split (per `shared/references/word_count_conventions.md`). Quotes exceeding 25 words MUST be replaced by `page` or `section` locator.
- **R-L3-1-C (no anchor reading by emitting agents):** Generate the `<!--anchor:...-->` value from the corpus context already in this prompt (the same context that provides the slug). You MUST NOT read entry frontmatter to discover anchor candidates — that breaks the v3.6.7 partial-inversion discipline that keeps the agent narrative-side and the finalizer audit-side separate. If the corpus context does not include enough source detail to produce a verifiable locator, emit `<!--anchor:none:-->` and let the gate surface it.
- **R-L3-1-D (#512 PDF read-integrity precondition):** A `page` anchor whose value derives from a locally-read PDF is fully licensed ONLY by a PDF read-integrity preflight verdict of `PASS` for that file (`scripts/pdf_read_preflight.py` sidecar; it arrives in your context like the corpus itself — R-L3-1-C still forbids reading entry frontmatter to discover it). Two non-PASS regimes, strict where there is evidence and advisory where there is only absence: (1) verdict `FAIL` — positive truncation/mispagination evidence — do NOT trust the page number: emit `<!--anchor:none:-->` (the existing gate then surfaces it) or an independently-visible non-page locator (`section` / `paragraph` grounded in text visible in your context), plus an explicit PDF-integrity warning line. (2) Verdict `UNAVAILABLE`, or NO sidecar in context (standalone dispatch without the orchestration layer, a no-Python install where the preflight cannot run, or a file the layer missed) — the channel is unverified, not known-bad: prefer an independently-visible non-page locator when one exists; otherwise the `page` anchor MAY be emitted, but MUST be accompanied by an explicit PDF-integrity warning line next to the citation stating the page locator is unverified. Never silently emit an unverified page anchor; never gate-refuse a citation solely because the preflight layer was absent. Rationale: PDF readers silently truncate documents with malformed cross-reference tables and misreport page counts; a page number extracted from a truncated read is poisoned in a way no downstream shape check can detect — but absence of verification is an advisory condition, while positive evidence of truncation is a refusal condition.
URL-encoding for `quote:` values uses standard percent-encoding (`%20` for space, `%2C` for comma, `%3A` for colon, etc.) **AND additionally percent-encodes any consecutive run of two or more hyphen characters: `--` MUST be written as `%2D%2D`** (and `---` as `%2D%2D%2D`, etc.). Standard RFC 3986 encoding treats `-` as an unreserved character and does NOT encode it, but a quote containing `--` (e.g., from an em-dash, a divider, or a nested HTML comment opener) would leave a literal `--` in the anchor value that prematurely closes the HTML comment. A single hyphen between word characters (e.g., `AI-generated`, `well-known`) is safe and may remain raw. Always percent-encode space, comma, colon, AND any consecutive-hyphen run. Never rely on the absence of `-->` in the quoted text. v3.7.3 gemini review F1 + codex round-6 F15 closure (prompt-vs-lint alignment).
@@ -0,0 +1,140 @@
# #512 — PDF read-integrity preflight for locally-extracted page/quote anchors
**Date:** 2026-07-20 · **Issue:** #512 · **Status:** implemented in the same PR
## Problem
The v3.7.3 Three-Layer Citation Emission guards locator *presence* and the #182 gate guards
citation *existence*, but nothing guards the **local extraction channel** those locators come
from. PDF readers silently truncate documents with malformed cross-reference tables and
misreport page counts; a real, correctly-cited source can then acquire an apparently valid
`page` anchor derived from a truncated or mispaginated read and pass every existing gate
(the emitters anchor in good faith from poisoned context; the v3.7.3 lint checks anchor
shape, not faithfulness; the #182 gate reduces anchors to a kind-only boolean).
Provenance: mechanism observed in kengo006/alexandria (page-tree `/Count` cross-check before
trusting page numbers); dual-track in-repo verification (2026-07-11) confirmed the gap.
## Design
Two layers, enforcement upstream of the writers (Bucket A agents cannot run Bash):
### Layer 1 — `scripts/pdf_read_preflight.py`
Stdlib CLI + `pypdf` for object plumbing, following the repo's existing
`verify_submission_package.py` precedent (`try: import pypdf / except ImportError: pypdf =
None`; CI installs it via `requirements-dev.txt`, local runs without it degrade). Not "grep
the first `/Count`": pypdf's xref machinery covers classic tables, xref streams, `/Prev`
incremental-update chains, and object streams; the script then computes **three independent
page-count signals** on top of it:
1. `declared_page_count` — the root page tree's `/Count`, read from the raw `/Root → /Pages`
object (not from pypdf's page list).
2. `enumerated_page_count` — a recursive walk of `/Kids`, counting `/Type /Page` leaves,
with a visited-set cycle guard and a node budget.
3. `reader_page_count``len(reader.pages)` (pypdf's own flattening), as a third opinion.
Parser warnings are captured from the `pypdf` logger (repair chatter is exactly the
"silently repaired xref" signal the issue names) and recorded.
**Trailing-data check** (cross-model review round 1, P1): a PDF truncated partway through
an incremental update keeps an OLDER valid `%%EOF`; pypdf silently reads that previous
revision, so all three counts agree on the OLD page tree — the exact truncation case the
preflight exists to catch. Non-whitespace bytes after the LAST `%%EOF` are that
signature: recorded as a `trailing-data` warning and a PASS veto. Complete incremental
updates always end with their own `%%EOF`, so legitimate multi-revision files pass.
**Verdict** (single enum, mirrors the repo's PASS-posture vocabulary):
| Verdict | Condition |
|---|---|
| `PASS` | all three counts agree, count > 0, no captured parser warnings, no trailing data after the final `%%EOF` |
| `FAIL` | parse completed but the counts disagree — the truncation/mispagination signal |
| `UNAVAILABLE` | anything preventing a confident parse: unreadable/missing file, encryption, missing/malformed page tree, cycle or node-budget hit, pypdf not installed, count agreement but parser-repair warnings or trailing data present |
Parser warnings captured before a structural failure survive every early exit (they are
appended in the capture handler's `finally`), so a repair warning that preceded a later
encryption/tree error still reaches the sidecar.
`UNAVAILABLE` (not `FAIL`) on repair warnings with agreeing counts: a repaired read may
still be complete, but the preflight cannot vouch for it — and only `PASS` licenses a page
anchor downstream, so the conservative bucket is the honest one.
**Sidecar** — JSON to stdout or `--output`; shape (`schema: "pdf_read_preflight/1"`):
```json
{
"schema": "pdf_read_preflight/1",
"verdict": "PASS | FAIL | UNAVAILABLE",
"file": "<path as given>",
"sha256": "<file hash, null when unreadable>",
"declared_page_count": 12,
"enumerated_page_count": 12,
"reader_page_count": 12,
"warnings": ["<pypdf/parser warnings, structural notes>"],
"generated_at": "<UTC ISO-8601>",
"tool": "pdf_read_preflight/<version>"
}
```
Exit code 0 whenever a verdict was produced (the verdict is data, not an error); 2 on usage
errors only — so orchestration can always consume the JSON without exit-code branching.
### Layer 2 — prompt rules
- **Three emitters** (`synthesis_agent`, `draft_writer_agent`, `report_compiler_agent`): a
`PDF Read-Integrity Precondition (#512)` rule appended inside the existing
`## Three-Layer Citation Emission (v3.7.3)` section — a `page` anchor whose value derives
from a locally-read PDF may be emitted only when the orchestration layer supplied a
preflight `PASS` for that file; on `FAIL`/`UNAVAILABLE` (or no sidecar), emit
`anchor:none` (the existing precedence-zero NO-LOCATOR machinery then surfaces it) or an
independently-visible non-page locator, plus an explicit PDF-integrity warning line. The
R-L3-1-C no-frontmatter-reads inversion is untouched: the sidecar verdict arrives in
context like the corpus itself.
- **`claim_ref_alignment_audit_agent`** (the Stage 4→5 L3 audit): the precondition binds to
the existing Step 2 `ref_retrieval_method == manual_pdf` discriminator (the machine-readable
"locally-read PDF" signal), not a re-inferred prose test. Sidecars join on `ref_slug`; the
sidecar `sha256` is confirmatory only — until #513's read ledger lands, no anchor-side field
carries a file hash, so a hash cannot be the primary key. Non-`PASS` or missing sidecar
becomes the `[pdf_read_integrity_unverified]` advisory rationale tag (never an UNSUPPORTED
verdict on this basis alone — terminality stays with the existing formatter gate machinery).
- **Executable audit path** (cross-model review round 1, P1): the prose rule alone never
executes in `scripts/claim_audit_pipeline.py`. `run_audit_pipeline` gains
`pdf_preflight_sidecars: dict[ref_slug → sidecar] | None``None` (unwired caller) is
byte-equivalent legacy; a provided map tags every completed `manual_pdf` page-anchor row
without a `PASS` sidecar at the single Step-6 emission point, AFTER cache resolution, so a
cache hit cannot bypass the check and the tag never enters the cached judge body. The tag
is appended (INV-6/INV-14 `startswith` contracts untouched) within the rationale budget.
`claim_audit_finalizer.classify_claim_audit_result` surfaces
`[LOW-WARN-PDF-READ-INTEGRITY-UNVERIFIED]` (advisory, never gate-refuse) on SUPPORTED rows
carrying the tag — otherwise the expected common case (content-based fallback finds
support) would render the advisory invisible at the formatter.
- **`pipeline_orchestrator_agent`** (the layer that CAN run Bash): run the preflight once per
locally-read PDF in the `literature_corpus[]` **at Stage 1 corpus intake, independent of
audit mode** (cross-model review round 1, P1: the Stage 4→5 audit is opt-in default OFF
while the emitters run earlier — an audit-gated preflight would leave default-mode runs
sidecar-less at R-L3-1-D, forcing valid local-PDF page citations to `anchor:none` and a
gate refusal). Deliberately NOT "only PDFs that sourced a page anchor": anchor→file
provenance is not recorded anywhere the orchestrator can read, an extra preflight is cheap
and deterministic, and the audit side narrows via `manual_pdf`. Sidecars ride the emitters'
and audit contexts keyed by `ref_slug`. This file is one of the five #528 content-locked
surfaces — the `CONTENT_LOCKS` hash in `scripts/check_pipeline_boundary_semantics.py` is
updated in the same commit per that lint's documented procedure.
## Out of scope (deliberate, from the issue)
- Extending `check_v3_7_3_three_layer_citation.py` (it lints emitted Markdown, not PDFs).
- Quote-accuracy verification against source text (the L3 claim-audit channel, tracked
separately).
- A passport schema aggregate. The sidecar is a file-level retrieval artifact; if #513
(`read_scope` ledger) lands, the sidecar's `sha256` + verdict are the natural join keys,
and naming here follows `citation_provenance.schema.json` precedent for that future join.
## Test plan
`scripts/test_pdf_read_preflight.py` (auto-discovered by `pytest.yml`'s `pytest scripts/`),
synthetic in-test PDFs (no binary fixtures): flat valid PDF (PASS), nested page tree (PASS,
enumeration exercises recursion), lying root `/Count` (FAIL), truncated tail (UNAVAILABLE or
FAIL, never PASS), encrypted marker (UNAVAILABLE), page-tree cycle (UNAVAILABLE via guard),
non-PDF bytes (UNAVAILABLE), missing file (UNAVAILABLE), pypdf absent (monkeypatched →
UNAVAILABLE with `pypdf-not-installed` warning), sidecar shape + hash stability, exit codes.
+1 -1
View File
@@ -71,7 +71,7 @@ REPO_ROOT = Path(__file__).resolve().parent.parent
# ---------------------------------------------------------------------------
CONTENT_LOCKS = {
"academic-pipeline/SKILL.md": "69c1a8bfe01ab252b13fe9685e778591d3078fd82a0d6d84763f1bae1d3ff510",
"academic-pipeline/agents/pipeline_orchestrator_agent.md": "10c34f1edb3e157d68c656a42dff420f74dbd8fc7eacca7e2f4688b06482bdff",
"academic-pipeline/agents/pipeline_orchestrator_agent.md": "9c9cdc58ded9f63e855c6d37697a4e02650f45b6f93c1f337b032cafd986cd99",
"academic-pipeline/agents/state_tracker_agent.md": "6528349959ae9ef9c126bab3117a57da894e9517104a83a0d1d7ad74daf36d5f",
"academic-pipeline/references/pipeline_state_machine.md": "d61ac458e39d8bd7f8ad0c85b01887a1ffc5657578b883af15989e2677d60e89",
"academic-pipeline/references/process_summary_protocol.md": "5c7053230d73b39d0a5d9d6f5e9f339c12570ae6d3aa2eae2eaf74f51d571e94",
+15
View File
@@ -50,6 +50,11 @@ TIER_HIGH_WARN = "high_warn"
# Changing a literal here MUST coordinate with the formatter prose update.
# ---------------------------------------------------------------------------
# #512 PDF read-integrity: pipeline-side tag substring (keep in lockstep with
# claim_audit_pipeline.PDF_READ_INTEGRITY_TAG) + the advisory annotation it drives.
PDF_READ_INTEGRITY_TAG = "[pdf_read_integrity_unverified]"
ANNOTATION_LOW_WARN_PDF_READ_INTEGRITY = "[LOW-WARN-PDF-READ-INTEGRITY-UNVERIFIED]"
ANNOTATION_CLAIM_AUDIT_AMBIGUOUS = "[CLAIM-AUDIT-AMBIGUOUS]"
ANNOTATION_HIGH_WARN_CLAIM_NOT_SUPPORTED = "[HIGH-WARN-CLAIM-NOT-SUPPORTED]"
ANNOTATION_HIGH_WARN_NEGATIVE_CONSTRAINT_VIOLATION = (
@@ -168,6 +173,16 @@ def classify_claim_audit_result(entry: dict[str, Any]) -> dict[str, Any]:
rationale = entry.get("rationale", "")
if judgment == "SUPPORTED":
# #512: a SUPPORTED row that reached support through an unverified local-PDF
# page anchor still surfaces an advisory — without this branch the pipeline's
# rationale tag would be invisible at the formatter for the expected common
# case (content-based fallback finds support). Advisory only, never a gate.
if PDF_READ_INTEGRITY_TAG in rationale:
return {
"annotation": ANNOTATION_LOW_WARN_PDF_READ_INTEGRITY,
"tier": TIER_LOW_WARN,
"gate_refuse": False,
}
return {"annotation": None, "tier": TIER_NONE, "gate_refuse": False}
if judgment == "AMBIGUOUS":
+70 -1
View File
@@ -75,6 +75,31 @@ _RATIONALE_TRUNC_MARK = "…[truncated]"
# Widest fault-class prefix across both exception families ("retrieval_network_error: ").
_WIDEST_FAULT_PREFIX = "retrieval_network_error: "
# #512 PDF read-integrity rationale tag. Appended (never prepended — INV-6/INV-14
# startswith contracts stay intact) to a completed manual_pdf page-anchor row whose
# preflight sidecar is missing or non-PASS. The finalizer keys its advisory
# annotation on this exact substring; keep in lockstep with
# claim_audit_finalizer.PDF_READ_INTEGRITY_TAG.
PDF_READ_INTEGRITY_TAG = "[pdf_read_integrity_unverified]"
def _tag_pdf_read_integrity(entry: dict[str, Any]) -> None:
"""#512: mark a manual_pdf page-anchor row whose preflight did not vouch.
Applied at the single Step-6 emission point AFTER cache resolution, so a cache
hit cannot bypass it (the tag is run-context, never written into the judge
cache body). Idempotent; respects the rationale maxLength budget."""
rationale = entry.get("rationale") or ""
if PDF_READ_INTEGRITY_TAG in rationale:
return
if rationale:
clamped = _clamp_to_rationale_budget(
rationale, reserved=len(PDF_READ_INTEGRITY_TAG) + 1
)
entry["rationale"] = f"{clamped} {PDF_READ_INTEGRITY_TAG}"
else:
entry["rationale"] = PDF_READ_INTEGRITY_TAG
def _clamp_to_rationale_budget(text: str, *, reserved: int) -> str:
"""Clamp `text` so that `reserved + len(result)` fits the rationale maxLength.
@@ -979,6 +1004,7 @@ def run_audit_pipeline(
cache: dict[str, Any] | None = None,
uncited_sentences: list[dict[str, Any]] | None = None,
all_uncited_sentences: list[dict[str, Any]] | None = None,
pdf_preflight_sidecars: dict[str, dict[str, Any]] | None = None,
) -> dict[str, list[dict[str, Any]]]:
"""Run §4 Step 1-6 + manifest set-diff over caller-supplied inputs.
@@ -1016,6 +1042,23 @@ def run_audit_pipeline(
- `all_uncited_sentences[]`: `sentence_text` + `section_path` only.
Constraint judging does not consult `trigger_tokens`.
`pdf_preflight_sidecars` (#512): optional map of `ref_slug` →
`pdf_read_preflight/1` sidecar dict (see `scripts/pdf_read_preflight.py`).
`None` (default) = the caller has not wired the preflight layer legacy
byte-equivalent behavior, no tagging. When provided (even empty), every
completed `manual_pdf` row with `anchor_kind == "page"` whose sidecar is
missing or non-`PASS` gets `PDF_READ_INTEGRITY_TAG` appended to its
rationale at the Step-6 emission point, cache hit or not. Additionally,
page-anchor citations handed to `retrieve_fn` carry a
`pdf_preflight_verdict` key (`PASS`/`FAIL`/`UNAVAILABLE`/`MISSING`):
retrieve_fn implementations SHOULD locate the passage by content rather
than by the page scope when the verdict is not `PASS` the judge must
never be fed a passage selected by an untrusted page number. Sidecar
freshness is the CALLER's contract: this function does no file I/O, so
the orchestrator re-hashes each PDF against its sidecar `sha256` before
dispatch and re-runs the preflight on mismatch (a stale `PASS` from a
replaced file must not license the new bytes).
Returns:
dict with six aggregate arrays keyed by passport-aggregate name:
claim_audit_results, uncited_assertions, claim_drifts,
@@ -1154,8 +1197,20 @@ def run_audit_pipeline(
# surface as INV-14 retrieval_* audit_tool_failure rows instead of
# aborting the pass (Step 13 R2 codex P2 finding, symmetric to the
# R1 _invoke_judge wrapper).
# #512 r2: when the preflight layer is wired, page-anchor citations carry
# the sidecar verdict INTO retrieval so implementations can locate the
# passage by content instead of the untrusted page scope BEFORE the judge
# sees it — tagging after the fact cannot fix a judge that already read
# the wrong passage. Copy-on-write; API-path retrieve_fns ignore the key.
retrieval_citation = citation
if pdf_preflight_sidecars is not None and anchor_kind == "page":
_sc = pdf_preflight_sidecars.get(citation["ref_slug"])
retrieval_citation = dict(citation)
retrieval_citation["pdf_preflight_verdict"] = (
_sc.get("verdict") if _sc else "MISSING"
)
try:
retrieval = _invoke_retrieve(retrieve_fn, citation)
retrieval = _invoke_retrieve(retrieve_fn, retrieval_citation)
except RetrievalInvocationError as ret_err:
entry = _retrieval_failure_entry(
citation,
@@ -1270,6 +1325,20 @@ def run_audit_pipeline(
judge_model=judge_model,
)
entry["scoped_manifest_id"] = written_scope
# #512 PDF read-integrity precondition, executable path. Sits AFTER cache
# resolution so hits and fresh invocations are tagged identically (codex
# #512 P1: a cache hit must not bypass the check). `None` = caller has not
# wired the preflight layer → byte-equivalent legacy behavior; a provided
# dict (even empty) means the orchestrator ran the layer, so a missing or
# non-PASS sidecar for a manual_pdf page-anchor row is tagged.
if (
pdf_preflight_sidecars is not None
and method == "manual_pdf"
and anchor_kind == "page"
):
sidecar = pdf_preflight_sidecars.get(citation["ref_slug"])
if not (sidecar and sidecar.get("verdict") == "PASS"):
_tag_pdf_read_integrity(entry)
claim_audit_results.append(entry)
# Precedence rule 1: cited constraint violation absorbs the drift signal.
+372
View File
@@ -0,0 +1,372 @@
"""PDF read-integrity preflight (#512).
Guards the LOCAL EXTRACTION CHANNEL behind v3.7.3 `page` anchors: PDF readers silently
truncate documents with malformed cross-reference tables and misreport page counts, so a
real, correctly-cited source can acquire an apparently valid page locator derived from a
truncated or mispaginated read and pass every downstream gate (the v3.7.3 lint checks
anchor shape, the #182 gate reduces anchors to a kind-only boolean). This preflight is run
at the orchestration/retrieval layer (never by Bucket A writer agents, which cannot run
Bash) BEFORE page numbers from a locally-read PDF are trusted as anchor values.
Mechanism (observed in kengo006/alexandria, reshaped per the #512 dual-track review):
three independent page-count signals must agree
1. declared_page_count the root page tree's /Count, read from the raw object;
2. enumerated_page_count this script's own recursive /Kids walk counting /Type /Page
leaves (cycle-guarded, node-budgeted);
3. reader_page_count pypdf's flattened page list, as a third opinion.
Verdict: PASS only when all three agree, the count is positive, and the parse emitted no
repair warnings. FAIL when the parse completed but counts disagree (the truncation /
mispagination signal itself). UNAVAILABLE for anything the preflight cannot vouch for:
unreadable or missing file, encryption, missing/malformed page tree, a /Kids cycle or
node-budget hit, pypdf absent, or parser-repair warnings even with agreeing counts (a
repaired read may be complete, but only PASS licenses a page anchor downstream, so the
conservative bucket is the honest one).
Object plumbing rides pypdf (already a repo dependency; `verify_submission_package.py`
precedent), which handles classic xref tables, xref streams, /Prev incremental-update
chains, and object streams this is deliberately NOT a "grep the first /Count" check.
CLI: `python scripts/pdf_read_preflight.py FILE [--output SIDECAR.json]`. Exit 0 whenever
a verdict was produced (the verdict is data, not an error; orchestration consumes the
JSON without exit-code branching); exit 2 on usage errors only.
Design: docs/design/2026-07-20-512-pdf-read-preflight-spec.md.
"""
from __future__ import annotations
import argparse
import io
import json
import logging
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
try:
from audit_snapshot import sha256_hex
except ImportError: # pragma: no cover - dual-path import (verify_submission_package precedent)
from scripts.audit_snapshot import sha256_hex
try:
import pypdf
except ImportError: # degrade to UNAVAILABLE, mirroring verify_submission_package.py
pypdf = None
TOOL_VERSION = "pdf_read_preflight/1.0.0"
SCHEMA = "pdf_read_preflight/1"
# Hard ceiling on page-tree nodes visited by the enumeration walk. Real documents sit
# far below this; hitting it means a pathological or adversarial tree we must not vouch
# for (and must not spin on).
NODE_BUDGET = 50_000
PASS, FAIL, UNAVAILABLE = "PASS", "FAIL", "UNAVAILABLE"
class _WarningCollector(logging.Handler):
"""Captures pypdf's parser chatter — repair messages ARE the silent-xref-repair
signal this preflight exists to surface."""
def __init__(self):
super().__init__(level=logging.WARNING)
self.messages: list[str] = []
def emit(self, record):
self.messages.append(record.getMessage())
class _TreeProblem(Exception):
"""Structural page-tree problem that forecloses a confident enumeration."""
def _kid_key(kid):
"""Stable identity for a /Kids entry (indirect ref when available)."""
ref = getattr(kid, "indirect_reference", None) or (
kid if hasattr(kid, "idnum") else None
)
if ref is not None:
return ("ref", ref.idnum, ref.generation)
return ("id", id(kid))
def _walk_page_tree(node, visited, budget):
"""Count /Type /Page leaves under `node`, guarding cycles and runaway trees."""
count = 0
stack = [node]
while stack:
if len(visited) > budget:
raise _TreeProblem("page-tree node budget exceeded")
current = stack.pop()
key = _kid_key(current)
if key in visited:
raise _TreeProblem("page-tree cycle detected")
visited.add(key)
obj = current.get_object() if hasattr(current, "get_object") else current
node_type = str(obj.get("/Type", ""))
if node_type == "/Page":
count += 1
elif node_type == "/Pages":
kids = obj.get("/Kids", [])
stack.extend(kids)
else:
raise _TreeProblem(f"unexpected page-tree node type {node_type or '(none)'}")
return count
def run_preflight(path) -> dict:
"""Run the read-integrity preflight on one PDF; always returns a sidecar dict."""
path = Path(path)
result = {
"schema": SCHEMA,
"verdict": UNAVAILABLE,
"file": str(path),
"sha256": None,
"declared_page_count": None,
"enumerated_page_count": None,
"reader_page_count": None,
"warnings": [],
"generated_at": datetime.now(timezone.utc).isoformat(),
"tool": TOOL_VERSION,
}
warnings = result["warnings"]
try:
data = path.read_bytes()
except OSError as exc:
warnings.append(f"unreadable: {exc}")
return result
result["sha256"] = sha256_hex(data)
# Structural check independent of the parser: a PDF truncated partway through an
# incremental update keeps an OLDER valid %%EOF, and pypdf silently reads that
# previous revision — all three counts then agree on the OLD page tree, which would
# PASS the exact truncation case this preflight exists to catch (codex #512 P1).
# Non-whitespace bytes after the LAST %%EOF are that signature: record the warning
# now, veto PASS at the verdict step. A complete incremental update always ends
# with its own %%EOF, so legitimate multi-revision files are not flagged.
# PDF whitespace per ISO 32000 §7.2.2 — NOT Python's: NUL is whitespace (common
# padding after %%EOF, must not veto), vertical tab 0x0B is NOT (r3 P1).
_PDF_WS = b"\x00\x09\x0a\x0c\x0d\x20"
trailing_ok = True
eof_at = data.rfind(b"%%EOF")
if eof_at != -1 and data[eof_at + 5 :].translate(None, _PDF_WS):
trailing_ok = False
warnings.append(
f"trailing-data: {len(data) - (eof_at + 5)} bytes after the final %%EOF "
"include non-whitespace content (possible truncated incremental update)"
)
if pypdf is None:
warnings.append("pypdf-not-installed: preflight cannot parse the document")
return result
collector = _WarningCollector()
pypdf_logger = logging.getLogger("pypdf")
pypdf_logger.addHandler(collector)
try:
try:
reader = pypdf.PdfReader(io.BytesIO(data)) # bytes already in hand for the hash
except Exception as exc: # malformed beyond pypdf's tolerance
warnings.append(f"parse-error: {exc}")
return result
if getattr(reader, "is_encrypted", False):
warnings.append("encrypted: preflight cannot verify an encrypted document")
return result
try:
root = reader.trailer["/Root"].get_object()
pages_node = root["/Pages"]
pages_obj = pages_node.get_object()
raw_count = pages_obj["/Count"]
# Require an actual PDF integer object. `int()` would coerce a float
# /Count 2.7 to 2 (or a text string "2") and then agree with two real
# leaves — a malformed page tree must be UNAVAILABLE, not PASS (r2 P1).
# pypdf NumberObject subclasses int; FloatObject subclasses float.
if isinstance(raw_count, bool) or not isinstance(raw_count, int):
warnings.append(
f"page-tree-unresolvable: /Count is not an integer object "
f"({type(raw_count).__name__}: {raw_count!r})"
)
return result
declared = int(raw_count)
except Exception as exc:
warnings.append(f"page-tree-unresolvable: {exc}")
return result
result["declared_page_count"] = declared
try:
enumerated = _walk_page_tree(pages_node, set(), NODE_BUDGET)
except Exception as exc: # incl. _TreeProblem — same degradation either way
warnings.append(f"page-tree-walk: {exc}")
return result
result["enumerated_page_count"] = enumerated
# The walk above verified the /Kids tree is cycle-free, so flattening the same
# tree cannot spin.
try:
reader_count = len(reader.pages)
except Exception as exc:
warnings.append(f"reader-page-list: {exc}")
return result
result["reader_page_count"] = reader_count
# Xref-coverage check (r2 P1): a malformed incremental update can append new
# objects PLUS a syntactically complete startxref that still points at the
# PREVIOUS revision's xref, followed by its own %%EOF — the trailing-data
# check then sees nothing after the final %%EOF while pypdf silently reads
# the old revision. Cross-check: every raw `N M obj` header in the file must
# be an object number the parsed xref chain knows about. An unreferenced
# object number = a revision the active xref chain cannot see. (Offsets are
# deliberately not compared — pypdf normalizes them; object-number coverage
# is the stable signal. Best-effort: if pypdf's xref internals are absent,
# skip rather than crash.)
try:
xref_map = getattr(reader, "xref", None)
if isinstance(xref_map, dict) and xref_map:
known_objs = set()
for gen_table in xref_map.values():
if isinstance(gen_table, dict):
known_objs.update(gen_table.keys())
compressed = getattr(reader, "xref_objStm", None)
if isinstance(compressed, dict):
known_objs.update(compressed.keys())
# Header token separators implement the FULL ISO 32000 lexer model,
# not Python's \s and not just whitespace: PDF permits bare-CR line
# endings (r4 P1), treats NUL as whitespace (r5 P1), and treats
# %-comments-to-end-of-line as token separators (r7 P1) — so
# `2 0%note\nobj` is a valid header. Anything the PDF lexer accepts
# as a separator must not hide a header from the coverage checks.
# Numeric tokens carry the full ISO 32000 integer form too (r8 P1):
# an optional sign and any leading-zero padding are valid and
# accepted by pypdf's int() coercion, so `+2 0 obj` or
# `00000000002 0 obj` must not hide from the scan either.
_ws = rb"[\x00\t\n\x0c\r ]"
_sep = rb"(?:" + _ws + rb"|%[^\r\n]*[\r\n])"
_num = rb"[+-]?0*\d{1,10}"
raw_offsets: dict[int, list[int]] = {}
for m in re.finditer(
rb"(?:^|" + _sep + rb")" + _sep + rb"*(" + _num + rb")" + _sep + rb"+" + _num + _sep + rb"+obj\b",
data,
):
raw_offsets.setdefault(int(m.group(1)), []).append(m.start(1))
orphaned = set(raw_offsets) - {int(n) for n in known_objs}
if orphaned:
warnings.append(
"xref-coverage: object number(s) "
f"{sorted(orphaned)[:5]} present in the file but absent from "
"the active xref chain (possible stale startxref / "
"unreachable newer revision)"
)
trailing_ok = False
# Redefined-object variant (r3 P1): a malformed update can append a
# REPLACEMENT body for an existing object number plus a stale
# startxref — number-membership alone then sees no orphan while
# pypdf reads the old copy. The newest raw copy of every directly-
# stored object must be the one the active chain references.
# Calibration guard: pypdf applies a global delta when a file has
# junk before %PDF; if NO active offset matches any raw offset the
# comparison is uncalibrated — skip rather than mass-flag.
direct_offsets = {}
for gen_table in xref_map.values():
if isinstance(gen_table, dict):
for objnum, off in gen_table.items():
if isinstance(off, int) and int(objnum) in raw_offsets:
direct_offsets[int(objnum)] = off
if direct_offsets and any(
off in raw_offsets[n] for n, off in direct_offsets.items()
):
superseded = sorted(
n
for n, off in direct_offsets.items()
if max(raw_offsets[n]) > off
)
if superseded:
warnings.append(
"xref-coverage: later unreferenced revision(s) of object "
f"number(s) {superseded[:5]} exist after the copy the "
"active xref chain references (possible stale startxref)"
)
trailing_ok = False
# Compressed-object variant (r5 P1): the active copy of N lives
# inside an object stream (no direct offset in reader.xref), so the
# loop above never inspects it — but a direct raw replacement of N
# appended AFTER its container, with a stale startxref, is exactly
# the unreachable-newer-revision case. A raw copy BEFORE the
# container is the legitimate superseded-into-objstm update and is
# not flagged.
if isinstance(compressed, dict):
compressed_superseded = []
for objnum, ref in compressed.items():
n = int(objnum)
if n not in raw_offsets or n in direct_offsets:
continue
container = ref[0] if isinstance(ref, (tuple, list)) and ref else None
container_off = None
if container is not None:
for gen_table in xref_map.values():
if (
isinstance(gen_table, dict)
and container in gen_table
and isinstance(gen_table[container], int)
):
container_off = gen_table[container]
break
if container_off is not None and max(raw_offsets[n]) > container_off:
compressed_superseded.append(n)
if compressed_superseded:
warnings.append(
"xref-coverage: direct replacement(s) of compressed object "
f"number(s) {sorted(compressed_superseded)[:5]} appear after "
"their object-stream container (possible stale startxref)"
)
trailing_ok = False
except Exception as exc: # best-effort cross-check, never a crash path
warnings.append(f"xref-coverage-skipped: {exc}")
finally:
pypdf_logger.removeHandler(collector)
# Append captured parser chatter HERE so every early return above (encryption,
# unresolvable tree, walk problems) still carries it — the repair warning that
# preceded a later structural error is part of the sidecar contract too.
warnings.extend(f"pypdf: {m}" for m in collector.messages)
if not (declared == enumerated == reader_count):
result["verdict"] = FAIL
return result
if declared <= 0:
warnings.append("empty-page-tree: agreeing counts but zero pages")
return result
if collector.messages or not trailing_ok:
# Counts agree, but the parse needed repair or the file carries data after its
# final %%EOF — cannot vouch, per the spec.
return result
result["verdict"] = PASS
return result
def main(argv=None) -> int:
parser = argparse.ArgumentParser(
description="PDF read-integrity preflight (#512): PASS/FAIL/UNAVAILABLE sidecar "
"for page-anchor trust decisions."
)
parser.add_argument("pdf", help="path to the locally-read PDF")
parser.add_argument(
"--output",
help="write the JSON sidecar here instead of stdout",
)
args = parser.parse_args(argv)
sidecar = json.dumps(run_preflight(args.pdf), indent=2, ensure_ascii=False)
if args.output:
Path(args.output).write_text(sidecar + "\n", encoding="utf-8")
else:
print(sidecar)
return 0
if __name__ == "__main__":
sys.exit(main())
+36
View File
@@ -939,5 +939,41 @@ class TUAFFinalizerRouting(unittest.TestCase):
self.assertIn(expected, annotations)
# T-512 — SUPPORTED row carrying the PDF read-integrity tag surfaces the advisory.
class T512PdfReadIntegrityAdvisory(unittest.TestCase):
def test_supported_with_tag_gets_low_warn_advisory(self) -> None:
from scripts.claim_audit_finalizer import (
ANNOTATION_LOW_WARN_PDF_READ_INTEGRITY,
PDF_READ_INTEGRITY_TAG,
)
out = classify_claim_audit_result(
_result(
judgment="SUPPORTED",
defect_stage=None,
ref_retrieval_method="manual_pdf",
rationale=f"supported via content match {PDF_READ_INTEGRITY_TAG}",
)
)
self.assertEqual(out["annotation"], ANNOTATION_LOW_WARN_PDF_READ_INTEGRITY)
self.assertEqual(out["tier"], "low_warn")
self.assertFalse(out["gate_refuse"])
def test_supported_without_tag_unchanged(self) -> None:
out = classify_claim_audit_result(
_result(judgment="SUPPORTED", defect_stage=None, ref_retrieval_method="manual_pdf")
)
self.assertIsNone(out["annotation"])
def test_tag_constants_in_lockstep_with_pipeline(self) -> None:
from scripts.claim_audit_finalizer import PDF_READ_INTEGRITY_TAG as fin_tag
from scripts.claim_audit_pipeline import PDF_READ_INTEGRITY_TAG as pipe_tag
self.assertEqual(fin_tag, pipe_tag)
if __name__ == "__main__": # pragma: no cover
unittest.main()
+105
View File
@@ -2289,5 +2289,110 @@ class TP360NonStringJudgeRationale(_PipelineTestBase):
self.assertEqual(self._validate_passport(out, [manifest]), [])
# ---------------------------------------------------------------------------
# T-512 — PDF read-integrity tag on manual_pdf page-anchor rows (#512).
# ---------------------------------------------------------------------------
class T512PdfReadIntegrityTag(_PipelineTestBase):
"""#512: completed manual_pdf page-anchor rows are tagged when the preflight
sidecar is missing or non-PASS; cache hits cannot bypass; None = legacy."""
@staticmethod
def _manual_pdf(citation: dict[str, Any]) -> dict[str, Any]:
return {"ref_retrieval_method": "manual_pdf", "retrieved_excerpt": "uploaded excerpt"}
def _tag(self) -> str:
from scripts.claim_audit_pipeline import PDF_READ_INTEGRITY_TAG
return PDF_READ_INTEGRITY_TAG
def test_missing_sidecar_tags_rationale(self) -> None:
out = self.run_pipeline(
citations=[_citation()], retrieve_fn=self._manual_pdf, pdf_preflight_sidecars={}
)
self.assertIn(self._tag(), out["claim_audit_results"][0]["rationale"])
def test_pass_sidecar_not_tagged(self) -> None:
out = self.run_pipeline(
citations=[_citation()],
retrieve_fn=self._manual_pdf,
pdf_preflight_sidecars={"smith2024preprints": {"verdict": "PASS"}},
)
self.assertNotIn(self._tag(), out["claim_audit_results"][0]["rationale"])
def test_fail_and_unavailable_sidecars_tagged(self) -> None:
for verdict in ("FAIL", "UNAVAILABLE"):
with self.subTest(verdict=verdict):
out = self.run_pipeline(
citations=[_citation()],
retrieve_fn=self._manual_pdf,
pdf_preflight_sidecars={"smith2024preprints": {"verdict": verdict}},
)
self.assertIn(self._tag(), out["claim_audit_results"][0]["rationale"])
def test_none_param_is_legacy_untagged(self) -> None:
out = self.run_pipeline(citations=[_citation()], retrieve_fn=self._manual_pdf)
self.assertNotIn(self._tag(), out["claim_audit_results"][0]["rationale"])
def test_api_rows_never_tagged(self) -> None:
out = self.run_pipeline(citations=[_citation()], pdf_preflight_sidecars={})
self.assertNotIn(self._tag(), out["claim_audit_results"][0]["rationale"])
def test_non_page_anchor_never_tagged(self) -> None:
out = self.run_pipeline(
citations=[_citation(anchor_kind="quote", anchor_value="verbatim%20text")],
retrieve_fn=self._manual_pdf,
pdf_preflight_sidecars={},
)
self.assertNotIn(self._tag(), out["claim_audit_results"][0]["rationale"])
def test_cache_hit_cannot_bypass_tag(self) -> None:
cache: dict[str, Any] = {}
first = self.run_pipeline(
citations=[_citation()],
retrieve_fn=self._manual_pdf,
cache=cache,
pdf_preflight_sidecars={},
)
self.assertEqual(len(cache), 1)
second = self.run_pipeline(
citations=[_citation()],
retrieve_fn=self._manual_pdf,
cache=cache,
pdf_preflight_sidecars={},
)
for out in (first, second):
self.assertIn(self._tag(), out["claim_audit_results"][0]["rationale"])
# Tag never contaminates the cached judge body (run-context only).
(cached,) = cache.values()
self.assertNotIn(self._tag(), str(cached))
def test_retrieve_fn_receives_preflight_verdict(self) -> None:
seen: list[Any] = []
def spy_retrieve(citation: dict[str, Any]) -> dict[str, Any]:
seen.append(citation.get("pdf_preflight_verdict"))
return {"ref_retrieval_method": "manual_pdf", "retrieved_excerpt": "x"}
self.run_pipeline(
citations=[_citation()],
retrieve_fn=spy_retrieve,
pdf_preflight_sidecars={"smith2024preprints": {"verdict": "FAIL"}},
)
self.run_pipeline(
citations=[_citation()], retrieve_fn=spy_retrieve, pdf_preflight_sidecars={}
)
self.run_pipeline(citations=[_citation()], retrieve_fn=spy_retrieve)
self.assertEqual(seen, ["FAIL", "MISSING", None])
def test_tagged_row_passes_consistency_lint(self) -> None:
out = self.run_pipeline(
citations=[_citation()], retrieve_fn=self._manual_pdf, pdf_preflight_sidecars={}
)
self.assertEqual(self._validate_passport(out), [])
if __name__ == "__main__":
unittest.main()
+436
View File
@@ -0,0 +1,436 @@
"""Tests for scripts/pdf_read_preflight.py (#512 PDF read-integrity preflight).
Fixtures are synthetic PDFs assembled in-test with correct xref offsets (no binary
fixture files): a flat valid document, a nested page tree, a root /Count that lies,
a truncated tail, an encrypted trailer, a page-tree cycle, and non-PDF bytes. The
preflight must answer PASS only when the declared root /Count, its own /Kids-walk
enumeration, and pypdf's flattened page list all agree with no parser warnings —
anything less confident lands in FAIL (counts disagree) or UNAVAILABLE (cannot
vouch). Design: docs/design/2026-07-20-512-pdf-read-preflight-spec.md.
"""
import hashlib
import json
import os
import subprocess
import sys
import tempfile
import unittest
from datetime import datetime
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import pdf_read_preflight as preflight # noqa: E402
# --- synthetic-PDF assembly ---------------------------------------------------------------
def _build_pdf(objects):
"""Assemble a classic-xref PDF from `objects` (list of object BODIES, bytes, without
the `N 0 obj`/`endobj` wrapper; object numbers are 1-based list positions). Returns
the full file bytes with a correct xref table and trailer pointing at object 1 as
/Root."""
out = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
offsets = []
for i, body in enumerate(objects, start=1):
offsets.append(len(out))
out += b"%d 0 obj\n" % i + body + b"\nendobj\n"
xref_at = len(out)
out += b"xref\n0 %d\n" % (len(objects) + 1)
out += b"0000000000 65535 f \n"
for off in offsets:
out += b"%010d 00000 n \n" % off
out += (
b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n"
% (len(objects) + 1, xref_at)
)
return bytes(out)
def _page(parent_num):
return b"<< /Type /Page /Parent %d 0 R /MediaBox [0 0 612 792] >>" % parent_num
def _flat_pdf(page_count=2, declared=None):
"""Catalog(1) -> Pages(2) -> `page_count` leaf pages. `declared` overrides /Count."""
declared = page_count if declared is None else declared
kids = b" ".join(b"%d 0 R" % (3 + i) for i in range(page_count))
objects = [
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [%s] /Count %d >>" % (kids, declared),
]
objects += [_page(2) for _ in range(page_count)]
return _build_pdf(objects)
def _nested_pdf():
"""Root Pages(2) -> [inner Pages(3) -> [page(4), page(5)], page(6)]; 3 leaves."""
objects = [
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 3 >>",
b"<< /Type /Pages /Parent 2 0 R /Kids [4 0 R 5 0 R] /Count 2 >>",
_page(3),
_page(3),
_page(2),
]
return _build_pdf(objects)
def _cyclic_pdf():
"""Pages(2) -> Pages(3) -> back to Pages(2): a /Kids cycle, zero real leaves."""
objects = [
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
b"<< /Type /Pages /Parent 2 0 R /Kids [2 0 R] /Count 1 >>",
]
return _build_pdf(objects)
def _encrypted_pdf():
"""Structurally flat PDF whose trailer carries /Encrypt — preflight must not vouch."""
raw = _flat_pdf(1)
return raw.replace(
b"/Root 1 0 R >>",
b"/Root 1 0 R /Encrypt << /Filter /Standard /V 1 /R 2 /O (x) /U (x) /P -1 >> >>",
)
def _objstm_pdf():
"""PDF 1.5-style fixture: catalog/pages/page live in an object stream (obj 4),
the xref is a cross-reference stream (obj 5); both unfiltered so offsets stay
computable. Exercises the compressed-object side of the coverage checks."""
import struct
bodies = [
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>",
]
offs, payload = [], b""
for b in bodies:
offs.append(len(payload))
payload += b + b" "
header = b" ".join(b"%d %d" % (i + 1, o) for i, o in enumerate(offs)) + b" "
content = header + payload
out = bytearray(b"%PDF-1.5\n%\xe2\xe3\xcf\xd3\n")
objstm_at = len(out)
out += (
b"4 0 obj\n<< /Type /ObjStm /N 3 /First %d /Length %d >>\nstream\n"
% (len(header), len(content))
) + content + b"\nendstream\nendobj\n"
xref_at = len(out)
rows = [(0, 0, 0), (2, 4, 0), (2, 4, 1), (2, 4, 2), (1, objstm_at, 0), (1, xref_at, 0)]
xdata = b"".join(struct.pack(">BHB", *r) for r in rows)
out += (
b"5 0 obj\n<< /Type /XRef /Size 6 /Root 1 0 R /W [1 2 1] /Index [0 6] /Length %d >>\nstream\n"
% len(xdata)
) + xdata + b"\nendstream\nendobj\n"
out += b"startxref\n%d\n%%%%EOF\n" % xref_at
return bytes(out)
def _write(tmpdir, name, data):
p = Path(tmpdir) / name
p.write_bytes(data)
return p
class PreflightVerdictTest(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.tmp = self._tmp.name
self.addCleanup(self._tmp.cleanup)
def run_on(self, data, name="doc.pdf"):
return preflight.run_preflight(_write(self.tmp, name, data))
def test_flat_valid_pdf_passes_with_agreeing_counts(self):
r = self.run_on(_flat_pdf(2))
self.assertEqual(r["verdict"], "PASS", r)
self.assertEqual(
(r["declared_page_count"], r["enumerated_page_count"], r["reader_page_count"]),
(2, 2, 2),
)
self.assertEqual(r["warnings"], [])
def test_nested_page_tree_enumerates_leaves_only(self):
r = self.run_on(_nested_pdf())
self.assertEqual(r["verdict"], "PASS", r)
self.assertEqual(r["enumerated_page_count"], 3)
self.assertEqual(r["declared_page_count"], 3)
def test_lying_root_count_fails(self):
# Root declares 5 pages, the tree holds 2 — the mispagination signal itself.
r = self.run_on(_flat_pdf(2, declared=5))
self.assertEqual(r["verdict"], "FAIL", r)
self.assertEqual(r["declared_page_count"], 5)
self.assertEqual(r["enumerated_page_count"], 2)
def test_truncated_pdf_never_passes(self):
whole = _flat_pdf(3)
r = self.run_on(whole[: int(len(whole) * 0.6)], name="cut.pdf")
self.assertNotEqual(r["verdict"], "PASS", r)
def test_encrypted_pdf_unavailable(self):
r = self.run_on(_encrypted_pdf())
self.assertEqual(r["verdict"], "UNAVAILABLE", r)
self.assertTrue(any("encrypt" in w.lower() for w in r["warnings"]), r["warnings"])
def test_page_tree_cycle_unavailable_not_hang(self):
r = self.run_on(_cyclic_pdf())
self.assertEqual(r["verdict"], "UNAVAILABLE", r)
self.assertTrue(any("cycle" in w.lower() for w in r["warnings"]), r["warnings"])
def test_non_pdf_bytes_unavailable(self):
r = self.run_on(b"just some text, not a PDF at all\n", name="not.pdf")
self.assertEqual(r["verdict"], "UNAVAILABLE", r)
def test_missing_file_unavailable_with_null_hash(self):
r = preflight.run_preflight(Path(self.tmp) / "nope.pdf")
self.assertEqual(r["verdict"], "UNAVAILABLE", r)
self.assertIsNone(r["sha256"])
def test_pypdf_missing_unavailable(self):
real = preflight.pypdf
preflight.pypdf = None
try:
r = self.run_on(_flat_pdf(1))
finally:
preflight.pypdf = real
self.assertEqual(r["verdict"], "UNAVAILABLE", r)
self.assertTrue(any("pypdf" in w for w in r["warnings"]), r["warnings"])
def test_zero_page_tree_never_passes(self):
r = self.run_on(_flat_pdf(0))
self.assertNotEqual(r["verdict"], "PASS", r)
def test_trailing_data_after_final_eof_never_passes(self):
# A PDF truncated partway through an incremental update keeps the OLDER valid
# %%EOF; pypdf silently reads that revision and all three counts agree on the
# old tree. The trailing-bytes check must veto PASS (codex #512 P1).
data = _flat_pdf(2) + b"6 0 obj\n<< /Type /Page /Parent 2 0 R >>\n"
r = self.run_on(data, name="cut_incremental.pdf")
self.assertEqual(r["verdict"], "UNAVAILABLE", r)
self.assertTrue(any("trailing-data" in w for w in r["warnings"]), r["warnings"])
def test_whitespace_after_final_eof_still_passes(self):
r = self.run_on(_flat_pdf(2) + b"\n\n \n")
self.assertEqual(r["verdict"], "PASS", r)
def test_stale_startxref_with_own_eof_never_passes(self):
# Malformed incremental update: new objects appended, then a syntactically
# complete startxref that still points at the PREVIOUS revision's xref,
# followed by its own %%EOF. The trailing-data check alone sees nothing after
# the final %%EOF; the xref-coverage check must flag the unreachable object
# (codex #512 r2 P1).
base = _build_pdf(
[
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
_page(2),
]
)
old_startxref = base[base.rfind(b"startxref") :] # points at revision-1 xref
stale = base + b"\n4 0 obj\n<< /Type /Page /Parent 2 0 R >>\nendobj\n" + old_startxref
r = self.run_on(stale, name="stale.pdf")
self.assertNotEqual(r["verdict"], "PASS", r)
self.assertTrue(any("xref-coverage" in w for w in r["warnings"]), r["warnings"])
def test_nul_padding_after_final_eof_still_passes(self):
# NUL is PDF whitespace (ISO 32000 §7.2.2) and a common post-%%EOF padding;
# Python's strip() does not know that (codex #512 r3 P1).
r = self.run_on(_flat_pdf(2) + b"\x00" * 16)
self.assertEqual(r["verdict"], "PASS", r)
def test_vertical_tab_after_final_eof_vetoes_pass(self):
# 0x0B is Python whitespace but NOT PDF whitespace — it is data.
r = self.run_on(_flat_pdf(2) + b"\x0b")
self.assertNotEqual(r["verdict"], "PASS", r)
def test_redefined_object_with_stale_startxref_never_passes(self):
# Malformed update variant (codex #512 r3 P1): a REPLACEMENT body for an
# EXISTING object number is appended, then a stale copy of the original
# startxref/%%EOF. Object-number membership sees no orphan; the newest-copy-
# must-be-referenced check must flag it.
base = _flat_pdf(2)
old_startxref = base[base.rfind(b"startxref") :]
replacement = b"\n2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"
r = self.run_on(base + replacement + old_startxref, name="redefined.pdf")
self.assertNotEqual(r["verdict"], "PASS", r)
self.assertTrue(any("xref-coverage" in w for w in r["warnings"]), r["warnings"])
def test_cr_only_line_endings_still_pass(self):
# ISO 32000 permits bare-CR line endings; byte count is unchanged so the
# xref offsets stay valid.
r = self.run_on(_flat_pdf(2).replace(b"\n", b"\r"), name="cr.pdf")
self.assertEqual(r["verdict"], "PASS", r)
def test_cr_only_stale_startxref_never_passes(self):
# r4 P1: a CR-only file must not blind the object-header scan — the stale
# startxref variant has to be caught in this convention too.
base = _flat_pdf(2).replace(b"\n", b"\r")
old_startxref = base[base.rfind(b"startxref") :]
replacement = b"\r2 0 obj\r<< /Type /Pages /Kids [3 0 R] /Count 1 >>\rendobj\r"
r = self.run_on(base + replacement + old_startxref, name="cr_stale.pdf")
self.assertNotEqual(r["verdict"], "PASS", r)
self.assertTrue(any("xref-coverage" in w for w in r["warnings"]), r["warnings"])
def test_objstm_xref_stream_pdf_passes(self):
# Object-stream + cross-reference-stream layout must parse and PASS.
r = self.run_on(_objstm_pdf(), name="objstm.pdf")
self.assertEqual(r["verdict"], "PASS", r)
self.assertEqual(r["enumerated_page_count"], 1)
def test_direct_replacement_of_compressed_object_never_passes(self):
# r5 P1: active copy of object 2 lives inside an object stream; a direct raw
# replacement appended AFTER the container with a stale startxref is
# unreachable but is neither orphaned nor covered by the direct-offset loop.
base = _objstm_pdf()
old_startxref = base[base.rfind(b"startxref") :]
replacement = b"\n2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"
r = self.run_on(base + replacement + old_startxref, name="objstm_stale.pdf")
self.assertNotEqual(r["verdict"], "PASS", r)
self.assertTrue(any("compressed object" in w for w in r["warnings"]), r["warnings"])
def test_nul_preceded_replacement_header_never_passes(self):
# r5 P1: NUL is PDF whitespace; a replacement header preceded only by NUL
# padding must still be seen by the coverage scan.
base = _flat_pdf(2)
old_startxref = base[base.rfind(b"startxref") :]
replacement = b"\x002 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"
r = self.run_on(base + replacement + old_startxref, name="nul_stale.pdf")
self.assertNotEqual(r["verdict"], "PASS", r)
self.assertTrue(any("xref-coverage" in w for w in r["warnings"]), r["warnings"])
def test_ten_digit_object_id_replacement_never_passes(self):
# r6 P1: object numbers may reach ten digits; the header scan's digit cap
# must not blind the coverage checks to such replacements.
base = _flat_pdf(2)
old_startxref = base[base.rfind(b"startxref") :]
replacement = b"\n1000000001 0 obj\n<< /Type /Pages /Count 9 >>\nendobj\n"
r = self.run_on(base + replacement + old_startxref, name="tendigit.pdf")
self.assertNotEqual(r["verdict"], "PASS", r)
self.assertTrue(any("xref-coverage" in w for w in r["warnings"]), r["warnings"])
def test_comment_separated_replacement_header_never_passes(self):
# r7 P1: %-comments are token separators in the PDF lexer, so
# `2 0%note\nobj` is a valid object header the scan must still see.
base = _flat_pdf(2)
old_startxref = base[base.rfind(b"startxref") :]
replacement = b"\n2 0%note\nobj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"
r = self.run_on(base + replacement + old_startxref, name="comment_stale.pdf")
self.assertNotEqual(r["verdict"], "PASS", r)
self.assertTrue(any("xref-coverage" in w for w in r["warnings"]), r["warnings"])
def test_signed_or_zero_padded_replacement_header_never_passes(self):
# r8 P1: ISO 32000 integers permit a leading sign (and arbitrary zero
# padding); pypdf's header reader coerces via int(), so these header forms
# must not hide from the scan.
base = _flat_pdf(2)
old_startxref = base[base.rfind(b"startxref") :]
for header in (b"+2 0 obj", b"00000000002 0 obj"):
with self.subTest(header=header):
replacement = (
b"\n" + header + b"\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"
)
r = self.run_on(base + replacement + old_startxref, name="signed_stale.pdf")
self.assertNotEqual(r["verdict"], "PASS", r)
self.assertTrue(any("xref-coverage" in w for w in r["warnings"]), r["warnings"])
def test_non_integer_count_unavailable(self):
# /Count 1.0 — int() would truncate-coerce and agree with one real leaf; a
# malformed page tree must be UNAVAILABLE, not PASS (codex #512 r2 P1).
objects = [
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1.0 >>",
_page(2),
]
r = self.run_on(_build_pdf(objects), name="floatcount.pdf")
self.assertEqual(r["verdict"], "UNAVAILABLE", r)
self.assertTrue(any("not an integer" in w for w in r["warnings"]), r["warnings"])
def test_parser_warnings_survive_early_exit(self):
# pypdf logs a repair warning, THEN parsing dies: the sidecar must carry BOTH
# the captured warning and the later error (codex #512 P2 — early returns must
# not drop collector messages).
import logging as _logging
class _StubReader:
def __init__(self, stream):
_logging.getLogger("pypdf").warning("synthetic repair warning")
raise ValueError("boom")
class _StubPypdf:
PdfReader = _StubReader
real = preflight.pypdf
preflight.pypdf = _StubPypdf
try:
r = self.run_on(_flat_pdf(1))
finally:
preflight.pypdf = real
self.assertEqual(r["verdict"], "UNAVAILABLE", r)
self.assertTrue(any(w == "pypdf: synthetic repair warning" for w in r["warnings"]), r["warnings"])
self.assertTrue(any(w.startswith("parse-error:") for w in r["warnings"]), r["warnings"])
class SidecarShapeTest(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.tmp = self._tmp.name
self.addCleanup(self._tmp.cleanup)
def test_sidecar_fields_and_hash(self):
data = _flat_pdf(2)
p = _write(self.tmp, "doc.pdf", data)
r = preflight.run_preflight(p)
self.assertEqual(r["schema"], "pdf_read_preflight/1")
self.assertEqual(r["file"], str(p))
self.assertEqual(r["sha256"], hashlib.sha256(data).hexdigest())
datetime.fromisoformat(r["generated_at"]) # parses or raises
self.assertTrue(r["tool"].startswith("pdf_read_preflight/"))
json.dumps(r) # JSON-serializable end to end
class CliTest(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.tmp = self._tmp.name
self.addCleanup(self._tmp.cleanup)
def _cli(self, *args):
return subprocess.run(
[sys.executable, str(REPO_ROOT / "scripts" / "pdf_read_preflight.py"), *args],
capture_output=True,
text=True,
timeout=60,
)
def test_cli_stdout_json_and_exit_zero_on_verdict(self):
p = _write(self.tmp, "doc.pdf", _flat_pdf(2))
proc = self._cli(str(p))
self.assertEqual(proc.returncode, 0, proc.stderr)
self.assertEqual(json.loads(proc.stdout)["verdict"], "PASS")
def test_cli_missing_file_is_a_verdict_not_an_error(self):
proc = self._cli(str(Path(self.tmp) / "nope.pdf"))
self.assertEqual(proc.returncode, 0, proc.stderr)
self.assertEqual(json.loads(proc.stdout)["verdict"], "UNAVAILABLE")
def test_cli_output_flag_writes_sidecar(self):
p = _write(self.tmp, "doc.pdf", _flat_pdf(1))
out = Path(self.tmp) / "doc.read_integrity.json"
proc = self._cli(str(p), "--output", str(out))
self.assertEqual(proc.returncode, 0, proc.stderr)
self.assertEqual(json.loads(out.read_text())["verdict"], "PASS")
def test_cli_no_args_usage_error(self):
proc = self._cli()
self.assertEqual(proc.returncode, 2)
if __name__ == "__main__":
unittest.main()