Skip to content

feat(c2pa-oracle): differential oracle against c2pa-rs, both directions - #535

Open
justin13888 wants to merge 38 commits into
feat/444-avif-c2pa-reservefrom
feat/447-c2pa-oracle
Open

justin13888 wants to merge 38 commits into
feat/444-avif-c2pa-reservefrom
feat/447-c2pa-oracle

Conversation

@justin13888

@justin13888 justin13888 commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #506 (feat/444-avif-c2pa-reserve), and transitively on #498. Review the commits whose
scope is c2pa-oracle (13 as of 91e099f4, the newest six from round-3 repair) plus the two merge
commits that bring #506's head forward; everything else belongs to the base. Merge #506 first.

No human approved this plan. This is an unattended automation run: the decision record below is
the whole basis for what was built and is what a human should read first.

Summary

Issue #447 asks for tooling/c2pa-oracle, a dev-only differential oracle against c2pa-rs — the
C2PA reference implementation — in both directions the #239 epic names. This is the first thing
in the repository to stand on the far side of the epic's split: gamut locates, bounds, carries
and reserves
a manifest store, and validation belongs to c2pa-rs.

New crate tooling/c2pa-oracle, workspace-excluded and invoked by manifest path (the
tooling/gamut-dng-real-conformance shape), plus a mise run test-c2pa / mise run check-c2pa
pair. Nothing under crates/ is touched — no shipped crate gains an edge to c2pa-rs, which is
what the epic's "no crypto in the shipped graph" criterion requires.

Both directions, on the two containers this branch can reach:

  • gamut reserves → an external signer completes → c2pa-rs validates. gamut-avif reserves a
    slot and reports its range before any signer exists; c2pa-rs signs a store over the finished
    file through its own placeholder workflow; the host patches it in at that range and the file
    validates. The store exactly fills the slot, so nothing after it moves.
  • c2pa-rs embeds → gamut locates the identical byte range. Both gamut-avif and
    gamut-heic report the same span — compared not against gamut's own parse but against a
    span derived from the store's own JUMBF header, so the comparison is a differential and not a
    tautology. Handing gamut's extraction straight back to c2pa-rs as an external store is the
    sharpest form: a span off by a byte at either end does not survive it.

Two assumptions become observations. crates/gamut-heic/STATUS.md records a deferred row
whose premise is "no known writer emits either shape". The oracle checks it:

  • every store c2pa-rs writes opens LBox + jumb — the TBox check gamut deliberately
    declines to assert, because the C2PA spec names that constant only in JPEG XL clauses
    attributing it to ISO/IEC 18181-2;
  • driven through BuilderIntent::Update (there is no public way to set the purpose string
    directly), c2pa-rs writes the 8-byte merkle offset in front of a box_purpose = update store
    exactly as it does for manifest and original. So the one in-spec layout gamut's probe
    cannot discriminate is not one the reference implementation emits, and the probe's offset-0
    fallback is dead weight against files in circulation rather than a source of mis-bounding.

Neither observation makes the LBox bound self-checking — that is #505 — but both replace an
assumption with evidence. They are recorded in the crate's README.md, which also states why
gamut owns the locate/bound step at all, so nobody later deletes gamut's locator as redundant:
c2pa-rs has no parse-only mode, and ValidationState::Invalid is documented as also being
what you get when verification is disabled (c2pa 0.90.21, src/validation_results.rs:36-41).

One extra assertion the record did not ask for, added because it was nearly free and is the
strongest single check in the crate: gamut-avif's whole ContentProvenanceBox is byte-identical
to the one c2pa-rs composes around the same store (tests/box_framing.rs).

Validation

Every command below was run in this worktree, inside the memory-capped scope
(systemd-run --user --scope --slice=agents.slice -p MemoryMax=16G -p MemorySwapMax=0,
CARGO_BUILD_JOBS=2). mise run fmt-tooling / fmt-tooling-check carry the documented
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) prefix, because in a nested
nested worktree cargo otherwise walks past the worktree root to the primary
checkout when loading the other tooling/* manifests. No manifest was changed to work around it
— though tooling/c2pa-oracle/Cargo.toml now carries the empty [workspace] table
tooling/gamut-fuzz already had, so this crate no longer needs the prefix on its own.

Correction. An earlier revision of this section listed nine gates as _running_ and was never
updated; none of those nine completed. What follows is only what completed in a run, with the
gates that did not run named as such.

command outcome
mise run check-c2pa passFinished dev profile in 10.63s, no warnings
mise run test-c2pa pass — 27 tests across 7 binaries, 0 failures, no warnings
__CARGO_TEST_ROOT=… mise run fmt-tooling pass
__CARGO_TEST_ROOT=… mise run fmt-tooling-check pass
mise run check-commits passno errors in 29 commits
convco check origin/master..HEAD passno errors in 29 commits
mise run check-release-deps passrelease dependency graph has no dev-only workspace edges
mise run check-tests passmodule docs, pinned proptest seeds and oracle filenames all conform
cargo run --manifest-path tooling/c2pa-oracle/Cargo.toml --example probe pass — the recorded findings were read off this

Three falsifiers were executed against this revision, each reverted immediately afterwards:

falsifier result
revert find_jumbf_superbox/declared_store_len to the previous reading 5 of the 7 new unit tests fail — LBox = 0 over a 76-byte buffer gives 0, LBox = 1, XLBox = 40 gives 1, and a stray jumb at offset 0 gives NoJumbfSuperbox
assign the model's store to EncodedMetadata::c2pa by hand (the Preserve arm C2paPolicy refuses to offer) a_derivative_built_from_the_parents_model_reads_back_as_unsigned_not_as_invalid fails: c2pa-rs reports Valid
shift AvifContainer::c2pa()'s reported range by one byte c2pa_rs_validates_the_store_read_out_of_gamut_avifs_reported_range now fails (InvalidJumbfHeader); in the previous revision it stayed green

Not run, and not claimed. mise run lint, mise run test, mise run check-ffi-features and
mise run coverage did not complete in any run of this branch. None of them reaches this crate:
it is workspace-excluded with no dependents, so clippy --workspace and test --workspace are
blind to it, no C surface changed, and coverage ignores it for the same reason. That gap is the
subject of the issue filed below, not something a run here can close. CI's own Clippy & Doctests
and Coverage (test gate) jobs cover the workspace for the stacked base's commits.

The mutation gate has nothing in this diff. No file under crates/ changed in these commits
(c6bc972b..HEAD touches tooling/c2pa-oracle/** and mise.toml only), so --in-diff selects
no mutants. This is stated rather than run.

Risks and rollout

  • Blast radius is zero for shipped crates. Nothing under crates/ changed. The only edits
    outside the new directory are one entry in the root manifest's [workspace].exclude list and
    two new mise tasks; neither is reachable from cargo build/test --workspace, from
    mise run lint, or from check-release-deps.
  • c2pa-rs is a large dependency tree, which is exactly why it is behind a workspace
    exclusion and its own task rather than in any gate. It is pure Rust as configured: nothing here
    compiles C. tests/build_configuration.rs fails if the openssl feature ever returns, checking
    both the manifest line a human would edit and the resolved lockfile, which would also catch
    the feature arriving by unification.
  • The oracle can go stale silently, and today it will. No workflow under .github/ names
    this crate, and being workspace-excluded with no dependents it is invisible to
    clippy --workspace and test --workspace too — its entire automated reach is
    fmt-tooling-check. A gamut-avif or gamut-heic API change can break it, or a differential
    can start failing, with every required check green. mise.toml and the crate README.md now
    say exactly that instead of implying otherwise, and issue CI: run check-c2pa in the PR lane and test-c2pa in extended (nothing calls either today) #541 wires check-c2pa into the
    PR lane and test-c2pa into extended, mirroring check-dng-real/test-dng-real. It was not
    done here because this pull request's scope excludes .github/.
  • gamut-heic's locator is measured on an AVIF. C2PA 2.4 Appendix A defines one placement for
    every BMFF-based asset and names HEIF and AVIF together; c2pa-rs serves both from the same
    handler and writer, and a test asserts the same bytes read identically under either MIME type.
    Hand-building a HEIF around real HEVC would have tested the fixture, not the locator.
  • Rollback is deleting tooling/c2pa-oracle/, the exclude entry and the two tasks.

Issue

Refs #447 — the issue is not fully closed: it names every container, and four of them
(gamut-png #440, gamut-dng #442, gamut-riff/gamut-webp #445, gamut-tiff #446, plus
gamut-jpeg #441 behind a procurement decision) were unmerged and still moving when this was
written, so their branches were deliberately not reached into.

Remainder filed as #534tooling/c2pa-oracle: cover PNG, DNG, RIFF/WebP and TIFF once their
container slices land (#447 remainder)
. The crate is scaffolded so each is a case, not a
mechanism
: the drivers take a MIME type as a plain argument. #534 also carries the two things a
later branch should not have to re-derive — that the non-BMFF containers bind by c2pa.hash.data
(byte ranges, not box paths, so the exclusion set a gamut crate reports is itself then under
test), and that DNG/TIFF need a second disjoint exclusion range for the IFD entry's count field
(§18.5.5).

Also filed from the round-2 repair: #541CI: run check-c2pa in the PR lane and test-c2pa in
extended (nothing calls either today)
.

Decisions taken

Issue 447 - tooling/c2pa-oracle: differential oracle against c2pa-rs, both directions
Plan:     v1
Branch:   feat/447-c2pa-oracle
Base:     head of PR #506 (origin/feat/444-avif-c2pa-reserve, 32cdc93) - stacked; transitively on #498
Cause:    -
Touches:  new tooling/c2pa-oracle/**; root Cargo.toml [workspace].exclude; mise.toml task
Will not: add c2pa-rs or any crypto to a SHIPPED crate's dependency graph; touch anything under crates/; build c2pa-rs with its default vendored-openssl feature; use c2pa::jumbf_io
Lane:     serialised behind E-444 (stacked)
Settled:  S1 - c2pa-rs is the one dependency this entry's own issue names, and only as a dev-only workspace-excluded tool; S3 docs/testing.md

Decisions taken.
1. Deliverable boundary
   Taken:    both directions for the containers reachable on this branch - HEIC (locate, from master) and AVIF (reserve -> sign -> validate, from #506) - and the crate scaffolded so a later branch adds PNG, DNG, RIFF/WebP and TIFF by adding a case rather than a mechanism
   Rejected: waiting for all six container slices to merge, which would strand the epic's only behavioural oracle behind five other pull requests; reaching into their branches, which are unmerged and still moving
   Reverses: delete the AVIF direction and keep the locator check
   Filed:    a remainder issue naming the four containers still to be covered, once their pull requests land
2. Build configuration
   Taken:    `default-features = false, features = ["rust_native_crypto"]`, asserted by a test that fails if the openssl feature is ever enabled, because a silent switch back would compile OpenSSL from C into a dev build and violate the epic's no-crypto criterion
   Rejected: the default features
   Reverses: change the dependency line
3. What the tests assert
   Taken:    byte-range agreement EXACTLY - gamut's reported range and the span c2pa-rs embedded are the same range, not merely overlapping (this is the assertion that would have caught the LBox mis-bounding hazard gamut-heic's STATUS records as deferred); the reserve -> external signer -> validate direction on AVIF; and that a re-encode does NOT carry a parent's store forward, with c2pa-rs reporting the derivative as unsigned rather than invalid
   Rejected: asserting only that both sides found "a store"
4. The update-manifest probe
   Taken:    attempt a store written WITHOUT the merkle offset under `box_purpose = update` - the one in-spec BMFF layout gamut-heic's probe cannot distinguish; if c2pa-rs cannot be made to emit one, record that in the crate's docs as the empirical finding, which is itself what closes the deferred row
   Rejected: leaving the deferred row open with no evidence either way
5. Where the crate's own docs record what it found
   Taken:    the crate's README states why gamut owns the locate/bound step (c2pa-rs has no parse-only mode - `ValidationState::Invalid` is also what verification-disabled returns), so nobody later deletes gamut's locator as redundant

Appended during delivery

6. The signing identity
   Taken:    c2pa::EphemeralSigner, which mints a self-signed Ed25519 CA + end-entity chain in memory with the key usage and EKU the C2PA certificate profile wants, and `verify.verify_trust = false` so an ephemeral certificate's absence from any trust list is not read as a failure; every assertion is written against ValidationState::Valid and none asks for Trusted, which is unreachable here by construction
   Rejected: committing a PEM key + certificate fixture (a private key in the tree, and an expiry to rotate); generating a chain with `rcgen` (a second dependency, and its default crypto backend compiles C - the exact outcome decision 2 exists to prevent)
   Reverses: replace `signing_context()` in src/lib.rs
   Note:     this keeps Settled S1 exact - c2pa-rs remains the crate's only dependency
7. How the oracle finds a store independently of gamut
   Taken:    search for the store's own JUMBF header - the first `jumb` box type at or after offset 4, whose four preceding bytes are the big-endian LBox (`find_jumbf_superbox` / `jumbf_superbox_span`)
   Rejected: re-implementing the C2PA 2.4 §A.5.1.2 walk in the oracle, which would be a second copy of the parser under test and would make "gamut reports the same range" prove nothing; building on c2pa::jumbf_io, which the record forbids and whose stream entry points name the crate-private CAIRead/CAIReadWrite anyway
   Reverses: delete the two functions; the direction-2 assertions lose their independent reference
8. Decision 4's outcome, and what it cost
   Taken:    c2pa-rs CAN be made to emit a `box_purpose = update` box - via BuilderIntent::Update over a file that already carries a store - and the empirical finding is the opposite of the one the record anticipated: it writes the 8-byte merkle offset there exactly as for the other two purposes, so the ambiguous offset-less layout is not one the reference implementation emits. Recorded in the crate README under "The `update`-purpose finding" and pinned by tests/update_manifest.rs
   Rejected: recording "c2pa-rs cannot be made to emit one" without having tried BuilderIntent::Update, which was the fallback the record allowed for
   Reverses: -
9. What a reserved-but-unfilled slot reads as
   Taken:    assert only the negative - an all-zero slot must never come back Valid. Observed: c2pa-rs finds the box (its purpose says `manifest`), fails to parse the zeros, and reports a parse error rather than JumbfNotFound
   Rejected: asserting JumbfNotFound, which was the first attempt and failed: it would pin a promise c2pa-rs never made, and a half-built file is honestly a malformed store rather than an absent one. The unsigned-not-invalid distinction the record asks for is about re-encodes, and that test is unchanged
   Reverses: tests/reserve_then_fill.rs
10. One assertion beyond the record
   Taken:    tests/box_framing.rs - gamut-avif's whole ContentProvenanceBox must be byte-identical to the one c2pa-rs composes around the same store. gamut-avif's own suite pins the framing against a fixture built from the §A.5.1.2 field list, which catches a transcription slip but not a *reading* of the clause that differs from everyone else's
   Rejected: leaving the framing checked only against gamut's own reading of the spec, which would also make "the identical byte range" a weaker statement than it looks, since the two framings would then be known to agree only in the fields a locator happens to read
   Reverses: delete the file

Appended during round-2 repair

An independent review of this pull request raised eight findings. The decision record for the
repair was fixed before it started; these are the decisions as taken, plus the two forks the
record did not cover.

11. F1 - the no-copy-forward test could not fail for the reason its name gave
    Taken:    add gamut-metadata as a dev-dependency and drive C2paPolicy, the mechanism issue #447's third bullet actually names. The derivative is now encoded from what MetadataEmbedder returned for a model carrying the store gamut-avif located in the SIGNED PARENT, so the parent is in the causal path: deleting it breaks the test. A second test drives C2paPolicy::Reject and asserts the refusal names the store by its located length
    Evidence: the record allowed a fallback to a renamed container-level claim IF AND ONLY IF reaching C2paPolicy needed new library code. It does not - MetadataExtractor::extract over a lone MetadataBlock::C2pa, MetadataEmbedder::embed, EncodedMetadata::c2pa and AvifEncoder::with_c2pa are all already pub, and the crate compiles and passes with a dev-dependency edge alone. So the first branch was taken and the fallback was not needed
    Rejected: renaming the test to the container-level claim, which the record permitted only on evidence that never materialised
    Reverses: tests/no_copy_forward.rs
12. F2 - the crate is reachable from no CI job, and a comment implied otherwise
    Taken:    correct the comments and FILE the wiring, per the record's scope decision. mise.toml's check-c2pa comment and README.md now state that no workflow calls either task and that the crate's whole automated reach is fmt-tooling-check
    Filed:    #541 - CI: run check-c2pa in the PR lane and test-c2pa in extended (nothing calls either today)
    Rejected: editing .github/ - out of this pull request's scope, and #447 asks only for the fmt gate
    Note:     the review located one of the two comments at tooling/c2pa-oracle/Cargo.toml:33; no such claim exists there at any revision of this branch. The second one is README.md:13 ("compile only, which is what the per-PR lint lane affords"), and that is the one corrected
13. F3 - find_jumbf_superbox abandoned the search on a stray `jumb`
    Taken:    enumerate and skip the first four windows instead of filtering the first match away, so the search CONTINUES past a `jumb` too early to carry an LBox. Doc corrected
    Reverses: src/lib.rs
14. F4 - the two reserved JUMBF LBox values were mis-read, silently
    Taken:    IMPLEMENT both per ISO box syntax, not refuse them. LBox == 0 means the box runs to the end of the file, so the length is the remainder of the buffer from the superbox's own first byte; LBox == 1 means the 8-byte big-endian XLBox after TBox carries the length, counting the whole box including that 16-byte header. LBox 2..=7 is shorter than the header it sits in and describes no box, so it is refused with a new typed OracleError::UnusableSuperboxLength naming why, as is a truncated LBox/XLBox
    Spec:     ISO 19566-5:2023 defines the JUMBF grammar and is paywalled and NOT vendored here (its procurement is issue #441). The syntax is restated in the vendored C2PA 2.4 specification at §8.4.2.3, which defines the C2PA salt as "a standard box consisting of: a box length (LBox, as a 4-byte big-endian unsigned integer); a box type (TBox, 4-byte big-endian unsigned integer, with a value of c2sh ...)". That is the text relied on, together with the same reserved-value convention gamut-isobmff already implements for ISO 14496-12 (size 0 / largesize), recorded in references/isobmff/README.md
    Rejected: refusing both with an error naming a clause that forbids them - no such clause could be cited, since the governing document is not in the tree
    Reverses: declared_store_len in src/lib.rs; its return type changed from Option<usize> to Result<usize>
15. F5-F8 - the doc fix, the sharpest-form test, the slice guard
    Taken:    OracleError::Asset's doc now admits reserve_then_fill raises it; the sharpest-form test cuts the store out of the asset AT THE REPORTED RANGE rather than reading slot_bytes, so a one-byte range shift fails it (verified - it stayed green before); box_framing.rs checks both ends before slicing
16. F7 - the unpinned dependency under line-number citations
    Taken:    pin `c2pa = "=0.90.21"`, guarded by a third drift guard in tests/build_configuration.rs
    Rejected: committing a lockfile as a .gitignore exception - larger, and the pin is what makes the citations honest
17. Padding, placement and trust
    Taken:    "c2pa-rs writes no padding after the store" is recorded in README.md the way the merkle finding is, AND asserted by its own test named for c2pa-rs; the box-bounded locator assertion is now containment, so a future padding c2pa-rs fails the observation test rather than being misread as gamut mis-bounding. README states §A.5.3 placement as out of scope (c2pa-rs validates regardless; gamut-avif's own suite is the authority) and that ValidationState::Trusted is unreachable by construction with EphemeralSigner, with no trust direction and no certificate checked in
18. Manifest hygiene
    Taken:    an empty [workspace] table, matching tooling/gamut-fuzz/Cargo.toml, so the crate no longer needs __CARGO_TEST_ROOT in a nested worktree. Verified: `mise run check-c2pa` and `mise run test-c2pa` both run without the prefix

Two forks the record did not cover, decided here:

19. Where the new JUMBF-header arms are pinned
    Taken:    inline `#[cfg(test)] mod tests` in src/lib.rs. Every arm is reachable only from a hand-built buffer - no fixture c2pa-rs produces uses a reserved LBox or puts a stray `jumb` ahead of a store - so an integration test would add a binary that no differential drives
    Rejected: a tests/jumbf_header.rs integration file; nothing about linkage forces it, and docs/testing.md leaves the placement legal either way
20. What OracleError::UnusableSuperboxLength carries
    Taken:    a &'static str naming which of the four causes fired, asserted by message in the unit tests rather than by is_err(), so removing a guard cannot be masked by a later check
    Rejected: a String (nothing dynamic to carry) and a bare fieldless variant (a refusal that names nothing is a worse diagnostic than the silently wrong span it replaces)

Unresolved review notes

Round-2 review findings F1-F8 are all addressed above; each has an executed falsifier or a named
artefact. Two residuals a human should weigh:

  • mise run lint and mise run test still do not see this crate, and cannot until CI: run check-c2pa in the PR lane and test-c2pa in extended (nothing calls either today) #541 lands.
    Nothing in this pull request changes that; the comments and README now state it rather than
    implying the opposite.
  • The c2pa-rs findings are pinned against =0.90.21 only. Raising the pin is deliberately a
    breaking act that also means re-reading every line-number citation in the crate's README.md;
    tests/build_configuration.rs fails until someone does.

Round 3 — repair

Six of the round-2 findings closed on review; the round-3 pass raised N1-N6 and N8 against this
branch. All eight decisions the record below carries were executed. Head 91e099f4.

finding what changed evidence
N1 (Medium) — XLBox had no minimum, so 0/1/4/8/15 all returned verbatim as a length, giving Ok(8..8) and Ok(8..12) declared_store_len now refuses XLBox < 16 with the same UnusableSuperboxLength, following crates/gamut-isobmff/src/boxes.rs:271 (size < header_size) through for both header sizes rather than only the 8-byte one two new unit tests beside the existing five: an_xlbox_below_the_sixteen_byte_header_is_refused_rather_than_resolved sweeps 0/1/8/15 and asserts the message, an_xlbox_of_exactly_the_header_size_is_a_length pins 16 as accepted so the refusal cannot creep past the boundary
N2 (Medium) — root Cargo.toml:33 claimed check-c2pa "is the compile-only half the per-PR lint lane affords", in the present tense the comment now says no workflow under .github/ calls either task and points at #541. Widening the manifest by one line was the round-3 decision; recorded as decision 21 below grep -rn c2pa .github/ returns nothing. Tree-wide sweep for the same class of claim (grep -rn 'check-c2pa|test-c2pa' over the whole tree excluding target/, .git/, third_party/): seven other sites, all already honest — mise.toml:230 and README.md:22-23 state the gap explicitly, the rest are bare task invocations. Root Cargo.toml:33 was the only false one
N3 (Medium, test design) — tests/no_copy_forward.rs:104-120 duplicated crates/gamut-metadata/tests/roundtrip.rs:340 at ~5x the reach, with no differential in it the duplicate is dropped. Its one genuine contribution — proving extract populated the model, without which its sibling passes vacuously — is now a direct assert!(meta.c2pa.is_some(), …) inside derivative_through, the helper that needs it crates/gamut-metadata/tests/ was not touched: the assertion already exists there. no_copy_forward.rs now runs 1 test, not 2, and no longer imports C2paPolicy/MetadataError
N4 (Low) — the rustdoc credited C2PA 2.4 §8.4.2.3 with spelling out a syntax that reserves two LBox values the rustdoc now mirrors README.md:221's framing: §8.4.2.3 mentions LBox once (the salt sentence, quoted accurately) and XLBox not at all; the grammar is ISO 19566-5:2023, paywalled and not vendored, procurement #441. The arms are stated as the convention read against c2pa-rs's behaviour, not as a citable clause the correction is at the call site a maintainer reads, src/lib.rs, not only in the README
N5 (Low) — three unguarded subtractions from update.start all three offsets now derive from one checked_sub(15) base with an .expect that names the condition, the way tests/box_framing.rs:27-35 already does; a wrong location is reported, not turned into a subtraction overflow that names no side tests/update_manifest.rs still passes 4/4
N6 (nit) — unreachable arms whose message said a length "does not fit this platform's usize" both removed. A const _: () = assert!(usize::BITS >= u64::BITS); states the assumption where they were — this is dev-only host tooling, never cross-compiled — so it is enforced at compile time instead of defended by an error that cannot fire mise run check-c2pa clean
design question 4 — bound the span by the enclosing box? not done, deliberately. Recorded as a third limit in "What this oracle does not check": parsing ISOBMFF framing here would make the oracle depend on the very structural understanding it exists to check independently, and a second copy of gamut's §A.5.1.2 walk proves nothing about the first. #534 is where it first bites tooling/c2pa-oracle/README.md, "What this oracle does not check"
N8 (nit) — the body said "review the last two commits" corrected at the top of this body

Base moved under this branch

#506's head advanced twice mid-run and both were brought in by merge commit, never by rebase and
never by amending anything already pushed: 32cdc935470e6787 (merge 4daeda8c) and
470e678755bd77d6 (merge 91e099f4, the branch head). The first renames AvifContainer::c2pac2pa_slot
and c2pa_manifest_storesc2pa_slots; gamut-heic keeps the old names, so
refactor(c2pa-oracle): follow gamut-avif's renamed C2PA read accessors adapts the AVIF call sites
only. The second needed no adaptation. mise run check-c2pa, mise run test-c2pa,
fmt-tooling-check, convco and check-release-deps were all re-run after the second merge and
are the results tabulated below.

Round-3 gates

command outcome
mise run check-c2pa passFinished dev profile, no warnings (run after the merge and after every edit)
mise run test-c2pa pass — 28 tests across 7 binaries, 0 failures, no warnings (9 unit tests, up from 7)
__CARGO_TEST_ROOT=… mise run fmt-tooling-check pass
convco check origin/feat/444-avif-c2pa-reserve..HEAD passno errors in 13 commits
mise run check-release-deps passrelease dependency graph has no dev-only workspace edges

mise run lint / mise run test / the mutation gate were not run in this round and are not
claimed: nothing under crates/ changed in the round-3 commits (Cargo.toml at the root gained a
comment line only), so the mutation gate selects nothing, and the workspace gates cannot reach a
workspace-excluded crate. The fmt-tooling-check prefix is still required — not for this crate,
which now carries its own [workspace] table, but because the task loops over every tooling/*
manifest and tooling/aom-oracle/Cargo.toml has no such table.

Decisions appended in round 3

21. Correcting a false claim that sits outside tooling/c2pa-oracle/**
    Taken:    widen the manifest by one line and fix root Cargo.toml:33. A false present-tense
              claim about CI, in the file that defines this crate's exclusion, is the same defect
              as the one already fixed inside the crate; a file boundary is not a reason to leave
              it standing. Then sweep the whole tree for the class rather than the line
    Rejected: file it as a follow-up issue. It is one comment line, refuted by a grep anyone can
              re-run, and leaving it would contradict README.md:22 and #541 in the same tree
    Reverses: the round-2 conclusion that the disputed comment did not exist. The address checked
              (tooling/c2pa-oracle/Cargo.toml:33) was genuinely `[dev-dependencies]`; refuting the
              address is not refuting the claim, and the surviving instance went unswept
22. Whether to bound a located span by its enclosing ContentProvenanceBox
    Taken:    no. Document the limit in "What this oracle does not check" and leave #534 as the
              home for the containers where it bites. The oracle's value is that it derives the
              store's position from the store's own header and nothing else
    Rejected: parse the ISOBMFF framing here. It would make this crate depend on exactly the
              structural understanding it exists to check independently, and a second copy of
              gamut's §A.5.1.2 walk proves nothing about the first
23. Where the two unreachable usize arms go
    Taken:    delete them and state the assumption as `const _: () = assert!(usize::BITS >=
              u64::BITS);`. Enforced at compile time, on the one host profile this dev-only crate
              is ever built for, instead of an error message defending something that cannot happen
    Rejected: keeping them "for portability". This crate is never cross-compiled - c2pa-rs and its
              signing backend do not build for wasm32 - so the portability was notional

Residual risk after round 3


Round 4 (closing round) — feat/447-c2pa-oracle, head f9895c41

Six Low findings from the round-4 review, all closed on executed evidence. No history was
rewritten; four commits were added on top of 91e099f4. The base, feat/444-avif-c2pa-reserve,
did not move during this round (55bd77d6 at start and at push).

Correction to the round-3 table (row N4)

Row N4 above records that "the rustdoc now mirrors README.md:221's framing". That is
backwards, and the row is wrong.
The rustdoc was the stricter of the two: it said §8.4.2.3 is
the only place the vendored specification writes any of this down, that it writes down only part,
that it never mentions XLBox and that it states no reserved LBox value. The README still said
the section "spells the syntax out" and then listed the oversized-header field as part of "the
syntax". Round 4 moves the README to the rustdoc's framing, not the other way round. Counted
in the vendored document, references/c2pa/C2PA_Specification_2.4.html:

$ grep -o "LBox"  references/c2pa/C2PA_Specification_2.4.html | wc -l   # 1
$ grep -o "XLBox" references/c2pa/C2PA_Specification_2.4.html | wc -l   # 0

Findings and what was done

finding what was done evidence
F1 (Low) — two stale prose references to AvifContainer::c2pa, renamed to c2pa_slot by the base merge both fixed: tests/update_manifest.rs:116 and README.md:125. The companion reference in that same README sentence, to gamut-heic::HeifContainer::c2pa, is correct and untouched — that method still exists tree sweep after the fix returns nothing: grep -rn 'AvifContainer::c2pa\b' tooling/c2pa-oracle/ is empty, and crates/gamut-avif/src/c2pa.rs defines c2pa_slot (230) and c2pa_slots (264), no bare c2pa
F2 (Low) — the README overclaimed what the vendored specification says the README's §8.4.2.3 paragraph now states what the section does and does not contain, cites the two counts above, and attributes both reserved values to ISO 19566-5:2023 (paywalled, not vendored, procurement #441) rather than to a clause this crate can cite the counts above, re-run in this worktree
F3 (Low) — the header minimum was not applied to the LBox == 0 arm applied. A buffer of 4–7 bytes with LBox == 0 is now UnusableSuperboxLength; the to-end-of-buffer reading is still a length counting the 8-byte header reproduced before the fix — the public reading returned Ok(4), Ok(5), Ok(6), Ok(7) for buffers of those sizes — then refused after it
F4 (Low) — the guard was weaker than its own message it now compares the model's bytes against the oracle's own reading of the parent (jumbf_superbox_span), not against the value the model was built from, which would be a tautology reproduced: with store_of returning Vec::new() the old guard left the test passing; the new one fails with its own message, and also fails when store_of drops the store's last byte
F5 (Low) — NoJumbfSuperbox named one raising function while two raised it the overrunning-length case is remapped to UnusableSuperboxLength, which is the variant that case was introduced to name; the doc now names find_jumbf_superbox as the only raiser and says the other two propagate. The pinned test is updated and asserts the message mutating the remap back to NoJumbfSuperbox fails a_declared_length_running_past_the_buffer_is_an_unusable_length_not_an_absent_superbox
F6 (Low) — the sibling LBox 2..=7 arm had one mid-range example, and two mutants of it survived the whole suite the arm now has the coverage its XLBox sibling has: a sweep over [2, 3, 4, 5, 6, 7] and a boundary test at LBox == 8 both survivors reproduced on the pre-fix tree (9 tests passed under each), and both die after the fix — see the table below

The surviving mutants, before and after

Each was applied to src/lib.rs, the crate's unit suite run, and the file restored.

mutant before after
2..=72..=8 (refuses the smallest legal LBox) survived — 9 passed killed by an_lbox_of_exactly_the_header_size_is_a_length
2..=73..=7 (accepts the reserved LBox 2) survived — 9 passed killed by an_lbox_between_two_and_seven_is_refused_rather_than_resolved
0..=70..=8 (refuses a legal 8-byte buffer) n/a — the arm did not exist killed by an_lbox_of_zero_in_a_buffer_of_exactly_the_header_size_is_a_length
0..=70..=6 (accepts a 7-byte buffer) n/a — the arm did not exist killed by an_lbox_of_zero_in_a_buffer_shorter_than_the_header_is_refused
overrun → NoJumbfSuperbox (F5 reverted) n/a killed by a_declared_length_running_past_the_buffer_is_an_unusable_length_not_an_absent_superbox
store_ofVec::new() (F4) survived — 1 passed killed by the byte-for-byte guard
store_of → drops the last byte (F4) not tried killed by the same guard

Each side of each range is pinned by a different test, so widening or narrowing either by one
is caught.

Round-4 gates

command outcome
mise run check-c2pa pass — exit 0, Finished dev profile, no warnings
mise run test-c2pa pass — exit 0, 31 tests across 7 binaries, 0 failures (12 unit tests, up from 9)
__CARGO_TEST_ROOT=… mise run fmt-tooling-check pass — exit 0
mise run check-commits passno errors in 41 commits; convco check origin/feat/444-avif-c2pa-reserve..HEAD also passes (no errors in 17 commits)
mise run check-release-deps passrelease dependency graph has no dev-only workspace edges

mise run lint, mise run test and the mutation gate were not run in this round and are not
claimed. Nothing under crates/ changed in the round-4 commits, so cargo mutants --in-diff
selects nothing — that is an absence of selection, not coverage — and the workspace-wide gates
cannot reach a workspace-excluded crate. The mutation evidence above was produced by hand, applying
each mutant to the source and running the crate's own suite, because no automated gate reaches
this crate. The __CARGO_TEST_ROOT prefix is still required: the task loops over every
tooling/*/Cargo.toml, and 15 of the 17 sibling manifests carry no [workspace] table.

Decisions appended in round 4

24. F5: where an overrunning declared length belongs
    Taken:    remap it to UnusableSuperboxLength. There *is* a superbox — the search found its
              LBox and `jumb` — and it is the length that is unusable, which is precisely what
              that variant was introduced to name. Reporting it as absence misattributes on the
              reference side of the differential, where a wrong answer becomes gamut's fault
    Rejected: widen NoJumbfSuperbox's doc to admit the second raiser. That documents the
              conflation rather than removing it, and leaves two unlike outcomes behind one name
25. F4: what the model guard compares against
    Taken:    compare the model's bytes against the oracle's own reading of the parent
              (jumbf_superbox_span), which is independent of the locator whose output built the
              model. This grows the test's reach by the oracle's own header reading only
    Rejected: compare the model back against the value it was built from. The two agree however
              wrong the locator is, so it would restate the tautology `is_some()` already was
    Rejected: assert only that the model's bytes parse as a whole JUMBF store. Weaker than the
              decision taken, and it would not catch a store located from the wrong file
26. F3: minimum, or a weaker doc
    Taken:    apply the header minimum to the third arm. A public reference reading that returns
              a length shorter than the header it counts is the same defect the other two arms
              refuse; the fix is four lines and mirrors the sibling arm's shape exactly
    Rejected: document the arm as accepting short buffers. The function's stated purpose is that
              a length is never guessed; a documented wrong answer is still a wrong answer on the
              side of the differential that is read as the reference

Residual risk after round 4

  • No automated gate reaches this crate until CI: run check-c2pa in the PR lane and test-c2pa in extended (nothing calls either today) #541 wires check-c2pa into the per-PR lint lane
    and test-c2pa into extended CI. Only fmt-tooling-check runs today, so a compile break or a
    differential regression here is caught by hand. This is disclosed in mise.toml, in
    tooling/c2pa-oracle/README.md and in the root Cargo.toml, and CI: run check-c2pa in the PR lane and test-c2pa in extended (nothing calls either today) #541 is the filed remedy.
    Whether the pull request should merge on that basis is a merge judgement, and the human's
    this round deliberately took no action on it.
  • The mutation evidence above is hand-produced. It is reproducible from the table (apply the
    expression, run cargo test --manifest-path tooling/c2pa-oracle/Cargo.toml --lib), but no gate
    re-checks it, and nothing stops a later edit from re-opening one of these arms.
  • XLBox remains untested against a real store, and the span's upper bound is still
    unchecked against its enclosing box
    (decision 22) — both round-3 residuals stand unchanged.
  • Every c2pa-rs finding stays pinned to =0.90.21, and every specification count above to
    the vendored C2PA 2.4 HTML.

No human approved this plan or these decisions: this is an unattended run, and the record above is
what a human reads afterwards.


Round 5 (closing round) — feat/447-c2pa-oracle, head 3d47fb67

The round-4 review found two more untested refusal arms (F1, F2) on the same two functions the
round-3 review had found one on, and the round-2 review before that. Its diagnosis is what this
round acts on: the pattern does not converge by iteration, it converges by enumerating the arms
once.
So F1 and F2 are not fixed on their own — the whole set is written down and closed.

The enumeration

#[cfg(test)] mod tests in src/lib.rs now opens with a table, one row per branch that can refuse
an input across the three header-reading functions, naming the test that pins each of its two
directions: the input the branch refuses, and the nearest input it must not refuse. A branch
added without a row, or a row with one side blank, is the finding. The one refusal outside that
layer — reserve_then_fill rejecting a slot that is not the signed store's length — needs c2pa-rs
and a gamut encoder in reach, so it is pinned in tests/reserve_then_fill.rs, and the table says
so.

Every arm was then mutated in both directions by hand and the crate's whole suite run against
each. All 21 mutants die. The four rows marked ▲ were the blank sides.

Arm Mutation Outcome
find_jumbf_superbox .skip(4) .skip(3) — consider one window too early killed (was surviving)
find_jumbf_superbox .skip(4) .skip(5) — skip one window too many killed
find_jumbf_superbox .skip(4) drop .skip entirely, type_offset.saturating_sub(4) killed
find_jumbf_superbox .ok_or(NoJumbfSuperbox) .map_or(Ok(0), Ok) — delete the refusal killed
find_jumbf_superbox .ok_or(NoJumbfSuperbox) raise UnusableSuperboxLength instead killed
declared_store_len get(..4) .unwrap_or_default() — delete the refusal killed (was surviving)
declared_store_len get(..4) get(..5) — demand one byte too many killed
declared_store_len LBox == 0, 0..=7 0..=6 — accept a 7-byte buffer killed
declared_store_len LBox == 0, 0..=7 0..=8 — refuse an 8-byte buffer killed
declared_store_len get(8..16) .unwrap_or_default() — delete the refusal killed
declared_store_len get(8..16) get(8..17) — demand one byte too many killed
declared_store_len XLBox 0..=15 0..=14 killed
declared_store_len XLBox 0..=15 0..=16 killed
declared_store_len LBox 2..=7 2..=6 killed
declared_store_len LBox 2..=7 2..=8 killed
jumbf_superbox_span checked_add wrapping_add — delete the overflow check killed
jumbf_superbox_span *end <= buffer.len() *end < buffer.len() killed
jumbf_superbox_span *end <= buffer.len() true — delete the bound killed
reserve_then_fill store.len() != slot.len() if false — delete the guard killed
reserve_then_fill store.len() != slot.len() == — invert the guard killed
.gitignore lockfile negation ▲ drop the ! line killed

Every refusal is asserted by the message it carries, never is_err(). Several branches refuse
the same input for different reasons — deleting the truncated-LBox arm sends a three-byte buffer
into the LBox == 0 arm, which refuses it too — so only the message distinguishes the branch that
fired from the one that caught the fall.

Findings and what was done

  • F1 (Low) — the truncated-LBox refusal had no test. Reproduced: replacing its .ok_or(…)
    with .unwrap_or_default() left all 31 tests green. Fixed by
    a_buffer_too_short_for_an_lbox_field_is_refused_as_a_truncated_field, sweeping 0–3 bytes and
    asserting the message, because the deletion is caught by the LBox == 0 arm and only the
    message tells the two apart.
  • F2 (Low) — the skip guard was pinned on one side, and the untested side enabled a panic.
    Reproduced: .skip(3) survived all 31 tests, and under it a jumb at offset 3 panics with a
    subtraction overflow. The decoy sat at offset 0 — far from the boundary. The fixture now places
    a decoy at offset 3 exactly, the last offset a superbox type cannot occupy, and a second
    fixture puts a superbox's TBox at offset 4, the first offset it can. Both directions die.
  • F3 (Informational) — two of three assertions could not fail. slot.len() == store.len()
    restated a guard reserve_then_fill applies before it returns, and "the slot's bytes are the
    store's bytes" was copy_from_slice's postcondition. Both removed; the test keeps the one claim
    about c2pa-rs (the store is the placeholder's length) and is renamed for it. The guard they
    restated is now driven instead, by reserving one byte too many.
  • F4 (Informational) — a new equality was strict against padding. Made containment-shaped,
    matching its sibling in locate_embedded.rs, with a note at the site naming
    c2pa_rs_leaves_no_padding_between_the_store_and_the_end_of_its_box as the test that owns the
    no-padding fact — so a future padding release fails that one rather than being misread here.
  • README claim made true. It asserted "each side of each refusal is pinned by its own test"
    while four sides were blank. The claim now stands and points at the enumeration that
    discharges it, states what a blank side means, and names the one refusal that lives elsewhere.

The lockfile, and what it guarantees that the pin did not

Decision 23 chose the exact = version pin instead of a lockfile. That is reversed here, and
recorded as reversed. The pin holds exactly one line. Everything under c2pa — 325 further
packages — re-resolved on every invocation, so
the_resolved_dependency_graph_contains_no_openssl_package could only ever read the resolution
cargo had written for it moments earlier. Reading a file you just caused to exist is not an
assertion about anything.

tooling/c2pa-oracle/Cargo.lock is now committed as a deliberate exception to the blanket
tooling/*/Cargo.lock rule, with the exception and its reason recorded beside the rule in
.gitignore. The version pin stays: it guards a different thing (the line-numbered citations
into c2pa-rs's source) and it is the line a human edits on purpose, where a lockfile is regenerated
by any cargo update. The exception is itself pinned by a drift guard —
the_lockfile_this_crate_resolves_against_is_committed_rather_than_ignored — because dropping the
negation is one edit that nothing else here would notice.

Filed

  • tooling/c2pa-oracle: exercise a reserved slot larger than the signed store, where the two locators diverge #598tooling/c2pa-oracle: exercise a reserved slot larger than the signed store, where the
    two locators diverge.
    The one configuration nothing exercises, and exactly what a signer does
    when it reserves generously before the certificate chain's size is known. It is the only shape in
    which the box-bounded (gamut-avif) and LBox-bounded (gamut-heic) locators legitimately
    report different ranges; today every fixture makes them identical. Beyond this issue's scope, and
    cited at the site in tests/reserve_then_fill.rs.

Round-5 gates

Base re-merged first: PR #506's head had moved to c38e64c7, merged as cbd3e30f; the reviewer's
report that its only non-comment change is an added test block was checked rather than assumed, and
the whole suite was re-run after.

Command Result
mise run check-c2pa pass — exit 0, Finished dev profile, no warnings
mise run test-c2pa pass — exit 0, 40 tests across 7 binaries, 0 failures (19 unit tests, up from 12)
__CARGO_TEST_ROOT=… mise run fmt-tooling-check pass — exit 0
mise run check-commits passno errors in 52 commits; convco check origin/feat/444-avif-c2pa-reserve..HEAD also passes (no errors in 24 commits)
mise run check-release-deps passrelease dependency graph has no dev-only workspace edges

The mutation gate selects nothing. This round's diff touches no file under crates/, so
cargo mutants --in-diff has nothing to select. That is an absence of selection, not coverage.
The 21 results in the table above were produced by hand — apply the expression, run
cargo test --manifest-path tooling/c2pa-oracle/Cargo.toml — because no automated gate reaches a
workspace-excluded crate. mise run lint and mise run test were not run this round and are not
claimed; neither can reach this crate. The __CARGO_TEST_ROOT prefix is still required, and is
kept: the task loops over every tooling/*/Cargo.toml, and the sibling manifests still need it.

Decisions appended in round 5

27. How the untested-arm pattern is closed
    Taken:    enumerate every refusing branch in the header-reading layer once, publish the
              enumeration as a table in the test module's own documentation, and pin both
              directions of every row. Three reviews each found one more arm because each looked
              at the arm the last had missed; a set is closed by writing the set down
    Rejected: fix F1 and F2 only. That is the fourth iteration of a pattern the reviewer
              diagnosed as not converging by iteration
    Rejected: hold the enumeration in README.md alone. The table has to sit where a branch is
              added, so a row is the obvious next edit; the README points at it instead
28. The F2 fixture's decoy placement
    Taken:    place a decoy at the exact boundary offset (3) and a real superbox type at the
              first legal one (4), in two fixtures. This repository's own recorded lesson is
              that a fixture must vary along the axis the mutation confuses
    Rejected: keep the decoy at offset 0 and add an assertion. The decoy's offset is the axis;
              no assertion over an input the mutation cannot reach can pin it
29. F3: the assertions that cannot fail
    Taken:    remove them, and drive the guard they restated instead — reserve one byte more
              than the placeholder asked for and require the refusal to name both lengths. An
              assertion that cannot fail is removed by making the claim it gestured at testable
    Rejected: leave them as documentation. They read as evidence and are not
30. F4: the padding-strict equality
    Taken:    make it containment-shaped like its sibling, and name at the site the test that
              owns the no-padding fact. One test per fact; a padding release must fail the test
              about padding
    Rejected: state at the site why it differs. It had no reason to differ
31. Reverses decision 23 — the lockfile
    Taken:    commit tooling/c2pa-oracle/Cargo.lock as a deliberate exception to
              `tooling/*/Cargo.lock`, and pin the exception with a drift guard. Decision 23 chose
              the `=` pin *instead of* a lockfile; the pin holds the direct dependency only,
              while the 325-package transitive graph re-resolves on every run, so the
              no-OpenSSL assertion could only inspect a resolution cargo had just written. The
              pin alone did not achieve what it was chosen for
    Rejected: keep the pin alone. It guards the line-numbered citations and is kept for that;
              it does not and cannot guard the graph
    Rejected: drop the pin now that the lockfile holds the version. A lockfile is regenerated by
              any `cargo update`; the manifest line is the one a human edits on purpose
32. The padded-slot configuration
    Taken:    file it (#598) and cite it at the site. It is a real gap — the only shape in which
              the two locators legitimately diverge — and it needs a padding path through
              `reserve_then_fill` that this round's scope does not cover
    Rejected: implement it here. Beyond the scope the record sets for this round

Residual risk after round 5

  • No automated gate reaches this crate until CI: run check-c2pa in the PR lane and test-c2pa in extended (nothing calls either today) #541 wires check-c2pa into the per-PR lint lane
    and test-c2pa into extended CI. Unchanged from round 4, and still a merge judgement for the
    human
    ; this round took no action on it.
  • The 21 mutation results are hand-produced. Reproducible from the table, but nothing
    re-checks them, and nothing stops a later edit from re-opening an arm. The enumeration table is
    the mitigation — a branch without a row is visible — not a gate.
  • The enumeration covers the header-reading layer and reserve_then_fill's length guard.
    Refusals that are pure ? propagation of a callee's error are covered at the callee, not
    restated per caller.
  • The padded-slot configuration is untested (tooling/c2pa-oracle: exercise a reserved slot larger than the signed store, where the two locators diverge #598), so the containment-shaped assertions in
    locate_embedded.rs and no_copy_forward.rs are correct by argument, not by fixture.
  • XLBox remains untested against a real store, and the span's upper bound is still
    unchecked against its enclosing box
    (decision 22) — round-3 residuals, unchanged.
  • Every c2pa-rs finding stays pinned to =0.90.21 — now with the whole resolved graph held
    by a committed lockfile as well — and every specification count to the vendored C2PA 2.4 HTML.

No human approved this plan or these decisions: this is an unattended run, and the record above is
what a human reads afterwards.

Self-review, after the round-5 gates

Three defects raised against this round's own diff and fixed in 447ab4bd and 3d47fb67, with
every gate re-run after each:
the test on the accepted side of both of jumbf_superbox_span's refusals was named for a
length
when it asserts a span; and the enumeration table has one test in both columns, which
reads as a gap and is not — a 4-to-7-byte buffer is one whose LBox field was read successfully
and whose resulting length is then too short, so it is the accepted side of the truncation branch
and the refused side of the LBox == 0 branch. Adjacent branches on the same input share a
boundary. The table now says so rather than leaving a reader to work it out. Third, both the
enumeration and the README claimed every refusal test asserts the refusal's message; three of
them assert a variant, because NoJumbfSuperbox is fieldless and has no message to name. The
claim that matters — each test says which refusal fired, not that one did — is now stated that
way in both places.

One failure was observed and is not a defect in the tree: after the mutation battery,
box_framing failed on a clean checkout with signed store is 13278 bytes but the reserved slot is 13278 — the inverted guard from the last mutant, still in the compiled binary. The harness
restored each file with mv, which carries the backup's older mtime, so cargo considered the
restored source fresh and did not rebuild. touch plus a re-run is green. Classified artefact of
the hand-mutation harness
, not caused and not pre-existing; each mutant's own compile was
triggered by a newer mtime, so only the tree left behind was stale. The harness was fixed and the
whole 21-mutant battery re-run against the final head 3d47fb67 from a clean tree: all 21 die,
the tree is clean afterwards, and all 7 test binaries pass.

Round 7 — repair against the round-6 review, head 0eb7b1bb

The round-6 review is treated as evidence to verify, not as instruction. Two of its results are
recorded first because they are findings about the round-5 work rather than against it.

  • The enumeration is not short. The reviewer derived the nine refusing branches independently,
    without reading the table, and reached the same nine with the same partition and the same scope
    boundary, then grepped every refusal-originating site to check the boundary itself. The
    incompleteness class that had recurred for four rounds did not recur.
  • The round-5 harness-artefact diagnosis is confirmed with a mechanism. The reviewer reproduced
    the failure and showed the message it printed — equal lengths on both sides of a != guard — is
    unreachable from the committed source, so it can only have come from the inverted-guard mutant
    still compiled in. All 21 mutants claimed dead are dead, each killed by the test the table names.

What reproduced

Every finding reproduced. Nothing in the review failed to reproduce.

  • F1 — the lockfile held nothing. With tooling/c2pa-oracle/Cargo.lock deleted, all four
    build_configuration tests passed and cargo wrote a resolution that differs from the
    committed one (bitflags 2.13.12.13.2, ryu 1.26.01.26.1) — the very file the
    no-OpenSSL assertion then read. Root cause confirmed: neither test-c2pa nor check-c2pa passed
    --locked.
  • F3 — is_jumbf_not_found was pinned in one direction. Mutated to -> bool { true } it
    survived all 40 tests. The rebuild was confirmed from cargo's own Compiling c2pa-oracle line
    before the result was believed.
  • F4 — the refusing fixture was not at the boundary. With the bound mutated to
    *end <= buffer.len() + 1, the whole suite stayed green: LBox = 4096 in a 32-byte buffer is
    4064 bytes past the bound, so a bound wrong by one still refuses it.
  • F2, F5, F6 were read directly off the artefacts and are as described.

What changed

Finding Change How it was verified
F1 --locked on both test-c2pa and check-c2pa; the .exists() assertion deleted Same falsifier: with the lockfile deleted mise run test-c2pa now fails (cannot create the lock file … because --locked was passed) and does not regenerate it. Restored, green
F2 The table is parsed out of the module doc at compile time and checked by three tests Falsified all three: renaming an_xlbox_of_exactly_the_header_size_is_a_length in its definition only, blanking a Not taken cell, misspelling tests/no_copy_forward.rs — each fails exactly one of them
F3 Enumeration scope widened to every discriminating branch; is_jumbf_not_found gains a row and both directions The -> bool { true } mutant now dies on a_c2pa_error_other_than_jumbf_not_found_is_not_an_absent_manifest
F4 The fixture declares 33 bytes in a 32-byte buffer — the nearest length the bound must refuse The + 1 mutant now dies, failing with the span it wrongly returned (0..33)
F5 The ▲ claim corrected below Counted
F6 # Panics on ComposedBox::store(); field visibility unchanged Read

The widened enumeration

The scope was "branches that can refuse an input". That is a defensible boundary and it excluded
exactly the predicates whose whole job is discriminating — F3 landed there, with the same
one-side-blank shape the table exists to make visible. The scope is now every discriminating
branch on the crate's own parsing surface
, and the columns are Taken / Not taken rather than
Refuses / Accepts, which generalises without losing the refusal reading (for a refusing branch
they are the input it refuses and the nearest input it must not).

Three rows join, taking the table from nine to twelve:

  • declared_store_len LBox == 0 — the reserved to-end-of-buffer value, not a literal zero;
  • declared_store_len LBox == 1 — the reserved defer-to-XLBox value, not a literal one;
  • is_jumbf_not_foundC2pa(JumbfNotFound) against everything else.

The first two were previously relegated to prose on the grounds that they refuse nothing. Under the
widened scope that is not a reason: they choose between two answers about a buffer, which is what
earns a row. All three share their Not taken side with the ordinary 32-bit LBox arm, which
an_lbox_of_exactly_the_header_size_is_a_length is; the table says so.

is_jumbf_not_found's false side is pinned on c2pa::Error::JumbfBoxNotFound — the nearest
neighbour there is: same crate, same phrasing, raised only once a store has been found and
something inside it is missing. That is precisely the case the no-copy-forward claim must not
confuse with an absent manifest. Its true side is pinned inline as well as differentially in
tests/no_copy_forward.rs, because the inline test fails for exactly one reason where the
differential fails for anything wrong anywhere in a re-encode.

Correction to the round-5 mutation table

That section says "The four rows marked ▲ were the blank sides." Counting the marks gives ten:
.skip(3), the dropped .skip, both .ok_or mutations, the get(..4) deletion, get(8..17),
wrapping_add, both reserve_then_fill slot-guard mutations, and the .gitignore negation.

The mark was meant to say "this mutation sits on an arm round 5 pinned", and it is applied
inconsistently: .skip(5) and get(..5) fit the same description and carry no mark. The column
that is load-bearing is Outcome — exactly two mutations, .skip(3) and the get(..4)
deletion, were live survivors before round 5, and their own cells say so. The ▲ marks are
descriptive and should be read as nothing more.

What the committed lockfile actually holds

Round 5 recorded two arguments for committing tooling/c2pa-oracle/Cargo.lock. Only one of them
carries the claim, and the record is corrected rather than left to be read either way.

  • Carries it: the = pin holds one direct dependency and nothing beneath it. The 325
    transitive packages under c2pa re-resolve on every invocation without a committed lockfile, so
    the_resolved_dependency_graph_contains_no_openssl_package could only inspect a resolution cargo
    had just written. --locked is the other half: the committed resolution is now the one used, or
    the task fails.
  • Does not: "nothing else in the tree depends on c2pa, so nothing else pins the graph it
    drags in." True, but it bears on the feature line — it says no other dependent can unify
    openssl back on — and says nothing about which versions those 325 packages resolve to. The two
    are different claims. The second has been removed from tests/build_configuration.rs and the
    distinction written down at both sites.

An open question for the reader, not resolved here

Should a crate that no CI job reaches merge? check-c2pa and test-c2pa are called by no
workflow in .github/; the crate's whole automated reach is fmt-tooling-check. Wiring them up is
#541, filed and disclosed in the mise task comment, the README and this body. Every result in
this body was produced by hand for that reason. Whether that is an acceptable state to merge in is
a human's judgement and this round deliberately takes no position on it.

Round-7 gates

Command Result
mise run test-c2pa pass — exit 0, 45 tests across 7 binaries, 0 failures (24 unit tests, up from 19)
mise run check-c2pa pass — exit 0, Finished dev profile, no warnings
cargo clippy --locked --manifest-path tooling/c2pa-oracle/Cargo.toml --all-targets -- -D warnings pass — exit 0, no warnings
__CARGO_TEST_ROOT=… mise run fmt-check pass — exit 0 (fmt-check + fmt-tooling-check)
mise run check-tests passmodule docs, pinned proptest seeds and oracle filenames all conform
mise run check-commits passno errors in 56 commits

mise run lint, mise run test and the mutation gate were not run this round and are not
claimed. None of them can reach a workspace-excluded crate, and this round's diff touches no file
under crates/, so cargo mutants --in-diff selects nothing — an absence of selection, not
coverage. The five hand-applied mutants above were each confirmed to have actually rebuilt from
cargo's own Compiling c2pa-oracle line before their result was believed, and each was restored by
copy with the tree verified clean afterwards. The __CARGO_TEST_ROOT prefix is still required for
fmt-check: the task loops over every tooling/*/Cargo.toml and the sibling manifests still need
it.

Decisions appended in round 7

33. Reverses part of decision 31 — the lockfile was not load-bearing
    Taken:    pass --locked in both `test-c2pa` and `check-c2pa`, and delete the `.exists()`
              assertion it makes redundant. Decision 31 committed the lockfile to make the
              resolved-graph assertion an assertion; without --locked cargo regenerated the file
              and the assertion still read what cargo had just written. --locked is what closes
              that, and it is the right way to remove an assertion that cannot fail — make the
              claim hold, then drop the decoration
    Rejected: keep the `.exists()` assertion as documentation. Cargo creates the file while
              building the test binary, so it cannot fail; under --locked it cannot even be absent
    Rejected: assert the lockfile's content instead. --locked is the mechanism cargo provides;
              re-implementing it in a test would be a second, weaker copy
    Reverses: drop the flag and restore the assertion
34. Machine-check the enumeration
    Taken:    parse the table out of the module doc at compile time and check its shape, that
              every name it links is a function this file defines, and that every `tests/` file
              it names exists. rustdoc never compiles a cfg(test) module, so its 21 intra-doc
              links are invisible to broken_intra_doc_links and a stale row stays green
    Rejected: move the table to rustdoc-visible documentation. It has to sit where a branch is
              added, so that adding a row is the obvious next edit
    Rejected: leave it to review. Four rounds of review is the evidence that does not work
    Reverses: delete those three tests
35. Widen the enumeration's scope to discrimination
    Taken:    every discriminating branch on the crate's own parsing surface earns a row, not
              only every refusing one. The narrow scope excluded exactly the predicates whose
              job is telling two cases apart, and F3 shows the blank-side shape lands there
    Rejected: keep the refusal scope and add `is_jumbf_not_found` as a special case. A boundary
              that needs exceptions is the wrong boundary
    Reverses: restore the narrower scope statement and drop the three added rows
36. The `false` side of `is_jumbf_not_found`
    Taken:    pin it on `c2pa::Error::JumbfBoxNotFound` — the nearest neighbour, raised only once
              a store has been found. Also pin the `true` side inline, because the inline test
              fails for one reason where the differential fails for anything in a re-encode
    Rejected: pin it on `OracleError::NoJumbfSuperbox`. A different outer variant is a farther
              input and would not kill a mutant that only confuses the inner one
    Reverses: delete both tests
37. F4: move the refusing fixture to the boundary
    Taken:    declare 33 bytes in a 32-byte buffer — the nearest length the bound must refuse.
              A far-past length is refused by a bound wrong by any amount, so it locates nothing.
              This is the rule the same enumeration already applies elsewhere
    Rejected: add a second fixture at the boundary and keep 4096. The far one then tests nothing
              the near one does not
    Reverses: restore the far fixture
38. F5: the ▲ count
    Taken:    count the marks, publish the number (ten) and name the rows, and state plainly that
              the marker is applied inconsistently and that Outcome is the load-bearing column.
              A correction stated is worth more than a marker rationalised
    Rejected: re-mark the rows to fit the sentence. The mark's intended meaning is not
              recoverable consistently from the table, so re-marking would be invention
    Reverses: restore "The four rows marked ▲ were the blank sides"
39. F6: ComposedBox::store()'s panic
    Taken:    document it with a `# Panics` section naming why `split_composed_box` cannot reach
              it. Unreachable through the crate's own constructor, reachable by a hand-built value
    Rejected: make the fields private and add accessors. A wider API change than the omission
              needs, on a dev-only crate whose callers build the struct directly
    Reverses: delete the section
40. Design question 2: which argument justifies the committed lockfile
    Taken:    keep the transitive-graph argument, remove the single-dependent one from
              `tests/build_configuration.rs`, and write down at both sites that the two are
              different claims — the second bears on the feature line, not on the graph
    Rejected: keep both. One of them does not carry the claim it was offered for, and a reader
              cannot tell which without doing the work again
    Reverses: restore the removed sentence

Self-review, after the round-7 gates

One defect raised against this round's own diff and fixed in 0eb7b1bb, gates re-run after:
README.md still described the enumeration as "one row per refusing branch" across three
functions, and still described the lockfile without --locked while repeating the
single-dependent argument this round removed from the test. A prose artefact contradicting the
code it documents is the same class of defect as F5, in the same round that closes F5, so it was
fixed rather than deferred. Both passages now match the module doc and the manifest.

Residual risk after round 7

  • No automated gate reaches this crate until CI: run check-c2pa in the PR lane and test-c2pa in extended (nothing calls either today) #541 wires check-c2pa into the per-PR lint lane
    and test-c2pa into extended CI. Unchanged since round 4, restated above as an open question,
    and still a merge judgement for the human.
  • The mutation results in this body remain hand-produced. Five this round, 21 in round 5.
    Reproducible from the tables; nothing re-checks them. The enumeration and its machine check are
    the mitigation — a branch without a row, or a row naming a function that does not exist, is now
    visible — not a gate.
  • The machine check reads text, not the AST. It confirms a name the table links is defined as a
    function in this file; it cannot confirm the test is the one that pins that branch, and it
    cannot see a branch that has no row at all. Adding a row is still a human act; forgetting one is
    still caught only by review.
  • --locked pins the resolution, not its freshness. A cargo update still moves the graph in
    one command; what changed is that the move is now a committed diff a reviewer sees.
  • The padded-slot configuration is untested (tooling/c2pa-oracle: exercise a reserved slot larger than the signed store, where the two locators diverge #598); XLBox remains untested against a real
    store
    ; the span's upper bound is still unchecked against its enclosing box (decision 22).
    Round-3 and round-5 residuals, unchanged.
  • Every c2pa-rs finding stays pinned to =0.90.21, with the whole resolved graph now held by
    a committed lockfile that --locked makes load-bearing, and every specification count to the
    vendored C2PA 2.4 HTML.

Round-7 verification re-established on the pushed head, and the mutation-gate claim closed

Round 7's evidence was produced while the round was still open, and the round ended before the
gate claim above was closed. Every claim in the round-7 section has since been re-established
first-hand against the pushed head 0eb7b1bb, on a tree verified clean before and after each
step. No code changed: this section adds evidence, not commits.

The mutation gate. The claim above — that the gate selects nothing from this pull request —
is now executed rather than reasoned. Against this pull request's own base branch,
origin/feat/444-avif-c2pa-reserve, not origin/master:

Command Result
GAMUT_MUTANTS_BASE=origin/feat/444-avif-c2pa-reserve mise run mutants-diff -- -- --list exit 0, INFO No mutants to filter, zero mutants listed

That is the whole selection, not a shard of it: git diff --name-only origin/feat/444-avif-c2pa-reserve...HEAD
lists fifteen files, none of them under crates/, and tooling/c2pa-oracle is excluded from the
workspace, so cargo-mutants cannot reach a single line this pull request changes. The four
Incremental (PR diff) shards on this head are green for the same reason — they had nothing to
mutate. Read the gate as an absence of selection, not as coverage; the enumeration and its
machine check remain the only thing standing where a mutation gate would.

The four falsifiers, re-executed. Each was applied, the rebuild confirmed from cargo's own
Compiling c2pa-oracle line, then restored by copy with git status verified empty:

Falsifier Outcome
Delete tooling/c2pa-oracle/Cargo.lock, run mise run check-c2pa fails, exit 101: cannot create the lock file … because --locked was passed, and the file is not regenerated
Rename an_xlbox_of_exactly_the_header_size_is_a_length at its definition only every_test_the_enumeration_names_exists_in_this_file fails, naming the stale row
is_jumbf_not_found-> bool { true } dies on a_c2pa_error_other_than_jumbf_not_found_is_not_an_absent_manifest
.filter(|end| *end <= buffer.len() + 1) dies on a_declared_length_running_past_the_buffer_is_an_unusable_length_not_an_absent_superbox, printing the span it wrongly returned, 0..33

The gates, re-run on the pushed head.

Command Result
mise run test-c2pa pass — exit 0, 45 tests across 7 binaries (24 unit), 0 failures
mise run check-c2pa pass — exit 0
cargo clippy --locked --manifest-path tooling/c2pa-oracle/Cargo.toml --all-targets -- -D warnings pass — exit 0, no warnings
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt-check pass — exit 0
mise run check-tests passmodule docs, pinned proptest seeds and oracle filenames all conform
convco check origin/feat/444-avif-c2pa-reserve..HEAD passno errors in 30 commits
mise run check-release-deps passrelease dependency graph has no dev-only workspace edges
mise run check-ffi-features passgamut-ffi features in sync with gamut

mise run lint and mise run test are still not claimed: neither reaches a workspace-excluded
crate, and this pull request changes no file under crates/.

No human approved this plan or these decisions: this is an unattended run, and the record above is
what a human reads afterwards.

Round 8 (final repair) — feat/447-c2pa-oracle, head 722c5ba1

The round-7 review re-executed all four of that round's falsifiers, added two of its own against
checks this branch had never claimed, and verified the mutation-gate statement link by link. Every
one of its findings is prose or a count; none is a defect in an assertion. This round repairs
exactly those, in five commits — two carrying the repair and three from self-review after the
push: a sentence the second commit had left as a riddle, "two versions" where two names occur at
three, and a re-wrap of the three comments the longer clause left ragged. It adds nothing: no test,
no check and no row.
mise run test-c2pa runs the same 45 tests here that it ran on 0eb7b1bb, and the enumeration
still has 12 rows.

What reproduced

Every finding reproduced; nothing failed to.

  • A — the README overstated the machine checks. Re-executed the reviewer's falsifier: a fifth,
    refusing arm added to declared_store_len (0xFFFF_FFFF => Err(UnusableSuperboxLength(…))) with
    no row in the enumeration. mise run test-c2pa exits 0 — 24 of 24 unit tests, 45 in all —
    because all three checks read rows → tests and none looks at the code. Restored by copy; the
    arm is not in either commit.
  • B — "the 325 transitive packages under c2pa" over-attributed. Derived below. The lockfile
    carries 326 entries; the closure under c2pa is 307.
  • C — a round-7 correction quoted a sentence the same edit had replaced. Confirmed against the
    published body: Correction to the round-5 mutation table quotes "The four rows marked ▲ were
    the blank sides", and the round-5 section no longer contained that sentence — the round-7 edit
    had rewritten it in place. Restored; see below.
  • D — "One test here is in no row" was off by three. Derived: four of 24.
  • E — the membership gloss is not mechanically applicable. Confirmed by reading, with no
    membership consequence
    : the table is complete under the intended reading, and this round adds
    and removes no row. What changed is how membership is stated.

B — the transitive-package figure, derived

Correcting five sites that each said 325: README.md (twice), tooling/c2pa-oracle/Cargo.toml
(twice), mise.toml, tests/build_configuration.rs (three times), and this body (four times, in
dated entries — see the note below). The four figures below are each re-derivable by the command
beside them, run at the repository root on this head:

Figure Command Output
307 packages under c2pa — the version-aware closure of its entries in the committed lockfile, c2pa excluded the tomllib walk now published in README.md § Build configuration is not optional 307
326 entries in the lockfile in all grep -c '^name = ' tooling/c2pa-oracle/Cargo.lock 326
240 packages compiled with default features off, c2pa included cargo tree --manifest-path tooling/c2pa-oracle/Cargo.toml --locked -e normal -p c2pa --prefix none | sed 's/ (\*)$//' | sort -u | wc -l 240
21 package names occurring at more than one version (syn and getrandom at three), which is why the walk must carry versions — a name-keyed walk lands on 285 names grep '^name = ' tooling/c2pa-oracle/Cargo.lock | sort | uniq -d | wc -l 21

326 = 307 + c2pa + c2pa-oracle + 17 reachable only through the four gamut-* dev-dependencies,
whose versions the root workspace lockfile resolves.

307 is the population the argument needs. Every one of the five sites is arguing that the =
pin holds one line and a committed lockfile holds the rest, so that
the_resolved_dependency_graph_contains_no_openssl_package reads a resolution nobody just wrote:
an openssl package can only arrive beneath c2pa, and it is resolution, not compilation, that
a missing lockfile redoes. So neither 326 (which includes packages the pin was never asked to hold)
nor 240 (which counts what is built, not what is resolved) carries the claim.

C — the round-5 sentence is restored, and the record's own rule with it

Round 7 corrected a round-5 count and, in the same edit, rewrote the sentence it was correcting.
A reader following the correction's pointer reached text that no longer said what the correction
quoted, which makes the correction unverifiable — the opposite of its purpose. The round-5
sentence

All 21 mutants die. The four rows marked ▲ were the blank sides.

now stands again exactly as round 5 wrote it, and Correction to the round-5 mutation table under
Round 7 above — which counts ten marks and explains why Outcome, not the marker, is the
load-bearing column — is left to do the work on its own. That is the only in-place edit this round
makes to a dated entry, and it is a reversal of an in-place edit, not a new one.

The same rule is why the four 325s in the dated round-5 and round-7 entries of this body are
left standing
. Correcting them in place would repeat exactly the defect above. This section is
their correction: where those entries say "the 325 transitive packages under c2pa", read 307,
derived above. The five source sites are not a record and were edited directly.

A, D, E — what the enumeration and the README now say

  • A. README.md said a branch added without a row "is the finding, and three tests in that
    same module parse the table and enforce it". They enforce a blank cell, and a name nothing in the
    file defines — nothing more. The sentence is narrowed to that, and the README now states that the
    checks read rows → tests, so a branch added without a row leaves them green and completeness
    is held by review. The per-function branch-count guard that would close the other direction is
    filed as tooling/c2pa-oracle: the branch enumeration is checked rows → tests only, so a branch added without a row stays green #616 rather than built: the round-7 review judged this loop to be generating its own
    findings, and new machinery at its close is how such a loop fails to end.
  • E. The gloss "every point where the code chooses between two answers about a buffer" cannot
    be applied mechanically — the row the scope widening was made for, is_jumbf_not_found, chooses
    between two answers about an error. A boundary that needs an exception is the wrong boundary
    (decision 35's own words), so membership is no longer derived from prose at all. The table is a
    named list over four named functions — find_jumbf_superbox, declared_store_len,
    jumbf_superbox_span, is_jumbf_not_found — and the two discriminating branches deliberately
    outside it are named in the same doc comment: reserve_then_fill's store.len() != slot.len()
    guard, which differs from a row by reach and not by subject (it needs c2pa-rs and a gamut
    encoder, so it is pinned in tests/reserve_then_fill.rs), and Display for OracleError's match
    over the variants, which picks the wording for an outcome another branch already decided and is
    exercised by every refusal test that asserts on a message. A named list needs no exception,
    because it does not claim to be derivable.
  • D. "One test here is in no row" is now four of 24, and the count carries the commands that
    re-derive it: grep -c '^ #\[test\]$' src/lib.rs → 24, grep -c '^ //! | \[' src/lib.rs
    12 rows, which name 20 distinct tests between their two columns. The four unrowed are
    a_span_is_still_found_when_a_decoy_jumb_precedes_the_superbox and the three checks over the
    table itself. The doc now states the asymmetry that let the count go stale: the checks run rows →
    tests and never tests → rows, so neither a test without a row nor a stale sentence about them is
    visible to the suite.

Filed

An open question for the reader, restated and not answered here

Should a pull request whose crate no workflow reaches merge? Nothing under .github/ names
check-c2pa or test-c2pa (#541); the crate's whole automated reach is fmt-tooling-check.
Inside the crate, the enumeration's completeness is likewise held by review and not by a check
(#616). Every result in this body was therefore produced by hand, on the pushed head, with the
command and its output shown. Whether that is an acceptable state to merge in is a human's
judgement; this round takes no position, and neither issue is a blocker this round claims to have
closed.

Round-8 gates

Run in this worktree on the pushed head 722c5ba1, and on each pushed head before it. mise run fmt / fmt-check carry the
documented __CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) prefix.

Command Result
mise run test-c2pa pass — exit 0, 45 tests across 7 binaries (24 unit), 0 failures
mise run check-c2pa pass — exit 0
cargo clippy --locked --manifest-path tooling/c2pa-oracle/Cargo.toml --all-targets -- -D warnings pass — exit 0, no warnings
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt then mise run fmt-check pass — exit 0, no reformatting
mise run check-tests passmodule docs, pinned proptest seeds and oracle filenames all conform
mise run check-commits passno errors in 62 commits
mise run check-release-deps passrelease dependency graph has no dev-only workspace edges
mise run check-ffi-features passgamut-ffi features in sync with gamut

On the pushed head 722c5ba1 every required check is green: Format & Metadata, Clippy &
Doctests
, Coverage (test gate) and all four Incremental (PR diff) shards pass; Full
workspace
is skipping, as it is on every pull request that touches no shipped crate.

mise run lint and mise run test are not claimed, for the same reason as in round 7: neither
reaches a workspace-excluded crate, and this pull request still changes no file under crates/.
The mutation gate selects nothing from this pull request; the round-7 section above establishes
that against this branch's own base, and this round changed no line it could reach.

Decisions appended in round 8

41. Finding A: what the README may claim for the machine checks
    Taken:    narrow the sentence to what the three checks hold — a blank cell, or a name nothing
              in the file defines — and state that they read rows -> tests, so completeness is
              held by review. File the per-function branch-count guard as #616 instead of
              building it: the review judged this loop to be generating its own findings, and
              adding machinery at its close is how the loop fails to end
    Rejected: implement the guard now and keep the stronger sentence. It is the right check and
              the wrong moment; a new check would need its own falsifier, its own review, and a
              round nobody is going to run
    Rejected: delete the sentence. The reach of the checks is worth stating; only the overclaim
              had to go
    Reverses: implement #616 and restore the original sentence
42. Finding B: which population the transitive-package figure counts
    Taken:    publish 307 — the version-aware closure under `c2pa` in the committed lockfile, that
              crate excluded — with the walk that derives it beside its first use, plus the 326
              total, the 240 compiled and the 21 duplicated names that make a name-keyed walk
              wrong. Fix the five source sites; correct the body's dated entries by appending
              rather than by editing
    Rejected: keep 325 and define it as "lockfile entries minus this crate". It is a true number
              for a population no site is arguing about, and it invites the same re-derivation
              again
    Rejected: publish 240, the graph actually compiled. A missing lockfile redoes resolution, not
              compilation, so 240 does not carry the claim
    Reverses: restore the single round figure
43. Finding C: a correction may not rewrite the text it corrects
    Taken:    restore the round-5 sentence exactly as it stood and let the appended round-7
              correction beside it do the work; leave the body's four dated `325`s standing and
              correct them by appending. A correction whose subject was rewritten cannot be
              checked, which is the whole reason the record is append-only
    Rejected: re-quote the replacement text in the correction. That makes the correction
              self-consistent and the round-5 entry permanently unrecoverable
    Reverses: re-apply the in-place edit
44. Finding E: stop deriving the enumeration's membership from prose
    Taken:    make the table a named list over four named functions and name the two
              discriminating branches deliberately outside it, with the reason for each. The
              shipped boundary needed an exception, and decision 35 already holds that a boundary
              needing an exception is the wrong boundary
    Rejected: redraw the boundary so the exception disappears. No wording covers the
              `is_jumbf_not_found` row and excludes `reserve_then_fill`'s guard on any ground but
              reach, and finding a wording is a round-9 problem for a table that is complete today
    Rejected: add a row for the two excluded branches. Neither is pinned in this file; a row whose
              columns point outside it is a different table
    Reverses: restore the gloss
45. Design question 4: whether a pull request no workflow reaches should merge
    Taken:    restate it in the body as the open question it is, name both machine gaps (#541 for
              CI, #616 for the enumeration), and leave it to the reader. It is a judgement about
              this repository's standards, not about this diff
    Rejected: take a position either way. Neither this lane nor the review that raised it has the
              standing to spend a human's judgement for them
    Reverses: answer it in the body

Residual risk after round 8

No human approved this plan or these decisions: this is an unattended run, and the record above is
what a human reads afterwards.

Unresolved review notes

None outstanding from the round-6 review: F1–F6 and design questions 1–4 are closed above, and
design question 5 is restated as an open question for the reader rather than resolved, because
whether a crate no CI job reaches should merge is a human's judgement.

From the round-7 review: findings A–E are closed in the round-8 section above — A, B, D and E
by change, C by restoring the sentence the round-7 edit had replaced — and design questions 1, 2
and 3 with them. Design question 4 is restated there as an open question and deliberately left to
the reader: whether a pull request whose crate no workflow reaches (#541), and whose enumeration's
completeness no check holds (#616), should merge. Nothing else is outstanding, and no round-8
review is planned.

The #239 epic splits the work: gamut locates, bounds, carries and
reserves a C2PA manifest store, and validation belongs to c2pa-rs.
Nothing has stood on the far side of that seam until now.

The crate is workspace-excluded and invoked by manifest path, the
`tooling/gamut-dng-real-conformance` shape, so no shipped crate can
gain an edge to it and `check-release-deps` never has to reason about
it. `c2pa` is built `default-features = false, features =
["rust_native_crypto"]`: its defaults are `["openssl", "default_http"]`
and `openssl` is pulled vendored, which would compile OpenSSL from C
source into a dev build of a repository whose whole point here is that
no crypto reaches the shipped graph.

`EphemeralSigner` mints an Ed25519 chain in memory, so no key material
is committed and no fixture expires. Trust-list checking is off, since
an ephemeral certificate is on no list and this oracle measures whether
gamut moved a byte, not whose key signed the file.

`c2pa::jumbf_io` is deliberately not built on: its stream entry points
name the crate-private `CAIRead`/`CAIReadWrite`, and its usable half
returns bytes without offsets, which is not the question a container
library asks.

Refs #447.
Direction 1: gamut-avif reserves a slot, c2pa-rs signs a store over the
finished file through its own placeholder workflow, the host patches it
in at the range `encode_with_report` gave, and the file validates. The
store exactly fills the slot, so nothing after it moves.

Direction 2: c2pa-rs embeds a store and chooses the placement; both
gamut-avif and gamut-heic must report the *identical* span, compared
against one derived from the store's own JUMBF header rather than from
gamut's parse of the ISOBMFF framing. Handing gamut's extraction back
to c2pa-rs as an external store is the sharpest form: a span off by a
byte at either end does not survive it.

Two observations settle assumptions gamut-heic's STATUS.md records as
deferred. Every store the reference implementation writes opens LBox +
`jumb`, the TBox check gamut declines to assert. And driven through
`BuilderIntent::Update`, c2pa-rs writes the 8-byte merkle offset in
front of an `update` store exactly as for the other two purposes, so
the one in-spec layout gamut's probe cannot discriminate is not one the
reference implementation emits.

Refs #447.
…earching

`find_jumbf_superbox` discarded a match below offset 4 instead of continuing past
it, so a stray `jumb` ahead of the real superbox made the whole buffer look
store-less. `declared_store_len` read `LBox` as a plain 32-bit length, so the two
values ISO box syntax reserves came back as lengths of 0 and 1 — a silently wrong
span on the side of the differential whose answers are treated as the reference.

A JUMBF box is a JPEG-family standard box; C2PA 2.4 §8.4.2.3 restates that syntax
where it defines the C2PA salt. `LBox == 0` means the box runs to the end of the
file, `LBox == 1` means the 8-byte `XLBox` after `TBox` carries the length. Both
are now read, `LBox` 2..=7 is refused with a typed `UnusableSuperboxLength`
naming why, and no length is ever guessed.

Also corrects `OracleError::Asset`'s doc, which said the variant was raised only
by a caller's closure while `reserve_then_fill` raises it too.

Refs #447
The test billed as the sharpest form of "gamut locates the identical byte range"
read `slot_bytes` rather than `range`, so a one-byte shift injected into the
reported range left it green while four other tests failed. It now cuts the store
out of the asset at the range gamut reported, which is what its name claims.

`gamut-avif` bounds a slot by the box, so it reports the store and anything after
it; the box-bounded assertion is therefore containment, and the equality — that
c2pa-rs sizes the box to the store exactly, writing no padding — is asserted on
its own and named for c2pa-rs. A future release that padded fails that one test
instead of being misread as gamut mis-bounding.

Also guards the box-framing slice at both ends: only the start was checked, so a
wrong offset would have surfaced as an index panic naming no side.

Refs #447
…ts directory

README.md cites c2pa-rs's own source by line number for two of the claims it
records, and `^0.90.21` lets a patch release move those lines under a citation
nobody re-checks. There is no committed lockfile to hold the resolution instead —
`.gitignore` excludes `tooling/*/Cargo.lock` because a workspace-excluded oracle
resolves standalone — so the version is pinned with `=` and a third drift guard
in tests/build_configuration.rs keeps it that way.

Adds the empty `[workspace]` table `tooling/gamut-fuzz` already carries, so cargo
stops walking past `tooling/` for a workspace root and the crate builds the same
wherever the checkout sits.

Refs #447
The old test built its derivative with a fresh `plain_avif()` and asserted
c2pa-rs found no store in it. There was no causal path from the signed parent:
with no parent created at all, the same assertion holds, so what it pinned was
only "the AVIF encoder does not spontaneously emit a ContentProvenanceBox".

Issue #447's third bullet names #428's `C2paPolicy`, and reaching it needs no new
library code. The derivative is now encoded from what `MetadataEmbedder` returned
for a model carrying the store `gamut-avif` located in the parent, so deleting
the parent breaks the test and a policy that forwarded the store would fail it.
Verified by assigning the store to `EncodedMetadata::c2pa` by hand: c2pa-rs then
reports `Valid` — a bit-identical re-encode wearing another party's claim — which
the file now records as the failure mode a `Preserve` arm would open.

The second test drives `C2paPolicy::Reject` and asserts the refusal names the
parent's store by its located length. Uses the `gamut-metadata` dev-dependency
added in the preceding commit.

Refs #447, #428
Two comments implied this crate is reached by CI the way the DNG conformance
tier is. It is not: no workflow under .github/ names it, and being
workspace-excluded with no dependents it is invisible to `clippy --workspace`
and `test --workspace` too. Its whole automated reach is `fmt-tooling-check`.
Both comments now say so and point at issue #541, which wires `check-c2pa` into
the PR lane and `test-c2pa` into extended.

README also gains four records the round-1 review asked for: c2pa-rs writes no
padding after the store (the observation that lets a box-bounded and an
LBox-bounded locator agree); §A.5.3 placement is out of scope here because
c2pa-rs validates regardless, so gamut-avif's own suite is the authority;
`ValidationState::Trusted` is unreachable by construction with `EphemeralSigner`
and no trust list or certificate is checked in; and the spec basis for reading
the two reserved JUMBF `LBox` values, with ISO 19566-5 noted as unvendored.

Refs #447
`declared_store_len` refused an `LBox` of 2..=7 — shorter than the 8-byte
header it sits in — but took an `XLBox` at face value. `XLBox` of 0, 1, 4,
8 or 15 all came back verbatim, so a span of `8..8` (an empty store) or
`8..12` was reported as a length, which is precisely the guessed answer the
typed refusal exists to prevent.

`gamut_isobmff`'s box reader applies one rule to both header sizes,
`size < header_size`; apply it to both here too. Five values are pinned
beside the existing arms, and 16 — the header alone, the smallest legal box
— is pinned as accepted so the refusal cannot creep past it.

Two error arms go with it. Both said a length "does not fit this platform's
usize", and neither can fire: this is dev-only host tooling that is never
cross-compiled, so `u32` and `u64` both reach `usize` losslessly. A compile-
time assertion states that where the arms were, and defends something real.

The rustdoc also over-claimed its source. C2PA 2.4 §8.4.2.3 mentions `LBox`
once, in the salt sentence quoted, and `XLBox` not at all; the grammar with
the reserved values is ISO 19566-5:2023, paywalled and not vendored here,
procurement tracked as #441. Say so at the call site, as README.md already
does, rather than attributing a convention to a clause that does not carry it.
`crates/gamut-metadata/tests/roundtrip.rs` already asserts that `Reject`
refuses a model carrying a store, and names the same `len` payload, from a
synthetic 8-byte store with neither c2pa-rs nor an encoder in reach. The
copy here minted an ephemeral chain, signed, read, encoded, parsed and
located to reach a claim whose payload is a `usize`, and c2pa-rs judged
nothing in it: five ways to fail for one thing being named.

Its one contribution was proving `extract` populated the model, without
which the surviving test passes vacuously — a policy asked to drop nothing
drops nothing. That is now asserted where it is needed, in the helper that
builds the derivative.
The three subtractions from `update.start` were unguarded, so a store gamut
located within 15 bytes of the file start would abort the test with a
subtraction overflow naming neither side of the differential. `box_framing.rs`
writes a comment about exactly that hazard and guards with `checked_sub`;
derive all three offsets from one guarded base the same way, so a wrong
location is reported as the defect it is.
…d bound

Three changes to what the README claims. The JUMBF-header section now states
one rule for both header sizes rather than only the 8-byte one. The test
table no longer credits `tests/no_copy_forward.rs` with the `Reject` arm it
no longer restates.

And a third limit joins "What this oracle does not check": `jumbf_superbox_span`
bounds a store against the end of the buffer, never against the end of the
`ContentProvenanceBox` around it. Closing that would mean parsing ISOBMFF
framing here, making the oracle depend on the structural understanding it
exists to check independently — a second copy of the walk proves nothing about
the first. The independence is worth more than the check, so the limit is
accepted and written down; #534, which points this crate at PNG, TIFF and RIFF,
is where a store followed by further container bytes first appears.
The `[workspace].exclude` comment said `mise run check-c2pa` "is the
compile-only half the per-PR lint lane affords", in the present tense.
`grep -rn c2pa .github/` returns nothing: no workflow calls either task, which
is what `tooling/c2pa-oracle/README.md` and #541 both say. Say the same here.
`AvifContainer::c2pa` became `c2pa_slot` and `c2pa_manifest_stores` became
`c2pa_slots` on the base branch, for what they report. `gamut-heic` keeps the
old names — its bounds differ — so only the AVIF call sites move.
…sence

`jumbf_superbox_span` raised `NoJumbfSuperbox` when a superbox's declared
length ran past the end of the buffer. There is a superbox — the search
found its `LBox` and `jumb` — and it is the length that is unusable, which
is exactly what `UnusableSuperboxLength` was introduced to name. Reporting
it as absence misattributes on the reference side of the differential, where
a wrong answer becomes gamut's fault.

`NoJumbfSuperbox`'s doc also claimed a single raising function while two
raised it; it now names `find_jumbf_superbox` as the only one and says the
other two propagate it.
`declared_store_len` applied the header minimum to the `LBox` 2..=7 and
`XLBox` below 16 arms but not to `LBox == 0`, so a 4-to-7-byte buffer read
back as `Ok(4)`..`Ok(7)` — a declared length shorter than the 8-byte header
it counts, which is the defect the other two arms exist to refuse. The
to-end-of-buffer reading is still a length counting that header, so it obeys
the same rule.

The 2..=7 arm also had a single mid-range example behind it, so widening or
narrowing that range by one survived the suite. Both sides of both refusals
now have their own test: a sweep over every reserved value, and a boundary
case at the smallest legal length.
The guard asserting the parent's store reached the model checked only
`is_some()`, while its message claimed the parent's store "must be in the
model". `MetadataExtractor` carries a C2PA block through whatever its
length, so a locator returning an empty vector left the test passing: it
killed "no block arrived", not "the parent's bytes are what the embedder
saw".

It now compares the model's bytes against the oracle's own reading of the
parent — the store's JUMBF header, not gamut's ISOBMFF walk. Comparing back
against the value the model was built from would say nothing.
Two prose references still named `AvifContainer::c2pa`, which the base merge
renamed to `c2pa_slot`; the README updated its other reference to the same
method in that commit, so this finishes the sweep. The companion reference
beside it, to `HeifContainer::c2pa`, is correct and stays.

The README also said C2PA 2.4 §8.4.2.3 "spells the syntax out" and then
listed the oversized-header field as part of it. `LBox` appears exactly once
in the vendored document and `XLBox` not at all: the section writes down the
4-byte length and the type, mentions no oversized-header field and states no
reserved value. The rustdoc already said so; the README now matches it, and
records the header minimum as one rule over every arm.
Three consecutive reviews each found one more refusal arm in the header
reader with a test on only one side of it, because each looked at the arm
the last one had missed rather than at the set. Write the set down and
close it.

The module documentation now carries a table with one row per refusing
branch across find_jumbf_superbox, declared_store_len and
jumbf_superbox_span, naming the test that pins each direction — the input
the branch refuses, and the nearest input it must not refuse. Four rows
had a blank side:

- the truncated LBox field: nothing exercised a buffer below four bytes,
  so deleting the refusal outright left every test green. Deleting it
  sends such a buffer into the LBox == 0 arm, which refuses it too, so
  the new test asserts the message rather than that an error occurred;
- the skip that keeps a `jumb` too early to carry an LBox from being read
  as a superbox type. The decoy sat at offset 0, far from the boundary,
  so a search that considered one window too early was unobserved — and
  under it a buffer with the marker at offset 3 panics with a subtraction
  overflow. The decoy now sits at offset 3, and a superbox whose LBox
  opens the buffer pins the other direction;
- a buffer carrying no `jumb` at all, which nothing asserted was reported
  as an absent superbox;
- a declared length that overflows the offset it is added to, the one
  input on which dropping the checked add hands back a backwards range
  that passes the bound check by arithmetic accident.

Every arm's two directions were then mutated by hand, in both directions,
and each mutant dies.
…stating it

Two of the three assertions in the slot-filling test could not fail.
`reserve_then_fill` refuses a store that is not the slot's length before
it returns, so restating that equality asserted the oracle's own guard
had run; and it writes the slot with `copy_from_slice`, so "the slot's
bytes are the store's bytes" was true by construction. Only the third —
that the signed store is the length the placeholder asked for — says
anything about c2pa-rs, so the test keeps that one and is renamed for it.

The guard itself is now exercised rather than assumed: reserving one byte
more than the placeholder asked for makes the signed store no longer fill
the slot, and the refusal has to name both lengths. Deleting the guard or
inverting it both die.

A slot larger than the store is also the one configuration nothing here
signs successfully, and it is where a box-bounded and an LBox-bounded
locator legitimately diverge. Filed as #598 and cited at the site.
The model's C2PA block is bounded by the enclosing box, so it carries any
padding a writer leaves after the store; the span it was compared against
is bounded by the store's own LBox. Asserting the two equal makes a
padding release of c2pa-rs fail here, where it reads as the model
carrying the wrong bytes, rather than at the test that pins the
no-padding observation by name.

Compare by containment instead, the way `locate_embedded.rs` states the
same relation, and say at the site which test owns the padding fact.
The exact `=` version pin was chosen instead of a lockfile, on the
grounds that it was the smaller fix and the one that kept the prose
honest. It holds the direct dependency and nothing under it: the three
hundred-odd transitive packages re-resolve on every invocation. So the
no-OpenSSL assertion, which is an assertion about the resolved graph,
could only ever inspect the resolution cargo had written for it moments
earlier — which is not an assertion.

Commit `Cargo.lock` for this crate as a deliberate exception to the
blanket `tooling/*/Cargo.lock` rule, with the exception and its reason
recorded beside the rule. The pin stays: it guards a different thing,
the line-numbered citations into c2pa-rs's source, and it is the line a
human edits on purpose where the lockfile is regenerated by any `cargo
update`.

The exception is itself a drift guard, because dropping the negation is
one edit that no other test here would notice.
The README asserted that each side of each refusal is pinned by its own
test. That is a claim about a set of branches, and it was false: four of
them had one side untested. Now that the set is enumerated in the test
module, say where the enumeration lives and what a blank side means, name
the one refusal that lives outside that layer, and record that the tests
assert the refusal's message rather than that an error occurred.

Also replaces the passage saying no lockfile holds the resolution, which
the committed lockfile reverses, with the reason it was committed.
Self-review of the enumeration. The test on the accepted side of both of
`jumbf_superbox_span`'s refusals was named for a length; it asserts a
span. And the table has one test in both columns, which reads as a gap
and is not: a 4-to-7-byte buffer is one whose LBox field was read
successfully and whose resulting length is then too short, so it is the
accepted side of the truncation branch and the refused side of the
LBox == 0 branch. Adjacent branches on the same input share a boundary;
that is what makes it one. Say so where the table is.
Both the enumeration and the README claimed every test asserts the
refusal's message. Three of them assert a variant instead, because
`NoJumbfSuperbox` is fieldless and has no message to name. The claim that
matters is that each test says *which* refusal fired rather than that one
did, so state it that way.
…e used

The lockfile committed for this crate held nothing: neither `test-c2pa` nor
`check-c2pa` passed `--locked`, so deleting it left all four build-configuration
tests green while cargo wrote a fresh resolution over it. Verified: with the file
removed the suite passed and the regenerated graph differed from the committed
one (bitflags 2.13.1 -> 2.13.2, ryu 1.26.0 -> 1.26.1), which is exactly the
resolution the no-OpenSSL assertion then read.

Both tasks now pass `--locked`, so a missing or stale lockfile fails the task
instead of being regenerated. That also makes the `.exists()` assertion
unfalsifiable -- cargo creates the file while building the test binary, and under
`--locked` it cannot be absent at all -- so it is deleted rather than left as
decoration. The `.gitignore` negation drift guard, the falsifiable half, stays.

The rationale on the record is corrected while it is being read: the exception
rests on the 325 transitive packages the `=` pin does not hold, not on nothing
else in the tree depending on `c2pa`. That second fact is true but bears on the
feature line -- no other dependent can unify `openssl` back on -- and says
nothing about which versions those packages resolve to.
`store()` slices `composed` from `store_offset` and panics if that offset is
past the end. `split_composed_box` cannot produce such a value -- it derives the
offset by finding a superbox type inside the buffer -- but both fields are `pub`,
so a hand-built value reaches it. Documented rather than defended: narrowing the
field visibility is a wider API change than the omission needs.
The fixture declared `LBox = 4096` in a 32-byte buffer -- 4064 bytes past the
bound, so any bound off by any amount still refuses it. Verified: with the bound
mutated to `*end <= buffer.len() + 1` the whole suite stayed green.

Declaring 33 bytes instead is the nearest input the bound must refuse, and the
same mutant now fails the test with the span it wrongly returned (`0..33`). The
accepting side was already at the boundary; this is the rule the same enumeration
applies elsewhere, applied here.
…fusals

The enumeration's scope was branches that can refuse an input. That boundary
excluded exactly the predicates whose whole job is telling two cases apart, and
one of them had a blank side: `is_jumbf_not_found` mutated to `-> true` survived
all 40 tests, because no test ever observed it answering `false`. Only the
`-> false` direction was pinned, by the differential in `tests/no_copy_forward.rs`.

The scope is now every discriminating branch on the crate's own parsing surface,
and the columns are `Taken` / `Not taken` rather than `Refuses` / `Accepts`, which
generalises without losing the refusal reading. Three rows join: the two reserved
`LBox` values, which resolve a length rather than rejecting one and were relegated
to prose, and `is_jumbf_not_found`.

Its `false` side is pinned on `c2pa::Error::JumbfBoxNotFound` -- the nearest
neighbour there is, raised only once a store has been found and something inside
it is missing, which is precisely the case the no-copy-forward claim must not
confuse with an absent manifest. Verified: the `-> true` mutant now fails that
test. Its `true` side is pinned inline as well as differentially, because the
inline test fails for one reason where the differential fails for anything wrong
in a re-encode.
Nothing enforced the table. Its 21 intra-doc links look like references but
rustdoc never compiles a `cfg(test)` module, so `broken_intra_doc_links` never
resolves them: renaming a pinned test and leaving its row stale kept the whole
suite and clippy green.

The table is now parsed out of the module doc at compile time -- the file reads
its own source -- and three tests check it, one reason each: the shape (four
cells, none blank, each of the three name columns carrying a link), that every
name it links is a function this file defines, and that every `tests/` file it
points at exists. Verified by falsifying all three: renaming
`an_xlbox_of_exactly_the_header_size_is_a_length` in its definition only, blanking
a `Not taken` cell, and misspelling `tests/no_copy_forward.rs` each fail exactly
the corresponding test.

This is what makes "a row with one side blank is the finding" enforced rather
than aspirational.
Two statements went stale in this round. The enumeration is no longer one row
per refusing branch across three functions -- it is one row per discriminating
branch across four, and three tests in the same module now parse the table and
enforce it. And the lockfile paragraph described committing the file without
`--locked`, which is the half that makes the committed resolution the one used;
it also repeated the single-dependent argument, which bears on the feature line
rather than on the graph.
…it counts

Five places said "325 transitive packages under `c2pa`". No command produces
that number for that population: the committed lockfile holds 326 entries, of
which 307 are the version-aware closure beneath `c2pa` — the packages whose
versions the `=` pin does not hold, and the population every one of those five
sentences is arguing about. The remainder are `c2pa` itself, this crate, and 17
reachable only through its four `gamut-*` dev-dependencies.

So state 307, and put the derivation beside its first use in `README.md`: the
walk over `Cargo.lock`, the count of the whole file, and the reason the walk has
to carry versions (21 names occur at two versions, so a name-keyed closure is a
different set). Note there too that only 240 packages are ever compiled with
default features off, and why the larger figure is the one the argument needs:
a missing lockfile redoes resolution, not compilation.
…a rule

`README.md` said a branch added without a row "is the finding, and three tests
parse the table and enforce it". They do not: adding a refusing arm to
`declared_store_len` with no row leaves all 24 tests green, because all three
checks read rows -> tests. Narrow the sentence to what they hold — a blank cell,
or a name nothing in the file defines — and say plainly that completeness is
held by review; the guard that would close the other direction is issue #616.

The membership rule was not applicable either. "Every point where the code
chooses between two answers about a buffer" excludes the `is_jumbf_not_found`
row, which chooses between two answers about an error, and a boundary that needs
an exception is the wrong boundary. So stop deriving membership: the table is a
named list over four named functions, and the two discriminating branches left
out — `reserve_then_fill`'s slot-length guard, which differs by reach rather
than by subject, and `Display`'s match over the variants, which picks wording
for an outcome already decided — are named beside it.

The count of unrowed tests was stale at one; it is four of 24, the other three
being the checks over the table itself. Derive it from two greps, and state the
asymmetry that let it go stale: rows -> tests never looks the other way.
The exclusion note said `reserve_then_fill`'s guard is pinned in `tests/` and
then explained it with a clause about "the last column of no row", which reads
as a riddle. The reason is mechanical: `every_test_the_enumeration_names_exists_in_this_file`
resolves every name a row links against this file's own definitions, so a row
pointing at an integration test would fail it. Say that instead.
Two of the 21 names that occur more than once in the lockfile — `syn` and
`getrandom` — occur three times, so "at two versions" is wrong. Say "more than
one", name the two, and give the figure a name-keyed walk actually lands on
(285 names, against 307 packages) so the reason the walk carries versions is
checkable rather than asserted.
… ragged

Replacing 325 with a longer clause left a short line mid-paragraph in
`mise.toml`, `Cargo.toml` and `tests/build_configuration.rs`. Reflow to the
column the surrounding prose uses. No wording changes.
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.

1 participant