/** * 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 `0015_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>(), 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>(), 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[]>().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;