Skip to content

Commit e54e416

Browse files
author
David Noble
committed
Resource slots record the catalog id and the checksum covers the catalog rows
A declared resource slot writes the resource's catalog id and reloads the catalog entry that id names (#735); dispatch resolves a Resource by id and a run-time string by key, as before. CanonicalContent carries the catalog's intent rows (#712 decision 11), so a doctored `resources` row is a checksum mismatch at load rather than an entry the plan never claimed. Refs #712, #735. Claude-Session: https://claude.ai/code/session_01SqQVFZz7AdiLZkGoAcUqRW
1 parent 2288b4d commit e54e416

7 files changed

Lines changed: 278 additions & 82 deletions

File tree

‎docs/plans/fix/712-any-slot-types.md‎

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ title: "Slots must carry their type"
33
issue: https://github.com/NobleFactor/devlore-cli/issues/712
44
status: in-progress
55
created: 2026-08-28
6-
updated: 2026-09-01
6+
updated: 2026-09-07
77
---
88

99
# Plan: Slots must carry their type
@@ -165,7 +165,7 @@ Receipts and the recovery stack feed compensation and resume. A float that reloa
165165
changes a *compensating* call, which is worse than changing a forward one. One shared treatment at every
166166
`any` seam, not a local patch at `bindingData`.
167167

168-
## Work in progress — state as of 2026-09-06
168+
## Work in progress — state as of 2026-09-07
169169

170170
**Resumed 2026-09-06.** `origin/develop` merged at 869ca1bd as `24136bdf` (195 files, no conflicts; the branch is
171171
7 ahead, 0 behind). The order this plan asked for held: #644, #645, and #649 landed first, so every announced
@@ -222,11 +222,16 @@ the bare id for a declared resource slot, and the retirement of `Discover(uri)`
222222
decision 8); `readSlotValue` reads a string in a resource-typed slot by `Lookup(id)` against the document's
223223
catalog (decision 10), so the slot holds the ledger's entry after load exactly as it did before save. An unknown
224224
id is an error. A string with the tag-URI prefix is a pre-ruling document, refused by name (#735).
225-
2. **Dispatch resolves by id.** `resolveDispatchResource` maps the slot's resource -- the graph catalog's entry --
226-
onto the run clone's entry by `Lookup(v.ID())`, never `Current(v.URI())`. A string at dispatch is refused
227-
outright: after (1) no string reaches a resource slot from a document, and immediate-mode session dispatch
228-
(`Graph == nil`) falls through to [Convert] as today. Of the five `method_test.go` pins, the string-key hit becomes
229-
a refusal and the resolve-by-URI becomes resolve-by-id; miss, non-identity, and session stay as they are.
225+
2. **Dispatch resolves a resource by id.** `resolveDispatchResource` maps a Resource slot value -- the graph
226+
catalog's entry -- onto the run clone's entry by `Lookup(v.ID())`, never `Current(v.URI())`. **Corrected
227+
2026-09-07, before the code landed:** the sizing said a string at dispatch is refused outright; the dispatch-miss
228+
judgment scenario (`test_judgment_dispatch_miss.star`) shows why it is not. A `gather` item supplies the path at
229+
run time, so `plan.item("source")` resolves to the authored string at dispatch -- a value no plan-time claim
230+
ever saw. That string is a key into the run catalog (§5.6), resolved by `Current(key)` as today, and a miss is
231+
the catalog's verdict ("not in the run catalog"), which is the scenario's assertion. What (1) removes is the
232+
string a *document* used to leave in a declared slot; what (2) changes is the Resource half only. Of the five
233+
`method_test.go` pins, resolve-by-URI becomes resolve-by-id; the string-key hit, miss, non-identity, and
234+
session stay exactly as they are.
230235
3. **`origin.go` `Annotations`** are enveloped per value on write and unwrapped on read (`OriginBase`'s two
231236
unmarshalers), recursing through the map and list forms the envelope already has. Consumers read strings out of
232237
them (writ's `files` annotation) and are unaffected; a numeric annotation value comes back typed rather than as
@@ -239,7 +244,28 @@ the bare id for a declared resource slot, and the retirement of `Discover(uri)`
239244
Filed as a follow-up under #625 rather than folded in here; the plan's "retire `Discover(uri)` on unmarshal"
240245
bullet is closed by the slot path no longer reaching it, and the methods' removal is that issue.
241246

242-
Steps: (1) and (2) with their tests, one commit; (3) and (4), one commit; the follow-up issue filed; then phase 4.
247+
**Decision 11 (2026-09-07, found by the doctored-checksum judgment scenario): the checksum covers the catalog's
248+
intent rows.** `CanonicalContent` serializes `children`, `edges`, `subgraphs`, `nodes`, and `origin`; the
249+
`resources` section -- the ids and URIs the graph's identity is built on -- was outside it. Before (1) that gap
250+
was masked: a declared slot carried the URI, so doctoring a URI anywhere in the document changed a node and the
251+
mismatch fired. After (1) the slot carries `res-N`, the URI lives only in its row, and
252+
`test_judgment_doctored_checksum.star` -- which rewrites `claimed.txt` to `ghost.txt` across the document and
253+
expects `checksum mismatch` -- loaded the doctored row without complaint: the resolved entry pointed at a file the
254+
plan never claimed, past the trust boundary. The rows are part of what the graph IS (decision 8 made the id the
255+
identity a slot names), so they enter the canonical form as `resources`, and a doctored row is a mismatch at load
256+
as every other doctored byte is. This answers the checksum half of phase 5's open question early; the
257+
canonical-form half (the envelope is in the canonical form, decision 9) stands.
258+
259+
Steps: (1), (2), and decision 11 with their tests, one commit; (3) and (4), one commit; the follow-up issue filed; then phase 4.
260+
261+
**Items (1), (2), and decision 11 applied 2026-09-07; `make check` green.** `marshalBindings` writes a Resource in a
262+
declared resource slot as its bare id and refuses an uncataloged one; `readSlotValue` resolves a string in a
263+
resource-typed slot by `Lookup(id)` against the document's catalog and refuses a tag-URI string by name (#735);
264+
`resolveDispatchResource` resolves a Resource by `Lookup(v.ID())` and a string by `Current(key)`, the two halves
265+
of (2) as corrected; `CanonicalContent` carries the catalog's intent rows as `resources`. Pins: the declared slot
266+
records the id and reloads the entry; a URI in a declared slot is refused; a doctored `resources` row is a checksum
267+
mismatch; dispatch resolves a Resource by id. The two judgment scenarios that forced the corrections pass unchanged.
268+
Next: items (3) and (4), one commit.
243269

244270
### State as of 2026-09-01
245271

‎pkg/op/any_slot_types_test.go‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,3 +503,112 @@ func TestLoadGraph_ANumberAParameterCannotHoldIsRefusedAtLoad(t *testing.T) {
503503
}
504504
}
505505
}
506+
507+
// resourceSlotFixture carries a method whose parameter is declared [Resource], for the declared-resource-slot rule.
508+
type resourceSlotFixture struct{ ProviderBase }
509+
510+
// Hold accepts a resource; the method does nothing with it.
511+
//
512+
// Parameters:
513+
// - `entry`: the declared-Resource parameter.
514+
//
515+
// Returns:
516+
// - `error`: always nil.
517+
func (p *resourceSlotFixture) Hold(entry Resource) error { return nil }
518+
519+
func init() {
520+
AnnounceProvider(reflect.TypeFor[resourceSlotFixture](), NewProviderFlags(SurfaceWorkflow, PlacementQualified),
521+
func(runtimeEnvironment *RuntimeEnvironment) (any, error) {
522+
return &resourceSlotFixture{ProviderBase: NewProviderBase(runtimeEnvironment)}, nil
523+
},
524+
map[string]MethodMetadata{
525+
"Hold": {ParameterNames: []string{"entry"}},
526+
})
527+
}
528+
529+
// resourceSlotGraph builds a one-node graph whose declared resource slot holds a cataloged resource, and returns
530+
// the document and the resource's id.
531+
func resourceSlotGraph(t *testing.T) (document []byte, id string) {
532+
t.Helper()
533+
environment := formatIdentityEnvironment(t)
534+
candidate, err := newAnySlotResource(environment, "test:held")
535+
if err != nil {
536+
t.Fatalf("newAnySlotResource: %v", err)
537+
}
538+
resource, err := environment.ResourceCatalog.GetOrCreate("hold", candidate.URI(), func() (Resource, error) { return candidate, nil })
539+
if err != nil {
540+
t.Fatalf("GetOrCreate: %v", err)
541+
}
542+
action, err := ReceiverRegistry().BuildAction("resourceSlotFixture.hold")
543+
if err != nil {
544+
t.Fatalf("BuildAction: %v", err)
545+
}
546+
node, err := NewNode(NewNodeSpec().WithID("hold").WithAction(action).WithSlot("entry", NewImmediateBinding(resource)))
547+
if err != nil {
548+
t.Fatalf("NewNode: %v", err)
549+
}
550+
graph, err := NewGraph(NewGraphSpec().WithOrigin(NewOriginBase("test", "home", NewAnnotationMap(nil))).
551+
WithUnits(node).WithResourceCatalog(environment.ResourceCatalog))
552+
if err != nil {
553+
t.Fatalf("NewGraph: %v", err)
554+
}
555+
return serializeGraph(t, graph, "json"), resource.ID()
556+
}
557+
558+
// TestLoadGraph_ADeclaredResourceSlotRecordsTheIDAndReloadsTheEntry pins #712 phase 3's declared-slot rule and
559+
// #735: the document holds the catalog id, bare, and the reloaded slot holds the ledger entry that id names.
560+
func TestLoadGraph_ADeclaredResourceSlotRecordsTheIDAndReloadsTheEntry(t *testing.T) {
561+
562+
document, id := resourceSlotGraph(t)
563+
if !strings.Contains(string(document), `"value":"`+id+`"`) {
564+
t.Fatalf("the document does not record the slot as the bare id %q:\n%s", id, document)
565+
}
566+
if strings.Contains(string(document), `"value":"tag:`) {
567+
t.Fatalf("the document records a URI in a resource slot:\n%s", document)
568+
}
569+
570+
loaded, err := LoadGraph(formatIdentityEnvironment(t), document, "json")
571+
if err != nil {
572+
t.Fatalf("LoadGraph: %v", err)
573+
}
574+
slots := loaded.Nodes()[0].ResolveSlots(nil, nil)
575+
held, isResource := slots["entry"].(Resource)
576+
if !isResource {
577+
t.Fatalf("the reloaded slot holds %T, want the ledger entry", slots["entry"])
578+
}
579+
if held.ID() != id {
580+
t.Errorf("the reloaded slot holds entry %q, want %q", held.ID(), id)
581+
}
582+
}
583+
584+
// TestLoadGraph_AURIInADeclaredResourceSlotIsRefused pins the pre-ruling document: a URI where an id belongs is
585+
// refused, not re-identified to the current generation (#735).
586+
func TestLoadGraph_AURIInADeclaredResourceSlotIsRefused(t *testing.T) {
587+
588+
document, id := resourceSlotGraph(t)
589+
rewritten := strings.Replace(string(document), `"value":"`+id+`"`, `"value":"`+tagURIPrefix+`test:held#x"`, 1)
590+
_, err := LoadGraph(formatIdentityEnvironment(t), []byte(rewritten), "json")
591+
if err == nil {
592+
t.Fatal("LoadGraph accepted a URI in a resource slot; want a refusal naming #735")
593+
}
594+
if !strings.Contains(err.Error(), "#735") {
595+
t.Errorf("refusal %q does not name the ruling", err)
596+
}
597+
}
598+
599+
// TestLoadGraph_ADoctoredCatalogRowIsAChecksumMismatch pins #712 decision 11: the catalog's intent rows are in the
600+
// canonical form, so a document whose `resources` row was rewritten -- the slot's id untouched -- is a checksum
601+
// mismatch at load, never a graph pointed at something the plan never claimed.
602+
func TestLoadGraph_ADoctoredCatalogRowIsAChecksumMismatch(t *testing.T) {
603+
604+
document, _ := resourceSlotGraph(t)
605+
if !bytes.Contains(document, []byte("test:held")) {
606+
t.Fatalf("the document does not carry the row's URI:\n%s", document)
607+
}
608+
doctored := bytes.ReplaceAll(document, []byte("test:held"), []byte("test:ghost"))
609+
610+
_, err := LoadGraph(formatIdentityEnvironment(t), doctored, "json")
611+
if err == nil || !strings.Contains(err.Error(), "checksum mismatch") {
612+
t.Fatalf("LoadGraph(doctored row) error = %v; want a checksum mismatch", err)
613+
}
614+
}

‎pkg/op/graph.go‎

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -805,8 +805,9 @@ func (g *Graph) UnitCount() int { return len(g.Nodes()) + len(g.Subgraphs()) }
805805
// CanonicalContent returns the graph serialized as YAML without checksum and signature.
806806
//
807807
// Used for computing checksums and verifying signatures. The output mirrors the symbol-table serialized form: top-level
808-
// `children` (root's children IDs in topological order), `subgraphs` (every non-root Subgraph sorted by ID), and
809-
// `nodes` (every Node sorted by ID).
808+
// `children` (root's children IDs in topological order), `subgraphs` (every non-root Subgraph sorted by ID), `nodes`
809+
// (every Node sorted by ID), and `resources` (the catalog's intent rows: the ids a slot names and the URIs they stand
810+
// for -- part of what the graph is, so a doctored row is a mismatch at load; #712 decision 11).
810811
//
811812
// Returns:
812813
// - `[]byte`: the canonical YAML bytes.
@@ -819,13 +820,14 @@ func (g *Graph) CanonicalContent() ([]byte, error) {
819820
// checksums with no input having changed -- the thing 2.4 says must not happen (#690). Provenance survives on
820821
// the graph and in the serialized document; only identity stops depending on it.
821822
type canonicalGraph struct {
822-
Kind string `yaml:"kind"`
823-
SchemaVersion uint32 `yaml:"schema_version"`
824-
Children []string `yaml:"children"`
825-
Edges []Edge `yaml:"edges,omitempty"`
826-
Subgraphs []*Subgraph `yaml:"subgraphs,omitempty"`
827-
Nodes []*Node `yaml:"nodes,omitempty"`
828-
Origin OriginBase `yaml:"origin"`
823+
Kind string `yaml:"kind"`
824+
SchemaVersion uint32 `yaml:"schema_version"`
825+
Children []string `yaml:"children"`
826+
Edges []Edge `yaml:"edges,omitempty"`
827+
Subgraphs []*Subgraph `yaml:"subgraphs,omitempty"`
828+
Nodes []*Node `yaml:"nodes,omitempty"`
829+
Origin OriginBase `yaml:"origin"`
830+
Resources []IntentEntry `yaml:"resources,omitempty"`
829831
}
830832

831833
var rootEdges []Edge
@@ -840,6 +842,10 @@ func (g *Graph) CanonicalContent() ([]byte, error) {
840842
nodes := g.root.descendantNodes()
841843
sort.Slice(nodes, func(i, j int) bool { return nodes[i].ID() < nodes[j].ID() })
842844

845+
var resources []IntentEntry
846+
if g.resourceCatalog != nil {
847+
resources = g.resourceCatalog.IntentEntries()
848+
}
843849
canonical := canonicalGraph{
844850
Kind: g.kind,
845851
SchemaVersion: g.schemaVersion,
@@ -848,6 +854,7 @@ func (g *Graph) CanonicalContent() ([]byte, error) {
848854
Subgraphs: subgraphs,
849855
Nodes: nodes,
850856
Origin: g.origin,
857+
Resources: resources,
851858
}
852859

853860
return yaml.Marshal(canonical)

‎pkg/op/method.go‎

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -788,9 +788,9 @@ func guardConsumedGone(activation *ActivationRecord, goArgs []any) error {
788788
// resolveDispatchResource is graph dispatch's identity resolution — the §5.6 seam.
789789
//
790790
// Applies only when `activation` is a graph dispatch with a run catalog and `target` implements
791-
// [Resource]. A [Resource] value resolves by its URI — the dispatched object must BE the run clone's
791+
// [Resource]. A [Resource] value resolves by its catalog id — the dispatched object must BE the run clone's
792792
// entry (re-based, state-carrying, the row pre-flight verified), never the captured planning object. A
793-
// string is the rehydrated identity a reload leaves in the slot and resolves as the key it is. Any other
793+
// string is a run-time value — a gather item, a variable — and resolves as the key it is (§5.6). Any other
794794
// value cannot name a resource at dispatch. A miss is the catalog's verdict: the catalog is complete by
795795
// construction (§5.1), so nothing constructs here — construction from strings survives only in load-time
796796
// rehydration and immediate mode.
@@ -817,38 +817,37 @@ func resolveDispatchResource(activation *ActivationRecord, value any, target ref
817817
return nil, false, nil
818818
}
819819

820-
var key string
820+
// A Resource in the slot is the graph catalog's entry; the run clone holds the same ids, so it resolves by id
821+
// (#712 decision 8, #735) -- never by URI, which would name whichever generation is current. A string is a
822+
// run-time value -- a gather item, a variable -- that no plan-time claim saw: a key into the run catalog, never
823+
// a constructor (4-resource-management.md §5.6). A miss is the catalog's verdict either way.
824+
var id string
821825
switch v := value.(type) {
822826
case Resource:
823-
key = v.URI()
827+
id = v.ID()
828+
if id == "" {
829+
return nil, true, fmt.Errorf("graph dispatch: resource %q is not cataloged; nothing constructs at dispatch", v.URI())
830+
}
824831
case string:
825-
key = v
832+
id = environment.ResourceCatalog.Current(v)
833+
if id == "" {
834+
return nil, true, fmt.Errorf("graph dispatch: resource %q is not in the run catalog", v)
835+
}
826836
case nil:
827837
return nil, false, nil
828838
default:
829839
return nil, true, fmt.Errorf(
830-
"graph dispatch: a %T cannot name a resource — a string is a key, never a constructor (4-resource-management.md §5.6)",
831-
value)
832-
}
833-
834-
catalog := environment.ResourceCatalog
835-
836-
id := catalog.Current(key)
837-
if id == "" {
838-
return nil, true, fmt.Errorf(
839-
"graph dispatch: %q is not in the run catalog — a string is a key, never a constructor; nothing constructs at dispatch (4-resource-management.md §5.6)",
840-
key)
840+
"graph dispatch: a %T cannot name a resource — a string is a key, never a constructor "+
841+
"(4-resource-management.md §5.6)", value)
841842
}
842-
843-
canonical, ok := catalog.Lookup(id)
843+
canonical, ok := environment.ResourceCatalog.Lookup(id)
844844
if !ok {
845-
return nil, true, fmt.Errorf("graph dispatch: run catalog names %q as %s but holds no entry", key, id)
845+
return nil, true, fmt.Errorf("graph dispatch: resource %s is not in the run catalog", id)
846846
}
847847
if !reflect.TypeOf(canonical).AssignableTo(target) {
848848
return nil, true, fmt.Errorf(
849-
"graph dispatch: run catalog entry %q is %T, which cannot fill a %s slot", key, canonical, target)
849+
"graph dispatch: run catalog entry %s is %T, which cannot fill a %s slot", id, canonical, target)
850850
}
851-
852851
return canonical, true, nil
853852
}
854853

‎pkg/op/method_test.go‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,17 @@ func TestResolveDispatchResource_StringKeyHitReturnsTheCanonical(t *testing.T) {
3131
}
3232
}
3333

34-
// TestResolveDispatchResource_ResourceValueResolvesByURI pins the captured-object half: a Resource slot
35-
// value resolves by its URI to the canonical, never dispatching the captured object itself — the aliasing
36-
// between planning catalog and run clone is severed, not load-bearing.
37-
func TestResolveDispatchResource_ResourceValueResolvesByURI(t *testing.T) {
34+
// TestResolveDispatchResource_ResourceValueResolvesByID pins the captured-object half: a Resource slot value
35+
// resolves by its catalog id to the run clone's canonical, never dispatching the captured object itself -- the
36+
// aliasing between planning catalog and run clone is severed, not load-bearing -- and never by URI, which would
37+
// name whichever generation is current (#735).
38+
func TestResolveDispatchResource_ResourceValueResolvesByID(t *testing.T) {
3839

3940
catalog := NewResourceCatalog()
4041
canonical := newLifecycle("test:///claimed", AddressingLocation)
41-
catalog.Resolve(canonical)
42-
42+
_, id := catalog.Resolve(canonical)
4343
captured := newLifecycle("test:///claimed", AddressingLocation) // the same identity, a different object
44-
44+
captured.id = id
4545
activation := &ActivationRecord{Graph: &Graph{}, RuntimeEnvironment: &RuntimeEnvironment{ResourceCatalog: catalog}}
4646

4747
resolved, applied, err := resolveDispatchResource(activation, captured, reflect.TypeFor[*lifecycleResource]())
@@ -51,6 +51,11 @@ func TestResolveDispatchResource_ResourceValueResolvesByURI(t *testing.T) {
5151
if resolved != Resource(canonical) {
5252
t.Errorf("resolved %p is not the canonical %p — the captured object must not dispatch", resolved, canonical)
5353
}
54+
55+
uncataloged := newLifecycle("test:///claimed", AddressingLocation)
56+
if _, applied, err := resolveDispatchResource(activation, uncataloged, reflect.TypeFor[*lifecycleResource]()); !applied || err == nil {
57+
t.Errorf("an uncataloged resource (no id) must be refused, got applied %t, err %v", applied, err)
58+
}
5459
}
5560

5661
// TestResolveDispatchResource_MissRefuses pins the refusal: a graph-dispatch catalog miss is the

0 commit comments

Comments
 (0)