6.9 — Build-loop contracts (releases, tickets, payloads)
On this page
6.9 — Build-loop contracts (releases, tickets, payloads)
This page is the single reference for the data contracts the spec → build loop runs on: the release tables, the acceptance-criterion (AC) columns, the ticket state machine and the event payloads. Every other part of the loop reads these contracts and none of them redefines them.
Sources of truth:
| Contract | Defined in |
|---|---|
| Schema | supabase/migrations/20261012012242_north_star_build_loop_contracts.sql |
| Generated types | types/database.types.ts (regenerated, never hand-edited) |
| Ticket state machine | lib/kanban/transitions.ts |
| Event payloads (zod) | lib/kanban/payloads.ts |
| Version-control names | lib/version-control/types.ts |
Two product decisions shape everything below:
- Version control is per release. Nothing is tracked during MVP1. Locking MVP1 pins a baseline snapshot, and from then on the project's changes are tracked against it.
- There is no retire or tombstone. Deleting a spec entity is a normal delete. The locked baseline snapshot still holds the prior row (including each AC's evidence), so "removed" means "in the baseline, absent from live".
1. Tables
1.1 releases
One row per release of a project (MVP1, MVP2, …).
| Column | Type | Notes |
|---|---|---|
id | uuid | |
project_id | uuid | FK projects, cascade |
label | text | Non-empty, e.g. "MVP1" |
ordinal | int | 1-based, UNIQUE(project_id, ordinal) |
status | text | draft → active → locked. One active per project (partial unique index) |
baseline_changeset_id | uuid | Composite FK (baseline_changeset_id, project_id) → project_changesets(id, project_id), ON DELETE RESTRICT. The release-lock changeset (of the same project) whose snapshot is this release's frozen state |
parent_release_id | uuid | Composite self-FK (parent_release_id, project_id) (no action). The release, of the same project, this one diffs against |
source | text | authored (locked from the spec) or imported (landed by code import) |
locked_at, locked_by | timestamptz, uuid | locked_by is FK auth.users, set null |
created_at, updated_at | timestamptz | updated_at maintained by trigger |
Invariants enforced in the database:
- A
lockedrow must havebaseline_changeset_idandlocked_at(CHECK). - A
lockedrow is frozen: any update is rejected (trg_releases_lock_guard). The transition intolockedis allowed. The only change a locked row accepts islocked_bybecoming null, when the locking user is deleted. - Every link stays inside the project: the baseline changeset and the parent release must belong to the release's own project (composite foreign keys).
- Clients can only read releases (RLS:
is_project_user(project_id)for SELECT). Every write goes through a server route using the service role, after its own access check. A member also cannot delete the baseline changeset of any release, because of the RESTRICT foreign key, so the baseline snapshot (which cascades from its changeset) survives. Deleting the whole project still cascades normally. - Realtime: published.
Writers: the release engine (create, activate, lock), and code import (an imported
project lands as a locked MVP1 with source = 'imported').
Readers: the release engine (work list), context routing (scopes next-ready to the
active release), the live build board (Release management tab, realtime), and
mission control.
1.2 release_items
The release work list. One row per spec entity that differs from (or matches) the parent release's baseline.
| Column | Type | Notes |
|---|---|---|
release_id | uuid | Composite FK (release_id, project_id) → releases(id, project_id), cascade. An item can only belong to a release of its own project |
project_id | uuid | FK projects |
entity_type | text | A version-control entity name (section 4). Same domain as project_changeset_ops.entity_type |
entity_id | uuid | No FK on purpose: removed entities no longer exist live |
change_kind | text | new, modified, removed, unchanged |
field_changes | jsonb | Field-level diff vs the baseline (modified only) |
base_row | jsonb | The baseline row. For a removed AC it carries the AC's build_evidence |
disposition | text | build, delete_code, verify, none (default) |
UNIQUE(release_id, entity_type, entity_id); indexed on (project_id, entity_type, entity_id).
RLS: clients can only read (is_project_user(project_id) for SELECT); the server writes.
Not published to realtime (recomputed in bulk, read on demand).
Writer: the release engine (recomputeWorkList). Readers: context routing (only
new/modified ACs are offered while a release is active), the release engine (sprint
planning for the active release), and the live build board. Code paths for delete_code
items come from spec_code_links, which has no FK and so survives the delete.
1.3 kanban_questions
The agent's channel to a human. When an agent blocks a ticket with a question, the block
handler files a row here; the answer is persisted as a project_decisions row.
| Column | Type | Notes |
|---|---|---|
project_id | uuid | Always derived from the criterion by trigger, whatever the caller sends |
criterion_id | uuid | FK acceptance_criteria, cascade |
asked_by_kind | text | Same domain as kanban_events.actor_kind: user, mcp, ci, system |
asked_by_id | text | Same domain as kanban_events.actor_id |
question | text | Non-empty |
options | jsonb | Optional JSON array of suggested answers |
status | text | open (default), answered, cancelled |
answer, answered_by, answered_at | An answered row must have a non-empty answer and answered_at (CHECK) | |
decision_id | uuid | Composite FK (decision_id, project_id) → project_decisions(id, project_id), set null (only decision_id). The decision must be of the same project. project_decisions.source already allows agent |
RLS: clients can only read (is_project_user(project_id) for SELECT). asked_by_* and
answered_by are provenance, so only server routes (service role) write. Realtime: published.
Writers: context routing (the block handler inserts the row when a spec_unclear block
carries question) and the live build board (the answer card calls a server route, which
writes answer, answered_by and the decision). Readers: the live build board (inline answer card), the MCP agent loop
(the agent reads the answer when unblocked), and mission control.
2. Columns on existing tables
| Column | Meaning | Written by | Read by |
|---|---|---|---|
acceptance_criteria.ac_code | AC- + at least three digits, per project. Never null, unique per project, immutable | Database trigger only | Everyone: test names ([AC-042] …), commit trailers (Spec-Ref: AC-042), code tags, context packs, CI per-test results |
acceptance_criteria.test_code | Executable test body for the AC | Generation completeness | Blueprint readiness (emits it verbatim), hosted builder |
acceptance_criteria.test_framework | vitest, playwright, other. Required when test_code is set | Generation completeness | Same as test_code |
acceptance_criteria.verification_kind | How the current pass was established: ci, manual, import. Null = never passed | Verification (ci / manual), code import (import) | Live build board (the "unverified" / "manually accepted" labels), mission control |
acceptance_criteria.stale_reason | Why an upstream edit may invalidate the AC. Shape: StaleReason (section 5). Cleared on the next pass | Propagation | Live build board (stale badge), context routing |
external_key on features, pages, schema_tables, acceptance_criteria | Stable import key. Unique per (project_id, external_key) when not null | Code import (upsert) | Code import (re-import is idempotent) |
user_stories.external_key | Same, but unique per (feature_id, external_key): user_stories has no project_id | Code import | Code import |
sprints.release_id | Release a sprint belongs to. Composite FK (release_id, project_id), so same project only; set null | Release engine (sprint planner) | Context routing, live build board |
build_runs.sprint_id, build_runs.release_id | What a hosted build ran for. Composite FKs with project_id, so same project only; set null | Hosted builder | Live build board, mission control |
project_system_design_specs.structured | Machine-readable SDS (NFR, ADRs, env and integrations, background jobs, API) beside the markdown | Generation completeness | Blueprint readiness (completeness check) |
How ac_code is assigned
trg_acceptance_criteria_zz_ac_code assigns the next code on every insert and ignores any
client-supplied value. Its name is load-bearing: Postgres fires same-event triggers in name
order, and it must run after trg_acceptance_criteria_set_project_id, which fills
project_id for the normal insert-by-story path. Codes are padded to at least three digits
and never truncated (AC-999, then AC-1000). trg_acceptance_criteria_ac_code_immutable
rejects any change once a code is set. The migration backfilled every null code, continuing
from each project's highest existing code.
Codes are never reused. Each project keeps a high-water mark per code kind in
project_entity_code_counters, so deleting the highest AC (say AC-042) does not hand
AC-042 to the next new AC; that one gets AC-043. Gaps are normal. The same holds for
story, feature, page, section, persona and table codes.
Codes survive a revert. A whole-project revert (revert_project_to_changeset) re-inserts the
snapshot rows with their snapshot codes; the code triggers keep a supplied code only while that
function runs. A snapshot row taken before the backfill (null code) gets its current live code
back. A single-change revert never writes any *_code column. Codes are compared as numbers, not
text: as text AC-1000 sorts before AC-999.
3. Ticket state machine (criterion)
Actor kinds: user (a signed-in person), mcp (an agent token), ci (a CI token), system
(server code). The table below is the complete list of legal moves.
| From | To | Actors | Reason required |
|---|---|---|---|
| draft | ready | user | |
| ready | in_progress | mcp, user | |
| in_progress | in_review | mcp | |
| in_review | passed | ci, user | |
| in_review | failed | ci, user | |
| failed | ready | user, mcp (assigned agent only, checked in the route) | |
| ready, in_progress, in_review | blocked | mcp, user | |
| blocked | ready, in_progress, in_review | mcp, user | |
| in_progress | ready | system | (auto-revert of idle claims) |
| passed | ready | system | release_change or spec_change |
| in_review | ready | system | spec_change |
| draft, ready | passed | system | imported |
The last three rows are new. Rules:
- A reason-gated move is illegal without one of its reasons. The reason travels in the event
payload as
reason, andwriteTransition(lib/kanban/events.ts) forwards it toassertLegalTransition. Use the payload schemas in section 5, which carry the right literal. - Agents still cannot pass anything. No rule lets
mcpreachpassed, from any status, with any reason (agents_cannot_self_resolve). The unit suite enumerates every combination. write_kanban_transition(the SQL function that does the compare-and-swap and the event insert in one transaction) does not validate actor kinds;kanban_events.actor_kindalready allowssystem. Legality is enforced in TypeScript only.
Who uses the new moves:
passed → ready (release_change): the release engine, for ACs a release diff marks modified, one event per AC throughwriteTransition.passed | in_review → ready (spec_change): propagation, when an upstream spec edit may invalidate the AC (it also setsstale_reason).draft | ready → passed (imported): code import, which also setsverification_kind = 'import'. The board labels these "passed (unverified)" until a CI run confirms them.
4. Version-control names
ChangesetSource (and the project_changesets.source CHECK) gains release-lock: the pinned
changeset whose snapshot is a locked release's baseline.
EntityType (and the project_changeset_ops.entity_type and release_items.entity_type
CHECKs) gains state_machine, state_transition, data_contract, interaction and
release. Editable architecture versions the first four; propagation keys its
staleness edges on these names. A new entity type needs the TypeScript union and both CHECKs
extended together.
5. Event payloads (lib/kanban/payloads.ts)
All payload schemas are strict: unknown keys are rejected, so a client can never inject an
agent_id or a reason.
Agent-written
SubmitForReviewPayload (in_progress → in_review). Done means a named test.
| Field | Rule |
|---|---|
git_sha | Required, 7–64 chars |
diff_url | Optional URL |
base_sha | Optional, 7–64 chars |
spec_seq | Optional non-negative integer: the spec sequence the agent built against |
changed_files | At least 1: {path, status: added/modified/deleted/renamed, additions?, deletions?} |
tests | At least 1: {file, name, ac_codes}, where ac_codes has at least one valid AC code |
notes | Optional |
This replaced the old sha-plus-URL shape and its files_changed count. No client had ever
submitted in production, so nothing deployed depends on the old shape.
Writer: the MCP agent loop. Reader: verification (per-AC evidence).
ProgressPayload (heartbeat while in progress): phase is required, one of planning,
writing_test, implementing, running_tests, waiting_ci. Optional: summary,
plan (list of {step, done}), files (paths), tests ({passed, failed, total}, where
passed plus failed cannot exceed total).
Writer: the MCP agent loop. Reader: the live build board ("Now building" drawer).
BlockPayload: category, reason, prior_status, plus optional question and
options (1–10 strings). options without a question is rejected.
Writer: the MCP agent loop. Reader: context routing (files a kanban_questions row).
CI- and user-written
CiResultPayload: outcome, test_run_url, optional git_sha, notes, and
per_test (list of {name, ac_codes, outcome: passed/failed/skipped}; ac_codes may be empty
for an untagged test).
Writer: the CI reporter (verification) and the hosted builder. Reader:
verification.
ResolveReviewPayload: outcome, optional test_run_url, manual_note, notes.
A pass must carry test_run_url or a manual_note of at least 10 characters. The route
records which one as verification_kind (ci or manual). ResolveReviewPayloadBase is
the same object without that rule, for composition.
Writers: a signed-in user or a CI token through the resolve-review route. Agents are refused
before parsing. Note: the board's own "Pass" button currently calls writeTransition
directly and does not parse this schema; verification routes it through the same rule.
System-written (never parsed from a client body)
| Schema | Fields | Transition |
|---|---|---|
ReleaseChangePayload | reason: "release_change", release_id (uuid), prior_status, change_kind (release_items domain), field_changes (object) | passed → ready |
SpecChangePayload | reason: "spec_change", changeset_id (uuid or null: there are no changesets while a project is untracked during MVP1), fields_changed (at least 1), optional source_entity {entity_type, entity_id} | passed or in_review → ready |
ImportPayload | reason: "imported", git_sha, files, verification: "unverified" | draft or ready → passed |
SystemTransitionPayload is the discriminated union of the three, keyed on reason.
Shared shapes
AcCode:AC-followed by at least three digits.StaleReason, the shape ofacceptance_criteria.stale_reason:{source_entity: {entity_type, entity_id}, fields: string[], changeset_id: uuid or null, at: ISO datetime}.
6. Applying the migration
The migration is additive apart from the ac_code repair (backfill, a non-null CHECK, an
immutability trigger, and the six entity-code triggers repointed to the padded code
function). It must be applied to production before any code that selects the new
columns is deployed.