Add Motion: the go-to-market operating system on top of the ledger

The ledger answers which contracted capacity is sold, to whom, at what
margin. It says nothing about the motion — the repeatable practice that
turns a customer conversation into a scoped deployment, and turns that
deployment into something the next one reuses.

Motion is deliberately not a parallel entity tree. DEMAND_STAGES already
is the motion, so Motion binds reusable artefacts to the stages of a
demand deal that already exists: an engagement hangs off one deal,
cascade deleted, one per deal by unique constraint.

Nine closed kinds, each declaring which stages it serves, and a starter
library of twelve templates covering all eight open stages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 18:27:03 -07:00
parent 99d165b5e5
commit 516685526c
61 changed files with 13013 additions and 29 deletions
+1
View File
@@ -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';
+226
View File
@@ -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 (010000). 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
View File
@@ -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' },
};
/**
+7
View File
@@ -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;
+187
View File
@@ -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 010000', () => {
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);
});
});
+14 -1
View File
@@ -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',
]),
);
});