Skip to content

Commit f843ed2

Browse files
author
David Noble
committed
Pin #735's acceptance: a slot binds its own generation, both seams agree, a ledger miss names slot and id
Three tests close the gap between #735's acceptance criteria and the tree. The plan records the mapping, and records that #712's default-value criterion depends on #857: plan.variable discards its default today, so there is nothing in a document to envelope. Refs #712, #735, #857. Claude-Session: https://claude.ai/code/session_01SqQVFZz7AdiLZkGoAcUqRW
1 parent 9f8ea0f commit f843ed2

3 files changed

Lines changed: 131 additions & 1 deletion

File tree

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,18 @@ that describe the positions it applies to link there. All five phases are comple
329329
documents table, and a plan carries an `## Issue NNN` heading per issue it serves; this plan and its issues were
330330
brought to that standard before the pull request.
331331

332+
**Acceptance pass, 2026-09-07.** #735's six criteria are pinned: a slot binds its own generation after a shadow and a
333+
run-time key binds the current one (`TestResolveDispatchResource_AResourceBindsItsOwnGenerationAfterShadow`, criteria
334+
1 and 2); a declared slot and an `any` slot agree on the entry (`TestLoadGraph_ADeclaredSlotAndAnAnySlotAgreeOnTheResource`,
335+
3); a ledger miss is refused naming the slot and the id at load (`TestLoadGraph_AResourceIDTheLedgerLacksIsRefusedNamingSlotAndID`)
336+
and at dispatch (`param %s: … not in the run catalog`, 4); load resolves by `Lookup` and never stats, every load test
337+
above running on entries that exist nowhere (5); no slot path reaches `Discover(uri)`, and the constructors' removal is
338+
#854 (6). #712's third criterion -- `plan.Variable(default=1.0)` survives save and reload -- cannot be met by this plan:
339+
`plan.variable` DISCARDS its default (`_ = defaultValue`, a stub the phase-8 plan never finished), so there is no
340+
default in any document to envelope. Filed 2026-09-07 as #857 (bug) under #856, the plan provider's feature (every
341+
provider is a feature under #815). Test-plan row 8 is #857's test; when the default reaches the document it is an
342+
`any` position and rule 3 of the architecture's Value Encoding section already governs it.
343+
332344
### State as of 2026-09-01
333345

334346

@@ -949,7 +961,7 @@ Two things Phase 1 changed about the plan itself:
949961
| 5 | `IsTruthy(json.Number("0"))` is false | unit | Removing the `json.Number` case from `scalarTruthy` |
950962
| 6 | A bare value in an `any` slot is refused | unit | Phase 4 reverted to inference |
951963
| 7 | A map whose own key is the reserved key round-trips intact | unit | The envelope is applied selectively |
952-
| 8 | `plan.Variable(default=1.0)` survives save/reload with its type | e2e | Either side reverted |
964+
| 8 | `plan.Variable(default=1.0)` survives save/reload with its type | e2e | Either side reverted -- **moved to #857**: the default never reaches a document today |
953965
| 9 | JSON and YAML agree on the same graph, both directions | unit | Enveloping in one codec only |
954966
| 10 | A receipt's `Result` keeps its type across compensation | scenario | Phase 3 applied to `bindingData` alone |
955967
| 11 | A `map[string]any` round-trips every element type | unit | The envelope stops at the top level |

‎pkg/op/any_slot_types_test.go‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,83 @@ func TestLoadGraph_AURIInADeclaredResourceSlotIsRefused(t *testing.T) {
665665
}
666666
}
667667

668+
// TestLoadGraph_ADeclaredSlotAndAnAnySlotAgreeOnTheResource pins #735's third acceptance criterion: one resource held
669+
// by a declared resource slot (bare id) and by an `any` slot (`$resource` envelope) in one graph reloads as one entry
670+
// -- the same ledger object under the same id -- through both seams.
671+
func TestLoadGraph_ADeclaredSlotAndAnAnySlotAgreeOnTheResource(t *testing.T) {
672+
673+
environment := formatIdentityEnvironment(t)
674+
candidate, err := newAnySlotResource(environment, "test:shared")
675+
if err != nil {
676+
t.Fatalf("newAnySlotResource: %v", err)
677+
}
678+
resource, err := environment.ResourceCatalog.GetOrCreate("hold", candidate.URI(), func() (Resource, error) { return candidate, nil })
679+
if err != nil {
680+
t.Fatalf("GetOrCreate: %v", err)
681+
}
682+
hold, err := ReceiverRegistry().BuildAction("resourceSlotFixture.hold")
683+
if err != nil {
684+
t.Fatalf("BuildAction(hold): %v", err)
685+
}
686+
keep, err := ReceiverRegistry().BuildAction("anySlotFixture.keep")
687+
if err != nil {
688+
t.Fatalf("BuildAction(keep): %v", err)
689+
}
690+
holder, err := NewNode(NewNodeSpec().WithID("hold").WithAction(hold).WithSlot("entry", NewImmediateBinding(resource)))
691+
if err != nil {
692+
t.Fatalf("NewNode(hold): %v", err)
693+
}
694+
keeper, err := NewNode(NewNodeSpec().WithID("keep").WithAction(keep).WithSlot("value", NewImmediateBinding(resource)))
695+
if err != nil {
696+
t.Fatalf("NewNode(keep): %v", err)
697+
}
698+
graph, err := NewGraph(NewGraphSpec().WithOrigin(NewOriginBase("test", "home", NewAnnotationMap(nil))).
699+
WithUnits(holder, keeper).WithResourceCatalog(environment.ResourceCatalog))
700+
if err != nil {
701+
t.Fatalf("NewGraph: %v", err)
702+
}
703+
704+
loaded, err := LoadGraph(formatIdentityEnvironment(t), serializeGraph(t, graph, "json"), "json")
705+
if err != nil {
706+
t.Fatalf("LoadGraph: %v", err)
707+
}
708+
values := map[string]any{}
709+
for _, node := range loaded.Nodes() {
710+
for name, value := range node.ResolveSlots(nil, nil) {
711+
values[node.ID()+"."+name] = value
712+
}
713+
}
714+
held, kept := values["hold.entry"], values["keep.value"]
715+
if held == nil || held != kept {
716+
t.Fatalf("the declared slot holds %p and the any slot %p; want one ledger entry", held, kept)
717+
}
718+
if got := held.(Resource).ID(); got != resource.ID() {
719+
t.Errorf("reloaded entry has id %s; want %s", got, resource.ID())
720+
}
721+
}
722+
723+
// TestLoadGraph_AResourceIDTheLedgerLacksIsRefusedNamingSlotAndID pins #735's fourth acceptance criterion at load: a
724+
// slot naming an id the document's ledger does not hold is refused, and the refusal names the slot and the id.
725+
func TestLoadGraph_AResourceIDTheLedgerLacksIsRefusedNamingSlotAndID(t *testing.T) {
726+
727+
document, id := resourceSlotGraph(t)
728+
slot := []byte(`"value":"` + id + `"`)
729+
if !bytes.Contains(document, slot) {
730+
t.Fatalf("the document does not carry the slot's bare id %s:\n%s", id, document)
731+
}
732+
doctored := bytes.Replace(document, slot, []byte(`"value":"res-404"`), 1)
733+
734+
_, err := LoadGraph(formatIdentityEnvironment(t), doctored, "json")
735+
if err == nil {
736+
t.Fatal("LoadGraph accepted a slot naming an id the ledger lacks; want a refusal")
737+
}
738+
for _, want := range []string{`slot "entry"`, "res-404", "not in the document's ledger"} {
739+
if !strings.Contains(err.Error(), want) {
740+
t.Errorf("refusal %q does not name %s", err, want)
741+
}
742+
}
743+
}
744+
668745
// TestLoadGraph_ADoctoredCatalogRowIsAChecksumMismatch pins #712 decision 11: the catalog's intent rows are in the
669746
// canonical form, so a document whose `resources` row was rewritten -- the slot's id untouched -- is a checksum
670747
// mismatch at load, never a graph pointed at something the plan never claimed.

‎pkg/op/method_test.go‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,44 @@ func TestResolveDispatchResource_ARecordedIDResolvesByID(t *testing.T) {
131131
t.Errorf("a recorded id the run catalog lacks must be refused; got applied %t, err %v", applied, err)
132132
}
133133
}
134+
135+
// TestResolveDispatchResource_AResourceBindsItsOwnGenerationAfterShadow pins #735's first two acceptance criteria at
136+
// the dispatch seam: after a URI is re-produced -- a second generation shadows the first -- a slot written against
137+
// either generation resolves to that generation by id, while a run-time string key resolves to whichever generation
138+
// is current, which is what a key means (§5.6).
139+
func TestResolveDispatchResource_AResourceBindsItsOwnGenerationAfterShadow(t *testing.T) {
140+
141+
catalog := NewResourceCatalog()
142+
first := newLifecycle("test:///versioned", AddressingLocation)
143+
_, firstID := catalog.Resolve(first)
144+
second := newLifecycle("test:///versioned", AddressingLocation)
145+
secondID := catalog.Shadow(second, "writer")
146+
if firstID == secondID {
147+
t.Fatalf("Shadow minted no new generation: both ids are %s", firstID)
148+
}
149+
activation := &ActivationRecord{Graph: &Graph{}, RuntimeEnvironment: &RuntimeEnvironment{ResourceCatalog: catalog}}
150+
target := reflect.TypeFor[*lifecycleResource]()
151+
152+
for _, testCase := range []struct {
153+
name string
154+
id string
155+
want *lifecycleResource
156+
}{
157+
{"the first generation", firstID, first},
158+
{"the second generation", secondID, second},
159+
} {
160+
captured := newLifecycle("test:///versioned", AddressingLocation)
161+
captured.id = testCase.id
162+
resolved, applied, err := resolveDispatchResource(activation, captured, target)
163+
if !applied || err != nil {
164+
t.Fatalf("%s: applied %t, err %v; want applied, nil", testCase.name, applied, err)
165+
}
166+
if resolved != Resource(testCase.want) {
167+
t.Errorf("%s: a slot written against %s resolved to %p, not its own generation %p", testCase.name, testCase.id, resolved, testCase.want)
168+
}
169+
}
170+
resolved, applied, err := resolveDispatchResource(activation, "test:///versioned", target)
171+
if !applied || err != nil || resolved != Resource(second) {
172+
t.Errorf("a run-time key resolved to %v (applied %t, err %v); want the current generation %p", resolved, applied, err, second)
173+
}
174+
}

0 commit comments

Comments
 (0)