Spec-Driven Authoring¶
Status: introduced in v0.11. Additive — your existing
spec.add_model(User, ...)Python keeps working unchanged.
SpecStar v0.11 introduces a spec-driven authoring layer on top of the existing engine. You describe resources in prose (intent.md), a two-step LLM pipeline writes a structured spec (spec.md), then declarative Python (_generated.py) that the engine consumes.
intent.md ← your free prose, never overwritten
↓ STEP 1: skill / specstar gen --call
spec.md ← structured β heading protocol
↓ STEP 2: same pipeline, AST-validated
<package>/_generated.py ← declarative Python, runtime SSOT
↓ deterministic (spec.dump_descriptor)
spec.lock.json ← hashes + descriptor + validation status
The engine is unchanged: spec.add_model(...), Schema(...).step(...), route templates, storage, permissions all behave exactly as before. Spec-driven is an authoring layer on top.
TL;DR¶
# 1. Bootstrap a starter project
uv run specstar init my_app
# 2. Edit intent.md to describe what you want, then either:
# a. In Claude Code: invoke /specstar
# b. From any provider: specstar gen --call --provider openai --model gpt-4o
uv run specstar gen --call --provider openai --model gpt-4o --yes
# 3. CI-friendly drift check (no LLM)
uv run specstar verify
The four artifacts¶
| File | Authoritative? | Edited by | Tracked in git? |
|---|---|---|---|
intent.md |
yes — your prose | you | yes |
spec.md |
derived | LLM (STEP 1) | yes |
<package>/_generated.py |
runtime SSOT | LLM (STEP 2) | yes |
spec.lock.json |
derived | specstar lock |
yes |
pyproject.toml [tool.specstar] |
yes | you | yes |
.env / .env.example |
secrets / template | you | only .env.example |
_generated.py is committed so PR review can see exactly what the model registration looks like. spec.lock.json is committed so specstar verify (CI) detects drift.
spec.md — the β heading protocol¶
Three heading levels matter; everything else is prose.
# My App ← project title (one per spec.md)
## Project ← optional, project-wide scalars
- model_naming: snake
- admin: root@example.com
## Resource: User ← per-resource section
A registered user.
### Fields ← required
- `name`: required string
- `email`: required, unique, format=email
### Permissions ← optional — uses 6 controlled tokens
- read: authenticated
- update: owner
- delete: admin
### Workflows ← optional — phase + action + dotted ref
- after create: my_app.logic.send_welcome_email
### Indexes ← optional
- email
- name
### Defaults ← optional
- default_status: draft
- default_user: anonymous
- default_now: utc
- id_generator: uuid4
- encoding: json
### Storage ← optional — omit to use project default
backend: postgres
dsn: env DATABASE_URL
### Schema versions ← only when you have versioned migrations
#### v1 → v2: rename email to email_address
Migration: rename only.
### Validation ← optional — string ref to user code
- my_app.logic.validate_user
### Constraints ← optional — list of string refs
- my_app.logic.no_duplicate_email
Full vocabulary lookup: see spec.md syntax reference.
Feature toggles¶
Each spec.md section maps to a feature flag in pyproject.toml. When a flag is off, STEP 2 leaves that section's content as a comment in _generated.py instead of writing real code.
Default: ["permissions", "workflows", "schema"] (the three pure-declarative essentials).
Widen as you adopt more surface:
features = [
"permissions", "workflows", "schema",
"indexes", "defaults", "encoding", "id_generator",
"storage", "mq", "blob",
"validators", "constraints",
]
Per-run override: --feature storage (add) / --no-feature workflows (remove). See How-to: feature toggles.
Secrets and .env¶
Spec-driven _generated.py cannot import os — the AST validator blocks it. To reference deployment env vars (DB URLs, S3 buckets, JWT keys), use specstar.env(...):
from specstar import BackendBinding, BackendConfig, spec
import specstar
spec.configure(
backend=BackendConfig(
connections={
"main": ConnectionProfile(
type="postgres",
options={"dsn": specstar.env("DATABASE_URL")},
),
},
meta=BackendBinding(use="main"),
resource=BackendBinding(use="main"),
),
)
specstar init scaffolds .env.example (committed) and .gitignore (lists .env). Copy .env.example to .env, fill in real values, never commit .env. Production environments inject env vars directly — .env is dev-only.
Full walkthrough: How-to: env vars in spec-driven projects.
Daily workflow¶
# Make a change
$EDITOR intent.md
# Regenerate
specstar gen --call --provider openai --model gpt-4o --yes
# Inspect the diff
git diff spec.md my_app/_generated.py spec.lock.json
# Run / test
uvicorn my_app:app
In Claude Code, replace step 2 with /specstar — same pipeline, no API key needed.
Both paths share:
- the same prompts (
specstar.skill.prompts) - the same feature toggles
- the same AST validator
- the same lock file
Recovery and self-healing¶
The gen --call pipeline ships three safety layers:
| Trigger | Behavior |
|---|---|
| LLM produces invalid Python | AST validator rejects pre-write — nothing lands on disk |
| LLM-generated code imports cleanly but errors at runtime (TypeError, missing kwarg) | Feedback retry: captured stderr is fed back to STEP 2 as additional prompt context, LLM self-corrects up to --feedback-retries N (default 2) times |
| Feedback retries exhausted | Rollback: working tree restored to pre-call state; user sees the error and the LLM output that triggered it |
rm spec.lock.json |
Treated as "rebuild from scratch" — force=True reruns STEP 1 + STEP 2 from intent.md |
rm spec.md |
Same as missing lock — STEP 1 recreates from intent.md |
What the skill is allowed to write¶
The skill writes declarative Python only into _generated.py:
spec.configure(...)once at file top (when storage / mq / blob / project scalars are used)spec.add_model(...)callsmsgspec.Structclass definitionsSchema(...).step(...)chains + pure-function migration bodies- Imports of:
specstar,msgspec,typing,enum,datetime,decimal - Built-in helpers:
specstar.env(...),specstar.string_ref(...),specstar.defaults.utcnow,specstar.id_generators.uuid4, the 5 permissionCheckFuncbuiltins,StringRefEventHandler,StringRefConstraintChecker
The skill never writes:
import os/subprocess/socket/requests/urllib*/pathlib/httpxetc.Try/With/Raise/While/Asyncstatementsexec,eval,compile,open,__import__,getattr,setattr,delattr- Dunder reads other than
__name__/__doc__ - Imports of any user package — use string references
Anything needing user logic (validators, event handler bodies, custom permission checks) lives in your own module under <package>/logic/ and is referenced from spec.md by dotted path.
Logic references (β pattern)¶
For business logic, write hand-coded Python in your own module and reference it from spec.md:
Your my_app/logic.py:
def send_welcome_email(context) -> None:
# idempotency, retry, side effects: your responsibility
user = context.data
smtp_send(user.email, subject="Welcome", body=f"Hi {user.name}")
The skill does not generate this body. STEP 2 emits a StringRefEventHandler("my_app.logic.send_welcome_email", phase="after", action=ResourceAction.create) in _generated.py. The dotted path is resolved lazily on first dispatch — your function doesn't need to exist when specstar lock runs.
This is the β (beta) pattern: clear separation between what (declared in spec.md) and how (your Python).
The string-ref wrapper class depends on the slot:
| add_model kwarg | Wrapper |
|---|---|
event_handlers |
specstar.events.StringRefEventHandler |
constraint_checkers |
specstar.resource_manager.StringRefConstraintChecker |
validator |
specstar.string_ref("...") (works directly) |
id_generator |
specstar.string_ref("...") |
default_user (callable form) |
specstar.string_ref("...") |
CLI commands¶
| Command | Purpose | Uses LLM? | Modifies files? |
|---|---|---|---|
specstar init [PACKAGE] |
Bootstrap a starter project | no | yes (creates) |
specstar status |
Show current drift state | no | no |
specstar verify |
CI: pass/fail on drift + AST | no | no |
specstar lock |
Rebuild spec.lock.json from current files |
no | yes (lock only) |
specstar gen --step {1,2} |
Print prompts for one step (dry-run) | no | no |
specstar gen --call |
Full pipeline — STEP 1 + STEP 2 + apply + lock + verify | yes | yes |
Use verify in CI; status interactively while editing; gen --call to actually regenerate.
Drift detection (the 8-case table)¶
specstar status classifies the three tracked files against the lock:
| Case | intent | spec | gen | What gen --call does |
|---|---|---|---|---|
| 1 | clean | clean | clean | Nothing — refresh lock |
| 2 | changed | clean | clean | Run STEP 1 + STEP 2 |
| 3 | clean | changed | clean | Run STEP 2 only (respect user spec edits) |
| 4 | clean | clean | changed | Refresh lock only (_generated.py is SSOT — user wins) |
| 5 | changed | changed | clean | STEP 2 only — user's spec edits win over intent.md |
| 6 | clean | changed | changed | Refresh lock only — spec/gen both diverged, neither overrides the other |
| 7 | changed | clean | changed | STEP 1 only — leave user's _generated.py alone, regenerate spec.md |
| 8 | changed | changed | changed | Refresh lock only — full divergence, reconcile manually or --force |
--force re-runs STEP 1 + STEP 2 unconditionally. --from-spec skips STEP 1 (use when spec.md is hand-written).
LLM providers¶
specstar gen --call uses litellm under the hood, so any provider it supports works:
# Anthropic (default)
specstar gen --call --provider anthropic --model claude-sonnet-4-6
# OpenAI
specstar gen --call --provider openai --model gpt-4o
# OpenAI-compatible self-host (Ollama, vLLM, LM Studio, ...)
specstar gen --call --provider openai-compatible \
--base-url http://localhost:11434/v1 \
--model llama3.1
API keys read from env (ANTHROPIC_API_KEY / OPENAI_API_KEY) or --api-key flag.
Reliability — what "spec-driven" promises¶
- The skill writes declarative Python only. It never generates business logic — that's always your hand-written module.
- An AST validator (
specstar.validator.DeclarativeASTValidator) rejects any non-declarative pattern in_generated.pybefore anything lands on disk. See AST validator reference. - Pre-write rollback restores the working tree if
lockorverifyrejects the LLM output post-write — your_generated.pyandspec.mdrevert to their pre-call content. - Feedback retry turns runtime errors (TypeError, missing kwarg, hallucinated symbol) into self-correction rounds before falling back to rollback.
- A property-graph descriptor (
spec.lock.json) makes every change auditable in PR review — reviewers see structured node/edge changes, not a wall of generated Python. - CI runs
specstar verifywhich is fully deterministic (no LLM). It re-hashes sources, re-checks AST, and fails the build if any artifact has drifted.
Every LLM call is human-in-the-loop. Every artifact is reviewable. Every drift is detectable in CI.
Migration story for existing v0.10 users¶
Three paths — pick whichever matches your appetite:
- A. Don't migrate. Your
spec.add_model(User, ...)Python keeps working. v0.11 is purely additive. - B. Mix. Add new resources via
intent.md/spec.md. Old resources stay where they are. Both kinds ofadd_modelcalls coexist on the samespecinstance. - C. Full migration. Wait for v1.x's
specstar init --from-existingreverse-engineering tool. Not in v0.11.
There are no breaking changes in v0.11.
See also¶
- Spec.md syntax reference — full β protocol lookup
- How-to: feature toggles — pyproject.toml, CLI flags, env vars
- AST validator reference — what the skill is allowed to emit
docs/design/spec-driven-architecture.md— strategic design rationale