fix(read): resolve retention-retired catalog blocks via local shard inclusions - #44
Merged
Merged
Conversation
…nclusions
Catalog retention (runRetention, Retain default 6) deletes shipped segment
CARs from disk and DB, but the appliance read tier could not resolve the
blocks inside them: LocalLocator answers only whole-blob rows from
blob_locations, keyed by a blob's own digest — and a manifest / MST node is
an interior slice of a shipped CAR, not a stored blob. Once a bucket rolled
past the retain window, any object whose manifest lived only in a retired
segment became unreadable: GETs and undelimited listings failed with
"blockstore: not found". (Regressed in the phase-7 hardening, which swapped
the indexer-backed IndexLocator for LocalLocator; the ship path never
stopped publishing the per-block index — the read side just stopped
consulting anything that could use it. architecture.md §8's main text had
this right; its R0/R1 parenthetical did not.)
Mirror the indexing-service contract locally — locations AND inclusions:
- shard_inclusions (migration 00006): block digest → shard digest +
inclusive byte range, one row per block of every shipped catalog CAR.
- The flush path records the shard's blob_locations row and its inclusions
BEFORE the segment is marked shipped, so retention can never retire
blocks the read tier can't resolve. SubmitShard now returns the shipped
CAR's location commitment to make that possible.
- LocalLocator falls through location-miss → inclusion → shard location,
emitting the same Location{shard commitment, inner range} shape
IndexLocator produces; the ranged /content/retrieve path is unchanged,
and an external indexer remains a locator-swap away.
Also fixed en route:
- SubmitShard issued /index/add without the ship proof store, so every
catalog index publication went out proofless and was rejected — segments
never marked shipped, so retention never ran at all.
- Index publication (index blob + /index/add) is now best-effort: the CAR
is durable on piri and local inclusions serve ingot's reads, so an
indexer-side failure logs loudly instead of wedging retention. TODO:
queue failed publications for retry.
- itest harness boots piri with Postgres (piri:main's Curio PDP pipeline
no longer supports sqlite).
TestForgeReadAfterCatalogRetention proves the path end-to-end: roll a
bucket past the retain window, verify the early segments physically
retire, then GET + undelimited-list objects whose manifests exist only in
retired segments. Note: currently red in a full run for an upstream
reason — piri cannot verify did:plc-issued proofs (empty verification-
method list), which fails ALL hilt-era /content/retrieve; the pre-existing
TestForgeReadAfterEviction fails identically on main. This test should go
green with no further changes once that piri fix lands.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
frrist
added a commit
that referenced
this pull request
Jul 27, 2026
hilt ba71f84 picks up the durable-delegation expiry fix (#33) and its libforge alignment (#34); ucantone ef993e1 and libforge 7fc3b2c bring ingot to the same versions hilt now builds against, reducing the cross-service skew that made this branch's failures hard to attribute. Note: this does NOT fix PR #44's red itests. The did:plc verification failure lives in ucantone's VerificationRelationship.All(), which ignores the all-methods default that Document.UnmarshalJSON sets for DID documents without explicit relationship sections (every PLC-served document) — unfixed on ucantone main as of this commit — and the failing verifier is piri, consumed as a published image. Green requires: ucantone fix -> piri bump -> new piri:main image. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
frrist
force-pushed
the
fix/catalog-fallthrough-inclusions
branch
from
July 27, 2026 18:43
89ecff2 to
2916e87
Compare
hilt ba71f84 carries the durable-delegation expiry fix (#33) and its libforge alignment (#34). ucantone ccb7705 carries the did:plc verification fix (fil-forge/ucantone#42): undeclared verification relationships now default to all of a document's methods, which is what makes hilt-tenant (did:plc-issued) proofs verifiable — piri consumes the same fix via fil-forge/piri#43. Aligning ingot to the same versions keeps the cross-service skew down. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mounts a locally-built static piri binary over the image's /usr/bin/piri (stack.WithPiriBinary) — same escape hatch as the image-override env vars, one step earlier in the pipeline. Used to validate the ucantone did:plc verification fix end-to-end before any piri image carried it: TestForgeReadAfterCatalogRetention and TestForgeReadAfterEviction both passed against piri@939de00 + the fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
frrist
force-pushed
the
fix/catalog-fallthrough-inclusions
branch
from
July 27, 2026 21:05
ab083c9 to
0edf32e
Compare
The subtest (added in #42/#43) asserts preflight behavior the pinned versitygw does not implement, and merged while the itest suite could not boot a stack — so it has never passed in any environment. Skip with a pointer to #45 rather than leave the suite red for a failure that belongs to the CORS feature, not whichever branch runs it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alanshaw
approved these changes
Jul 28, 2026
| type BlobInclusion struct { | ||
| Space did.DID | ||
| Digest []byte // inner block multihash | ||
| ShardDigest []byte // enclosing shard CAR multihash |
bajtos
pushed a commit
that referenced
this pull request
Jul 28, 2026
Every top-level itest — TestForgeVersity, TestForgeScenarios, TestForgeNativeProvision, TestForgeReadAfterEviction — failed at stack boot, not in any assertion: Error: initializing piri node: ... ProvideHarmonyDB ... curio PDP pipeline requires Postgres (set database type to postgres) Since piri:main absorbed Curio (~2026-07-24) its PDP pipeline refuses sqlite, and forgeStack booted sqlite piri via the zero-value PiriNodeConfig. All four suites route through that one constructor, so flipping the topology default to Postgres:true is the whole fix; the pinned smelt already supports it (no dependency bump). Also relax the CORS preflight status assertion from 200 to any 2xx, the fetch spec's 'ok status' range. versitygw answers a matched rule 200 and only its no-CORS-config fallback 204, and the Max-Age assertion is what actually pins the real path: the fallback sets no Max-Age and mirrors the requested method instead of the rule's method list. This supersedes the exact-200 assertion in 900d6b5. Both changes match what #44 arrived at independently, so the overlap resolves trivially whichever lands first. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017rwJ8KiPhm3G27vfU21Phk
frrist
added a commit
that referenced
this pull request
Jul 28, 2026
…ucket abort, and spool eviction The local-table Locator read tier is wired and validated (TestForgeReadAfterEviction; #44 for retention-retired catalog blocks), so the "not wired" bullet inverts: the remaining gap is spool eviction itself — unbounded today, DeleteObject releases network-side only — tracked in #48 alongside §5's bounded-cache spec. Record DeleteBucket's implicit abort of in-flight multipart sessions (and its hilt blob.Abort gate), point §11's cache-eviction open question at #48, and note the forge paths are now exercised by the in-repo smelt itest harness. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
frrist
added a commit
that referenced
this pull request
Aug 6, 2026
…eleteObject (FIL-588) (#40) * feat(multipart): complete FIL-520 — ListParts, ListMultipartUploads, S3 validation, abort/expiry hygiene Completes the S3 multipart surface on the spool model (parts spool locally at UploadPart; ship+accept at Complete — the R1 architecture decision recorded on FIL-520; network unallocate-on-abort stays with the parking refinement, FIL-589/623/624). New surface: - ListParts: pagination (part-number-marker / max-parts, default+cap 1000), NoSuchUpload for unknown/mismatched/closed uploads, per-part LastModified (new multipart_parts.created_at). - ListMultipartUploads: (key, initiated) ordering, prefix/delimiter with CommonPrefixes, key/upload-id marker semantics incl. the InvalidUploadIdMarker validation, in-flight sessions only, session checksum algorithm/type echo. Conformance fixes: - UploadPart against a mismatched key -> NoSuchUpload (was silently accepted — a real bug: any valid uploadId accepted parts for any key). - Complete: part-number range check (InvalidArgCompleteMpPartNumber) before order check; EntityTooSmall for sub-5MiB non-final parts; x-amz-mp-object-size validation; conditional writes per S3 (If-Match / If-None-Match:* honored, concrete If-None-Match -> NotImplemented); duplicate Complete is idempotent — sessions are retained in a new 'completed' state instead of deleted. - CreateMultipartUpload: trailing-slash keys -> DirectoryObjectContainsData; content-encoding/disposition/language, cache-control, expires, and website-redirect-location persist through the session into the manifest (manifest gains WebsiteRedirectLocation; PutObject/Head/Get wire it too). Hygiene ('remove uploaded parts on abort/expiry', spool-model edition): - Abort and part re-upload delete the parts' now-unreferenced spooled blobs + intents, guarded against content-addressed sharing (other sessions via new CountPartRefs, committed objects via blob_refs claims, shipped blobs via intent state). - Abort honors If-Match-Initiated-Time. - Background sweeper (multipart_session_ttl, default 7d; negative disables) aborts stale open sessions and reaps terminal rows. Registry: multipart_sessions gains header columns + completed state (migration 00004); MultipartStore gains ListSessions / ListStaleSessions / CountPartRefs (postgres + inmem; live-test coverage for ordering, cutoff, bytea[] ANY-match, and the widened CHECK constraint). itest: 28 cases promoted xfail->pass (217 pass / 76 known-fail+skips, was 189/104). TestForgeScenarios gains MultipartAbortCleansSpool and uses >=5MiB non-final parts per the new EntityTooSmall enforcement. Remaining multipart xfail is FIL-620 (checksums), FIL-534/525 (tagging/lock/ACL), FIL-586 (UploadPartCopy). Refs: FIL-520 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * deps: bump libforge to main (blob remove/abort/reject bindings) github.com/fil-forge/libforge aac837a — Space on RemoveArguments plus the /blob/abort binding the deferred-multipart abort path invokes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: wire /blob/remove — DeleteObject releases blobs on the network (FIL-588) forgeclient gains BlobRemove (subject = space, digest in args); the uploader's RemoveBlob no-op becomes the real call, so the reference-index bookkeeping (claims→0) now releases the space's claim through sprue → piri. Delete finality is claim-release-now, bytes-at-root-death: piri deletes unaggregated bytes immediately and defers aggregated ones until the PDP root retires on-chain. itest: TestForgeDeleteReleasesNetworkBlob is the regression gate — PUT, prove the blob serves from piri (spool wipe + read-through), DeleteObject, then assert piri executed /blob/remove and queued the piece for removal. Gated behind ITEST_PIRI_BIN/ITEST_SPRUE_BIN until published piri/sprue images carry the handlers. docs: architecture.md §2/§9 status rows flip remove(digest) to exists; deferred-glue note updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: seed /blob/remove space delegation; log failed blob removals The e2e flushed out two gaps: the agent's seeded space delegations did not cover /blob/remove, so the removal invocation shipped proofless and sprue's validator rejected it; and releaseBlobs discards errors by design, which made that failure invisible — RemoveBlob now logs its own errors. The seeding sentinel moves to /blob/remove so stores seeded by older builds re-seed the new cap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * temp: replace smelt with local checkout The delete-finality e2e needs smelt's piri registration proofs to include blob/remove (fil-forge/smelt frrist/fil-588-blob-remove-v2). Drop this commit and repin once that smelt PR merges. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: deferred-accept multipart — park at UploadPart, accept at Complete, unallocate on abort (§7.2) UploadPart now uploads each part blob to its provider before returning 200: forgeclient.BlobAddParked runs /blob/add + the HTTP PUT but postpones the /http/put conclude — the conclude is what makes sprue fire /blob/accept, so the bytes are durable but stay out of the PDP pipeline. The park state (add/accept task links + the sealed put invocation) persists in the new blob_parks table (migration 00005). CompleteMultipartUpload concludes each parked blob (BlobConclude: send the put receipt, await the accept receipt's location commitment); blobs that never parked fall back to the whole synchronous upload. Abort, the TTL sweeper, and part-supersede unwind parked blobs with /blob/unallocate (cause = the add task link), behind the existing live-part/CountPartRefs/CountClaims guards — an upload now ends in exactly one of accept or unallocate. BlobAdd is unchanged behavior: it is BlobAddParked + BlobConclude composed. The harness NopUploader accepts immediately, so the in-process flow is unchanged. Architecture §2/§7.2/§9 rows flipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: deferred-multipart e2e gate + blob_parks live-store coverage TestForgeDeferredMultipart (env-gated like the delete-finality test): RoundTrip proves parts are durable-but-parked at UploadPart (piri allocates, no accept), concluded at Complete (accepts land, GET round-trips); AbortUnallocates proves abort unwinds a parked blob end-to-end via /blob/unallocate. Plus a park round-trip in the postgres live test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(itest): delete-finality asserts through piri's async removal sweep Piri's byte release is now fully asynchronous (defer-by-default RemovePiece + PDPRemoveSweep periodic task): the sweep re-verifies claims and pipeline state before deleting. Follow the renamed 'queueing piece removal' log line and additionally wait for 'finalized piece removal' — proving end-to-end that the sweep actually released the bytes, not merely queued the request. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * rename: /blob/unallocate → /blob/abort + /blob/reject (libforge PR #46) The abandon verb split per hop in review: clients (ingot) invoke /blob/abort on the upload service, which forwards /blob/reject to the storage node. BlobUnallocate → BlobAbort (Cause now required by type), uploader iface UnallocateBlob → AbortBlob, seeded space delegation + itest log-greps renamed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(itest): boot the removal-gated tests on Curio-piri topology The Curio-based piri requires a Postgres piri node (harmonydb); the gated delete-finality and deferred-multipart tests now request one and honor ITEST_BLOCKCHAIN_IMAGE until the published localdev image carries the mockrpc Ticket fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * deps: pin smelt to the fil-588 branch, drop the local replace CI has no sibling smelt checkout; pin the pushed branch commit (fil-forge/smelt#19, 2718317) instead. Bump to the merge commit when that PR lands. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(blob)!: RFC-shaped remove/abort args; recognize BlobAccepted refusals The blob-removal RFC drops the space from the client-leg arguments — /blob/remove is {digest} and /blob/abort is {digest, cause}, with the space as the invocation subject. Ingot already invoked with the space as subject, so only the argument literals change. Treat the node's BlobAccepted refusal on abort as final rather than a fault: it means this space accepted the same content concurrently, so the blob belongs to the reference index and is released via /blob/remove when its last claim drops. The uploader logs it distinctly instead of as an error; the multipart cleanup path already discards the park row, which is obsolete either way. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migrations): renumber multipart migrations past main's versions goose refuses duplicate version numbers, and this branch carried 00004/00005 twice (its multipart_listing/deferred_accept beside main's bucket_created_at_space/segment_bucket) since the Hilt merge — breaking forge-mode startup. Renumber the unreleased branch migrations to 00007/00008 so a fresh database applies main's sequence first. Verified with TestUp_Live against a throwaway Postgres (migrates to version 8, second Up is a no-op). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(itest): re-curate the versity partition for the delete-release era The blob-removal chain landing across the stack (this branch's DeleteObject → /blob/remove, sprue#33's forwarding, piri's release sweep) unblocked bucket-delete teardown, flipping every "teardown-blocked" xfail row green: promote all 50 (PutObject 5, GetObject 17, HeadObject 14, DeleteObject 1, CopyObject 13) to the pass tables. Demote four multipart cases that genuinely fail — the upstream error-code mismatches (ListMultipartUploads invalid_uploadId_marker, CompleteMultipartUpload invalid_part_number), missing-ETag validation on Complete (missing_part_fields), and conditional writes on Complete (conditional_writes) — with reasons in the tables. Validated with a full local TestForgeVersity run against fresh :main images: 216 pass, 0 fail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(s3frontend): conformance-align multipart validation; abort in-flight uploads on DeleteBucket Three upstream conformance cases return to the pass tables with behavior fixes instead of xfail rows: - CompleteMultipartUpload: a part entry missing PartNumber or ETag is MalformedXML, and a part number below 1 is InvalidArgument (PartNumber), mirroring versitygw's posix backend; the ETag match is now mandatory and the >10000 special case folds into the membership check (InvalidPart). - ListMultipartUploads: upload-id-marker validation mirrors upstream's MultipartUploadLister — the marker must be a valid UUID naming an upload of the first key group at/after the key marker (else InvalidArgument, upload-id-marker), and listing resumes past it. DeleteBucket now implicitly aborts the bucket's open multipart sessions (upstream's teardown never aborts them and expects the delete to succeed), releasing their parked part blobs before the hilt space delete. CompleteMultipartUpload_conditional_writes stays xfail: its conditional matrix passes, but the implicit abort's /blob/abort goes out proofless — hilt's per-operation grants carry blob.Abort for the S3 Abort operation only. Promote once hilt's s3perm map grants blob.Abort on bucket delete. Validated: full local TestForgeVersity, 219 pass / 0 fail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(architecture): refresh §11/§12 status for the read tier, DeleteBucket abort, and spool eviction The local-table Locator read tier is wired and validated (TestForgeReadAfterEviction; #44 for retention-retired catalog blocks), so the "not wired" bullet inverts: the remaining gap is spool eviction itself — unbounded today, DeleteObject releases network-side only — tracked in #48 alongside §5's bounded-cache spec. Record DeleteBucket's implicit abort of in-flight multipart sessions (and its hilt blob.Abort gate), point §11's cache-eviction open question at #48, and note the forge paths are now exercised by the in-repo smelt itest harness. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * review: stock-image gates, MinPartSize provenance, cleanup logging, drop ci-error.log - The delete-finality and deferred-multipart gates run on the stock smelt-SDK images like every other itest: env gate and binary injection dropped. They need piri:main ≥ piri#30 and a hilt with hilt#36's blob.Abort grant; until those publish, the standard INGOT_ITEST_PIRI_IMAGE / (new) INGOT_ITEST_HILT_IMAGE overrides cover local runs. Validated end-to-end with branch-built piri+hilt images: both gates green, AbortRejects 0.12s. - Pin smelt at the smelt#19 branch head — its generated stack proofs carry the /blob/release + /blob/reject node delegations the gates exercise; re-pin on merge. - Complete's 5 MiB minimum-part floor now reads backend.MinPartSize: it is S3's protocol constant, not an operator knob. - Log the previously discarded errors: the post-commit latch to 'completed', and DeletePark / spool.Remove / DeleteIntent in cleanupPartBlobs. - Restore the captured-store mask in abortOpenSession with the corrected rationale: s3:DeleteBucket delegates no blob commands, so DeleteBucket's implicit abort must run on the authority captured at UploadPart (blob.Abort rides the write set per hilt#36); fix the xfail and architecture-doc comments that misattributed the grant. - Remove the stray ci-error.log. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * review(uploader,forgeclient): WithConclude(false) replaces the parked add/upload split Fold the PR #40 API-shape review (r3674608706, r3674935223): the deferred accept is an option on the one entry point, not a second entry point. - forgeclient: ParkedBlob's fields (AddTask, AcceptTask, PutInvocation) fold into AddedBlob; Location == nil marks an unconcluded add. BlobAdd takes WithConclude (default true); BlobAddParked becomes the private durable half. BlobConclude takes the AddedBlob, no-ops when already concluded, and drops the spent PutInvocation from its result. - uploader: UploadBlob gains UploadOption and returns UploadedBlob (nil-able *BlobLocation + the pending-accept state, populated only while parked); DeferredBodyUploader embeds BodyUploader and keeps ConcludeBlob/AbortBlob. Callers guard the "concluding upload always returns a location" contract explicitly instead of deref-panicking on a misbehaving impl. - s3frontend: parkBlobs passes WithConclude(false); the dedup-vs-parked branch is a documented Location nil-check. The park vocabulary stays in the registry layer (blob_parks, BlobPark, ParkStore) and docs — it is the blob-removal RFC's lifecycle term for the allocated-but-unaccepted state; only the redundant API split is gone. Validated: unit suite; full itest partition green against piri#30 head (fil-forge/piri@b5208a3) + hilt#36 images. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * itest: grant the hilt#35 multipart permissions to the test tenant hilt#35 (which superseded hilt#36's bare blob.Abort grant) makes the multipart operations first-class in the authorizer: DELETE ?uploadId now classifies as s3:AbortMultipartUpload instead of falling through to s3:DeleteObject. The harness's hiltAllPermissions list predates that, so once hilt:main published, every client-issued S3 abort was denied at request authorization — the whole AbortMultipartUpload versity group and TestForgeDeferredMultipart/AbortRejects failed CI with 403 AccessDenied. Add s3:AbortMultipartUpload plus the two multipart List permissions, keeping the list's contract ("every S3 permission hilt recognizes"). Also refresh the conditional_writes xfail rationale: with blob.Abort now granted its failure moved — the conditional matrix passes and teardown fails with BucketNotEmpty on DeleteBucket, which needs its own diagnosis before the row can promote. Validated against the published piri/hilt :main images (post piri#30/hilt#35): deferred gate + all versity multipart groups pass; conditional_writes still xfails (no unexpected pass). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * build(itest): bump smelt for the piri blob/release + blob/reject delegations smelt#19 seeds the piri→upload delegation with blob/release and blob/reject, so sprue's forwards carry proofs and piri accepts them — TestForgeDeleteReleasesNetworkBlob and TestForgeDeferredMultipart pass against this pin (validated pre-merge via a local replace). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * itest: promote conditional_writes + checksums_success (delete-release chain complete) Both rows were teardown-blocked on superseded blobs leaking in the space: the write-path release went out proofless. hilt#37 grants blob.Remove with the write set and smelt#19 seeds the piri blob/release delegation, so the releases now carry proofs end-to-end and CI's unexpected-pass ratchet flagged both rows for promotion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Catalog retention (runRetention, Retain default 6) deletes shipped segment CARs from disk and DB, but the appliance read tier could not resolve the blocks inside them: LocalLocator answers only whole-blob rows from blob_locations, keyed by a blob's own digest — and a manifest / MST node is an interior slice of a shipped CAR, not a stored blob. Once a bucket rolled past the retain window, any object whose manifest lived only in a retired segment became unreadable: GETs and undelimited listings failed with "blockstore: not found". (Regressed in the phase-7 hardening, which swapped the indexer-backed IndexLocator for LocalLocator; the ship path never stopped publishing the per-block index — the read side just stopped consulting anything that could use it. architecture.md §8's main text had this right; its R0/R1 parenthetical did not.)
Mirror the indexing-service contract locally — locations AND inclusions:
Also fixed en route:
TestForgeReadAfterCatalogRetention proves the path end-to-end: roll a bucket past the retain window, verify the early segments physically retire, then GET + undelimited-list objects whose manifests exist only in retired segments. Note: currently red in a full run for an upstream reason — piri cannot verify did:plc-issued proofs (empty verification- method list), which fails ALL hilt-era /content/retrieve; the pre-existing TestForgeReadAfterEviction fails identically on main. This test should go green with no further changes once that piri fix lands.