22 KiB
Explicit Per-Run Profile Overrides Implementation Plan
Status: Complete.
Purpose
This document is the decision-complete implementation plan for the explicit per-run profile overrides. 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.mdmay already be uncommitted when implementation begins; retain both. - Follow every policy under
docs/policy/, the task-specific reading guide indocs/development.md, and the target behavior inprofile-overrides.md. - Keep the public API in the root
promptkitpackage and implementation details underinternal/. 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
PrepareandRun, engine-local invocation state, endpoint-only profiles, backend-selected profiles, and injectedLLMClientbehavior. - Keep all tests deterministic and offline. Use existing fakes or local
httptestservers; 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-
v1source compatibility change so it can be included in the next minor release note.
Fixed Design
Public API
Add a direct session field to RunRequest:
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_idtemplate; - 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:
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:
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:
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.ReasoningEffortProfile.ReasoningEffortdomain.ExecutionTarget.ReasoningEffortExecutionTarget.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:
func NormalizeSessionID(raw string) (string, error)
The function:
- trims surrounding Unicode whitespace with
strings.TrimSpace; - returns
"",nilfor 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:
- make a value copy of the loaded prompt definition;
- clear only the copy's
SessionIDtemplate; - pass the copied definition to the existing renderer; and
- set the renderer's returned
RenderedPrompt.SessionIDto the normalized direct value before hashing or constructingPreparedRun.
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:
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.SessionIDserializes as top-levelsession_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.goowns shared trimming, blank handling, and the 256/257 Unicode-code-point boundary.internal/prompt/renderer_test.goretains 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.goowns 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.goremains 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
- In
types.go, changeExecutionTargetOverride.ReasoningEffortto*stringand write exact tri-state GoDoc. Update the surrounding override summary so it no longer says every empty string inherits. - In
internal/domain/domain.go, make the corresponding override field a*string. Leave profile and effective-target fields unchanged. - In
convert.go, addcopyStringPtrbesidecopyFloat64PtrandcopyIntPtr, and use it when converting the override. Never retain the caller's pointer. - In
internal/usecase/runner.go, change only the request-override merge to assignstrings.TrimSpace(*override.ReasoningEffort)whenever the pointer is non-nil. Preserve profile-level blank-string behavior. - Update all in-repository public and internal
ExecutionTargetOverridecomposite literals to use string pointers where they intend an override. Do not changeProfile,ExecutionProfile, or effectiveExecutionTargetliterals. - Keep
ExecutionTargetPresence, the model-client request type, and the OpenAI-compatible wire structure unchanged.
Tests
- Update the existing root execution-precedence test so its ordinary nonblank reasoning override uses a pointer.
- 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.ReasoningEffortis empty. - 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
"".
- 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:
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: Complete.
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
- Add
internal/domain/session.gowith the shared normalization function and refactor the prompt renderer and model client to consume it. - Add
SessionIDto public and internalRunRequestand copy it intoDomainRunRequest. - Implement direct normalization at the start of
Runner.Prepareafter required prompt-ID validation. Wrap an overlong value with use-caseErrInvalidRequest. - 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.
- 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.
- Add
SessionIDto internal and publicRunResult, populate it fromPreparedRuninRunner.Run, and carry it throughfromDomainRunResult. - Add the field to
runResultJSON,MarshalJSON, andUnmarshalJSONwithomitempty. - Update GoDoc for
RunRequest.SessionID,PreparedRun.SessionID,RunResult.SessionID, andRenderedPrompt.SessionID. Ensure none describes the value as necessarily renderer-produced. - Keep the built-in HTTP request body and reserved-field behavior unchanged; no session header is added.
Tests
- Add focused domain normalization cases for:
- surrounding whitespace;
- blank input;
- exactly
SessionIDMaxLengthUnicode code points; and - one code point over the limit.
- 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
ErrInvalidRequestand does not invoke generation.
- Add one assembled root-package workflow using an injected fake model
client. Assert that the same direct session appears in
PreparedRun,GenerateRequest.Prompt, andRunResult. This test owns the public adapter path; it must not repeat body/header assertions. - Extend the public
RunResultJSON round-trip test to cover a nonempty session, and add or extend omission coverage for an empty session. - Keep the existing prompt-renderer and model-client boundary tests passing after normalization is centralized.
Focused Validation
Run:
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: Complete.
Goal
Make current-state contracts accurately describe the implemented feature, record the compatibility impact for release handoff, and validate the complete repository.
Work
- Review all changed exported declarations in
types.goanddoc.go. Ensure GoDoc is the canonical owner of exact field types, pointer states, copying, normalization, privacy exposure, JSON, and error behavior. - Update
docs/consumers/pkg-promptkit.mdwith 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.
- Update
docs/formats.mdonly where the file format interacts with runtime precedence:- a direct request session bypasses the prompt
session_idtemplate; 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.
- a direct request session bypasses the prompt
- Update
docs/integrations/openai-compatible-chat.mdto 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. - Update
docs/internal/runner.mdto describe direct-session normalization and template bypass, effective hashing, tri-state reasoning precedence, and propagation into completed results. - Do not update
docs/internal/llm.mdor the internal component inventory. Shared normalization is an incidental mechanism and does not change the model client's documented flow or any package responsibility. - Do not change architecture, documentation, or testing policy unless implementation reveals a genuine policy change. This feature is expected to fit the current policies.
- 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. - In the implementation handoff, explicitly identify
ExecutionTargetOverride.ReasoningEffortchanging fromstringto*stringas a pre-v1minor-release source compatibility change. Do not edit release procedure or create a tag.
Full Validation
Run the complete sequence from docs/development.md:
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:
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_idremains 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.