Validate artifact candidates before final encoding

This commit is contained in:
2026-07-17 15:13:19 +00:00
parent 3772b308e9
commit 35bffdf336
5 changed files with 338 additions and 10 deletions

View File

@@ -236,6 +236,13 @@ never used as a checkpoint source. Typed artifact debug envelopes are
domain-neutral, redact sensitive metadata and bytes through the common debug
policy, and record codec identity plus schema and content digests.
Merge and normalize attempts serialize their in-memory candidate with the
codec's candidate encoder before typed validation. Serialized validators and
attempt debug use that candidate representation, which carries the codec media
type and schema identity but is never checkpointed or passed downstream. Only
a validator-approved value is encoded through the strict final codec and made
eligible for a checkpoint or stage output.
Checkpoint identity, physical layout, reuse behavior, and debug artifact
handling are operator contracts in [Operations](../operations.md). Serialization
and recorder implementation are inventoried in

View File

@@ -0,0 +1,268 @@
package pipeline
import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
type observedNotesCodec struct {
candidateValues []codecNotes
finalValues []codecNotes
candidateError string
finalError string
}
func (*observedNotesCodec) Kind() contracts.ArtifactKind { return "test/notes" }
func (*observedNotesCodec) Schema() contracts.ArtifactSchema { return notesCodec().schema }
func (*observedNotesCodec) MediaType() string { return "application/json" }
func (c *observedNotesCodec) EncodeCandidate(value codecNotes) ([]byte, error) {
c.candidateValues = append(c.candidateValues, value)
if c.candidateError != "" && firstNote(value) == c.candidateError {
return nil, errors.New("candidate encoding failed")
}
return json.Marshal(value)
}
func (c *observedNotesCodec) Encode(value codecNotes) ([]byte, error) {
c.finalValues = append(c.finalValues, value)
if c.finalError != "" && firstNote(value) == c.finalError {
return nil, errors.New("final encoding failed")
}
if firstNote(value) == "invalid" {
return nil, errors.New("invalid note is not a final artifact")
}
return json.Marshal(value)
}
func (*observedNotesCodec) Decode(content []byte) (codecNotes, error) {
var value codecNotes
return value, json.Unmarshal(content, &value)
}
func firstNote(value codecNotes) string {
if len(value.Items) == 0 {
return ""
}
return value.Items[0]
}
func (c *observedNotesCodec) candidateCalls(value string) int {
return matchingNotes(c.candidateValues, value)
}
func (c *observedNotesCodec) finalCalls(value string) int {
return matchingNotes(c.finalValues, value)
}
func matchingNotes(values []codecNotes, value string) int {
count := 0
for _, candidate := range values {
if firstNote(candidate) == value {
count++
}
}
return count
}
type candidateCheckpointRecorder struct {
CheckpointRecorder
mergeSucceeded int
normalizeSucceeded int
mergeOutput CheckpointArtifact
normalizeOutput CheckpointArtifact
}
func (r *candidateCheckpointRecorder) MergeSucceeded(_ string, _ string, _ []CheckpointFingerprint, output CheckpointArtifact, _ []contracts.Warning) error {
r.mergeSucceeded++
r.mergeOutput = cloneCheckpointArtifact(output)
return nil
}
func (r *candidateCheckpointRecorder) NormalizeSucceeded(_ string, _ string, _ []CheckpointFingerprint, output CheckpointArtifact, _ []contracts.Warning) error {
r.normalizeSucceeded++
r.normalizeOutput = cloneCheckpointArtifact(output)
return nil
}
func installObservedNotesCodec(t *testing.T, prepared *PreparedPipeline, codec *observedNotesCodec) {
t.Helper()
registry := NewArtifactCodecRegistry()
if err := RegisterArtifactCodec(registry, codec); err != nil {
t.Fatalf("RegisterArtifactCodec() error = %v", err)
}
entry, _, err := registry.entry("test/notes")
if err != nil {
t.Fatalf("codec entry error = %v", err)
}
prepared.lanes[0].typed.codec = entry
}
func configureCandidateOperation(prepared *PreparedPipeline, target ModuleStage, value codecNotes) {
lane := &prepared.lanes[0]
switch target {
case StageMerge:
lane.typed.merge = func(context.Context, any, contracts.TypedMergeRequest[any]) (erasedTypedResult, error) {
return erasedTypedResult{Value: value}, nil
}
lane.typed.normalize = func(context.Context, any, contracts.TypedNormalizeRequest[any]) (erasedTypedResult, error) {
return erasedTypedResult{Value: codecNotes{Items: []string{"normalized-other"}}}, nil
}
case StageNormalize:
lane.typed.merge = func(context.Context, any, contracts.TypedMergeRequest[any]) (erasedTypedResult, error) {
return erasedTypedResult{Value: codecNotes{Items: []string{"merged-other"}}}, nil
}
lane.typed.normalize = func(context.Context, any, contracts.TypedNormalizeRequest[any]) (erasedTypedResult, error) {
return erasedTypedResult{Value: value}, nil
}
}
}
func setCandidateValidator(prepared *PreparedPipeline, target ModuleStage, approved bool) {
validator := preparedValidator{
resolved: ResolvedValidator{Binding: Binding("candidate-check"), Target: ValidatorTargetTyped, ArtifactKind: "test/notes"},
typedValidate: func(context.Context, any, typedValidationTarget) (contracts.ValidationResult, error) {
return contracts.ValidationResult{Approved: approved, ReasonCode: "candidate_rejected", Message: "candidate rejected by validator"}, nil
},
}
switch target {
case StageMerge:
prepared.lanes[0].mergeValidators.validators = []preparedValidator{validator}
case StageNormalize:
prepared.lanes[0].normalizeValidators.validators = []preparedValidator{validator}
}
}
func TestRunnerRejectsCandidatesBeforeFinalEncoding(t *testing.T) {
for _, target := range []ModuleStage{StageMerge, StageNormalize} {
t.Run(string(target), func(t *testing.T) {
prepared := preparedAttemptDebugPipeline(t)
codec := &observedNotesCodec{}
installObservedNotesCodec(t, prepared, codec)
configureCandidateOperation(prepared, target, codecNotes{Items: []string{"invalid"}})
setCandidateValidator(prepared, target, false)
recorder := &candidateCheckpointRecorder{CheckpointRecorder: NoopCheckpointRecorder()}
output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), Checkpoints: recorder})
if err != nil {
t.Fatalf("Run() error = %v, want validator rejection", err)
}
if len(output.Rejected) != 1 || output.Rejected[0].Stage != string(target) || output.Rejected[0].ReasonCode != "candidate_rejected" {
t.Fatalf("rejected outputs = %#v, want %s validator rejection", output.Rejected, target)
}
if codec.candidateCalls("invalid") != 1 || codec.finalCalls("invalid") != 0 {
t.Fatalf("invalid candidate calls = candidate %d, final %d; want 1, 0", codec.candidateCalls("invalid"), codec.finalCalls("invalid"))
}
if target == StageMerge && recorder.mergeSucceeded != 0 {
t.Fatalf("merge checkpoints = %d, want none", recorder.mergeSucceeded)
}
if target == StageNormalize && recorder.normalizeSucceeded != 0 {
t.Fatalf("normalize checkpoints = %d, want none", recorder.normalizeSucceeded)
}
})
}
}
func TestRunnerFinalEncodesAcceptedCandidatesOnce(t *testing.T) {
for _, target := range []ModuleStage{StageMerge, StageNormalize} {
t.Run(string(target), func(t *testing.T) {
prepared := preparedAttemptDebugPipeline(t)
codec := &observedNotesCodec{}
installObservedNotesCodec(t, prepared, codec)
value := "accepted-" + string(target)
configureCandidateOperation(prepared, target, codecNotes{Items: []string{value}})
setCandidateValidator(prepared, target, true)
recorder := &candidateCheckpointRecorder{CheckpointRecorder: NoopCheckpointRecorder()}
loader := &extractResultLoader{CheckpointLoader: NoopCheckpointLoader(), decision: CheckpointDecision{Reason: "not found"}}
output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), Checkpoints: recorder, Checkpoint: loader})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
if codec.candidateCalls(value) != 1 || codec.finalCalls(value) != 1 {
t.Fatalf("accepted candidate calls = candidate %d, final %d; want 1, 1", codec.candidateCalls(value), codec.finalCalls(value))
}
if target == StageMerge && recorder.mergeSucceeded != 1 {
t.Fatalf("merge checkpoints = %d, want one", recorder.mergeSucceeded)
}
if target == StageNormalize && recorder.normalizeSucceeded != 1 {
t.Fatalf("normalize checkpoints = %d, want one", recorder.normalizeSucceeded)
}
checkpoint := recorder.mergeOutput
if target == StageNormalize {
checkpoint = recorder.normalizeOutput
}
var stored codecNotes
if err := json.Unmarshal(checkpoint.Artifact.Content, &stored); err != nil || !reflect.DeepEqual(stored, codecNotes{Items: []string{value}}) {
t.Fatalf("checkpoint content = %s, %v; want accepted value %q", checkpoint.Artifact.Content, err, value)
}
gotStages := make([]string, len(output.CheckpointEvents))
for i, event := range output.CheckpointEvents {
gotStages[i] = event.Stage
}
wantStages := []string{"source", string(StageChunk), string(StageExtract), string(StageMerge), string(StageNormalize)}
if !reflect.DeepEqual(gotStages, wantStages) {
t.Fatalf("checkpoint event stages = %#v, want %#v", gotStages, wantStages)
}
})
}
}
func TestRunnerRecordsCandidateAndFinalEncodingFailures(t *testing.T) {
tests := []struct {
name string
target ModuleStage
value string
candidate bool
wantError string
}{
{name: "merge candidate", target: StageMerge, value: "merge-candidate-failure", candidate: true, wantError: "serialize merge candidate"},
{name: "normalize candidate", target: StageNormalize, value: "normalize-candidate-failure", candidate: true, wantError: "serialize normalize candidate"},
{name: "merge final", target: StageMerge, value: "merge-final-failure", wantError: "serialize accepted merge output"},
{name: "normalize final", target: StageNormalize, value: "normalize-final-failure", wantError: "serialize accepted normalize output"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
prepared := preparedAttemptDebugPipeline(t)
codec := &observedNotesCodec{}
if tc.candidate {
codec.candidateError = tc.value
} else {
codec.finalError = tc.value
}
installObservedNotesCodec(t, prepared, codec)
configureCandidateOperation(prepared, tc.target, codecNotes{Items: []string{tc.value}})
setCandidateValidator(prepared, tc.target, true)
recorder := &candidateCheckpointRecorder{CheckpointRecorder: NoopCheckpointRecorder()}
debug := newCapturedDebugRecorder()
_, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), Checkpoints: recorder, Debug: debug})
if err == nil || !strings.Contains(err.Error(), tc.wantError) {
t.Fatalf("Run() error = %v, want %q", err, tc.wantError)
}
attemptPath := fmt.Sprintf("%s/notes/attempt-01.json", tc.target)
envelope := debug.envelope(t, attemptPath)
if !strings.Contains(envelope.Error, tc.wantError) {
t.Fatalf("attempt error = %q, want %q", envelope.Error, tc.wantError)
}
if tc.candidate && codec.finalCalls(tc.value) != 0 {
t.Fatalf("final encode calls = %d, want none after candidate failure", codec.finalCalls(tc.value))
}
if tc.target == StageMerge && recorder.mergeSucceeded != 0 {
t.Fatalf("merge checkpoints = %d, want none", recorder.mergeSucceeded)
}
if tc.target == StageNormalize && recorder.normalizeSucceeded != 0 {
t.Fatalf("normalize checkpoints = %d, want none", recorder.normalizeSucceeded)
}
})
}
}

View File

@@ -111,6 +111,14 @@ func checkpointArtifact(codec artifactCodecEntry, laneID, moduleKey, sourceID st
return CheckpointArtifact{LaneID: laneID, ModuleKey: moduleKey, SourceID: sourceID, Artifact: serialized, SchemaDigest: contracts.DigestArtifactSchema(serialized.Schema)}, nil
}
func serializeCandidateArtifact(codec artifactCodecEntry, laneID, moduleKey, sourceID string, value any) (CheckpointArtifact, error) {
serialized, err := serializeArtifact(codec, value, true)
if err != nil {
return CheckpointArtifact{}, err
}
return CheckpointArtifact{LaneID: laneID, ModuleKey: moduleKey, SourceID: sourceID, Artifact: serialized, SchemaDigest: contracts.DigestArtifactSchema(serialized.Schema)}, nil
}
type laneRunError struct {
stage ModuleStage
err error
@@ -183,8 +191,8 @@ func (r *Runner) continueTypedLane(ctx context.Context, input RunInput, checkpoi
return false, nil, attemptErr
}
candidate := erasedMergeArtifact{LaneID: lane.ID, MergerKey: lane.Merge.Module, SourceID: doc.ID, Value: result.Value}
stored, encodeErr := checkpointArtifact(typed.codec, candidate.LaneID, candidate.MergerKey, candidate.SourceID, candidate.Value)
attemptWarnings := cloneWarnings(result.Warnings)
serializedCandidate, encodeErr := serializeCandidateArtifact(typed.codec, candidate.LaneID, candidate.MergerKey, candidate.SourceID, candidate.Value)
if encodeErr != nil {
attemptErr := fmt.Errorf("serialize merge candidate for lane %q: %w", lane.ID, encodeErr)
if debugErr := attemptEnvelope(map[string]any{"warnings": debugWarningEnvelopes(attemptWarnings)}, attemptErr); debugErr != nil {
@@ -192,15 +200,23 @@ func (r *Runner) continueTypedLane(ctx context.Context, input RunInput, checkpoi
}
return false, nil, attemptErr
}
warnings, rejected, validateErr := r.validateTypedArtifact(attemptCtx, typed.codec, typedValidationTarget{stage: StageMerge, laneID: lane.ID, moduleKey: lane.Merge.Module, source: doc, sourceID: doc.ID, sourceInput: sourceInput.Clone(), sessionID: sessionID, references: lane.MergeReferences.ReferenceSet, metadata: input.Metadata, value: result.Value}, prepared.mergeValidators, attempt, input.Debug)
warnings, rejected, validateErr := r.validateTypedArtifact(attemptCtx, typed.codec, typedValidationTarget{stage: StageMerge, laneID: lane.ID, moduleKey: lane.Merge.Module, source: doc, sourceID: doc.ID, sourceInput: sourceInput.Clone(), sessionID: sessionID, references: lane.MergeReferences.ReferenceSet, metadata: input.Metadata, value: result.Value, candidate: &serializedCandidate}, prepared.mergeValidators, attempt, input.Debug)
attemptWarnings = append(attemptWarnings, warnings...)
payload := map[string]any{"output": debugCheckpointArtifact(stored), "warnings": debugWarningEnvelopes(attemptWarnings), "rejection": debugRejectedOutputPtr(rejected)}
payload := map[string]any{"output": debugCheckpointArtifact(serializedCandidate), "warnings": debugWarningEnvelopes(attemptWarnings), "rejection": debugRejectedOutputPtr(rejected)}
if validateErr != nil || rejected != nil {
if debugErr := attemptEnvelope(payload, validateErr); debugErr != nil {
return false, nil, fmt.Errorf("write merge attempt debug artifact: %w", debugErr)
}
return false, rejected, validateErr
}
stored, encodeErr := checkpointArtifact(typed.codec, candidate.LaneID, candidate.MergerKey, candidate.SourceID, candidate.Value)
if encodeErr != nil {
attemptErr := fmt.Errorf("serialize accepted merge output for lane %q: %w", lane.ID, encodeErr)
if debugErr := attemptEnvelope(payload, attemptErr); debugErr != nil {
return false, nil, fmt.Errorf("write merge attempt debug artifact: %w", debugErr)
}
return false, nil, attemptErr
}
if debugErr := attemptEnvelope(payload, nil); debugErr != nil {
return false, nil, fmt.Errorf("write merge attempt debug artifact: %w", debugErr)
}
@@ -272,8 +288,8 @@ func (r *Runner) continueTypedLane(ctx context.Context, input RunInput, checkpoi
}
return false, nil, attemptErr
}
stored, encodeErr := checkpointArtifact(typed.codec, lane.ID, lane.Normalize.Module, doc.ID, result.Value)
attemptWarnings := cloneWarnings(result.Warnings)
serializedCandidate, encodeErr := serializeCandidateArtifact(typed.codec, lane.ID, lane.Normalize.Module, doc.ID, result.Value)
if encodeErr != nil {
attemptErr := fmt.Errorf("serialize normalize candidate for lane %q: %w", lane.ID, encodeErr)
if debugErr := attemptEnvelope(map[string]any{"warnings": debugWarningEnvelopes(attemptWarnings)}, attemptErr); debugErr != nil {
@@ -281,15 +297,23 @@ func (r *Runner) continueTypedLane(ctx context.Context, input RunInput, checkpoi
}
return false, nil, attemptErr
}
warnings, rejected, validateErr := r.validateTypedArtifact(attemptCtx, typed.codec, typedValidationTarget{stage: StageNormalize, laneID: lane.ID, moduleKey: lane.Normalize.Module, source: doc, sourceID: doc.ID, sourceInput: sourceInput.Clone(), sessionID: sessionID, references: lane.NormalizeReferences.ReferenceSet, metadata: input.Metadata, value: result.Value}, prepared.normalizeValidators, attempt, input.Debug)
warnings, rejected, validateErr := r.validateTypedArtifact(attemptCtx, typed.codec, typedValidationTarget{stage: StageNormalize, laneID: lane.ID, moduleKey: lane.Normalize.Module, source: doc, sourceID: doc.ID, sourceInput: sourceInput.Clone(), sessionID: sessionID, references: lane.NormalizeReferences.ReferenceSet, metadata: input.Metadata, value: result.Value, candidate: &serializedCandidate}, prepared.normalizeValidators, attempt, input.Debug)
attemptWarnings = append(attemptWarnings, warnings...)
payload := map[string]any{"output": debugCheckpointArtifact(stored), "warnings": debugWarningEnvelopes(attemptWarnings), "rejection": debugRejectedOutputPtr(rejected)}
payload := map[string]any{"output": debugCheckpointArtifact(serializedCandidate), "warnings": debugWarningEnvelopes(attemptWarnings), "rejection": debugRejectedOutputPtr(rejected)}
if validateErr != nil || rejected != nil {
if debugErr := attemptEnvelope(payload, validateErr); debugErr != nil {
return false, nil, fmt.Errorf("write normalize attempt debug artifact: %w", debugErr)
}
return false, rejected, validateErr
}
stored, encodeErr := checkpointArtifact(typed.codec, lane.ID, lane.Normalize.Module, doc.ID, result.Value)
if encodeErr != nil {
attemptErr := fmt.Errorf("serialize accepted normalize output for lane %q: %w", lane.ID, encodeErr)
if debugErr := attemptEnvelope(payload, attemptErr); debugErr != nil {
return false, nil, fmt.Errorf("write normalize attempt debug artifact: %w", debugErr)
}
return false, nil, attemptErr
}
if debugErr := attemptEnvelope(payload, nil); debugErr != nil {
return false, nil, fmt.Errorf("write normalize attempt debug artifact: %w", debugErr)
}
@@ -358,17 +382,17 @@ func (r *Runner) validateTypedArtifact(ctx context.Context, codec artifactCodecE
target.llmProfile = binding.LLMProfile
result, err = item.typedValidate(validatorCtx, item.typed, target)
case ValidatorTargetSerialized:
artifact, encodeErr := serializeArtifact(codec, target.value, true)
artifact, encodeErr := validationCandidateArtifact(codec, target)
if encodeErr != nil {
err = encodeErr
break
}
result, err = item.serialized.Validate(validatorCtx, contracts.SerializedValidationRequest{Stage: string(target.stage), LaneID: target.laneID, ModuleKey: target.moduleKey, Source: target.source, SourceID: target.sourceID, SourceInput: target.sourceInput.Clone(), SessionID: target.sessionID, References: CloneReferenceSet(target.references), LLMProfile: binding.LLMProfile, Metadata: cloneMetadata(target.metadata), Chunk: cloneSourceChunkPtr(target.chunk), Chunks: cloneSourceChunks(target.chunks), Schema: contracts.CloneArtifactSchema(artifact.Schema), MediaType: artifact.MediaType, Content: append([]byte(nil), artifact.Content...)})
result, err = item.serialized.Validate(validatorCtx, contracts.SerializedValidationRequest{Stage: string(target.stage), LaneID: target.laneID, ModuleKey: target.moduleKey, Source: target.source, SourceID: target.sourceID, SourceInput: target.sourceInput.Clone(), SessionID: target.sessionID, References: CloneReferenceSet(target.references), LLMProfile: binding.LLMProfile, Metadata: cloneMetadata(target.metadata), Chunk: cloneSourceChunkPtr(target.chunk), Chunks: cloneSourceChunks(target.chunks), Schema: contracts.CloneArtifactSchema(artifact.Artifact.Schema), MediaType: artifact.Artifact.MediaType, Content: append([]byte(nil), artifact.Artifact.Content...)})
default:
return nil, nil, fmt.Errorf("validator %q is incompatible with typed artifact validation", binding.Module)
}
artifact, _ := serializeArtifact(codec, target.value, true)
debugCall := debugValidationCall{ValidatorName: binding.Module, Request: map[string]any{"stage": string(target.stage), "lane_id": target.laneID, "module_key": target.moduleKey, "source_id": target.sourceID, "artifact": debugCheckpointArtifact(CheckpointArtifact{Artifact: artifact, SchemaDigest: contracts.DigestArtifactSchema(artifact.Schema)}), "metadata": redactSensitiveMap(target.metadata)}, Result: debugValidationResultEnvelope(result)}
artifact, _ := validationCandidateArtifact(codec, target)
debugCall := debugValidationCall{ValidatorName: binding.Module, Request: map[string]any{"stage": string(target.stage), "lane_id": target.laneID, "module_key": target.moduleKey, "source_id": target.sourceID, "artifact": debugCheckpointArtifact(artifact), "metadata": redactSensitiveMap(target.metadata)}, Result: debugValidationResultEnvelope(result)}
if err != nil {
debugCall.Error = err.Error()
}
@@ -403,3 +427,10 @@ func (r *Runner) validateTypedArtifact(ctx context.Context, codec artifactCodecE
}
return warnings, nil, nil
}
func validationCandidateArtifact(codec artifactCodecEntry, target typedValidationTarget) (CheckpointArtifact, error) {
if target.candidate != nil {
return cloneCheckpointArtifact(*target.candidate), nil
}
return serializeCandidateArtifact(codec, target.laneID, target.moduleKey, target.sourceID, target.value)
}

View File

@@ -41,6 +41,7 @@ type typedValidationTarget struct {
chunks []source.Chunk
ref source.SourceRef
value any
candidate *CheckpointArtifact
}
func exactTypedValue[T any](operation string, value any) (T, error) {

View File

@@ -2,6 +2,7 @@ package spells
import (
"bytes"
"context"
"encoding/json"
"os"
"reflect"
@@ -12,6 +13,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
)
func TestCodecMatchesMaintainedDurableFixture(t *testing.T) {
@@ -101,6 +103,25 @@ func TestCodecRejectsInvalidCanonicalValues(t *testing.T) {
}
}
func TestCodecEncodesIncompleteCandidateWithoutWeakeningFinalEncoding(t *testing.T) {
codec := New()
candidate := dnd.SpellList{}
content, err := codec.EncodeCandidate(candidate)
if err != nil {
t.Fatalf("EncodeCandidate() error = %v, want nil", err)
}
if !json.Valid(content) {
t.Fatalf("EncodeCandidate() = %q, want JSON", content)
}
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)
}
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)
}
}
func TestCodecSchemaIsMutationSafe(t *testing.T) {
first := New().Schema()
first.JSONSchema[0] = '['