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:
@@ -2,6 +2,7 @@ export * from './ontology';
|
||||
export * from './calendar';
|
||||
export * from './learn';
|
||||
export * from './margin';
|
||||
export * from './motion';
|
||||
export * from './permissions';
|
||||
export * from './piggy-context';
|
||||
export * from './theme';
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Motion — the reusable practice that sits alongside the ledger.
|
||||
*
|
||||
* PIG already models what capacity was bought and what was sold. It does not
|
||||
* model the *motion*: the repeatable work that turns a messy customer
|
||||
* conversation into a scoped deployment, and turns that deployment into an
|
||||
* asset the next one reuses. `DEMAND_STAGES` is already exactly that sequence,
|
||||
* so Motion is deliberately NOT a parallel entity tree — every kind below
|
||||
* declares which demand stages it serves, and the artifacts hang off deals that
|
||||
* already exist.
|
||||
*
|
||||
* The loop the whole feature exists for:
|
||||
*
|
||||
* library template --instantiate--> engagement artifact --promote--> template v2
|
||||
*
|
||||
* A template is never edited in place once it has been used; promotion writes a
|
||||
* new version row pointing back at its predecessor and at the artifact that
|
||||
* proved it. That is what makes "each deployment makes the next one easier" a
|
||||
* mechanism rather than a slogan, and it is why `usageCount` and the lineage
|
||||
* columns are load-bearing rather than decoration.
|
||||
*
|
||||
* No zod in this file. `packages/core` is the ontology layer; the API route
|
||||
* modules build their schemas from these constants, so removing a value stops
|
||||
* validating rather than silently persisting.
|
||||
*/
|
||||
import { DEMAND_OPEN_STAGES, type DemandStage } from './ontology';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The nine kinds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const MOTION_KINDS = [
|
||||
'discovery',
|
||||
'qualification',
|
||||
'poc',
|
||||
'proposal',
|
||||
'pricing',
|
||||
'architecture',
|
||||
'case_study',
|
||||
'narrative',
|
||||
'playbook',
|
||||
] as const;
|
||||
export type MotionKind = (typeof MOTION_KINDS)[number];
|
||||
|
||||
export const MOTION_KIND_LABELS: Record<MotionKind, string> = {
|
||||
discovery: 'Discovery',
|
||||
qualification: 'Qualification framework',
|
||||
poc: 'POC structure',
|
||||
proposal: 'Proposal blocks',
|
||||
pricing: 'Pricing and packaging',
|
||||
architecture: 'Reference architecture',
|
||||
case_study: 'Case study',
|
||||
narrative: 'Technical narrative',
|
||||
playbook: 'Deployment playbook',
|
||||
};
|
||||
|
||||
/**
|
||||
* One line each, in the register of `ontology.ts`: what the kind is *for*, not
|
||||
* what it contains. These strings are shown in the library filter and in
|
||||
* Piggy's tool description, so they are the only definition most people read.
|
||||
*/
|
||||
export const MOTION_KIND_DESCRIPTIONS: Record<MotionKind, string> = {
|
||||
discovery: 'The questions that surface what a customer is actually training, before anyone scopes it.',
|
||||
qualification: 'Weighted dimensions that turn a judgement about a deal into a score somebody can argue with.',
|
||||
poc: 'What a proof of concept must demonstrate, and what closes it — so a pilot cannot run forever.',
|
||||
proposal: 'Language blocks assembled into a proposal, so wording that survived procurement is reused.',
|
||||
pricing: 'The inputs behind a quoted price: term, commitment shape, and what the block cost.',
|
||||
architecture: 'A deployment shape that has already worked, described well enough to be copied.',
|
||||
case_study: 'A deployment written up as evidence, for the next customer who asks whether this is real.',
|
||||
narrative: 'The technical argument for why this capacity suits this workload, written once.',
|
||||
playbook: 'The end-to-end sequence for a deployment, spanning every stage rather than one.',
|
||||
};
|
||||
|
||||
/**
|
||||
* Which demand stages each kind serves.
|
||||
*
|
||||
* The closed stages are absent everywhere 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. `playbook`
|
||||
* spans the whole live motion, which is what distinguishes it from the eight
|
||||
* kinds that answer one stage.
|
||||
*/
|
||||
export const MOTION_KIND_STAGES: Record<MotionKind, readonly DemandStage[]> = {
|
||||
discovery: ['qualification', 'scoping'],
|
||||
qualification: ['qualification'],
|
||||
poc: ['poc'],
|
||||
proposal: ['proposal', 'procurement'],
|
||||
pricing: ['proposal', 'procurement'],
|
||||
architecture: ['scoping', 'poc', 'deployment'],
|
||||
case_study: ['qualification', 'proposal', 'expansion'],
|
||||
narrative: ['qualification', 'scoping', 'proposal'],
|
||||
playbook: DEMAND_OPEN_STAGES,
|
||||
};
|
||||
|
||||
/**
|
||||
* Private is the default, and it is a real access rule rather than a label —
|
||||
* see the header of `packages/db/src/schema/motion.ts`. A library nobody can
|
||||
* draft in privately becomes a library nobody drafts in.
|
||||
*/
|
||||
export const MOTION_VISIBILITIES = ['private', 'shared'] as const;
|
||||
export type MotionVisibility = (typeof MOTION_VISIBILITIES)[number];
|
||||
|
||||
export const MOTION_VISIBILITY_LABELS: Record<MotionVisibility, string> = {
|
||||
private: 'Private',
|
||||
shared: 'Shared',
|
||||
};
|
||||
|
||||
/** Only a `final` artifact may be promoted — see the promotion path in the API. */
|
||||
export const ARTIFACT_STATUSES = ['draft', 'review', 'final'] as const;
|
||||
export type ArtifactStatus = (typeof ARTIFACT_STATUSES)[number];
|
||||
|
||||
export const ARTIFACT_STATUS_LABELS: Record<ArtifactStatus, string> = {
|
||||
draft: 'Draft',
|
||||
review: 'In review',
|
||||
final: 'Final',
|
||||
};
|
||||
|
||||
export const ENGAGEMENT_STATUSES = ['open', 'won', 'lost', 'paused'] as const;
|
||||
export type EngagementStatus = (typeof ENGAGEMENT_STATUSES)[number];
|
||||
|
||||
export const ENGAGEMENT_STATUS_LABELS: Record<EngagementStatus, string> = {
|
||||
open: 'Open',
|
||||
won: 'Won',
|
||||
lost: 'Lost',
|
||||
paused: 'Paused',
|
||||
};
|
||||
|
||||
export function isMotionKind(value: string): value is MotionKind {
|
||||
return (MOTION_KINDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function isMotionVisibility(value: string): value is MotionVisibility {
|
||||
return (MOTION_VISIBILITIES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function isArtifactStatus(value: string): value is ArtifactStatus {
|
||||
return (ARTIFACT_STATUSES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function isEngagementStatus(value: string): value is EngagementStatus {
|
||||
return (ENGAGEMENT_STATUSES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scoring
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Each dimension is scored on this scale, inclusive. */
|
||||
export const MOTION_MIN_DIMENSION_SCORE = 0;
|
||||
export const MOTION_MAX_DIMENSION_SCORE = 4;
|
||||
|
||||
/** The full scale of a motion score. Basis points, exactly as money is cents. */
|
||||
export const MOTION_BASIS_POINTS_MAX = 10_000;
|
||||
|
||||
export interface MotionDimensionScore {
|
||||
readonly id: string;
|
||||
readonly weight: number;
|
||||
readonly score: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weighted score in basis points of the maximum (0–10000). Weights need not
|
||||
* sum to 100.
|
||||
*
|
||||
* Basis points rather than a float for the same reason money is cents: a score
|
||||
* decides what a seller is told to do, it is persisted, and 0.55 is not 0.55 in
|
||||
* binary. The whole computation stays in integers and rounds half-up at the
|
||||
* single division, so the stored number is the number that was displayed.
|
||||
*
|
||||
* Inputs are normalised rather than rejected — a dimension carrying a
|
||||
* non-finite weight is a bug upstream, and throwing here would take a
|
||||
* dashboard down rather than under-weight one row.
|
||||
*/
|
||||
export function motionScoreBasisPoints(dimensions: readonly MotionDimensionScore[]): number {
|
||||
let weightTotal = 0;
|
||||
let weightedScoreTotal = 0;
|
||||
|
||||
for (const dimension of dimensions) {
|
||||
if (!Number.isFinite(dimension.weight) || !Number.isFinite(dimension.score)) continue;
|
||||
const weight = Math.round(dimension.weight);
|
||||
if (weight <= 0) continue;
|
||||
const score = Math.min(
|
||||
MOTION_MAX_DIMENSION_SCORE,
|
||||
Math.max(MOTION_MIN_DIMENSION_SCORE, Math.round(dimension.score)),
|
||||
);
|
||||
weightTotal += weight;
|
||||
weightedScoreTotal += weight * score;
|
||||
}
|
||||
|
||||
// No weight is not a zero score dressed up — it is "nothing was asked". Both
|
||||
// answer 0 here because a qualification with no dimensions has to render as
|
||||
// something, and NaN renders as `NaN`.
|
||||
if (weightTotal === 0) return 0;
|
||||
|
||||
const numerator = MOTION_BASIS_POINTS_MAX * weightedScoreTotal;
|
||||
const denominator = MOTION_MAX_DIMENSION_SCORE * weightTotal;
|
||||
|
||||
// Half-up, in integers. `Math.round` on the quotient would be a float
|
||||
// division first, which is exactly the step that loses the half.
|
||||
return Math.floor((2 * numerator + denominator) / (2 * denominator));
|
||||
}
|
||||
|
||||
export const MOTION_BANDS = [
|
||||
{ min: 0, max: 3499, label: 'Decline or defer', tone: 'danger' },
|
||||
{ min: 3500, max: 5499, label: 'Not yet', tone: 'warning' },
|
||||
{ min: 5500, max: 7499, label: 'Qualified', tone: 'info' },
|
||||
{ min: 7500, max: 10_000, label: 'Strategic', tone: 'positive' },
|
||||
] as const;
|
||||
export type MotionBand = (typeof MOTION_BANDS)[number];
|
||||
export type MotionBandTone = MotionBand['tone'];
|
||||
|
||||
/**
|
||||
* The band a score falls in. Boundaries are inclusive at `min`, so 3500 is
|
||||
* "Not yet" and 3499 is not — an off-by-one here changes what a seller is told
|
||||
* to do without changing any number they can see.
|
||||
*
|
||||
* Out-of-range input clamps to an end band rather than returning undefined,
|
||||
* because every caller renders this and none of them has a null branch.
|
||||
*/
|
||||
export function motionBand(basisPoints: number): MotionBand {
|
||||
const clamped = Math.min(
|
||||
MOTION_BASIS_POINTS_MAX,
|
||||
Math.max(0, Number.isFinite(basisPoints) ? Math.round(basisPoints) : 0),
|
||||
);
|
||||
return MOTION_BANDS.find((band) => clamped >= band.min && clamped <= band.max) ?? MOTION_BANDS[0];
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export const CAPABILITIES = [
|
||||
'data:import',
|
||||
'fact:review',
|
||||
'integration:connect',
|
||||
'motion:write',
|
||||
'motion:publish',
|
||||
'settings:admin',
|
||||
'book:read',
|
||||
'economics:read',
|
||||
@@ -36,6 +38,8 @@ export const TEAM_CAPABILITIES = [
|
||||
'data:import',
|
||||
'fact:review',
|
||||
'integration:connect',
|
||||
'motion:write',
|
||||
'motion:publish',
|
||||
] as const satisfies readonly Capability[];
|
||||
export type TeamCapability = (typeof TEAM_CAPABILITIES)[number];
|
||||
|
||||
@@ -93,6 +97,14 @@ export const TEAM_CAPABILITY_RULES: Readonly<Record<TeamCapability, CapabilityRu
|
||||
// research team's judgement about evidence, not a commercial authority.
|
||||
'fact:review': { teams: ['research'], minimumRole: 'admin' },
|
||||
'integration:connect': { teams: TEAMS, minimumRole: 'admin' },
|
||||
// Every team runs a motion, which is why this is not scoped to the
|
||||
// commercial pair: research authors reference architectures and technical
|
||||
// narratives as much as demand authors proposals.
|
||||
'motion:write': { teams: TEAMS, minimumRole: 'member' },
|
||||
// Publishing is what everybody else copies next quarter — a lead's
|
||||
// judgement, not a member's. Authoring privately stays open to everyone
|
||||
// precisely so this gate can be a real one.
|
||||
'motion:publish': { teams: TEAMS, minimumRole: 'lead' },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -52,6 +52,13 @@ export const PIGGY_PAGE_ROUTES = [
|
||||
'/team',
|
||||
'/facts',
|
||||
'/learn',
|
||||
// Order is load-bearing for the first time in this list. `toPiggyPageRoute`
|
||||
// takes the FIRST match and matches on prefix, so '/motion' listed above its
|
||||
// children would swallow '/motion/library/:id' and report the whole feature
|
||||
// as one page. Most specific first.
|
||||
'/motion/library',
|
||||
'/motion/engagements',
|
||||
'/motion',
|
||||
'/settings',
|
||||
'/piggy',
|
||||
] as const;
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Tests for the motion scoring arithmetic.
|
||||
*
|
||||
* A qualification score is not a dashboard ornament: the band it lands in is
|
||||
* what a seller is told to do about a deal, and the row is append-only
|
||||
* evidence that the judgement was made. So the cases below pin the
|
||||
* *decisions* — the empty book, the rounding direction, and above all the band
|
||||
* boundaries, each of which a plausible-but-wrong implementation gets wrong by
|
||||
* exactly one basis point and reports no error about.
|
||||
*/
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { describe, it } from 'node:test';
|
||||
import { DEMAND_STAGES } from '../src/ontology';
|
||||
import {
|
||||
MOTION_BANDS,
|
||||
MOTION_KINDS,
|
||||
MOTION_KIND_DESCRIPTIONS,
|
||||
MOTION_KIND_LABELS,
|
||||
MOTION_KIND_STAGES,
|
||||
isArtifactStatus,
|
||||
isMotionKind,
|
||||
isMotionVisibility,
|
||||
motionBand,
|
||||
motionScoreBasisPoints,
|
||||
} from '../src/motion';
|
||||
|
||||
describe('motionScoreBasisPoints', () => {
|
||||
it('returns 0 for an empty framework rather than NaN', () => {
|
||||
// A framework with no dimensions renders somewhere. `NaN` renders as "NaN".
|
||||
const score = motionScoreBasisPoints([]);
|
||||
assert.equal(score, 0);
|
||||
assert.ok(!Number.isNaN(score));
|
||||
});
|
||||
|
||||
it('returns 0 rather than throwing when every weight is zero', () => {
|
||||
// The naive version divides by the weight total and produces Infinity,
|
||||
// which `motionBand` would then clamp to "Strategic" — a deal nobody
|
||||
// scored recommended as the best one in the book.
|
||||
const score = motionScoreBasisPoints([
|
||||
{ id: 'fit', weight: 0, score: 4 },
|
||||
{ id: 'budget', weight: 0, score: 4 },
|
||||
]);
|
||||
assert.equal(score, 0);
|
||||
});
|
||||
|
||||
it('scores a full house at exactly the maximum', () => {
|
||||
assert.equal(
|
||||
motionScoreBasisPoints([
|
||||
{ id: 'fit', weight: 30, score: 4 },
|
||||
{ id: 'budget', weight: 70, score: 4 },
|
||||
]),
|
||||
10_000,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not require weights to sum to 100', () => {
|
||||
// Two frameworks expressing the same judgement must score the same, or the
|
||||
// number means nothing across templates.
|
||||
const outOf100 = motionScoreBasisPoints([
|
||||
{ id: 'fit', weight: 50, score: 4 },
|
||||
{ id: 'budget', weight: 50, score: 2 },
|
||||
]);
|
||||
const outOf6 = motionScoreBasisPoints([
|
||||
{ id: 'fit', weight: 3, score: 4 },
|
||||
{ id: 'budget', weight: 3, score: 2 },
|
||||
]);
|
||||
assert.equal(outOf100, 7500);
|
||||
assert.equal(outOf6, 7500);
|
||||
});
|
||||
|
||||
it('rounds half-up rather than truncating', () => {
|
||||
// 10000 × 5 / (4 × 8) = 1562.5 exactly. Truncation gives 1562, which is
|
||||
// the whole difference between this and a naive `Math.floor` — small, and
|
||||
// it moves a score across a band boundary once every few hundred deals.
|
||||
const score = motionScoreBasisPoints([
|
||||
{ id: 'fit', weight: 5, score: 1 },
|
||||
{ id: 'budget', weight: 3, score: 0 },
|
||||
]);
|
||||
assert.equal(score, 1563);
|
||||
});
|
||||
|
||||
it('does not let a zero-weight dimension move the total', () => {
|
||||
// The case that flatters: a dimension somebody scored 4 and weighted out
|
||||
// of the framework must not drag the score up on its way past.
|
||||
const withoutIt = motionScoreBasisPoints([{ id: 'fit', weight: 10, score: 2 }]);
|
||||
const withIt = motionScoreBasisPoints([
|
||||
{ id: 'fit', weight: 10, score: 2 },
|
||||
{ id: 'vanity', weight: 0, score: 4 },
|
||||
]);
|
||||
assert.equal(withoutIt, 5000);
|
||||
assert.equal(withIt, withoutIt);
|
||||
});
|
||||
|
||||
it('always returns an integer inside 0–10000', () => {
|
||||
for (const weight of [1, 3, 7, 17, 100]) {
|
||||
for (const score of [0, 1, 2, 3, 4]) {
|
||||
const basisPoints = motionScoreBasisPoints([
|
||||
{ id: 'a', weight, score },
|
||||
{ id: 'b', weight: weight + 1, score: 4 - score },
|
||||
]);
|
||||
assert.ok(Number.isInteger(basisPoints), `${weight}/${score} produced a fraction`);
|
||||
assert.ok(basisPoints >= 0 && basisPoints <= 10_000, `${basisPoints} is out of range`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('motionBand', () => {
|
||||
/**
|
||||
* The boundaries, one assertion per edge. Inclusive at `min`: an
|
||||
* implementation using `>` instead of `>=` passes every other test in this
|
||||
* file and tells a seller to defer a deal that qualified.
|
||||
*/
|
||||
it('is inclusive at the lower edge of every band', () => {
|
||||
assert.equal(motionBand(3500).label, 'Not yet');
|
||||
assert.equal(motionBand(5500).label, 'Qualified');
|
||||
assert.equal(motionBand(7500).label, 'Strategic');
|
||||
});
|
||||
|
||||
it('keeps the basis point below each edge in the band underneath', () => {
|
||||
assert.equal(motionBand(3499).label, 'Decline or defer');
|
||||
assert.equal(motionBand(5499).label, 'Not yet');
|
||||
assert.equal(motionBand(7499).label, 'Qualified');
|
||||
});
|
||||
|
||||
it('covers both ends of the scale', () => {
|
||||
assert.equal(motionBand(0).label, 'Decline or defer');
|
||||
assert.equal(motionBand(10_000).label, 'Strategic');
|
||||
});
|
||||
|
||||
it('leaves no gap and no overlap across the whole range', () => {
|
||||
// Written as a sweep because a hand-edited band table is exactly the kind
|
||||
// of data where a typo produces a score that belongs to two bands, or none.
|
||||
for (let basisPoints = 0; basisPoints <= 10_000; basisPoints += 1) {
|
||||
const matches = MOTION_BANDS.filter(
|
||||
(band) => basisPoints >= band.min && basisPoints <= band.max,
|
||||
);
|
||||
assert.equal(matches.length, 1, `${basisPoints} matched ${matches.length} bands`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the kind register', () => {
|
||||
it('labels and describes every kind', () => {
|
||||
for (const kind of MOTION_KINDS) {
|
||||
assert.ok(MOTION_KIND_LABELS[kind], `${kind} has no label`);
|
||||
assert.ok(MOTION_KIND_DESCRIPTIONS[kind], `${kind} has no description`);
|
||||
}
|
||||
});
|
||||
|
||||
it('maps every kind onto stages that actually exist', () => {
|
||||
// The stage list is the demand pipeline, not a second vocabulary. A typo
|
||||
// here would produce a template nothing can ever be filed against.
|
||||
for (const kind of MOTION_KINDS) {
|
||||
const stages = MOTION_KIND_STAGES[kind];
|
||||
assert.ok(stages.length > 0, `${kind} serves no stage`);
|
||||
for (const stage of stages) {
|
||||
assert.ok(DEMAND_STAGES.includes(stage), `${kind} claims unknown stage ${stage}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('never files a kind against a closed stage', () => {
|
||||
// A won or lost deal has left the motion; offering to instantiate into it
|
||||
// is an invitation to do work against a dead deal.
|
||||
for (const kind of MOTION_KINDS) {
|
||||
for (const stage of MOTION_KIND_STAGES[kind]) {
|
||||
assert.ok(stage !== 'closed_won' && stage !== 'closed_lost', `${kind} serves ${stage}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('gives the playbook the whole live motion', () => {
|
||||
assert.equal(MOTION_KIND_STAGES.playbook.length, 8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('type guards', () => {
|
||||
it('accepts members and rejects near misses', () => {
|
||||
assert.equal(isMotionKind('playbook'), true);
|
||||
assert.equal(isMotionKind('Playbook'), false);
|
||||
assert.equal(isMotionVisibility('shared'), true);
|
||||
assert.equal(isMotionVisibility('public'), false, 'shared is book-wide, not public');
|
||||
assert.equal(isArtifactStatus('final'), true);
|
||||
assert.equal(isArtifactStatus('published'), false);
|
||||
});
|
||||
});
|
||||
@@ -134,7 +134,14 @@ describe('reads', () => {
|
||||
describe('the whole role × capability matrix', () => {
|
||||
const EXPECTED: Readonly<Record<TeamRole, readonly Capability[]>> = {
|
||||
viewer: ['book:read', 'team:read'],
|
||||
member: ['book:read', 'economics:read', 'team:read', 'deal:write', 'activity:write'],
|
||||
member: [
|
||||
'book:read',
|
||||
'economics:read',
|
||||
'team:read',
|
||||
'deal:write',
|
||||
'activity:write',
|
||||
'motion:write',
|
||||
],
|
||||
lead: [
|
||||
'book:read',
|
||||
'economics:read',
|
||||
@@ -142,6 +149,8 @@ describe('the whole role × capability matrix', () => {
|
||||
'deal:write',
|
||||
'activity:write',
|
||||
'commitment:write',
|
||||
'motion:write',
|
||||
'motion:publish',
|
||||
],
|
||||
admin: [
|
||||
'book:read',
|
||||
@@ -153,6 +162,8 @@ describe('the whole role × capability matrix', () => {
|
||||
'contract:sign',
|
||||
'data:import',
|
||||
'integration:connect',
|
||||
'motion:write',
|
||||
'motion:publish',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -188,6 +199,8 @@ describe('the whole role × capability matrix', () => {
|
||||
'data:import',
|
||||
'fact:review',
|
||||
'integration:connect',
|
||||
'motion:write',
|
||||
'motion:publish',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
-- Motion: the library, engagements, artifacts and the append-only score log.
|
||||
--
|
||||
-- Hand-written, and it has to be. `motion_templates.origin_artifact_id`
|
||||
-- references `engagement_artifacts`, and `engagement_artifacts.template_id`
|
||||
-- references `motion_templates` — the promotion loop is a foreign key cycle,
|
||||
-- and there is no ordering of two CREATE TABLE statements that satisfies both.
|
||||
-- Drizzle emits every constraint with the table it belongs to and will not
|
||||
-- order this for you, so the second half of the cycle is added below as its own
|
||||
-- ALTER TABLE once both tables exist. Generated output for this schema fails on
|
||||
-- `relation "engagement_artifacts" does not exist`.
|
||||
CREATE TABLE "motion_templates" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"version" integer DEFAULT 1 NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"summary" text NOT NULL,
|
||||
"body" text NOT NULL,
|
||||
"fields" jsonb,
|
||||
"stage" text NOT NULL,
|
||||
"visibility" text DEFAULT 'private' NOT NULL,
|
||||
"owner_user_id" uuid,
|
||||
"supersedes_id" uuid,
|
||||
"origin_artifact_id" uuid,
|
||||
"is_system" boolean DEFAULT false NOT NULL,
|
||||
"usage_count" integer DEFAULT 0 NOT NULL,
|
||||
"archived_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "motion_templates_slug_version_key" UNIQUE("slug","version"),
|
||||
CONSTRAINT "motion_templates_kind_check" CHECK ("motion_templates"."kind" IN ('discovery', 'qualification', 'poc', 'proposal', 'pricing', 'architecture', 'case_study', 'narrative', 'playbook')),
|
||||
CONSTRAINT "motion_templates_stage_check" CHECK ("motion_templates"."stage" IN ('qualification', 'legal', 'scoping', 'proposal', 'procurement', 'poc', 'deployment', 'expansion', 'closed_won', 'closed_lost')),
|
||||
CONSTRAINT "motion_templates_visibility_check" CHECK ("motion_templates"."visibility" IN ('private', 'shared')),
|
||||
CONSTRAINT "motion_templates_version_positive_check" CHECK ("motion_templates"."version" > 0),
|
||||
CONSTRAINT "motion_templates_private_has_owner_check" CHECK ("motion_templates"."visibility" <> 'private' OR "motion_templates"."owner_user_id" IS NOT NULL)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "engagements" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"demand_deal_id" uuid NOT NULL,
|
||||
"playbook_template_id" uuid,
|
||||
"owner_user_id" uuid,
|
||||
"status" text DEFAULT 'open' NOT NULL,
|
||||
"summary" text,
|
||||
"opened_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"closed_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "engagements_demand_deal_key" UNIQUE("demand_deal_id"),
|
||||
CONSTRAINT "engagements_status_check" CHECK ("engagements"."status" IN ('open', 'won', 'lost', 'paused'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "engagement_artifacts" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"engagement_id" uuid NOT NULL,
|
||||
"template_id" uuid,
|
||||
"kind" text NOT NULL,
|
||||
"stage" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"body" text NOT NULL,
|
||||
"fields" jsonb,
|
||||
"status" text DEFAULT 'draft' NOT NULL,
|
||||
"authored_by_user_id" uuid,
|
||||
"promoted_template_id" uuid,
|
||||
"archived_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "engagement_artifacts_kind_check" CHECK ("engagement_artifacts"."kind" IN ('discovery', 'qualification', 'poc', 'proposal', 'pricing', 'architecture', 'case_study', 'narrative', 'playbook')),
|
||||
CONSTRAINT "engagement_artifacts_stage_check" CHECK ("engagement_artifacts"."stage" IN ('qualification', 'legal', 'scoping', 'proposal', 'procurement', 'poc', 'deployment', 'expansion', 'closed_won', 'closed_lost')),
|
||||
CONSTRAINT "engagement_artifacts_status_check" CHECK ("engagement_artifacts"."status" IN ('draft', 'review', 'final'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "qualification_scores" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"engagement_id" uuid NOT NULL,
|
||||
"framework_template_id" uuid,
|
||||
"dimensions" jsonb NOT NULL,
|
||||
"basis_points" integer NOT NULL,
|
||||
"band" text NOT NULL,
|
||||
"note" text,
|
||||
"scored_by_user_id" uuid,
|
||||
"scored_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "qualification_scores_basis_points_check" CHECK ("qualification_scores"."basis_points" >= 0 AND "qualification_scores"."basis_points" <= 10000)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "motion_templates" ADD CONSTRAINT "motion_templates_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "motion_templates" ADD CONSTRAINT "motion_templates_supersedes_id_motion_templates_id_fk" FOREIGN KEY ("supersedes_id") REFERENCES "public"."motion_templates"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "engagements" ADD CONSTRAINT "engagements_demand_deal_id_demand_deals_id_fk" FOREIGN KEY ("demand_deal_id") REFERENCES "public"."demand_deals"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "engagements" ADD CONSTRAINT "engagements_playbook_template_id_motion_templates_id_fk" FOREIGN KEY ("playbook_template_id") REFERENCES "public"."motion_templates"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "engagements" ADD CONSTRAINT "engagements_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "engagement_artifacts" ADD CONSTRAINT "engagement_artifacts_engagement_id_engagements_id_fk" FOREIGN KEY ("engagement_id") REFERENCES "public"."engagements"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "engagement_artifacts" ADD CONSTRAINT "engagement_artifacts_template_id_motion_templates_id_fk" FOREIGN KEY ("template_id") REFERENCES "public"."motion_templates"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "engagement_artifacts" ADD CONSTRAINT "engagement_artifacts_authored_by_user_id_users_id_fk" FOREIGN KEY ("authored_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "engagement_artifacts" ADD CONSTRAINT "engagement_artifacts_promoted_template_fk" FOREIGN KEY ("promoted_template_id") REFERENCES "public"."motion_templates"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
-- The other half of the loop, and the reason this file is hand-written.
|
||||
ALTER TABLE "motion_templates" ADD CONSTRAINT "motion_templates_origin_artifact_fk" FOREIGN KEY ("origin_artifact_id") REFERENCES "public"."engagement_artifacts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "qualification_scores" ADD CONSTRAINT "qualification_scores_engagement_id_engagements_id_fk" FOREIGN KEY ("engagement_id") REFERENCES "public"."engagements"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "qualification_scores" ADD CONSTRAINT "qualification_scores_framework_template_fk" FOREIGN KEY ("framework_template_id") REFERENCES "public"."motion_templates"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "motion_templates_kind_stage_idx" ON "motion_templates" USING btree ("kind","stage");--> statement-breakpoint
|
||||
CREATE INDEX "motion_templates_visibility_kind_idx" ON "motion_templates" USING btree ("visibility","kind");--> statement-breakpoint
|
||||
CREATE INDEX "motion_templates_owner_idx" ON "motion_templates" USING btree ("owner_user_id");--> statement-breakpoint
|
||||
CREATE INDEX "engagements_status_idx" ON "engagements" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "engagement_artifacts_engagement_stage_idx" ON "engagement_artifacts" USING btree ("engagement_id","stage");--> statement-breakpoint
|
||||
CREATE INDEX "qualification_scores_engagement_idx" ON "qualification_scores" USING btree ("engagement_id","scored_at" DESC);
|
||||
@@ -99,6 +99,13 @@
|
||||
"when": 1786700000000,
|
||||
"tag": "0013_learn_self_hosted_provider",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1786800000000,
|
||||
"tag": "0014_motion",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -7,7 +7,7 @@
|
||||
* situation this command is for — someone demoing on top of their own data —
|
||||
* and it must leave that data untouched.
|
||||
*/
|
||||
import { eq, inArray, like } from 'drizzle-orm';
|
||||
import { eq, inArray, like, sql } from 'drizzle-orm';
|
||||
import { unstampDemoActivity } from './activities';
|
||||
import {
|
||||
accounts,
|
||||
@@ -21,8 +21,12 @@ import {
|
||||
contractObligations,
|
||||
contracts,
|
||||
demandDeals,
|
||||
engagementArtifacts,
|
||||
engagements,
|
||||
exportAuthorizations,
|
||||
learnResources,
|
||||
motionTemplates,
|
||||
qualificationScores,
|
||||
slaTerms,
|
||||
teamMemberships,
|
||||
users,
|
||||
@@ -79,6 +83,68 @@ export async function clear(context: DemoContext): Promise<void> {
|
||||
if (demandDealIds.length > 0) {
|
||||
await db.delete(capacityRequests).where(inArray(capacityRequests.demandDealId, demandDealIds));
|
||||
}
|
||||
/*
|
||||
* The Motion rows, before the deals they hang off.
|
||||
*
|
||||
* `engagements.demand_deal_id` cascades, so the delete below would take the
|
||||
* engagements, their artefacts and their scores with it — but not the
|
||||
* promoted version 2, which is a `motion_templates` row and would be left
|
||||
* behind as an orphaned version of a shipped lineage with its
|
||||
* `origin_artifact_id` quietly set to null. So the promoted template goes
|
||||
* first, and the three engagement tables are removed explicitly rather than
|
||||
* by cascade, in child-to-parent order, so the teardown reads as what it
|
||||
* does. The starter library itself is NOT touched: it is authored product
|
||||
* content from the base seed, carries no prefix, and survives `--clear` the
|
||||
* same way the PIG-hosted learn rows do.
|
||||
*/
|
||||
const demoEngagements = await db
|
||||
.select({ id: engagements.id })
|
||||
.from(engagements)
|
||||
.where(like(engagements.summary, `${prefix}%`));
|
||||
const engagementIds = demoEngagements.map((engagement) => engagement.id);
|
||||
if (engagementIds.length > 0) {
|
||||
/*
|
||||
* Give back the `usage_count` the demo book borrowed, before the artefacts
|
||||
* that justify it are deleted.
|
||||
*
|
||||
* Above zero, `usage_count` makes a template permanently un-editable — the
|
||||
* API answers 409 and tells you to cut a new version. So a teardown that
|
||||
* left the counter raised would hand back a starter library three of whose
|
||||
* templates can never be edited again, for engagements that no longer
|
||||
* exist, and nothing in the UI would explain why. Decremented by exactly
|
||||
* what the demo added rather than reset to zero, because a real
|
||||
* instantiation on the same template must survive `--clear`.
|
||||
*/
|
||||
const borrowed = await db
|
||||
.select({ templateId: engagementArtifacts.templateId })
|
||||
.from(engagementArtifacts)
|
||||
.where(inArray(engagementArtifacts.engagementId, engagementIds));
|
||||
const usageByTemplate = new Map<string, number>();
|
||||
for (const row of borrowed) {
|
||||
if (row.templateId) {
|
||||
usageByTemplate.set(row.templateId, (usageByTemplate.get(row.templateId) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
for (const [templateId, count] of usageByTemplate) {
|
||||
await db
|
||||
.update(motionTemplates)
|
||||
.set({ usageCount: sql`greatest(${motionTemplates.usageCount} - ${count}, 0)` })
|
||||
.where(eq(motionTemplates.id, templateId));
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(qualificationScores)
|
||||
.where(inArray(qualificationScores.engagementId, engagementIds));
|
||||
await db
|
||||
.delete(engagementArtifacts)
|
||||
.where(inArray(engagementArtifacts.engagementId, engagementIds));
|
||||
await db.delete(engagements).where(inArray(engagements.id, engagementIds));
|
||||
}
|
||||
// After the artefacts, so `origin_artifact_id` is already gone rather than
|
||||
// being set null on the way past. Scoped to the prefix, which the seeded
|
||||
// library deliberately does not carry.
|
||||
await db.delete(motionTemplates).where(like(motionTemplates.title, `${prefix}%`));
|
||||
|
||||
await db.delete(demandDeals).where(like(demandDeals.name, `${prefix}%`));
|
||||
await db.delete(supplyDeals).where(like(supplyDeals.name, `${prefix}%`));
|
||||
await db.delete(capacityCommitments).where(like(capacityCommitments.name, `${prefix}%`));
|
||||
|
||||
@@ -55,6 +55,7 @@ import { seedCompliance } from './compliance';
|
||||
import { seedDemandPaper } from './contracts';
|
||||
import { seedDemand } from './demand';
|
||||
import { seedHostedLearn, seedLearn } from './learn';
|
||||
import { seedMotionEngagements } from './motion';
|
||||
import { seedSupply } from './supply';
|
||||
|
||||
const db = createDatabase();
|
||||
@@ -173,6 +174,12 @@ export async function seedDemo(context: DemoContext): Promise<void> {
|
||||
// because it is not demo data and must not be removed with `--clear`.
|
||||
const hosted = await seedHostedLearn(context);
|
||||
|
||||
// After the demand book, which owns the deals both engagements hang off, and
|
||||
// after the base seed has put the starter library in place — an engagement
|
||||
// whose artifacts came from nothing would demonstrate the folder rather than
|
||||
// the loop. Run `pnpm db:seed` before `pnpm db:demo`, as the README says.
|
||||
const motion = await seedMotionEngagements(context);
|
||||
|
||||
const facts = await seedFacts(context);
|
||||
|
||||
console.log(' 5 capacity commitments (4 live, 1 lapsed), with sites, MSAs and negotiated SLAs');
|
||||
@@ -197,6 +204,11 @@ export async function seedDemo(context: DemoContext): Promise<void> {
|
||||
console.log(
|
||||
` ${learn.total} illustrative concept videos (${learn.added} new), members-only`,
|
||||
);
|
||||
console.log(
|
||||
` ${motion.engagements} Motion engagement(s) with ${motion.artifacts} artefact(s) and ` +
|
||||
`${motion.scores} qualification score(s) — ${motion.promoted} artefact promoted back into ` +
|
||||
'the library as a version 2',
|
||||
);
|
||||
console.log(
|
||||
` ${hosted.present} PIG-hosted learn videos (${hosted.added} new)` +
|
||||
`${hosted.missing > 0 ? `, ${hosted.missing} manifest entries with no file yet` : ''}`,
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* Two demo engagements, chosen so the loop is visible without a live customer.
|
||||
*
|
||||
* The loop is the only reason Motion exists:
|
||||
*
|
||||
* library template --instantiate--> engagement artifact --promote--> v2
|
||||
*
|
||||
* A screenshot of the library alone shows a folder of documents, which is not
|
||||
* the claim. So one engagement is **mid-POC with three qualification scores**,
|
||||
* because a single score is a number and three are a trajectory — the movement
|
||||
* is the evidence that qualification happened at all — and the other is
|
||||
* **closed-won with a promoted artifact**, which puts a version 2 into the
|
||||
* library carrying `supersedes_id` back to the shipped version 1 and
|
||||
* `origin_artifact_id` back to the engagement that proved it. Both halves of
|
||||
* the FK cycle are therefore populated on a first run.
|
||||
*
|
||||
* Both hang off demand deals the demand book already created, rather than deals
|
||||
* of their own: an engagement has no independent existence, and inventing a
|
||||
* deal here would put a thirteenth row on a pipeline whose counts are quoted in
|
||||
* `demo/index.ts`.
|
||||
*
|
||||
* The scores are computed with `motionScoreBasisPoints` rather than written as
|
||||
* literals. Hard-coding 6600 would let the seed and the product disagree about
|
||||
* what the same dimensions are worth, and the first place anyone would notice
|
||||
* is a demo.
|
||||
*/
|
||||
import { motionBand, motionScoreBasisPoints, type MotionDimensionScore } from '@pig/core';
|
||||
import { and, eq, like, sql } from 'drizzle-orm';
|
||||
import {
|
||||
demandDeals,
|
||||
engagementArtifacts,
|
||||
engagements,
|
||||
motionTemplates,
|
||||
qualificationScores,
|
||||
users,
|
||||
} from '../../schema/index';
|
||||
import type { DemoContext } from './index';
|
||||
|
||||
/**
|
||||
* The scorecard's dimensions and their authored weights, copied from
|
||||
* `seed/motion/trainability-qualification.json`.
|
||||
*
|
||||
* Copied rather than read out of the seeded row, deliberately: a stored score
|
||||
* is a snapshot of the framework *as it was scored*, which is the whole reason
|
||||
* `qualification_scores.dimensions` holds the weights rather than a reference.
|
||||
* Reading them live would make the demo history silently re-weight itself the
|
||||
* day someone edits the template, which is precisely the behaviour the column
|
||||
* exists to prevent.
|
||||
*/
|
||||
const SCORECARD_WEIGHTS: readonly { readonly id: string; readonly weight: number }[] = [
|
||||
{ id: 'task_definability', weight: 10 },
|
||||
{ id: 'verifier_quality', weight: 13 },
|
||||
{ id: 'trace_data_rights', weight: 8 },
|
||||
{ id: 'baseline_measured', weight: 8 },
|
||||
{ id: 'headroom', weight: 10 },
|
||||
{ id: 'env_constructibility', weight: 8 },
|
||||
{ id: 'metric_owner', weight: 9 },
|
||||
{ id: 'budget_source', weight: 9 },
|
||||
{ id: 'exec_sponsor', weight: 4 },
|
||||
{ id: 'security_legal_path', weight: 8 },
|
||||
{ id: 'forcing_function', weight: 8 },
|
||||
{ id: 'expansion_surface', weight: 5 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Three passes over the same scorecard, in the order they were run.
|
||||
*
|
||||
* The trajectory is the teaching: the deal opens borderline on an unmeasured
|
||||
* verifier and no named budget, the POC scoping call fixes the verifier, and
|
||||
* the third pass lands once finance names a source. Two dimensions deliberately
|
||||
* do NOT move — `trace_data_rights` and `expansion_surface` — because a
|
||||
* scorecard where every number rises on every pass is a scorecard nobody is
|
||||
* really filling in.
|
||||
*/
|
||||
const SCORE_PASSES: readonly {
|
||||
readonly daysAgo: number;
|
||||
readonly note: string;
|
||||
readonly scores: Readonly<Record<string, number>>;
|
||||
}[] = [
|
||||
{
|
||||
daysAgo: 52,
|
||||
note:
|
||||
'First pass, straight out of technical discovery. The task is well specified and there ' +
|
||||
'is real headroom, but the verifier is a rubric nobody has run against human labels and ' +
|
||||
'no budget line has been named. Not yet — and the two things to fix are explicit.',
|
||||
scores: {
|
||||
task_definability: 3,
|
||||
verifier_quality: 1,
|
||||
trace_data_rights: 2,
|
||||
baseline_measured: 2,
|
||||
headroom: 3,
|
||||
env_constructibility: 2,
|
||||
metric_owner: 2,
|
||||
budget_source: 1,
|
||||
exec_sponsor: 2,
|
||||
security_legal_path: 2,
|
||||
forcing_function: 2,
|
||||
expansion_surface: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
daysAgo: 27,
|
||||
note:
|
||||
'Re-scored after the POC scoping call. They ran the judge against 300 human-labelled ' +
|
||||
'items and the baseline is now measured rather than remembered. Budget is still a ' +
|
||||
'reallocation nobody has signed.',
|
||||
scores: {
|
||||
task_definability: 3,
|
||||
verifier_quality: 3,
|
||||
trace_data_rights: 2,
|
||||
baseline_measured: 3,
|
||||
headroom: 3,
|
||||
env_constructibility: 3,
|
||||
metric_owner: 3,
|
||||
budget_source: 1,
|
||||
exec_sponsor: 2,
|
||||
security_legal_path: 3,
|
||||
forcing_function: 2,
|
||||
expansion_surface: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
daysAgo: 6,
|
||||
note:
|
||||
'Third pass, in POC week two. Finance named the source and the VP Engineering owns the ' +
|
||||
'metric in their own planning doc. Data rights are unchanged: the customer still cannot ' +
|
||||
'export traces beyond the pilot without a DPA amendment, and that is the live risk.',
|
||||
scores: {
|
||||
task_definability: 4,
|
||||
verifier_quality: 3,
|
||||
trace_data_rights: 2,
|
||||
baseline_measured: 3,
|
||||
headroom: 4,
|
||||
env_constructibility: 3,
|
||||
metric_owner: 4,
|
||||
budget_source: 3,
|
||||
exec_sponsor: 3,
|
||||
security_legal_path: 3,
|
||||
forcing_function: 3,
|
||||
expansion_surface: 3,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function dimensionsFor(scores: Readonly<Record<string, number>>): MotionDimensionScore[] {
|
||||
return SCORECARD_WEIGHTS.map((dimension) => ({
|
||||
id: dimension.id,
|
||||
weight: dimension.weight,
|
||||
score: scores[dimension.id] ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async function findDeal(context: DemoContext, name: string): Promise<string | undefined> {
|
||||
const [deal] = await context.db
|
||||
.select({ id: demandDeals.id })
|
||||
.from(demandDeals)
|
||||
.where(eq(demandDeals.name, name))
|
||||
.limit(1);
|
||||
return deal?.id;
|
||||
}
|
||||
|
||||
async function findSeller(context: DemoContext, name: string): Promise<string | null> {
|
||||
const [seller] = await context.db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.name, `${context.prefix}${name}`))
|
||||
.limit(1);
|
||||
return seller?.id ?? null;
|
||||
}
|
||||
|
||||
async function findTemplate(
|
||||
context: DemoContext,
|
||||
slug: string,
|
||||
): Promise<{ id: string; version: number } | undefined> {
|
||||
const [template] = await context.db
|
||||
.select({ id: motionTemplates.id, version: motionTemplates.version })
|
||||
.from(motionTemplates)
|
||||
.where(and(eq(motionTemplates.slug, slug), eq(motionTemplates.isSystem, true)))
|
||||
.limit(1);
|
||||
return template;
|
||||
}
|
||||
|
||||
export async function seedMotionEngagements(
|
||||
context: DemoContext,
|
||||
): Promise<{ engagements: number; artifacts: number; scores: number; promoted: number }> {
|
||||
const { db, prefix, at } = context;
|
||||
|
||||
/*
|
||||
* The whole section is skipped if either engagement already exists.
|
||||
*
|
||||
* `engagements_demand_deal_key` would refuse a duplicate engagement, but
|
||||
* nothing would refuse a second set of artifacts or — worse — a second
|
||||
* promotion, and a second promotion writes case-study-frame v3 on top of the
|
||||
* v2 this seed already created. `qualification_scores` is append-only by
|
||||
* design and so has no constraint to conflict on at all; three more rows on
|
||||
* every run would turn the trajectory into noise. An existence check on the
|
||||
* parent is the only thing that covers all three.
|
||||
*/
|
||||
const existing = await db
|
||||
.select({ id: engagements.id })
|
||||
.from(engagements)
|
||||
.where(like(engagements.summary, `${prefix}%`))
|
||||
.limit(1);
|
||||
if (existing.length > 0) {
|
||||
return { engagements: 0, artifacts: 0, scores: 0, promoted: 0 };
|
||||
}
|
||||
|
||||
const playbook = await findTemplate(context, 'strategic-deployment-playbook');
|
||||
const scorecard = await findTemplate(context, 'trainability-qualification');
|
||||
const pocTemplate = await findTemplate(context, 'post-training-poc');
|
||||
const caseStudy = await findTemplate(context, 'case-study-frame');
|
||||
|
||||
let engagementCount = 0;
|
||||
let artifactCount = 0;
|
||||
let scoreCount = 0;
|
||||
let promotedCount = 0;
|
||||
|
||||
// ------------------------------------------------- mid-POC, still scoring
|
||||
const halcyonDealId = await findDeal(context, `${prefix}Managed post-training run`);
|
||||
const ines = await findSeller(context, 'Ines Fabre');
|
||||
|
||||
/*
|
||||
* A missing deal is reported, not skipped in silence.
|
||||
*
|
||||
* Both engagements hang off deals the demand book creates, and that book
|
||||
* skips an account it has already seen — so a database where the accounts
|
||||
* survive but the deals were removed leaves these lookups empty and this
|
||||
* whole section a no-op that reports zeroes and looks like a bug in the
|
||||
* loader. It was found exactly that way, on a shared development database.
|
||||
*/
|
||||
if (!halcyonDealId) {
|
||||
console.warn(
|
||||
` No "${prefix}Managed post-training run" deal — skipping the mid-POC engagement. ` +
|
||||
'Run `pnpm db:demo -- --clear` and reseed to rebuild the demand book.',
|
||||
);
|
||||
}
|
||||
|
||||
if (halcyonDealId) {
|
||||
const [engagement] = await db
|
||||
.insert(engagements)
|
||||
.values({
|
||||
demandDealId: halcyonDealId,
|
||||
playbookTemplateId: playbook?.id ?? null,
|
||||
ownerUserId: ines,
|
||||
status: 'open',
|
||||
summary: `${prefix}Post-training motion on the Halcyon summarisation workflow — POC week two.`,
|
||||
openedAt: at(-58),
|
||||
})
|
||||
.returning({ id: engagements.id });
|
||||
if (engagement) {
|
||||
engagementCount += 1;
|
||||
|
||||
await db.insert(engagementArtifacts).values({
|
||||
engagementId: engagement.id,
|
||||
templateId: scorecard?.id ?? null,
|
||||
kind: 'discovery',
|
||||
stage: 'qualification',
|
||||
title: `${prefix}Discovery notes — Halcyon summarisation`,
|
||||
body:
|
||||
'## What they are trying to do\n\n' +
|
||||
'Reduce reviewer time on internal research summaries. Today a senior analyst reads a ' +
|
||||
'40-page source and writes a one-page brief; the bar is "would the head of research ' +
|
||||
'send this out unedited".\n\n' +
|
||||
'## Unit of work\n\n' +
|
||||
'One source document in, one brief out. They have 4,100 historical pairs and the ' +
|
||||
'reviewer decision on each.\n\n' +
|
||||
'## Verifier\n\n' +
|
||||
'A model judge calibrated against 300 of those human decisions. Agreement is 0.81 ' +
|
||||
'Cohen’s kappa, measured on a held-out slice rather than the calibration set.\n\n' +
|
||||
'## The live risk\n\n' +
|
||||
'Trace export beyond the pilot needs a DPA amendment their counsel has not seen yet.',
|
||||
status: 'final',
|
||||
authoredByUserId: ines,
|
||||
fields: { source: 'Technical discovery call, plus the follow-up with the data owner.' },
|
||||
});
|
||||
|
||||
await db.insert(engagementArtifacts).values({
|
||||
engagementId: engagement.id,
|
||||
templateId: pocTemplate?.id ?? null,
|
||||
kind: 'poc',
|
||||
stage: 'poc',
|
||||
title: `${prefix}POC plan — summarisation reward model`,
|
||||
body:
|
||||
'## Success criterion, agreed in writing\n\n' +
|
||||
'Judge-scored acceptance rate on a held-out set of 400 briefs rises from the measured ' +
|
||||
'baseline of 61% to at least 78%, at no more than 1.4× the current inference cost ' +
|
||||
'per brief.\n\n' +
|
||||
'## What we run\n\n' +
|
||||
'Two weeks on 32× H100. Week one builds the environment and reproduces the ' +
|
||||
'baseline; week two is the training run and the evaluation.\n\n' +
|
||||
'## What we hand back\n\n' +
|
||||
'The environment, the eval harness, and the number — whichever way it comes out. ' +
|
||||
'A POC that reports a failure honestly is what makes the next number believable.\n\n' +
|
||||
'## Open\n\n' +
|
||||
'Trace rights beyond the pilot. Blocked on the DPA amendment.',
|
||||
status: 'review',
|
||||
authoredByUserId: ines,
|
||||
fields: { baselineAcceptancePct: 61, targetAcceptancePct: 78, gpuCount: 32, weeks: 2 },
|
||||
});
|
||||
artifactCount += 2;
|
||||
|
||||
for (const pass of SCORE_PASSES) {
|
||||
const dimensions = dimensionsFor(pass.scores);
|
||||
const basisPoints = motionScoreBasisPoints(dimensions);
|
||||
await db.insert(qualificationScores).values({
|
||||
engagementId: engagement.id,
|
||||
frameworkTemplateId: scorecard?.id ?? null,
|
||||
// The column is `Record<string, unknown>[]`; Drizzle's jsonb `$type`
|
||||
// will not take an interface with readonly properties, and widening
|
||||
// the column to suit the seed would lose the shape everywhere else.
|
||||
dimensions: dimensions as unknown as Record<string, unknown>[],
|
||||
basisPoints,
|
||||
band: motionBand(basisPoints).label,
|
||||
note: `${prefix}${pass.note}`,
|
||||
scoredByUserId: ines,
|
||||
scoredAt: at(-pass.daysAgo),
|
||||
});
|
||||
scoreCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------- closed won, and promoted afterwards
|
||||
const northwindDealId = await findDeal(context, `${prefix}A100 inference pilot`);
|
||||
const marcus = await findSeller(context, 'Marcus Oyelaran');
|
||||
|
||||
if (!northwindDealId) {
|
||||
console.warn(
|
||||
` No "${prefix}A100 inference pilot" deal — skipping the closed-won engagement, and ` +
|
||||
'with it the promotion that demonstrates the loop.',
|
||||
);
|
||||
}
|
||||
|
||||
if (northwindDealId) {
|
||||
const [engagement] = await db
|
||||
.insert(engagements)
|
||||
.values({
|
||||
demandDealId: northwindDealId,
|
||||
playbookTemplateId: playbook?.id ?? null,
|
||||
ownerUserId: marcus,
|
||||
status: 'won',
|
||||
summary: `${prefix}Northwind inference pilot — won, and written up.`,
|
||||
openedAt: at(-190),
|
||||
closedAt: at(-96),
|
||||
})
|
||||
.returning({ id: engagements.id });
|
||||
|
||||
if (engagement) {
|
||||
engagementCount += 1;
|
||||
|
||||
const [artifact] = await db
|
||||
.insert(engagementArtifacts)
|
||||
.values({
|
||||
engagementId: engagement.id,
|
||||
templateId: caseStudy?.id ?? null,
|
||||
kind: 'case_study',
|
||||
stage: 'expansion',
|
||||
title: `${prefix}Northwind Robotics — A100 inference pilot`,
|
||||
body:
|
||||
'## The problem\n\n' +
|
||||
'Northwind ran perception inference on reserved A100s they had bought for training, ' +
|
||||
'and were paying training prices for a serving workload with a 40:1 peak-to-trough ' +
|
||||
'shape.\n\n' +
|
||||
'## What we did\n\n' +
|
||||
'Moved the trough onto committed capacity and the peak onto burst, with the split ' +
|
||||
'set by their own 90-day request trace rather than by a headline number.\n\n' +
|
||||
'## The number\n\n' +
|
||||
'Cost per million inferences fell 34%. Measured against their own billing, over a ' +
|
||||
'full quarter, not against a list price.\n\n' +
|
||||
'## What made it work, and what to reuse\n\n' +
|
||||
'They had the request trace. Every engagement since has asked for it in the first ' +
|
||||
'call, which is the change this write-up put into the frame itself.',
|
||||
status: 'final',
|
||||
authoredByUserId: marcus,
|
||||
fields: {
|
||||
metric: 'Cost per million inferences',
|
||||
improvementPct: 34,
|
||||
window: 'One quarter, customer billing',
|
||||
approvedForExternalUse: false,
|
||||
},
|
||||
})
|
||||
.returning({ id: engagementArtifacts.id, title: engagementArtifacts.title });
|
||||
|
||||
artifactCount += 1;
|
||||
|
||||
/*
|
||||
* The promotion, written by hand rather than by calling the API service:
|
||||
* the seed has no HTTP surface and no principal. It follows the same
|
||||
* rules the service does — version = previous + 1, `supersedesId` to the
|
||||
* shipped v1, `originArtifactId` to the artifact, `visibility: 'shared'`,
|
||||
* `isSystem: false` — because a demo that produced a row the real path
|
||||
* could not have produced would teach the wrong shape of the table.
|
||||
*
|
||||
* `isSystem` is false and the title is prefixed, which is what lets
|
||||
* `clear()` find it. It is a version 2 of a shipped lineage, so removing
|
||||
* the demo book genuinely returns the library to what ships.
|
||||
*/
|
||||
if (artifact && caseStudy) {
|
||||
const [promoted] = await db
|
||||
.insert(motionTemplates)
|
||||
.values({
|
||||
kind: 'case_study',
|
||||
stage: 'expansion',
|
||||
slug: 'case-study-frame',
|
||||
version: caseStudy.version + 1,
|
||||
title: `${prefix}Case Study Frame — ask for the request trace first`,
|
||||
summary:
|
||||
'Version 2, promoted out of the Northwind pilot write-up. Adds the one question ' +
|
||||
'that made that engagement measurable: get the customer’s own usage trace ' +
|
||||
'before anyone quotes a saving.',
|
||||
body:
|
||||
'_Promoted from an engagement. The change from version 1 is the section below._\n\n' +
|
||||
'## Before you frame anything: get the trace\n\n' +
|
||||
'A case study is only as good as the baseline it is measured against, and the ' +
|
||||
'only baseline a customer cannot argue with afterwards is their own telemetry. ' +
|
||||
'Ask for it in the first call, not at write-up time.\n\n' +
|
||||
'## Everything else\n\n' +
|
||||
'As version 1: the problem, what we did, the number, what to reuse.',
|
||||
fields: {
|
||||
promotedFrom: artifact.title,
|
||||
addedSection: 'Before you frame anything: get the trace',
|
||||
},
|
||||
visibility: 'shared',
|
||||
ownerUserId: marcus,
|
||||
supersedesId: caseStudy.id,
|
||||
originArtifactId: artifact.id,
|
||||
isSystem: false,
|
||||
})
|
||||
.onConflictDoNothing({ target: [motionTemplates.slug, motionTemplates.version] })
|
||||
.returning({ id: motionTemplates.id });
|
||||
|
||||
if (promoted) {
|
||||
await db
|
||||
.update(engagementArtifacts)
|
||||
.set({ promotedTemplateId: promoted.id })
|
||||
.where(eq(engagementArtifacts.id, artifact.id));
|
||||
promotedCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* `usage_count` is what the API reads to refuse a `PATCH` on a used
|
||||
* template, so the demo must move it or a library where three templates have
|
||||
* visibly been instantiated still reads as never used — and the rule the
|
||||
* whole feature turns on would be undemonstrable. Incremented in one sweep at
|
||||
* the end rather than per insert, so the count is derived from the artefacts
|
||||
* that actually landed and `clear()` can undo it by counting the same rows.
|
||||
*/
|
||||
const instantiated = await db
|
||||
.select({ templateId: engagementArtifacts.templateId })
|
||||
.from(engagementArtifacts)
|
||||
.where(like(engagementArtifacts.title, `${prefix}%`));
|
||||
const usageByTemplate = new Map<string, number>();
|
||||
for (const row of instantiated) {
|
||||
if (row.templateId) usageByTemplate.set(row.templateId, (usageByTemplate.get(row.templateId) ?? 0) + 1);
|
||||
}
|
||||
for (const [templateId, count] of usageByTemplate) {
|
||||
await db
|
||||
.update(motionTemplates)
|
||||
.set({ usageCount: sql`${motionTemplates.usageCount} + ${count}` })
|
||||
.where(eq(motionTemplates.id, templateId));
|
||||
}
|
||||
|
||||
return {
|
||||
engagements: engagementCount,
|
||||
artifacts: artifactCount,
|
||||
scores: scoreCount,
|
||||
promoted: promotedCount,
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,9 @@
|
||||
* • **A worked example** of the thing PIG exists for: one capacity
|
||||
* commitment, two allocations against it, and therefore a real margin
|
||||
* number and a real idle-capacity alert on the dashboard.
|
||||
* • **The Motion starter library.** Authored content shipped with the
|
||||
* product rather than invented data, which is why it lives here and not in
|
||||
* `demo/`. See `motion/index.ts`.
|
||||
*
|
||||
* The example is clearly labelled, and the company buying is invented. Real
|
||||
* named companies appear here only with a source; commercial terms attached to
|
||||
@@ -31,6 +34,7 @@ import {
|
||||
teamMemberships,
|
||||
users,
|
||||
} from '../schema/index';
|
||||
import { seedMotionLibrary } from './motion/index';
|
||||
import { PRIME_INTELLECT_PEOPLE, PUBLIC_CUSTOMER_REFERENCES, UNRESOLVED_NAMES } from './people';
|
||||
|
||||
const db = createDatabase();
|
||||
@@ -343,6 +347,16 @@ async function seed() {
|
||||
console.log(' Worked example already present — skipped.');
|
||||
}
|
||||
|
||||
// ------------------------------------------------ the Motion starter library
|
||||
const motion = await seedMotionLibrary(db);
|
||||
console.log(
|
||||
` ${motion.total} Motion starter template(s) (${motion.added} new) — authored content, ` +
|
||||
'shared and system-owned, version 1 of their lineages.',
|
||||
);
|
||||
for (const bad of motion.rejected) {
|
||||
console.error(` SKIPPED a starter template with a value outside the ontology: ${bad}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- the dev user
|
||||
//
|
||||
// Only when the table is empty. With authentication disabled in development
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* The starter library — twelve authored templates, shipped with the product.
|
||||
*
|
||||
* These are **not** demo data and carry no `DEMO — ` prefix. They are written
|
||||
* content in the same class as `docs/learn-scripts.md`: a customer who
|
||||
* self-hosts PIG gets them, uses them against real deals, and promotes their
|
||||
* own versions back over the top. That is why they seed `isSystem: true`,
|
||||
* `visibility: 'shared'`, `version: 1` and `ownerUserId: null` — a shared
|
||||
* system row belongs to the deployment rather than to whoever ran the seed,
|
||||
* and version 1 is the base of a lineage that a customer's promotions extend.
|
||||
*
|
||||
* The prose lives in JSON beside this file rather than in a TypeScript literal
|
||||
* because the bodies are long-form markdown — the shortest is 7kB and the
|
||||
* longest 24kB — and a 200kB module of backtick strings is unreviewable and
|
||||
* unmergeable by two people at once. They are imported statically with an
|
||||
* import attribute rather than read with `readdir`, which was tried first: a
|
||||
* static import makes a missing or renamed file a typecheck failure instead of
|
||||
* a seed that quietly ships eleven templates. Both `tsx` and `tsc` were run
|
||||
* against this to confirm the attribute syntax is understood by each, since
|
||||
* the server runs the TypeScript directly.
|
||||
*
|
||||
* Idempotency is `onConflictDoNothing({ target: [slug, version] })`, which
|
||||
* works **only** because of `motion_templates_slug_version_key`. AGENTS.md §5
|
||||
* is blunt about what that clause does without a constraint to fire on — it
|
||||
* silently duplicated seed data here twice — so `assertNoDuplicates` below
|
||||
* checks the outcome rather than trusting the schema, and CI counts this table
|
||||
* in its idempotency gate alongside `contacts`.
|
||||
*/
|
||||
import { DEMAND_STAGES, isMotionKind, type DemandStage, type MotionKind } from '@pig/core';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import type { Database } from '../../client';
|
||||
import { motionTemplates } from '../../schema/index';
|
||||
|
||||
import caseStudyFrame from './case-study-frame.json' with { type: 'json' };
|
||||
import dataAndSecurityBrief from './data-and-security-brief.json' with { type: 'json' };
|
||||
import postTrainingPoc from './post-training-poc.json' with { type: 'json' };
|
||||
import pricingAndPackaging from './pricing-and-packaging.json' with { type: 'json' };
|
||||
import procurementAndBudgetPath from './procurement-and-budget-path.json' with { type: 'json' };
|
||||
import productionReadiness from './production-readiness.json' with { type: 'json' };
|
||||
import proposalBlocks from './proposal-blocks.json' with { type: 'json' };
|
||||
import referenceArchitectures from './reference-architectures.json' with { type: 'json' };
|
||||
import strategicDeploymentPlaybook from './strategic-deployment-playbook.json' with { type: 'json' };
|
||||
import technicalDiscovery from './technical-discovery.json' with { type: 'json' };
|
||||
import technicalNarratives from './technical-narratives.json' with { type: 'json' };
|
||||
import trainabilityQualification from './trainability-qualification.json' with { type: 'json' };
|
||||
|
||||
/**
|
||||
* The authored shape. `kind` and `stage` are `string` here and narrowed at the
|
||||
* boundary below, because `resolveJsonModule` infers `string` for a JSON string
|
||||
* and there is no honest way to tell TypeScript otherwise without a cast that
|
||||
* would also swallow a genuine typo in the file.
|
||||
*/
|
||||
interface StarterTemplate {
|
||||
readonly kind: string;
|
||||
readonly slug: string;
|
||||
readonly title: string;
|
||||
readonly summary: string;
|
||||
readonly stage: string;
|
||||
readonly body: string;
|
||||
readonly fields: Record<string, unknown>;
|
||||
/** Guidance for whoever runs it. Folded into `fields` — see `toRow`. */
|
||||
readonly notes: string;
|
||||
}
|
||||
|
||||
const STARTER_LIBRARY: readonly StarterTemplate[] = [
|
||||
technicalDiscovery,
|
||||
dataAndSecurityBrief,
|
||||
trainabilityQualification,
|
||||
referenceArchitectures,
|
||||
technicalNarratives,
|
||||
postTrainingPoc,
|
||||
proposalBlocks,
|
||||
pricingAndPackaging,
|
||||
procurementAndBudgetPath,
|
||||
productionReadiness,
|
||||
caseStudyFrame,
|
||||
strategicDeploymentPlaybook,
|
||||
];
|
||||
|
||||
const isDemandStage = (value: string): value is DemandStage =>
|
||||
(DEMAND_STAGES as readonly string[]).includes(value);
|
||||
|
||||
/**
|
||||
* `notes` has no column, deliberately.
|
||||
*
|
||||
* It is guidance for the person running the template — when to reach for it,
|
||||
* what it is not for — rather than a field the artifact renders, so a column
|
||||
* would put it on every engagement artifact's editor for no reason. Keeping it
|
||||
* inside `fields` means promotion carries it forward with the rest of the
|
||||
* structured payload without any special handling in the promote path.
|
||||
*/
|
||||
function toRow(
|
||||
template: StarterTemplate,
|
||||
kind: MotionKind,
|
||||
stage: DemandStage,
|
||||
): typeof motionTemplates.$inferInsert {
|
||||
return {
|
||||
kind,
|
||||
stage,
|
||||
slug: template.slug,
|
||||
version: 1,
|
||||
title: template.title,
|
||||
summary: template.summary,
|
||||
body: template.body,
|
||||
fields: { ...template.fields, notes: template.notes },
|
||||
visibility: 'shared',
|
||||
ownerUserId: null,
|
||||
isSystem: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the outcome, not the constraint.
|
||||
*
|
||||
* Reading `pg_constraint` would prove the unique index exists; counting the
|
||||
* rows proves the clause did its job, which is the thing that has actually
|
||||
* gone wrong here before. It throws rather than warns: a duplicated starter
|
||||
* library is a corrupt library, and every subsequent run would double it again.
|
||||
*
|
||||
* Scoped to version 1, and found by running it: the demo book promotes an
|
||||
* artifact into `case-study-frame` v2, so counting every row for these slugs
|
||||
* reported the loop working as a duplicated seed. Version 1 is the only row
|
||||
* this loader writes and therefore the only one it may assert about.
|
||||
*/
|
||||
async function assertNoDuplicates(db: Database, slugs: readonly string[]): Promise<number> {
|
||||
const rows = await db
|
||||
.select({ slug: motionTemplates.slug })
|
||||
.from(motionTemplates)
|
||||
.where(and(inArray(motionTemplates.slug, [...slugs]), eq(motionTemplates.version, 1)));
|
||||
|
||||
if (rows.length > slugs.length) {
|
||||
const counts = new Map<string, number>();
|
||||
for (const row of rows) counts.set(row.slug, (counts.get(row.slug) ?? 0) + 1);
|
||||
const duplicated = [...counts.entries()].filter(([, count]) => count > 1).map(([slug]) => slug);
|
||||
throw new Error(
|
||||
`The starter library has duplicate rows for: ${duplicated.join(', ')}. ` +
|
||||
'The unique constraint motion_templates_slug_version_key is missing or the ' +
|
||||
'conflict target no longer matches it — see AGENTS.md §5.',
|
||||
);
|
||||
}
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
export async function seedMotionLibrary(
|
||||
db: Database,
|
||||
): Promise<{ total: number; added: number; rejected: readonly string[] }> {
|
||||
const rejected: string[] = [];
|
||||
let added = 0;
|
||||
|
||||
for (const template of STARTER_LIBRARY) {
|
||||
/*
|
||||
* A bad `kind` or `stage` is skipped, not thrown on. These files are
|
||||
* authored by hand and the CHECK constraints would reject the row anyway —
|
||||
* but as an aborted transaction partway through the base seed, taking the
|
||||
* eleven good templates and everything after them down with it. Reporting
|
||||
* the one bad file by name is more useful and leaves the deployment usable.
|
||||
*/
|
||||
if (!isMotionKind(template.kind) || !isDemandStage(template.stage)) {
|
||||
rejected.push(`${template.slug} (kind=${template.kind}, stage=${template.stage})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(motionTemplates)
|
||||
.values(toRow(template, template.kind, template.stage))
|
||||
.onConflictDoNothing({ target: [motionTemplates.slug, motionTemplates.version] })
|
||||
.returning({ id: motionTemplates.id });
|
||||
if (created) added += 1;
|
||||
}
|
||||
|
||||
const total = await assertNoDuplicates(
|
||||
db,
|
||||
STARTER_LIBRARY.map((template) => template.slug),
|
||||
);
|
||||
|
||||
return { total, added, rejected };
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user