Deterministic op substrate: entity-oriented library, op registry, generated adapters
Status: open/accepted
Summary
Section titled “Summary”- The deterministic core becomes an entity-oriented TypeScript library (
plugin/lib/): operations live with the entity they act on, generalizing the precedent already set bymodel/entities/<type>/migrations/. - Every deterministic operation is declared once in a single registry (
defineOp). The registry — not the CLI — is the source of truth. - Adapters are generated from the registry: the
sdlc <path…>CLI is the shipped front-door; MCP and HTTP servers are planned projections of the same ops. One op, many doors. - Composition is layered. A deterministic composition (a composite op or a multi-step workflow definition) stays in the substrate. An LLM-driven composition (a skill) lives outside it. The line between them is the only thing that forces an op boundary.
- This retires S-0001-co-locate-first-promote-when-shared for capabilities (operations are placed with their entity by default, not promoted on a second caller) and amends S-0004-sdlc-cli-llm-head-deterministic-tail (the CLI is one adapter among several; the “head” is a spectrum, not always thin).
- Supersedes the design in
sdlc-cli, which prototyped the noun/verb CLI and the head/tail split that this ADR generalizes. - Migration was taxonomy-first, then a staged big-bang (executed under milestone M-0003); the cost concentrated in re-cutting a handful of composite scripts at their judgment seams, not in the clean ports.
Context
Section titled “Context”The plugin evolved from Claude-orchestrated prose toward deterministic scripts, under a rule (S-0001-co-locate-first-promote-when-shared) that co-located a script with its skill and promoted it to a shared bucket only when a second caller appeared. The Python→TypeScript migration (D-0006-typescript-substrate) changed the economics: deterministic code is now a typed, importable substrate, not packaged-Python-near-a-skill.
At decision time plugin/ carried four overlapping buckets — lib/ (importable logic), scripts/
(~18 mostly-monolithic entry points), validators/, and the nascent cli/
(backlog/task/lease nouns from sdlc-cli). The boundary leaked: scripts/_pyyaml.ts and
scripts/_schema_patterns.ts were libraries (3 importers + tests each) misfiled under an
_-prefix; validators/ straddled lib-and-script; new_task/new_milestone/new_backlog were
near-duplicates over a shared schema contract.
The forcing goal is P-0008-harness-agnostic-substrate: the deterministic core should be
consumable by any agent (Claude, another LLM over MCP, a plain shell, CI) — not only through a
CLI. Reframed, the existing scripts are operations on the project’s entities (task, milestone,
backlog, epic, standard, lease, …). They belong with the entity so they are reusable as typed
modules and exposable to other agents. model/entities/<type>/migrations/ already does exactly this
for one operation class.
Decision
Section titled “Decision”1. Layering — entity-oriented library
Section titled “1. Layering — entity-oriented library”The deterministic core is plugin/lib/, one file plus four directories:
plugin/lib/ registry.ts # defineOp() + the op registry — the core; adapters derive from it config/ # sdlc.yaml resolution + validation (config is not an entity) util/ # entity-AGNOSTIC infra: yaml, schema, git, gh, prose, fs, log, naming model/ # the entity DOMAIN ops/ # generic cross-entity ops, defined once off the entity schema: # audit · validate · migrate · check-identifiers (+ shared create/update cores) entities/<type>/ # schema.ts (Zod) · body-template.eta · migrations/ · ops/ (behavior co-located) services/ # cross-entity / non-entity capabilities (compose entity ops): # commit · config · dashboard · docs · gate · git · lease # · orchestrator · plugin · pr · project · quality · reportplugin/cli/ # the generated CLI adapter; MCP + HTTP are planned adapters off the same registryplugin/skills/ # authored LLM heads (kept separate from generated adapters)Placement rules:
util/holds entity-agnostic infrastructure. The algorithmpluralizelives here; any enumeration of entity types does not — that is model knowledge and is sourced from the entity registry, never frozen intoutil/.model/is the entity domain. Generic operations that work over any entity (read off the Zodschema.ts) live inmodel/ops/; entity-specific behavior co-locates undermodel/entities/<type>/ops/, next to the schema, templates, and existingmigrations/.services/holds capabilities that span entities or aren’t about an entity at all (project— cleanup, doctor, scans —quality,dashboard, …).leaseis a service for now. These compose entity ops; they are not entities.config/is first-class:sdlc.yamlis the project contract, not an entity and not generic infra.
2. The op registry (defineOp)
Section titled “2. The op registry (defineOp)”Every deterministic operation is declared once. The descriptor carries more than a schema:
defineOp({ path: ["task", "create"], // 2–3 kebab segments; a {noun, verb} shorthand normalizes to path input: TaskCreateInput, // zod → JSON Schema for MCP, validation everywhere output: TaskResult, // zod → typed result contract cli: { /* flag ergonomics zod can't express: short flags, */ /* repeatable --tag, positional vs option, body file/stdin */ }, handler: (input, ctx) => { /* (input, ctx) — no ambient cwd/env */ },})Three properties are load-bearing for “agnostic + multi-agent” and therefore live in the core, not the CLI:
- Typed I/O via Zod. Inputs and outputs are schemas. MCP tool schemas and HTTP contracts derive from them; P-0005-schema-over-prose.
- A canonical
OpErrortaxonomy. Handlers return/throw a typed error with acode. Each adapter maps the code to its native failure (CLI exit code, MCP error, HTTP status). The exit-code table lives in the registry;cli/_common.tsdelegates to it. - A context object
(input, ctx).ctxcarries{ projectRoot, dryRun, io, git, gh }. No handler reads ambient cwd/env — an MCP/HTTP caller has none. This DI seam is what makes ops testable and callable off-process, and lets--dry-run/--jsonbe applied once by the generator rather than re-coded per verb.
A Zod schema alone does not generate a usable CLI; the cli hints are required for flag
ergonomics.
2a. Registry composition — a runtime registry built by a module-walk over a structural API
Section titled “2a. Registry composition — a runtime registry built by a module-walk over a structural API”The registry is a runtime index, populated at process start — not a
build-time artifact. Its primary composition path is an automatic discovery
walk over the code modules that adhere to a structural API: each
entity-operation module under lib/model/entities/<type>/ops/ (and each
lib/services/ op) exports a descriptor of the agreed shape — path,
input/output schema, cli hints, handler. Discovery loads every
conforming module and registers its descriptor; a project-check asserts
structural conformance, so a malformed or missing export fails loudly rather
than silently dropping a command.
Explicit registration stays first-class. Not every command lives under a
walk root. Operations outside them — e.g. the generic cross-entity entities
ops under model/ops/ — register by calling defineOp(...) directly into the
same runtime registry (the CLI imports the model/ops barrel at bootstrap).
The discovery walk is the convenience path that spares the uniform
entity/service ops from hand-wiring; it does not replace manual
registration for commands that don’t fit a discovered module shape. Both paths
produce the same descriptor and the same generated adapters.
This reconciles D-0004-entity-definition-architecture with this ADR:
D0004’s per-entity ops/<op> module layout is the registry’s source,
and the runtime registry is the composed index over those modules.
defineOp(...) is the structural contract each module’s export satisfies
(called by the module, or applied by the walk); the adapters (CLI / MCP / HTTP)
are generated from the composed registry, so adding a conforming module makes
the op appear in every adapter with no hand-wiring. The registry replaced the
CLI’s hand-maintained noun table; the shrinking LEGACY_NOUNS list in
plugin/cli/sdlc.ts (today only backlog) carries the last un-migrated verb
tree.
3. Op classes — what the registry can and cannot represent
Section titled “3. Op classes — what the registry can and cannot represent”| Class | Example | Home |
|---|---|---|
| Deterministic request/response | task.create, entity.validate, lease.claim | registry → all adapters generated |
| Deterministic composite / workflow | cleanup (gather→classify→execute), planned multi-step workflow defs | registry (a composite op or workflow) — still adapter-exposable |
| Long-running | dashboard, lease heartbeat-loop | registry via defineService — lifecycle dispatch (start/stop/list) instead of request/response |
| LLM head | backlog capture (claude -p), procedural skills | outside the substrate — in plugin/cli/ heads and skills/, never in lib/ |
LLM heads are kept structurally out of lib/: the substrate stays Claude-agnostic. A head calls
ops; it is never an op.
4. Composition spectrum and the granularity rule
Section titled “4. Composition spectrum and the granularity rule”Composition — sequencing atoms — is owned by a layer above the op, of two kinds:
- Deterministic composition — a composite op, or a multi-step workflow definition (planned). Control flow, no LLM. Reproducible. Stays in the substrate and is adapter-exposable.
- LLM-driven composition (a head) — prose + judgment interleaved with op calls. A skill, or an external agent. Outside the substrate.
Granularity rule: an op boundary is required only where an LLM head must interpose judgment.
Internal deterministic sequencing is not exposed as separate ops. project cleanup stays one
composite op (or a workflow); it is split only at the seam where the skill pauses for sub-agent
investigation + human approval — and not a cut more. Coarse, deterministic composites are
legitimate; they are not “monoliths to break up.”
5. Adapters
Section titled “5. Adapters”Adapters are thin and generated from the registry: the CLI is the directly-invoked executable
(sdlc <path…>, generated at plugin/cli/ via registry_adapter.ts); MCP and HTTP are planned
long-running servers for other agents/clients, projected from the same command paths
(D-H7FS-op-substrate-surface). plugin/skills/ is separate — authored LLM heads that compose
ops in-session, never nesting claude -p.
- One substrate, many agents (P-0008-harness-agnostic-substrate). A single typed registry projected into CLI + MCP + HTTP means a capability is reachable identically from a shell, CI, a Claude session, or a non-Claude agent — with no per-adapter contract drift.
- Determinism stays the core (P-0001-prefer-deterministic-over-llm). The LLM is an optional
front-door (a head), never a dependency of the op. Heads are structurally excluded from
lib/. - Entity-local placement beats promote-on-shared. Operations are methods on entities; their home
is the entity module regardless of caller count. This removes the speculative-sharing judgment
call S-0001-co-locate-first-promote-when-shared imposed, and matches the working
migrations/precedent. - The registry pays for the big-bang. Declaring an op once and generating three adapters is the concrete payoff that justifies a coordinated migration over incremental drift.
Options considered
Section titled “Options considered”Lib-extraction scope — all vs entity-placed vs demand-driven
Section titled “Lib-extraction scope — all vs entity-placed vs demand-driven”Blanket extraction of every script to lib/ was rejected as the speculative sharing
S-0001-co-locate-first-promote-when-shared warns against. Pure demand-driven promotion was
rejected as too slow given the substrate goal. Chosen: entity-local placement — an op lives with
its entity by default, which is a coherent rule, not a guess about future reuse.
Adapters — single registry (generated) vs hand-written per surface
Section titled “Adapters — single registry (generated) vs hand-written per surface”Hand-writing CLI + MCP + HTTP restates the contract three times and drifts. Chosen: single registry → generated adapters. Higher up-front machinery, but it is the mechanism that makes multi-agent exposure real and keeps the surfaces in lock-step.
Cross-entity operations — services tier vs fold-into-nearest-entity vs hidden plumbing
Section titled “Cross-entity operations — services tier vs fold-into-nearest-entity vs hidden plumbing”Forcing count_inflight / cleanup / quality / dashboard into a single owning entity strains
the model. Chosen: a first-class services/ tier that composes entity ops, with util/ infra
beneath the entity layer.
Co-location vs CLI-only-as-API
Section titled “Co-location vs CLI-only-as-API”Exposing ops only through the CLI subprocess boundary loses types and pays process-spawn cost for
TS→TS calls. Chosen: lib/ is the primary typed surface; the CLI is one consumer of it. TS
imports the lib; skills/shell/CI/foreign-agents go through an adapter.
Consequences
Section titled “Consequences”- Easier: discoverability (one registry, one
--help); adding a capability (onedefineOp, three adapters free); testing (ops are pure(input, ctx)); reuse across skills, workflows, and external agents. - Harder / new obligations: the registry’s type system must cover real ops, including
cliflag hints and theOpErrortaxonomy; long-running and head ops need explicit handling outside plaindefineOp; the capability-vs-skill-scratch line must be mechanical (proposed test: acts on a domain entity or shared infra →lib/; skill-specific UX/orchestration glue → co-locate). - Binds future work: the planned multi-step workflow engine is a deterministic composition layer over registry ops — it consumes the substrate, it is not a parallel system.
- Standards churn: S-0001-co-locate-first-promote-when-shared is retired for capabilities
(kept only for genuinely skill-private helpers); S-0004-sdlc-cli-llm-head-deterministic-tail
is amended — CLI becomes one of several adapters and the head model becomes a spectrum (thin →
procedural).
sdlc-cliis superseded by this ADR.
Migration
Section titled “Migration”Executed under milestone M-0003 (T-NV49-op-path-substrate): taxonomy-first, then a staged big-bang (build-green → flip-callers → delete), not one commit.
- Taxonomy + registry contract locked first. The noun set, the generic verb lexicon, the
defineOpdescriptor, theOpErrorenum, and thectxshape — finalized as 2–3-segmentpath[]command paths by D-H7FS-op-substrate-surface. This was the de-risking lever. - Obvious infra promoted.
_pyyaml→util/yaml,_schema_patterns→util/schema_patterns;prs_fieldsplit (lease-footer →services/lease; generic PR helpers →util/gh);validate_sdlc_yaml→config/. - Behavior-locked before moving. Stdout markers + exit codes snapshotted as golden tests so each move was provably behavior-preserving against the live skill surface.
- Two populations, priced separately. Clean ports —
new_*, validators,audit_entities— collapsed 1:1 intomodel/ops/*generic ops. Composites —cleanup,inflight,dashboard,import-planning,quality— moved as composite ops/services, each split only at a genuine LLM-judgment seam (usually 0–1 cuts), per the granularity rule. - Callers codemodded.
bun run ${CLAUDE_PLUGIN_ROOT}scripts/X.ts→sdlc <path…>across skills (T-QL5F-skill-prose-codemod), with lint forbidding the old paths post-cutover. - Shims deleted. Per-script forwarding shims lived for one window; then
plugin/scripts/andplugin/validators/were deleted, with a guard against reintroduction (T-YBKU-shim-deletion-guard). The deterministic surface IS the registry.
Open questions
Section titled “Open questions”- Workflow-definition shape. How deterministic multi-step workflows are declared and how they reference ops (inline composite handlers vs a separate workflow artifact the engine interprets).
- Adapter generation depth. How much of the MCP/HTTP server is generated vs hand-assembled around the generated tool/route set.
- Decision-folder vs entity files. Whether
sdlc-cli(and other folder-form designs) should be normalized into decision entities or remain freeform design docs. - Skill-local script triage. Per-skill helpers (e.g.
task-ensure-ready/ensure_ready_mutate.ts) must be triaged in the same sweep: task-entity ops move tomodel/entities/task/ops/; generic utilities move toutil/.
The concrete command surface — 2–3-segment path[] command paths, umbrella nouns,
defineService, the --output text|json|jsonl projection contract, and the SERVICE_ERROR exit
tier — is specified by D-H7FS-op-substrate-surface.
This ADR fixes direction and shape; the migration sequence ran as milestone M-0003. It builds
on D-0006-typescript-substrate (language) and the entity model of
D-0004-entity-definition-architecture / D-0003-datamodel-categorization. The migrations/
directories under model/entities/<type>/ are the existing, tested precedent for co-locating
behavior with an entity — this ADR generalizes that pattern to all operations.