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
+2
View File
@@ -72,6 +72,7 @@ import { createNotionImportRoutes, NOTION_OAUTH_CALLBACK_PATH } from './routes/n
import { createGrowthRoutes } from './routes/growth';
import { createCalendarRoutes } from './routes/calendar';
import { createLearnRoutes, LEARN_ACCESS_PATH, LEARN_PUBLIC_PATH } from './routes/learn';
import { createMotionRoutes } from './routes/motion';
import { createReadGuardRoutes } from './routes/read-guards';
import { createActivityRoutes } from './routes/activities';
import { NotificationOutbox } from './services/notification-outbox';
@@ -266,6 +267,7 @@ export function createApp(
app.route('/', createGrowthRoutes(db));
app.route('/', createCalendarRoutes(db));
app.route('/', createLearnRoutes(db));
app.route('/', createMotionRoutes(db));
app.route(
'/',
createPiggyChatRoutes({
+933
View File
@@ -0,0 +1,933 @@
/**
* Motion — the HTTP surface for the library, the engagements and the loop.
*
* Thin by design. Every decision worth arguing about — who may see a private
* template, what promotion writes, how a score is computed — is in
* `services/motion.ts`, so Piggy reaches the same answers in-process without
* going through a handler. The writes are exported `MutationDefinition`
* factories in the register of `capacity-writes.ts`, which is what lets a test
* drive the rules in §7 without an HTTP server or a database.
*
* Two things here are not obvious from the endpoint table.
*
* **`motion:write` and `motion:publish` are team capabilities held on any
* team**, so they are enforced with `requireAnyTeamCapability` rather than a
* `{ capability, team }` pair. A motion template belongs to a person and a
* lineage, not to supply or demand; picking a team to check against would mean
* inventing one, and inventing one is how a research lead ends up unable to
* publish the reference architecture they are the one writing.
*
* **Creating or forking straight to `shared` needs `motion:publish`.** The
* publish endpoint is not the only way into the shared library — `POST
* /templates` and `POST /templates/:id/versions` both take a `visibility` — so
* the gate is applied in all three places rather than on the one door that
* happens to be named after it.
*/
import {
ARTIFACT_STATUSES,
DEMAND_STAGES,
ENGAGEMENT_STATUSES,
MOTION_KINDS,
MOTION_MAX_DIMENSION_SCORE,
MOTION_MIN_DIMENSION_SCORE,
MOTION_VISIBILITIES,
motionBand,
permissionGranted,
resolveWritePermissionGrants,
} from '@pig/core';
import type {
Database,
Engagement,
EngagementArtifact,
MotionTemplate,
QualificationScore,
} from '@pig/db';
import { demandDeals, engagementArtifacts, engagements, motionTemplates } from '@pig/db';
import { eq } from 'drizzle-orm';
import { Hono } from 'hono';
import { z } from 'zod';
import { requireAnyTeamCapability, type Principal } from '../lib/auth';
import {
apiError,
bodylessMutation,
MutationError,
mutation,
type ApiEnv,
type MutationDefinition,
} from '../lib/mutation';
import {
assertTemplateEditable,
assertTemplateWritable,
instantiateArtifact,
loadEngagement,
loadTemplateForWrite,
lockNewestVersion,
motionSlug,
MotionService,
newestVisibleInLineage,
ownsTemplate,
promoteArtifact,
recordScore,
versionConflict,
type MotionTransaction,
type MotionViewer,
} from '../services/motion';
// ------------------------------------------------------------------- schemas
const uuid = z.string().uuid();
const title = z.string().trim().min(1).max(200);
const summary = z.string().trim().min(1).max(2_000);
/** Markdown, and long: a playbook is a document, not a field. */
const bodyText = z.string().max(200_000);
const slug = z.string().trim().min(1).max(80);
const fields = z.record(z.unknown()).nullable();
const templateCreateSchema = z
.object({
kind: z.enum(MOTION_KINDS),
slug: slug.optional(),
title,
summary,
body: bodyText,
stage: z.enum(DEMAND_STAGES),
fields: fields.optional(),
visibility: z.enum(MOTION_VISIBILITIES).optional(),
})
.strict();
/**
* No `kind` and no `visibility`. A lineage that changes kind halfway is a
* different template wearing the same slug, and visibility moves through
* `/publish`, which is where the capability check lives.
*/
const templateUpdateSchema = z
.object({ title, summary, body: bodyText, stage: z.enum(DEMAND_STAGES), fields })
.partial()
.strict()
.refine((input) => Object.keys(input).length > 0, 'At least one change is required.');
const templateVersionSchema = z
.object({
title: title.optional(),
summary: summary.optional(),
body: bodyText.optional(),
stage: z.enum(DEMAND_STAGES).optional(),
fields: fields.optional(),
visibility: z.enum(MOTION_VISIBILITIES).optional(),
})
.strict();
const emptySchema = z.object({}).strict();
const engagementCreateSchema = z
.object({
demandDealId: uuid,
playbookTemplateId: uuid.nullable().optional(),
ownerUserId: uuid.nullable().optional(),
summary: z.string().trim().max(2_000).nullable().optional(),
})
.strict();
const engagementUpdateSchema = z
.object({
status: z.enum(ENGAGEMENT_STATUSES),
ownerUserId: uuid.nullable(),
playbookTemplateId: uuid.nullable(),
summary: z.string().trim().max(2_000).nullable(),
})
.partial()
.strict()
.refine((input) => Object.keys(input).length > 0, 'At least one change is required.');
const artifactCreateSchema = z
.object({
templateId: uuid.optional(),
kind: z.enum(MOTION_KINDS).optional(),
stage: z.enum(DEMAND_STAGES).optional(),
title: title.optional(),
body: bodyText.optional(),
fields: fields.optional(),
status: z.enum(ARTIFACT_STATUSES).optional(),
})
.strict()
.refine(
(input) =>
Boolean(input.templateId) ||
Boolean(input.kind && input.stage && input.title && input.body !== undefined),
'Supply a templateId to instantiate, or a kind, stage, title and body.',
);
const artifactUpdateSchema = z
.object({
title,
body: bodyText,
fields,
stage: z.enum(DEMAND_STAGES),
status: z.enum(ARTIFACT_STATUSES),
archived: z.boolean(),
})
.partial()
.strict()
.refine((input) => Object.keys(input).length > 0, 'At least one change is required.');
const promoteSchema = z
.object({ slug: slug.optional(), title: title.optional(), summary: summary.optional() })
.strict();
/**
* Weights and scores are integers, and the total is recomputed server-side from
* these — the client never posts a score. Bounds come from `@pig/core` so the
* scale is defined in one place.
*/
const scoreSchema = z
.object({
dimensions: z
.array(
z
.object({
id: z.string().trim().min(1).max(120),
weight: z.number().int().min(0).max(1_000),
score: z
.number()
.int()
.min(MOTION_MIN_DIMENSION_SCORE)
.max(MOTION_MAX_DIMENSION_SCORE),
})
.strict(),
)
.max(50),
frameworkTemplateId: uuid.nullable().optional(),
note: z.string().trim().max(2_000).nullable().optional(),
})
.strict();
const filtersSchema = z
.object({
kind: z.enum(MOTION_KINDS).optional(),
stage: z.enum(DEMAND_STAGES).optional(),
visibility: z.enum(MOTION_VISIBILITIES).optional(),
q: z.string().trim().max(200).optional(),
all: z.string().optional(),
})
.strict();
const engagementFiltersSchema = z
.object({ status: z.enum(ENGAGEMENT_STATUSES).optional() })
.strict();
// ------------------------------------------------------------------- helpers
function viewerOf(principal: Principal): MotionViewer {
return { userId: principal.userId, isPlatformAdmin: principal.isPlatformAdmin };
}
const authorizeWrite = {
authorize: (p: Principal) => requireAnyTeamCapability(p, 'motion:write'),
};
const authorizePublish = {
authorize: (p: Principal) => requireAnyTeamCapability(p, 'motion:publish'),
};
/**
* A template id somebody sent, checked before it is stored.
*
* `playbook_template_id` and `framework_template_id` are provenance, and they
* are returned book-wide on every engagement summary. Written raw, an id
* nobody but its owner may read becomes public through the engagement that
* cites it, and a wrong id is a foreign-key 500 rather than a 404. Reading it
* through the visibility filter makes both cases a clean not-found.
*/
async function checkedTemplateId(
tx: MotionTransaction,
principal: Principal,
id: string | null | undefined,
): Promise<void> {
if (!id) return;
await loadTemplateForWrite(tx, viewerOf(principal), id);
}
/** The same gate as `authorizePublish`, applied once the body says `shared`. */
function requirePublishFor(principal: Principal, visibility: string | undefined): void {
if (visibility === 'shared') requireAnyTeamCapability(principal, 'motion:publish');
}
/**
* The hint the detail page renders its Publish button from. It checks the
* `write` scope as well as the capability, because `requireAnyTeamCapability`
* does — a read-only API key belonging to a lead would otherwise be told it may
* publish and then be refused `insufficient_scope` by the endpoint itself.
*/
function canPublish(principal: Principal): boolean {
return (
principal.scopes.includes('write') &&
permissionGranted(resolveWritePermissionGrants(principal), 'motion:publish')
);
}
function requiredId(params: Readonly<Record<string, string>>, resource: string): string {
const id = params.id;
if (!id) throw MutationError.notFound(resource);
return id;
}
// ------------------------------------------------------------ library writes
export function motionTemplateCreateDefinition(): MutationDefinition<
typeof templateCreateSchema,
{ template: MotionTemplate }
> {
return {
schema: templateCreateSchema,
permission: authorizeWrite,
invalidMessage: 'Invalid motion template.',
async mutate({ input, principal, tx, now }) {
requirePublishFor(principal, input.visibility);
const slug = motionSlug(input.slug ?? input.title);
// Checked rather than left to `(slug, version)`, for the reason the
// engagement create gives below: a title that derives an existing slug —
// "Proposal Blocks" is one of the nine shipped starters — would otherwise
// be a 500 with `Internal error` and no way for the caller to tell what
// they collided with.
if (await lockNewestVersion(tx, slug)) {
throw new MutationError(
'template_slug_exists',
`A template lineage already uses the slug "${slug}". POST /api/motion/templates/<id>/versions to add a version to it, or supply a different slug.`,
409,
);
}
const [created] = await tx
.insert(motionTemplates)
.values({
kind: input.kind,
slug,
version: 1,
title: input.title,
summary: input.summary,
body: input.body,
fields: input.fields ?? null,
stage: input.stage,
visibility: input.visibility ?? 'private',
// Never null. The CHECK refuses an unowned private row, and an
// unowned private row is one nobody could ever read back.
ownerUserId: principal.userId,
createdAt: now,
updatedAt: now,
})
.returning()
.catch(versionConflict);
if (!created) throw new Error('Motion template insert returned no row');
return {
data: { template: created },
activity: {
type: 'note',
subject: `Added a motion template: ${created.title}`,
meta: {
action: 'motion_template.created',
templateId: created.id,
kind: created.kind,
slug: created.slug,
visibility: created.visibility,
},
},
};
},
};
}
export function motionTemplateUpdateDefinition(): MutationDefinition<
typeof templateUpdateSchema,
{ template: MotionTemplate }
> {
return {
schema: templateUpdateSchema,
permission: authorizeWrite,
invalidMessage: 'Invalid motion template change.',
async mutate({ input, params, principal, tx, now }) {
const viewer = viewerOf(principal);
const existing = await loadTemplateForWrite(
tx,
viewer,
requiredId(params, 'Motion template'),
);
assertTemplateWritable(viewer, existing);
assertTemplateEditable(existing);
const [updated] = await tx
.update(motionTemplates)
.set({
...(input.title !== undefined ? { title: input.title } : {}),
...(input.summary !== undefined ? { summary: input.summary } : {}),
...(input.body !== undefined ? { body: input.body } : {}),
...(input.stage !== undefined ? { stage: input.stage } : {}),
...(input.fields !== undefined ? { fields: input.fields } : {}),
updatedAt: now,
})
.where(eq(motionTemplates.id, existing.id))
.returning();
if (!updated) throw MutationError.notFound('Motion template');
return {
data: { template: updated },
activity: {
type: 'note',
subject: `Edited a motion template: ${updated.title}`,
meta: {
action: 'motion_template.updated',
templateId: updated.id,
fields: Object.keys(input),
},
},
};
},
};
}
/**
* A new version of a lineage — the answer to a `409 template_in_use`, and the
* "fork to private" affordance, in one endpoint.
*
* Ownership is deliberately NOT required: a shared template belongs to the
* book, and forking it into your own private draft is what a library is for.
* What is required is `motion:publish` if the fork is to land shared.
*/
export function motionTemplateVersionDefinition(): MutationDefinition<
typeof templateVersionSchema,
{ template: MotionTemplate }
> {
return {
schema: templateVersionSchema,
permission: authorizeWrite,
invalidMessage: 'Invalid motion template version.',
async mutate({ input, params, principal, tx, now }) {
requirePublishFor(principal, input.visibility);
const viewer = viewerOf(principal);
const source = await loadTemplateForWrite(
tx,
viewer,
requiredId(params, 'Motion template'),
);
// Two different questions, deliberately answered by two queries. The
// version has to be allocated against the whole lineage or it collides
// with a private fork nobody else can see, but `supersedes_id` must name
// a row this caller could actually fetch — otherwise a fork of a shared
// v1 comes back claiming to supersede somebody's private v4, which both
// leaks that the private versions exist and records a lineage edge that
// is not the one the user made.
const newest = await lockNewestVersion(tx, source.slug);
const previous = (await newestVisibleInLineage(tx, viewer, source.slug)) ?? source;
const [created] = await tx
.insert(motionTemplates)
.values({
kind: source.kind,
slug: source.slug,
version: (newest?.version ?? source.version) + 1,
title: input.title ?? source.title,
summary: input.summary ?? source.summary,
body: input.body ?? source.body,
fields: input.fields !== undefined ? input.fields : source.fields,
stage: input.stage ?? source.stage,
// Private unless asked otherwise, so forking a shared template is
// never accidentally a publication.
visibility: input.visibility ?? 'private',
ownerUserId: principal.userId,
supersedesId: previous.id,
isSystem: false,
createdAt: now,
updatedAt: now,
})
.returning()
.catch(versionConflict);
if (!created) throw new Error('Motion template version insert returned no row');
return {
data: { template: created },
activity: {
type: 'note',
subject: `New version of ${created.title} (v${created.version})`,
meta: {
action: 'motion_template.versioned',
templateId: created.id,
supersedesId: previous.id,
slug: created.slug,
version: created.version,
},
},
};
},
};
}
export function motionTemplatePublishDefinition(): MutationDefinition<
typeof emptySchema,
{ template: MotionTemplate }
> {
return {
schema: emptySchema,
permission: authorizePublish,
invalidMessage: 'Invalid publish request.',
async mutate({ params, principal, tx, now }) {
const viewer = viewerOf(principal);
const existing = await loadTemplateForWrite(
tx,
viewer,
requiredId(params, 'Motion template'),
);
assertTemplateWritable(viewer, existing);
// Idempotent: publishing twice is the same statement made twice, and a
// 409 here would only ever fire on a double-clicked button.
const [published] =
existing.visibility === 'shared'
? [existing]
: await tx
.update(motionTemplates)
.set({ visibility: 'shared', updatedAt: now })
.where(eq(motionTemplates.id, existing.id))
.returning();
if (!published) throw MutationError.notFound('Motion template');
return {
data: { template: published },
activity: {
type: 'note',
subject: `Published to the library: ${published.title} (v${published.version})`,
meta: {
action: 'motion_template.published',
templateId: published.id,
alreadyShared: existing.visibility === 'shared',
},
},
};
},
};
}
/**
* Archive, never delete: an engagement artifact records the template it came
* from, and provenance that can vanish is not provenance.
*/
export function motionTemplateArchiveDefinition(): MutationDefinition<
typeof emptySchema,
{ id: string; archivedAt: Date | null }
> {
return {
schema: emptySchema,
permission: authorizeWrite,
invalidMessage: 'Invalid template removal.',
async mutate({ params, principal, tx, now }) {
const viewer = viewerOf(principal);
const existing = await loadTemplateForWrite(
tx,
viewer,
requiredId(params, 'Motion template'),
);
assertTemplateWritable(viewer, existing);
const [archived] = existing.archivedAt
? [existing]
: await tx
.update(motionTemplates)
.set({ archivedAt: now, updatedAt: now })
.where(eq(motionTemplates.id, existing.id))
.returning();
if (!archived) throw MutationError.notFound('Motion template');
return {
data: { id: archived.id, archivedAt: archived.archivedAt },
activity: {
type: 'note',
subject: `Archived a motion template: ${archived.title}`,
meta: {
action: 'motion_template.archived',
templateId: archived.id,
alreadyArchived: existing.archivedAt !== null,
},
},
};
},
};
}
// --------------------------------------------------------- engagement writes
export function motionEngagementCreateDefinition(): MutationDefinition<
typeof engagementCreateSchema,
{ engagement: Engagement }
> {
return {
schema: engagementCreateSchema,
permission: authorizeWrite,
invalidMessage: 'Invalid engagement.',
async mutate({ input, principal, tx, now }) {
const [deal] = await tx
.select()
.from(demandDeals)
.where(eq(demandDeals.id, input.demandDealId))
.limit(1);
if (!deal) throw MutationError.notFound('Demand deal');
await checkedTemplateId(tx, principal, input.playbookTemplateId);
// Checked rather than left to the unique constraint, so the caller gets
// the id of the engagement that already exists instead of a 500.
const [existing] = await tx
.select()
.from(engagements)
.where(eq(engagements.demandDealId, deal.id))
.limit(1);
if (existing) {
throw new MutationError(
'engagement_exists',
`This deal already has an engagement (${existing.id}).`,
409,
);
}
const [created] = await tx
.insert(engagements)
.values({
demandDealId: deal.id,
playbookTemplateId: input.playbookTemplateId ?? null,
ownerUserId: input.ownerUserId ?? principal.userId,
summary: input.summary ?? null,
openedAt: now,
createdAt: now,
updatedAt: now,
})
.returning();
if (!created) throw new Error('Engagement insert returned no row');
return {
data: { engagement: created },
activity: {
type: 'note',
subject: `Opened an engagement on ${deal.name}`,
accountId: deal.accountId,
demandDealId: deal.id,
meta: { action: 'engagement.opened', engagementId: created.id },
},
};
},
};
}
export function motionEngagementUpdateDefinition(): MutationDefinition<
typeof engagementUpdateSchema,
{ engagement: Engagement }
> {
return {
schema: engagementUpdateSchema,
permission: authorizeWrite,
invalidMessage: 'Invalid engagement change.',
async mutate({ input, params, principal, tx, now }) {
const { engagement, deal } = await loadEngagement(tx, requiredId(params, 'Engagement'));
await checkedTemplateId(tx, principal, input.playbookTemplateId);
const [updated] = await tx
.update(engagements)
.set({
...(input.status !== undefined ? { status: input.status } : {}),
...(input.ownerUserId !== undefined ? { ownerUserId: input.ownerUserId } : {}),
...(input.playbookTemplateId !== undefined
? { playbookTemplateId: input.playbookTemplateId }
: {}),
...(input.summary !== undefined ? { summary: input.summary } : {}),
// `closed_at` follows the status rather than being sent, so a
// reopened engagement cannot keep a close date it no longer has.
...(input.status !== undefined
? { closedAt: input.status === 'won' || input.status === 'lost' ? now : null }
: {}),
updatedAt: now,
})
.where(eq(engagements.id, engagement.id))
.returning();
if (!updated) throw MutationError.notFound('Engagement');
return {
data: { engagement: updated },
activity: {
type: 'note',
subject: `Updated the engagement on ${deal.name}`,
accountId: deal.accountId,
demandDealId: deal.id,
meta: {
action: 'engagement.updated',
engagementId: updated.id,
fields: Object.keys(input),
status: updated.status,
},
},
};
},
};
}
export function motionArtifactCreateDefinition(): MutationDefinition<
typeof artifactCreateSchema,
{ artifact: EngagementArtifact }
> {
return {
schema: artifactCreateSchema,
permission: authorizeWrite,
invalidMessage: 'Invalid engagement artefact.',
async mutate({ input, params, principal, tx, now }) {
const { engagement, deal } = await loadEngagement(tx, requiredId(params, 'Engagement'));
const { artifact, template } = await instantiateArtifact(
tx,
viewerOf(principal),
{ ...input, engagementId: engagement.id },
now,
);
return {
data: { artifact },
activity: {
type: 'note',
subject: template
? `Instantiated ${template.title} (v${template.version}) on ${deal.name}`
: `Added a ${artifact.kind} artefact to ${deal.name}`,
accountId: deal.accountId,
demandDealId: deal.id,
meta: {
action: 'engagement_artifact.created',
engagementId: engagement.id,
artifactId: artifact.id,
templateId: template?.id ?? null,
kind: artifact.kind,
stage: artifact.stage,
},
},
};
},
};
}
/**
* Not restricted to the author: an engagement is worked by a team, and an
* artifact nobody but its author can finish is one that stalls the week they go
* on leave. The library — which everybody copies — is what ownership guards.
*/
export function motionArtifactUpdateDefinition(): MutationDefinition<
typeof artifactUpdateSchema,
{ artifact: EngagementArtifact }
> {
return {
schema: artifactUpdateSchema,
permission: authorizeWrite,
invalidMessage: 'Invalid artefact change.',
async mutate({ input, params, tx, now }) {
const id = requiredId(params, 'Engagement artefact');
const [existing] = await tx
.select()
.from(engagementArtifacts)
.where(eq(engagementArtifacts.id, id))
.limit(1);
if (!existing) throw MutationError.notFound('Engagement artefact');
const { deal } = await loadEngagement(tx, existing.engagementId);
const [updated] = await tx
.update(engagementArtifacts)
.set({
...(input.title !== undefined ? { title: input.title } : {}),
...(input.body !== undefined ? { body: input.body } : {}),
...(input.fields !== undefined ? { fields: input.fields } : {}),
...(input.stage !== undefined ? { stage: input.stage } : {}),
...(input.status !== undefined ? { status: input.status } : {}),
...(input.archived !== undefined ? { archivedAt: input.archived ? now : null } : {}),
updatedAt: now,
})
.where(eq(engagementArtifacts.id, existing.id))
.returning();
if (!updated) throw MutationError.notFound('Engagement artefact');
return {
data: { artifact: updated },
activity: {
type: 'note',
subject: `Updated ${updated.title} on ${deal.name}`,
accountId: deal.accountId,
demandDealId: deal.id,
meta: {
action: 'engagement_artifact.updated',
artifactId: updated.id,
engagementId: updated.engagementId,
fields: Object.keys(input),
status: updated.status,
},
},
};
},
};
}
export function motionArtifactPromoteDefinition(): MutationDefinition<
typeof promoteSchema,
{ template: MotionTemplate; artifact: EngagementArtifact }
> {
return {
schema: promoteSchema,
permission: authorizePublish,
invalidMessage: 'Invalid promotion.',
async mutate({ input, params, principal, tx, now }) {
const result = await promoteArtifact(
tx,
viewerOf(principal),
requiredId(params, 'Engagement artefact'),
input,
now,
);
return {
data: { template: result.template, artifact: result.artifact },
activity: {
type: 'note',
subject: `Promoted to the library: ${result.template.title} (v${result.template.version})`,
// The account, so the loop lands on the timeline of the deal that
// proved it rather than only in the library.
accountId: result.engagement.deal.accountId,
demandDealId: result.engagement.deal.id,
meta: {
action: 'engagement_artifact.promoted',
artifactId: result.artifact.id,
engagementId: result.engagement.engagement.id,
templateId: result.template.id,
supersedesId: result.supersedes?.id ?? null,
slug: result.template.slug,
version: result.template.version,
},
},
};
},
};
}
export function motionScoreDefinition(): MutationDefinition<
typeof scoreSchema,
{ score: QualificationScore }
> {
return {
schema: scoreSchema,
permission: authorizeWrite,
invalidMessage: 'Invalid qualification score.',
async mutate({ input, params, principal, tx, now }) {
const { engagement, deal } = await loadEngagement(tx, requiredId(params, 'Engagement'));
await checkedTemplateId(tx, principal, input.frameworkTemplateId);
const score = await recordScore(tx, viewerOf(principal), engagement.id, input, now);
return {
data: { score },
activity: {
type: 'note',
// Rounded, never truncated — the same rule money follows, for the
// same reason: this line is the only score most people will read.
subject: `Qualified ${deal.name}: ${score.band} (${Math.round(score.basisPoints / 100)}%)`,
accountId: deal.accountId,
demandDealId: deal.id,
meta: {
action: 'qualification_score.recorded',
engagementId: engagement.id,
scoreId: score.id,
basisPoints: score.basisPoints,
band: score.band,
tone: motionBand(score.basisPoints).tone,
},
},
};
},
};
}
// -------------------------------------------------------------------- routes
export function createMotionRoutes(db: Database): Hono<ApiEnv> {
const routes = new Hono<ApiEnv>();
const motion = new MotionService(db);
routes.get('/api/motion', async (c) =>
c.json(await motion.overview(viewerOf(c.get('principal')))),
);
routes.get('/api/motion/templates', async (c) => {
const parsed = filtersSchema.safeParse(c.req.query());
if (!parsed.success) {
return c.json(
apiError('invalid_request', 'Invalid library filter.', parsed.error.issues),
400,
);
}
const { all, ...filters } = parsed.data;
return c.json(
await motion.listTemplates(viewerOf(c.get('principal')), { ...filters, all: all === '1' }),
);
});
routes.get('/api/motion/templates/:id', async (c) => {
const principal = c.get('principal');
const found = await motion.template(viewerOf(principal), c.req.param('id'));
// A private template belonging to somebody else answers exactly as an
// unknown id does. Anything else confirms that it exists.
if (!found) return c.json(apiError('not_found', 'Motion template not found.'), 404);
const owned = ownsTemplate(viewerOf(principal), found.template);
return c.json({
...found,
/** The server's judgement, so the page does not re-derive §7a in TSX. */
canEdit: owned && found.template.usageCount === 0,
canPublish: owned && found.template.visibility === 'private' && canPublish(principal),
});
});
routes.get('/api/motion/engagements', async (c) => {
const parsed = engagementFiltersSchema.safeParse(c.req.query());
if (!parsed.success) {
return c.json(
apiError('invalid_request', 'Invalid engagement filter.', parsed.error.issues),
400,
);
}
return c.json({ engagements: await motion.listEngagements(parsed.data.status) });
});
routes.get('/api/motion/engagements/:id', async (c) => {
const found = await motion.engagement(viewerOf(c.get('principal')), c.req.param('id'));
if (!found) return c.json(apiError('not_found', 'Engagement not found.'), 404);
return c.json(found);
});
routes.post('/api/motion/templates', mutation(db, motionTemplateCreateDefinition()));
routes.patch('/api/motion/templates/:id', mutation(db, motionTemplateUpdateDefinition()));
routes.post(
'/api/motion/templates/:id/versions',
mutation(db, motionTemplateVersionDefinition()),
);
routes.post(
'/api/motion/templates/:id/publish',
bodylessMutation(db, motionTemplatePublishDefinition()),
);
routes.delete(
'/api/motion/templates/:id',
bodylessMutation(db, motionTemplateArchiveDefinition()),
);
routes.post('/api/motion/engagements', mutation(db, motionEngagementCreateDefinition()));
routes.patch('/api/motion/engagements/:id', mutation(db, motionEngagementUpdateDefinition()));
routes.post(
'/api/motion/engagements/:id/artifacts',
mutation(db, motionArtifactCreateDefinition()),
);
routes.patch('/api/motion/artifacts/:id', mutation(db, motionArtifactUpdateDefinition()));
routes.post(
'/api/motion/artifacts/:id/promote',
mutation(db, motionArtifactPromoteDefinition()),
);
routes.post(
'/api/motion/engagements/:id/scores',
mutation(db, motionScoreDefinition()),
);
return routes;
}
+26
View File
@@ -58,6 +58,22 @@ export const READ_RULES: readonly ReadRule[] = [
{ method: 'GET', path: '/api/growth/accounts/:id', capability: 'book:read' },
{ method: 'GET', path: '/api/facts', capability: 'book:read' },
/**
* The motion library is not the cost book. A proposal block, a reference
* architecture and a qualification framework carry no supplier cost and no
* break-even, so gating them on `economics:read` would keep a research lead
* out of the reference architectures they are the ones writing.
*
* The private/shared split is NOT enforced here and could not be: this table
* is keyed on a path and knows nothing about rows. It is a real WHERE clause
* in `services/motion.ts`, which every one of these reads goes through.
*/
{ method: 'GET', path: '/api/motion', capability: 'book:read' },
{ method: 'GET', path: '/api/motion/templates', capability: 'book:read' },
{ method: 'GET', path: '/api/motion/templates/:id', capability: 'book:read' },
{ method: 'GET', path: '/api/motion/engagements', capability: 'book:read' },
{ method: 'GET', path: '/api/motion/engagements/:id', capability: 'book:read' },
{ method: 'GET', path: '/api/team', capability: 'team:read' },
/**
@@ -120,6 +136,16 @@ const PIGGY_PAGE_CAPABILITIES: Readonly<Record<PiggyPageRoute, ReadCapability>>
'/supply': 'book:read',
'/calendar': 'book:read',
'/contracts': 'book:read',
// The motion library is not the cost book. pig_get_motion_summary,
// pig_search_motion_library and pig_get_engagement return templates,
// artifacts and qualification scores — authored practice, carrying no
// supplier cost and no break-even — so the classification follows what those
// tools return rather than what the pages look like. The private/shared rule
// is a separate matter and is enforced in the query, not here: the library
// tool filters to `visibility = 'shared'` unconditionally.
'/motion': 'book:read',
'/motion/library': 'book:read',
'/motion/engagements': 'book:read',
};
const PIGGY_RECORD_CAPABILITIES: Readonly<Record<PiggyRecordType, ReadCapability>> = {
File diff suppressed because it is too large Load Diff
+725
View File
@@ -0,0 +1,725 @@
/**
* Motion — the two rules that make this a system, and the one departure that
* makes it a risk.
*
* The departure first. `permissions.ts` states that every read endpoint returns
* the whole book because no row-level filter exists anywhere in the query
* layer; motion templates are the first exception, and an exception that is
* only enforced by the UI is not an exception, it is a leak with a nice screen
* in front of it. So the first suite renders the actual predicate to SQL and
* asserts on it. It looks like a test of an implementation detail and is not:
* the WHERE clause IS the access policy, and the two ways of getting it wrong —
* dropping the owner comparison, or widening it with `OR owner IS NULL` — both
* produce a query that returns rows and reports nothing.
*
* The rest drive the exported mutation definitions against a scripted
* transaction, in the register of `activities.test.ts`. The fake answers
* queries in call order and records what ran; it is deliberately not a
* database, because a fake that pretends to run SQL is a fake that will one day
* assert a broken query works.
*/
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import { getTableName, isSQLWrapper, isTable, type SQL } from 'drizzle-orm';
import { PgDialect } from 'drizzle-orm/pg-core';
import { motionScoreBasisPoints } from '@pig/core';
import type { Database, MotionTemplate } from '@pig/db';
import { Hono } from 'hono';
import { AuthError, type Principal } from '../src/lib/auth';
import { executeMutation, MutationError, type ApiEnv } from '../src/lib/mutation';
import {
createMotionRoutes,
motionArtifactCreateDefinition,
motionArtifactPromoteDefinition,
motionEngagementCreateDefinition,
motionEngagementUpdateDefinition,
motionScoreDefinition,
motionTemplateCreateDefinition,
motionTemplatePublishDefinition,
motionTemplateUpdateDefinition,
motionTemplateVersionDefinition,
} from '../src/routes/motion';
import { motionSlug, visibleTemplates } from '../src/services/motion';
import { onTeam, principal } from './helpers/principal';
const OWNER = '00000000-0000-4000-8000-000000000001';
const OTHER = '00000000-0000-4000-8000-0000000000aa';
const DEAL_ID = '00000000-0000-4000-8000-0000000000d1';
const ACCOUNT_ID = '00000000-0000-4000-8000-0000000000ac';
const ENGAGEMENT_ID = '00000000-0000-4000-8000-0000000000e1';
const ARTIFACT_ID = '00000000-0000-4000-8000-0000000000f1';
const TEMPLATE_ID = '00000000-0000-4000-8000-0000000000b1';
const NOW = new Date('2026-08-17T09:00:00.000Z');
/** A demand member: `motion:write`, no `motion:publish`. */
const member = principal(onTeam('demand', 'member'));
/** A demand lead: both, which is the point of the split. */
const lead = principal(onTeam('demand', 'lead'));
// --------------------------------------------------------------- the fixtures
function template(overrides: Partial<MotionTemplate> = {}): MotionTemplate {
return {
id: TEMPLATE_ID,
kind: 'poc',
slug: 'poc-plan',
version: 1,
title: 'POC plan',
summary: 'What a proof of concept must show.',
body: '# POC plan',
fields: null,
stage: 'poc',
visibility: 'private',
ownerUserId: OWNER,
supersedesId: null,
originArtifactId: null,
isSystem: false,
usageCount: 0,
archivedAt: null,
createdAt: NOW,
updatedAt: NOW,
...overrides,
};
}
const deal = { id: DEAL_ID, accountId: ACCOUNT_ID, name: 'DEMO — Northwind training' };
const engagement = { id: ENGAGEMENT_ID, demandDealId: DEAL_ID, status: 'open' };
function artifact(overrides: Record<string, unknown> = {}) {
return {
id: ARTIFACT_ID,
engagementId: ENGAGEMENT_ID,
templateId: null,
kind: 'poc',
stage: 'poc',
title: 'Northwind POC plan',
body: '# What we proved',
fields: null,
status: 'final',
authoredByUserId: OWNER,
promotedTemplateId: null,
archivedAt: null,
createdAt: NOW,
updatedAt: NOW,
...overrides,
};
}
// ------------------------------------------------------------------ the fake
interface Recorded {
events: string[];
inserted: { table: string; row: Record<string, unknown> }[];
updated: { table: string; values: Record<string, unknown> }[];
}
/**
* A transaction that answers selects from a script, in call order, and records
* every write with the table it landed in. Order-dependence is the price of not
* pretending to be Postgres, and it is what makes "no UPDATE ran" assertable.
*/
function database(script: unknown[][]): { db: Database; log: Recorded } {
const log: Recorded = { events: [], inserted: [], updated: [] };
const results = [...script];
const next = (): unknown[] => results.shift() ?? [];
const selectChain = {
from: () => selectChain,
innerJoin: () => selectChain,
leftJoin: () => selectChain,
where: () => selectChain,
orderBy: () => selectChain,
groupBy: () => selectChain,
// `limit` returns the chain rather than a promise so that `.for('update')`
// can follow it, as it does on every query in this feature that allocates
// a version number. The chain is a thenable, so `await` still ends it.
limit: () => selectChain,
for: () => selectChain,
then: (resolve: (rows: unknown[]) => unknown) => resolve(next()),
};
const name = (table: unknown): string => (isTable(table) ? getTableName(table) : 'unknown');
const tx = {
select: () => {
log.events.push('select');
return selectChain;
},
insert: (table: unknown) => ({
values: (row: Record<string, unknown>) => {
log.events.push(`insert:${name(table)}`);
log.inserted.push({ table: name(table), row });
const written = [{ id: `${name(table)}-${log.inserted.length}`, ...row }];
return {
returning: async () => written,
then: (resolve: (value: unknown) => unknown) => resolve(undefined),
};
},
}),
update: (table: unknown) => ({
set: (values: Record<string, unknown>) => ({
where: () => {
log.events.push(`update:${name(table)}`);
log.updated.push({ table: name(table), values });
return Object.assign(Promise.resolve(undefined), {
returning: async () => [{ id: `${name(table)}-updated`, ...values }],
});
},
}),
}),
};
return {
db: {
transaction: async (work: (t: unknown) => Promise<unknown>) => {
log.events.push('transaction');
return work(tx);
},
// The same scripted chain outside a transaction, so a read handler can be
// driven through the mounted routes rather than only its service.
select: tx.select,
} as unknown as Database,
log,
};
}
/** The motion routes with a principal already resolved, as `createApp` mounts them. */
function mounted(db: Database, who: Principal): Hono<ApiEnv> {
const app = new Hono<ApiEnv>();
app.use('*', async (c, next) => {
c.set('principal', who);
await next();
});
app.route('/', createMotionRoutes(db));
return app;
}
function rendered(userId: string, isPlatformAdmin = false) {
const predicate = visibleTemplates({ userId, isPlatformAdmin });
return predicate ? new PgDialect().sqlToQuery(predicate) : null;
}
// ------------------------------------------------------------------- the tests
describe('the private library filter is a WHERE clause, not an affordance', () => {
it('matches shared rows for everyone and private rows only against the viewer id', () => {
const query = rendered(OWNER);
assert.ok(query, 'a member must be filtered at all');
assert.match(query.sql, /"visibility" = \$1/);
assert.match(query.sql, /"owner_user_id" = \$3/);
assert.deepEqual(query.params, ['shared', 'private', OWNER]);
});
it('never widens to `owner_user_id IS NULL` — an orphaned private draft belongs to nobody', () => {
const query = rendered(OWNER);
// `ON DELETE SET NULL` on the owner column can produce a private row with
// no owner. Matching it here would publish every departed colleague's
// drafts to the whole workspace, and the query would look like a fix for
// rows that had "gone missing".
assert.ok(query);
assert.doesNotMatch(query.sql, /owner_user_id" is null/i);
});
it('is deliberately absent for a platform admin — an admin CAN read a private template', () => {
// Not a hole. An unfiltered query here is the decision: someone has to be
// able to answer "what is in this workspace" during an audit or a
// departure. If this ever starts returning a predicate, that was a choice
// somebody made, and this test is where they say so.
assert.equal(rendered(OTHER, true), null);
});
});
describe('a slug is the identity of a lineage', () => {
it('strips the combining mark NFKD leaves behind rather than hyphenating through a word', () => {
// `normalize('NFKD')` splits "é" into "e" plus a combining acute, and the
// `[^a-z0-9]+` rule that follows turns that mark into a separator — so
// without the strip, "Café strategy" starts the lineage `caf-e-strategy`
// and the next person authoring the same title cannot find it.
assert.equal(motionSlug('Café strategy'), 'cafe-strategy');
assert.equal(motionSlug('Proposal Blocks'), 'proposal-blocks');
// Nothing latin survives, and an empty slug would violate the NOT NULL.
assert.equal(motionSlug('日本語'), 'untitled');
});
});
describe('a used template is never edited in place', () => {
it('refuses a PATCH once usage_count is above zero, and names the versions endpoint', async () => {
const { db, log } = database([[template({ usageCount: 3 })]]);
await assert.rejects(
executeMutation(
db,
principal(onTeam('demand', 'member')),
async () => ({ body: '# Rewritten' }),
motionTemplateUpdateDefinition(),
{ id: TEMPLATE_ID },
),
(error: unknown) =>
error instanceof MutationError &&
error.code === 'template_in_use' &&
error.status === 409 &&
error.message.includes('/versions'),
);
assert.deepEqual(log.updated, [], 'a live engagement must not have its template move underneath it');
});
it('edits freely while nobody has instantiated it — an unused template is still a draft', async () => {
const { db, log } = database([[template({ usageCount: 0 })]]);
await executeMutation(
db,
member,
async () => ({ title: 'POC plan, tightened' }),
motionTemplateUpdateDefinition(),
{ id: TEMPLATE_ID },
);
assert.equal(log.updated.length, 1);
assert.equal(log.updated[0]?.table, 'motion_templates');
assert.equal(log.updated[0]?.values.title, 'POC plan, tightened');
});
it('refuses an edit to somebody else\'s template even when it is shared — publishing is not donating', async () => {
const { db, log } = database([[template({ visibility: 'shared', ownerUserId: OTHER })]]);
await assert.rejects(
executeMutation(
db,
member,
async () => ({ body: '# Mine now' }),
motionTemplateUpdateDefinition(),
{ id: TEMPLATE_ID },
),
(error: unknown) => error instanceof AuthError && error.code === 'not_owner',
);
assert.deepEqual(log.updated, []);
});
});
describe('publishing is a lead\'s judgement, and the owner\'s', () => {
it('refuses a member without motion:publish before the row is read at all', async () => {
const { db, log } = database([[template()]]);
await assert.rejects(
executeMutation(db, member, async () => ({}), motionTemplatePublishDefinition(), {
id: TEMPLATE_ID,
}),
(error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission',
);
assert.deepEqual(log.events, [], 'permission precedes the transaction, so nothing was queried');
});
it('refuses a lead flipping somebody else\'s private template to shared', async () => {
const { db, log } = database([[template({ ownerUserId: OTHER })]]);
await assert.rejects(
executeMutation(db, lead, async () => ({}), motionTemplatePublishDefinition(), {
id: TEMPLATE_ID,
}),
(error: unknown) => error instanceof AuthError && error.code === 'not_owner',
);
assert.deepEqual(log.updated, []);
});
it('gates creating straight into the shared library on the same capability', async () => {
// The publish endpoint is not the only door into the shared library, so a
// member who simply POSTs `visibility: 'shared'` must be refused too.
const { db, log } = database([]);
await assert.rejects(
executeMutation(
db,
member,
async () => ({
kind: 'poc',
title: 'POC plan',
summary: 'What a POC must show.',
body: '# POC',
stage: 'poc',
visibility: 'shared',
}),
motionTemplateCreateDefinition(),
),
(error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission',
);
assert.deepEqual(log.inserted, []);
});
it('creates a private template owned by the author, because an unowned private row is unreadable', async () => {
const { db, log } = database([]);
await executeMutation(
db,
member,
async () => ({
kind: 'poc',
title: 'POC plan',
summary: 'What a POC must show.',
body: '# POC',
stage: 'poc',
}),
motionTemplateCreateDefinition(),
);
const written = log.inserted.find((row) => row.table === 'motion_templates');
assert.equal(written?.row.visibility, 'private');
assert.equal(written?.row.ownerUserId, member.userId);
assert.equal(written?.row.version, 1);
assert.equal(written?.row.slug, 'poc-plan');
});
});
describe('promotion is the loop', () => {
function promote(db: Database, input: Record<string, unknown> = {}) {
return executeMutation(
db,
lead,
async () => input,
motionArtifactPromoteDefinition(),
{ id: ARTIFACT_ID },
);
}
it('refuses to promote an artifact that is already in the library', async () => {
const { db, log } = database([[artifact({ promotedTemplateId: TEMPLATE_ID })]]);
await assert.rejects(
promote(db),
(error: unknown) =>
error instanceof MutationError &&
error.code === 'already_promoted' &&
error.status === 409,
);
assert.deepEqual(log.inserted, [], 'a second promotion would fork the lineage silently');
});
it('refuses to promote a draft — the library is what the next deployment copies', async () => {
const { db, log } = database([[artifact({ status: 'draft' })]]);
await assert.rejects(
promote(db),
(error: unknown) =>
error instanceof MutationError &&
error.code === 'artifact_not_final' &&
error.status === 409,
);
assert.deepEqual(log.inserted, []);
});
it('writes a shared version pointing back at the artifact that proved it', async () => {
const { db, log } = database([[artifact()], [{ engagement, deal }], []]);
const result = (await promote(db, { slug: 'poc-plan' })) as {
template: { version: number; visibility: string; originArtifactId: string };
};
assert.equal(result.template.version, 1);
assert.equal(result.template.visibility, 'shared');
assert.equal(result.template.originArtifactId, ARTIFACT_ID);
assert.equal(
log.updated.find((row) => row.table === 'engagement_artifacts')?.values.promotedTemplateId,
'motion_templates-1',
);
});
it('chains supersedes_id across three promotions of one lineage', async () => {
let previous: { id: string; version: number } | null = null;
const chain: { id: string; version: number; supersedesId: string | null }[] = [];
for (let round = 0; round < 3; round += 1) {
// The lineage is read twice and on purpose: once unfiltered for the
// version number, once through the visibility filter for the row the new
// version may claim to supersede.
const lineage = previous ? [{ ...template(), ...previous, slug: 'poc-plan' }] : [];
const { db } = database([
[artifact({ id: `${ARTIFACT_ID}-${round}` })],
[{ engagement, deal }],
lineage,
lineage,
]);
const result = (await promote(db, { slug: 'poc-plan' })) as {
template: { id: string; version: number; supersedesId: string | null };
};
chain.push({
id: result.template.id,
version: result.template.version,
supersedesId: result.template.supersedesId,
});
previous = { id: result.template.id, version: result.template.version };
}
assert.deepEqual(
chain.map((row) => row.version),
[1, 2, 3],
);
assert.equal(chain[0]?.supersedesId, null, 'the first version supersedes nothing');
assert.equal(chain[1]?.supersedesId, chain[0]?.id);
assert.equal(chain[2]?.supersedesId, chain[1]?.id);
});
it('lands the audit row on the account of the deal that proved it', async () => {
const { db, log } = database([[artifact()], [{ engagement, deal }], []]);
await promote(db, { slug: 'poc-plan' });
const activity = log.inserted.find((row) => row.table === 'activities')?.row;
assert.equal(activity?.accountId, ACCOUNT_ID);
assert.equal(activity?.demandDealId, DEAL_ID);
assert.match(String(activity?.subject), /^Promoted to the library:/);
});
});
describe('a lineage nobody can read never supplies content to one everybody can', () => {
/**
* The version number and the predecessor row are two different questions, and
* answering both from one unfiltered query is the shape of the bug these pin.
* Both suites script a lineage whose newest row is private to somebody else:
* the unfiltered read finds v4, the filtered read finds nothing.
*/
const PRIVATE_SUMMARY = 'Unreleased pricing: 40% floor, Northwind only.';
/** Alice's v4, which the unfiltered read finds and the filtered read must not. */
const hidden = () =>
template({
id: '00000000-0000-4000-8000-0000000000c4',
version: 4,
ownerUserId: OTHER,
summary: PRIVATE_SUMMARY,
});
it('promotes past a private newest version without copying its summary or naming its id', async () => {
const { db, log } = database([
[artifact()],
[{ engagement, deal }],
[hidden()],
[],
]);
await executeMutation(db, lead, async () => ({ slug: 'poc-plan' }), motionArtifactPromoteDefinition(), {
id: ARTIFACT_ID,
});
const written = log.inserted.find((row) => row.table === 'motion_templates')?.row;
// v5, because the unique constraint is on the whole lineage and a private
// fork still consumes a number.
assert.equal(written?.version, 5);
// But nothing else from that row. The summary fell back to the artifact's
// own title, and the new version supersedes nothing it cannot show.
assert.notEqual(written?.summary, PRIVATE_SUMMARY);
assert.equal(written?.summary, artifact().title);
assert.equal(written?.supersedesId, null);
});
it('forks a shared template without disclosing that private versions of it exist', async () => {
const source = template({ id: TEMPLATE_ID, visibility: 'shared', ownerUserId: OTHER });
const { db, log } = database([[source], [hidden()], []]);
await executeMutation(db, member, async () => ({}), motionTemplateVersionDefinition(), {
id: TEMPLATE_ID,
});
const written = log.inserted.find((row) => row.table === 'motion_templates')?.row;
assert.equal(written?.version, 5, 'the number is allocated against the whole lineage');
assert.equal(
written?.supersedesId,
source.id,
'the edge points at the row that was actually forked, not at a private row the forker cannot fetch',
);
});
it('refuses to promote an artifact whose source template is unreadable, rather than 404ing on the artifact', async () => {
const { db, log } = database([
[artifact({ templateId: TEMPLATE_ID })],
[{ engagement, deal }],
[],
]);
await assert.rejects(
executeMutation(db, lead, async () => ({}), motionArtifactPromoteDefinition(), {
id: ARTIFACT_ID,
}),
(error: unknown) =>
error instanceof MutationError &&
error.code === 'source_template_unreadable' &&
error.status === 409 &&
error.message.includes('slug'),
);
assert.deepEqual(log.inserted, []);
});
});
describe('a template id somebody sent is read before it is stored', () => {
it('refuses a playbook the setter cannot read, so a private id cannot go book-wide on an engagement', async () => {
const { db, log } = database([[{ engagement, deal }], []]);
await assert.rejects(
executeMutation(
db,
member,
async () => ({ playbookTemplateId: TEMPLATE_ID }),
motionEngagementUpdateDefinition(),
{ id: ENGAGEMENT_ID },
),
(error: unknown) => error instanceof MutationError && error.status === 404,
);
assert.deepEqual(log.updated, [], 'an unreadable id must not reach the foreign key either');
});
});
describe('a UI hint that disagrees with its endpoint is the hint that is wrong', () => {
it('does not offer publish to a read-only key, which the endpoint would refuse for scope', async () => {
const readOnlyLead = principal({ ...onTeam('demand', 'lead'), via: 'api_key', scopes: ['read'] });
const mine = template({ ownerUserId: readOnlyLead.userId });
const { db } = database([[mine], [mine]]);
const response = await mounted(db, readOnlyLead).request(`/api/motion/templates/${TEMPLATE_ID}`);
const body = (await response.json()) as { canPublish: boolean };
assert.equal(response.status, 200);
assert.equal(
body.canPublish,
false,
'the button would 403 insufficient_scope, and a button that cannot work must not be offered',
);
});
it('still offers it to the same lead on a full-scope session', async () => {
const mine = template({ ownerUserId: lead.userId });
const { db } = database([[mine], [mine]]);
const response = await mounted(db, lead).request(`/api/motion/templates/${TEMPLATE_ID}`);
const body = (await response.json()) as { canPublish: boolean };
assert.equal(body.canPublish, true);
});
});
describe('a slug collision is the caller\'s to fix, not a 500', () => {
it('answers 409 naming the versions endpoint when the title derives a slug already in use', async () => {
// "Proposal Blocks" is one of the nine shipped starter templates, so this
// is the first thing a new author trips over rather than an edge case.
const { db, log } = database([[{ version: 1 }]]);
await assert.rejects(
executeMutation(
db,
member,
async () => ({
kind: 'proposal',
title: 'Proposal Blocks',
summary: 'Reusable proposal language.',
body: '# Blocks',
stage: 'proposal',
}),
motionTemplateCreateDefinition(),
),
(error: unknown) =>
error instanceof MutationError &&
error.code === 'template_slug_exists' &&
error.status === 409 &&
error.message.includes('/versions'),
);
assert.deepEqual(log.inserted, []);
});
});
describe('instantiating a template', () => {
it('increments usage_count in the same transaction that writes the artifact', async () => {
const { db, log } = database([
[{ engagement, deal }],
[template({ usageCount: 2, visibility: 'shared', ownerUserId: OTHER })],
]);
await executeMutation(
db,
member,
async () => ({ templateId: TEMPLATE_ID }),
motionArtifactCreateDefinition(),
{ id: ENGAGEMENT_ID },
);
// One transaction, both writes: a count that could be committed without the
// artifact would close the template to edits for a use that never happened.
assert.equal(log.updated[0]?.table, 'motion_templates');
// And the count is incremented by the database, not by JS arithmetic on the
// row that was read a moment ago. Two people instantiating one template
// under READ COMMITTED both read 2 and both write 3, so a use is lost — and
// `usage_count` is the only thing holding §7a shut. Asserting the rendered
// SQL is what makes the difference between the two visible at all: the
// wrong version writes the literal 3 and passes every behavioural test.
const increment = log.updated[0]?.values.usageCount;
assert.ok(
isSQLWrapper(increment),
'usage_count must be written as an expression, never as a number computed in JS',
);
assert.match(new PgDialect().sqlToQuery(increment as SQL).sql, /"usage_count" \+ \$?1/);
assert.equal(log.inserted[0]?.table, 'engagement_artifacts');
assert.equal(log.inserted[0]?.row.templateId, TEMPLATE_ID);
// The body is copied, not referenced — editing the artifact must not reach
// back into the library.
assert.equal(log.inserted[0]?.row.body, template().body);
});
});
describe('one engagement per deal', () => {
it('answers 409 with the id of the engagement that already exists', async () => {
const { db, log } = database([[deal], [engagement]]);
await assert.rejects(
executeMutation(
db,
member,
async () => ({ demandDealId: DEAL_ID }),
motionEngagementCreateDefinition(),
),
(error: unknown) =>
error instanceof MutationError &&
error.code === 'engagement_exists' &&
error.status === 409 &&
error.message.includes(ENGAGEMENT_ID),
);
assert.deepEqual(log.inserted, []);
});
});
describe('qualification scores', () => {
it('computes the score from the dimensions rather than believing the body', async () => {
const { db, log } = database([[{ engagement, deal }]]);
const dimensions = [
{ id: 'budget', weight: 30, score: 4 },
{ id: 'urgency', weight: 20, score: 2 },
{ id: 'fit', weight: 50, score: 3 },
];
await executeMutation(
db,
member,
async () => ({ dimensions }),
motionScoreDefinition(),
{ id: ENGAGEMENT_ID },
);
const written = log.inserted.find((row) => row.table === 'qualification_scores')?.row;
assert.equal(written?.basisPoints, motionScoreBasisPoints(dimensions));
assert.equal(written?.band, 'Strategic');
assert.equal(typeof written?.basisPoints, 'number');
assert.ok(Number.isInteger(written?.basisPoints), 'a score is an integer, exactly as money is');
});
it('refuses a posted basisPoints outright — a score somebody can send is a score somebody can fix', async () => {
const { db } = database([[{ engagement, deal }]]);
await assert.rejects(
executeMutation(
db,
member,
async () => ({ dimensions: [{ id: 'fit', weight: 1, score: 1 }], basisPoints: 10_000 }),
motionScoreDefinition(),
{ id: ENGAGEMENT_ID },
),
(error: unknown) => error instanceof MutationError && error.code === 'invalid_request',
);
});
});