516685526c
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>
229 lines
14 KiB
Markdown
229 lines
14 KiB
Markdown
# 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** (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.
|