Functional Contract Patterns
Quick reference for the repeated design patterns used in akm's refactor away from type-centric behavior.
1. Core Rule
Model behavior around core processes, not around one large asset type object.
Use:
typeas data- small contracts as extension seams
- fixed-stage pipelines as orchestrators
Do not use:
- one mega interface with many optional methods
- open-ended plugin graphs
- process logic hidden behind global type switches
2. Repeated Pattern
For each process:
- define a small context object
- define a narrow contributor interface
- register ordered contributors
- let each contributor decide
appliesTo(...) - keep one central orchestrator for stage order
This is the standard pattern to repeat across the codebase.
Contributor invariants:
- registration is static and in-process only
- ordering is deterministic
- composition semantics are declared once per seam
- execution is sequential by default
- contributors do not dispatch into other registries directly
- if a seam needs more machinery than this, it is probably too abstract
3. Pattern Catalog
3.1 Fixed-Stage Pipeline
Use when a process already has a clear top-down flow.
Examples:
- search
- improve
- indexing
Shape:
for (const stage of stages) {
for (const contributor of contributorsFor(stage)) {
if (!contributor.appliesTo(ctx)) continue;
contributor.run(ctx);
}
}
Why:
- preserves readability
- makes order explicit
- prevents plugin soup
3.2 Ordered Contributor Registry
Use when one stage needs several isolated policies.
Examples:
- ranking signals
- proposal validators
Shape:
interface Contributor<TContext> {
name: string;
order?: number;
appliesTo?(ctx: TContext): boolean;
}
Why:
- testable in isolation
- easy to add behavior without editing the orchestrator
3.3 Structural Contract
Use for stable physical concerns such as refs and paths.
Example:
PathResolver
Why:
- storage layout is real and durable
- should not be mixed with ranking, rendering, or validation
Rule:
- keep structural contracts small and boring
3.4 Classification As Facts
Classification should produce facts, not choose downstream behavior too early.
Good output:
typespecificity- annotations or reasons
Avoid:
- coupling classification directly to renderer names or search policy
3.5 Process-Local Validation
Validation belongs to the process that needs it.
Examples:
- proposal validation
- lint validation
- improve preflight validation
Avoid one shared validator that tries to know every process.
3.6 One Pipeline, Many Signals
Search must remain one pipeline.
Use contributors for:
- exact-match boosts
- type preference boosts
- belief-state boosts
- graph boosts
- utility boosts
Do not create separate per-type scoring pipelines.
3.7 Adapters Before Rewrites
First move existing behavior behind the new seam.
Only after parity is proven should behavior be reorganized.
Why:
- lower risk
- easier regression testing
- architectural progress without feature churn
3.8 Refactor-Only Safety Rule
When the work is explicitly architectural cleanup:
- no functionality changes
- all tests must pass with the same validation expectations
- the only allowed test edits are import-path or symbol-import updates caused by file moves
- do not rewrite assertions, fixtures, or expected outputs to accommodate the refactor
This keeps architectural cleanup separate from feature or bug-fix work.
3.8a Non-goals
This pattern guide is not permission to:
- build a framework
- add complexity for its own sake
- create registries everywhere by default
- replace simple code with abstract dispatch when no real hotspot exists
- introduce dynamic plugin systems or runtime discovery
- change runtime behavior under the banner of refactoring
The purpose of these patterns is to remove concrete duplication and switchboard logic while keeping the system simpler to reason about than it is today.
3.9 Execution Pipeline Contract
Every execution crosses the same plain functions:
resolveExecution()selects the engine (nearest layer,defaults.engine, then theopencode-sdkfallback), merges the engine's defaults with the caller's layers (nearest wins), expands amodels.jsonalias once, authorizes tools againstexecution.allowedTools, and returns theResolvedExecutionRequestV1, the engine'sRunnerSpecwith the request applied, and per-field provenance. Credentials stay symbolic.buildExecution()hands the request to the harness's own builder (derived fromHARNESS_REGISTRY), or builds chat messages for a direct LLM. A field the transport cannot carry becomes a secret-free notice; a tool policy it cannot enforce, or a denied tool selection, is a pre-dispatch error.buildExecutionFromWire()does the same from a journaled{ request, runner }without reading config, aliases, environment variables, or credentials — workflow resume.runExecution()reads credentials for the call, runs the agent CLI, the OpenCode SDK, or the chat transport (an exhaustive switch overllm | agent | sdk), and redacts every secret the child could have seen.
A prompt-free interactive native-agent launch (akm agent) resolves its
engine directly; it carries no request to build.
Diagnostic provenance is field metadata (field, layer, kind, via), not
resolved values or content. Lowering notices are secret-free records with a
fixed public vocabulary; arbitrary authored inference keys are represented by
wildcard field names rather than copied into diagnostics.
3.10 Session Log Harness Contract
Use one narrow raw-event ingestion seam for harness logs and session histories.
Rule:
- harness adapters discover files and parse raw events
- shared AKM logic performs normalization, fingerprinting, aggregation, and de-duplication
- new harnesses should not require edits to shared unions or duplicated aggregation logic
4. Implemented Contracts
[0.9.0 change, ruled Q-06/Q-16] This section previously listed twelve
"recommended" contracts; seven had no implementation anywhere in src/
(PathResolver, MatchContributor, MetadataContributor,
LintContributor, ImproveContributor, IndexPostProcessor,
AgentRunner) and were aspirational, not a contract any code followed.
Trimmed to the contributor contracts below. SearchHitEnricher and
ActionContributor were later folded into direct calls
(src/indexer/search/search-hit-enrichers.ts, buildLocalAction in
src/indexer/search/db-search.ts) — each had one implementation. The engine-owned
AgentRequestLowerer is the separate structural boundary documented in
§3.9, not a revival of the removed generic AgentRunner. See the drift
register
if a removed entry needs reviving; add it back only alongside a real
implementation.
4.1 RankingContributor
For search score adjustments and explanations.
4.2 ProposalValidator
For proposal acceptance checks.
4.3 SessionLogHarness
For raw session-log or history ingestion from external harnesses.
5. What Stays Centralized
- source/provider model
- CLI command contracts
- DB schema and persistence boundaries
- write-target resolution
- search stage order
- final score normalization and clamping
- proposal storage
If these become pluggable, the system becomes harder to reason about.
6. What Gets Delegated
- heuristics
- enrichment
- validation rules
- process-specific side-effects
- applicability logic
- search explanations
If these stay centralized, switchboards keep growing.
7. Smells To Avoid
- One large interface with many optional methods
- Registries keyed only by
type - Matching logic that also chooses presentation names
- Renderers that also own indexing and search policy
- Ad hoc per-command path lookup logic
- Open-ended capability graphs with unclear precedence
- Hidden special cases spread across callers instead of isolated behind one seam
- Architectural cleanup that changes behavior or rewrites tests to fit the refactor
8. Review Checklist
When adding a new seam, ask:
- Is this a stable process boundary?
- Can the contributor be tested in isolation?
- Does orchestration remain readable top-down?
- Is ordering explicit?
- Is
typeonly an input, not the behavior object? - Can existing behavior be adapted before rewriting it?
- Is this a refactor-only change with behavior preserved?
- Does onboarding a new external harness require only one narrow adapter?
If the answer is no to several of these, the abstraction is probably too large.