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:
@@ -12,6 +12,7 @@
|
||||
* contracts MSA, DPA, SLA, order forms, obligations
|
||||
* compliance export control as a predicate on the match
|
||||
* calendar the one dated row type nothing else owns
|
||||
* motion the reusable practice bound to the demand stages
|
||||
* agent the leased task queue and evidence-bearing facts
|
||||
* fields user-defined fields
|
||||
*/
|
||||
@@ -26,6 +27,7 @@ export * from './contracts';
|
||||
export * from './compliance';
|
||||
export * from './calendar';
|
||||
export * from './learn';
|
||||
export * from './motion';
|
||||
export * from './agent';
|
||||
export * from './fields';
|
||||
export * from './integrations';
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user