Audit the public package API and add a staged improvement roadmap
This commit is contained in:
273
docs/roadmap/cleanup.md
Normal file
273
docs/roadmap/cleanup.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# Public Package Cleanup And Hardening Plan
|
||||
|
||||
This plan converts the public package audit into staged implementation work. The target audience is an LLM coding agent implementing each stage in order.
|
||||
|
||||
The goals are:
|
||||
|
||||
- make public-facing code concise, idiomatic, and predictable;
|
||||
- harden all public boundary inputs against accidental secret exposure, mutation side effects, and malformed caller-provided values;
|
||||
- preserve the current library user story: a downstream Go application can provide prompt/profile sources, choose built-ins or in-memory profiles, pass a request-scoped API key, and receive prepared or generated results;
|
||||
- avoid broad feature work or unrelated refactors.
|
||||
|
||||
Unless a stage explicitly says otherwise, do not change public method names, request/result shapes, CLI behavior, HTTP behavior, or built-in profile semantics.
|
||||
|
||||
## Stage 1: Make Load Error Classification Deterministic
|
||||
|
||||
Problem:
|
||||
|
||||
`internal/usecase.ErrProfileLoad` currently wraps both prompt-definition load failures and execution-profile load failures. Public mapping then infers `ErrPromptLoad` or `ErrProfileLoad` from wrapped causes. This is brittle because some profile repository failures, such as profile directory read failures, are not wrapped with a profile sentinel and can still map to `scriptorium.ErrPromptLoad`.
|
||||
|
||||
Implementation:
|
||||
|
||||
1. In `internal/usecase`, split the current load sentinel into two explicit sentinels:
|
||||
- `ErrPromptLoad = errors.New("failed to load prompt definition")`
|
||||
- `ErrProfileLoad = errors.New("failed to load execution profile")`
|
||||
2. Update `Runner.Prepare`:
|
||||
- wrap `promptDefs.GetPromptDefinition` failures with `ErrPromptLoad`;
|
||||
- wrap prompt-definition hashing failures with `ErrPromptLoad`;
|
||||
- wrap `profiles.GetProfile` failures with `ErrProfileLoad`.
|
||||
3. Update internal tests that currently expect prompt load failures to use `ErrProfileLoad`.
|
||||
4. Simplify public error mapping in `errors.go`:
|
||||
- map `usecase.ErrPromptLoad` to public `ErrPromptLoad`;
|
||||
- map `usecase.ErrProfileLoad` to public `ErrProfileLoad`;
|
||||
- retain direct mappings for `promptdef.ErrPromptDefinitionNotFound`, `profile.ErrProfileNotFound`, profile YAML/profile validation causes, and prompt YAML/prompt validation causes.
|
||||
5. Keep adapter behavior stable:
|
||||
- HTTP prompt-load failures should still return the existing prompt-load status/code;
|
||||
- HTTP profile-load failures should still return the existing profile-load status/code;
|
||||
- CLI errors should remain concise and must not include raw secrets.
|
||||
|
||||
Tests:
|
||||
|
||||
- Add public package tests proving:
|
||||
- prompt-definition repository load errors map to `scriptorium.ErrPromptLoad`;
|
||||
- selected profile repository read/list errors map to `scriptorium.ErrProfileLoad`;
|
||||
- selected malformed profile YAML and raw `api_key` rejection still map to `ErrProfileLoad`;
|
||||
- prompt-not-found and profile-not-found sentinels still map to `ErrPromptNotFound` and `ErrProfileNotFound`.
|
||||
- Add or update internal usecase tests for the new `ErrPromptLoad` and `ErrProfileLoad` split.
|
||||
- Run `go test ./...`.
|
||||
|
||||
## Stage 2: Stop Mutating Caller-Owned HTTP Clients
|
||||
|
||||
Problem:
|
||||
|
||||
The default OpenAI-compatible client currently stores the caller-provided `*http.Client` and mutates `Timeout` when it is zero. Because the public `scriptorium.Config.HTTPClient` accepts this client, engine construction can unexpectedly modify caller-owned shared clients, including `http.DefaultClient`.
|
||||
|
||||
Implementation:
|
||||
|
||||
1. In `internal/llm.NewOpenAICompatibleClient`, clone any supplied `cfg.HTTPClient` before applying timeout defaults:
|
||||
- if `cfg.HTTPClient != nil`, copy the struct value with `cloned := *cfg.HTTPClient` and use `&cloned`;
|
||||
- if `cloned.Timeout == 0`, set `cloned.Timeout` to the resolved default timeout;
|
||||
- do not mutate `cfg.HTTPClient`.
|
||||
2. Preserve existing request-level timeout behavior:
|
||||
- positive `timeout_seconds` still overrides the client timeout for that request;
|
||||
- explicit `timeout_seconds: 0` with target presence still disables client timeout for that request;
|
||||
- omitted timeout still uses the client/default timeout.
|
||||
3. Do not clone or replace shared internals such as `Transport`; shallow `http.Client` copying is the idiomatic choice here.
|
||||
|
||||
Tests:
|
||||
|
||||
- Add LLM client tests proving:
|
||||
- a supplied zero-timeout `http.Client` remains unchanged after `NewOpenAICompatibleClient`;
|
||||
- the constructed client still uses the default timeout internally;
|
||||
- a supplied nonzero-timeout `http.Client` remains unchanged;
|
||||
- request-level timeout override behavior remains unchanged.
|
||||
- Run `go test ./internal/llm ./...`.
|
||||
|
||||
## Stage 3: Harden Public JSON-Like Inputs
|
||||
|
||||
Problem:
|
||||
|
||||
Public `ExtraParams` and structured-output schema values are copied with a permissive reflection helper. It deep-copies useful typed JSON-like maps/slices, but it accepts arbitrary values, pointers, functions, channels, and cyclic structures. A cyclic value can recurse indefinitely and crash the process.
|
||||
|
||||
Decision:
|
||||
|
||||
Replace permissive copying at public boundaries with validating JSON-compatible copying. This is more correct and maintainable than trying to copy arbitrary Go object graphs. `ExtraParams` and schema payloads are provider JSON payloads; accepting non-JSON values only delays failure and makes security behavior harder to reason about.
|
||||
|
||||
Implementation:
|
||||
|
||||
1. Introduce an internal root-package helper for public boundary JSON values, for example:
|
||||
- `copyJSONMap(src map[string]any) (map[string]any, error)`
|
||||
- `copyJSONValue(value any, path string, seen map[visit]struct{}) (any, error)`
|
||||
2. Accept only JSON-compatible value kinds:
|
||||
- `nil`
|
||||
- `string`
|
||||
- `bool`
|
||||
- signed and unsigned integer types that can be represented safely enough for JSON provider payloads;
|
||||
- floating-point values excluding `NaN` and infinities;
|
||||
- maps with string keys;
|
||||
- slices and arrays;
|
||||
- values behind `interface`;
|
||||
- optionally pointers only by dereferencing them, with cycle detection.
|
||||
3. Reject unsupported values with clear public errors:
|
||||
- `WithProfiles` profile `ExtraParams` errors return `ErrInvalidConfig`;
|
||||
- `OpenAICompatibleProfile` should not silently accept invalid `ExtraParams`; because it currently returns only `Profile`, keep it a pure field-copy constructor and perform validation in `WithProfiles`;
|
||||
- request `ExecutionTargetOverride.ExtraParams` errors return `ErrInvalidRequest`.
|
||||
4. Track visited map/slice/pointer identities to reject cycles instead of recursing indefinitely.
|
||||
5. Keep typed JSON-compatible values isolated from caller mutation:
|
||||
- `map[string]string`, `map[string]int`, `[]int`, `[]float64`, nested `map[string]any`, and values behind `any` should still be copied.
|
||||
6. Preserve internal-only copy helpers where they are operating on trusted domain data, but use validating helpers at public-to-domain boundaries.
|
||||
7. Ensure any provider `extra_params` validation in the LLM adapter still rejects reserved keys and empty keys as it does today.
|
||||
|
||||
Tests:
|
||||
|
||||
- Add public package tests for `ExecutionTargetOverride.ExtraParams`:
|
||||
- typed JSON-compatible maps/slices are accepted and isolated from caller mutation;
|
||||
- function/channel/struct values return `ErrInvalidRequest`;
|
||||
- maps with non-string keys return `ErrInvalidRequest`;
|
||||
- cyclic maps/slices return `ErrInvalidRequest` without panic or hang;
|
||||
- `NaN` and infinities return `ErrInvalidRequest`.
|
||||
- Add public package tests for `WithProfiles`:
|
||||
- invalid profile `ExtraParams` return `ErrInvalidConfig`;
|
||||
- cyclic profile `ExtraParams` return `ErrInvalidConfig`;
|
||||
- valid typed maps/slices remain deep-copied.
|
||||
- Run `go test ./...`.
|
||||
|
||||
## Stage 4: Redact Public Secret Fields In String Formatting
|
||||
|
||||
Problem:
|
||||
|
||||
`RunRequest.APIKey` and `GenerateRequest.APIKey` use `json:"-"`, so JSON marshaling does not emit direct API keys. Ordinary Go formatting such as `fmt.Printf("%+v", req)` can still print exported string fields.
|
||||
|
||||
Decision:
|
||||
|
||||
Add redacting formatting methods to the public request types that directly carry raw secrets. This does not protect against reflection-based dumpers, but it materially improves safety for normal `fmt`, structured logs that call `String`, and test diagnostics. Avoid replacing raw strings with a new secret wrapper type in this cleanup pass because that would add API complexity and likely require downstream changes.
|
||||
|
||||
Implementation:
|
||||
|
||||
1. Add `String() string` and `GoString() string` methods for:
|
||||
- `RunRequest`
|
||||
- `GenerateRequest`
|
||||
2. Formatting behavior:
|
||||
- include useful non-secret context such as prompt/profile IDs and whether an API key is set;
|
||||
- never include the raw `APIKey` value;
|
||||
- avoid dumping full rendered prompt or output content unless existing public behavior explicitly needs it.
|
||||
3. Consider adding redaction helpers for nested public types only if needed by these methods.
|
||||
4. Do not alter JSON tags or existing JSON output shapes.
|
||||
5. Update consumer docs to warn that reflection-based debug dumpers can still expose exported fields and that callers should avoid logging raw request structs with reflection dump libraries.
|
||||
|
||||
Tests:
|
||||
|
||||
- Add public package tests proving:
|
||||
- `fmt.Sprint(req)`, `fmt.Sprintf("%+v", req)`, and `fmt.Sprintf("%#v", req)` do not contain the direct API key for `RunRequest`;
|
||||
- the same holds for `GenerateRequest`;
|
||||
- JSON marshaling continues to omit the direct API key.
|
||||
- Run `go test ./...`.
|
||||
|
||||
## Stage 5: Polish The Public Option Type
|
||||
|
||||
Problem:
|
||||
|
||||
The public API currently exposes `type Option func(*engineOptions) error`. This works, but `go doc` shows an exported type whose signature references an unexported implementation type. That is legal Go, but it is not a polished public API.
|
||||
|
||||
Decision:
|
||||
|
||||
Change `Option` to an exported interface with an unexported method, backed by an unexported function adapter. This is the standard pattern when a package wants an opaque option type while preserving functional option ergonomics for package-provided options.
|
||||
|
||||
Implementation:
|
||||
|
||||
1. Replace the exported function type with:
|
||||
|
||||
```go
|
||||
type Option interface {
|
||||
apply(*engineOptions) error
|
||||
}
|
||||
|
||||
type optionFunc func(*engineOptions) error
|
||||
|
||||
func (f optionFunc) apply(options *engineOptions) error {
|
||||
return f(options)
|
||||
}
|
||||
```
|
||||
|
||||
2. Update all option constructors to return `optionFunc(...)`.
|
||||
3. Update `NewEngine` to call `opt.apply(&options)`.
|
||||
4. Preserve nil-option behavior if practical:
|
||||
- because `Option` is now an interface, a nil interface can still be skipped;
|
||||
- typed nil option values are not expected from package users and do not need special support.
|
||||
5. This is a public source compatibility change only for callers that manually construct custom `Option` values. The package does not currently provide a documented way for callers to create custom options, so this is acceptable before API freeze.
|
||||
|
||||
Tests:
|
||||
|
||||
- Existing option tests should continue passing.
|
||||
- Add a small compile-time or runtime test proving package-provided options still compose as before.
|
||||
- Run `go test ./...` and `go doc .` to verify the public docs no longer expose `*engineOptions` in the `Option` signature.
|
||||
|
||||
## Stage 6: Review And Tighten Redundant Public Surface
|
||||
|
||||
Problem:
|
||||
|
||||
`OpenAICompatibleProfileConfig` currently duplicates `Profile` almost exactly, and `OpenAICompatibleProfile` is a shallow convenience constructor plus deep copy. This is not harmful, but it may be unnecessary public surface unless it provides provider-specific clarity or future extension space.
|
||||
|
||||
Decision:
|
||||
|
||||
Keep `OpenAICompatibleProfile` and `OpenAICompatibleProfileConfig` for now, but tighten documentation and tests around their purpose. The constructor should remain a convenience for building a normal in-memory `Profile`; it must not register global state, maintain a model catalog, or hide credential resolution. Removing it would reduce surface area, but it would also undo the recently documented library ergonomics without a strong correctness benefit.
|
||||
|
||||
Implementation:
|
||||
|
||||
1. Add or improve doc comments for all exported option functions and source helpers:
|
||||
- `WithPromptFS`
|
||||
- `WithPromptFile`
|
||||
- `WithProfileFS`
|
||||
- `WithProfileFile`
|
||||
- `WithSchemaFS`
|
||||
- `WithSchemaFile`
|
||||
2. Improve doc comments for `Profile`, `OpenAICompatibleProfileConfig`, and `OpenAICompatibleProfile`:
|
||||
- explain that the constructor returns an ordinary in-memory profile;
|
||||
- explain that `APIKeyRequired` is satisfied by `RunRequest.APIKey`;
|
||||
- state that raw API keys do not belong in profiles.
|
||||
3. Re-run `go doc .` and confirm the public API reads cleanly.
|
||||
4. Do not remove or rename existing exported types in this stage.
|
||||
|
||||
Tests:
|
||||
|
||||
- No new behavior tests are required unless comments reveal behavior drift.
|
||||
- Run `go test ./...` and `go vet ./...`.
|
||||
|
||||
## Stage 7: Final Security And Compatibility Pass
|
||||
|
||||
Implementation:
|
||||
|
||||
1. Search for raw secret emission paths:
|
||||
- `rg "APIKey|api_key|Authorization|%\\+v|GoString|String"`.
|
||||
2. Confirm:
|
||||
- config, profile YAML, CLI flags, and HTTP JSON still reject raw API keys;
|
||||
- public `RunRequest.APIKey` remains request-scoped and JSON-omitted;
|
||||
- `PreparedRun`, `RunResult`, and formatter output do not include direct API keys;
|
||||
- injected `LLMClient` still receives `GenerateRequest.APIKey` when provided.
|
||||
3. Run:
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go run ./examples/go-library/prepare
|
||||
go run ./cmd/scriptorium render \
|
||||
--config ./examples/config.yml \
|
||||
--prompt generic.markdown_summary \
|
||||
--input transcript=./examples/fixtures/transcript.md \
|
||||
--input glossary=./examples/fixtures/glossary.yml \
|
||||
--format json
|
||||
```
|
||||
|
||||
4. Update implemented-behavior docs only after code changes:
|
||||
- `docs/consumers/pkg-scriptorium.md` for redaction and JSON-compatible `ExtraParams` validation;
|
||||
- `docs/internal/runner.md` if internal error sentinel names or load classification are documented there;
|
||||
- `docs/internal/adapters.md` if adapter error mapping documentation references the old shared sentinel.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not add a credential resolver.
|
||||
- Do not add raw API-key support to config, profile YAML, CLI flags, or HTTP JSON.
|
||||
- Do not introduce new third-party dependencies.
|
||||
- Do not add new model/profile catalog behavior.
|
||||
- Do not redesign prompt/profile/schema source loading beyond the explicit cleanup items above.
|
||||
- Do not change CLI or HTTP request/response shapes unless required to preserve current documented behavior.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None. The plan chooses the more idiomatic and maintainable options where the audit identified tradeoffs:
|
||||
|
||||
- split internal load sentinels rather than expanding public inference heuristics;
|
||||
- clone caller-owned `http.Client` values rather than documenting mutation;
|
||||
- validate JSON-like inputs rather than copying arbitrary Go object graphs;
|
||||
- redact public secret-bearing request structs rather than replacing API-key strings with a larger credential abstraction;
|
||||
- keep `OpenAICompatibleProfile` as documented ergonomic surface for now rather than removing recently added API.
|
||||
Reference in New Issue
Block a user