diff --git a/artifact_ownership_contract_test.go b/artifact_ownership_contract_test.go new file mode 100644 index 0000000..ba5cfad --- /dev/null +++ b/artifact_ownership_contract_test.go @@ -0,0 +1,43 @@ +package promptkit_test + +import ( + "context" + "fmt" + "sync" + "testing" + + "go.maximumdirect.net/configkit" + "go.maximumdirect.net/promptkit" +) + +type independentArtifactReader struct{} + +func (independentArtifactReader) Read(_ context.Context, ref promptkit.ArtifactRef) (*promptkit.Artifact, error) { + return &promptkit.Artifact{Body: []byte(ref.Body), Hash: ref.Body}, nil +} + +func TestConcurrentPreparationWithTransferredArtifacts(t *testing.T) { + input := configkit.Input{Source: configkit.Mapping("ownership", 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", "inputs": []any{map[string]any{"name": "document", "required": true}}, "messages": []any{map[string]any{"role": "user", "content": `{{input "document"}}`}}, "output": map[string]any{"format": "text", "validation_mode": "none"}}}, + })} + engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &input}}, promptkit.WithArtifactReader(independentArtifactReader{})) + if err != nil { + t.Fatal(err) + } + var group sync.WaitGroup + for i := range 24 { + group.Go(func() { + value := fmt.Sprintf("body-%d", i) + prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: "prompt", Inputs: map[string]promptkit.ArtifactRef{"document": promptkit.Inline(value)}}) + if err != nil { + t.Errorf("prepare: %v", err) + return + } + if len(prepared.Messages) != 1 || prepared.Messages[0].Content != value || prepared.InputHashes["document"] != value { + t.Errorf("prepared = %+v", prepared) + } + }) + } + group.Wait() +} diff --git a/docs/consumers/pkg-promptkit.md b/docs/consumers/pkg-promptkit.md index 0e1e25a..7b5e93d 100644 --- a/docs/consumers/pkg-promptkit.md +++ b/docs/consumers/pkg-promptkit.md @@ -891,6 +891,11 @@ of collaborator errors. Implementations must honor cancellation, safely manage copies they retain, avoid unsafe logging of content or credentials, and enforce the application policy that motivated the injection. +A successful `ArtifactReader.Read` transfers the returned artifact and mutable +body storage to Promptkit. Readers that previously reused a returned buffer +must instead return independent storage and stop mutating it after return; +private scratch buffers may still be reused when the returned bytes are copied. + ## Handle Errors Use `errors.Is` with the diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 17ec1dc..d3e2261 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -244,6 +244,10 @@ Focused authentication and public smoke tests pass on Go 1.27.3. the accepted handoff, without promising safety for contract-violating readers. Explain migration in consumer guidance; no borrowing/release protocol is added. +Completion: ArtifactReader ownership and migration guidance require independent +transferred storage. Focused concurrent preparation, adapter isolation, and +cause-propagation tests pass with the race detector on Go 1.27.3. + ## Stage 9: Detach Prepared Ownership And Session Storage **Catalog:** 16, 18. **Files:** runner/prepared state and domain session normalization. diff --git a/types.go b/types.go index 29703d7..533df8c 100644 --- a/types.go +++ b/types.go @@ -295,15 +295,17 @@ type Artifact struct { // // Read may be called concurrently. It must honor ctx cancellation to make // Prepare, PrepareExecution, and Run responsive to cancellation. The engine -// passes a copied ref and immediately copies the returned Artifact.Body; it -// does not retain either value. Readers supply artifact metadata, and the +// passes a copied ref and defensively copies the returned Artifact.Body. +// A successful Read transfers the returned artifact and its mutable body +// storage to Promptkit. The reader must not mutate them through retained +// aliases after return. Private buffers may be reused only when the returned +// storage is independent. Readers supply artifact metadata, and the // engine assigns an input-map name only when the returned artifact name is // empty. // // An injected reader owns any application-specific path containment, // authorization, content-size, and content-type policy. It must protect // sensitive references and bodies in its logging and in any copies it retains. -// It may reuse or mutate the returned artifact and body after Read returns. // // Returning a non-nil error makes the engine return an error matching // ErrArtifactLoad while preserving the reader error through errors.Is.