Skip to content

0.3.0 foundation - #670

Open
mberrys wants to merge 167 commits into
stablefrom
unstable
Open

mberrys wants to merge 167 commits into
stablefrom
unstable

Conversation

@mberrys

@mberrys mberrys commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

What changed

Release changelog

Proof

  • python scripts/agent/check-change.py --base origin/dev --build-dir build-local reports pass, or the report and the failing check are quoted here
  • One changes/<sanitized-head-branch>.md fragment added (Category, Audience, Breaking-Change, Summary)
  • Changed behaviour has a test that fails without the change
  • Protected-path or contract change named above, with the reason it is required

Internal logic (touched behavior-bearing code)

  • Guard clauses handle invalid, stale, cancelled, absent, unauthorized, and terminal cases before the happy path
  • Untrusted input is parsed once at the boundary into trusted typed or domain state, with no repeated checks downstream
  • Invalid state stops before partial mutation or publication and returns a descriptive error or result
  • Names carry the domain intent, and comments explain rationale rather than restating the code

Anti-slop pass

  • Redundant or explanatory comments that do not match the file's style removed
  • Abnormal defensive checks and broad try/catch blocks removed where a trusted upstream boundary already guarantees the invariant, with real boundary and safety checks kept
  • No any or equivalent cast added only to suppress a type error
  • Python imports stay at file scope unless a local import is required
  • Generated boilerplate, needless wrappers, and local-style drift removed
  • Validation, security, cancellation, provenance, and failure handling preserved

Anti-slop summary (1-3 sentences):

Security and rollback

  • Untrusted input validated at the trust boundary; no new unsafe construct without an inline justification
  • Rollback:

Docs

  • Docs updated in this PR, or "none needed" with the reason

Self-review (BSP-002 §4.3)

  • Reviewed in the diff view, not the editor, at least 30 minutes after the final commit; overnight if the change touches security-sensitive code, data handling, or public API surface

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

mberrys and others added 30 commits September 13, 2026 17:51
…ty matrix (#266)

capabilitiesReportMatrixVersions is a drift guard, not a RED-first fix: the four published schema versions already match the compatibility matrix, so there was no failing assertion to write first. It passes before and after the source change.
Implement Interaction-layer catalog, controller, steps model, and run
submitter; wire EditorHost with validate/plan/confirm/execute workflow;
replace the Fix placeholder with ActionListPane.qml; extend unit tests and
accessibility smoke mirrors.

Co-authored-by: michael berry <mberrys@users.noreply.github.com>
Co-authored-by: michael berry <mberrys@users.noreply.github.com>
Pinned invariant: no analyze, apply, or candidate-serialize path writes to PDFRepairTransactionOptions::sourcePath. The slot passed without a production change.
…239)

Pinned invariant: PDFArtifactStore publishes an imported file at its digest-addressed path with write bits cleared, so the received input is a distinct, read-only identity.
)

Pinned invariant: the slot passed without a production change. Its non-vacuity
comes from three assertions inside it - the registry holds at least 7
operations, at least one of them declines the append path, and
downsample-images declares full rewrite. A test-side mutation that inverted the
loop's else-branch expectation failed on add-bleed, proving the branch is
reached against a signed source.
document.redact now runs pdf::validateSaveRequest with a
fullRewrite("redaction removes prior content") requirement before it reads the
document, so the refusal does not depend on how much work the redaction would
have done, and it emits save-policy.refused with ProcessingFailure (4) instead
of silently overwriting the caller's input.

Also reformats PdfTool/pdftoolredact.cpp: the file was already clang-format
dirty at HEAD (exit 74), and the changed-file format gate checks touched files.
mberrys and others added 30 commits September 20, 2026 01:15
…ources

Both files were already clang-format-dirty at the branch base, and the per-file format gate has no clean-at-base exemption.
Issue #589. Certified preflight state (#133/#237) is internal to Loop, so a shop
has no production handoff it can give a customer, a press, or an auditor and
have verified without Loop.

Core (`pdf::PreflightEvidenceBundle`):
- `buildPreflightEvidenceBundle()` produces a machine-readable manifest plus
  declared members from canonical state: the pathless preflight report, the
  retained certificate, the governed sign-off record, the operation-history
  slice, and digest-addressed rollback references. The manifest carries the
  document revision digest, the effective profile digest with its identity and
  resolution provenance, the coverage scope actually evaluated, the decisions
  and approvals with actor and timestamp, and a `sha256` plus `byte_count` for
  every member. Export fails closed when the report does not describe the
  supplied bytes, when the certificate or sign-off binds a different revision or
  profile, or when the supplied chain does not verify from sequence 1.
- `writePreflightEvidenceBundle()` refuses a non-empty output directory and
  commits `manifest.json` last, so a directory carrying a manifest is a complete
  bundle.
- `verifyPreflightEvidenceBundle()` is the offline verifier: member integrity
  against the manifest, the exact declared member set, path hygiene, identity
  agreement across manifest/report/certificate/sign-off, and the hash chain of
  the exported history slice. Every refusal is attributable, naming the member
  and the value it disagrees on. The verdict is refusal-driven (each finding
  clears it) rather than derived from an aggregate.

Path hygiene: the exported report drops the source-path keys, free-form operator
text has path-shaped substrings replaced with `<path-omitted>` (profile JSON
pointers are deliberately kept), rollback references keep the content digest
instead of the store path, and artifact identities drop the storage token. The
exported chain is therefore re-hashed over the redacted copy: the manifest says
`chain_mode: "path-redacted"`, records the canonical chain digest so an auditor
with the sidecar can bind the slice, and the certificate's issuance event is
bound by id rather than by position. The bundle is explicitly not a second
source of truth.

PdfTool:
- `export-evidence-bundle <document.pdf> --report <file> --output <dir>
  [--certificate] [--sign-off] [--artifact]` and
  `verify-evidence-bundle <dir>`, both JSON-first with the four registration
  points (capability, descriptors, parser, options) and the non-JSON rejection
  guard its siblings use.

Evidence:
- `UnitTestsPreflightVerdict` gains three slots: identity binding plus offline
  verification, per-member tamper/missing/undeclared/short-circuit detection with
  attributable codes, and the no-raw-paths assertion (non-vacuity checked: the
  fixture inputs carry the path, and disabling the source-path drop or the string
  redaction fails the slot).
- `UnitTestsPdfToolContract` gains the CLI export/verify pair end to end on a
  certified fixture, including the off-line assertion after the document, its
  sidecar, and the supplied certificate are deleted, plus the non-JSON rejection.
- `scripts/qualification/run_independent_validators.py --evidence-bundle` consumes
  a bundle without Loop and fails the lane closed on any mismatch, covered by six
  new unit tests.

Docs: `docs/PREFLIGHT_EVIDENCE_BUNDLE.md` (export/verify pair, bundle layout,
finding codes, and the two properties a reader must not over-read), plus
cross-references from `CERTIFIED_PREFLIGHT.md`, `PROVENANCE_EVENT_CHAIN.md`,
`INDEPENDENT_VALIDATION.md` and `PDFTOOL_CLI_CONTRACT.md`.
…ndle

Covers the two scope bullets the first commit asserted only structurally: the corrected-output identity and the publication sign-off bound to exact accepted identities. The slot fails closed on a sign-off record that names a different source, profile, or published artifact, and proves the exported approval keeps its actor, timestamp, and path-redacted rationale. Non-vacuity: disabling the sign-off source check fails the slot.
… report

The exporter's fail-closed binding check had no coverage: a report edited after certification must be refused with an explanatory error instead of exported with a binding that cannot be re-derived.
…he coverage backlog (#165)

Issue #165 asks for a published check catalog and a GWG / PDF-X coverage
matrix. I measured all four of its scope items against the tree before writing
anything: the coverage matrix (item 2) and the in-product coverage scope
(item 3) already shipped, so this change closes the two that had not, and does
not re-do the rest.

Check rows. Every one of the 21 registered checks now carries, in
`docs/preflight-check-catalog-overlay.json` and in the generated catalog:

- `parameters`, every profile-side value the check reads, as `{id, type,
  default, range, meaning}`. Ids come from the engine's own profile parser, and
  defaults from the `PreflightCheckConfig` member initialisers and the parser's
  per-check fallbacks.
- `severity`, the finding types the check emits and the condition for each,
  mirroring how the engine sets `PreflightFinding::severity`.
- `evidence`, the report fields the check's findings carry, named as
  `findingToJson` emits them, with a check's own evidence keys written
  `evidence.<key>`.
- `fixups`, the registered preflight fixup ids that remediate the check's
  findings, `[]` when none does.

Coverage backlog. `docs/generated/preflight-coverage-backlog.json` is a
prioritised gap register derived from the same matrix: 26 rows, each with the
gap, the process families it affects, a priority from a stated rule, a state
from a stated rule, and either a verified GitHub issue, the landed check id that
now covers the class, or the literal `unfiled`. The generator records the issue
snapshot it verified and fails closed on an unverified reference, on state or
issue drift, on an orphan `not_covered` class, and on a registered preflight
fixup that no row claims.

Both artifacts are held by the existing seam rather than a second document. The
generator already failed closed when the registry and the overlay disagreed; it
now requires the new row fields and validates the backlog the same way, so the
published catalog cannot drift from the checks without reddening
`scripts/generate-architecture-catalogs.py --check`.

`scripts/ci/test_preflight_check_catalog.py` pins each of those refusals,
mirroring the sibling correction-operation catalog test, and runs in the
`source_integrity` job beside the existing catalog check.

Verification at this commit, all exit 0: `generate-architecture-catalogs.py
--write` then `--check`; `check_source_integrity.py`; `test_correction_
operation_catalog.py` (9 tests); `check_phase5_residue.py`; `check_unmanaged_
async.py`; `test_preflight_check_catalog.py` (18 tests); `test_workflow_
contracts` (27 tests). Non-vacuity: removing `severity` from one overlay row
fails with `catalog entry 'ink-coverage' missing severity`, and deleting a
backlog row reddens `--check` as staleness; both were restored byte-exactly.

Sourcing review, done against the tree rather than accepted from the generator:
all 56 parameter ids resolve to a key the engine reads, and a declared-type
sweep against the engine's members found one real mismatch, `min_dpi` typed as
`number` while the parser reads it with `toInt` (`preflightengine.cpp:7107`),
now `integer`. One association is not engine-derived and is labelled in the
docs: `image-resolution -> downsample-images`, which comes from the audited
correction-operation catalog's `evidence_impact.findings`, because the engine
advertises that fixup on profile declaration plus a high-DPI image candidate,
never keyed on a check id.
A hard exit inside one output's staging window is the crash the atomic write exists for, and no seam reached it: the job's manifestPersist kill fires around the manifest write, not inside the output publish. PDFPageMasterExportJob::beforeOutputCommit is called for one output after its bytes are handed to the atomic writer and before the commit that makes them visible at the final path, keyed on the output's path because the manifest goes through the same writer helper.

Behaviour is unchanged with an empty seam: PDFSafeFileWriter::writeData is a one-line wrapper over writeDevice with a producer that writes the payload and returns written == size (pdfsafefilewriter.cpp:37-46), and the staged producer mirrors that check before calling the seam.
… gaps

Nine slots on the existing target, no new target: a five-output batch whose third destination directory was never created marks only that output failed while outputs 1-2 still reopen as valid PDFs; a child killed inside the third output's staging window leaves a parsing manifest, byte-valid outputs 1-2, no file at any unpublished final path, and a batch that resumes to five written outputs under the same batch_id; resume retries a failed entry, re-runs a written entry whose file is gone, and treats a missing manifest as a fresh batch; manifest integrity covers a path that cannot be read as a manifest, an empty outputs array, and two batches sharing the default name in one directory, asserted as the observed aliasing rather than as a lock; the success path pins the default location, schema_version 3 and manifest retention.

The kill harness exits 92 or 93 when its seam was armed and never fired, so the scenario cannot pass by measuring nothing.
The code drifted from the accepted ADR, so the ADR is amended rather than the tests bent to it: schema_version 3 and the fields it actually carries, exactly pending|written|failed with no skipped status, QSaveFile open/producer/commit instead of a <finalPath>.<pid>.partial file plus QFile::rename, a manifest retained after a successful batch with no completion marker, and resume's real rules. Each drifted claim is listed as deliberately superseded, and Last-verified moves to this commit.
The deleted PDFRecoveryManager held the policy clamp and the source-identity inspection that docs/EDITOR_RECOVERY.md specifies, and both are pure value logic with no Widgets or event-loop dependency, so they belong in LoopLibCore on their own. Bodies are restored from 2a19e2c^ rather than rewritten: the clamp, the fingerprint reads, the normalized-path hash, inspectSource and classifySource keep their deleted text, with the namespace, the export macro and the four signature lines changed to a Core free-function surface.
UnitTests/tst_recoverytest.cpp included a header that existed nowhere and was registered in no target, so the only executable specification of this contract did not compile. It now builds as UnitTestsRecovery, app-less and linked to Core and Test only. The two slots keep every assertion and bound they had; only their call sites move from the deleted manager's statics to the restored free functions, and slot 2's fixture calls the clamp directly.

Registering the target also fixes the classification: agent-policy.json gains the target in core.tests and the test file in core.paths, so a test-only edit classifies as core rather than unclassified, and the two generated catalogs are regenerated to match.
The page opened by stating the service does not exist in this tree, which stops being true once its value surface is registered. It now names what exists, what has no coverage yet, and keeps everything below as the contract to restore.

One contract bullet also had to change: it prescribed QtConcurrent for serialization and hashing, which CI pins at zero remaining launches in scripts/ci/check_unmanaged_async.py, so the sanctioned mechanism is recorded as the job scheduler instead.
…ed issues (#165)

The register left twelve rows unfiled. Two of those are P1, meaning no registered check inspects the class at all and a clean run is silent about it, so they are now filed and the rows reference them: GWG 2022/2024 sheetfed and packaging conformance certificates as #664, and PDF/X-5 and PDF/A-3 validation and output as #665. The generator verifies every reference against the recorded GitHub state, so the entries carry the verbatim OPEN state and the row states still agree with it.

The remaining ten rows stay unfiled on purpose: they are P2 and P3, where a check does inspect the class and its named limitation is what can suppress a finding, so the register is the right place for them until triage.
The backlog validator already rejected unknown families, but check overlay
rows could drop or corrupt their families field without failing --check.

Co-authored-by: michael berry <mberrys@users.noreply.github.com>
The per-file format gate has no clean-at-base exemption and this file carried
five clang-format violations at dbb1c32 (the `checks` braced list in
preflightRestrictedAuditBindsEffectiveScope and the std::find_if lambda below
it). Formatting it is a prerequisite for any change that touches it.

Whitespace-only: `git show HEAD:UnitTests/tst_pdftoolcontract.cpp | tr -d
'[:space:]'` and the same over the formatted file hash identically
(sha256 6619d4d18bf37a92...), and `clang-format --dry-run --Werror` over the
result reports zero violations.
Issue #589's first acceptance criterion is a documented export/verify pair whose
input the CLI produces. The portable evidence bundle requires a preflight report
file, but `preflight` only ever emitted the report inside its stdout envelope, so
the documented workflow required a hand-extracted report. `repair` already takes
`--report-file`; preflight now matches it.

The file is the report in its retained form (`redactSensitiveJson` over
`preflightAuditReportSummary`): exactly the payload the operation-history chain
stores and `issuePreflightCertificate` hashes, so a consumer can bind the file to
the certificate's `report_digest` without re-running the document. The JSON
envelope keeps reporting the run's own view, and the written file is registered
as an extra output alongside it.

Failing first: with `--report-file` unimplemented, the new slot
`PdfToolContractTest::preflightWritesTheCanonicalReport` fails with
`run.exitCode` 2 plus `cli.invalid-arguments` / "Unknown option 'report-file'."
and no report file on disk. After the change the same slot passes, asserts the
written report's canonical JSON equals the accepted PreflightRun event's retained
summary, and `UnitTestsPdfToolContract` is 29 passed / 0 failed.

The touched test source carried five clang-format violations at dbb1c32 (the
per-file gate has no clean-at-base exemption), which the preceding `style:`
commit fixes whitespace-only.
…preflight

Validate preflight output paths against the input document and each other
before running checks, preventing --report-file from overwriting the PDF.

Register --report-file only on the preflight command via PreflightReportFile
so add-bleed and action-list no longer advertise an unused option. Repair
keeps its existing separately registered --report-file.

Add contract tests for both behaviors.

Co-authored-by: michael berry <mberrys@users.noreply.github.com>
Co-authored-by: michael berry <mberrys@users.noreply.github.com>
… gaps (#44) (#659)

## What changed

A mid-batch failure or a hard kill during a PageMaster export cannot
silently leave a torn PDF, and until now that promise rested on tests
that did not reach the window it protects. The kill the suite exercised
fired around the manifest write, not inside one output's publish, so the
case atomic writes exist for was measured at the wrong instant.

This closes the four measured gaps and amends ADR-004 to the code as
built. The next maintainer inherits a suite where the crash case cannot
pass by measuring nothing, and an ADR whose claims match the tree
instead of contradicting it.

`Refs #44`.

## Scope, measured before writing anything

The issue asks for five test cases. I checked each against
`UnitTests/tst_pagemasterexporttest.cpp` at `dbb1c324`, which already
held 36 slots.

| Issue case | State at base | This PR |
| --- | --- | --- |
| 1. Mid-batch operation failure | Only a write-stage failure | Adds an
operation-stage failure at index 3 of 5, with outputs 1-2 still
reopening as valid PDFs |
| 2. Kill mid-write | Killed after commit | Adds the kill inside the
staging window, the window atomic writes exist for |
| 3. Resume | Six slots, three behaviours missing | Adds retry of a
`failed` entry, re-run of a `written` entry whose file was deleted, and
a missing manifest treated as a fresh batch |
| 4. Manifest integrity | Corrupt JSON only | Adds a path unreadable as
a manifest, an empty `outputs` array, and two batches sharing the
default name in one directory |
| 5. Success path | Partial | Pins the default location, `schema_version
== 3`, and that the manifest survives |

## The one seam, and why an empty seam cannot change production

`PDFPageMasterExportJob` gains `beforeOutputCommit`, a
`std::function<void(const QString&)>` beside the existing
`manifestPersist` seam, empty by default. It is called for one output
after that output's bytes are handed to the atomic writer and before the
commit that makes them visible at the final path, keyed on the output's
path, because the manifest goes through the same writer helper and an
ordinal write counter is not the output index.

The write site now stages through `PDFSafeFileWriter::writeDevice` with
a producer that mirrors the payload write. I checked the substitution at
the source rather than assuming it: `writeData` is a one-line wrapper
over `writeDevice` whose producer writes the payload and returns
`written == size` (`pdfsafefilewriter.cpp:37-46`). The staged producer
keeps that short-write check, so a disk-full or quota short write is
still reported as failure rather than as a truncated output. With an
empty seam the behaviour is identical.

## ADR-004 amended to the code

The code drifted from the accepted ADR, and the issue's rule is that the
ADR wins or is amended, not the test. The amendment records
`schema_version` 3 with the fields the manifest really carries, exactly
`pending|written|failed` with no `skipped` status, `QSaveFile` open,
producer and commit instead of a `<finalPath>.<pid>.partial` file plus
`QFile::rename`, a manifest retained after a successful batch with no
completion marker, and resume's real rules. Every drifted claim is
listed as deliberately superseded, `Amended:` is the field ADR-003
already uses, and `Last-verified` moves to this commit.

## Honest gaps

- The literal open-failure branch of `loadExistingManifest` stays
unexercised. On Windows a path that `exists()` reports true while
`open()` fails is a directory, a sharing violation, or an ACL denial,
and the directory case is caught one step earlier by the planned-output
conflict check. The function's parse-failure branch is covered, and the
slot says this in the code rather than leaving it implicit.
- A hard exit inside the staging window leaves Qt's own staging file
behind, named `<finalPath>.XXXXXX` with six random alphanumerics and
zero bytes in every observed case. It never appears at a final output
path, which is the promise the ADR makes and the test asserts, and it is
reported as a diagnostic only. Nothing ever removes that residue, so it
is a candidate for its own issue.

## Proof

`python scripts/agent/check-change.py --base
dbb1c32 --head-branch
test/0.3.0-44-pagemaster-manifest-regression --build-dir build-local` on
`080dfa17`:

```json
{ "status": "pass", "risk": "high", "modules": ["core", "documentation", "pagemaster"],
  "protected_paths": ["LoopLibCore/sources/pdfpagemasterexport.cpp", "LoopLibCore/sources/pdfpagemasterexport.h"],
  "targets": ["LoopLibCore", "PdfTool"], "tests": 46, "checks": "62/62 pass" }
```

`risk: high` is the protected-path signal, disclosed below rather than
buried. 62 checks pass, including 46 test targets built and run.

Suite on my own run, after `ninja: no work to do` confirmed the build
was current: `Totals: 47 passed, 0 failed, 0 skipped, 0 blacklisted,
16274ms`, up from the 38-row baseline.

Non-vacuity, measured rather than assumed. The kill harness exits 91 for
the in-window kill, 92 for a bad mode, and 93 when the seam was armed
and never fired, and the slot asserts 91 exactly. I reproduced 93 myself
by occupying the third output's final path with a directory, so a run
that measures nothing cannot read as a pass. The retry slot self-checks
its discriminator, asserting that a rewrite cannot survive because a
read-only destination rejects the Win32 replace Qt uses.

## Contracts touched

- `LoopLibCore/sources/pdfpagemasterexport.h` and `.cpp` are protected
paths, so this is named for review. The change is additive,
default-empty, leaves behaviour identical when unset, and follows the
`manifestPersist` seam already in that header. No caller outside the
library and this test includes the header. If you would rather the kill
point lived elsewhere, the alternative I rejected was a compiled-out
environment kill switch inside the shared library, which trades an
optional member for a build-configuration promise shipped in
`LoopLibCore`.
- `docs/adr/adr-004-pagemaster-batch-manifest.md` is amended, with
front-matter that `scripts/generate-architecture-catalogs.py --check`
still validates.

## Open decision for review

Should the `QSaveFile` staging residue get its own issue? It is harmless
for the ADR's promise and untidy on disk after a crash. My
recommendation is yes, as a cleanup item, and I will file it with the
reproduction if you agree.

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/studio-berry/codesmith/loop/pr/659?ref=codesmith_pr_footer"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img
alt="View with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a>
<a
href="https://backend.blacksmith.sh/track/enable-autofix?expires=1792490197&installation_model_id=435800&pr_number=659&ref=codesmith_pr_footer&repository=studio-berry%2Floop&return_to=https%3A%2F%2Fgithub.com%2Fstudio-berry%2Floop%2Fpull%2F659&signature=92aebbfaf6b583a2e948a2ce5e61382cfc002e876e400e2f227e086d5877bc12"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img
alt="Autofix with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>@codesmith-bot</code> with what you
need. Autofix is disabled.</sup>

<!-- codesmith:autofix:disabled -->
<!-- /codesmith:footer -->
… orphaned spec (#575) (#660)

## What changed

The Editor's crash recovery has a starting point again, and the tree's
only executable specification of it runs for the first time.
`UnitTests/tst_recoverytest.cpp` used to include a header that existed
nowhere and was registered in no CMake target, so the two slots that
define source-identity classification and policy clamping were dead
text.

This restores the value-level surface those slots exercise and makes
them run. The full service, the per-session lock, the checkpoint
rotation, the retention sweep and the Editor wiring are still absent,
and the page that documents the contract now says so instead of claiming
nothing exists.

`Refs #575`. This is slice 0 of the restoration and does not close the
issue.

## Why the port rather than a redesign

I ran two competing designs. One restored the deleted implementation
nearly verbatim, the other redesigned from the contract. The port won on
a falsifiable claim: the deleted pair contains no Widgets reference, and
the ported code compiles against today's Core with no API drift.

| Measure | Result |
| --- | --- |
| Deleted `.cpp` | 951 lines, of which 97 of the 114 ported lines are
byte-identical to `2a19e2c1^` |
| Deleted header value region | 46 lines, of which 39 are byte-identical
|
| The 7 header exceptions | Exactly the contract-forced grafts below |
| Abandon trigger | Did not fire. The first cold compile and link of the
ported code was clean, so this is a restoration and not a rewrite
wearing one |

The redesign's strongest argument was that the orphan suite is
`QTEST_APPLESS_MAIN` and can never exercise a Core object that owns
timers. That is true and it does not decide this slice, because both
slots are pure. One fingerprints source identity, the other clamps a
policy, and neither needs an event loop. Confirmed by measurement: the
ported translation unit references no timer, the target links no Gui,
and the app-less run passes.

## Four grafts, all contract-forced

- The dead `cleanlySaved` field is gone. It was written false and read,
and nothing acted on it.
- The candidate carries `sessionId` and `sourceFileName` and no path,
because `docs/EDITOR_RECOVERY.md` allows the raw path only inside the
private recovery store. The deleted `sessionDirectory` and
`recoveryFile` are not restored.
- The document revision is a `QString` from the revision identity rather
than a `quint64`, and diagnostics travel as a bounded code the host
translates.
- The export macro is the Core one. `scripts/ci/check_loop_identity.py`
matches the retired product token case-insensitively, so the deleted
macro cannot come back.

## The specification was not edited to fit the code

The two orphan slots keep every assertion and every bound they had, `>=
1`, `>= 0`, `> 0`, the `isValid` check, and the three
`RecoverySourceStatus` comparisons. Only their call sites move, from the
deleted manager's statics to the restored free functions, and slot 2's
fixture now calls the clamp directly instead of round-tripping through a
manager that does not exist yet. The stale `NOT COMPILED` comment above
the includes is removed because it is no longer true.

## One contract sentence had to change

The page prescribed `QtConcurrent` for serialization and hashing.
`scripts/ci/check_unmanaged_async.py` pins remaining unmanaged launches
at zero and `LoopLibCore` does not link `Qt6::Concurrent`, so the bullet
now records the job scheduler as the sanctioned mechanism. Left as
written, the next slice would have implemented the contract and failed
CI.

## Proof

`python scripts/agent/check-change.py --base
dbb1c32 --head-branch
feat/0.3.0-575-editor-recovery-restore --build-dir build-local` on
`df40df81`:

```json
{
  "status": "pass",
  "risk": "high",
  "modules": [
    "build_policy",
    "core",
    "documentation"
  ],
  "protected_paths": [
    "LoopLibCore/sources/pdfrecoverymanager.cpp",
    "LoopLibCore/sources/pdfrecoverymanager.h",
    "UnitTests/CMakeLists.txt"
  ],
  "targets": [
    "LoopLibCore",
    "PdfTool",
    "UnitTests"
  ],
  "tests": 46,
  "checks": "63/63 pass"
}
```

Suite on my own run: `Totals: 4 passed, 0 failed, 0 skipped, 0
blacklisted, 6ms`, with `PASS :
RecoveryTest::sourceIdentityDetectsReplacement()` and `PASS :
RecoveryTest::policyClampsUnsafeValues()`.

Checks run by hand: `generate-architecture-catalogs.py --check`,
`check_source_integrity.py`, `check_phase5_residue.py`,
`check_unmanaged_async.py` (`Known legacy unmanaged launches: 0`) and
`check_loop_identity.py` all exit 0, and `clang-format --dry-run
-Werror` is clean on all three C++ files.

One pre-existing failure is environmental and unrelated.
`scripts/ci/test_verify_phase5_widgets_contract.py` fails because this
machine holds gitignored sibling worktrees under `.worktrees/` whose
forms appear in its recursive scan, and the assertion diff is entirely
`.worktrees/...` paths. No path in my diff appears in it.

## Contracts touched

`LoopLibCore/sources/**` and `UnitTests/CMakeLists.txt` are protected
paths, so both are named here. The Core change adds new files rather
than altering an existing interface, and the new symbols are the free
functions and the candidate type. Registering the target also required
`agent-policy.json` to place `UnitTestsRecovery` in `core.tests` and the
test file in `core.paths`, without which a test-only edit classifies as
unclassified.

## What remains, as its own slice

The manager, the per-session `QLockFile` claim with stale-lock
reclamation, the checkpoint write with payload validation and generation
rotation, the scheduler submission, the retention sweep, the Editor
wiring at the capture point, and the process-kill GUI coverage. The
capture point is the open question I would settle first, since nothing
in product code calls the modification path the contract names.

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/studio-berry/codesmith/loop/pr/660?ref=codesmith_pr_footer"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img
alt="View with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a>
<a
href="https://backend.blacksmith.sh/track/enable-autofix?expires=1792491224&installation_model_id=435800&pr_number=660&ref=codesmith_pr_footer&repository=studio-berry%2Floop&return_to=https%3A%2F%2Fgithub.com%2Fstudio-berry%2Floop%2Fpull%2F660&signature=41fab0bf8cc332a939df49f5e8f0de3846287c4098d5d74c80f36360b518d0f4"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img
alt="Autofix with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>@codesmith-bot</code> with what you
need. Autofix is disabled.</sup>

<!-- codesmith:autofix:disabled -->
<!-- /codesmith:footer -->
…he coverage backlog (#165) (#658)

## What changed

Anyone who has to answer "is this profile adequate for this job" can now
read what each preflight check actually measures, what it reads from the
profile and with what range, which severities it can emit and when, what
evidence its findings carry, and which fixups can remediate it. That
answer previously required reading `preflightengine.cpp`.

The same change publishes the register of what Loop does **not** detect:
`docs/generated/preflight-coverage-backlog.json`, 26 rows, each naming
the gap, the GWG families it affects, a priority from a stated rule, a
state from a stated rule, and either a verified GitHub issue, the landed
check that now covers the class, or an explicit `unfiled`.

For the maintainer: both artifacts are generated from one seam that
already fails closed. The registry and the overlay could not disagree
before; now a row missing a required field, an unverified issue
reference, state or issue drift, an orphan `not_covered` class, or a
registered preflight fixup that no row claims all redden
`scripts/generate-architecture-catalogs.py --check`.

`Closes #165`. `dev` is not the default branch, so
`.github/workflows/issue-promotion.yml` closes it on promotion.

## Scope, measured before writing anything

The issue's four scope items, audited against the tree at `dbb1c324`
rather than trusted from the issue body. Two were already satisfied, so
this PR does not re-do them.

| Item | State | Evidence |
| --- | --- | --- |
| 1. Published catalog from the registry with parameters and ranges,
severity model, evidence, fixups, limitations | **Partial, closed here**
| All 21 checks had rows carrying only `measures`, `limitations`,
`coverage`, `families` |
| 2. Coverage matrix against GWG families and PDF/X | Already shipped |
`docs/PREFLIGHT_COVERAGE_MATRIX.md`, `docs/PDFX_POLICY_MATRIX.md`, 5
families, 3 PDF/X targets, 5 named not-covered classes |
| 3. Surface the coverage scope in the product | Already shipped |
`report.coverage_scope` carries `claim`, `matrix_id`, `enabled_checks`,
asserted by five suites |
| 4. Prioritised backlog cross-referenced to filed checks | **Missing,
added here** | No register existed; the checks the issue names are
largely closed and landed |

The issue's headline numbers are stale. It says eight checks are
registered and lists seven open issues; the registry holds 21, and
`#12`, `#124`, `#130`, `#131` are closed with their checks in the
registry.

## Sourcing rules, so the rows cannot drift into prose

`parameters` ids are fields the engine's own profile parser reads, not
fields a schema merely lists. Defaults come from the
`PreflightCheckConfig` member initialisers and the parser's per-check
fallbacks. The shared check envelope is excluded from `parameters` and
described by the `severity` block instead. `evidence` names follow
`findingToJson`, with a check's own evidence keys written
`evidence.<key>`. A `fixups` id must be a registered repair operation
whose `isPreflightFixup()` is true, every registered preflight fixup
must be claimed by at least one row, and a fixup is listed only where
the engine, or the audited correction-operation catalog, ties it to that
check.

## Review findings on the generated data

I reviewed the data rather than accepting the generator's output, with
two throwaway sweeps kept outside the repository.

- Every one of the 56 parameter ids resolves to a key the engine reads.
- A declared-type sweep against the engine's members found one real
mismatch: `min_dpi` was typed `number` while the parser reads it with
`toInt` (`preflightengine.cpp:7107`). Fixed to `integer` with default
`0`. The other two sweep hits are artifacts of my own regex, since the
engine declares `QStringList allowedColorModes` and two siblings, never
a `bool allowed`.
- One association is **not** engine-derived and is labelled as such in
the matrix doc: `image-resolution -> downsample-images`, taken from
`docs/correction-operation-catalog-overlay.json`
`evidence_impact.findings`, because the engine advertises that fixup on
profile declaration plus a high-DPI image candidate and never keys it on
a check id.

## Proof

`python scripts/agent/check-change.py --base
dbb1c32 --head-branch
docs/0.3.0-165-preflight-catalog-coverage-backlog --build-dir
build-local` on `df354868`:

```json
{ "status": "pass", "risk": "standard", "modules": ["documentation"],
  "protected_paths": [], "targets": [], "tests": [], "checks": "9/9 pass" }
```

Nine checks pass, including the four whole-tree guards that ran because
they run on every change set: `source_integrity`,
`preflight_truth_source`, `qml_mirror_parity`, `qt_test_runtime`. No
build is required, since no CMake target changed.

Non-vacuity, each mutation reverted and restored byte-exactly:

| Mutation | Observed failure |
| --- | --- |
| removed `severity` from the `ink-coverage` overlay row | `error:
cannot generate architecture catalog: catalog entry 'ink-coverage'
missing severity`, exit 1 |
| deleted the `bleed-raster-strip-depth` backlog row | `generated
preflight coverage backlog is stale:` with the missing row in the diff,
exit 1 |

Guard suites, all exit 0: `scripts/ci/test_preflight_check_catalog.py`
(18 tests, new), `scripts/ci/test_correction_operation_catalog.py` (9),
`python3 -m unittest scripts.ci.test_workflow_contracts` (27),
`check_source_integrity.py`, `check_phase5_residue.py`,
`check_unmanaged_async.py`.

## Contracts touched

`.github/workflows/ci.yml` gains one `source_integrity` step that runs
the new guard, immediately after the existing catalog check. A guard
that never runs is not a guard, and adding it beside its sibling is the
smallest place to put it.

## Open decisions for review

The register left twelve rows `unfiled`. The two P1 rows are now filed
and referenced: GWG 2022/2024 sheetfed and packaging conformance
certificates as #664, and PDF/X-5 and PDF/A-3 validation and output as
#665, both where no registered check inspects the class at all and a
clean run is silent about it. The remaining ten stay unfiled on purpose,
eight P2 and two P3, because a registered check does inspect those
classes and its named limitation is what can suppress or misclassify a
finding, so the register is the right place for them until triage. Every
reference is verified against the recorded GitHub state, so the
generator fails when a row and its issue disagree.

Both P1 gaps were filed in the same push that repointed their rows, so
the register and the tracker agree at this head. No decision is left
open on this PR.
…#589) (#657)

## What changed

Loop can certify a preflight run internally (#133/#237), but has no
production handoff: a shop
cannot give a customer, a press, or an auditor a self-contained artifact
that can be verified
without Loop. This adds the export/verify pair from issue #589 —
`PdfTool
export-evidence-bundle` / `PdfTool verify-evidence-bundle` over a new
Core surface,
`pdf::PreflightEvidenceBundle`.

The exporter writes a manifest plus declared members (`report.json`,
`certificate.json`,
`signoff.json`, `history.json`, `rollback-references.json`) from
canonical state, refuses to
build when the report, certificate, sign-off, or chain disagree with the
supplied revision, and
refuses a non-empty output directory. `manifest.json` is committed last,
carries a `sha256` and
`byte_count` for every member, and carries the document revision digest,
the effective profile
digest with its identity and resolution provenance, the coverage scope
actually evaluated, the
decisions and approvals with actor and timestamp, and digest-addressed
rollback references. The
verifier reads only the bundle directory and reports attributable
findings per member and per
chain link; its verdict is refusal-driven rather than derived from an
aggregate.

Bundle hygiene: the exported report drops source-path keys, free-form
operator text is
path-redacted to `<path-omitted>` (profile JSON pointers are
deliberately kept), rollback
references keep the content digest instead of the store path, and
artifact identities drop the
storage token. Because the canonical chain hashes a payload that names
the source path, the
exported history slice is re-hashed over the redacted copy: the manifest
states
`chain_mode: "path-redacted"`, records a digest over the canonical chain
so an auditor holding
the sidecar can bind the slice, and binds the certificate's issuance
event by id. The bundle is
explicitly not a second source of truth, and `verify-certificate`
remains the check against live
Loop state.

Issue closure: `dev` is not the default branch, so
`.github/workflows/issue-promotion.yml`
closes #589 on promotion.

## Release changelog

Topic PR: the fragment is
`changes/feat-0.3.0-589-preflight-evidence-bundle.md` (Category
`added`, Audience `operators, integrators, auditors`, Breaking-Change
`no`).

## Proof

- [x] `python scripts/agent/check-change.py --base
dbb1c32 --head-branch
feat/0.3.0-589-preflight-evidence-bundle --build-dir build-local` —
report attached in the comments of this PR
- [x] One `changes/feat-0.3.0-589-preflight-evidence-bundle.md` fragment
added
- [x] Changed behaviour has a test that fails without the change
(mutation-checked below)
- [x] Protected paths touched: `LoopLibCore/sources/**`,
`docs/schemas/**`, `UnitTests/CMakeLists.txt` is **not** touched (no new
target added, an existing mapped target was extended)

## Internal logic (touched behavior-bearing code)

- [x] Guard clauses handle invalid, stale, cancelled, absent,
unauthorized, and terminal cases before the happy path: the exporter
refuses a missing report, a report describing other bytes, a certificate
or sign-off binding a different revision or profile, an absent
operation-history sidecar, and a chain that does not verify from
sequence 1; the verifier refuses an absent/unsupported manifest, a
missing or undeclared member, a member whose bytes disagree with the
manifest, and every identity disagreement
- [x] Untrusted input is parsed once at the boundary into trusted typed
or domain state: member bytes are hashed and compared against the
manifest in one pass; the exported history is parsed into
`PDFOperationHistoryEvent` once and then chain-verified field by field
- [x] Invalid state stops before partial mutation or publication:
`writePreflightEvidenceBundle` refuses a non-empty directory and
publishes `manifest.json` last, so a directory carrying a manifest is a
complete bundle
- [x] Names carry the domain intent; comments explain rationale (why the
chain is re-hashed, why JSON pointers survive redaction, why the
certificate head is bound by id rather than by position)

## Anti-slop pass

- [x] Redundant or explanatory comments that do not match the file's
style removed
- [x] Abnormal defensive checks removed: the verifier does not re-derive
what the manifest already declares; it checks agreement instead of
re-parsing
- [x] No cast added to suppress a type error
- [x] Python imports stay at file scope
- [x] Generated boilerplate and needless wrappers removed (the
export/verify pair shares one `.cpp`; the bundle format constants are
functions rather than a duplicated literal set)

Anti-slop summary: the exporter is one build function plus one writer,
and the verifier is one pass that records a refusal instead of
accumulating a verdict from an aggregate — the semantic-trust source
guard rejects aggregate verdict derivation, and the first draft of
`verification.valid = verification.findings.isEmpty()` was replaced with
a refusal-driven state rather than renamed around it.

## Security and rollback

- [x] Untrusted input validated at the trust boundary: bundle members
are untrusted input to the verifier, so the member name is restricted to
a plain bundle-local name, the declared member set is compared against
the directory contents, and no member content is executed or
dereferenced
- [x] Rollback: revert the two commits (`0316d4f2`, `e63c0b66`); the
surface is additive (`export-evidence-bundle`, `verify-evidence-bundle`,
one Core header) and no existing command, schema kind, or persistence
format changes

## Docs

- [x] `docs/PREFLIGHT_EVIDENCE_BUNDLE.md` (new: export/verify pair,
bundle layout, finding codes, the two properties a reader must not
over-read), cross-referenced from `docs/CERTIFIED_PREFLIGHT.md`,
`docs/PROVENANCE_EVENT_CHAIN.md`, `docs/INDEPENDENT_VALIDATION.md` and
`docs/PDFTOOL_CLI_CONTRACT.md`; the independent-validation evidence
schema gained the `bundle` block

## Self-review (BSP-002 §4.3)

- [ ] Reviewed in the diff view, not the editor, at least 30 minutes
after the final commit; overnight if the change touches
security-sensitive code, data handling, or public API surface

### Non-vacuity (mutation) evidence

Each guard was broken, rebuilt, and observed failing, then the source
was restored byte-exactly
(`cmp` clean):

| Mutation | Observed failure |
| --- | --- |
| stop dropping the source-path keys | `bundle.source-path-present
[report.json] The exported report still carries the source path key
'pdf'.` and all three bundle slots red |
| stop redacting path-shaped free-form text | `bundle member
'report.json' carries 'C:/…/artwork.pdf'` |
| stop comparing member digests against the manifest |
`member.digest-mismatch` no longer reported for a same-size edit |

### Local results at the commit

- `UnitTestsPreflightVerdict`: 43 passed, 0 failed (3 new slots)
- `UnitTestsPdfToolContract`: 30 passed, 0 failed (2 new slots)
- `python -m unittest
scripts.qualification.test_run_independent_validators`: 12 passed
- `scripts/ci/check_trust_contract_sources.py`,
`check_source_integrity.py`,
  `check_loop_identity.py`, `validate_product_surface.py`,
`generate-architecture-catalogs.py --check`,
`test_correction_operation_catalog.py`: pass
- `clang-format --dry-run --Werror`: 0 violations on every changed C++
source (the two test
files were already dirty at base, so their reformat is a separate
`style:` commit, verified
whitespace-only by comparing `tr -d '[:space:]'` against the base blobs)

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/studio-berry/codesmith/loop/pr/657?ref=codesmith_pr_footer"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img
alt="View with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a>
<a
href="https://backend.blacksmith.sh/track/enable-autofix?expires=1792484222&installation_model_id=435800&pr_number=657&ref=codesmith_pr_footer&repository=studio-berry%2Floop&return_to=https%3A%2F%2Fgithub.com%2Fstudio-berry%2Floop%2Fpull%2F657&signature=38aaee7efc7cc5405aa64405088f0b1ccd63e7c60bd6a7c968106721bb450223"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img
alt="Autofix with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>@codesmith-bot</code> with what you
need. Autofix is disabled.</sup>

<!-- codesmith:autofix:disabled -->
<!-- /codesmith:footer -->
# Conflicts:
#	PdfTool/pdftoolabstractapplication.h
…#669)

<!-- Berry Studio BSP-002 §4.2, adopted for this repository. Loop's own
gates live in AGENTS.md. -->

## What changed

Issue [#589](#589) asks for a
portable proof-of-preflight handoff, and
its first acceptance criterion is that a *documented export/verify pair*
produces and validates the artifact with
the CLI. The exporter that landed on
[#657](#657) (`PdfTool
export-evidence-bundle`) requires a preflight
report file, but nothing on the CLI could write one: `preflight` emitted
the report only inside its stdout
envelope, so the documented workflow needed a hand-extracted report —
the CLI test in #657 does exactly that
extraction in C++. This PR gives `preflight` the missing producer,
matching the `--report-file` that `repair` has
had since #370:

```text
PdfTool preflight --profile profile.json --report-file preflight-report.json --certify certificate.json document.pdf
PdfTool export-evidence-bundle document.pdf --report preflight-report.json --output handoff-bundle
PdfTool verify-evidence-bundle handoff-bundle
```

`--report-file` writes the report in its **retained form** —
`redactSensitiveJson` over
`preflightAuditReportSummary`, i.e. byte-for-byte the payload
`PDFOperationHistoryStore` stores and
`issuePreflightCertificate` hashes — so a consumer can bind the file to
a certificate's `report_digest` without
re-running the document. Writing the envelope's own rendering instead
would hand out a report that no certificate
digest covers. The JSON envelope still reports the run's own view; the
file is registered as an extra envelope
output (`outputs[]`, `{"kind": "file", "role": "report", "state":
"written"}`).

Two commits: `aa2a29ed` (whitespace-only clang-format of the touched
test source, which carried five violations at
`dbb1c324` — the per-file format gate has no clean-at-base exemption)
and `4392027a` (the feature, its test, the
docs and the changelog fragment).

`Refs #589`. `dev` is not the default branch, so this PR does not close
the issue: the promotion workflow closes it
once the bundle work (#657) and this producer are on a promoted branch.
No issue is closed by hand here.

## Release changelog

Topic PR: fragment is `changes/feat-0.3.0-589-preflight-report-file.md`
(Category `added`, Audience
`operators, integrators`, Breaking-Change `no`). No promotion changelog
in this PR.

## Proof

- [x] `python scripts/agent/check-change.py --base origin/dev
--head-branch feat/0.3.0-589-preflight-report-file
      --build-dir build-local` — `pass`:

      ```json
      { "status": "pass", "risk": "standard", "checks": "20/20 pass",
        "modules": ["documentation", "pagemaster", "pdftool"],
        "targets": ["PdfTool"],
"tests": ["UnitTestsOcrCli", "UnitTestsPageMasterExport",
"UnitTestsPdfToolContract"],
"protected_paths": [], "comparison_base_sha": "dbb1c324…", "head_sha":
"4392027a…" }
      ```

The 20 checks are `changelog`, `source_integrity`,
`architecture_catalog`, `policy_adapters`,
`preflight_truth_source`, `qml_mirror_parity`, `search_budget_gate`,
`independent_validation_gate`,
`qt_test_runtime`, four `format:*`, two `clang_tidy:*`, `build:PdfTool`
plus the three test targets, and
      `focused_tests` (`100% tests passed, 0 tests failed out of 3`)
- [x] One `changes/feat-0.3.0-589-preflight-report-file.md` fragment
added (Category, Audience, Breaking-Change, Summary)
- [x] Changed behaviour has a test that fails without the change
(evidence below)
- [x] Protected paths: **none touched.** `PdfTool/**`,
`UnitTests/tst_pdftoolcontract.cpp`,
`docs/CERTIFIED_PREFLIGHT.md` and `changes/**` are all outside
`agent-policy.json`'s `protected_paths`
(which lists `LoopLibCore/sources/**`, `UnitTests/CMakeLists.txt`,
`docs/schemas/**`,
`loop-preflight/schemas/**`, the root `CMakeLists.txt` and the vcpkg
files). No contract or schema changes.

### Failing first (RED)

```text
FAIL!  : PdfToolContractTest::preflightWritesTheCanonicalReport() Compared values are not the same
   Actual   (run.exitCode)    : 2
   Expected (expectedExitCode): 0
tst_pdftoolcontract.cpp(89) : failure location
FAIL!  : PdfToolContractTest::preflightWritesTheCanonicalReport() 'QFile::exists(reportPath)' returned FALSE.
Totals: 2 passed, 1 failed, 0 skipped, 0 blacklisted, 250ms
```

and the invocation that produced it, straight from `PdfTool`:

```json
{"status": "invalid-invocation", "exit_code": 2,
 "diagnostics": [{"code": "cli.invalid-arguments", "message": "Unknown option 'report-file'.", "severity": "error"}]}
```

### Passing after (GREEN)

```text
UnitTestsPdfToolContract: Totals: 29 passed, 0 failed, 0 skipped, 0 blacklisted
```

The slot is not satisfied by "a file appeared": it asserts the written
report's `document_revision_digest` equals
the SHA-256 of the document, that the effective-profile digest and
coverage scope are present, that the verdict
matches the envelope's, and that `pdf::canonicalJson(file)` equals
`pdf::canonicalJson(<accepted PreflightRun
event>.resultSummary)` from the operation-history sidecar — i.e. the
file is the retained payload, not a second
rendering of the run.

### Probe against the built binary (Windows/MSVC, Qt 6.11.1,
`build-local`)

`PdfTool preflight artwork.pdf --profile profile.json --report-file
preflight-report.json --certify certificate.json --console-format json`:

```text
preflight exit: 0, envelope status: success
outputs: [{"kind": "file", "path": "…\\preflight-report.json", "role": "report", "state": "written"}]
report bytes: 3806
report document_revision_digest == certificate document_revision_digest: True
report effective_profile_digest == certificate effective_profile_digest: True
report pdf entry: …\loop-589-probe\artwork.pdf
capabilities reports report-file: {"names": ["--report-file"], "value_name": "file", "value_type": "path",
                                   "required": false, "sensitive": false}
```

The two `True` lines are the binding the change exists for: the file a
run writes is the payload the certificate
issued in that same run covers. The `pdf` line is the caveat the docs
now state.

## Internal logic (touched behavior-bearing code)

- [x] Guard clauses before the happy path: the write runs only when
`--report-file` is non-empty, and a failed
open, short write or failed commit returns `ProcessingFailure` with
`output.write-failed` and the OS error
      text instead of reporting success
- [x] Untrusted input parsed once at the boundary: the report object
already exists (`auditSummary`); the change
neither re-parses nor re-derives it, it only re-inserts it as a file
- [x] Invalid state stops before partial publication: the write uses
`QSaveFile`, so a failure leaves no partial
file at the operator's path (`cancelWriting`), and it precedes the
`--certify` block so certification never
runs after a report-write failure. One ordering consequence is
deliberate and stated in the commit body: the
operation-history event is appended before this write, so a failed write
leaves a correct chain and no report
      file — the fail-closed direction
- [x] Names carry domain intent (`preflightReportPath`,
`retainedReport`) and the comment explains *why* the
      redacted summary is written rather than restating the code

## Anti-slop pass

- [x] No explanatory comments added beyond the one rationale comment
(why the retained form, not the envelope view)
- [x] No abnormal defensive checks: the new guard is a real boundary
(filesystem write), and no check duplicates
      `QSaveFile`'s own failure reporting
- [x] No cast added to suppress a type error
- [x] Python imports: none added (the edit scripts used to keep CRLF
byte-exact live outside the repository)
- [x] No needless wrapper or boilerplate: the option reuses the existing
`PreflightProfile` flag group and the
existing `PDFToolOptions`/descriptor machinery rather than introducing a
new flag group

Anti-slop summary: the change is one option registration plus one
guarded write; nothing was abstracted to make it
look smaller, and the write reuses Core's own `redactSensitiveJson`
rather than reimplementing redaction in
`PdfTool`. Kept on purpose: the explicit `#include
"pdfartifactidentity.h"` (the function is used directly here and
was previously only reachable transitively).

## Security and rollback

- [x] Untrusted input validated at the trust boundary: `--report-file`
is an operator-supplied path passed through
`QSaveFile`; the option is registered with `PDFToolValueType::Path` in
the capability descriptor so
`PdfTool capabilities` publishes it as a path, and no new unsafe
construct is introduced
- [x] Data handling: the file reproduces what the local history sidecar
already stores for this document. It is
not path-stripped — the retained report identifies the revision by
digest and its `pdf` entry records the
path the run was invoked with, which `docs/CERTIFIED_PREFLIGHT.md` now
states — so operators who hand the
report to a third party derive the same disclosure they already had from
the sidecar. The evidence bundle
      exporter (#657) drops that key when it exports
- [x] Rollback: revert `4392027a` (the feature) and `aa2a29ed` (the
whitespace-only format commit); the surface is
additive — one option, one diagnostic path, one extra output record. No
existing command, schema kind or
      persistence format changes

## Docs

- [x] `docs/CERTIFIED_PREFLIGHT.md`: the certificate example now
includes `--report-file`, with the retained-form
      property and the `pdf` caveat stated
- [x] `changes/feat-0.3.0-589-preflight-report-file.md`: the fragment
- [x] `docs/PDFTOOL_CLI_CONTRACT.md`: none needed — it specifies the
envelope, exit codes, diagnostics and `data`
payloads, and does not enumerate a command's options; `PdfTool
capabilities` publishes this one from the
      descriptor registry at runtime
- [x] `docs/PREFLIGHT_EVIDENCE_BUNDLE.md`: none possible in this PR —
that file arrives with #657, so this PR
deliberately does not link to it (a link to a file that does not exist
on `dev` would be a dead link). The
      cross-reference belongs to whichever of the two PRs lands second

## Relationship to PR #657 (same issue)

#657 is open and green with the bundle feature; this PR is the producer
its documented pair needs. One defect
found while reviewing #657 cannot be fixed here:
`docs/PREFLIGHT_EVIDENCE_BUNDLE.md:144` names
`run_independent_validators.py --bundle`, while the script's flag is
`--evidence-bundle`. That file does not exist
on `dev`, so the one-line fix belongs on #657's branch, which this PR
does not touch (its review clock stays
intact). Flagged here so it is not lost.

## Self-review (BSP-002 §4.3)

- [ ] Reviewed in the diff view, not the editor, at least 30 minutes
after the final commit; overnight if the
change touches security-sensitive code, data handling, or public API
surface

<!-- codesmith:footer -->
---
<a
href="https://app.blacksmith.sh/studio-berry/codesmith/loop/pr/669?ref=codesmith_pr_footer"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-light-v2.svg"><img
alt="View with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v2.svg"></picture></a>
<a
href="https://backend.blacksmith.sh/track/enable-autofix?expires=1792556695&installation_model_id=435800&pr_number=669&ref=codesmith_pr_footer&repository=studio-berry%2Floop&return_to=https%3A%2F%2Fgithub.com%2Fstudio-berry%2Floop%2Fpull%2F669&signature=40d8ae97edb63ba8c8125636adec167560836ae68434d43a10d92c2b2384b5e7"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-light.svg"><img
alt="Autofix with [code]smith"
src="https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark.svg"></picture></a>
<sup>Need help on this PR? Tag <code>@codesmith-bot</code> with what you
need. Autofix is disabled.</sup>

<!-- codesmith:autofix:disabled -->
<!-- /codesmith:footer -->
Summary

Promotes dev into unstable as the current integration candidate.

This PR aggregates 162 commits across 375 files and advances the 0.3.0
architecture across preflight execution, governed repair, provenance,
schema evolution, evidence generation, operator workflows, fuzzing,
recovery, and CI qualification.

Technical scope

Preflight architecture

* Extends PreflightEngine, verdict reduction, audit generation, profile
resolution, and finding production.
* Adds versioned preflight report/schema fixtures and generated
preflight check catalogs.
* Adds restriction-aware execution across page, region, layer,
object-class, and page-box scopes.
* Adds revalidation semantics for post-repair state transitions.
* Expands certified-preflight and provenance-linked evidence generation.

Governed correction pipeline

* Expands repair operation definitions, parameter schemas, operation
history, and impact analysis.
* Adds object-selection primitives and governed execution boundaries.
* Adds generated correction-operation catalog validation.
* Extends bleed repair, production geometry, page-master export,
recovery, and repair-diff infrastructure.
* Adds action-list execution across core, interaction, editor, and CLI
layers.

Evidence and provenance

* Adds portable preflight evidence bundles with manifest-based member
integrity verification.
* Adds candidate-revision binding via SHA-256 digest checks.
* Rejects undeclared, missing, tampered, or revision-mismatched bundle
members.
* Extends provenance-event, evidence-graph, and operation-result schema
coverage.
* Strengthens source-integrity and trust-contract validation.

Incremental save and signature preservation

* Adds signed PDF fixtures for incremental-save regression testing.
* Adds independent qpdf structural validation and pdfsig signature
validation.
* Adds CI evidence generation for signed incremental-save preservation.
* Verifies that appended revisions preserve the original signed byte
range.

CLI and schema surface

* Extends PdfTool commands for preflight, repair, action lists, evidence
bundles, certificate verification, and schema inspection.
* Adds schema-version and compatibility handling.
* Adds versioned fixtures for reports, certificates, action lists,
operation plans/results, provenance events, package manifests, and
capability discovery.
* Expands CLI contract and acceptance coverage.

Editor and interaction layer

* Extends preflight, inspection, action-list, pages/production, and
production-preview surfaces.
* Adds finding navigation and overlay integration.
* Adds state visualization and preflight profile draft handling.
* Extends operator-facing governed repair and inspection workflows.
* Adds recovery/restore integration.

Fuzzing and malformed-input coverage

* Adds seeded corpora for:
    * PDF parser
    * content-stream processor
    * stream-filter decoders
* Adds malformed fixtures covering:
    * truncated xref tables
    * cyclic page trees
    * generation mismatches
    * malformed object streams
    * unsupported encryption filters
    * non-PDF input
* Enforces corpus manifest ownership, checksums, and per-harness seed
coverage.
* Marks Fuzz/corpus/**/*.bin as binary to prevent line-ending
transformation.

CI and qualification

* Adds CI gates for correction-operation catalogs and preflight coverage
catalogs.
* Adds Linux independent-validation qualification for signed incremental
saves.
* Adds Windows Qt runtime validation and explicit runtime-path setup for
test executables.
* Adds governed-parity, source-integrity, trust-contract, and
workflow-contract checks.
* Adds issue-promotion automation for branch-stage tracking.
* Extends generated architecture artifact validation.

Verification focus

For this promotion, the primary review surface is integration
correctness rather than isolated feature review.

Validate:

* Linux and Windows qualification pipelines remain green.
* Generated catalogs are consistent with their source implementations.
* Preflight verdicts, provenance, and evidence outputs remain
deterministic.
* Repair operations trigger the expected revalidation scope.
* Evidence bundles fail closed on integrity or revision mismatch.
* Signed incremental saves preserve existing signatures.
* Versioned schema contracts remain backward-compatible where required.
* CLI and editor paths produce equivalent governed execution semantics.
* Fuzz and malformed-input paths terminate safely without hangs or
uncontrolled resource use.
* No branch-specific or development-only state is introduced into
unstable.
Add-LoopQtRuntimeToPath is Windows-only; Linux Release Gate already
exports Qt via LD_LIBRARY_PATH. Unblocks run-quick-backend-smoke.ps1.

Co-authored-by: michael berry <mberrys@users.noreply.github.com>
UnitTestsBleedFixup used QTEST_APPLESS_MAIN, so PDFBleedFixup::apply
aborted in the raster path on the Windows widgets-absent release build.
QTEST_MAIN supplies a QGuiApplication without linking Widgets. Drop the
unused storage local that MSVC reported as C4189.

Co-authored-by: michael berry <mberrys@users.noreply.github.com>
loop-release surface verification rejects capability commands that are
absent from docs/product-surface.json. Register export-evidence-bundle,
verify-evidence-bundle, schema, verify-certificate, and the
preflight-profile export, fork, and validate commands.

Co-authored-by: michael berry <mberrys@users.noreply.github.com>
windows-latest configures with the Visual Studio generator, which omits
CMAKE_CXX_COMPILER. The guard treated that cache as a non-Windows build
and failed Release Gate after the Windows tests had already passed.

Co-authored-by: michael berry <mberrys@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants