Require candidate decoders for artifact codecs

This commit is contained in:
2026-08-09 01:03:50 +00:00
parent 557809f364
commit 5a58d87995
14 changed files with 138 additions and 11 deletions

View File

@@ -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()

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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)

View File

@@ -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] {

View File

@@ -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 ""

View File

@@ -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)

View File

@@ -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{}

View File

@@ -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{}

View File

@@ -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()

View File

@@ -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{}

View File

@@ -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{}

View File

@@ -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) {

View File

@@ -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)
}