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

14 KiB
Raw Permalink Blame History

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 does404, 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.