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:
+228
@@ -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** (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.
|
||||
Reference in New Issue
Block a user