Repository navigation
A resource-valued slot re-identifies by URI, binding to the current generation #735
Description
Related: #712 (slots must carry their type), #734 (a number in an any slot makes a graph unloadable)
Fixed by: #712's identity work — the same PR, tracked here because the defect is independent of it.
A resource-valued slot is re-identified by URI on every deserialization, so it binds to whichever generation is
current at the moment of the lookup rather than the generation the graph was written against.
Mechanism
ResourceCatalog.entries is an append-only ledger: every generation of a resource persists under its own id.
ns maps a URI to only the current generation, and Discover resolves through it
(pkg/op/resource_catalog.go:244):
if id := c.Current(uri); id != "" {A URI therefore does not name a resource. It names whichever generation is current right now. Once a resource
is re-produced, Shadow mints a new generation and ns moves to it, while the generation a saved graph
actually used stays in the ledger under its original id with nothing pointing at it.
Two paths, not one
The codec path reaches this first, and is the one that was missed:
discoverResourceis called from the resource'sUnmarshalJSON,UnmarshalText, andUnmarshalYAML
(pkg/op/provider/file/resource_base.go:389,420,446, and the same shape in eight other providers:
appnet,function,git,json,mem,pkg,service,yaml).resolveDispatchResource(pkg/op/method.go:807) has the same flaw at the dispatch seam, and is gated on
the declared target implementingResource— so it never fires for ananyslot at all.
Retiring the dispatch path alone does not fix this. Deserialization re-identifies by URI before dispatch is
reached.
Why it is worse than #734
#734 refuses the document — loud, and impossible to miss. This one succeeds and returns a different
resource than the graph named. Nothing reports anything.
The fix
Identity is the catalog id, never the URI. A slot serializes res-7; load resolves it through
ResourceCatalog.Lookup(id), binding the exact ledger entry the graph was written against.
The id is stable where it must be: LedgerEntrySnapshot carries each entry's ID,
ResourceLedgerSnapshot.NextID is serialized, and Rehydrate restores both. The graph is immutable and its
ledger is saved to the trace, so an id written into a graph names the same entry every time it loads. That the
id is meaningful only relative to its ledger is the point: a URI is globally meaningful and therefore cannot
distinguish generations.
Both paths must change together. Fixing only the any case would leave one slot resolving correctly by id
while the declared slot beside it drifts to the current generation — one graph giving two different answers
about which resource it means.
Load resolves identity only and never verifies existence: a Pending entry is the normal state for a plan
whose producing node has not run (docs/architecture/4-resource-management.md §3).
Acceptance
- A slot binds the generation it was written against, after the resource has been re-produced
- Two slots naming different generations of one URI each resolve to their own generation
- A declared resource slot and an
anyslot in one graph agree on which resource they mean - A ledger miss fails the whole run, naming the slot and the id
- Load never verifies existence; a
Pendingentry loads normally -
Discover(uri)is no longer reached from a slot's deserialization path
Plan and design documents
| Kind | Document |
|---|---|
| Plan | docs/plans/fix/712-any-slot-types.md (shared with #712) |
| Design | docs/architecture/4-resource-management.md |
| Design | docs/architecture/5-graph-trace-integrity.md |
Activity
David-Noble-at-work commentedon Aug 29, 2026
A third path, found while scoping the fix
The body says "two paths, not one." It is three.
resolveRecordedResource(
pkg/op/recovery_stack.go:678) resolves a recorded result through the same current-generation lookup:This is the resume path. The value being resolved is what a node actually produced on the original run, so
re-identifying it by URI means a resumed run can bind a generation that did not exist when the node ran. Of
the three, this is the one where the correct answer is least ambiguous: the produced resource is a recorded
historical fact, and there is exactly one right entry for it.
Full list, for the fix:
discoverResourceUnmarshalJSON/UnmarshalText/UnmarshalYAMLresolveDispatchResourcepkg/op/method.go:807method.go:725resolveRecordedResourcepkg/op/recovery_stack.go:678Two notes for whoever implements this:
resolveDispatchResourcehas exactly one production caller (method.go:725); every other reference isin
method_test.go. Retiring it for slots is a single call site.resolveRecordedResourceandresolveDispatchResourceare both gated on the target/product typeimplementing
Resource, so neither fires for ananyslot — the same gap A value in an any-typed slot loses its concrete type at save; the document must carry it #712 finding 3 describes.Receipts and the recovery stack envelope their results and slots, and…
Size the second half of the any-slot plan's phase 3: declared resourc…
Resource slots record the catalog id and the checksum covers the cata…
A bare value or an unknown envelope in an any slot is refused, and th…
Pin #735's acceptance: a slot binds its own generation, both seams ag…
A value in an any slot carries its type, a resource slot records the …