diff --git a/docs/internal/pipeline.md b/docs/internal/pipeline.md index 31fd08f..e9fd69b 100644 --- a/docs/internal/pipeline.md +++ b/docs/internal/pipeline.md @@ -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 diff --git a/internal/framework/pipeline/runner_candidate_encoding_test.go b/internal/framework/pipeline/runner_candidate_encoding_test.go new file mode 100644 index 0000000..3f77a63 --- /dev/null +++ b/internal/framework/pipeline/runner_candidate_encoding_test.go @@ -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) + } + }) + } +} diff --git a/internal/framework/pipeline/runner_typed.go b/internal/framework/pipeline/runner_typed.go index fbb922b..4c5bdb3 100644 --- a/internal/framework/pipeline/runner_typed.go +++ b/internal/framework/pipeline/runner_typed.go @@ -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) +} diff --git a/internal/framework/pipeline/typed_execution.go b/internal/framework/pipeline/typed_execution.go index ea7862d..cbcda4c 100644 --- a/internal/framework/pipeline/typed_execution.go +++ b/internal/framework/pipeline/typed_execution.go @@ -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) { diff --git a/internal/modules/dnd/codec/spells/codec_test.go b/internal/modules/dnd/codec/spells/codec_test.go index 26b5716..58385c3 100644 --- a/internal/modules/dnd/codec/spells/codec_test.go +++ b/internal/modules/dnd/codec/spells/codec_test.go @@ -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] = '['