diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 81d0183..4600927 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -410,6 +410,17 @@ configured-default/runtime-override diagnostics through the public facade. ## Stage 15: Verify Cross-Path Ownership And Runtime Compatibility +**Completed:** Cross-path checks cover mutations by an injected client on both +initial and repair requests, detached results and details, concurrent details +with claim/discard, and typed, numeric, null, cyclic, unsupported, and deep +runtime provider values. Remaining validating copies serve configuration +acceptance/overlay or public override acceptance; outward projections and schema +detachment remain ownership boundaries. Go 1.27.3 `go test -race +./internal/usecase ./internal/configuration ./internal/jsonvalue . -run +'Prepared|Provider|Extra|Smoke|Profile|Schema'` passed (the filtered jsonvalue +package selected no tests); the full `go test -race ./internal/jsonvalue` passed +separately. + **Findings:** 5–8. **Scope:** focused existing public and package contract tests. 1. Reconcile the ownership inventory with the delivered call paths. Verify that diff --git a/prepared_execution_contract_test.go b/prepared_execution_contract_test.go index 70ff4cd..3de1299 100644 --- a/prepared_execution_contract_test.go +++ b/prepared_execution_contract_test.go @@ -307,6 +307,95 @@ func TestPreparedExecutionRepairsEmptyBasicOutput(t *testing.T) { } } +func TestPreparedExecutionIsolatesMutatingClientAcrossRepair(t *testing.T) { + client := &mutatingRepairClient{} + engine, err := promptkit.NewEngine(preparedContractConfig("original message", false), + promptkit.WithSchemaFS(preparedSchemaSource(), "."), promptkit.WithLLMClient(client)) + if err != nil { + t.Fatal(err) + } + prepared, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{ + PromptID: "prepared", + Execution: &promptkit.ExecutionTargetOverride{ExtraParams: map[string]any{ + "nested": map[string]any{"value": "original"}, + }}, + Validation: &promptkit.OutputContract{Format: promptkit.FormatJSON, ValidationMode: promptkit.ValidationJSONSchema, SchemaPath: "schema.json", RepairAttempts: 1}, + AppendedMessages: []promptkit.RenderedMessage{{Role: "user", Content: "appended", CacheControl: &promptkit.CacheControl{Type: promptkit.CacheControlEphemeral, TTL: "1h"}}}, + }) + if err != nil { + t.Fatal(err) + } + before := prepared.Details() + result, err := engine.RunPrepared(context.Background(), prepared) + if err != nil || result == nil || result.Validation.Status != promptkit.ValidationPassed || client.calls != 2 { + t.Fatalf("run result = %+v, error = %v, calls = %d", result, err, client.calls) + } + if !reflect.DeepEqual(before, prepared.Details()) { + t.Fatal("client mutation changed retained prepared metadata") + } + result.EffectiveModelParams.ExtraParams["nested"].(map[string]any)["value"] = "result mutation" + if prepared.Details().EffectiveModelParams.ExtraParams["nested"].(map[string]any)["value"] != "original" { + t.Fatal("result mutation changed retained prepared metadata") + } +} + +type mutatingRepairClient struct{ calls int } + +func (c *mutatingRepairClient) Generate(_ context.Context, request promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) { + c.calls++ + if len(request.Prompt.Messages) < 2 || request.Prompt.Messages[0].Content != "original message" || + request.Prompt.Messages[1].Content != "appended" || request.Prompt.Messages[1].CacheControl == nil || + request.Prompt.Messages[1].CacheControl.TTL != "1h" || + request.Target.ExtraParams["nested"].(map[string]any)["value"] != "original" || + request.StructuredOutput.JSONSchema.Schema.(map[string]any)["title"] != "original root" { + return nil, fmt.Errorf("generation %d received mutated request", c.calls) + } + request.Prompt.Messages[0].Content = "client mutation" + request.Prompt.Messages[1].CacheControl.TTL = "client mutation" + request.Target.ExtraParams["nested"].(map[string]any)["value"] = "client mutation" + request.StructuredOutput.JSONSchema.Schema.(map[string]any)["title"] = "client mutation" + if c.calls == 1 { + return &promptkit.GenerateResponse{Content: `{"value":"invalid"}`}, nil + } + return &promptkit.GenerateResponse{Content: `{"value":3}`}, nil +} + +type runtimeLabels []string +type runtimeCounts map[string]int + +func TestPreparedExecutionRuntimeProviderValues(t *testing.T) { + engine := newPreparedContractEngine(t, &preparedRecordingClient{response: &promptkit.GenerateResponse{Content: "ok"}}, "message") + labels := runtimeLabels{"original"} + counts := runtimeCounts{"count": 3} + request := promptkit.RunRequest{PromptID: "prepared", Execution: &promptkit.ExecutionTargetOverride{ExtraParams: map[string]any{ + "labels": labels, "counts": counts, "number": json.Number("9007199254740993"), "null": nil, + }}} + prepared, err := engine.PrepareExecution(context.Background(), request) + if err != nil { + t.Fatal(err) + } + labels[0], counts["count"] = "changed", 9 + details := prepared.Details().EffectiveModelParams.ExtraParams + if details["labels"].(runtimeLabels)[0] != "original" || details["counts"].(runtimeCounts)["count"] != 3 || + details["number"] != json.Number("9007199254740993") || details["null"] != nil { + t.Fatalf("runtime values changed: %#v", details) + } + cycle := map[string]any{} + cycle["self"] = cycle + var deep any = "leaf" + for range 150 { + deep = map[string]any{"child": deep} + } + for name, invalid := range map[string]any{"cyclic": cycle, "unsupported": func() {}, "too deep": deep} { + t.Run(name, func(t *testing.T) { + _, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prepared", Execution: &promptkit.ExecutionTargetOverride{ExtraParams: map[string]any{"value": invalid}}}) + if !errors.Is(err, promptkit.ErrInvalidRequest) { + t.Fatalf("invalid runtime provider value error = %v", err) + } + }) + } +} + func TestPreparedExecutionConcurrentClaimAllowsOneGeneration(t *testing.T) { release := make(chan struct{}) client := &preparedRecordingClient{ @@ -383,6 +472,7 @@ func TestPreparedExecutionRunAndDiscardRaceHasOneWinner(t *testing.T) { } runOutcome := make(chan outcome, 1) discardDone := make(chan struct{}) + detailsDone := make(chan bool, 1) go func() { <-start @@ -394,10 +484,23 @@ func TestPreparedExecutionRunAndDiscardRaceHasOneWinner(t *testing.T) { prepared.Discard() close(discardDone) }() + go func() { + <-start + for range 20 { + if prepared.Details().PromptID != "prepared" { + detailsDone <- false + return + } + } + detailsDone <- true + }() close(start) runResult := <-runOutcome <-discardDone + if !<-detailsDone { + t.Fatalf("attempt %d lost details during lifecycle race", i) + } calls := len(client.snapshot()) switch { case runResult.err == nil: