feat(stelae): give a publisher digest, verify and inspect - #1188
feat(stelae): give a publisher digest, verify and inspect#1188scarmuega wants to merge 2 commits into
Conversation
The publisher-productization slice minus sign: an independent party reproduces a published stele's inscription from its own stores, and an operator sees what a repository holds without pulling it. - stelae: a Discarding SteleWriter — every layer framed, hashed and compressed exactly as a publish would, into std::io::sink. What comes back is the identity, which is the whole of a reproduction. - snapshot: history_for and same_network move from registry.rs into export.rs so a verifier reaches the contiguity rule without a registry and without the oci feature; Following reads a predecessor's canonical inscription off disk and extends it by the publisher's own rule; Attested carries a published history verbatim; verify_reproduction rebuilds every layer and compares descriptor by descriptor before digest against digest; Standing reads where a node stands against a repository before anything is built; registry::verify streams every blob against both of its digests; registry::inspect reads the two documents and no layer. - dolos snapshot digest: the canonical inscription and its sha256 from local stores and no registry, --chain-from for a chained digest, --output for a determinism job. stdout is the document, byte-exact. - dolos snapshot verify: transport checks by default, --reproduce opt-in (it costs what a publish costs). Digests only; signatures and chain provenance are phase 5, and the help says so. - dolos snapshot inspect: sequence, position, history, one line per layer with the compressed size the manifest carries; --json emits the canonical inscription verbatim, which is what --chain-from takes. - dolos snapshot publish: a repository already at the node's sequence reports nothing to publish and exits zero (--require-new makes it an error); the gap refusal now names the distance alongside both sequences. The registry end-to-end tests (publish, snapshot_verify, restore_registry) are #[ignore]d and were run against a spawned distribution registry; output in the PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
📝 WalkthroughWalkthroughThis PR adds deterministic snapshot reproduction, predecessor-history validation, OCI registry verification and inspection, repository standing checks, and ChangesSnapshot lifecycle
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant Operator
participant SnapshotCLI
participant Registry
participant LocalStores
participant Discarding
Operator->>SnapshotCLI: run verify or digest
SnapshotCLI->>Registry: inspect or verify stele
Registry-->>SnapshotCLI: metadata, layer verification, identity
SnapshotCLI->>LocalStores: load stores and build plan
LocalStores-->>SnapshotCLI: export inputs
SnapshotCLI->>Discarding: reproduce layers without persistence
Discarding-->>SnapshotCLI: canonical inscription and digest
SnapshotCLI-->>Operator: verification or reproduction result
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (5)
crates/snapshot/src/export.rs (1)
651-673: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winCheck the network before the walk, as the sequence is checked.
verify_reproductionrefuses a sequence mismatch before it touches a store. It does not refuse a network mismatch. If the local stores hold another chain, the walk runs to completion and the divergence surfaces at the final digest comparison assubject: "the inscription"with the reason "the divergence is in a generic field (position, parameters, compression, history) or in layer order".Two consequences. The operator pays the full compression cost to learn it. The report names a generic field instead of the network.
same_networkis in this module and takes exactly these two arguments.♻️ Proposed check before the export
if plan.sequence != published.sequence { return Err(Error::ReproductionMismatch { subject: "sequence".to_owned(), reason: format!( "the published stele is sequence {} and these stores stand at sequence {}; a \ reproduction runs over stores at the epoch the stele was published from", published.sequence, plan.sequence, ), }); } + // The same refusal a publish makes, for the same reason the sequence is + // checked here: a store on another chain cannot reproduce this stele, and + // finding that out should not cost a full walk. + same_network(published, plan)?; + let reproduced = export(🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/snapshot/src/export.rs` around lines 651 - 673, Update verify_reproduction to call same_network with published and the local archive/state context before beginning the store walk, alongside the existing sequence validation. Return the appropriate network-specific ReproductionMismatch error immediately when the networks differ, preserving the current sequence check and reproduction flow for matching networks.crates/stelae/src/transport.rs (1)
342-364: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider extracting the shared
layer_sinkprologue.This is the third copy of the same prologue.
SteleDir::layer_sinkincrates/stelae/src/dir.rs(lines 300-341) andRegistry::layer_sinkincrates/stelae/src/oci.rs(lines 861-878) perform the same four steps in the same order: validate the media type, build theLayerHeader, wrap the writer inSeqWriter::with_max_record(LayerWriter::new(...), profile.max_record()), then write the header as the first record.The doc comment states that the header and the media type are inside the layer's identity. A drift between the three copies would therefore change a
diffId. A small shared helper that returns the configuredSeqWriterplus the encoded header record would make that drift impossible.This is optional: the current code is correct and the test
a_discarding_writer_reproduces_what_a_directory_storescompares the directory's descriptors against this writer's.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/stelae/src/transport.rs` around lines 342 - 364, Optionally extract the repeated layer-sink setup shared by SteleDir::layer_sink, Registry::layer_sink, and this layer_sink method into a helper that validates the media type, builds LayerHeader, creates the configured SeqWriter, and returns the encoded header record. Update all three callers to use the helper while preserving the existing header-first write order and layer identity behavior.crates/snapshot/tests/export.rs (1)
630-637: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueUse
stelae::Digest::ALGORITHMfor the blob directory name.
crates/stelae/tests/toy_profile.rsbuilds the same path fromstelae::Digest::ALGORITHM. A literal"sha256"here goes stale if the algorithm changes.♻️ Proposed change
- let blobs = std::fs::read_dir(temp.path().join("blobs").join("sha256")) + let blobs = std::fs::read_dir( + temp.path() + .join("blobs") + .join(stelae::Digest::ALGORITHM), + ) .unwrap() .count();🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/snapshot/tests/export.rs` around lines 630 - 637, Update the blob directory path in the export test to use stelae::Digest::ALGORITHM instead of the literal "sha256", matching the path construction in the toy profile test while preserving the existing blob count assertion.src/bin/dolos/snapshot/mod.rs (1)
15-21: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueUpdate the heading to match the number of commands that share
EpochRange.
publish,digest, andverifyall take--epochsand callrestrict. The heading says "Two commands, one epoch selection", so it undercounts the sharing it documents.📝 Proposed doc change
-//! ## Two commands, one epoch selection +//! ## One epoch selection, shared by every command that takes one //! //! [`EpochRange`] lives here rather than in either command, because a publisher🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/bin/dolos/snapshot/mod.rs` around lines 15 - 21, Update the module documentation heading above EpochRange to state that three commands share the epoch selection. Keep the surrounding explanation and the restrict reference unchanged.crates/snapshot/tests/snapshot_verify.rs (1)
392-424: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winBound the socket operations in
request.
connectandread_to_endhave no timeout. If the registry accepts the connection and then sends nothing, the test blocks instead of failing with a message.Fixture::wait_until_readyincrates/snapshot/tests/registry_fixture/mod.rsdocuments this exact hazard and bounds every socket operation for it.🛠️ Proposed fix
- let mut socket = std::net::TcpStream::connect(&self.address).unwrap(); + let patience = std::time::Duration::from_secs(30); + let endpoint: std::net::SocketAddr = self.address.parse().unwrap(); + + let mut socket = + std::net::TcpStream::connect_timeout(&endpoint, patience).unwrap(); + + socket.set_write_timeout(Some(patience)).unwrap(); + socket.set_read_timeout(Some(patience)).unwrap();🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/snapshot/tests/snapshot_verify.rs` around lines 392 - 424, Update Fixture::request to apply the same operation timeout used by Fixture::wait_until_ready: bound the TcpStream connect, writes, and read_to_end, and preserve the existing failure behavior while ensuring stalled registry connections fail promptly with a useful timeout message.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/snapshot/src/export.rs`:
- Around line 266-333: Add a unit test in the existing chain tests for
Following::read that supplies valid inscription bytes whose parsed form
re-encodes differently, then assert it returns Error::MalformedInscription. Keep
the test focused on canonical-form rejection and use the existing test helpers
and assertion conventions.
In `@crates/snapshot/src/registry.rs`:
- Around line 435-443: Update the compressed-size verification in the
surrounding verify logic to reject a None result from stele.compressed_size
instead of skipping validation. Preserve the existing mismatch error for
readable claims, and return an appropriate verification error when the
manifest’s compressed size claim is unreadable, so verify cannot succeed for
negative or otherwise invalid claims.
In `@src/bin/dolos/snapshot/digest.rs`:
- Around line 142-153: The canonical stdout writers must explicitly flush and
propagate flush failures. In src/bin/dolos/snapshot/digest.rs lines 142-153,
retain the stdout handle, write the canonical bytes, then call flush with
into_diagnostic().context(...); apply the same change in
src/bin/dolos/snapshot/inspect.rs lines 64-78 within the --json branch before
return Ok(()).
---
Nitpick comments:
In `@crates/snapshot/src/export.rs`:
- Around line 651-673: Update verify_reproduction to call same_network with
published and the local archive/state context before beginning the store walk,
alongside the existing sequence validation. Return the appropriate
network-specific ReproductionMismatch error immediately when the networks
differ, preserving the current sequence check and reproduction flow for matching
networks.
In `@crates/snapshot/tests/export.rs`:
- Around line 630-637: Update the blob directory path in the export test to use
stelae::Digest::ALGORITHM instead of the literal "sha256", matching the path
construction in the toy profile test while preserving the existing blob count
assertion.
In `@crates/snapshot/tests/snapshot_verify.rs`:
- Around line 392-424: Update Fixture::request to apply the same operation
timeout used by Fixture::wait_until_ready: bound the TcpStream connect, writes,
and read_to_end, and preserve the existing failure behavior while ensuring
stalled registry connections fail promptly with a useful timeout message.
In `@crates/stelae/src/transport.rs`:
- Around line 342-364: Optionally extract the repeated layer-sink setup shared
by SteleDir::layer_sink, Registry::layer_sink, and this layer_sink method into a
helper that validates the media type, builds LayerHeader, creates the configured
SeqWriter, and returns the encoded header record. Update all three callers to
use the helper while preserving the existing header-first write order and layer
identity behavior.
In `@src/bin/dolos/snapshot/mod.rs`:
- Around line 15-21: Update the module documentation heading above EpochRange to
state that three commands share the epoch selection. Keep the surrounding
explanation and the restrict reference unchanged.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: bb97b556-442b-4925-9dea-e279171f47f0
📒 Files selected for processing (18)
adrs/004_stelae_snapshots.mdcrates/snapshot/src/export.rscrates/snapshot/src/lib.rscrates/snapshot/src/registry.rscrates/snapshot/tests/export.rscrates/snapshot/tests/node/mod.rscrates/snapshot/tests/publish.rscrates/snapshot/tests/registry_fixture/mod.rscrates/snapshot/tests/snapshot_verify.rscrates/stelae/src/lib.rscrates/stelae/src/transport.rscrates/stelae/tests/toy_profile.rssrc/bin/dolos/snapshot/digest.rssrc/bin/dolos/snapshot/inspect.rssrc/bin/dolos/snapshot/mod.rssrc/bin/dolos/snapshot/publish.rssrc/bin/dolos/snapshot/verify.rstests/snapshot_publish.rs
| if let Some(claimed) = stele.compressed_size(blobs, descriptor)? { | ||
| if digests.compressed_size != claimed { | ||
| return Err(mismatch( | ||
| "compressed size", | ||
| claimed.to_string(), | ||
| digests.compressed_size.to_string(), | ||
| )); | ||
| } | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
A manifest with an unreadable compressed size passes verify unchecked.
compressed_size returns None where the manifest claims a negative size. See Stele::compressed_size in crates/stelae/src/oci.rs (lines 1092-1100), which documents that reading. Here None skips the comparison, so verify returns Ok for a manifest whose size claim is impossible.
The doc comment on line 406 states that the layer's byte count "is the compressed size it claims". That does not hold when the claim cannot be read.
inspect prints ? for the same case, which is right for a report. verify is the command that decides an exit code, so it should refuse instead.
🛡️ Proposed refusal for an unreadable size claim
- if let Some(claimed) = stele.compressed_size(blobs, descriptor)? {
- if digests.compressed_size != claimed {
- return Err(mismatch(
- "compressed size",
- claimed.to_string(),
- digests.compressed_size.to_string(),
- ));
- }
- }
+ match stele.compressed_size(blobs, descriptor)? {
+ Some(claimed) if claimed == digests.compressed_size => {}
+ Some(claimed) => {
+ return Err(mismatch(
+ "compressed size",
+ claimed.to_string(),
+ digests.compressed_size.to_string(),
+ ))
+ }
+ // A size a `u64` cannot hold is a manifest this stele cannot be
+ // verified against, rather than one check fewer.
+ None => {
+ return Err(mismatch(
+ "compressed size",
+ "not a readable size".to_owned(),
+ digests.compressed_size.to_string(),
+ ))
+ }
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if let Some(claimed) = stele.compressed_size(blobs, descriptor)? { | |
| if digests.compressed_size != claimed { | |
| return Err(mismatch( | |
| "compressed size", | |
| claimed.to_string(), | |
| digests.compressed_size.to_string(), | |
| )); | |
| } | |
| } | |
| match stele.compressed_size(blobs, descriptor)? { | |
| Some(claimed) if claimed == digests.compressed_size => {} | |
| Some(claimed) => { | |
| return Err(mismatch( | |
| "compressed size", | |
| claimed.to_string(), | |
| digests.compressed_size.to_string(), | |
| )) | |
| } | |
| // A size a `u64` cannot hold is a manifest this stele cannot be | |
| // verified against, rather than one check fewer. | |
| None => { | |
| return Err(mismatch( | |
| "compressed size", | |
| "not a readable size".to_owned(), | |
| digests.compressed_size.to_string(), | |
| )) | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/snapshot/src/registry.rs` around lines 435 - 443, Update the
compressed-size verification in the surrounding verify logic to reject a None
result from stele.compressed_size instead of skipping validation. Preserve the
existing mismatch error for readable claims, and return an appropriate
verification error when the manifest’s compressed size claim is unreadable, so
verify cannot succeed for negative or otherwise invalid claims.
Five findings from the review of #1188, none of which change what the commands do: - `digest` and `inspect --json` flush stdout explicitly. The canonical inscription carries no trailing newline, so a line-buffered stdout still holds its tail when `run` returns and the flush at process exit discards its error: `digest > stele.json` onto a full disk or a closed pipe wrote a truncated document and exited zero. The `--output` arm already propagated through `fs::write`; the two paths now agree. - `verify_reproduction` checks the network before it walks a store. It refused a sequence mismatch up front for exactly this reason, and the network is not implied by it — epoch numbers collide across chains, and `Attested::of` takes the published history verbatim, so unlike `Following::new` nothing else on that path checks it. - A unit test for `Following::read`'s canonical-form refusal, which had none: every caller in the tree hands it canonical bytes, and the branch it guards is the one that would chain onto a re-encoded copy and report a digest correct for a stele nobody published. Reordered keys and a trailing newline, both parseable, both refused. - `snapshot/mod.rs`'s heading no longer counts the commands that share `EpochRange`; `restrict` has three callers now. - The `Distribution` test client bounds its socket operations, as the registry fixture's `wait_until_ready` already does: a wedged registry turned an `#[ignore]`d end-to-end test into a hung CI job rather than a failing one. Two further review comments are declined, on the code as it stands: `registry.rs`'s "unreadable compressed size passes verify unchecked" is unreachable — `stream_layer` resolves the same layer first and pulls the blob under a ceiling clamped from that size, so an absent or negative one fails before the line in question — and the `Digest::ALGORITHM` swap in `tests/export.rs` is declined because a test pinning on-disk layout should trip rather than follow an algorithm change silently.
|
Review pass applied in 5c12704. Applied (5)
Declined (2)
The shared Gate re-run green: |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/bin/dolos/snapshot/inspect.rs (2)
140-145: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winKeep the aggregate compressed size unknown when any layer size is unavailable.
The per-layer branch correctly prints
?forNone. The totals line always printsinspected.total_compressedas a number. The registry implementation clamps negative manifest sizes to zero for that aggregate, so the command can print?for a layer and a misleading numeric total. (raw.githubusercontent.com)Compute the total from
inspected.compressed, or make the total optional. Print?when any component is unavailable.Suggested total handling
+ let total_compressed = if inspected.compressed.iter().any(|size| size.is_none()) { + "?".to_owned() + } else { + inspected.total_compressed.to_string() + }; + println!( "totals: {} records, {} uncompressed bytes, {} compressed bytes", records, inscription.uncompressed_size(), - inspected.total_compressed, + total_compressed, );Also applies to: 157-162
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/bin/dolos/snapshot/inspect.rs` around lines 140 - 145, Update the totals formatting logic near the compressed display and the corresponding totals line to derive the aggregate compressed size from inspected.compressed rather than inspected.total_compressed, preserving an unknown result when any component is unavailable. Ensure the totals output prints “?” for that case while retaining numeric output only when all layer sizes are known.Source: MCP tools
135-138: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winCheck registerable totals before printing the inspect report.
Lines 138, 158, and 160 print a combined
records, uncompressed size, and compressed size without rejecting overflow. Individual fields can be valid while the aggregate exceedsu64::MAX; debug builds can panic, and release builds can show a wrapped total. Use checked accumulation for the record total, and treatInscription::uncompressed_size()andtotal_compressedthe same way: return a diagnostic instead of printing the wrapped aggregate.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/bin/dolos/snapshot/inspect.rs` around lines 135 - 138, Use checked accumulation for the `records` total in the inspect-report loop, and apply the same overflow handling to `Inscription::uncompressed_size()` and `total_compressed`. If any aggregate exceeds `u64::MAX`, return a diagnostic before printing the report; otherwise preserve the existing output with validated totals.Source: MCP tools
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@src/bin/dolos/snapshot/inspect.rs`:
- Around line 140-145: Update the totals formatting logic near the compressed
display and the corresponding totals line to derive the aggregate compressed
size from inspected.compressed rather than inspected.total_compressed,
preserving an unknown result when any component is unavailable. Ensure the
totals output prints “?” for that case while retaining numeric output only when
all layer sizes are known.
- Around line 135-138: Use checked accumulation for the `records` total in the
inspect-report loop, and apply the same overflow handling to
`Inscription::uncompressed_size()` and `total_compressed`. If any aggregate
exceeds `u64::MAX`, return a diagnostic before printing the report; otherwise
preserve the existing output with validated totals.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 8a506bf9-4e7e-4449-b096-8e70410faeda
📒 Files selected for processing (5)
crates/snapshot/src/export.rscrates/snapshot/tests/snapshot_verify.rssrc/bin/dolos/snapshot/digest.rssrc/bin/dolos/snapshot/inspect.rssrc/bin/dolos/snapshot/mod.rs
🚧 Files skipped from review as they are similar to previous changes (3)
- src/bin/dolos/snapshot/digest.rs
- crates/snapshot/tests/snapshot_verify.rs
- crates/snapshot/src/export.rs
Phase 4, publisher productization: the three read/verify commands and incremental detection. Implements the Trellis plan
plans/dolos-stelae-publisher-commands.md("Stelae — a publisher can ask what it has, and a verifier can reproduce it") of the Brain/txpipe domain, contextssolution/stelae+solution/dolos.What changed
crates/stelae— aDiscardingSteleWriter: every layer framed, hashed and compressed exactly as a publish would — zstd runs in full, because the bug class this exists to catch only appears when the same bytes go through the same pipeline twice — and the bytes go tostd::io::sink. What comes back is the identity.crates/snapshot—history_forandsame_networkmoved fromregistry.rs(feature-gated) intoexport.rs: the contiguity rule is one function shared between the registry publisher and a verifier that has no registry.export::Followingreads a predecessor's canonical inscription off disk and extends it by the publisher's own rule (digest --chain-from); a re-encoded copy is refused, since a stele is chained to by the digest of its own bytes.export::Attestedcarries a published history verbatim;export::verify_reproductionrebuilds every layer through the discarding writer and compares descriptors kind by kind and scope by scope before comparing the inscription digest, with a cheap sequence refusal before the walk.export::Standingreads where a node stands against a repository (empty / up to date / next / ahead-with-distance) out of the two numbers a publish already has.registry::verifystreams every blob end to end — blob digest and compressed size against the manifest,diffId, uncompressed size and record count against the inscription — and names the offending layer's kind and scope on failure (Error::LayerVerification).registry::inspectreads the manifest and the config blob and no layer.src/bin/dolos/snapshot/— three new modules:digest— canonical inscription + sha256 from local stores and no registry;--chain-from FILE,--output FILE; stdout is the byte-exact document (no trailing newline), the report goes to stderr.verify— transport checks by default;--reproduceopt-in, and the help says it costs what a publish costs. Output states plainly: digests only; signatures and chain provenance are phase 5.inspect— sequence, position, profile, compression, history depth with first/last entries, one line per layer with the compressed size the manifest carries, totals;--jsonemits the canonical inscription verbatim. No signers column until there are signatures to list; the help says which.publish— a repository already at the node's sequence reports "nothing to publish" and exits zero;--require-newmakes that case an error; a node further ahead is still refused, now with the distance alongside both sequences.EpochRangeand the plan report are hoisted tosnapshot/mod.rssopublish,digestandverifyshare one epoch parser and one report.Scope decisions honored: no
sign/ signature verification, no CI workflow, no gap policy (the refusal stands, its message now names the distance), no[snapshot] source, no tag pruning, no progress reporting, no--scratch-dir.Done criteria
digestandpublish --output-dirproduce byte-identical canonical inscriptions —a_discarding_export_reproduces_what_a_publish_stores(records-bearing harness store, compared on the canonical bytes) anda_discarding_writer_reproduces_what_a_directory_stores(toy profile: every descriptor field, blob digest, compressed size, seal).digest --chain-fromreproduces a stele published with reuse on —a_stele_published_with_reuse_is_reproduced_from_the_stores(3 layers inherited, digest reproduced from stores alone; chained onto nothing is a different digest).verify --repopasses against a fresh stele, and refuses with the offence named: a blob that is not the layer (streamed check, layer kind + scope named), a manifestdiffIdannotation disagreeing (refused at the pull, layer position + both identities named), a history that skips a sequence (refused at parse, gap named). Tampered artifacts are planted through the raw distribution API — the transport refuses to write any of them.verify --reproducepasses against the store the stele was published from and fails against a store standing at a different epoch — the failure costs a comparison of two sequences, not hours.inspectlists every layer with the manifest's compressed size (inherited layers included), sizes sum to the manifest total, and its canonical JSON round-trips throughFollowing::read— the exactdigest --chain-frompath — chaining to the successor's published digest.publish --repoat the node's sequence: nothing to publish, exit zero;--require-newnon-zero; three ahead refused with both sequences and "3 sequences ahead" in the message —a_repository_is_read_as_empty_current_next_or_ahead,a_gap_names_the_distance_alongside_both_sequences,a_publisher_can_ask_where_it_stands.#[ignore]d and run — output quoted below.Verification
cargo test— full workspace: 15 suites, 0 failures.cargo clippy --all-targets --all-features -- -D warnings— clean.cargo +nightly fmt --all -- --check— clean.cargo deny check advisories—advisories ok.cargo tree -p stelae -e normal --all-features— matches nothing^dolos(-|$).Ignored registry suites, run against a spawned
registry:2cargo test -p dolos-snapshot --features oci --test snapshot_verify -- --ignored --nocapture:cargo test -p dolos-snapshot --features oci --test publish -- --ignored --nocapture:cargo test -p dolos-snapshot --features oci --test restore_registry -- --ignored --nocapture:cargo test -p stelae --features oci --test oci -- --ignored:10 passed; 0 failed(13.94s).Notes for review
digest/inspect --jsonstdout carries no trailing newline. The document is bytes:digest > stele.jsonhas to hash to the reported identity, and--chain-fromrefuses anything that is not the canonical encoding itself. The plan report moved to stderr for the same reason;tests/snapshot_publish.rswas updated to read it there.verify --reproducehas not been run against a production-published stele — it needs a node whose stores stand at the published epoch, which this environment does not have. Per the plan's risk section, a failing--reproduceagainst our own published stele is a finding for org/founder, not a comparison to adjust; the determinism job ofdolos-stelae-publisher-pipelineis where that run lives.cargo treeboundary were trimmed to match reality (the CI guard plan is back in draft); the check ran by hand as part of the gate here.Trellis trail
plans/dolos-stelae-publisher-commands.md(Brain/txpipe) — done criterion met; plan staysactive, retirement is the owner's verdict.plans/dolos-stelae-sign.md(draft) — Phase 5 signing, the one deferral in this plan with no plan to point at; this PR now promises "phase 5" inverify's output,inspect's help and the module docs.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
snapshot digestto reproduce snapshot identities without writing repository data.snapshot inspectfor metadata and canonical JSON output without downloading layers.snapshot verify, including optional local reproduction checks.--require-newpublishing protection.Bug Fixes
Documentation