Add Motion: the go-to-market operating system on top of the ledger

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 customer conversation into a scoped deployment, and turns that
deployment into something the next one reuses.

Motion is deliberately not a parallel entity tree. DEMAND_STAGES already
is the motion, so Motion binds reusable artefacts to the stages of a
demand deal that already exists: an engagement hangs off one deal,
cascade deleted, one per deal by unique constraint.

Nine closed kinds, each declaring which stages it serves, and a starter
library of twelve templates covering all eight open stages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 18:27:03 -07:00
parent 99d165b5e5
commit 516685526c
61 changed files with 13013 additions and 29 deletions
+228
View File
@@ -0,0 +1,228 @@
# 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 `0014_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** (010000), 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.