# Motion Why the go-to-market half of PIG is shaped the way it is. Read `packages/db/src/schema/motion.ts` and `apps/api/src/services/motion.ts` alongside this — the code carries the same reasoning in comments, and it is the version that cannot go stale. ## The gap this fills The ledger answers *which contracted capacity is sold, to whom, at what margin*. It says nothing about the **motion** — the repeatable practice that turns a messy customer conversation into a scoped deployment, and turns that deployment into something the next one reuses. A company whose second post-training engagement is as expensive as its first does not have a product motion; it has a sequence of heroics. Motion is deliberately **not a parallel entity tree**. `DEMAND_STAGES` in `@pig/core` already *is* the motion — `qualification → legal → scoping → proposal → procurement → poc → deployment → expansion` — so Motion binds reusable artefacts to the stages of a demand deal that already exists. An engagement has no independent life: it hangs off one `demand_deal`, cascade deleted, one per deal by unique constraint. ## Nine kinds, each answering a stage `MOTION_KINDS` in `packages/core/src/motion.ts` is a closed list, and each kind declares in `MOTION_KIND_STAGES` which demand stages it serves. That declaration is what lets an engagement offer "instantiate something useful here" without a person browsing the whole library, and it is why the kinds are an enum rather than a free-text tag: a tag set nobody curates ends up with `proposal`, `Proposal` and `proposal-v2` in it, and stage coverage stops being computable. | Kind | What it is for | Stages it serves | |---|---|---| | `discovery` | The questions that surface what a customer is actually training, before anyone scopes it | qualification, scoping | | `qualification` | Weighted dimensions that turn a judgement about a deal into a score somebody can argue with | qualification | | `poc` | What a proof of concept must demonstrate, and what closes it — so a pilot cannot run forever | poc | | `proposal` | Language blocks assembled into a proposal, so wording that survived procurement is reused | proposal, procurement | | `pricing` | The inputs behind a quoted price: term, commitment shape, and what the block cost | proposal, procurement | | `architecture` | A deployment shape that has already worked, described well enough to be copied | scoping, poc, deployment | | `case_study` | A deployment written up as evidence, for the next customer who asks whether this is real | qualification, proposal, expansion | | `narrative` | The technical argument for why this capacity suits this workload, written once | qualification, scoping, proposal | | `playbook` | The end-to-end sequence for a deployment, spanning every stage rather than one | all eight open stages | Two details in that table are decisions rather than data entry. `playbook` maps to `DEMAND_OPEN_STAGES` and is the only kind that does; spanning the whole live motion is exactly what distinguishes a playbook from the eight kinds that answer one question each. And the **closed** stages appear nowhere, on purpose: a deal that is won or lost has left the motion, and offering to instantiate a discovery template into it would be an invitation to file work against a dead deal. `pricing` deserves a word, because it looks like it belongs in the ledger. It does not carry a price — supplier cost and break-even live in the commitment and are gated on `economics:read`. It carries the *reasoning* behind a quote: which term was offered, what commitment shape it assumed, what the block cost then. That is practice, and the motion library is not the cost book — the same classification `read-guards.ts` applies when it gates every motion GET on `book:read` rather than `economics:read`. ## The loop ``` library template ──instantiate──▶ engagement artefact ──promote──▶ template v2 ▲ │ └──────────────────────── supersedes_id ◀──────────────────────────┘ origin_artifact_id ``` This is the only reason the feature exists, and two rules are what make it a mechanism rather than a slogan. **A used template is never edited in place.** Instantiating increments `usage_count`; once it is above zero a `PATCH` returns `409 template_in_use` and names the fix (`POST .../versions`). A live engagement whose template changed underneath it has lost its provenance, and provenance is the only thing that makes "this came from playbook v3" mean anything a quarter later. A template at `usage_count = 0` is still a draft and edits freely. **Promotion writes a new row, never an update.** `version = previous + 1`, `supersedes_id` back to its predecessor, `origin_artifact_id` back to the artefact that proved it, `visibility: 'shared'`. A draft cannot be promoted — `409 artifact_not_final` — because the library is what the next deployment copies. Promoting twice is `409 already_promoted`. The whole of it happens in the transaction the `mutation()` chokepoint already opens, and it emits an activity onto the account of the deal that proved it, so the loop is visible on a customer timeline rather than only in a library. **Every collision on `(slug, version)` is a 409, never a 500.** The unique constraint is real and callers hit it in ordinary use: "Proposal Blocks" is a shipped starter, so the obvious first template anyone authors derives a slug that already exists. Creating one is checked first and answers `409 template_slug_exists` naming the versions endpoint; allocating a version takes `FOR UPDATE` on the newest row of the lineage so two concurrent forks queue rather than race; and because a brand-new slug has no row to lock, the insert also catches Postgres' `23505` and answers `409 template_version_conflict`. Verified by running four concurrent creates of one title: one 200 and three 409s. Those two constraints form an FK cycle — `motion_templates.origin_artifact_id ↔ engagement_artifacts.template_id` — which is why migration `0015_motion.sql` is hand-written and adds one of the two constraints in a separate `ALTER TABLE` after both tables exist. Drizzle will not order that for you. ## Private and shared — a departure, stated out loud `permissions.ts` says plainly that every read endpoint returns the whole book, because **no row-level filter exists anywhere in the query layer**. Motion is the first exception, and it is written down here so it cannot be discovered by surprise: - `visibility = 'shared'` — book-wide on `book:read`, like everything else. - `visibility = 'private'` — readable and writable only by `owner_user_id`, plus platform admins. A real `WHERE` clause, in `visibleTemplates()`. It was worth the departure because a draft proposal for a live deal is not the same object as a contract, and a library nobody can draft in privately becomes a library nobody drafts in. Three details are load-bearing: - The predicate is `owner_user_id = $viewer`, which is NULL-safe by construction, so an orphaned private row would be invisible to everyone but a platform admin. Writing it as `owner = $me OR owner IS NULL` would hand every departed user's drafts to the whole workspace. As it happens Postgres will not currently produce such a row — found by running the delete, not by reading the DDL: `motion_templates_private_has_owner_check` is evaluated on the UPDATE that `ON DELETE SET NULL` performs, so **deleting a user who owns a private template fails outright**. Whoever adds a member-removal path has to archive or reassign those rows first. The NULL-safe predicate stays regardless, so that relaxing the CHECK cannot quietly publish them. - **A lineage the reader cannot see never supplies content to one they can.** The version number for a new version is allocated against the whole lineage, because `(slug, version)` is unique and a private fork still consumes a number — but that query returns a number and nothing else, `FOR UPDATE`. `supersedes_id` and the fallback summary come from a second, visibility- filtered read. Answering both from one unfiltered query is how a private draft's summary reached the shared library, and it is pinned by a test named for the decision. - **A private template somebody else owns answers exactly as an unknown id does** — `404`, not `403`. A 403 on a specific id is a working existence oracle: it tells you a colleague has a draft at that address, which is half of what the filter exists to withhold. - A platform admin gets **no predicate at all**. That is deliberate, and it is pinned by a test that says so in its name, because it looks like the filter failing open. - Piggy filters to `visibility = 'shared'` unconditionally, under every filter a model can send. A model that can be talked into reading someone's private draft is a leak with extra steps. Ownership and capability are different questions and fail differently on purpose: a missing `motion:write` is `insufficient_permission` from the chokepoint before the body is read, and a wrong owner is `not_owner` once the row is known. A shared template is published, not communal — it is still only its owner's to edit. Sharing one needs `motion:publish`, which is a lead's judgement, because publishing is what everybody else copies next quarter. ## Scores move, and the movement is the evidence `qualification_scores` is append-only — never updated, never deleted. A mutable current score is a number somebody can make true afterwards; the trajectory across an engagement is the evidence that qualification happened at all. Scores are integers, in **basis points of the maximum** (0–10000), computed with integer arithmetic and rounded half-up — the same rule as money, for the same reason. Weights need not sum to 100. Band boundaries are inclusive at `min` (3500, 5500, 7500), pinned exactly, because an off-by-one there silently changes what a seller is told to do. The API computes the score from the dimensions and refuses a posted `basisPoints` outright: a score somebody can send is a score somebody can fix. `qualification_scores.dimensions` stores the weights rather than referencing the framework, so history does not silently re-weight itself the day someone publishes a new version of the scorecard. ## The starter library Twelve authored templates ship in `packages/db/src/seed/motion/`, covering all nine `MOTION_KINDS`. They are **not** demo data and carry no `DEMO — ` prefix — they are product content, like `docs/learn-scripts.md`. Seeded `isSystem: true`, `visibility: 'shared'`, `version: 1`, unowned. Idempotency rests on `onConflictDoNothing({ target: [slug, version] })` against `motion_templates_slug_version_key`, which is a **silent no-op** without that constraint. CI counts `motion_templates` across two seed runs for exactly that reason; counting `contacts` alone could never have caught it, because `contacts` is idempotent by an explicit existence check. Two demo engagements live in `packages/db/src/seed/demo/motion.ts`: one mid-POC with three qualification scores, because a single score is a number and three are a trajectory, and one closed-won with a promoted case study, which puts a version 2 into the library and populates both halves of the FK cycle on a first run. ## Deliberately not built - **Mermaid.** Reference-architecture diagrams arrive as `mermaid` fences and render as a labelled, scrollable code block with a copy button — the same `CodeBlock` whether the diagram came from a template's `fields` or from a fence inside a promoted body. A 2 MB diagram renderer and a CSP hash change were not worth it for this change; the block already reads the language off the fence, so the renderer is the only missing piece if it is ever wanted. - **A single markdown renderer.** The app now carries two: `streamdown`, which Piggy's dock uses for a half-finished token stream in 22rem, and plain `react-markdown` for page-width authored prose, in `components/motion/Markdown.tsx`. They are different problems and the second costs ~20 kB in a lazy chunk that only the Motion routes load — the eager entry chunk is unchanged. Consolidating them is a reasonable future change and a bad one to make blind. - **Export to PDF.** A proposal leaves PIG by being copied into whatever the customer already reads. Rendering paper is a different product. - **Per-customer sharing links.** There is no way to hand a customer a URL onto a template or an artefact, and no third visibility between `private` and `shared`. Those two earned their complexity; a third needs an ACL table, and the moment the library has an ACL nobody drafts in it. An external link is a larger decision again — an unauthenticated route serving authored practice — and it belongs to whoever builds a customer-facing surface, not to this change. - **Full-text search.** `?q=` is `ILIKE` over title and summary, with `%` and `_` escaped so a wildcard a caller types is a literal. Body text is not searched. Postgres `tsvector` is the right answer when the library is large enough for that to be the complaint; twelve starter templates are not. - **Pagination.** As everywhere else here: a hard `.limit()` instead. - **Contiguous version numbers.** A private fork consumes a number in the shared lineage, so a reader who cannot see the fork sees a hole where it sits. The alternative — a separate counter per visibility — makes "which of these is newer" unanswerable across a fork that is later published, which is the question the lineage exists to answer. The hole is the honest rendering.