Pipebrain

The specification Pipebrain is built from, published straight from the repository. This page and the document the team builds against are the same file.

Pipebrain — V1 Requirements

Status: Published reference · August 2026 · Revision 15 — corrections: §6’s trust model names the manual criteria as the whole human gate on finishing (it had counted the done transition too, contradicting the same paragraph’s opening — the completion guard takes no actor at all), and §6 rule 2 no longer says what remains after → verify is human judgment. Rev 14 (§15 records the decisions module as built — it had shipped while that entry still said deferred, contradicting §7’s own tool table; §7’s opening names the hosted streamable-HTTP endpoint beside stdio; and §12 records the live custom domain). Rev 13 (acceptance criteria widened from epics to work of any kind: an epic, a task and a bug each carry them, with the same AC-{n} identity and the same gates — §4 criteria, §6 rules 2–3, §7 set_criteria). Rev 12 (deployment topology removed from this public page: cluster, pool, port and role identities are operational facts that belong in the deploy specification and the team’s internal cluster note, and restating them here published a second copy that could quietly disagree with the first). Rev 11 (acceptance suites and run history removed — acceptance criteria are the only gate; §6 renumbered). Purpose: The published interface reference for Pipebrain — what an agent, an integrator, or a reader outside the team needs in order to use the product: the MCP tool surface (§7), the HTTP API (§9), and the embeddable feedback widget (§10). The governing requirements are versioned documents in the Pipebrain tracker itself (the PIPE-R… series), and they are what an epic links to; where a tracker document and this page disagree, the tracker document is the one that governs. Companion: WORKFLOW.md defines the delivery workflow Pipebrain exists to support. Entity names, status values, and MCP tool names must match between the two documents exactly.


1. What Pipebrain is

Pipebrain is the system of record for product work — requirements, epics, tasks, bugs, acceptance criteria, and user feedback — for a small team whose builders are agents. V1 is operated by two humans and their AI agents.

Pipebrain is a ledger, not an orchestrator. It records intent, holds claimable work, and receives reports. It never spawns, schedules, or drives agents. Agents (Claude Code sessions) do the thinking inside product repositories and report state changes through the Pipebrain MCP server. Humans gate the workflow at defined points.

V1 shipped two modules — issues (work tracking, with per-issue acceptance criteria) and requirements (versioned, living requirement documents); the platform has since grown content & assets, secrets, and org-instructions modules (§4, §7). Module boundaries in code reflect that Pipebrain is the platform and these are feature areas.

The problem the UI must solve

Today, working a feature means switching between a roadmap file (status), an epic index (structure), a feature file (acceptance checkboxes), and a requirements document (context) — and “checking off” a criterion means editing markdown. Pipebrain replaces that with the feature epic’s page: feature → slices → criteria, with status changes and criterion check-offs in place, and requirement context readable without navigating away. This is the design center of §8. The project overview and activity log are derived views that replace the hand-maintained roadmap and runlog files entirely.

Product principles

  1. Simplicity first. Two users. Build the basics needed to organize, view, and filter work. No dashboards, no charts, no drag-and-drop, no configurable workflows.
  2. Markdown is first-class. Requirement bodies, issue bodies, and comments are markdown, with quality editing and rendering everywhere.
  3. Agent-native by design. The MCP server is a primary interface, not an afterthought. Web and MCP consume the same API.
  4. Own the data, minimize vendors. DigitalOcean + Postgres only. No new third-party services.
  5. Cost discipline. Marginal infrastructure cost ≈ $0 (shared database cluster) plus the smallest App Platform tiers.

2. Technical stack (locked — do not substitute)

ConcernChoice
MonorepoNx, npm workspaces, npm only (never yarn/pnpm)
LanguageTypeScript everywhere, Node ≥ 24
APIFastify
WebReact Router v7, framework mode with ssr: false (SPA); Tailwind CSS v4; TanStack React Query
ValidationZod-first (Zod v4, pinned): every domain object and API payload derives from a Zod schema via z.infer. No hand-written interfaces for domain types. Named exports only.
Module systemESM throughout"type": "module", TS NodeNext.
DatabasePostgreSQL 17, raw SQL via postgres.js. No ORM.
Migrationsdbmate, plain SQL, forward-only. Schema truth lives in db/migrations/.
AuthBetter Auth (httpOnly cookie sessions for web) + first-party API tokens for MCP
TestsJest (unit + integration), run via @swc/jest
MCP@modelcontextprotocol/sdk, stdio transport
Markdownreact-markdown + remark-gfm + rehype-sanitize (+ rehype-highlight for fenced code); editor is CodeMirror 6 with live preview. Markdown is always rendered client-side and sanitized at render.
Icons@tabler/icons-react, bundled/tree-shaken — no runtime CDN (the mock’s CDN webfont is mock-only)
Configuration.env-first, fail-fast: every runtime value — ports included — comes from environment variables (local: gitignored .env; prod: App Platform env vars). A Zod schema validates config at boot; the process refuses to start, naming every missing or invalid var. No in-code defaults or fallbacks. A committed .env.example documents every variable with recommended values.
HostingDigitalOcean App Platform
Explicitly excludedRedis, message brokers, SSE/WebSockets*, ORMs, CSS-in-JS, Cloudflare, Google/Microsoft/Meta services

*The V1 exclusion of WebSockets is revisited by the chat slice (§15, PIPE-11), which introduces @fastify/websocket + Postgres LISTEN/NOTIFY for one org-scoped stream.

Suggested workspace layout: apps/api, apps/web, apps/mcp, libs/domain (Zod schemas + types — single source of truth), libs/db (queries), libs/api-client, db/migrations (dbmate, repo root), libs/widget (builds the single widget.js artifact).


3. Users, auth, and attribution


4. Domain model

Multi-organization deployment (shipped additively after V1 — PIPE-R8; no rename below the data model, which was only possible because the org anchor was real from the first migration). The organization is a real root entity and the RLS scope anchor (see §11). All tenant tables carry org_id NOT NULL.

Display-ID numbering uses per-project counters keyed by kind (issue, requirement), each updated atomically inside the insert transaction. Gaps acceptable; duplicates never.

organizations — the root entity

id (uuid), name, slug (unique), kind (shared | personal), created_by, created_at, updated_at, deleted_at. Every tenant table’s org_id references organizations(id). RLS is enabled and forced on it, self-scoped (id = current_setting('app.org_id')::uuid); it carries no org_id of its own — it is the scope anchor. V1 ran single-org (with a second org seeded for the RLS isolation tests, §13); multi-org shipped additively: shared orgs hold a team’s work, and each operator owns a personal org (slug derived from the email localpart). RLS enforces isolation across all orgs, shared and personal alike.

users — operators and invited members

The Better Auth user table, extended with default_org_id (nullable FK → organizations — the bare-login landing pointer, renamed from V1’s org_id NOT NULL; the truth about access lives in org_members, never in this pointer) and deleted_at. Its schema is generated once via the Better Auth CLI, translated to plain SQL, and hand-written into the dbmate migrations; Better Auth’s session/account/verification tables likewise live in migrations. All actor / created_by / claimed_by / approved_by / author_user_id / state_set_by FKs are uuid referencing users(id). The auth tables (user, session, account, verification) are org-independent infrastructure — not tenant tables: no org_id on session/account/verification, no forced RLS, read at cookie-validation time before app.org_id is set. Better Auth’s own migrator is never run in production.

org_members — membership is the truth

id, org_id, user_id, role (OWNER | ADMIN | CONTRIBUTOR), invited_by, created_at, updated_at, deleted_at; one live membership per (org_id, user_id) (partial unique index excluding soft-deleted rows, so a removed member can be re-added). A user belongs to any number of orgs and the web app selects the active one — access derives from a membership row, never from inference. Like users, this is auth-infrastructure zone: no RLS, read at request-binding time before app.org_id is set.

org_invitations — invite-gated signup

An ADMIN/OWNER invites by email as ADMIN or CONTRIBUTOR; the invite token gates account creation and, for a new user, the by-token new-user branch (§3). Managed in Settings (Organization tab → Members); email delivery via SES.

projects

name, key (2–5 uppercase letters, unique per org, used in display IDs), widget fields (§10), default_environment_id (FK → environments; seeded to the auto-created prod), timestamps, deleted_at. Creating a project automatically seeds two environments: dev and prod, and sets default_environment_id to prod.

environments

Per project: name, slug (unique per project), position. Operators can add, rename, and delete an environment — delete is allowed only when the environment has no issues.

milestones

Per project, operator-managed (§8 Settings): id, org_id, project_id, token (e.g. M1, unique per project), label (e.g. “production launch”), position (int), active (boolean; at most one active per project — enforced by a partial unique index), archived (boolean, default false — the release is finished and leaves the everyday pickers, WITHOUT being deleted: the row stays listed, keeps its token, label and position, and every issue carrying that token is untouched; a table CHECK refuses active and archived together, so archiving the active milestone and activating an archived one are both refused, each with its own error code), created_at, updated_at, deleted_at. issues.milestone stays free text and is a loose reference to milestones.token: an issue may carry a token with no matching milestones row (it groups by that token on the overview, without a label or active state). The Work page’s active-milestone summary joins the active milestone row for its token, label, and progress.

issues — the single work entity

One table for all work. type distinguishes kinds, parent_id builds hierarchy, and the workflow is carried by status plus convention (defined in WORKFLOW.md).

FieldNotes
iduuid
org_id, project_idrequired
environment_idrequired on every issue
numberint; display ID = {project.key}-{number} (e.g. DWB-42)
typeepic | task | bug | feedback
statustriage | backlog | ready | in_progress | verify | done | killed · default triage
prioritylow | medium | high | critical · default medium
milestonenullable free text (e.g. M1); a loose reference to milestones.token (no FK — an issue may carry a token with no milestones row); filterable; counted on the project overview
titlerequired
bodymarkdown, default ''
header_summary, header_why, header_affected_surfacesnullable text — the issue’s header (§7 set_issue_header): what the change is, why it is worth doing, and which parts of the product it touches and who feels it. Written only to an issue that already exists; create_issue, update_issue, promote_feedback and promote_deliverable refuse all three. header_summary is capped at 350 characters by a check constraint, matched by the schema cap so the refusal is a 400 rather than a 500.
header_body_sha, header_derived_atnullable — the sha256 of body as it stood when the header was written, and when. Staleness is derived on read by re-hashing the body and comparing (headerStale on every payload, the needsHeader filter in §7 list_issues — one expression, so the flag and the filter cannot disagree); a null sha makes “never written” fall out of the same comparison as “changed”. A check constraint ties the summary and both provenance columns to being written together.
parent_idnullable self-reference (feature epics parent slice epics; epics parent bugs found during verification). Must not self-reference or create a cycle — the server walks the ancestor chain on create/update and rejects.
claimed_bynullable user id — orthogonal to status
session_idnullable, opaque — which agent session holds the claim. Cleared with the claim. It carries no FK: a session names itself, and attribution is already claimed_by plus the audit trigger’s actor/token. Available work and the work list drop issues held under a different session identifier, so one operator’s parallel sessions stop being offered each other’s work.
branch, worktree, basenullable — where the work happens. Written by agents (through the claim and update_issue), shown to a person without an editor; the person’s control point is the project’s defaults (PIPE-D46). worktree is a label, never a path — a path is true only on the machine that wrote it. They OUTLIVE the claim: with a live holder they are a reservation, without one they say where the work last went. Two partial unique indexes on (project_id, branch) and (project_id, worktree), predicated on a live claim, are what make “no two live claims hold one place” true rather than merely checked. Null means “ask the next level up”: resolution walks the issue, then the nearest ancestor carrying a value, then the project’s default_branch / default_worktree / default_base.
reserved_for_personboolean, default false — the work is kept for a person, so an agent is not offered it (absent from §7 get_work_context’s available list) and a bearer token’s claim on it is refused 403 reserved_for_person. Orthogonal to status, priority and claimed_by: a claim says someone has started, this says the work is spoken for before anyone starts. Any type of work can carry it. Set and cleared only by a browser session (PUT/DELETE /issues/:ref/reserved, 403 web_session_required to a token) — an agent able to clear the mark could unmark work and then claim it. Reading is unrestricted and every payload carries the mark.
sourceweb | mcp | widget
metadatajsonb, default {} — namespaced: metadata.system = server-captured, trusted (user agent, origin, received-at); metadata.client = client-submitted, untrusted (page URL, app version, reporter email, custom payload)
custom_fieldsjsonb, default {} — reserved for operator-defined fields
created_bynullable user (null = widget submission)
created_at, updated_at, status_changed_at, deleted_at

“Open” (defined once): an issue is open when its status is not in {done, killed}. This single definition drives the Work page’s attention preset, the open-issue counts (§7 list_projects, §8), and which issues the blocked surfaces report (§8). Blocked-ness itself is recorded state, never derived from an issue’s status or its comments: an active blocked mark (with its reason) or at least one unresolved blocking issue — and a blocker resolves exactly when it stops being open. open only scopes which blocked issues the queue and the attention preset surface.

criteria — itemized acceptance criteria (epics, tasks, bugs)

Individually checkable rows on a piece of work: its contract — and, split by verification mode, the machine-verified evidence trail plus the human verification checklist (WORKFLOW.md Stage 6). An epic, a task and a bug each carry criteria, because what a criterion answers is how anyone knows the thing is done, and that question does not get easier as the work gets smaller. feedback is the one kind refused (422 criteria_not_supported), and it is refused for what it is rather than for its size: a report of something noticed, not work anyone has agreed to do — promote_feedback retypes it into work first, and that is the moment its contract gets written.

FieldNotes
id, org_id, issue_idissue must be a kind that carries criteria — epic, task or bug
numberint; stable per-issue identity, assigned at creation, never renumbered, never reused (soft-deleted rows retire their numbers). Display: AC-{number}; addressable as {ISSUE-KEY}/AC-{n}. Identity is number; ordering is position.
positionint, operator-orderable
textmarkdown (inline rendering — no block elements needed)
verificationauto | manual · default manual. auto = an evaluator can drive the input and read the result against the running system — a database row, an HTTP response, a generated artifact, or behavior on screen wherever the project has a means of driving its own interface. The mode follows from what the project can drive, not from where the behavior appears. manual = the verdict itself needs a person’s judgment: whether a screen matches its design, whether copy reads well, whether a flow feels right. The stored default is manual because a mode the caller never stated has to land on the gate that fails safe.
statepending | passed | failed · default pending
state_evidencenullable markdown — how the state was established (e.g. “queried monitors; row 4f2… present with status=active”). Required when a token sets passed/failed.
how_tonullable markdown — how to check this criterion by hand (where to look, what to do, what counts as passing), written by whoever specified it. The method, where state_evidence is the findings. manual rows only: an auto row stores null, so a manualauto flip drops it. Required when a token writes or changes a manual criterion (§7 set_criteria), never of a web session; changing it never resets state.
state_set_by, state_set_at, state_via_token_idattribution for the last state change; null while pending and untouched
created_at, updated_at, deleted_at

Enforcement (server rule, not convention): a request authenticated by an API token may set state only on auto criteria. State on manual criteria can be set only from a web session. This is what keeps the human gate real — an agent can never check off human judgment.

issue_blocked_marks — the explicit blocked mark

org_id, issue_id, reason (required, human-readable), set_by, set_via_token_id (nullable), set_at, cleared_by, cleared_via_token_id, cleared_at (null while the mark stands), created_at, updated_at, deleted_at. At most one active mark per issue — a partial unique index on issue_id where cleared_at is null and deleted_at is null; writes serialize on the issue row. Clearing never deletes: cleared rows are the issue’s blockage history, so who blocked it, why, and who ended it stay readable. Re-marking an already-marked issue clears the standing row and inserts a new one, so attribution is never overwritten.

issue_relations — typed edges between issues

org_id, from_issue_id (the blocker), to_issue_id (the waiting issue), kind (a CHECK over blocks | depends_on | duplicates | relates_to), created_by, via_token_id (nullable), created_at, updated_at, deleted_at; from_issue_id <> to_issue_id, and one live edge per (from_issue_id, to_issue_id, kind) (partial unique). Only kind = 'blocks' is wired (§15): both issues must be in the same project, and an edge that would close a cycle — directly or through other issues — is rejected with nothing recorded, checked under a per-project advisory lock so two concurrent writes cannot compose one. An edge is unresolved while its blocker is open; a done or killed blocker resolves it without the row changing.

requirements — project-scoped living documents

A separate first-class entity — not an issue type. Requirements describe the product, so they carry no environment, no status board, no claiming. Display ID = {project.key}-R{number} (e.g. DWB-R4).

FieldNotes
id, org_id, project_id
numberint (requirement counter)
cross_cuttingboolean, default false. A cross-cutting requirement binds every epic in the project without explicit links — see §5 rule 6. Toggling it is a metadata change (audited), not a revision. Toggling on is rejected with an actionable error while the requirement has any non-deleted issue_requirement_links (unlink first via update_issue — a pinned link is part of an epic’s frozen slice contract, so the server never silently unlinks). Toggling off is always allowed (epics then need explicit links again).
approved_revision_idnullable FK → requirement_revisions; the currently approved version
created_by, created_at, updated_at, deleted_at

Title and body live in revisions; the “current” title/body is the latest revision. Lifecycle is derived, never stored: draft (no approval yet) · approved (approved revision = latest revision) · amended (revisions exist after the approved one).

requirement_revisions — append-only history

Every save appends a revision. No UPDATE or DELETE grants on this table for the runtime role — it is immutable history by construction.

FieldNotes
id, org_id, requirement_id
revision_numberint, sequential per requirement, starting at 1
title, bodymarkdown
notenullable — why this revision exists (e.g. “tightened rate-limit wording after verification findings”)
created_by, via_token_id, created_at

requirement_approvals — approval history

org_id, requirement_id, revision_id, approved_by, created_at. Append-only (no UPDATE/DELETE grant). Approving also sets requirements.approved_revision_id.

Connects epics to the requirements they implement, pinned to the revision that was approved at link time: org_id, issue_id, requirement_id, revision_id, created_by, created_at, deleted_at. revision_id is NOT NULL. Linking a requirement with no approved revision is rejected with an actionable error (approve it first — matches WORKFLOW.md Stage 3’s gate); the §5 rule-4 “latest if never approved” read fallback never applies to freezing a link.

Staleness is derived, never stored: a link whose revision_id is older than the requirement’s current approved_revision_id means the epic was sliced from a superseded version — the UI badges this (§8). Cross-cutting requirements are never linked and never pinned (§5 rule 6), so staleness applies only to explicit links.

content_drafts — project-scoped topic documents (Content & Assets module)

A first-class entity, a sibling of requirementsnot an issue type. A draft is raw material (a topic to publish) that gets promoted into work; it carries no environment, no status board, no claiming, and deliberately no approval gate and no lifecycle (kept lightweight — revisions simply accrue). Display ID = {project.key}-C{number} (e.g. DWB-C3).

FieldNotes
id, org_id, project_id
numberint (content counter — shares the display_counters table; its kind CHECK is widened to include 'content')
areanullable free-text product-area label — audited metadata (a peer of the body); changing it does NOT append a revision. Mirrors requirements.area.
authornullable free-text editorial byline the draft is credited to (e.g. sam) — a peer of area: draft-level, audited metadata, changing it does NOT append a revision. Indexed (project_id, author) and a list_content filter, so “never mix authors” is enforceable — not just a convention. Distinct from created_by (the authenticated actor that made the write): author is the chosen credit, client-supplied and never defaulted to the caller, which is why it is free text (more authors may join without a migration) and not an FK. null = unattributed.
metadatajsonb, default '{}' — an OPEN capture/provenance bag (tags, score, sensitivity, source refs) that the capture sink folds in. Stored passively and never acted on — the sensitivity/voice critics stay in the config plane, so nothing here gates a write.
created_by, created_at, updated_at, deleted_at

Title/summary/body live on revisions; the “current” title/body is the latest revision.

content_draft_revisions — append-only history

Every save appends a revision (update_content). No UPDATE/DELETE grant — immutable by construction, like requirement_revisions. Fields: id, org_id, draft_id, revision_number (sequential from 1), title, summary (nullable), body (markdown), note (nullable — the why), created_by, via_token_id, created_at. Title/summary/body carry forward from the latest revision when omitted.

content_deliverables — per-platform children of a draft

One deliverable per platform per draft (unique (draft_id, platform) on live rows). platformbsky | x | reddit | hn | blog (a CHECK; easy to extend). Post text lives on revisions. The “posted” fact is just three fields: posted_url, posted_at, and posted_revision_id (pins the revision that was posted) — set by record_post, the only mutable non-timestamp state in the module (an external world-fact, the deliverable analog of issues.claimed_by). No status machine. Rendered id = {draft-key}/{platform} (e.g. DWB-C3/bsky). No project_id — a grandchild routes through the draft, matching criteria. posted_revision_id is a nullable FK into content_deliverable_revisions (the deliverable↔revision cycle, mirroring requirements.approved_revision_id).

content_deliverable_revisions — append-only post-text history

Every update_deliverable appends a revision (id, org_id, deliverable_id, revision_number, body, note, created_by, via_token_id, created_at; unique (deliverable_id, revision_number)). No UPDATE/DELETE grant.

Copies issue_requirement_links in shape, pinned to the deliverable’s LATEST revision at link time: org_id, issue_id, deliverable_id, revision_id (NOT NULL), created_by, created_at, deleted_at. Content has no approval gate, so the pin basis is latest where the requirements link pins approved; the only link-time guard is cross-project (there is no “unapproved” rejection and no cross-cutting concept). Staleness is derived, never stored: pinned_revision_number < latest_revision_number means the post text was amended after the work was scoped — the UI badges the task (§8), reusing the requirements staleness badge.

assets — S3-backed files (Content & Assets module)

A project-scoped metadata row over a DigitalOcean Spaces object (the storage the §15 “Evidence / artifact entity” roadmap named). A peer module — issues, requirements, deliverables, and (later) chat messages all reference it; it is not content-specific. Fields: id, org_id, project_id, storage_key (the S3 key — internal, never on the wire), filename, content_type, size, sha256, status (pending | active | rejected), reject_reason (nullable), created_by, created_at, verified_at (nullable), deleted_at. Any file type. Integrity: SHA-256 read-back verification — the client computes the digest; the server verifies it by reading the uploaded object back and hashing (Spaces has no additional-checksum support), then copies the verified bytes to an immutable final key (copy-after-verify) before flipping the row active. A row needs a status machine (unlike content) because it exists before its bytes land. Upload/download are available over web, CLI, and MCP (upload_asset/download_asset) — all path-based: the bytes move directly to/from storage by a local file path and never cross a tool argument or result. That is the real invariant borrowed from the secrets reveal-plane split (it is about values, not the plane) — and it still holds: get_asset/list_assets return metadata only, and storage_key is never on the wire.

Modeled on issue_requirement_links but polymorphic: id, org_id, asset_id, target_type (a text CHECK), target_id (a bare uuid — no FK, since the target is polymorphic), created_by, created_at, deleted_at (unique (asset_id, target_type, target_id) on live rows). target_type is seeded with the wired + planned values (deliverable_revision, content_draft, issue, requirement, chat_message, run, criterion), but only deliverable_revision and content_draft are wired today — adding another is a new enum value + UI, not a migration off an array. App-level integrity: a link is only ever created via link_asset, which resolves the target first. Appending a deliverable revision carries its live asset links forward, so an amendment never orphans the post’s attachments.

status_history — append-only transition log

One row per status change, written in the same transaction as the transition, plus a row at creation (from_status = null). Powers the activity log (§8). Append-only like requirement history — no UPDATE grant. (The audit schema cannot serve this purpose: the runtime role deliberately has no access to it.)

FieldNotes
id, org_id, issue_id
from_statusnullable — null means creation
to_status
actor_user_id, via_token_idnullable (widget-created feedback has no actor)
created_at

comments

org_id, issue_id, body (markdown), author_user_id, via_token_id (nullable), created_at, updated_at, deleted_at.

api_tokens

id, org_id, user_id (owning operator), label, token_hash (SHA-256, unique), token_prefix (first 8 chars, for display), last_used_at, revoked_at, created_at. Token format and hashing per §3.

org_instructions — org-level agent instructions

One row per (org, scope). scope is the closed set general | requirements | content | work.epic | work.task | work.bug | work.feedback (mirrors the domain instructionScopeSchema; the work.* values track issue types 1:1). body is 1–4000 chars — over-cap writes are rejected, never truncated; the cap bounds context fan-out, since every scope’s text is composed into the guidance the MCP get_work_context read returns (§7). Columns: id, org_id, scope, body, created_by, via_token_id, timestamps, deleted_at; partial unique on live (org_id, scope). Clear = soft delete; a later set inserts a fresh live row. Plain audited config rows (the milestones shape) — no revisions table; edit history lives in audit. Writes are ADMIN-only on both auth planes — deliberately bearer-reachable so an admin-owned pbt_ token edits over MCP (an accepted risk: instruction text binds every future agent session with no approval gate); any member reads. No project_id: a pbt_ token is org-pinned and one MCP session serves every project in the org, so per-project text cannot ride startup-frozen session guidance — project overrides, if ever wanted, arrive additively with a project-aware validation layer.

audit

Per §11.


5. Requirements module semantics

  1. Project-scoped living documents. Requirements are organized per product area (e.g. “Status pages”, “Backup”) or per concern (vision, constraints, non-functionals), not per feature. Features link to the requirements they touch; a requirement is amended across the product’s whole life.
  2. Every save is a revision. update_requirement appends to requirement_revisions; nothing is edited in place. Old versions are always viewable.
  3. Approval pins a version. approve_requirement records an approval of a specific revision (default: latest) and sets approved_revision_id. Re-approval after amendments pins the newer revision; the approvals table keeps history.
  4. Reads default to the approved revision. Agents building against a requirement get the approved version unless they explicitly request another revision (or the pinned revision on an epic link).
  5. Links pin, badges warn. Epic↔requirement links freeze the revision the epic was sliced from. When a requirement’s approval advances past a link, the UI badges the epic. In chip rows the badge is the compact form r{current-approved} since (e.g. r4 since), sitting beside the pinned · r{n} link; in detail contexts the full phrase requirement updated since slicing is the badge/tooltip. Humans decide whether to re-slice, re-link, or ignore.
  6. Cross-cutting requirements bind everything. A requirement flagged cross_cutting (vision, legal/privacy constraints, platform conventions, non-functionals) applies to every epic in the project with no explicit links. Agent context (get_issue) automatically includes the project’s cross-cutting documents at their latest approved revision — deliberately unpinned, because project-wide constraints evolve and always apply in their current form. Feature links remain pinned. Handling of mid-flight changes — a cross-cutting re-approval that conflicts with an already-frozen contract or in-progress epic — is defined in WORKFLOW.md: the evaluator records the epic blocked (set_blocked, with the cross-cutting change as the reason) and comments the conflict, and the operator decides whether to amend the contract or defer the new rule.

6. Workflow semantics (fixed — no configurable workflows)

The status enum is the only workflow. Conventions live in WORKFLOW.md; the server enforces exactly these rules:

  1. Claiming is atomic. claim succeeds only if claimed_by IS NULL (single UPDATE … WHERE … RETURNING). Under concurrent claims, exactly one wins; the loser’s error names the current claimer. release clears it; explicit reassignment is allowed.
  2. verify requires every non-deleted criterion the issue carries with verification = 'auto' to be passed; an epic must additionally carry at least one non-deleted criterion. This is the agent handing off: its half of verification is done. What remains is whatever the issue’s manual criteria ask a person to judge, which for an issue carrying none is nothing. An issue whose criteria are all manual has no auto rows, so it moves freely — correctly, because there is nothing for the agent to verify — and so does a task or a bug carrying no criteria at all.
  3. done requires every non-deleted criterion the issue carries to be passed and every child issue to be terminal (done or killed), whatever the parent’s type; an epic must additionally carry at least one non-deleted criterion. A parent’s gate reads its children’s terminal status and never their criteria: a child’s own unpassed criterion holds that child open, and reaches the parent through the child’s status — the same fact arriving once rather than twice. The at-least-one check is the epic’s alone for two reasons: “every criterion passed” is trivially true over an empty set, so without it an epic that stated no contract walks past every gate; and below an epic most work is adequately described by its body, so demanding a checklist there would strand every task and bug already in flight. (A criterion added mid-verification blocks done until it passes — this is how discovery fold-in stays honest; see WORKFLOW.md.)
  4. Every status change writes status_history in the same transaction (§4).
  5. All other transitions are unrestricted, with one cross-type exception: a blocked issue (an active blocked mark, or at least one unresolved blocking issue — see §4) is refused on → ready and → in_progress, and the refusal names the mark’s reason and every unresolved blocker. ready otherwise carries no guard: it means “planned and specced, an agent can claim it.” Humans are trusted; agents follow the conventions in WORKFLOW.md. (In particular, “done epics never reopen” is a workflow convention, not a server guard — mistakes must remain correctable.)
  6. Attaching open work moves the parent to in_progress. Both acts of attachment count: creating an issue that names a parent, and editing an existing issue so its parent changes. When the arriving child is non-terminal, the parent is set to in_progress and the move is written to status_history (rule 4) attributed to the actor that attached the child, which is the whole record of it — nothing else is raised. The reason is that a parent with fresh open work under it is being built again, and one left at verify goes on asking a person to judge work that is not finished, and stays in the waiting queue (§7 list_waiting) while it does. The move changes only the parent’s status: it gates nothing and refuses nothing. Three cases leave the parent’s status untouched: a terminal parent, which already refuses a non-terminal child (§4) so that this rule can never reopen finished work; a parent that could not lawfully reach in_progress — blocked under rule 5, or carrying no milestone, which every status but triage and killed requires — which keeps its status and still takes the child, because a finding has to stay fileable against blocked and unscheduled work; and the completion review task open_review creates, which is filed under its epic with the move suppressed, since the review is the gate the epic is waiting at. The rule has no inverse: a parent whose children all reach terminal stays where the rule left it, and only a person moves it on. An edit that leaves parent unchanged is not an attachment and moves nothing.

Trust model (state it in code comments and in the UI): acceptance criteria are the only gate. Criterion states split by mode: auto states are agent-verified observables with recorded evidence (set by the evaluator per WORKFLOW.md, and server-limited to auto rows); manual states are human assertions that only a web session can set. What this does and does not prove: evidence records that the evaluator confirmed a behavior against the running system, which is a stronger claim than a test file being unaltered — but it is still agent-reported, and a manual criterion is the only assertion a human makes directly. Independent execution arrives with the V2 runner (§15). The human gate on finishing is the manual criteria, and nothing else — reserved_for_person (§4) holds work for a person before anyone starts, which is a different gate on a different act. The done transition is not part of it: the completion guard takes no actor at all, so it cannot inspect the credential even accidentally. Every manual row holds its issue open until a person works through it, and an issue carrying none does not wait at all — so a criterion’s mode decides how much of an operator’s attention the work costs.


7. MCP server

apps/mcp: a stdio MCP server for Claude Code; the same tool surface is also served over streamable HTTP at the API’s /mcp endpoint, for clients that cannot spawn a subprocess and connect by signing the person in instead (upload_asset/download_asset are the local-only exceptions — they move bytes by a path on the machine running the server). Env (stdio): PIPEBRAIN_API_URL, PIPEBRAIN_TOKEN (a pbt_ token) — missing either fails startup immediately with a clear error (§2 configuration rule). All calls go through the HTTP API — the MCP server holds no database access. Tool errors return actionable messages.

Every mutation tool returns the created/updated resource — at least its uuid, display id, and the fields the call changed, plus server-computed fields (e.g. set_criteria returns the resulting list with each row’s stable number) — so agents can chain calls without a follow-up get.

Wire parameter names are camelCase (requirementLinks, crossCutting, criterionNumber, claimedBy, …), matching §9’s wire-casing rule — the tables below spell some fields snake_case for readability only.

Every tool refuses a call carrying an argument name it does not recognise, and the refusal names that argument — matching the HTTP layer’s strict-schema posture, so a misspelled or extra argument is never silently dropped. A near miss of a real field name (Summary, affected_surfaces) is therefore a refusal rather than a silent loss of what was typed, and every advertised input schema carries additionalProperties: false, so a caller can read the contract instead of learning it from a refusal.

Org instructions compose into the guidance the work-context read returns. At server start the org’s instruction texts (§4 org_instructions) are fetched over the API — fail-open: an unreachable API boots the server with no organization text, never blocks or crashes it — and composed into ONE document: the environment-binding banner, then the product’s shipped doctrine, then every saved scope, each under a heading naming it (## Organization instructions — general, — requirements, — content, — work.epic, …). A scope with nothing saved contributes nothing, not even its heading, and no tool description carries any of it. The guidance is a per-session snapshot — an edit applies from the next session, never a running one. An org with no instructions gets a byte-identical base surface.

Three surfaces, because the session-start field is 2KB (decision record PIPE-D30). The composition above is roughly nineteen thousand characters and the agent client truncates the server-level instructions field at 2KB — silently, and where several servers are configured they share about 4KB, so 2KB is a ceiling rather than a grant. So the composition rides the get_work_context result, which is not bound by that cap and which declares its own ceiling in _meta["anthropic/maxResultSizeChars"] rather than inheriting an operator-set default; the instructions field carries a bounded index naming that call, the environment binding, and the few rules whose violation cannot be undone; and every other read carries one fixed pointer line naming where the binding guidance lives and which call returns it. The index is independent of the org’s saved text, so its bound holds for every org rather than for the one that was measured. get_work_context is additionally marked _meta["anthropic/alwaysLoad"], since tool search defers definitions until an agent searches and the one call a session should make first must not need a search step to be visible. Nothing compels the fetch — the product is a ledger and does not drive agents — so the index, the pointer, and the pointer line on each authoring tool’s description are three independent prompts, and whether the layering works is checked by a person rather than asserted by a test that cannot drive the real client.

The complete registered tool surface — organizations, projects and milestones, deliverable targets, the waiting queue (list_waiting, the org-wide read of everything not moving on its own; it takes no narrowing arguments and returns the whole queue, while the §8 web view narrows what it draws by project and category), the working-context pack (get_work_context — everything a session needs before it starts, in one read, addressed by a work item or by a project), issues, requirements, Conventions (CONV-{n} — standing guidance for the whole organization, approved by a person), Prompts (PROMPT-{n} — the organization’s reusable prompt library), Decision records (KEY-D{n} — one decision per record, superseded rather than edited, with no delete tool), content and deliverables, Assets (metadata plus path-based upload_asset/download_asset — the bytes move by local file path, never through a tool argument or result; see §12), Secrets (values inbound-only, never returned — the reveal plane is the web UI and the ppb CLI; see §12), and Org instructions:

ToolContract
list_orgsThe organizations this credential may act in — name, slug, id, kind (personal or shared) and your role in each. The one tool that needs no org: every other call names the organization it acts in, and omitting it is refused unless the session has a default. This is how you find the names you may pass. Read-only.
list_projectsEvery project: key, environments, milestones (an archived one leaves the pickers and stays valid on issues naming it), deliverable targets and open-issue counts. Pages with limit (default 200, max 500) and offset.
list_waitingEverything not moving on its own across all projects in the organization, longest wait first: a requirement revision awaiting approval, an unanswered question, and work at verify holding a criterion that has not passed. Each entry names its project, the ref to act on and when the wait began. Read-only — it clears nothing. A revision clears with approve_requirement, a question with answer_question, an auto criterion with set_criterion_state; a criterion marked for human judgment waits for a person.
get_work_contextEverything a session needs before it starts work, and the only call that returns the doctrine and the organization’s writing rules in full. Address it by issue or project, never both. An issue returns its detail plus the text of every requirement it links, with comments capped at commentLimit (default 20) beside the true commentCount; a project returns available — startable work, excluding done, killed, blocked, person-reserved and other people’s claims — capped at availableLimit beside the true availableCount. Naming session also drops the work the caller’s own other sessions hold. Read-only.
create_projectCreate a project; nothing can be filed until one exists. name is the display label, and key is 2–5 uppercase letters (e.g. DWB), unique per organization, prefixing every display id.
create_milestoneCreate a milestone. token is the ref issues point at (e.g. M1) and is unique per project, so a duplicate is rejected; label names it and position orders it, last by default. active: true demotes whichever was active.
update_milestoneRename, reorder, archive or restore a milestone, by token or uuid; supply at least one of token, label, position or archived, since an empty edit is rejected. Renaming a token does not rewrite the issues carrying it, so prefer label. The active milestone cannot be archived (milestone_active), and an archived one cannot be activated (milestone_archived).
set_active_milestoneMake a milestone its project’s active one, by token or uuid. At most one per project is active, so this clears the previous in the same transaction.
delete_milestoneSoft-delete a milestone by token or uuid; the token frees up for reuse. Issues naming it are not rewritten and keep a dangling token, so re-point them first.
create_deliverable_targetAdd a platform a deliverable can target. slug is the immutable id it stores (e.g. bsky), unique per project; label names it, position orders it, and active (default true) gates new deliverables.
update_deliverable_targetUpdate a deliverable target by slug or uuid; supply at least one of label, position or active, since an empty edit is rejected. The slug is immutable, and active: false hides it from new deliverables.
delete_deliverable_targetDelete a deliverable target by slug or uuid. Only one with no deliverables can be deleted; a target in use is rejected — deactivate it with update_deliverable_target instead.
list_issuesIssue summaries — status, priority, milestone, claimer, criteria progress and the header — filtered by project, status, type, claimer, priority, milestone, text, parent, blocked, needsHeader and session (which drops work another session holds). With no status filter the default is buildable work only: triage, backlog, ready, in_progress, verify. Terminal work is excluded until you name it, and an explicit filter is used verbatim, never merged. needsHeader: true returns the issues whose header is missing or stale.
get_issueFull issue detail by key or uuid: header, body, criteria, requirement links, cross-cutting requirements, approved conventions, children, comments, and placement — the branch, worktree and base resolved through the parent chain to the project’s defaults, each naming which level supplied it. terminal is true when the issue is done or killed; projectArchived is true when the project is retired, and this read still answers while lists and writes do not; headerStale is true when no header was written or the body has moved since. Long records page with bodyOffset and commentOffset, and omitted and next say what was left out.
create_issueCreate an epic, task, bug or feedback. Status defaults to triage, and anything but triage or killed needs a milestone or the create is refused milestone_required. Naming an archived milestone is refused milestone_archived_write at any status, triage included — the retired release is the fault, not the status, so this guard fires wherever a milestone is supplied. An open issue under a done or killed parent is refused parent_terminal. Epics, tasks and bugs carry criteria; feedback is refused criteria_not_supported, and a manual criterion with no howTo is refused how_to_required. Naming a parent is an attachment, under the rule on update_issue.
promote_feedbackRetype a feedback report as real work — an epic or a task — so it can carry criteria and be built. Scheduling that new work into an archived milestone is refused milestone_archived_write — the guard fires on the issue this call creates, not on the report it retypes. Closing the source feedback is refused status_guard when anything beneath it, at any depth, is not done or killed, and the refusal names it. A manual criterion with no howTo is refused how_to_required.
update_issueChange an issue’s fields, parent, links and placement; header fields are refused here, and criteria are set with set_criteria. branch, worktree and base record where the work happens, and null clears one so the issue inherits again. Moving an open issue onto a done or killed parent is refused parent_terminal, and moving a terminal one that still has open work beneath it is refused parent_terminal_subtree — the check reads its whole subtree, at any depth. A re-parent that would close a cycle is refused parent_cycle, and that holds when two callers race. Scheduling the issue into an archived milestone is refused milestone_archived_write, with two exemptions this call has and create_issue does not. Re-sending the milestone the issue already carries is not a move, so the guard is not consulted at all; and a token the issue’s own parent already carries is a lend rather than a schedule, so it is allowed — that is how a parent hands its retired release to an unscheduled child so the child can close and let the parent close. The parent read is the one the edit leaves the issue with, so a move and a lend arriving in the same call are judged against the new parent. Nothing is lent to an issue that does not exist yet: a child may keep a parent’s archived release on an edit, but may never be created into one. Attaching open work starts its new parent, except where the issue is already terminal, the parent already has it, the parent is blocked or carries no milestone, or the parent’s own parent is finished.
set_issue_headerWrite an issue’s header — summary (the change in one sentence), why (the user, business or cost reason, not the technical one) and affectedSurfaces (what this touches and who feels it). All three are required and replace the header whole, in plain words: a summary shaped like code is refused, and summary caps at 350 characters. Create and update refuse these fields.
open_reviewOpen the completion review of an epic’s whole change, read by a session that neither built nor evaluated it: one task at ready carrying the review checklist. Pass exactly one of epic, or project plus milestone. A first call is refused review_target_not_epic when the target is a task or a bug, review_target_terminal when the epic is already done or killed, milestone_required when the epic carries no milestone, and milestone_not_found when the milestone form names a token the project does not define. Both forms are exempt from the archived-release guard — a review reads a release rather than scheduling work into it. One review stands per scope, and a second is refused review_already_open, naming it. An epic-form review also covers that project and milestone, so a later milestone-form open is refused; a milestone-form review blocks no epic. File findings as children of the epic, not of the review.
set_statusMove an issue to a new status. Checked ahead of every gate below: moving to an open status while the parent is done or killed is refused parent_terminal_reopen, which binds the TARGET status rather than the transition, so a triage child under a killed parent cannot be started either. verify needs every auto criterion passed; done needs every criterion passed, and every issue beneath it, at any depth, done or killed — epic, task or bug alike — read from status. An epic with no criteria is refused at both gates, while a task or a bug with none moves through. A blocked issue cannot reach ready or in_progress until its blockers clear, and an issue with no milestone can only be triage or killed.
claim_issueClaim an issue for this session, and reserve where the work happens. It succeeds only if unclaimed, so under a race one caller wins and the loser is told who holds it; claiming is separate from status. branch and worktree are refused placement_conflict when another live claim in the project holds either, and the refusal names that issue; a field the call omits keeps whatever the issue already recorded. session records which agent session made the claim. It is refused reserved_for_person when an operator has reserved the work for a person — that mark refuses the claim and nothing else, and only a person in the browser can lift it.
release_issueRelease your claim on an issue, leaving it unclaimed and its status unchanged. The session identifier is cleared with the claim; the branch and worktree are kept, so they stop being a reservation and go on saying where the work last happened.
add_commentAdd a markdown comment to an issue, attributed to the operator and token label. A comment explains a blockage; set_blocked and link_blocker are what record one.
set_blockedMark an issue blocked with a required reason — state on the issue, not a comment, and only clear_blocked ends it. Setting it again replaces the reason and keeps the old one as history; what you tried goes in a comment.
clear_blockedClear an issue’s blocked mark. Clearing one that is not blocked is a no-op rather than an error, the mark stays as history, and blocked on the returned issue is null.
link_blockerRecord that issue is waiting on blocker: one call writes both sides, and both must be in the same project. An issue cannot block itself, and an edge that would close a cycle is refused with nothing recorded.
unlink_blockerRemove the blocked-by edge between issue and blocker; one call clears it from both sides. Unlinking a pair that is not linked is a no-op, not an error.
ask_questionsAsk the operator one or more questions on an issue, sent as one batch. Each gets a stable Q-{n} number that is never reused, and options are the choices you propose. Answers come back on every later read of the issue, so read them before asking again. answer_question records one your own work settled; withdraw_question is for one that should never have been asked.
withdraw_questionWithdraw a question by its Q-{n} number. Only the asker may withdraw, so another agent’s question is not yours to clear. The question is kept for the audit trail, its number retires, and later reads no longer carry it.
answer_questionAnswer a question by its Q-{n} number with exactly one of option — a 1-based index into the choices the question offers — or text in your own words; both together, or neither, is refused by the tool itself before any request is sent. Answering again revises, and every answer records who answered and the credential behind it. An index outside the offered choices is refused option_out_of_range, which names how many there are, and a withdrawn or unknown number is refused question_not_found.
set_criteriaDefine or replace the acceptance criteria on an issue — a criterion is one sentence: a concrete situation and what must be true afterward. Epics, tasks and bugs all carry criteria; feedback is refused criteria_not_supported, so promote it first. Matched rows keep their AC-{n} number, and changing text or mode resets state. A manual criterion needs howTo, else refused how_to_required; howTo on an auto criterion is refused by the schema.
set_criterion_stateSet a criterion’s state — pending, passed or failed — on an epic, a task or a bug alike. Address it by criterionId alone, or by issue plus criterionNumber; exactly one form. A token is refused on a manual criterion, which only a person in the browser may set, and a token marking passed or failed must supply evidence.
list_requirementsRequirements filtered by project and text: lifecycle (draft, approved, amended), the latest and approved revision numbers, the cross-cutting flag and the linked-epic count.
get_requirementA requirement by display id or uuid: the approved revision by default, the latest when none is approved, or the revision you name, with lifecycle, approvals and linked issues.
create_requirementCreate a project-scoped requirement at revision 1 from title and body. summary is a short outline versioned with them and area is a free-text product-area label; the cross-cutting flag binds every epic in the project on its own, so never link one explicitly.
update_requirementUpdate a requirement. title, body and summary append a new revision — nothing is edited in place — and note records why; area, cross-cutting and decision links are metadata, with no revision. Turning cross-cutting on is rejected while explicit links exist, so unlink first, and an empty edit is refused (400).
approve_requirementApprove a requirement’s latest revision, or the revision you name. It enters the approval history and becomes the approved revision that reads and links build against.
list_conventionsThis organization’s conventions — standing guidance binding every project here: display id (CONV-{n}), title, the standing text, lifecycle and revision numbers. Retired ones are included and marked, and text matches title and body. Only approved, unretired conventions are delivered to agents.
get_conventionOne convention by display id (CONV-{n}) or uuid: its approved text, the revision and approval history, and what replaced it once retired. One never approved reads here and binds nobody.
create_conventionWrite a convention: standing guidance for the whole organization, with no project attached. Pass outlivesAnyProject: true or the write is refused, and text naming one project is refused convention_names_project_scope — file that as a cross-cutting requirement instead. It is a draft that binds nobody until a person approves it in a browser.
update_conventionAmend a convention. title and body append a new revision — nothing is edited in place — and note records why; supplying neither is an empty edit (400). Amended text naming one project is refused as on create, and the amendment binds nobody until a person approves it.
list_promptsThe organization’s prompt library (PROMPT-{n}): display id, title, current text, latest revision and any attached project. With no filter this returns the whole library; project narrows it to the ones attached there — an attachment says what a prompt is about, not who may run it — and text matches title and body.
get_promptOne prompt by display id (PROMPT-{n}) or uuid: its current text, which is simply the latest revision, plus every earlier one with who saved it, when and why.
create_promptWrite a prompt: reusable text held by the organization (PROMPT-{n}). body is the prompt and must not be empty, title names it, note says why, and project records what the prompt is about, not who may use it. Nothing approves or deletes a prompt; later wording is a new revision.
update_promptAmend a prompt. title and body append a new revision — nothing is edited in place — and note records why; project moves the attachment and project: null detaches it, neither writing a revision. Naming none of the three is an empty edit (400).
list_decisionsDecision records filtered by project and text: each one’s state, its replacement when superseded, and how many requirements and work items cite it. text matches title and body.
get_decisionA decision record by display id (KEY-D{n}) or uuid: its text, its state (proposed, accepted or superseded), who accepted it, what replaced it, and everything citing it.
create_decisionRecord one decision as a project-scoped record (KEY-D{n}): title names it, body says what was decided and why. It starts proposed and is frozen once accepted, so changing it then means a new record plus supersede_decision.
update_decisionCorrect a decision record while it is still proposed: title, body, or both. An edit naming nothing to change is refused (400). Once a record is accepted or superseded its text is frozen and the correction is refused (422 decision_frozen), which says to write the change as a new record and supersede this one.
accept_decisionAccept a decision record that is still proposed: it becomes accepted, stamped with who accepted it and when, and its text is frozen from then on. The tool names the move, so the record is the only argument; a record that is already accepted or superseded is refused (422 decision_transition_not_allowed).
supersede_decisionReplace an accepted decision record with another: decision becomes superseded and points at replacedBy, where the current decision lives. Refused while the record is still proposed (correct it instead), when a record replaces itself, when the replacement is in another project, or when its chain already leads back to this record.
list_contentContent drafts (KEY-C{n}) filtered by project, text, area and author. Content is a project-scoped sibling of requirements, not an issue type.
get_contentA content draft by display id (KEY-C{n}) or uuid: its revisions, latest or the revision you name, plus every deliverable, linked issue and linked asset.
create_contentCreate a project-scoped content draft at revision 1 from title and body. summary is versioned with them, area is a free-text label, author is the byline credited on the draft rather than the operator calling, and metadata is stored passively.
update_contentUpdate a content draft. title, body or summary appends a revision and note says why; area, author and metadata are metadata only. An empty edit is refused (400).
create_deliverableAdd a per-platform deliverable to a content draft at revision 1, addressed DRAFT-KEY/platform; body seeds it. platform must be one of the project’s active deliverable targets (see list_projects), and any other slug is rejected. One deliverable per platform per draft — another wording is a revision.
update_deliverableAppend a revision of the post text to a deliverable, addressed DRAFT-KEY/platform; note says why. The platform itself is immutable.
record_postRecord that a deliverable (DRAFT-KEY/platform) was posted: its public url and postedAt (now by default), pinning its current revision as posted. Pipebrain never posts; a person does, then records it here.
promote_deliverableCreate a new issue from a deliverable (DRAFT-KEY/platform) with a protected link pinning its current revision, which cannot be removed. Needs title and type; status defaults to triage and body seeds the issue, and a status past triage or killed needs a milestone or it is refused milestone_required. Naming an archived milestone is refused milestone_archived_write at any status, triage included, since that guard reads the release rather than the status.
list_assetsProject assets filtered by status (pending, active, rejected), category (image, video, document) and filename text, sorted by date or size. No bytes cross this result.
get_assetAn asset’s metadata by uuid: filename, contentType, size, sha256, status and linkCount. No bytes and no URL come back — use download_asset or ppb assets download.
upload_assetUpload a local file as a project asset and activate it. Only the path crosses the wire, never base64 or file contents, and contentType is guessed from the extension when omitted. Returns the asset metadata; link_asset then attaches it to a draft or a deliverable revision.
download_assetWrite an active asset’s bytes to a local file at path, created or overwritten, so you can read an uploaded image or document. Addressed by asset uuid; the bytes are checked against the recorded sha256, and the result is metadata and the path.
link_assetReference an active asset from a deliverable_revision or a content_draft, whose links copy onto later deliverables. A non-active asset or a cross-project link is rejected.
unlink_assetRemove an asset reference by its link id; this drops the reference only, and the asset itself is untouched. A link on a chat message is author-only — anyone else is refused not_message_author — while any member can remove every other target type.
set_secretCreate a secret or add a new version under a project and environment. Do not send a sensitive value here: it lands in the chat transcript before the server can encrypt it, so use ppb secrets set from stdin or --from-file. name is UPPER_SNAKE_CASE and environment is a slug (dev/prod).
list_secretsA project’s secrets: id, name, environment, description, current version and timestamps. No value comes back, and an optional environment slug narrows it.
describe_secretOne secret’s metadata plus each version — when, by whom, and whether it was crypto-shredded. Nothing here returns a value.
rotate_secretAdd a new version to an existing secret and advance the pointer. The value rules are set_secret’s: a value crosses inbound only and nothing here returns one, so set a real credential with ppb secrets set, from a file or stdin.
update_secret_descriptionChange a secret’s description — metadata only: no new version, and nothing here returns a value.
delete_secretDelete a secret: soft-delete the entry and crypto-shred every version, so the value is unrecoverable. Returns confirmation metadata only.
list_org_instructionsThis organization’s agent instructions, one per scope — the texts composed into the guidance get_work_context returns. What is saved here may be newer than what your session composed at startup.
set_org_instructionSet one scope’s organization-level agent instruction, up to 4000 characters; over that is refused, not truncated. The scopes are general, requirements, content and work.<type>. It writes the organization the call acts in and needs the ADMIN role there, so a contributor is refused; new sessions pick it up, and running ones keep what they started with.
delete_org_instructionClear one scope’s organization-level agent instruction — that organization’s standing instruction to every agent working there. Clearing a scope that is already unset is an error naming it, and it needs the ADMIN role there, so a contributor is refused.

Deliverable promotion: create_issue and update_issue also accept deliverable_links (add/remove), mirroring requirement_links and pinning the deliverable’s current revision — the removable counterpart to the protected link promote_deliverable writes.


8. Web UI

Design intent: simple, fast, markdown-first — and everything about a feature on one screen. Explicitly out of scope: dashboards, time-series charts, drag-and-drop, command palettes, configurable views.

Design language (dark-first — match the reference mock in the repository)

Navigation: URLs carry the org and the project — /:orgSlug/:projectKey/…. The org segment resolves the active org (and points the shared API client’s X-Org-Id header at it, §9) before any child query runs; the org home lands a projectless visit on the org’s waiting queue (/:orgSlug/waiting), never inside whichever project was open last. Within an org, everything but that queue is scoped to the currently-selected project, chosen via a project switcher (backed by list_projects) at the top of the sidebar; the queue itself reads no project, but renders in the shell with the org’s remembered (else first) project selected — the same resolution the old landing redirect used — so landing there never starts a session at a dead project nav. Within that project: the Work page, plus Requirements, Content, Assets, Secrets, and Log sections. Pinned to the bottom of the sidebar, a utility cluster: Help · Feedback · Log · Settings. Feedback is a control, not a destination — it opens the shipped widget’s own panel in place (§10), so it is a button rather than a link with a URL to middle-click. Because the shell always offers that row, the widget’s floating button is suppressed for as long as the shell is mounted and restored when it is not: the signed-out pages (login, invite, forgot/reset password, verify-email) and /help have no Feedback row of their own, and the float is the only way in there.


9. HTTP API

Fastify under /api/v1, Zod-validated bodies, queries, and responses. Auth: session cookie or Authorization: Bearer pbt_…. Per-request org binding: a caller binds the org it acts in per request via an X-Org-Id header (or, on a GET, an ?org= query param for browser navigations that cannot set a header), so the request’s outbound scope and its cache key derive from the same org id — a mutable “current org” singleton would let one tab’s switch bleed into another tab’s request, and per-request binding closes that. This is one rule for every credential (PIPE-R7 rule 5): a bearer (pbt_) caller and a chat-client connection resolve the header exactly as a session caller does, and the caller’s role comes from their membership in the NAMED org, not from the credential’s row. Naming an org the credential’s holder is not a live member of is refused (403) before any org scope is set. A call that names none is refused (403 org_required) when the caller presents a bearer token or a chat-client connection, and the refusal names the organizations that caller could have named; no credential carries an organization of its own to fall back to. The routes whose purpose is to discover which organizations a caller may name are the exception, and answer without a scope. A session caller still falls back, through default_org_id → personal org → any membership. Resources mirror §4 and §7: issues (with nested comments / claim / status / criteria), requirements (with revisions / approvals / links), content (drafts / deliverables / posts), assets, projects (with environments, milestones, read-only status history for the activity log, and the project-nested secrets routes — /api/v1/projects/:key/secrets), orgs, org membership (members / invitations), org instructions, notifications (the caller’s own registered devices and per-event preferences), tokens, auth. Public and unauthenticated: GET /api/healthz (checks DB connectivity with SELECT 1) and the feedback intake (§10).

Wire casing: JSON on the wire is camelCase everywhere (matching §10’s projectKey/pageUrl/appVersion and TS idiom); database columns stay snake_case; the Zod schemas define the camelCase wire shape.

List endpoints accept limit (default 200, max 500) and offset, and return { items: [...] }.

Web-session-only routes: token management (mint / list / revoke) and password change reject bearer auth — a pbt_ token must not mint or revoke tokens. (This sits alongside the manual-criteria web-only rule of §4.)

The web app consumes this same API — no private endpoints for the UI (UI-is-contract).

Reserved, documented, not built: /api/v1/jobs/* — runner claim/lease/heartbeat/complete (§15). Design module boundaries so adding it later touches nothing in the issues or requirements modules.


10. Feedback widget

The wire contract is the product; clients are thin.

Intake endpoint — POST /api/v1/feedback (unauthenticated)

Web snippet (V1 deliverable)

A single widget.js — vanilla JS, zero dependencies, ≤ 10 KB, served from the app — configured by data attributes:

<script src="https://app.pipebrain.dev/widget.js" data-key="pk_…" data-environment="prod" defer></script>

Floating button → small panel (message textarea + optional email) → POST → thank-you → reset. Graceful failure on network errors and 429. Basic accessibility (labels, focus trap, Esc closes). No screenshots in V1.

The floating button is the default shape, not the only one. data-launcher="none" on the script tag suppresses it — the launcher is still mounted, just hidden — leaving the host page to open the panel on its own terms. Absent the attribute (every embed written before this existed), the button appears exactly as before. The widget publishes a single global, window.pipebrainFeedback, carrying open, close, showLauncher, hideLauncher, and submit({ message, email?, pageUrl? }) — and nothing else — then announces itself by dispatching a pipebrain:feedback-ready event on window. The event is load-bearing: the script is deferred, so the host page’s own code may run either before or after it, and without the announcement whichever ran second would win by accident. A page that finds the global already present uses it immediately and also listens; both orders work. submit shares one post path with the panel’s own form — same frozen body, no extra fields — resolves only on an ok response, rejects on an empty message, a non-ok response, or a network failure, and never touches the panel UI. With no floating button there is nothing to anchor to, so open() centres the panel over a dimmed scrim (click to close); with the launcher present it stays anchored to the launcher’s corner — the bottom right, unless the embed asked for another.

Which corner that is comes from data-corner on the script tag, one of exactly four values: bottom-right (the default), bottom-left, top-right, top-left. The raw attribute is trimmed and then matched exactly, as data-key, data-environment and data-app-version are read (data-launcher is the exception — it compares untrimmed), and it is not lowercased. Anything else — absent, empty, whitespace, a typo, a different case — resolves to bottom-right silently, with nothing written to the console: the widget runs on somebody else’s page, where a mistyped attribute should cost the chosen corner and leave a working feedback button. The corners are physical viewport corners and do not follow the page’s writing direction. The launcher and the panel opened from it share one corner; with data-launcher="none" the panel still centres over the scrim, because a centred panel has no corner to sit in. The corner is read once at mount and is not part of the public surface — window.pipebrainFeedback gains no method and no argument for it, and the five methods above remain the whole of it.

React Native client

Post-V1. It targets the endpoint contract above, unchanged.


11. Data & security invariants (non-negotiable)

  1. Soft deletes only. Every tenant table has deleted_at; the runtime role has no DELETE grant; all reads exclude soft-deleted rows. Exception by design: requirement_revisions, requirement_approvals, and status_history are append-only history — the runtime role additionally has no UPDATE grant on them.
  2. Audit via DB triggers. An audit.log table in a separate schema records insert/update/soft-delete on tenant tables with the row diff and the actor. The actor is read from two transaction-local GUCs set alongside app.org_id: set_config('app.actor_id', …) (the acting user, written to actor_user_id) and set_config('app.token_id', …) (the API token, null for web sessions, written to actor_token_id) — so the audit row carries both, distinguishing “an operator in the browser” from “their agent over MCP” per §3. The runtime role has no privileges on the audit schema. The trigger functions are therefore SECURITY DEFINER, owned by the migration role, with a pinned search_path (SET search_path = '', fully-qualifying every reference) so the invoker’s lack of audit privileges does not block the insert and the standard escalation footgun is closed.
  3. RLS from migration 0001. Every tenant table: org_id NOT NULL, RLS enabled and forced, policies scoped to current_setting('app.org_id'), set per transaction from the session/token. The runtime role is non-superuser, non-BYPASSRLS; the API refuses to boot if it detects a privileged database login, and additionally verifies at boot that the runtime role owns no tenant table (ownership implicitly confers DELETE/UPDATE regardless of grants). Tenant tables (and the audit schema, functions, and sequences) are owned by the migration role, never the runtime role — runtime ownership would nullify the grant-based soft-delete and append-only invariants.
  4. Zod-first, named exports. If money ever appears, integer minor units (it should not in V1).
  5. Migrations are forward-only. Production changes are new migrations, never edits to applied ones.
  6. Secrets live only in App Platform encrypted env vars and local gitignored config. .do/app.yaml is committed as a placeholder template only.

12. Infrastructure & deployment

Topology: one DO App Platform app with three components — web (static site: SPA build + widget.js), api (Dockerfile service, routed at /api, pinned to exactly one instance — the in-memory rate limiter (§10) keys per-process, so horizontal scaling would silently multiply the effective limits; §2 excludes a shared store (Redis), making single-instance an enforced V1 constraint), migrate (PRE_DEPLOY job running dbmate up). The migrate job runs schema migrations only, connecting as the migration role via a separate privileged DATABASE_URL_ADMIN env var scoped to that job and never available to the api service (so the §11.3 boot guard never sees a privileged login). Same-origin cookies by construction. deploy_on_push from main; a deploy goes live only if migrations and the GET /api/healthz check pass. The app serves at its custom domain, app.pipebrain.dev.

Database — a logical database on a shared managed cluster. Pipebrain does not get its own cluster: an operator provisions, once and before the first deploy, a dedicated logical database, a non-privileged runtime login that may connect to that database and to no other on the cluster, and a transaction-mode connection pool sized to leave the cluster headroom for migrations and admin scripts. The runtime role’s grants arrive with the migrations, so §11’s RLS rules govern it rather than hand configuration, and the api reaches it over the pool while migrations and one-time admin scripts use the direct connection. Which cluster, in which region, at which size, on which ports, and under which pool and role names, are operational facts rather than requirements: they belong to the deploy specification and to the team’s internal cluster note, which change on their own schedule and are verifiable against the running system.

Local development: docker compose Postgres 17. Every local port (Postgres, api, web) comes from .env — docker compose reads it too; nothing is hardcoded. The operator machines run several projects’ databases and dev servers concurrently, so .env.example ships collision-avoiding suggestions (e.g. Postgres 5456, not 5432) and each machine adjusts locally. Dev servers bind 0.0.0.0 so the app is reachable from other devices on the LAN; the web dev server proxies /api to the api process, so session cookies stay same-origin from any device. npm run setup = compose up + dbmate up + seed (org, two operators, one demo project with cross-cutting + area requirements, a feature epic, slices with criteria, and a milestone). npm run dev = api + web. npm run check = typecheck + lint + tests + build (integration tests read the same .env). No cloud dependencies locally.

Production bootstrap: the migrate PRE_DEPLOY job runs schema migrations only — it seeds no rows. A documented one-time script, npm run bootstrap:prod, is run manually against the direct (non-pooled) admin connection after the first deploy’s migrations succeed; it creates the org and the two operators from gitignored config (values sourced from App Platform secrets in prod), and nothing else — no demo project or sample content. Without it a fresh prod database has no org row (RLS requires one) and no operators (login requires them).

Env vars & secrets (per component):

ComponentVarSecret?Notes
apiDATABASE_URLsecretthe pooled runtime login
apiDATABASE_CA_CERTsecretthe cluster CA as a base64-encoded PEM, decoded to a UTF-8 string before it is handed to the driver, for verify-full TLS
apiBETTER_AUTH_SECRETsecretstable random ≥32 bytes; regenerating invalidates all sessions
apiRATE_LIMIT_IP_PER_MIN, RATE_LIMIT_IP_PER_HOUR, RATE_LIMIT_KEY_PER_DAYnothe §10 limits — .env.example recommends 10 / 100 / 1,000 (no in-code default, per §2)
apiAPI_HOST, API_PORTnobind address + port; local dev uses 0.0.0.0 + an .env port, App Platform supplies its own
migrateDATABASE_URL_ADMINsecretthe privileged migration role on the direct (non-pooled) connection; never set on api
MCP client (product repo)PIPEBRAIN_API_URLnothe deployed API base URL
MCP client (product repo)PIPEBRAIN_TOKENsecreta pbt_ token
localPOSTGRES_PORT, WEB_PORTnodocker compose + web dev-server ports, from .env
localgitignored .env + seed config (operator names/emails/initial passwords)secretdev only; prod bootstrap reads the same fields from App Platform secrets

Every variable above appears in the committed .env.example; none has an in-code default — a process missing any required var refuses to start and names it (§2).

CI: GitHub Actions runs npm run check on pull requests and pushes to main, required before merge; deploy_on_push remains the deploy trigger. npm run check’s integration tests need Docker (a local Postgres 17 service container mirroring the compose setup), so CI provisions one.


13. Testing requirements (the build-era record)

This section and §14 record what V1 was built and gated against, and they are kept for that reason: forty-odd code and test files cite their items by number. New work is governed by the tracker’s requirement documents and by the acceptance criteria on the work itself — an epic, a task or a bug — not by this list.

Jest. Integration tests run against local Postgres and are the proof for §14. Minimum coverage:


14. V1 acceptance criteria (the build-era record)

Item numbers are stable and never reused — the same rule this document sets for AC-{n} in item 12, and for the same reason: roughly forty code and test files cite these items by number (§14.26), so renumbering silently repoints every one of them at a different claim. Items 14–16 are retired: they specified acceptance suites and run history, removed in rev 11. The gap is deliberate.

Projects & environments

  1. An operator can create a project (name, key); dev and prod environments are seeded automatically and default_environment_id is set to prod; environments can be added, renamed, and deleted (delete refused when the environment has issues); milestones can be created, renamed (token + label), reordered, set active (at most one active per project), and archived and restored — an archived milestone stays listed and keeps its token, label and position, disappears from the everyday pickers except where it is the value already chosen, and still accepts a completion review; archiving the active milestone and activating an archived one are refused.
  2. Issue display IDs are KEY-N and requirement display IDs are KEY-R{N}, per-project, increasing; 50 concurrent creations of each kind produce no duplicate numbers (integration test).

Issues 3. Issues of all four types can be created and edited with markdown bodies; environment is required; parent and milestone are optional. Omitted status defaults to triage; omitted environment resolves explicit → parent’s environment → project default_environment_id. Changing type to feedback is rejected while the issue has criteria, and epic/task/bug retypes are permitted; a self-parent or a parent cycle is rejected. Attaching open work to a parent — at creation, or by changing an existing issue’s parent — moves that parent to in_progress and records the move against whoever attached the child; a terminal parent refuses the attachment instead, a blocked or milestone-less parent takes the child and keeps its status, and the completion review task is filed without moving anything. 4. Filters work: project, environment, status, type, claimed_by, priority, milestone, and text search over title + body. 5. Status transitions follow the enum; the criteria guards are enforced on whatever work carries criteria (ready is unguarded; verify needs every auto criterion passed; done needs every criterion passed — including criteria added mid-verification; both verify and done additionally refuse an EPIC with no criteria at all, a refusal that binds an epic alone). 6. Claiming is atomic: concurrent claims by both operators yield exactly one winner (integration test); release works; the claimer is visible in lists. 7. Soft delete: deleted rows vanish from lists and API responses but remain in the database; the runtime role has no DELETE grant (test).

Requirements 8. Requirements can be created, listed, and read per project; every edit appends a revision; an optional revision note is persisted and shown in history; any historical revision is viewable; direct UPDATE of a revision row is rejected at the database level. 9. approve_requirement pins a revision; lifecycle badges derive correctly (draftapprovedamended after a post-approval edit → approved again after re-approval); approvals history is retained. 10. Linking an epic to a requirement pins the approved revision; when the requirement’s approval later advances, the epic shows a staleness indicator (API-derived, UI-visible); linking to a requirement with no approved revision is rejected with an actionable error. 11. A requirement can be flagged cross_cutting; get_issue on any epic in that project includes it at the latest approved revision (re-approvals reflected immediately, no staleness shown); non-flagged requirements appear only via explicit links. Flagging cross_cutting is rejected while the requirement has explicit links; un-flagging is always allowed.

Acceptance criteria 12. Criteria can be authored on an epic, a task, or a bug (ordered, each auto or manual), edited, reordered, and soft-deleted, and are refused on a feedback issue; each carries a stable per-issue AC-{n} number preserved across reorder and set_criteria replacement and never reused; set_criterion_state resolves by uuid or by issue + number; states (pending/passed/failed) carry attribution (who, when, via which token) and evidence; a token cannot set state on a manual criterion (rejected with a clear error), while auto criteria accept token or session. 13. An issue of any kind with any non-passed criterion cannot be set to done, and one with any non-passed auto criterion cannot be set to verify; each guard error names the unmet criteria by AC-{n}. An epic with zero criteria is refused at both; a task or a bug with zero criteria is not.

Comments 17. Markdown comments render correctly with attribution (operator, plus token label when submitted via MCP).

Auth & tokens 18. Seeded operators and invited members can log in; there is no open signup surface — account creation happens only through a valid org invitation (invite-gated, per §3). 19. Tokens: mint (plaintext shown once), label, revoke; the API rejects revoked tokens; MCP authenticates with a token; actions attribute to the owning operator and record the token.

Widget 20. The snippet, embedded on an external origin in the CORS allowlist, submits successfully; a disallowed origin is rejected. 21. A submission creates a feedback issue in triage with metadata captured under system (server-captured) and client (submitted) namespaces; rate limits are enforced per IP and per key (integration test); a payload over 16 KB is rejected.

Security invariants 22. RLS: a second seeded org cannot read or write the first org’s rows through the API or as the runtime role via direct SQL (integration test). 23. Audit rows are produced for insert/update/soft-delete on issues, requirements, and criteria, with the actor recorded; the runtime role cannot read the audit schema. 24. The API refuses to boot when the database login is superuser or BYPASSRLS, or when the runtime role owns any tenant table.

Web 25. The Work page (Queue, Board, and Overview consolidated) shows the verify work with any non-passed (pending/failed) manual criteria and their counts, blocked work (open issues with an active blocked mark or an unresolved blocking issue — the union, each issue counted once, carrying the mark’s reason where there is one), the triage count, and the active-milestone summary (active row’s token, label, done/total progress), all derived live; navigation project → environment → status works; the Requirements section lists and renders documents with revision history, approval, and the cross-cutting toggle. 26. The feature epic’s detail page works end-to-end: children table with inline status changes and claim/release; criteria checklists expand in place showing stable AC-{n} ids and are checkable with attribution and evidence; linked requirements open in a slide-over at the pinned revision without navigation; cross-cutting requirements appear as their own always-current group; a failed criterion offers a prefilled file-bug action. 27. Issue detail shows the rendered body, edit-with-preview, comments, and — on any kind that carries criteria — the criteria panel. 28. The overview/milestone strip on the Work page shows feature epics with children counts by status and criteria progress, project totals, and per-milestone counts — all derived live from current data. 29. The activity log shows status transitions grouped by day (default filter: flips to done), each entry linking to its issue, derived from status_history. 30. Lists and open detail views refresh via polling (≤ 10 s) without user action.

MCP 31. The full registered tool surface functions end-to-end against a running API — a Jest-driven test that spawns apps/mcp over stdio against a locally running API (with a minted pbt_ token), asserts tools/list matches the registered tool names exactly, that the criteria-authoring tools’ descriptions teach the howTo rule, and that the server instructions field names the API the session is bound to under the Pipebrain banner, and drives the core workflow tools.

Ops 32. On a fresh clone, cp .env.example .env then npm run setup brings up local Postgres, migrates, and seeds; then npm run check passes clean (its integration tests require the Docker Postgres from setup). All ports come from .env; the dev servers bind 0.0.0.0 and the app is usable from another device on the LAN; a missing required env var fails startup with an error naming it. CI runs npm run check on PRs and pushes to main, required before merge. 33. Deploys run migrations as a PRE_DEPLOY job (as the migration role via DATABASE_URL_ADMIN) and abort on failure; GET /api/healthz gates rollout; npm run bootstrap:prod is run once against the direct admin connection to create the org and operators.

Org instructions 34. Org-level agent instructions round-trip end-to-end: an ADMIN sets per-scope texts (the Settings editor or the *_org_instruction tools), a freshly spawned MCP session’s get_work_context read returns every saved scope exactly once — the doctrine whole, then one block per scope under a heading naming it, an unsaved scope contributing nothing, no tool description carrying any of it, and the server-level instructions field carrying only the bounded index — a running session keeps its startup snapshot, an unreachable API at spawn boots fail-open with no organization text, and writes below ADMIN are rejected on both auth planes.


15. Out of scope for V1 / roadmap