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
+294
View File
@@ -0,0 +1,294 @@
/**
* Motion — the library, the engagements, and the loop between them.
*
* Four tables, and three of the decisions in them are load-bearing.
*
* **A used template is never edited in place.** `usage_count` is incremented
* when a template is instantiated into an engagement, and the API refuses a
* `PATCH` once it is above zero — a new version row is written instead,
* carrying `supersedes_id` back to its predecessor and `origin_artifact_id`
* forward to the artifact that proved it. 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.
* That is also why `(slug, version)` is unique: the seed writes
* `onConflictDoNothing({ target: [slug, version] })`, which is a silent no-op
* without a constraint to conflict on, and that has already duplicated seed
* data twice in this codebase.
*
* **`visibility = 'private'` is a real access rule, and it is this codebase's
* first row-level filter.** `permissions.ts` says plainly that every read
* endpoint returns the whole book, because no row-level team filter exists
* anywhere in the query layer. Motion is a deliberate exception, stated here
* so it cannot be discovered by surprise: 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. `shared` is book-wide on
* `book:read` exactly like everything else; `private` is readable and writable
* by `owner_user_id` and by a platform admin, and by nobody else.
*
* The CHECK below refuses a private row with no owner, because such a row is
* readable by nobody and writable by nobody — a leak the day someone "fixes"
* the query that appears to be dropping rows. It also, measured by running the
* delete rather than by reading the DDL, makes `ON DELETE SET NULL` on
* `owner_user_id` unreachable for a private row: Postgres evaluates the CHECK
* on the UPDATE the referential action performs, so deleting a user who owns
* one raises `motion_templates_private_has_owner_check` and the delete fails.
* Private authorship therefore blocks a user delete today, and whoever adds a
* member-removal path has to archive or reassign those rows first. The read
* query still treats a private row with a null owner as invisible to everyone
* but a platform admin, so that relaxing the CHECK cannot quietly publish
* them — do not write that filter as `owner_user_id = $me OR owner_user_id IS
* NULL`.
*
* **`qualification_scores` is append-only.** Never updated, never deleted. The
* movement of a score across an engagement is the evidence that qualification
* happened at all; a mutable current score is a number somebody can make true
* afterwards. Everything else archives rather than deletes, as elsewhere in
* PIG.
*
* The FK cycle between `motion_templates.origin_artifact_id` and
* `engagement_artifacts.template_id` is intentional — it is the loop — and it
* is why migration `0014_motion.sql` is hand-written, adding one of the two
* constraints in a separate `ALTER TABLE` after both tables exist.
*/
import { sql } from 'drizzle-orm';
import {
boolean,
check,
index,
integer,
jsonb,
pgTable,
text,
timestamp,
unique,
uuid,
type AnyPgColumn,
} from 'drizzle-orm/pg-core';
import {
ARTIFACT_STATUSES,
DEMAND_STAGES,
ENGAGEMENT_STATUSES,
MOTION_BASIS_POINTS_MAX,
MOTION_KINDS,
MOTION_VISIBILITIES,
} from '@pig/core';
import { demandDeals } from './demand';
import { users } from './identity';
/**
* Render a value set as a SQL `IN` list from the ontology constant.
*
* Copied from `learn.ts` rather than imported: it is a private detail of how a
* schema file renders its constraints, and exporting it would make two files
* that must be able to diverge share one. The values are compile-time literal
* constants from `@pig/core`, never input.
*/
const inList = (values: readonly string[]) =>
sql.raw(values.map((value) => `'${value}'`).join(', '));
export const motionTemplates = pgTable(
'motion_templates',
{
id: uuid('id').primaryKey().defaultRandom(),
kind: text('kind', { enum: MOTION_KINDS }).notNull(),
/** Stable across versions: the slug, not the id, is the identity of a lineage. */
slug: text('slug').notNull(),
version: integer('version').notNull().default(1),
title: text('title').notNull(),
summary: text('summary').notNull(),
/** Markdown. Rendered by the web app; never framed, never executed. */
body: text('body').notNull(),
/** The structured half — scoring dimensions, pricing inputs, checklist items. */
fields: jsonb('fields').$type<Record<string, unknown>>(),
stage: text('stage', { enum: DEMAND_STAGES }).notNull(),
visibility: text('visibility', { enum: MOTION_VISIBILITIES }).notNull().default('private'),
ownerUserId: uuid('owner_user_id').references(() => users.id, { onDelete: 'set null' }),
/** The previous version in this lineage. Null on version 1. */
supersedesId: uuid('supersedes_id').references((): AnyPgColumn => motionTemplates.id, {
onDelete: 'set null',
}),
/**
* The engagement artifact this version was promoted from. Half of the
* cycle the migration has to break — see the file header.
*/
originArtifactId: uuid('origin_artifact_id').references(
(): AnyPgColumn => engagementArtifacts.id,
{ onDelete: 'set null' },
),
/** The starter library shipped with the product, not somebody's draft. */
isSystem: boolean('is_system').notNull().default(false),
/** Incremented on instantiate. Above zero, this row stops being editable. */
usageCount: integer('usage_count').notNull().default(0),
archivedAt: timestamp('archived_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
check('motion_templates_kind_check', sql`${t.kind} IN (${inList(MOTION_KINDS)})`),
check('motion_templates_stage_check', sql`${t.stage} IN (${inList(DEMAND_STAGES)})`),
check(
'motion_templates_visibility_check',
sql`${t.visibility} IN (${inList(MOTION_VISIBILITIES)})`,
),
check('motion_templates_version_positive_check', sql`${t.version} > 0`),
/**
* Written as an implication so it reads as the rule it encodes: private
* implies owned. An unowned private row is invisible to every query that
* is written correctly, which is precisely why it must not exist.
*/
check(
'motion_templates_private_has_owner_check',
sql`${t.visibility} <> 'private' OR ${t.ownerUserId} IS NOT NULL`,
),
/** Load-bearing for the seed's `onConflictDoNothing` — see the file header. */
unique('motion_templates_slug_version_key').on(t.slug, t.version),
/** The library browse: a kind, at a stage. */
index('motion_templates_kind_stage_idx').on(t.kind, t.stage),
/** The shared-library read, which filters on visibility before anything else. */
index('motion_templates_visibility_kind_idx').on(t.visibility, t.kind),
/** "My drafts", the other half of the private/shared split. */
index('motion_templates_owner_idx').on(t.ownerUserId),
],
);
export const engagements = pgTable(
'engagements',
{
id: uuid('id').primaryKey().defaultRandom(),
/**
* The spine. An engagement has no independent existence — it is the motion
* being run against a deal that already exists, so it dies with the deal.
*/
demandDealId: uuid('demand_deal_id')
.notNull()
.references(() => demandDeals.id, { onDelete: 'cascade' }),
playbookTemplateId: uuid('playbook_template_id').references(() => motionTemplates.id, {
onDelete: 'set null',
}),
ownerUserId: uuid('owner_user_id').references(() => users.id, { onDelete: 'set null' }),
status: text('status', { enum: ENGAGEMENT_STATUSES }).notNull().default('open'),
summary: text('summary'),
openedAt: timestamp('opened_at', { withTimezone: true }).notNull().defaultNow(),
closedAt: timestamp('closed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
check('engagements_status_check', sql`${t.status} IN (${inList(ENGAGEMENT_STATUSES)})`),
/**
* One engagement per deal. Two engagements on one deal would give the same
* opportunity two qualification histories and two answers to "what stage
* is this at"; if that ever has to relax it relaxes deliberately.
*/
unique('engagements_demand_deal_key').on(t.demandDealId),
index('engagements_status_idx').on(t.status),
],
);
export const engagementArtifacts = pgTable(
'engagement_artifacts',
{
id: uuid('id').primaryKey().defaultRandom(),
engagementId: uuid('engagement_id')
.notNull()
.references(() => engagements.id, { onDelete: 'cascade' }),
/** What it was instantiated from. Null for an artifact written from scratch. */
templateId: uuid('template_id').references(() => motionTemplates.id, { onDelete: 'set null' }),
kind: text('kind', { enum: MOTION_KINDS }).notNull(),
stage: text('stage', { enum: DEMAND_STAGES }).notNull(),
title: text('title').notNull(),
body: text('body').notNull(),
fields: jsonb('fields').$type<Record<string, unknown>>(),
status: text('status', { enum: ARTIFACT_STATUSES }).notNull().default('draft'),
authoredByUserId: uuid('authored_by_user_id').references(() => users.id, {
onDelete: 'set null',
}),
/** Set once, by promotion. Its presence is what refuses a second promotion. */
promotedTemplateId: uuid('promoted_template_id').references(() => motionTemplates.id, {
onDelete: 'set null',
}),
archivedAt: timestamp('archived_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
check('engagement_artifacts_kind_check', sql`${t.kind} IN (${inList(MOTION_KINDS)})`),
check('engagement_artifacts_stage_check', sql`${t.stage} IN (${inList(DEMAND_STAGES)})`),
check('engagement_artifacts_status_check', sql`${t.status} IN (${inList(ARTIFACT_STATUSES)})`),
/** The workspace reads one engagement's artifacts grouped by stage. */
index('engagement_artifacts_engagement_stage_idx').on(t.engagementId, t.stage),
],
);
export const qualificationScores = pgTable(
'qualification_scores',
{
id: uuid('id').primaryKey().defaultRandom(),
engagementId: uuid('engagement_id')
.notNull()
.references(() => engagements.id, { onDelete: 'cascade' }),
frameworkTemplateId: uuid('framework_template_id').references(() => motionTemplates.id, {
onDelete: 'set null',
}),
/** `MotionDimensionScore[]` exactly as scored, so an old row survives a reweighted framework. */
dimensions: jsonb('dimensions').$type<Record<string, unknown>[]>().notNull(),
/**
* Basis points of the maximum, integer, computed by
* `motionScoreBasisPoints`. Stored rather than derived because the
* framework's weights can change and this number must not.
*/
basisPoints: integer('basis_points').notNull(),
band: text('band').notNull(),
note: text('note'),
scoredByUserId: uuid('scored_by_user_id').references(() => users.id, { onDelete: 'set null' }),
scoredAt: timestamp('scored_at', { withTimezone: true }).notNull().defaultNow(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
check(
'qualification_scores_basis_points_check',
sql`${t.basisPoints} >= 0 AND ${t.basisPoints} <= ${sql.raw(String(MOTION_BASIS_POINTS_MAX))}`,
),
/** The history panel: one engagement, newest first. */
index('qualification_scores_engagement_idx').on(t.engagementId, t.scoredAt.desc()),
],
);
export type MotionTemplate = typeof motionTemplates.$inferSelect;
export type NewMotionTemplate = typeof motionTemplates.$inferInsert;
export type Engagement = typeof engagements.$inferSelect;
export type NewEngagement = typeof engagements.$inferInsert;
export type EngagementArtifact = typeof engagementArtifacts.$inferSelect;
export type NewEngagementArtifact = typeof engagementArtifacts.$inferInsert;
export type QualificationScore = typeof qualificationScores.$inferSelect;
export type NewQualificationScore = typeof qualificationScores.$inferInsert;