From 557809f3641ea7cf391bc7308a5d0b2a1e2d2a2c Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 9 Aug 2026 00:57:03 +0000 Subject: [PATCH] Add candidate artifact codec decoding --- internal/framework/contracts/artifact.go | 7 ++++ .../dnd/codec/itemoccurrences/codec.go | 1 + .../dnd/codec/itemoccurrences/codec_test.go | 15 +++++++ .../modules/dnd/codec/itemregistry/codec.go | 1 + .../dnd/codec/itemregistry/codec_test.go | 39 ++++++++++++++++++- .../dnd/codec/locationoccurrences/codec.go | 1 + .../codec/locationoccurrences/codec_test.go | 33 ++++++++++++++++ .../dnd/codec/locationregistry/codec.go | 1 + .../dnd/codec/locationregistry/codec_test.go | 33 ++++++++++++++++ .../modules/dnd/codec/npcoccurrences/codec.go | 1 + .../dnd/codec/npcoccurrences/codec_test.go | 33 ++++++++++++++++ .../modules/dnd/codec/npcregistry/codec.go | 1 + .../dnd/codec/npcregistry/codec_test.go | 33 ++++++++++++++++ 13 files changed, 198 insertions(+), 1 deletion(-) diff --git a/internal/framework/contracts/artifact.go b/internal/framework/contracts/artifact.go index 644d12e..e994aa0 100644 --- a/internal/framework/contracts/artifact.go +++ b/internal/framework/contracts/artifact.go @@ -50,6 +50,13 @@ type ArtifactCodec[T any] interface { Decode([]byte) (T, error) } +// CandidateArtifactCodec extends an artifact codec with strict representation +// decoding for values that have not yet passed semantic validation. +type CandidateArtifactCodec[T any] interface { + ArtifactCodec[T] + DecodeCandidate([]byte) (T, error) +} + // DigestArtifactSchema returns the SHA-256 digest of the exact JSON Schema // bytes. Schema formatting is therefore part of the registered identity. func DigestArtifactSchema(schema ArtifactSchema) string { diff --git a/internal/modules/dnd/codec/itemoccurrences/codec.go b/internal/modules/dnd/codec/itemoccurrences/codec.go index 5d4bdd5..ffa2946 100644 --- a/internal/modules/dnd/codec/itemoccurrences/codec.go +++ b/internal/modules/dnd/codec/itemoccurrences/codec.go @@ -24,6 +24,7 @@ const ( var schemaAssets embed.FS var _ contracts.ArtifactCodec[dnd.ItemOccurrenceList] = (*Codec)(nil) +var _ contracts.CandidateArtifactCodec[dnd.ItemOccurrenceList] = (*Codec)(nil) type Codec struct{} diff --git a/internal/modules/dnd/codec/itemoccurrences/codec_test.go b/internal/modules/dnd/codec/itemoccurrences/codec_test.go index c1006b0..213f1ea 100644 --- a/internal/modules/dnd/codec/itemoccurrences/codec_test.go +++ b/internal/modules/dnd/codec/itemoccurrences/codec_test.go @@ -124,14 +124,29 @@ func TestCodecDeepCopiesBoundaryValuesAndMetadata(t *testing.T) { if err != nil { t.Fatal(err) } + sibling, err := codec.DecodeCandidate(content) + if err != nil { + t.Fatal(err) + } if decoded.Occurrences[1].Quantity == value.Occurrences[1].Quantity || &decoded.Occurrences[1].SourceRefs[0] == &value.Occurrences[1].SourceRefs[0] { t.Fatal("DecodeCandidate() retained caller-owned occurrence fields") } + nameOffset := bytes.Index(content, []byte("Gold Pieces")) + if nameOffset < 0 { + t.Fatal("candidate JSON does not contain item name") + } + content[nameOffset] = 'X' + if decoded.Occurrences[1].Name != "Gold Pieces" { + t.Fatal("DecodeCandidate() retained input bytes") + } *decoded.Occurrences[1].Quantity = 99 decoded.Occurrences[1].SourceRefs[0].SourceID = "changed" if *value.Occurrences[1].Quantity != 12 || value.Occurrences[1].SourceRefs[0].SourceID != "session" { t.Fatal("decoded item occurrence aliases input") } + if *sibling.Occurrences[1].Quantity != 12 || sibling.Occurrences[1].SourceRefs[0].SourceID != "session" { + t.Fatal("decoded item occurrence aliases a sibling decode") + } first := codec.Schema() first.JSONSchema[0] = '[' diff --git a/internal/modules/dnd/codec/itemregistry/codec.go b/internal/modules/dnd/codec/itemregistry/codec.go index 31cfdec..05c0b67 100644 --- a/internal/modules/dnd/codec/itemregistry/codec.go +++ b/internal/modules/dnd/codec/itemregistry/codec.go @@ -23,6 +23,7 @@ const ( var schemaAssets embed.FS var _ contracts.ArtifactCodec[dnd.ItemRegistry] = (*Codec)(nil) +var _ contracts.CandidateArtifactCodec[dnd.ItemRegistry] = (*Codec)(nil) type Codec struct{} diff --git a/internal/modules/dnd/codec/itemregistry/codec_test.go b/internal/modules/dnd/codec/itemregistry/codec_test.go index 5084040..f6b9e0b 100644 --- a/internal/modules/dnd/codec/itemregistry/codec_test.go +++ b/internal/modules/dnd/codec/itemregistry/codec_test.go @@ -59,9 +59,13 @@ func TestCodecOwnsDurableSchemaAndExactType(t *testing.T) { func TestCodecCandidateEncodingMetadataAndStrictDecode(t *testing.T) { codec := New() candidate := dnd.ItemRegistry{Items: []dnd.Item{{Name: "Silver Key"}}} - if content, err := codec.EncodeCandidate(candidate); err != nil || string(content) != `{"items":[{"id":"","name":"Silver Key","source_refs":null}]}` { + content, err := codec.EncodeCandidate(candidate) + if err != nil || string(content) != `{"items":[{"id":"","name":"Silver Key","source_refs":null}]}` { t.Fatalf("EncodeCandidate() = %s, %v", content, err) } + if decoded, err := codec.DecodeCandidate(content); err != nil || !reflect.DeepEqual(decoded, candidate) { + t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate) + } if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), "item ID pattern") { t.Fatalf("Encode() error = %v, want durable validation", err) } @@ -80,6 +84,39 @@ func TestCodecCandidateEncodingMetadataAndStrictDecode(t *testing.T) { } } +func TestCodecCandidateDecodeOwnsValues(t *testing.T) { + codec := New() + input := validRegistry() + content, err := codec.EncodeCandidate(input) + if err != nil { + t.Fatal(err) + } + first, err := codec.DecodeCandidate(content) + if err != nil { + t.Fatal(err) + } + second, err := codec.DecodeCandidate(content) + if err != nil { + t.Fatal(err) + } + nameOffset := bytes.Index(content, []byte("Silver Key")) + if nameOffset < 0 { + t.Fatal("candidate JSON does not contain item name") + } + content[nameOffset] = 'X' + if first.Items[0].Name != "Silver Key" { + t.Fatal("DecodeCandidate() retained input bytes") + } + first.Items[0].Name = "changed" + first.Items[0].SourceRefs[0].SourceID = "changed" + if input.Items[0].Name != "Silver Key" || input.Items[0].SourceRefs[0].SourceID != "session-alpha" { + t.Fatal("DecodeCandidate() retained input values") + } + if second.Items[0].Name != "Silver Key" || second.Items[0].SourceRefs[0].SourceID != "session-alpha" { + t.Fatal("DecodeCandidate() returned aliased values") + } +} + func TestCodecSchemaAndMetadataAreDefensive(t *testing.T) { codec := New() first := codec.Schema() diff --git a/internal/modules/dnd/codec/locationoccurrences/codec.go b/internal/modules/dnd/codec/locationoccurrences/codec.go index 7afc4a9..1ca7be2 100644 --- a/internal/modules/dnd/codec/locationoccurrences/codec.go +++ b/internal/modules/dnd/codec/locationoccurrences/codec.go @@ -23,6 +23,7 @@ const ( var schemaAssets embed.FS var _ contracts.ArtifactCodec[dnd.LocationOccurrenceList] = (*Codec)(nil) +var _ contracts.CandidateArtifactCodec[dnd.LocationOccurrenceList] = (*Codec)(nil) type Codec struct{} diff --git a/internal/modules/dnd/codec/locationoccurrences/codec_test.go b/internal/modules/dnd/codec/locationoccurrences/codec_test.go index a495104..c3279b3 100644 --- a/internal/modules/dnd/codec/locationoccurrences/codec_test.go +++ b/internal/modules/dnd/codec/locationoccurrences/codec_test.go @@ -107,6 +107,39 @@ func TestCodecSupportsEmptyListsAndCandidateSemanticFailures(t *testing.T) { } } +func TestCodecCandidateDecodeOwnsValues(t *testing.T) { + codec := New() + input := validList() + content, err := codec.EncodeCandidate(input) + if err != nil { + t.Fatal(err) + } + first, err := codec.DecodeCandidate(content) + if err != nil { + t.Fatal(err) + } + second, err := codec.DecodeCandidate(content) + if err != nil { + t.Fatal(err) + } + nameOffset := bytes.Index(content, []byte("The Old Tavern")) + if nameOffset < 0 { + t.Fatal("candidate JSON does not contain location name") + } + content[nameOffset] = 'X' + if first.Occurrences[0].Name != "The Old Tavern" { + t.Fatal("DecodeCandidate() retained input bytes") + } + first.Occurrences[0].Name = "changed" + first.Occurrences[0].SourceRefs[0].SourceID = "changed" + if input.Occurrences[0].Name != "The Old Tavern" || input.Occurrences[0].SourceRefs[0].SourceID != "session-alpha" { + t.Fatal("DecodeCandidate() retained input values") + } + if second.Occurrences[0].Name != "The Old Tavern" || second.Occurrences[0].SourceRefs[0].SourceID != "session-alpha" { + t.Fatal("DecodeCandidate() returned aliased values") + } +} + func TestCodecRejectsStructuralJSONBeforeSemanticApproval(t *testing.T) { valid := `{"occurrences":[{"location_id":"location:sha256:0000000000000000000000000000000000000000000000000000000000000000","name":"The Tavern","kind":"visited","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}` for _, test := range []struct{ name, raw, want string }{ diff --git a/internal/modules/dnd/codec/locationregistry/codec.go b/internal/modules/dnd/codec/locationregistry/codec.go index b093c44..15c28fa 100644 --- a/internal/modules/dnd/codec/locationregistry/codec.go +++ b/internal/modules/dnd/codec/locationregistry/codec.go @@ -23,6 +23,7 @@ const ( var schemaAssets embed.FS var _ contracts.ArtifactCodec[dnd.LocationRegistry] = (*Codec)(nil) +var _ contracts.CandidateArtifactCodec[dnd.LocationRegistry] = (*Codec)(nil) type Codec struct{} diff --git a/internal/modules/dnd/codec/locationregistry/codec_test.go b/internal/modules/dnd/codec/locationregistry/codec_test.go index 704a315..23ec8f9 100644 --- a/internal/modules/dnd/codec/locationregistry/codec_test.go +++ b/internal/modules/dnd/codec/locationregistry/codec_test.go @@ -106,6 +106,39 @@ func TestCodecSupportsEmptyListsAndCandidateSemanticFailures(t *testing.T) { } } +func TestCodecCandidateDecodeOwnsValues(t *testing.T) { + codec := New() + input := validList() + content, err := codec.EncodeCandidate(input) + if err != nil { + t.Fatal(err) + } + first, err := codec.DecodeCandidate(content) + if err != nil { + t.Fatal(err) + } + second, err := codec.DecodeCandidate(content) + if err != nil { + t.Fatal(err) + } + nameOffset := bytes.Index(content, []byte("The Old Tavern")) + if nameOffset < 0 { + t.Fatal("candidate JSON does not contain location name") + } + content[nameOffset] = 'X' + if first.Locations[0].Name != "The Old Tavern" { + t.Fatal("DecodeCandidate() retained input bytes") + } + first.Locations[0].Name = "changed" + first.Locations[0].SourceRefs[0].SourceID = "changed" + if input.Locations[0].Name != "The Old Tavern" || input.Locations[0].SourceRefs[0].SourceID != "session-alpha" { + t.Fatal("DecodeCandidate() retained input values") + } + if second.Locations[0].Name != "The Old Tavern" || second.Locations[0].SourceRefs[0].SourceID != "session-alpha" { + t.Fatal("DecodeCandidate() returned aliased values") + } +} + func TestCodecRejectsStructuralJSONBeforeSemanticApproval(t *testing.T) { valid := `{"locations":[{"id":"location:sha256:0000000000000000000000000000000000000000000000000000000000000000","name":"The Tavern","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}` for _, test := range []struct{ name, raw, want string }{ diff --git a/internal/modules/dnd/codec/npcoccurrences/codec.go b/internal/modules/dnd/codec/npcoccurrences/codec.go index 39c0e9c..c02a0bd 100644 --- a/internal/modules/dnd/codec/npcoccurrences/codec.go +++ b/internal/modules/dnd/codec/npcoccurrences/codec.go @@ -22,6 +22,7 @@ const ( var schemaAssets embed.FS var _ contracts.ArtifactCodec[dnd.NPCOccurrenceList] = (*Codec)(nil) +var _ contracts.CandidateArtifactCodec[dnd.NPCOccurrenceList] = (*Codec)(nil) type Codec struct{} diff --git a/internal/modules/dnd/codec/npcoccurrences/codec_test.go b/internal/modules/dnd/codec/npcoccurrences/codec_test.go index af2180f..917e0ae 100644 --- a/internal/modules/dnd/codec/npcoccurrences/codec_test.go +++ b/internal/modules/dnd/codec/npcoccurrences/codec_test.go @@ -106,6 +106,39 @@ func TestCodecSupportsEmptyListAndPreservesCollectionPresenceInCandidates(t *tes } } +func TestCodecCandidateDecodeOwnsValues(t *testing.T) { + codec := New() + input := validList() + content, err := codec.EncodeCandidate(input) + if err != nil { + t.Fatal(err) + } + first, err := codec.DecodeCandidate(content) + if err != nil { + t.Fatal(err) + } + second, err := codec.DecodeCandidate(content) + if err != nil { + t.Fatal(err) + } + nameOffset := bytes.Index(content, []byte("Mira Thorn")) + if nameOffset < 0 { + t.Fatal("candidate JSON does not contain NPC name") + } + content[nameOffset] = 'X' + if first.Occurrences[0].Name != "Mira Thorn" { + t.Fatal("DecodeCandidate() retained input bytes") + } + first.Occurrences[0].Name = "changed" + first.Occurrences[0].SourceRefs[0].SourceID = "changed" + if input.Occurrences[0].Name != "Mira Thorn" || input.Occurrences[0].SourceRefs[0].SourceID != "session-alpha" { + t.Fatal("DecodeCandidate() retained input values") + } + if second.Occurrences[0].Name != "Mira Thorn" || second.Occurrences[0].SourceRefs[0].SourceID != "session-alpha" { + t.Fatal("DecodeCandidate() returned aliased values") + } +} + func TestCodecStrictlyRejectsMalformedUnknownAndTrailingJSON(t *testing.T) { validJSON := `{"occurrences":[{"npc_id":"npc:test-mira","name":"Mira Thorn","kind":"dialogue","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}` for _, test := range []struct{ name, raw, want string }{ diff --git a/internal/modules/dnd/codec/npcregistry/codec.go b/internal/modules/dnd/codec/npcregistry/codec.go index 8054e45..5202e49 100644 --- a/internal/modules/dnd/codec/npcregistry/codec.go +++ b/internal/modules/dnd/codec/npcregistry/codec.go @@ -22,6 +22,7 @@ const ( var schemaAssets embed.FS var _ contracts.ArtifactCodec[dnd.NPCRegistry] = (*Codec)(nil) +var _ contracts.CandidateArtifactCodec[dnd.NPCRegistry] = (*Codec)(nil) type Codec struct{} diff --git a/internal/modules/dnd/codec/npcregistry/codec_test.go b/internal/modules/dnd/codec/npcregistry/codec_test.go index adaf6d8..1ea0a44 100644 --- a/internal/modules/dnd/codec/npcregistry/codec_test.go +++ b/internal/modules/dnd/codec/npcregistry/codec_test.go @@ -112,6 +112,39 @@ func TestCodecCandidatePreservesInvalidTypedValues(t *testing.T) { } } +func TestCodecCandidateDecodeOwnsValues(t *testing.T) { + codec := New() + input := validList() + content, err := codec.EncodeCandidate(input) + if err != nil { + t.Fatal(err) + } + first, err := codec.DecodeCandidate(content) + if err != nil { + t.Fatal(err) + } + second, err := codec.DecodeCandidate(content) + if err != nil { + t.Fatal(err) + } + nameOffset := bytes.Index(content, []byte("Mira Thorn")) + if nameOffset < 0 { + t.Fatal("candidate JSON does not contain NPC name") + } + content[nameOffset] = 'X' + if first.NPCs[0].Name != "Mira Thorn" { + t.Fatal("DecodeCandidate() retained input bytes") + } + first.NPCs[0].Name = "changed" + first.NPCs[0].SourceRefs[0].SourceID = "changed" + if input.NPCs[0].Name != "Mira Thorn" || input.NPCs[0].SourceRefs[0].SourceID != "session-alpha" { + t.Fatal("DecodeCandidate() retained input values") + } + if second.NPCs[0].Name != "Mira Thorn" || second.NPCs[0].SourceRefs[0].SourceID != "session-alpha" { + t.Fatal("DecodeCandidate() returned aliased values") + } +} + func TestCodecRejectsEveryRequiredShapeBoundary(t *testing.T) { base := validList().NPCs[0] tests := []struct {