Files
promptkit/docs/roadmap/implementation.md

557 lines
22 KiB
Markdown

# Explicit Per-Run Profile Overrides Implementation Plan
**Status:** Ready for implementation.
## Purpose
This document is the decision-complete implementation plan for the
[explicit per-run profile overrides](profile-overrides.md). It is written for
a coding agent that will implement each stage in order.
The feature roadmap owns the intended capability, user intent, policy choices,
compatibility decision, and target end state. This document owns the concrete
API changes, internal representation, implementation sequence, test ownership,
documentation updates, and completion gates.
## Implementation Rules
- Complete the stages in order. Keep the repository compiling and the focused
tests passing at every stage boundary.
- Preserve unrelated working-tree changes. The selected feature roadmap and
the removal of its source idea from `future.md` may already be uncommitted
when implementation begins; retain both.
- Follow every policy under `docs/policy/`, the task-specific reading guide in
`docs/development.md`, and the target behavior in
`profile-overrides.md`.
- Keep the public API in the root `promptkit` package and implementation
details under `internal/`. Do not create another public package.
- Do not broaden the feature into conversation storage, automatic session
generation, provider capability discovery, backend-specific headers,
concurrency control, retries, queues, or file-format changes.
- Preserve synchronous `Prepare` and `Run`, engine-local invocation state,
endpoint-only profiles, backend-selected profiles, and injected
`LLMClient` behavior.
- Keep all tests deterministic and offline. Use existing fakes or local
`httptest` servers; never contact OpenRouter or another live provider.
- Update exact GoDoc with each exported declaration change. Update durable
current-state documents only after the corresponding behavior is
implemented.
- Add tests at the narrowest stable owner described below. Do not reproduce
the same precedence table at the domain, runner, facade, and transport
layers.
- Do not create a release, change a module version, or tag a commit. The final
implementation handoff must call out the planned pre-`v1` source
compatibility change so it can be included in the next minor release note.
## Fixed Design
### Public API
Add a direct session field to `RunRequest`:
```go
type RunRequest struct {
// Insert after ProfileID and before APIKey.
SessionID string
}
```
Its exact GoDoc must state that:
- a nonblank value is trimmed and overrides the prompt definition's
`session_id` template;
- a blank value supplies no direct override;
- the maximum is 256 Unicode code points after trimming;
- a direct value is opaque consumer metadata, is not a credential, and may be
exposed in prepared values, results, collaborator requests, provider
requests, and provider observability; and
- callers should use stable, non-sensitive identifiers.
Change the existing reasoning override field:
```go
type ExecutionTargetOverride struct {
// Existing fields remain otherwise unchanged.
ReasoningEffort *string
}
```
The field has these exact states:
| Public value | Effective behavior |
| --- | --- |
| `nil` | Inherit the profile's reasoning effort. |
| Pointer to a nonblank string | Trim and replace the profile value. |
| Pointer to an empty or whitespace-only string | Clear the inherited value and disable reasoning for this run. |
Do not add `DisableReasoning`, a parallel reasoning override, a public optional
string wrapper, or a fixed reasoning enum. The pointer is the one supported
runtime reasoning surface.
Add completed-run session metadata:
```go
type RunResult struct {
// Insert after PromptHash and before RenderedPromptHash.
SessionID string `json:"session_id,omitempty"`
}
```
`PreparedRun.SessionID` remains the preparation metadata field. Update its
GoDoc from "rendered" to "effective direct or rendered" session identity.
Likewise, update `RenderedPrompt.SessionID` GoDoc because injected clients may
receive a direct request value rather than one produced by the prompt
renderer.
Do not add session identity to `ExecutionTarget`,
`ExecutionTargetOverride`, `Profile`, or the profile file format. Do not add a
second result field that records whether the session came from the request or
prompt.
### Internal Values And Copy Ownership
Mirror the public request changes in `internal/domain`:
```go
type RunRequest struct {
// Insert after ProfileID and before APIKey.
SessionID string
}
type ExecutionTargetOverride struct {
// Existing fields...
ReasoningEffort *string
}
type RunResult struct {
// Insert after PromptHash and before RenderedPromptHash.
SessionID string
}
```
`toDomainRunRequest` copies `RunRequest.SessionID`. The public-to-domain
execution override conversion must allocate a new string for a non-nil
reasoning pointer rather than retain the caller's pointer. Add a small
`copyStringPtr` helper beside the existing numeric pointer-copy helpers in
`convert.go`.
The internal and public profile and effective-target fields remain ordinary
strings:
- `domain.ExecutionProfile.ReasoningEffort`
- `Profile.ReasoningEffort`
- `domain.ExecutionTarget.ReasoningEffort`
- `ExecutionTarget.ReasoningEffort`
Do not extend `ExecutionTargetPresence`. That value exists to preserve
explicit numeric zero serialization. After reasoning resolution, an empty
effective reasoning string is sufficient to instruct built-in and injected
clients to omit reasoning.
### Shared Session Normalization
Add `internal/domain/session.go` with one shared internal domain rule:
```go
func NormalizeSessionID(raw string) (string, error)
```
The function:
- trims surrounding Unicode whitespace with `strings.TrimSpace`;
- returns `""`, `nil` for a blank value;
- counts Unicode code points with `utf8.RuneCountInString`;
- rejects a normalized value longer than `SessionIDMaxLength`; and
- returns an ordinary internal error containing the actual and maximum
lengths, without attaching a prompt, request, or transport error sentinel.
Keep `SessionIDMaxLength` in `internal/domain` at 256. The renderer, runner,
and model client wrap normalization failures into their own error boundaries:
- prompt session template: prompt-render failure;
- direct request session: invalid request; and
- defensive model-client validation: invalid generate request.
Refactor `internal/prompt.renderSessionID` and
`internal/llm.openAIChatRequestFromGenerateRequest` to use this helper. Remove
their duplicated trimming and rune-count logic and any now-unused imports.
### Direct Session Resolution
In `Runner.Prepare`, preserve required prompt-ID validation as the first
request check. Immediately afterward, normalize `req.SessionID`. A
normalization failure must return an error matching use-case
`ErrInvalidRequest` before prompt or profile loading begins.
Continue loading and hashing the original prompt definition. When the
normalized direct session is nonempty:
1. make a value copy of the loaded prompt definition;
2. clear only the copy's `SessionID` template;
3. pass the copied definition to the existing renderer; and
4. set the renderer's returned `RenderedPrompt.SessionID` to the normalized
direct value before hashing or constructing `PreparedRun`.
Do not mutate the repository-owned prompt definition. Do not change the
renderer interface, inject the direct value into `Vars`, or expose it to
message templates. Message rendering continues to use the request's existing
variables and inputs.
When the direct session is blank, pass the original definition to the renderer
and retain current prompt-template behavior. This includes current missing
variable, invalid template, blank result, and maximum-length behavior.
The prompt-definition hash must always be computed from the original
definition. The rendered-prompt hash must use the effective session ID after
the direct override is applied.
`Runner.Run` must copy `PreparedRun.SessionID` into the internal `RunResult`.
The existing generation request must continue receiving the same effective
value through `RenderedPrompt.SessionID`.
### Reasoning Resolution
Leave `mergeExecutionTarget`, which overlays framework/backend/profile
`ExecutionTarget` values, unchanged: blank profile-level reasoning strings
continue to mean "no replacement."
Change only `mergeExecutionTargetOverride`, which applies the request's
pointer-based override:
```go
if override.ReasoningEffort != nil {
out.ReasoningEffort = strings.TrimSpace(*override.ReasoningEffort)
}
```
Assign the trimmed value even when it is empty. This is the operation that
clears inherited reasoning. Do not validate against a fixed vocabulary and do
not translate an empty value to `none`, `null`, or any other provider value.
The built-in client already omits an empty effective `ReasoningEffort` and
serializes a nonempty value. Preserve that transport behavior. A consumer may
still explicitly provide a provider-supported nonblank value such as `none`;
Promptkit treats it as an opaque override rather than its own disable
mechanism.
### Stable JSON And Result Conversion
Add `session_id,omitempty` to the private `runResultJSON` representation in
`json.go`. Carry it in both `RunResult.MarshalJSON` and
`RunResult.UnmarshalJSON`.
The stable JSON contract is:
- a nonempty `RunResult.SessionID` serializes as top-level `session_id`;
- an empty value is omitted; and
- JSON produced before this field existed remains valid and decodes with an
empty session ID.
Carry the internal result value through `fromDomainRunResult`. No custom JSON
work is needed for `PreparedRun`, whose existing `SessionID` field already has
the required name and omission behavior.
### OpenAI-Compatible Wire Contract
Do not add `x-session-id`. The built-in client continues sending the effective
nonempty value only as the top-level `session_id` body field. Keep
`session_id` in the reserved extra-parameter field set.
The existing transport tests that assert body serialization, blank omission,
length rejection, and absence of the session header remain the canonical wire
tests. Refactoring normalization must not weaken those assertions.
### Error Identities
Do not add public or internal error sentinels.
| Failure | Required identity |
| --- | --- |
| Direct session exceeds the maximum | Public `ErrInvalidRequest` through use-case `ErrInvalidRequest` |
| Prompt session template is invalid or cannot render | Existing `ErrPromptRender` behavior |
| Prompt-rendered session exceeds the maximum | Existing `ErrPromptRender` behavior |
| Model client defensively receives an overlong session | Existing internal LLM `ErrInvalidRequest` |
| Blank reasoning pointer | No error; clears inherited reasoning |
| Nonblank unfamiliar reasoning value | No Promptkit validation error; pass through after trimming |
Do not make exact diagnostic prose a public contract. Tests should use
`errors.Is` and inspect only a useful semantic fragment when necessary.
## Test Ownership
Use the following ownership split:
- `internal/domain/session_test.go` owns shared trimming, blank handling, and
the 256/257 Unicode-code-point boundary.
- `internal/prompt/renderer_test.go` retains ownership of session-template
parsing, rendering, and prompt-render error mapping. Update existing tests
only as required by the shared normalizer refactor.
- `internal/usecase/runner_test.go` owns request/profile reasoning precedence,
direct-session bypass of the template, invalid direct-session
classification, and application of the effective session before hashing.
- Root external-package tests own public conversion, an assembled
direct-session workflow, completed-result propagation, and stable public
JSON.
- `internal/llm/openai_compatible_client_test.go` remains the sole owner of
body-versus-header delivery and wire omission. Do not duplicate those exact
HTTP assertions in a root workflow test.
Prefer extending coherent existing table tests over creating parallel
end-to-end suites. Keep literal-limit testing centralized around
`domain.SessionIDMaxLength`; use relative boundary cases rather than copying
the number into multiple tests.
## Stage 1 — Tri-State Reasoning Override
**Status:** Complete.
### Goal
Replace the ambiguous string request override with an owned pointer and
implement inherit, replace, and clear semantics without changing profiles or
the provider payload model.
### Work
1. In `types.go`, change
`ExecutionTargetOverride.ReasoningEffort` to `*string` and write exact
tri-state GoDoc. Update the surrounding override summary so it no longer
says every empty string inherits.
2. In `internal/domain/domain.go`, make the corresponding override field a
`*string`. Leave profile and effective-target fields unchanged.
3. In `convert.go`, add `copyStringPtr` beside `copyFloat64Ptr` and
`copyIntPtr`, and use it when converting the override. Never retain the
caller's pointer.
4. In `internal/usecase/runner.go`, change only the request-override merge to
assign `strings.TrimSpace(*override.ReasoningEffort)` whenever the pointer
is non-nil. Preserve profile-level blank-string behavior.
5. Update all in-repository public and internal
`ExecutionTargetOverride` composite literals to use string pointers where
they intend an override. Do not change `Profile`, `ExecutionProfile`, or
effective `ExecutionTarget` literals.
6. Keep `ExecutionTargetPresence`, the model-client request type, and the
OpenAI-compatible wire structure unchanged.
### Tests
1. Update the existing root execution-precedence test so its ordinary
nonblank reasoning override uses a pointer.
2. Extend that coherent public precedence coverage with a profile that has a
nonempty reasoning default and a pointer to a blank request value; assert
that `PreparedRun.EffectiveModelParams.ReasoningEffort` is empty.
3. In the runner resolution tests, cover all three states:
- nil inherits the profile value;
- a pointer to `" high "` produces `"high"`; and
- a pointer to whitespace produces `""`.
4. Retain the existing model-client tests for serializing nonempty reasoning
and omitting empty reasoning. Do not add another HTTP test for the same
transport branches.
### Focused Validation
Run:
```sh
gofmt -w types.go convert.go internal/domain/domain.go \
internal/usecase/runner.go engine_test.go \
internal/usecase/runner_test.go
go test . ./internal/usecase ./internal/llm
go vet . ./internal/usecase ./internal/llm
git diff --check
```
### Completion Gate
This stage is complete when the repository builds with the pointer-based
public API, all three reasoning states resolve correctly, caller pointer
ownership is not retained, and the provider client still receives only the
resolved string.
## Stage 2 — Direct Session Resolution And Result Metadata
**Status:** Pending.
### Goal
Add direct per-run session identity, bypass the prompt session template when
selected, and carry the effective value consistently through hashes,
generation, results, and stable JSON.
### Work
1. Add `internal/domain/session.go` with the shared normalization function and
refactor the prompt renderer and model client to consume it.
2. Add `SessionID` to public and internal `RunRequest` and copy it in
`toDomainRunRequest`.
3. Implement direct normalization at the start of `Runner.Prepare` after
required prompt-ID validation. Wrap an overlong value with use-case
`ErrInvalidRequest`.
4. Preserve the original definition for its hash. For a nonempty direct
session, render with a copied definition whose session template is cleared,
then apply the direct value before computing the rendered-prompt hash.
5. Leave message variables and templates unchanged. Confirm that a direct
session succeeds even if the unused prompt session template would fail
because of a missing variable.
6. Add `SessionID` to internal and public `RunResult`, populate it from
`PreparedRun` in `Runner.Run`, and carry it through `fromDomainRunResult`.
7. Add the field to `runResultJSON`, `MarshalJSON`, and `UnmarshalJSON` with
`omitempty`.
8. Update GoDoc for `RunRequest.SessionID`, `PreparedRun.SessionID`,
`RunResult.SessionID`, and `RenderedPrompt.SessionID`. Ensure none describes
the value as necessarily renderer-produced.
9. Keep the built-in HTTP request body and reserved-field behavior unchanged;
no session header is added.
### Tests
1. Add focused domain normalization cases for:
- surrounding whitespace;
- blank input;
- exactly `SessionIDMaxLength` Unicode code points; and
- one code point over the limit.
2. Add runner tests showing:
- a direct value is trimmed and wins over a prompt session template;
- a direct value bypasses an otherwise failing session template while
message templates still render normally;
- blank direct input retains prompt-template behavior;
- changing only the direct session changes the rendered-prompt hash but not
the prompt-definition hash; and
- an overlong direct value matches `ErrInvalidRequest` and does not invoke
generation.
3. Add one assembled root-package workflow using an injected fake model
client. Assert that the same direct session appears in `PreparedRun`,
`GenerateRequest.Prompt`, and `RunResult`. This test owns the public adapter
path; it must not repeat body/header assertions.
4. Extend the public `RunResult` JSON round-trip test to cover a nonempty
session, and add or extend omission coverage for an empty session.
5. Keep the existing prompt-renderer and model-client boundary tests passing
after normalization is centralized.
### Focused Validation
Run:
```sh
gofmt -w types.go convert.go json.go internal/domain/domain.go \
internal/domain/session.go internal/domain/session_test.go \
internal/prompt/go_renderer.go internal/prompt/renderer_test.go \
internal/usecase/runner.go internal/usecase/runner_test.go \
internal/llm/openai_compatible_client.go \
internal/llm/openai_compatible_client_test.go \
engine_test.go public_contract_test.go
go test . ./internal/domain ./internal/prompt ./internal/usecase ./internal/llm
go vet . ./internal/domain ./internal/prompt ./internal/usecase ./internal/llm
git diff --check
```
If an existing test file did not require an edit, omit it from the `gofmt`
arguments rather than touching it mechanically.
### Completion Gate
This stage is complete when one normalized effective session flows from the
public request through preparation, hashing, generation, completed results,
and stable JSON; direct values bypass the prompt session template; and the
existing body-only transport contract remains intact.
## Stage 3 — Durable Documentation And Final Validation
**Status:** Pending.
### Goal
Make current-state contracts accurately describe the implemented feature,
record the compatibility impact for release handoff, and validate the complete
repository.
### Work
1. Review all changed exported declarations in `types.go` and `doc.go`.
Ensure GoDoc is the canonical owner of exact field types, pointer states,
copying, normalization, privacy exposure, JSON, and error behavior.
2. Update `docs/consumers/pkg-promptkit.md` with concise task-oriented guidance
for:
- supplying a direct per-run session;
- inheriting, replacing, and disabling profile reasoning with a string
pointer; and
- treating session IDs as stable, non-secret correlation values.
Keep snippets illustrative and link to GoDoc for the exact contract.
3. Update `docs/formats.md` only where the file format interacts with runtime
precedence:
- a direct request session bypasses the prompt `session_id` template; and
- reasoning is the exception to the general nonempty request-string rule
because its pointer can explicitly clear the profile value.
Do not add or change YAML fields.
4. Update `docs/integrations/openai-compatible-chat.md` to describe the
effective direct-or-rendered session, body-only delivery, the 256-code-point
check, and omission of an explicitly disabled effective reasoning setting.
Do not duplicate public Go field declarations.
5. Update `docs/internal/runner.md` to describe direct-session normalization
and template bypass, effective hashing, tri-state reasoning precedence, and
propagation into completed results.
6. Do not update `docs/internal/llm.md` or the internal component inventory.
Shared normalization is an incidental mechanism and does not change the
model client's documented flow or any package responsibility.
7. Do not change architecture, documentation, or testing policy unless
implementation reveals a genuine policy change. This feature is expected
to fit the current policies.
8. After every implementation and documentation check passes, set the feature
roadmap and this implementation plan status to `Complete`. Do not remove
either roadmap in the implementation change; lifecycle retirement can
follow after review.
9. In the implementation handoff, explicitly identify
`ExecutionTargetOverride.ReasoningEffort` changing from `string` to
`*string` as a pre-`v1` minor-release source compatibility change. Do not
edit release procedure or create a tag.
### Full Validation
Run the complete sequence from `docs/development.md`:
```sh
go test ./...
go test -race ./...
go vet ./...
go build ./...
go run ./examples/go-library/prepare
gofmt -l $(git ls-files '*.go')
git diff --check
```
The formatting command must produce no paths. Follow every added or changed
Markdown link and confirm its target and heading exist. Also inspect:
```sh
git status --short
git diff --stat
git diff
```
Confirm that:
- only intended feature, test, documentation, and roadmap files changed;
- no `go.work`, `go.work.sum`, local module replacement, credential, generated
binary, or unrelated change was introduced;
- all public and internal override literals use the correct pointer or string
type for their layer;
- `session_id` remains reserved in extra parameters;
- the built-in client does not set `x-session-id`;
- current-state documents describe implemented behavior rather than referring
readers to the roadmaps; and
- the feature and implementation roadmaps contain no unresolved work marked
complete.
### Completion Gate
The implementation is complete only when every target-end-state item in
`profile-overrides.md` is implemented, the full validation sequence passes,
durable contracts no longer depend on roadmap prose, and the compatibility
change is clearly reported for the next minor release.
## Open Questions
None. The feature roadmap and this plan fix the public representation,
precedence, normalization, error identities, body-versus-header choice,
metadata, hashing, test ownership, compatibility treatment, and non-goals
needed for implementation.