Files
pig/docs/motion.md
karti 7a6852e33a Merge gitea/main into the Motion branch
Motion was written against a base five commits behind main, so the
integration is the interesting part of this commit:

- The migration is renumbered 0014 -> 0015. Main shipped
  0014_piggy_conversations, and two migrations sharing an index is a
  journal that applies one of them.
- The seed-idempotency gate keeps main's all-tables diff rather than the
  motion_templates counter this branch added; the general check subsumes
  the specific one.
- Nav gains a Motion group alongside main's new Workspace group, and
  Piggy keeps the mark main gave it.
- Stat keeps main's container-scaled figure, which already carries the
  min-w-0 this branch added for the same reason.
- Piggy's page labels keep main's refusal wording for the four pages with
  no tool of their own, and gain the three Motion routes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 18:30:45 -07:00

229 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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** (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.