6.9 — Build-loop contracts (releases, tickets, payloads)

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:

ContractDefined in
Schemasupabase/migrations/20261012012242_north_star_build_loop_contracts.sql
Generated typestypes/database.types.ts (regenerated, never hand-edited)
Ticket state machinelib/kanban/transitions.ts
Event payloads (zod)lib/kanban/payloads.ts
Version-control nameslib/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, …).

ColumnTypeNotes
iduuid
project_iduuidFK projects, cascade
labeltextNon-empty, e.g. "MVP1"
ordinalint1-based, UNIQUE(project_id, ordinal)
statustextdraft → active → locked. One active per project (partial unique index)
baseline_changeset_iduuidComposite 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_iduuidComposite self-FK (parent_release_id, project_id) (no action). The release, of the same project, this one diffs against
sourcetextauthored (locked from the spec) or imported (landed by code import)
locked_at, locked_bytimestamptz, uuidlocked_by is FK auth.users, set null
created_at, updated_attimestamptzupdated_at maintained by trigger

Invariants enforced in the database:

  • A locked row must have baseline_changeset_id and locked_at (CHECK).
  • A locked row is frozen: any update is rejected (trg_releases_lock_guard). The transition into locked is allowed. The only change a locked row accepts is locked_by becoming 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.

ColumnTypeNotes
release_iduuidComposite FK (release_id, project_id) → releases(id, project_id), cascade. An item can only belong to a release of its own project
project_iduuidFK projects
entity_typetextA version-control entity name (section 4). Same domain as project_changeset_ops.entity_type
entity_iduuidNo FK on purpose: removed entities no longer exist live
change_kindtextnew, modified, removed, unchanged
field_changesjsonbField-level diff vs the baseline (modified only)
base_rowjsonbThe baseline row. For a removed AC it carries the AC's build_evidence
dispositiontextbuild, 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.

ColumnTypeNotes
project_iduuidAlways derived from the criterion by trigger, whatever the caller sends
criterion_iduuidFK acceptance_criteria, cascade
asked_by_kindtextSame domain as kanban_events.actor_kind: user, mcp, ci, system
asked_by_idtextSame domain as kanban_events.actor_id
questiontextNon-empty
optionsjsonbOptional JSON array of suggested answers
statustextopen (default), answered, cancelled
answer, answered_by, answered_atAn answered row must have a non-empty answer and answered_at (CHECK)
decision_iduuidComposite 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

ColumnMeaningWritten byRead by
acceptance_criteria.ac_codeAC- + at least three digits, per project. Never null, unique per project, immutableDatabase trigger onlyEveryone: test names ([AC-042] …), commit trailers (Spec-Ref: AC-042), code tags, context packs, CI per-test results
acceptance_criteria.test_codeExecutable test body for the ACGeneration completenessBlueprint readiness (emits it verbatim), hosted builder
acceptance_criteria.test_frameworkvitest, playwright, other. Required when test_code is setGeneration completenessSame as test_code
acceptance_criteria.verification_kindHow the current pass was established: ci, manual, import. Null = never passedVerification (ci / manual), code import (import)Live build board (the "unverified" / "manually accepted" labels), mission control
acceptance_criteria.stale_reasonWhy an upstream edit may invalidate the AC. Shape: StaleReason (section 5). Cleared on the next passPropagationLive build board (stale badge), context routing
external_key on features, pages, schema_tables, acceptance_criteriaStable import key. Unique per (project_id, external_key) when not nullCode import (upsert)Code import (re-import is idempotent)
user_stories.external_keySame, but unique per (feature_id, external_key): user_stories has no project_idCode importCode import
sprints.release_idRelease a sprint belongs to. Composite FK (release_id, project_id), so same project only; set nullRelease engine (sprint planner)Context routing, live build board
build_runs.sprint_id, build_runs.release_idWhat a hosted build ran for. Composite FKs with project_id, so same project only; set nullHosted builderLive build board, mission control
project_system_design_specs.structuredMachine-readable SDS (NFR, ADRs, env and integrations, background jobs, API) beside the markdownGeneration completenessBlueprint 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.

FromToActorsReason required
draftreadyuser
readyin_progressmcp, user
in_progressin_reviewmcp
in_reviewpassedci, user
in_reviewfailedci, user
failedreadyuser, mcp (assigned agent only, checked in the route)
ready, in_progress, in_reviewblockedmcp, user
blockedready, in_progress, in_reviewmcp, user
in_progressreadysystem(auto-revert of idle claims)
passedreadysystemrelease_change or spec_change
in_reviewreadysystemspec_change
draft, readypassedsystemimported

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, and writeTransition (lib/kanban/events.ts) forwards it to assertLegalTransition. Use the payload schemas in section 5, which carry the right literal.
  • Agents still cannot pass anything. No rule lets mcp reach passed, 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_kind already allows system. 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 through writeTransition.
  • passed | in_review → ready (spec_change): propagation, when an upstream spec edit may invalidate the AC (it also sets stale_reason).
  • draft | ready → passed (imported): code import, which also sets verification_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.

FieldRule
git_shaRequired, 7–64 chars
diff_urlOptional URL
base_shaOptional, 7–64 chars
spec_seqOptional non-negative integer: the spec sequence the agent built against
changed_filesAt least 1: {path, status: added/modified/deleted/renamed, additions?, deletions?}
testsAt least 1: {file, name, ac_codes}, where ac_codes has at least one valid AC code
notesOptional

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)

SchemaFieldsTransition
ReleaseChangePayloadreason: "release_change", release_id (uuid), prior_status, change_kind (release_items domain), field_changes (object)passed → ready
SpecChangePayloadreason: "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
ImportPayloadreason: "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 of acceptance_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.