Skip to content

A resource-valued slot re-identifies by URI, binding to the current generation #735

Closed

Description

@David-Noble-at-work

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:

  • discoverResource is called from the resource's UnmarshalJSON, UnmarshalText, and UnmarshalYAML
    (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 implementing Resource — so it never fires for an any slot 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 any slot 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 Pending entry 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

Epic: #444 · Feature: #285

Activity

  1. David-Noble-at-work commented on Aug 29, 2026

    @David-Noble-at-work
    ContributorAuthor

    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:

    id := catalog.Current(uri)
    if id == "" {
        return nil, false
    }
    canonical, ok := catalog.Lookup(id)

    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:

    Path Location Reached from
    discoverResource nine providers' UnmarshalJSON / UnmarshalText / UnmarshalYAML deserialization
    resolveDispatchResource pkg/op/method.go:807 dispatch, one production caller at method.go:725
    resolveRecordedResource pkg/op/recovery_stack.go:678 receipt / recovery-stack rehydration on resume

    Two notes for whoever implements this:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Area:ArchitectureSystem architecture and designEpic:ResourceModelThe resource modelPriority:P1This cycleSeverity:HighA supported path is broken with no workaroundbugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions