diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 612d915..4a06d10 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -23,7 +23,7 @@ contributor workflow and validation. | `internal/generation` | Coordinates frozen built-in HTTP attempts and one-shot injected calls through active backend permits, including bounded status retries, cancellable backoff, ordinary attempt observations, and invocation-local evidence for diagnostics. | [Internal generation coordination](generation.md) | | `internal/defaults` | Defines application-neutral framework constants and constructs the default execution target. It contains no CLI, server, or inbound HTTP limits. | [Framework defaults](../../internal/defaults/defaults.go) | | `internal/filecatalog` | Provides deterministic YAML discovery and path helpers for operating-system filesystems and `fs.FS` sources. | [File catalog](../../internal/filecatalog/catalog.go) | -| `internal/jsonvalue` | Validates and deeply copies bounded JSON-compatible extra-parameter and prepared-schema trees while preserving supported concrete value types and rejecting cycles or excessive depth and work. | [JSON values](../../internal/jsonvalue/jsonvalue.go) | +| `internal/jsonvalue` | Validates and deeply copies bounded JSON-compatible provider values, and detaches previously accepted values without repeating validation. | [JSON values](../../internal/jsonvalue/jsonvalue.go) | | `internal/promptdef` | Provides a narrow captured-prompt lookup contract, not-found identity, and execution-ready definition hash. | [Prompt lookup](../../internal/promptdef/repository.go), [framework formats](../formats.md) | | `internal/profile` | Provides a narrow captured-profile lookup contract and not-found identity. | [Profile lookup](../../internal/profile/repository.go), [framework formats](../formats.md) | | `internal/prompt` | Renders prompt messages from Go templates with artifact, variable, session, and cache-control data, and conservatively enumerates dependencies from captured template syntax. | [Go-template renderer](../../internal/prompt/go_renderer.go), [requirement discovery](../../internal/prompt/requirements.go) | diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index d2a0b22..37689aa 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -272,6 +272,13 @@ schema/smoke tests. Resolve Stage 8's deferred checks. ## Stage 10: Remove Copier-Derived Schema Budgets +**Completed:** Schema documents are detached using trusted cloning rather than +provider-value validation. A real loaded schema with more than 100,000 annotation +values prepares successfully and its public details are independent; deep +prepared-plan isolation is also covered. Go 1.27.3 `go test ./internal/usecase +./internal/validate ./internal/jsonvalue . -run 'Schema|Prepared|Copy|CloneTrusted'` +passed. + **Finding:** 7. **Files:** `internal/usecase/runner.go`, `prepared_execution.go`, schema/preparation tests. 1. Replace schema-document validation/copying in diff --git a/internal/usecase/prepared_execution.go b/internal/usecase/prepared_execution.go index 492e9ef..b27a13d 100644 --- a/internal/usecase/prepared_execution.go +++ b/internal/usecase/prepared_execution.go @@ -249,16 +249,8 @@ func clonePreparedRun(source *domain.PreparedRun) (*domain.PreparedRun, error) { if source.StructuredOutput.JSONSchema != nil { jsonSchema := *source.StructuredOutput.JSONSchema copied.StructuredOutput.JSONSchema = &jsonSchema - schema, copyErr := cloneJSONValue(source.StructuredOutput.JSONSchema.Schema) - if copyErr != nil { - return nil, copyErr - } - copied.StructuredOutput.JSONSchema.Schema = schema + copied.StructuredOutput.JSONSchema.Schema = jsonvalue.CloneTrusted(source.StructuredOutput.JSONSchema.Schema) } } return &copied, nil } - -func cloneJSONValue(source any) (any, error) { - return jsonvalue.Copy(source) -} diff --git a/internal/usecase/prepared_execution_test.go b/internal/usecase/prepared_execution_test.go index e423151..31cda8d 100644 --- a/internal/usecase/prepared_execution_test.go +++ b/internal/usecase/prepared_execution_test.go @@ -171,75 +171,32 @@ func TestRunnerPrepareExecutionCompletesWithoutAdmissionOrGeneration(t *testing. } } -func TestRunnerPreparationRejectsExcessivelyDeepPreparedSchema(t *testing.T) { - operations := []struct { - name string - run func(*Runner, domain.RunRequest) error - }{ - { - name: "Prepare", - run: func(runner *Runner, request domain.RunRequest) error { - _, err := runner.Prepare(context.Background(), request) - return err - }, - }, - { - name: "Run", - run: func(runner *Runner, request domain.RunRequest) error { - _, err := runner.Run(context.Background(), request) - return err - }, - }, - { - name: "PrepareExecution", - run: func(runner *Runner, request domain.RunRequest) error { - _, err := runner.PrepareExecution(context.Background(), request) - return err - }, - }, +func TestRunnerPreparationDetachesDeepPreparedSchema(t *testing.T) { + var nested any = "original" + for range 150 { + nested = map[string]any{"child": nested} } - - for _, operation := range operations { - t.Run(operation.name, func(t *testing.T) { - def := promptDef(domain.FormatJSON, domain.ValidationJSONSchema, 0) - def.Validation.SchemaPath = "schema.json" - llmClient := &fakeLLM{forbid: true} - validator := &recordingValidationPreparer{ - plan: &recordingPreparedValidation{schemaDocument: excessivelyDeepPreparedJSONValue()}, - } - runner := NewRunner( - &fakePromptRepo{def: def}, - &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}}, - - defaultArtifactReader(), - defaultRenderer(), - llmClient, - validator, - nil, - ) - - err := operation.run(runner, domain.RunRequest{ - PromptID: "p", - ProfileID: "exec", - Inputs: singleInputRef(), - }) - if !errors.Is(err, ErrValidation) { - t.Fatalf("expected ErrValidation, got %v", err) - } - if llmClient.calls != 0 { - t.Fatalf("invalid prepared schema reached generation: %d calls", llmClient.calls) - } - }) + schema := map[string]any{"annotation": nested} + def := promptDef(domain.FormatJSON, domain.ValidationJSONSchema, 0) + def.Validation.SchemaPath = "schema.json" + validator := &recordingValidationPreparer{plan: &recordingPreparedValidation{schemaDocument: schema}} + runner := NewRunner(&fakePromptRepo{def: def}, + &fakeExecutionProfileRepo{profiles: map[string]*domain.ExecutionProfile{"exec": defaultExecutionProfile()}}, + defaultArtifactReader(), defaultRenderer(), &fakeLLM{forbid: true}, validator, nil) + prepared, err := runner.PrepareExecution(context.Background(), domain.RunRequest{PromptID: "p", ProfileID: "exec", Inputs: singleInputRef()}) + if err != nil { + t.Fatalf("prepare execution: %v", err) } -} - -func excessivelyDeepPreparedJSONValue() any { - const clearlyUnsafeContainerDepth = 1_000 - var value any = true - for level := 0; level < clearlyUnsafeContainerDepth; level++ { - value = map[string]any{"child": value} + schema["annotation"].(map[string]any)["child"] = "source mutation" + first := prepared.Details().StructuredOutput.JSONSchema.Schema.(map[string]any) + if _, ok := first["annotation"].(map[string]any)["child"].(map[string]any); !ok { + t.Fatal("prepared schema aliases the validator document") + } + first["annotation"].(map[string]any)["child"] = "caller mutation" + second := prepared.Details().StructuredOutput.JSONSchema.Schema.(map[string]any) + if _, ok := second["annotation"].(map[string]any)["child"].(map[string]any); !ok { + t.Fatal("prepared schema details share mutable storage") } - return value } func TestRunnerRunPreparedCredentialAvailabilityBeforeAdmission(t *testing.T) { diff --git a/internal/usecase/runner.go b/internal/usecase/runner.go index 7ba4438..17db5c9 100644 --- a/internal/usecase/runner.go +++ b/internal/usecase/runner.go @@ -435,11 +435,7 @@ func (r *Runner) structuredOutputFromValidationPlan( } return nil, fmt.Errorf("%w: prepared json_schema validation has no schema document", ErrValidation) } - schemaDocument, err := jsonvalue.Copy(schemaDocument) - if err != nil { - return nil, fmt.Errorf("%w: invalid prepared json_schema schema document: %v", ErrValidation, err) - } - return structuredOutputSpec(def, schemaDocument), nil + return structuredOutputSpec(def, jsonvalue.CloneTrusted(schemaDocument)), nil } func (r *Runner) completePreparationWithStructuredOutput( diff --git a/schema_file_contract_test.go b/schema_file_contract_test.go index ad336da..29a00f1 100644 --- a/schema_file_contract_test.go +++ b/schema_file_contract_test.go @@ -80,6 +80,39 @@ func TestSchemaFilePreparationAndNativeSubmission(t *testing.T) { } } +func TestSchemaFilePreparationDetachesLargeAnnotation(t *testing.T) { + // Annotation values are not constraints; this array exceeds the former + // provider-value copier node limit without requiring a large fixture file. + const entries = 100_001 + path := filepath.Join(t.TempDir(), "schema.json") + schema := `{"type":"string","examples":[` + strings.Repeat(`0,`, entries-1) + `0]}` + if err := os.WriteFile(path, []byte(schema), 0o600); err != nil { + t.Fatal(err) + } + input := configkit.Input{Source: configkit.Mapping("schema test", map[string]any{ + "profiles": []any{map[string]any{"id": "profile", "endpoint": "https://example.invalid/v1", "model": "model"}}, + "prompts": []any{map[string]any{"id": "prompt", "default_profile": "profile", "messages": []any{map[string]any{"role": "user", "content": "hello"}}, "output": map[string]any{"format": "json", "validation_mode": "json_schema", "schema_path": "schema.json"}}}, + })} + engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}}, promptkit.WithSchemaFile(path)) + if err != nil { + t.Fatal(err) + } + prepared, err := engine.PrepareExecution(context.Background(), promptkit.RunRequest{PromptID: "prompt"}) + if err != nil { + t.Fatalf("prepare large schema: %v", err) + } + first := prepared.Details().StructuredOutput.JSONSchema.Schema.(map[string]any) + examples := first["examples"].([]any) + if len(examples) != entries { + t.Fatalf("examples = %d, want %d", len(examples), entries) + } + examples[0] = "changed" + second := prepared.Details().StructuredOutput.JSONSchema.Schema.(map[string]any) + if _, ok := second["examples"].([]any)[0].(json.Number); !ok { + t.Fatal("schema details share mutable annotation values") + } +} + func TestSchemaFileRejectsInvalidSourcesAndExternalReferences(t *testing.T) { dir := t.TempDir() for _, name := range []string{filepath.Join(dir, "missing.json"), dir} {