24 KiB
Profile Inheritance Implementation Plan
Purpose
Implement the profile-inheritance feature defined by the
profile inheritance roadmap. This plan is the
decision-complete execution sequence for a coding agent. Follow the repository
architecture, testing, and
documentation policies and the task-specific
reading guide in docs/development.md throughout the
work.
Target Outcome
A downstream consumer can define a YAML or in-memory profile whose
base_profile/BaseProfileID names another profile, including a Promptkit
built-in. Promptkit resolves the complete one-parent chain through the assembled
profile catalog, merges root-to-leaf profile settings, resolves the final
backend, and then applies request overrides exactly as it does for a standalone
profile.
The selected leaf ID remains the public profile identity. Ordinary operations resolve sources afresh, while prepared execution freezes the resolved target. Missing dependencies, cycles, excessive depth, and incomplete resolved profiles fail as profile-load errors without changing the distinct not-found contract for an absent directly selected profile.
Fixed Decisions
- YAML uses
base_profile; the public Go fields areProfile.BaseProfileIDandOpenAICompatibleProfileConfig.BaseProfileID. - One profile may name one base. Bases may form a linear chain of at most 32 profiles, including the selected leaf.
- The resolver performs every lookup through the complete raw composite repository. Each base therefore observes the ordinary in-memory, configured, fallback, and built-in precedence.
- The resolving repository is stateless and does not cache results across calls. It is applied once, outside the fully assembled raw overlay chain.
- Profile definitions with a base may omit
modeland both connection fields. Standalone definitions retain their current completeness requirements. - Root-to-leaf merge behavior follows existing profile presence semantics:
nonblank strings and nonzero numbers override; blank strings and zero numbers
inherit; a nonempty
extra_paramsmap replaces the complete inherited map; andAPIKeyRequiredis a sticky logical OR. idalways comes from the selected leaf.base_profileis cleared from the resolved profile before it reaches the use-case layer.- Backend and endpoint remain independent fields. A child backend replaces the inherited backend ID without implicitly clearing an inherited endpoint; a child endpoint replaces only the endpoint.
- This work introduces no profile-level clearing syntax, presence pointers, multiple inheritance, source-qualified references, public resolution graph, new public error sentinel, or provider-wire change.
- A missing directly selected profile continues to expose
ErrProfileNotFound. Once the leaf exists, a missing base is an invalid selected profile and must exposeErrProfileLoadwithout also matchingErrProfileNotFound. - Existing raw-source failures and context cancellation remain discoverable
through
errors.Iswhere they are currently preserved. The missing-base branch is the one deliberate exception: its diagnostic names the missing ID and chain but does not wrap the internal not-found sentinel. - No release notes, version tags, commits, or pushes are part of these stages.
Execution Rules
- Complete the stages in numerical order. Each stage is sized for one gpt-5.6-terra implementation prompt and must finish its focused verification before the next begins.
- At the start of every stage, inspect the working tree and preserve unrelated changes. The feature roadmap is intentional planning state and must remain.
- Treat intermediate stages as an unreleased partial implementation. Do not describe the feature in durable current-state prose until Stage 4.
- Keep inheritance logic in
internal/profile. Do not add recursive profile lookup, chain merging, or source-precedence logic tointernal/usecaseor the root public facade. - Use real in-memory repositories and synthetic
fs.FSsources where practical. Tests must remain deterministic, offline, parallel-safe, and independent of provider credentials. - Update existing test owners when their validation contract changes; do not retain contradictory expectations or duplicate the full inheritance matrix at higher layers.
- Use
apply_patchfor edits, format every changed Go file, and review the complete stage diff before proceeding.
Stage 1: Add Profile Definition Surfaces And Separate Local Validation
Objective
Represent a base-profile reference in every supported profile input while preserving strict decoding, standalone-profile compatibility, and eager local validation. At the end of this stage repositories can publish locally valid derived definitions, but the engine does not yet resolve them.
Implementation
- Add
BaseProfileID stringtointernal/domain.ExecutionProfilewith the YAML tagbase_profile. Keep it out of execution targets and provider request types. - Add
BaseProfileID stringto the publicProfileimmediately afterID. Its GoDoc must state that:WithProfilestrims the value;- a nonblank value permits required target fields to be inherited;
- reference existence and resolved completeness are checked only when the profile is selected or inspected; and
- blank means the profile remains standalone.
- Add the matching field to
OpenAICompatibleProfileConfig, document that it maps toProfile.BaseProfileID, and copy it inOpenAICompatibleProfile. Preserve the constructor's shallow-copy and deferred deep-validation behavior forExtraParams. - Map and trim
Profile.BaseProfileIDintoDomainProfile. Ensure the memory repository continues to return caller-independentExtraParamsand does not expose shared mutable profile state. - Centralize the duplicated file-profile and in-memory-profile normalization
rules in
internal/profilerather than adding a third validation path:- provide an internal-package exported helper named
NormalizeAndValidateDefinitionso the root facade can validate a converted in-memorydomain.ExecutionProfilewithout owning profile parsing rules; - trim
ID,BaseProfileID,BackendID, andEndpointin that helper; - require a nonblank
IDand validate a supplied endpoint and all execution setting bounds for every definition; - when
BaseProfileIDis blank, retain the existing requirements for a nonblank model and at least one nonblank backend or endpoint; - when
BaseProfileIDis nonblank, allow model, backend, and endpoint to be omitted so they can be inherited; and - do not add unrelated normalization, environment-name policy, clearing behavior, or backend registry lookup.
- provide an internal-package exported helper named
- Replace
normalizeAndValidateProfileininternal/profile/filesystem_repository.goandnormalizeAndValidatePublicProfileinprofiles.gowith the shared helper. Preserve each caller's current public/internal error wrapping, selected-file path diagnostic, raw-key prohibition, strict single-document rule, and duplicate-ID behavior. Remove imports and helpers made obsolete by the centralization. - Keep the raw repositories raw: file,
fs.FS, memory, overlay, and built-in repositories returnBaseProfileIDbut do not recursively load it in this stage.
Tests
Update the narrow existing owners:
- In
internal/profile/repository_test.go, extend the filesystem/fs.FSsource-parity coverage to prove that:- strict YAML accepts
base_profile, trims it, and publishes it; - an alias containing only
idandbase_profileis locally valid; - a derived profile still rejects an invalid supplied endpoint, negative or
out-of-range setting, malformed
extra_params, rawapi_key, duplicate ID, and extra YAML document through their existing identities; and - a standalone profile missing connection fields or model remains invalid. Modify existing missing-field expectations rather than duplicating their entire matrices.
- strict YAML accepts
- In root package tests, extend the existing
WithProfilesvalidation owner to show that an incomplete profile with a nonblankBaseProfileIDis accepted byNewEngine, while the equivalent standalone profile retains its currentErrInvalidConfigresult. Do not attempt to select the derived profile until the resolver is implemented. - Extend
TestOpenAICompatibleProfileMapsEveryFieldsoBaseProfileIDis included in the constructor's exact field mapping. - Preserve existing tests for defensive copying, duplicates, standalone profiles, overlay precedence, built-ins, and strict decoding.
Verification
gofmt -w internal/domain/domain.go internal/profile/*.go profiles.go types.go
go test ./internal/profile
go test ./internal/profile/builtin
go test ./... -run 'Profile|OpenAICompatibleProfile'
go test ./...
git diff --check
Stage 1 is complete when all supported definition surfaces carry the normalized base ID, locally valid aliases can be stored, standalone validation is unchanged, and no repository performs inheritance resolution yet.
Status: Complete.
Stage 2: Implement The Stateless Resolving Repository
Objective
Add the internal component that resolves one complete profile chain, merges it deterministically, enforces safety bounds, and returns one ordinary resolved profile to its caller.
Implementation
- Add
internal/profile/resolving_repository.gowith:- an unexported
resolvingRepositorycontaining one underlying rawRepository; NewResolvingRepository(source Repository) Repository; and- an unexported constant setting the maximum chain length to 32, counting the selected leaf.
- an unexported
- Make the wrapper safe for concurrent calls by keeping the source immutable
and all chain, visited-ID, and merge state local to
GetProfile. Do not add a cache, mutex, process-global registry, or source snapshot. - Resolve one request using this exact flow:
- reject a nil underlying repository or blank requested ID as
ErrInvalidProfile; - trim and look up the directly requested ID through the underlying raw repository;
- if that first lookup fails, return its error unchanged so direct
ErrProfileNotFoundbehavior remains intact; - reject a nil profile returned without an error as
ErrInvalidProfile; - follow each normalized nonblank
BaseProfileIDthrough the same underlying repository, recording IDs and profiles from leaf toward root; - check
ctx.Err()between lookups; - detect a repeated ID before loading it again and return an
ErrInvalidProfilecycle diagnostic containing thea -> b -> achain; - reject a reference that would make the chain exceed 32 profiles with an
ErrInvalidProfilediagnostic containing the traversed chain and next ID; - when a base lookup returns
ErrProfileNotFound, return a newErrInvalidProfileerror naming the missing base and chain without wrappingErrProfileNotFound; and - for any other base error, add
ErrInvalidProfile, base ID, and chain context while preserving the source error and cancellation identity with wrapping.
- reject a nil underlying repository or blank requested ID as
- Merge the collected definitions from root to leaf into a new value. Use one
focused helper with these rules:
- retain the selected leaf
IDand clear finalBaseProfileID; - replace
BackendID,Endpoint,Model,ServiceTier,ReasoningEffort, andAPIKeyEnvonly for a nonblank child value; - replace
Temperature,MaxTokens,TopP, andTimeoutSecondsonly for a nonzero child value; - set
APIKeyRequiredto the logical OR of every definition in the chain; - replace
ExtraParamsonly when the child map is nonempty; otherwise inherit the current map; and - defensively deep-copy every retained or replacement
ExtraParamsmap by using the existing boundedinternal/jsonvalueowner. Never mutate a repository-returned profile or map. Treat an unexpected copy failure asErrInvalidProfile, preserve the copy error, and include the selected chain in the diagnostic.
- retain the selected leaf
- Add an unexported final validator in
internal/profilethat requires a nonblank model and at least one backend or endpoint on the merged profile, and reuses the common endpoint and execution-setting validation. Wrap final failures withErrInvalidProfileand the selected chain. The resolver must not perform backend registry membership lookup; that remains ininternal/usecaseafter profile resolution. - Return a caller-owned resolved profile on every successful call. A standalone profile passes through the same final validation and copy path, ensuring the wrapper does not create separate standalone and inherited execution contracts.
Tests
Create internal/profile/resolving_repository_test.go and use small synthetic
repositories plus the real overlay repository where it materially protects
composition:
- Prove a three-level root/middle/leaf chain merges representative values for
every rule: inherited values, string and numeric replacements, independent
backend/endpoint behavior, sticky
APIKeyRequired, complete-mapExtraParamsreplacement, leaf identity, and cleared resolution metadata. Use one well-structured table or fixture rather than one test per field. - Prove an empty child
ExtraParamsinherits the base map, a nonempty child map replaces rather than key-merges it, and mutating the returned nested values cannot mutate the raw repository or a later result. - Prove the same composite repository resolves a base from a lower-precedence source and honors a higher-precedence shadow of that base ID. Do not add source-specific resolution to the wrapper.
- Cover the safety/error matrix through the resolving repository boundary:
- directly selected missing ID preserves
ErrProfileNotFound; - missing base matches
ErrInvalidProfileand does not matchErrProfileNotFound; - direct and indirect cycles are rejected with useful chains;
- exactly 32 profiles are accepted and a 33rd is rejected;
- nil repository results and incomplete final profiles are invalid;
- non-not-found base errors retain their original identity; and
- cancellation before or during traversal remains discoverable and stops later lookups.
- directly selected missing ID preserves
- Prove freshness and concurrency without timing sleeps: change a controlled raw repository between sequential calls and observe the new base value, then issue concurrent independent resolutions and verify results do not share mutable maps. Use synchronization owned by the fake; do not race on a plain map.
- Preserve the existing raw repository and overlay tests unchanged except where Stage 1 intentionally changed local completeness rules.
Verification
gofmt -w internal/profile/*.go
go test ./internal/profile -run 'Resolving|Inheritance|Profile'
go test -race ./internal/profile
go test ./...
git diff --check
Stage 2 is complete when a standalone internal wrapper can safely and freshly resolve every accepted chain into one complete, copied profile with the fixed merge and error contracts.
Status: Complete.
Stage 3: Assemble Inheritance And Prove Public Workflows
Objective
Place the resolver at the correct engine boundary and prove that YAML, in-memory, built-in, inspection, ordinary preparation, runtime overrides, and prepared execution compose as intended through the public facade.
Implementation
- Update
newProfileRepositoryinengine.goto:- assemble the raw repository in its current precedence order—built-ins,
fallback, configured source or
Config.ProfileDir, then in-memory; - wrap that complete raw repository exactly once with
profile.NewResolvingRepository; and - return the wrapper to
usecase.NewRunner. Do not wrap individual sources, because that would prevent a higher-level child from referencing a lower-level base and would change shadowing rules.
- assemble the raw repository in its current precedence order—built-ins,
fallback, configured source or
- Keep
internal/usecase.resolveProfileSelectionstructurally unchanged. It should continue to request one profile, preserve the requested normalized ID as selection identity, resolve the final backend once, and then apply the ordinary backend/profile/request precedence. Do not passBaseProfileIDintoExecutionTarget,PreparedRun,RunResult, injectedLLMClientrequests, stable JSON, hashes, or provider payloads. - Confirm
APIKeyRequiredbehavior after inheritance without introducing a special credential path: a true value in any ancestor reaches the existing profile-to-target merge, clears inherited profile/backendAPIKeyEnv, and still permits a direct request key or explicit request environment override. - Preserve the current error mapper. The resolver's use of
ErrInvalidProfilemust make a missing/invalid base cross the use-case and root boundaries asErrProfileLoad; no new root sentinel or special-case string matching is permitted. - Do not modify built-in profile assets merely to demonstrate inheritance. Built-ins serve as valid bases without changing their own definitions.
Tests
Use the smallest public tests that protect assembled behavior without repeating the resolver's matrix:
- Add one external-package contract workflow that defines
weather-lightas an in-memory alias/refinement of the built-indeepseek-4-flash. Compare its inspected target with an inspection of the base rather than duplicating every built-in literal. Assert:- the child inspection reports
weather-light; - backend and model are inherited;
- explicit child reasoning and timeout values win;
Preparereports the child asSelectedProfileID; and- a per-request reasoning or timeout override still wins over the child.
- the child inspection reports
- Exercise YAML through
WithProfileFSwith a minimal alias of a built-in and prove it resolves through inspection or preparation. Parser tests own YAML edge cases, so do not repeat them here. - Add a focused public error-identity table:
- a directly selected absent profile still matches
ErrProfileNotFoundand notErrProfileLoad; - an existing child with an absent base matches
ErrProfileLoadand notErrProfileNotFound; and - one representative cycle matches
ErrProfileLoadand includes its IDs. Reuse or extend the existing profile-inspection public error owner where that keeps the suite lean.
- a directly selected absent profile still matches
- Prove fresh ordinary resolution and frozen prepared execution with one
controlled mutable
fs.FSworkflow:- prepare an execution while its base supplies model A;
- change the base source to model B after preparation;
- run the prepared handle through an injected deterministic client and observe model A; and
- perform a new inspection or ordinary preparation and observe model B. Keep all mutation sequential or synchronized and make no provider call.
- Add only a narrow credential assertion if existing use-case tests do not
already prove the final inherited
APIKeyRequiredtarget behavior. The resolver test owns sticky merging; current credential tests own availability, precedence, redaction, and prepared rechecking. - Run all existing source-precedence, fallback, malformed-profile, built-in, public JSON, runtime-override, credential, and prepared-execution tests. Fix production behavior rather than weakening those contracts.
Verification
gofmt -w engine.go engine_test.go public_contract_test.go prepared_execution_contract_test.go profiles.go types.go
go test ./... -run 'Profile|Inheritance|PreparedExecution|RuntimeOverride'
go test ./internal/profile
go test ./internal/usecase
go test -race ./...
go vet ./...
go build ./...
git diff --check
Stage 3 is complete when the engine resolves aliases and refinements across all supported sources, public identities and errors match the roadmap, ordinary operations remain fresh, and prepared execution remains frozen.
Status: Complete.
Stage 4: Publish Canonical Documentation And Complete Validation
Objective
Align every canonical contract owner with the implemented feature, remove temporary ambiguity, and validate the release candidate comprehensively.
Documentation
- Finalize exact GoDoc for
Profile.BaseProfileIDandOpenAICompatibleProfileConfig.BaseProfileID, plus nearbyProfile,OpenAICompatibleProfile, andWithProfilestext whose standalone completeness or validation-timing statements changed. GoDoc owns the public fields and construction contract; do not reproduce internal traversal. - Update
docs/formats.mdas the exact YAML contract owner:- add
base_profileto the field table and example; - distinguish locally complete standalone profiles from derived aliases;
- document root-to-leaf override rules, complete-map
extra_paramsreplacement, stickyAPIKeyRequired, independent backend/endpoint behavior, the 32-profile limit, and lack of clearing syntax; - document cross-source lookup precedence and shadowing; and
- distinguish a missing selected profile from an invalid selected chain.
- add
- Update
docs/internal/sources.mdto describe the raw composite catalog, the one outer resolving repository, fresh per-operation traversal, cycle/depth protection, final validation, and prepared-state freezing. Link to the format reference for exact consumer syntax and merge rules. - Update the
internal/profileentry indocs/internal/overview.mdbecause the concrete package now resolves inherited definitions in addition to loading, validating, and overlaying raw sources. Do not change architecture policy because package ownership and dependency direction are unchanged. - Add one concise task-oriented example to
docs/consumers/pkg-promptkit.mdshowing an application-owned alias of a built-in and a selected child override. Link to the format reference and public GoDoc for details rather than duplicating the complete contract. - Do not update the README, examples, release documents, release procedure, future/deferred catalogs, or built-in catalog unless the implementation has made a concrete current-state statement there inaccurate. Do not copy the roadmap into durable documentation.
Final Validation
Run the complete maintainer workflow from
docs/development.md#maintainer-validation:
go test ./...
go test -race ./...
go vet ./...
go build ./...
go run ./examples/go-library/prepare
go run ./examples/go-library/run
Then run the documented tracked-Go formatting check, repository-relative
Markdown link checker, workspace/vendor/module-replacement checks, unstaged and
staged whitespace checks, ignored-file check, credential scan, and
git diff --check. Inspect the complete diff and confirm:
- only
base_profile/BaseProfileIDwere added to consumer configuration; - raw repositories remain source-local and the resolver wraps the full catalog once;
- chain resolution is bounded, cycle-safe, fresh, and concurrency-safe;
- merge behavior covers every profile field without mutating source values;
- direct missing and dependent missing profiles preserve distinct public error identities;
- selected IDs, backend resolution, runtime overrides, credentials, and prepared freezing retain their stated ownership;
- resolution metadata never reaches stable execution JSON, hashes, injected generation targets, or provider payloads;
- tests remain lean, deterministic, offline, and owned at the narrowest stable boundary; and
- durable documentation describes only implemented behavior with one canonical owner per exact contract.
Stage 4 is complete when all focused and repository-wide checks pass and the implementation, GoDoc, format reference, internal documentation, and consumer guidance agree.
Status: Complete.
Open Questions
None. Naming, supported sources, lookup precedence, merge behavior, connection semantics, credential inheritance, validation timing, chain bounds, error identity, freshness, prepared freezing, package ownership, test boundaries, and documentation ownership are fixed by the feature roadmap and this plan.