Files
promptkit/docs/roadmap/implementation.md

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.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:

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:

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.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:

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:

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:

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

  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:

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

  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:

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_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.