Spec-driven development¶
Intent lives in a spec. The coding agent builds; tuvl referees. Every tuvl step below is deterministic and spends no tokens — only the agents you build (llm, loop, decision models) call models at run time.
spec (.md) ──spec check──▶ checked spec ──coding agent (skills)──▶ task list + workflows, models, code, tests
▲ │
│ spec diff / P005: stale tasks ▼
│ codegen: schemas, stubs, prompt placeholders, tests
│ │
└── derived task status ◀── spec verify · validate · test (honest) ◀─────┘
│
lock · ship
1. Write the spec¶
A spec is Markdown. Prose sections (# Intent, # Policies, anything else) are for people and the
coding agent. Three sections are structured and checked: # Data, # Flows and the
tuvl-example blocks under # Acceptance. tuvl spec new refund-request writes a template that
passes tuvl spec check.
---
name: refund-request
owner: payments-team
---
# Intent
Customers request refunds. Small ones are paid automatically; large ones need a finance lead.
# Data
- **Order** (existing) — `id` (uuid), `customer_email` (string; secure), `total` (numeric)
- **Refund** (new) — `order_id` (uuid → Order; required), `amount` (numeric; required),
`status` (enum: requested | approved | rejected | paid; required; default requested)
# Flows
## Flow: refund_request
Trigger: POST /refunds · signed-in
Input: `order_id` (uuid), `amount` (float), `reason` (str)
Ends: default 201 · unknown_order 404 · rejected 200 · failed 500
1. `load_order` — tool db read Order · in `order_id` · out `order` (Order)
- not_found → END.unknown_order
2. `check_amount` — decide rules · in `amount` · out `lane` (enum[auto, review])
- `amount <= 100` → auto
- otherwise → review
- auto → `record_refund`
- review → `finance_review`
3. `finance_review` — human group finance_lead · expires 24h · in `order`, `amount` · out `approved` (bool)
- true → `record_refund`
- false, expired → END.rejected
4. `record_refund` — tool db create Refund · in `order_id`, `amount`, `reason` · out `refund` (Refund)
- conflict → END.failed
# Acceptance
```tuvl-example
name: a small refund is recorded
given: { Order: [{ id: "6f1c0a52-2a8e-4d0b-9a77-0b9d7c2f1e10", total: 120 }] }
as: { user: u-customer }
input: { order_id: "6f1c0a52-2a8e-4d0b-9a77-0b9d7c2f1e10", amount: 40, reason: "Arrived broken." }
expect: { end: default }
```
Grammar¶
| Element | Form |
|---|---|
| Model | - **Name** (new\|existing) — fields; a bullet may wrap onto indented lines |
| Field | `name` (type; flags) — types string text integer numeric boolean uuid date timestamptz jsonb, enum: a \| b, uuid → Model; flags required unique secure, default <v> |
| Flow | ## Flow: <workflow_name> under # Flows |
| Trigger | Trigger: POST /path · signed-in — or group <g> / public; schedule <cron>; tool (only called as a loop's workflow tool) |
| Input / Ends | Input: `f` (type), … · Ends: default 201 · <name> <status> · failed 500 (default and failed are required) |
| Step | N. `id` — <engine …> · in `a`, `b`? · out `x` (type) · budget 800 tok, 20s, retry 2 · effect write · expires 24h · guardrails `g@1` — a trailing `b`? marks an optional input; long steps continue on an indented · … line |
| Engines | code · tool db read\|list\|create\|update\|upsert\|delete <Model> · tool http <METHOD> <name> · tool mcp <server> · llm <model> · loop <model> (with tools: `t1`, workflow `w` (approval <group> when `expr`; max 1 call)) · decide rules · decide model <ref> ≥ 0.8 · decide rules + model <ref> ≥ 0.7 · human group <g> |
| Route | - signal[, signal] → `step` or → END / → END.<end>; (outf(type)) names outputs a reserved signal carries |
| Rule | - `TEL condition` → value, last - otherwise → value |
| Waiver | - <signal>: waived — <reason> under a step; - tests: waived — <reason> for a step no offline test can run (e.g. it needs pgvector) — reported by spec verify, counted by task status; - <end>: no example — <reason> (indented) under Ends: |
| Off-spine | T1. steps — a loop's tools (tool ofloop), compensators (a step's · compensate undo_x ` part: runs on a rollback end to undo it) and alternate paths, which routedefault` explicitly |
Implicit: default goes to the next numbered step (or END), and error to END.failed.
2. Check it¶
tuvl spec check [SPEC] [--strict] [--json] (MCP spec_check, GET /dev/specs/check) — zero
tokens, deterministic. It reports every finding with its line; blocking findings fail.
| Rule | Finding | |
|---|---|---|
| S001 | ✗ | A line in # Data / # Flows doesn't parse — never skipped silently |
| S002 | ✗ | A route targets an end not in Ends:, an unknown step or a loop tool |
| S003 | ✗ | A listed end is unreachable from the trigger |
| S004 | ✗ | An engine-implied signal is neither routed nor waived (db read/delete → not_found, create/update/upsert → conflict, llm → parse_error timeout budget_exceeded, loop → max_iterations budget_exceeded timeout aborted, decide model → low_confidence, human … expires → expired, guardrails → guardrail_violation, each enum value of a decide/human output) — or a routed signal the engine never emits |
| S005 | ✗ | A model step lacks its safeguards: decide model without a ref, threshold or time budget; decide with neither rules nor a model, or rules without otherwise and no model; llm/loop without a model or a token + time budget |
| S006 | ✗ | A step reads a field not produced on every path to it (mark it optional with ? if that's intended) |
| S007 | ✗ | A model or field isn't declared in # Data, an (existing) model or field isn't in models/, an unknown column type, or an enum default outside its values |
| S008 | ✗ | A secure field (or a model holding one) is an input of a model-backed step |
| S009 | ✗ | A step output reuses a name the trigger input or an earlier step on its path already holds |
| S010 | ✗ | An HTTP trigger declares no access (signed-in, public or group <g>) |
| S011 | ! | An end has no tuvl-example and no no example waiver |
| S012 | ✗ | An example names an unknown flow, step, end or model, or a mock sets outputs the step doesn't declare for that signal |
| S013 | ! | A human step has no group or no expiry |
| S014 | ! | A free-text acceptance bullet — listed, not checked |
| S015 | ✗ | A decide rule doesn't compile or type-check against the step's inputs, reads a non-input, or yields a value outside the output enum |
| S016 | ✗ | A loop tool isn't a T step of the flow (or a ## Flow:/project workflow), an approval condition doesn't compile, or an off-spine step is never used |
| T001 | ! | An example mocks deterministic logic (code, tool db, decide rules) — seed given: and as: instead |
Prose sections are listed as unchecked. A waived signal or end is reported (·) so a reviewer sees it.
3. Verify the implementation¶
tuvl spec verify [SPEC] [--strict] [--no-tests] [--json] (MCP spec_verify,
GET /dev/specs/verify) — zero tokens. It compares each ## Flow: with its workflow, each # Data
model with its ModelDefinition, and runs the tests (offline) to see which steps they exercise.
Errors are contradictions; warnings are unfinished work (a missing workflow, a pending
agent, an untested step), which --strict — the CI gate — also fails.
| Rule | Checks |
|---|---|
| R001 | The flow has a workflow of that name; trigger method, path, schedule, access (public ↔ trigger.http.public, group <g> ↔ metadata.required_scope) and input names and types match |
| R002 | Each step is an agent with the same id and engine family (tool db read Order ↔ use: db, op: read, model: Order; decide rules + model ↔ both); pending is reported as not implemented; agents the spec doesn't name are reported |
| R003 | Inputs and outputs match the step's contract |
| R004 | Every spec route (including the implicit default and waivers) is in the workflow with the same target; a workflow route to an end the spec doesn't have is reported |
| R005 | Ends and their HTTP statuses match the workflow's outputs |
| R006 | Data fields exist in the ModelDefinition with a compatible type, the same enum values and the same required/unique/secure/default |
| R007 | Model steps use the spec's model, threshold and budget (tokens, time, iterations, tool calls); decide rules match in order; loop tools, approvals and max_calls match |
| R008 | Human steps use the spec's group and expiry |
| R009 | Each tuvl-example has its generated test, and it passed (stubbed doesn't count) |
| R010 | Each step is run by a passing test; deterministic steps must run for real (mocked-only is reported), model, external and human steps count through their mocks |
specs/triage-ticket.md
triage_ticket
classify llm default :23 ✓ mocked in 3
prioritise code :25 ✓ 3 passed
review human group support_leads :28 ✓ mocked in 1
store tool db create Ticket :32 ✓ 1 passed
0 mismatch(es) · steps implemented 4/4 · exercised by real tests 2/4
4. The task list¶
The coding agent writes the task list (skill tuvl-plan) from the checked spec — tuvl doesn't
plan with a model. tuvl checks it as part of tuvl spec check and derives each task's status.
kind: TaskPlan
version: tuvl/v2
metadata: { name: refund-request, spec: specs/refund-request.md, spec_hash: 9f2c… }
spec:
tasks:
- id: T3
title: Route refunds by amount (decide rules)
type: agent # model | workflow | agent | artifact | integration | test | policy | manual
spec_ref: { anchor: flow-refund-request, hash: 41ad… }
target: { workflow: refund_request, agent: check_amount }
depends_on: [T2]
- id: T7
title: "Example: a small refund is paid automatically"
type: test
spec_ref: { anchor: acceptance, hash: 7be0… }
target: { workflow: refund_request, example: a small refund is paid automatically }
- id: T9
title: Payments API credentials
type: manual # only manual tasks set a status
target: { workflow: refund_request, agent: pay_refund }
status: todo
One task per new model, per step, per example, and a manual task per external integration
(tool http/mcp); dependencies follow the flow. No estimates. After a spec change the agent adds
tasks for new steps and marks removed ones obsolete: true (never deletes them).
| Rule | Finding | |
|---|---|---|
| P001 | ✗ | The file isn't a valid TaskPlan, or a non-manual task has no target |
| P002 | ✗ | A task targets something the spec doesn't declare (model, flow, step or example) |
| P003 | ! | Coverage: a new model, step, example or external integration has no task (or there is no task list yet) |
| P004 | ✗ | A task carries an estimate (effort) or a hand-set status (only manual tasks may) |
| P005 | ! | A task's spec section changed since it was written — the agent should update it |
Derived status¶
| Status | Rule |
|---|---|
todo |
Target agent is pending, the target file is absent, or an example has no generated test |
in_progress |
Target exists but validate reports errors on it, or it contradicts the spec (spec verify) |
implemented |
Target validates cleanly and matches the spec |
tested |
A passing Test covers it (stubbed results don't count), and its acceptance examples pass |
done |
Nothing blocks ship: lock current, no warnings on the target |
blocked (overlay) |
The spec has blocking spec check findings, or a depends_on task isn't done |
stale (overlay) |
The spec section changed since the task was written |
Only type: manual tasks take a manual status. Models and artifacts skip tested. A task tuvl can't
measure is untracked.
tuvl spec status --json # derived statuses (runs the tests offline; --no-tests caps at implemented)
tuvl spec status --strict # exit 1 unless every live task is done and none is blocked or stale
tuvl spec diff # stale tasks and spec sections no task covers
5. Codegen¶
tuvl codegen [<workflow>] [--check] — deterministic, no model:
| Output | Rule |
|---|---|
agents/_generated/schemas.py |
<Id>In/<Id>Out models per agent (per-signal variants, <Id>Signal), <Workflow>Input, <Workflow>End. Never hand-edited |
agents/<run>.py |
A stub per code agent when missing; otherwise a libcst merge that regenerates decorator, signature and docstring and keeps parameter names and the body |
artifacts/<name>.md |
A prompt placeholder for a missing artifact:// referenced by an llm/loop agent |
decide agents |
A block with neither rules nor model gets one never-matching rule per enum value |
tests/<workflow>/<example>.yaml |
Test documents from the spec's tuvl-example blocks; hand-written tests are never touched |
--check exits 1 if anything would change (the CI drift gate). tuvl never writes function bodies.
6. Lock¶
tuvl lock writes tuvl.lock: the tuvl version, each AgentModel's model id, each artifact://
ref's version and sha256, judge and decision-model versions, and each allowed MCP tool's input-schema
hash (listed live; --no-mcp compares by name only). tuvl lock --check verifies it; tuvl ship
requires it (V024) and pins the locked tuvl version in the image.
7. The CI gate¶
tuvl spec check && tuvl validate --strict && tuvl codegen --check && tuvl lock --check && tuvl test --strict && tuvl spec verify --strict && tuvl spec status --strict
tuvl init <name> --sample creates a project that passes this gate as created.
Coding agents¶
tuvl mcp [-d <project>] [--allow-write]serves the same operations over MCP (stdio):validate,spec_check,spec_verify,spec_status,spec_diff,get_contract,list_agents,explain_error,get_run,get_run_events,codegen,test; with--allow-writealsocodegen_write,run_workflow,pin_fixture. It refusesTUVL_ENV=production..agents/skills/(refreshed bytuvl skills update [--diff], mirrored to.claude/skills/):tuvl-spec,tuvl-plan,tuvl-task-loop,agent-code,agent-tool,agent-llm,agent-loop,agent-decide,agent-human,tuvl-data,tuvl-test,tuvl-harden,tuvl-ship, plus a rootAGENTS.mdwith the non-negotiable rules.- Every read command has
--jsonwith a documented schema (/dev/schema/cliin dev mode).