diff --git a/internal/cli/reference_contract_test.go b/internal/cli/reference_contract_test.go index d3d8add..9d41e40 100644 --- a/internal/cli/reference_contract_test.go +++ b/internal/cli/reference_contract_test.go @@ -400,6 +400,9 @@ func (referenceContractCodecA) Encode(stateTestArtifact) ([]byte, error) { func (referenceContractCodecA) Decode([]byte) (stateTestArtifact, error) { return stateTestArtifact{Value: "ok"}, nil } +func (codec referenceContractCodecA) DecodeCandidate(content []byte) (stateTestArtifact, error) { + return codec.Decode(content) +} func (referenceContractCodecB) Kind() contracts.ArtifactKind { return referenceContractKindBeta } func (referenceContractCodecB) Schema() contracts.ArtifactSchema { @@ -415,6 +418,9 @@ func (referenceContractCodecB) Encode(stateTestArtifact) ([]byte, error) { func (referenceContractCodecB) Decode([]byte) (stateTestArtifact, error) { return stateTestArtifact{Value: "ok"}, nil } +func (codec referenceContractCodecB) DecodeCandidate(content []byte) (stateTestArtifact, error) { + return codec.Decode(content) +} func referenceContractLane(t *testing.T, resolved pipeline.ResolvedPipeline, id string) pipeline.ResolvedArtifactLane { t.Helper() diff --git a/internal/cli/state_hardening_test.go b/internal/cli/state_hardening_test.go index 7c2c242..b5dc531 100644 --- a/internal/cli/state_hardening_test.go +++ b/internal/cli/state_hardening_test.go @@ -955,6 +955,9 @@ func (stateTestCodec) Encode(v stateTestArtifact) ([]byte, error) { func (stateTestCodec) Decode([]byte) (stateTestArtifact, error) { return stateTestArtifact{Value: "ok"}, nil } +func (codec stateTestCodec) DecodeCandidate(content []byte) (stateTestArtifact, error) { + return codec.Decode(content) +} type stateTestExtractor struct{ harness *stateTestHarness } diff --git a/internal/core/config/effective_config_contract_test.go b/internal/core/config/effective_config_contract_test.go index efcee44..fe6e4bd 100644 --- a/internal/core/config/effective_config_contract_test.go +++ b/internal/core/config/effective_config_contract_test.go @@ -427,6 +427,10 @@ func (effectiveCodec) Decode(content []byte) (effectiveArtifact, error) { return value, err } +func (codec effectiveCodec) DecodeCandidate(content []byte) (effectiveArtifact, error) { + return codec.Decode(content) +} + type effectiveInput struct{ key string } func (m effectiveInput) Key() string { return m.key } diff --git a/internal/framework/pipeline/artifact_codec_registry.go b/internal/framework/pipeline/artifact_codec_registry.go index ec67381..5321d41 100644 --- a/internal/framework/pipeline/artifact_codec_registry.go +++ b/internal/framework/pipeline/artifact_codec_registry.go @@ -69,6 +69,7 @@ type artifactCodecEntry struct { encodeCandidate func(any) ([]byte, error) metadata func(any) (map[string]any, error) decode func([]byte) (any, error) + decodeCandidate func([]byte) (any, error) } func NewArtifactCodecRegistry() *ArtifactCodecRegistry { @@ -77,7 +78,7 @@ func NewArtifactCodecRegistry() *ArtifactCodecRegistry { // RegisterArtifactCodec registers one codec for T. The concrete type is kept // private and checked at every erased encode boundary. -func RegisterArtifactCodec[T any](registry *ArtifactCodecRegistry, codec contracts.ArtifactCodec[T]) error { +func RegisterArtifactCodec[T any](registry *ArtifactCodecRegistry, codec contracts.CandidateArtifactCodec[T]) error { if registry == nil { return fmt.Errorf("artifact codec registry must not be nil") } @@ -119,6 +120,13 @@ func RegisterArtifactCodec[T any](registry *ArtifactCodecRegistry, codec contrac } return decoded, nil }, + decodeCandidate: func(content []byte) (any, error) { + decoded, err := codec.DecodeCandidate(append([]byte(nil), content...)) + if err != nil { + return nil, &ArtifactCodecOperationError{Operation: "decode candidate", Kind: spec.Kind, Err: err} + } + return decoded, nil + }, } entry.encodeCandidate = func(value any) ([]byte, error) { typed, err := exactTypedValue[T]("encode candidate artifact", value) diff --git a/internal/framework/pipeline/artifact_codec_registry_test.go b/internal/framework/pipeline/artifact_codec_registry_test.go index ea75c0e..f3594b9 100644 --- a/internal/framework/pipeline/artifact_codec_registry_test.go +++ b/internal/framework/pipeline/artifact_codec_registry_test.go @@ -24,12 +24,13 @@ type codecScore struct { type codecNotesAlias codecNotes type testArtifactCodec[T any] struct { - kind contracts.ArtifactKind - schema contracts.ArtifactSchema - mediaType string - encodeFunc func(T) ([]byte, error) - candidateFunc func(T) ([]byte, error) - decodeFunc func([]byte) (T, error) + kind contracts.ArtifactKind + schema contracts.ArtifactSchema + mediaType string + encodeFunc func(T) ([]byte, error) + candidateFunc func(T) ([]byte, error) + decodeFunc func([]byte) (T, error) + candidateDecodeFunc func([]byte) (T, error) } func (c testArtifactCodec[T]) Kind() contracts.ArtifactKind { return c.kind } @@ -43,8 +44,15 @@ func (c testArtifactCodec[T]) EncodeCandidate(value T) ([]byte, error) { } func (c testArtifactCodec[T]) Encode(value T) ([]byte, error) { return c.encodeFunc(value) } func (c testArtifactCodec[T]) Decode(content []byte) (T, error) { return c.decodeFunc(content) } +func (c testArtifactCodec[T]) DecodeCandidate(content []byte) (T, error) { + if c.candidateDecodeFunc != nil { + return c.candidateDecodeFunc(content) + } + return c.decodeFunc(content) +} var _ contracts.ArtifactCodec[codecNotes] = testArtifactCodec[codecNotes]{} +var _ contracts.CandidateArtifactCodec[codecNotes] = testArtifactCodec[codecNotes]{} func TestArtifactCodecRegistryStoresHeterogeneousExactTypes(t *testing.T) { registry := NewArtifactCodecRegistry() @@ -138,6 +146,44 @@ func TestArtifactCodecRegistryKeepsCandidateAndFinalEncodingDistinct(t *testing. } } +func TestArtifactCodecRegistryKeepsCandidateAndFinalDecodingDistinct(t *testing.T) { + candidateCalls, finalCalls := 0, 0 + codec := notesCodec() + codec.candidateDecodeFunc = func([]byte) (codecNotes, error) { + candidateCalls++ + return codecNotes{Items: []string{"candidate"}}, nil + } + codec.decodeFunc = func([]byte) (codecNotes, error) { + finalCalls++ + return codecNotes{Items: []string{"final"}}, nil + } + registry := NewArtifactCodecRegistry() + if err := RegisterArtifactCodec(registry, codec); err != nil { + t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err) + } + entry, _, err := registry.entry(codec.kind) + if err != nil { + t.Fatalf("entry() error = %v, want nil", err) + } + + candidate, err := entry.decodeCandidate([]byte(`{"items":["one"]}`)) + if err != nil { + t.Fatalf("candidate decode error = %v", err) + } + candidateWant := codecNotes{Items: []string{"candidate"}} + if !reflect.DeepEqual(candidate, candidateWant) || candidateCalls != 1 || finalCalls != 0 { + t.Fatalf("candidate decode = %#v, calls = candidate %d, final %d", candidate, candidateCalls, finalCalls) + } + decoded, err := entry.decode([]byte(`{"items":["one"]}`)) + if err != nil { + t.Fatalf("final decode error = %v", err) + } + finalWant := codecNotes{Items: []string{"final"}} + if !reflect.DeepEqual(decoded, finalWant) || candidateCalls != 1 || finalCalls != 1 { + t.Fatalf("final decode = %#v, calls = candidate %d, final %d", decoded, candidateCalls, finalCalls) + } +} + func TestArtifactCodecRegistryStoresValidatedSchemaMetadata(t *testing.T) { registry := NewArtifactCodecRegistry() codec := notesCodec() @@ -267,6 +313,28 @@ func TestArtifactCodecRegistryWrapsEncodeFailure(t *testing.T) { } } +func TestArtifactCodecRegistryWrapsCandidateDecodeFailure(t *testing.T) { + cause := errors.New("cannot decode candidate notes") + codec := notesCodec() + codec.candidateDecodeFunc = func([]byte) (codecNotes, error) { + return codecNotes{}, cause + } + registry := NewArtifactCodecRegistry() + if err := RegisterArtifactCodec(registry, codec); err != nil { + t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err) + } + entry, _, err := registry.entry(codec.kind) + if err != nil { + t.Fatalf("entry() error = %v, want nil", err) + } + + _, err = entry.decodeCandidate([]byte(`{"items":["one"]}`)) + var operationErr *ArtifactCodecOperationError + if !errors.As(err, &operationErr) || operationErr.Operation != "decode candidate" || !errors.Is(err, cause) { + t.Fatalf("candidate decode error = %T %v, want typed wrapping error", err, err) + } +} + func TestArtifactCodecRegistryClonesCodecBytes(t *testing.T) { shared := []byte(`{"items":["one"]}`) codec := notesCodec() @@ -275,6 +343,10 @@ func TestArtifactCodecRegistryClonesCodecBytes(t *testing.T) { content[0] = '[' return codecNotes{Items: []string{"one"}}, nil } + codec.candidateDecodeFunc = func(content []byte) (codecNotes, error) { + content[0] = '[' + return codecNotes{Items: []string{"candidate"}}, nil + } registry := NewArtifactCodecRegistry() if err := RegisterArtifactCodec(registry, codec); err != nil { t.Fatalf("RegisterArtifactCodec() error = %v, want nil", err) @@ -295,6 +367,18 @@ func TestArtifactCodecRegistryClonesCodecBytes(t *testing.T) { if !bytes.Equal(artifact.Content, before) { t.Fatalf("serialized content changed during decode: %q", artifact.Content) } + candidateContent := []byte(`{"items":["candidate"]}`) + candidateBefore := append([]byte(nil), candidateContent...) + entry, _, err := registry.entry("test/notes") + if err != nil { + t.Fatalf("entry() error = %v, want nil", err) + } + if _, err := entry.decodeCandidate(candidateContent); err != nil { + t.Fatalf("candidate decode error = %v, want nil", err) + } + if !bytes.Equal(candidateContent, candidateBefore) { + t.Fatalf("candidate content changed during decode: %q", candidateContent) + } } func notesCodec() testArtifactCodec[codecNotes] { diff --git a/internal/framework/pipeline/runner_candidate_encoding_test.go b/internal/framework/pipeline/runner_candidate_encoding_test.go index f8bebd8..803fda6 100644 --- a/internal/framework/pipeline/runner_candidate_encoding_test.go +++ b/internal/framework/pipeline/runner_candidate_encoding_test.go @@ -49,6 +49,10 @@ func (*observedNotesCodec) Decode(content []byte) (codecNotes, error) { return value, json.Unmarshal(content, &value) } +func (c *observedNotesCodec) DecodeCandidate(content []byte) (codecNotes, error) { + return c.Decode(content) +} + func firstNote(value codecNotes) string { if len(value.Items) == 0 { return "" diff --git a/internal/framework/pipeline/typed_resolution_test.go b/internal/framework/pipeline/typed_resolution_test.go index fd9ced2..1637014 100644 --- a/internal/framework/pipeline/typed_resolution_test.go +++ b/internal/framework/pipeline/typed_resolution_test.go @@ -500,7 +500,7 @@ func registriesFromModuleCatalog(catalog ModuleCatalog) Registries { } } -func mustRegisterArtifactCodec[T any](t *testing.T, registry *ArtifactCodecRegistry, codec contracts.ArtifactCodec[T]) { +func mustRegisterArtifactCodec[T any](t *testing.T, registry *ArtifactCodecRegistry, codec contracts.CandidateArtifactCodec[T]) { t.Helper() if err := RegisterArtifactCodec(registry, codec); err != nil { t.Fatalf("RegisterArtifactCodec() error = %v", err) diff --git a/internal/modules/dnd/codec/combatturns/codec.go b/internal/modules/dnd/codec/combatturns/codec.go index ba79bb7..c448a2a 100644 --- a/internal/modules/dnd/codec/combatturns/codec.go +++ b/internal/modules/dnd/codec/combatturns/codec.go @@ -21,6 +21,7 @@ const ( var schemaAssets embed.FS var _ contracts.ArtifactCodec[dnd.CombatTurnList] = (*Codec)(nil) +var _ contracts.CandidateArtifactCodec[dnd.CombatTurnList] = (*Codec)(nil) type Codec struct{} diff --git a/internal/modules/dnd/codec/enemyevents/codec.go b/internal/modules/dnd/codec/enemyevents/codec.go index 7d68ce5..a5e28bc 100644 --- a/internal/modules/dnd/codec/enemyevents/codec.go +++ b/internal/modules/dnd/codec/enemyevents/codec.go @@ -23,6 +23,7 @@ const ( var schemaAssets embed.FS var _ contracts.ArtifactCodec[dnd.EnemyEventList] = (*Codec)(nil) +var _ contracts.CandidateArtifactCodec[dnd.EnemyEventList] = (*Codec)(nil) type Codec struct{} diff --git a/internal/modules/dnd/codec/enemyevents/codec_test.go b/internal/modules/dnd/codec/enemyevents/codec_test.go index 9890489..f0988e2 100644 --- a/internal/modules/dnd/codec/enemyevents/codec_test.go +++ b/internal/modules/dnd/codec/enemyevents/codec_test.go @@ -82,13 +82,16 @@ func TestCodecDefensivelyOwnsValuesAndDefersSemanticValidation(t *testing.T) { if err != nil { t.Fatal(err) } - decoded, err := codec.Decode(content) + decoded, err := codec.DecodeCandidate(content) if err != nil || !reflect.DeepEqual(decoded, candidate) { - t.Fatalf("Decode() = %#v, %v; want semantic candidate preservation", decoded, err) + t.Fatalf("DecodeCandidate() = %#v, %v; want semantic candidate preservation", decoded, err) } decoded.Events[0].SourceRefs[0].SourceID = "changed" if candidate.Events[0].SourceRefs[0].SourceID != "" { - t.Fatal("Decode() retained caller-owned source references") + t.Fatal("DecodeCandidate() retained caller-owned source references") + } + if decoded, err := codec.Decode(content); err != nil || !reflect.DeepEqual(decoded, candidate) { + t.Fatalf("Decode() = %#v, %v; want durable decode behavior", decoded, err) } first := codec.Schema() diff --git a/internal/modules/dnd/codec/scenedescriptions/codec.go b/internal/modules/dnd/codec/scenedescriptions/codec.go index a15f16f..4cc3ee5 100644 --- a/internal/modules/dnd/codec/scenedescriptions/codec.go +++ b/internal/modules/dnd/codec/scenedescriptions/codec.go @@ -21,6 +21,7 @@ const ( var schemaAssets embed.FS var _ contracts.ArtifactCodec[dnd.SceneDescriptionList] = (*Codec)(nil) +var _ contracts.CandidateArtifactCodec[dnd.SceneDescriptionList] = (*Codec)(nil) type Codec struct{} diff --git a/internal/modules/dnd/codec/spells/codec.go b/internal/modules/dnd/codec/spells/codec.go index f27735f..5c6a25c 100644 --- a/internal/modules/dnd/codec/spells/codec.go +++ b/internal/modules/dnd/codec/spells/codec.go @@ -21,6 +21,7 @@ const ( var schemaAssets embed.FS var _ contracts.ArtifactCodec[dnd.SpellList] = (*Codec)(nil) +var _ contracts.CandidateArtifactCodec[dnd.SpellList] = (*Codec)(nil) type Codec struct{} diff --git a/internal/modules/dnd/codec/spells/codec_test.go b/internal/modules/dnd/codec/spells/codec_test.go index b6be18e..f6700c9 100644 --- a/internal/modules/dnd/codec/spells/codec_test.go +++ b/internal/modules/dnd/codec/spells/codec_test.go @@ -114,6 +114,10 @@ func TestCodecEncodesIncompleteCandidateWithoutWeakeningFinalEncoding(t *testing if !json.Valid(content) { t.Fatalf("EncodeCandidate() = %q, want JSON", content) } + decoded, err := codec.DecodeCandidate(content) + if err != nil || !reflect.DeepEqual(decoded, candidate) { + t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate) + } result, err := spellshape.New(spellshape.Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.SpellList]{Value: candidate}) if err != nil || result.Approved || result.ReasonCode != spellshape.ReasonCode { t.Fatalf("shape validation = %#v, %v; want candidate rejection", result, err) @@ -121,6 +125,9 @@ func TestCodecEncodesIncompleteCandidateWithoutWeakeningFinalEncoding(t *testing if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), "spell_casts must be present") { t.Fatalf("Encode() error = %v, want strict final shape error", err) } + if _, err := codec.Decode(content); err == nil || !strings.Contains(err.Error(), "spell_casts must be present") { + t.Fatalf("Decode() error = %v, want strict final shape error", err) + } } func TestCodecSchemaIsMutationSafe(t *testing.T) { diff --git a/internal/modules/seriatim/input/transcript/runner_helpers_test.go b/internal/modules/seriatim/input/transcript/runner_helpers_test.go index 16134fd..2707be1 100644 --- a/internal/modules/seriatim/input/transcript/runner_helpers_test.go +++ b/internal/modules/seriatim/input/transcript/runner_helpers_test.go @@ -164,3 +164,7 @@ func (seriatimArtifactCodec) Decode(content []byte) (seriatimArtifact, error) { err := json.Unmarshal(content, &value) return value, err } + +func (codec seriatimArtifactCodec) DecodeCandidate(content []byte) (seriatimArtifact, error) { + return codec.Decode(content) +}