# 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.
