ADR-0020: LLM Drafts — seams propose Calls a Provider Command and Keeps a Draft Only When Its Acceptance Tests Pass
Status: Accepted
Date: 2026-11-03
Source: milestone M022/S04; backlog C4; decision D059; research note An LLM generator for the code templates cannot write sections 3.3 and 3.6; builds on ADR-0012 (the handlers package) and ADR-0019
Context
Templates cannot write a service task's body. C3 assembles the facts the body depends on with a context hash, C1 writes a typed stub, and C2 generates acceptance tests that check a body against the model. C4 asks an LLM for the body. The research note left open whether drafts should pin a provider and model, cache the response, or allow any provider, and its section 3.6 sets safety rules: model text only as quoted data, binding names but never values, never run in CI, nothing accepted without passing tests and review.
Decision
- The provider is a command.
seams propose TASKruns the command named by[propose] providerinseams.toml, or by--provider, split withshlexand run without a shell. The prompt goes to its stdin and the draft comes from its stdout; one fenced code block, if present, is stripped. No provider SDK, API key or network code is in the repository, so any LLM (or a person, or a fake in tests) can answer. A non-zero exit, empty output or a timeout is a provider failure. - The prompt is deterministic and versioned.
seams_cli.propose.build_promptfillsprompts/draft-v1.txt(the version is in the file name) from the C3 manifest, the C1 stub and the C2 tests. Every piece of model text, labels, comments and native BPMN and DMN fragments, sits in a fenced DATA block the prompt says is data, not instructions. Bindings appear by name only; no secret value is read. - Responses are cached, providers are not pinned (the model is now named; see the amendment below). A successful response is stored in
.seams/drafts/<sha256(context hash, template, provider)>.py(gitignored). The same context, template and provider re-run free and give the same draft; a changed fact changes the context hash and misses the cache. Failed calls are not cached. - A draft is kept only when it passes. The project is copied to a scratch directory, the draft is written at the handlers module's path there, and the module's acceptance tests run with pytest. Only on pass is the draft written to the handlers package (ADR-0012), and only there: a target outside the handlers directory is refused, and an existing module is replaced only with
--force. On failure nothing is written and each failing test is named. - Accept once, then own. An accepted draft is an ordinary hand-owned file.
seams generatenever touches it. Recording its context hash, template, provider and file hash inseams.lock, and reporting staleness, is C5. - Never in CI. CI runs the acceptance tests against committed handlers; it calls no provider. The tests of
seams proposeuse fake provider scripts.
Lock record and staleness
C5 (M022/S05) records each accepted draft in seams.lock under a top-level drafts list, sorted by task: the task IRI, the module path relative to the project root, the task's context hash, the template, the provider command and the hex sha256 of the written file. Only an accepted draft is recorded; a rejection leaves the lock alone, and seams generate carries the records forward. seams check recomputes each task's context hash with seams.context_for, as build_prompt does, and reports draft-stale when it differs or the task no longer resolves, so a draft is reviewed again whenever a fact it was written from changes. It reports draft-hand-edited when the file's sha256 differs from the record or the file is missing. Both are problems, not warnings; accepting a new draft (--force) refreshes the record.
Amendment: the model is pinned by name (M027/S05, D063, backlog X11)
Rule 3 said providers are not pinned. The provider command is still the pinned provider, but a command such as my-llm does not say which model answered. So a project may now name the model:
- By name only.
[propose] modelin seams.toml, or--llm-model(which wins), is a non-empty string; anything else is a ProjectError namingpropose.model.seamschecks nothing else about it and adds no SDK or network code: the provider gets it as the environment variableSEAMS_LLM_MODELand decides what to do with it. Without a name the variable is removed from the provider's environment, so an inherited value cannot change the answer without changing the key. The flag and the variable are not--modelandSEAMS_MODEL, because those already name the TriG model (D034). - In the cache key. The key is sha256(context hash, template, provider, model). The model is added only when one is named, so keys made before this amendment still hit, and asking for another model misses the cache.
- In the lock. An accepted draft's record gets
modelwhen one was named. Without one the key is absent, so existing locks keep their bytes. The command prints anllm-model <name>line afterprovider. - No SemPKM copilot connection.
seamsdoes not reuse SemPKM's copilot connection or its credentials; the provider command stays the only way to reach a model. - The same for suggest and draft-generator.
seams suggest(ADR-0028) andseams draft-generator(ADR-0029) take the same[propose] modeland--llm-model, pass the same variable, add the name to their own cache keys only when one is named, and print the samellm-model <name>line afterprovider. A stored suggestion records it asllm_modelin itssuggestion.json. Neither writes a lock record, so the lock field is propose's alone. - Still never in CI (rule 6): the tests use fake provider scripts that echo the model name they get.
Consequences
- The command prints
task,context,template,provider,cache hit|missanddraft <sha256>, thentests passedandwrote <module>(exit 0) or onefailed <test>line per failure (exit 1). A provider failure exits 1; a project error exits 2. - Changing the prompt text means a new template file (
draft-v2.txt), which also invalidates cached drafts. - Passing tests is the machine half of acceptance; reviewing the written file before committing it stays the person's half.