Skip to content

Deterministic op substrate: entity-oriented library, op registry, generated adapters

Status: open/accepted

  • The deterministic core becomes an entity-oriented TypeScript library (plugin/lib/): operations live with the entity they act on, generalizing the precedent already set by model/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.

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.

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 · report
plugin/cli/ # the generated CLI adapter; MCP + HTTP are planned adapters off the same registry
plugin/skills/ # authored LLM heads (kept separate from generated adapters)

Placement rules:

  • util/ holds entity-agnostic infrastructure. The algorithm pluralize lives here; any enumeration of entity types does not — that is model knowledge and is sourced from the entity registry, never frozen into util/.
  • model/ is the entity domain. Generic operations that work over any entity (read off the Zod schema.ts) live in model/ops/; entity-specific behavior co-locates under model/entities/<type>/ops/, next to the schema, templates, and existing migrations/.
  • services/ holds capabilities that span entities or aren’t about an entity at all (project — cleanup, doctor, scans — quality, dashboard, …). lease is a service for now. These compose entity ops; they are not entities.
  • config/ is first-class: sdlc.yaml is the project contract, not an entity and not generic infra.

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 OpError taxonomy. Handlers return/throw a typed error with a code. 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.ts delegates to it.
  • A context object (input, ctx). ctx carries { 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/--json be 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”
ClassExampleHome
Deterministic request/responsetask.create, entity.validate, lease.claimregistry → all adapters generated
Deterministic composite / workflowcleanup (gather→classify→execute), planned multi-step workflow defsregistry (a composite op or workflow) — still adapter-exposable
Long-runningdashboard, lease heartbeat-loopregistry via defineService — lifecycle dispatch (start/stop/list) instead of request/response
LLM headbacklog capture (claude -p), procedural skillsoutside 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.”

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.

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.

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.

  • Easier: discoverability (one registry, one --help); adding a capability (one defineOp, 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 cli flag hints and the OpError taxonomy; long-running and head ops need explicit handling outside plain defineOp; 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-cli is superseded by this ADR.

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.

  1. Taxonomy + registry contract locked first. The noun set, the generic verb lexicon, the defineOp descriptor, the OpError enum, and the ctx shape — finalized as 2–3-segment path[] command paths by D-H7FS-op-substrate-surface. This was the de-risking lever.
  2. Obvious infra promoted. _pyyamlutil/yaml, _schema_patternsutil/schema_patterns; prs_field split (lease-footer → services/lease; generic PR helpers → util/gh); validate_sdlc_yamlconfig/.
  3. Behavior-locked before moving. Stdout markers + exit codes snapshotted as golden tests so each move was provably behavior-preserving against the live skill surface.
  4. Two populations, priced separately. Clean portsnew_*, validators, audit_entities — collapsed 1:1 into model/ops/* generic ops. Compositescleanup, 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.
  5. Callers codemodded. bun run ${CLAUDE_PLUGIN_ROOT}scripts/X.tssdlc <path…> across skills (T-QL5F-skill-prose-codemod), with lint forbidding the old paths post-cutover.
  6. Shims deleted. Per-script forwarding shims lived for one window; then plugin/scripts/ and plugin/validators/ were deleted, with a guard against reintroduction (T-YBKU-shim-deletion-guard). The deterministic surface IS the registry.
  • 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 to model/entities/task/ops/; generic utilities move to util/.

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.


← Back to Decisions