/** * The motion page tools, and the one rule none of them may relax. * * Motion is the first feature in PIG with a row-level access rule: a `private` * template belongs to its owner and to a platform admin, and to nobody else. * Every other read in this process is book-wide, so the habit of the codebase * is against this clause rather than for it — which is exactly why it is pinned * here rather than left to a review. * * Piggy cannot enforce ownership because it does not reliably know who is * asking: the dock publishes a route and the relay checks a capability, and * neither reaches the query. So the query is closed instead. The tests below * assert the closed form under every filter combination a model can send, * because the plausible-but-wrong version of this code is one where the clause * is present in the unfiltered read and lost in a branch. * * The unit suite runs in CI BEFORE the migration step, against a database with * no tables, so nothing here may execute a query. The WHERE clause is rendered * with `PgDialect` rather than run, and the tools themselves are executed * against a stub handle — the second half of this file, which pins the figures * they report and the denominators those figures are drawn from. */ import assert from 'node:assert/strict'; import test from 'node:test'; import { MOTION_KINDS } from '@pig/core'; import { engagementArtifacts, engagements, motionTemplates, qualificationScores, type Database, } from '@pig/db'; import type { SQL } from 'drizzle-orm'; import { PgDialect } from 'drizzle-orm/pg-core'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { assertPigToolBoundary } from '../src/chat'; import { createPagePigTools, motionLibraryWhere, type ResultScope } from '../src/page-tools'; /** Schema and SQL-shape checks only: no query is executed. */ const db = {} as Database; const dialect = new PgDialect(); function renderedWhere(filter: Parameters[0]): { text: string; params: unknown[]; } { const query = dialect.sqlToQuery(motionLibraryWhere(filter)); return { text: query.sql, params: query.params }; } function tool(route: '/motion' | '/motion/library' | '/motion/engagements') { const [only, ...rest] = createPagePigTools(db, route); assert.ok(only, `${route} has a tool`); // One tool per page: a second is a second thing to choose wrongly, and a // wrong choice costs one of four turns. assert.equal(rest.length, 0); return only; } // --------------------------------------------------------------------------- // The visibility invariant. Do not relax this to "unless the user owns it". // --------------------------------------------------------------------------- test('the library query filters to shared templates UNCONDITIONALLY, under every filter', () => { const filters: Parameters[0][] = [ {}, { kind: 'proposal' }, { stage: 'poc' }, { query: 'sovereign' }, { kind: 'playbook', stage: 'deployment', query: 'inference' }, // The nulls a schema-abiding model sends for "no filter" must not read as // "no visibility filter either". { kind: null, stage: null, query: null }, ]; for (const filter of filters) { const { params } = renderedWhere(filter); assert.ok( params.includes('shared'), `visibility = 'shared' is missing for ${JSON.stringify(filter)}`, ); // Bound to the column, not merely present somewhere in the statement. assert.match(renderedWhere(filter).text, /"visibility" = \$\d+/); } }); test('no filter a model can send widens the library beyond shared — private is not a parameter', () => { const library = tool('/motion/library'); const accepts = (input: unknown) => library.inputSchema.safeParse(input).success; // `.strict()`, so every one of these is refused rather than ignored. A tool // that silently drops an unknown key teaches a model to keep trying. assert.equal(accepts({ visibility: 'private' }), false); assert.equal(accepts({ ownerUserId: '20000000-0000-4000-8000-000000000002' }), false); assert.equal(accepts({ includePrivate: true }), false); assert.equal(accepts({ all: '1' }), false); assert.equal(accepts({ kind: 'proposal' }), true); }); test('a private template is invisible even when its title is the search term', () => { // The query filter is an AND alongside the visibility clause, never an OR // beside it: an `or(...)` at the top level would make any matching title // satisfy the whole WHERE and return the private row. const { text, params } = renderedWhere({ query: 'Halcyon' }); const [visibility] = text.split('and'); assert.ok(visibility?.includes('"visibility"'), text); assert.ok(text.startsWith('('), text); assert.match(text, /^\("[a-z_]+"\."visibility" = \$1 and /); assert.equal(params[0], 'shared'); }); test('archived templates are excluded from every library read', () => { // Archiving is how deletion works here, so a query that ignores it hands the // model practice somebody deliberately withdrew. assert.match(renderedWhere({}).text, /"archived_at" is null/); assert.match(renderedWhere({ kind: 'case_study' }).text, /"archived_at" is null/); }); test('LIKE wildcards in the model-supplied query are escaped, not honoured', () => { // Unescaped, `%` matches every shared template and the model is handed the // first eight as though they answered the question. assert.deepEqual(renderedWhere({ query: '%' }).params, ['shared', '%\\%%', '%\\%%', '%\\%%']); }); // --------------------------------------------------------------------------- // The boundary // --------------------------------------------------------------------------- test('every motion page tool sits inside the PIG tool boundary', () => { const tools = [tool('/motion'), tool('/motion/library'), tool('/motion/engagements')]; assert.deepEqual(tools.map((entry) => entry.name), [ 'pig_get_motion_summary', 'pig_search_motion_library', 'pig_get_engagement', ]); // The assertion the chat provider runs on every request: a name that fails it // takes the whole conversation down rather than one tool. assert.doesNotThrow(() => assertPigToolBoundary(tools)); for (const entry of tools) { assert.ok(entry.description.length > 40, `${entry.name} has a usable description`); } }); test('the library and engagement tools say out loud that private drafts are not read', () => { // The description is the only place the model learns the limit, and "I found // nothing" is a materially different answer from "I cannot see private // drafts" to someone looking at their own. assert.match(tool('/motion/library').description, /[Pp]rivate drafts are never searched/); assert.match(tool('/motion').description, /[Pp]rivate drafts are not visible/); }); // --------------------------------------------------------------------------- // The input bounds // --------------------------------------------------------------------------- test('the motion summary takes no input at all', () => { const summary = tool('/motion'); assert.equal(summary.inputSchema.safeParse({}).success, true); assert.equal(summary.inputSchema.safeParse({ stage: 'poc' }).success, false); }); test('the library filters accept only ontology values, and a bounded query', () => { const library = tool('/motion/library'); const accepts = (input: unknown) => library.inputSchema.safeParse(input).success; for (const kind of MOTION_KINDS) assert.equal(accepts({ kind }), true); // A kind the model invented reaches the database as a cast error rather than // a miss, so it is refused at the schema. assert.equal(accepts({ kind: 'battlecard' }), false); assert.equal(accepts({ stage: 'closed_won' }), true); assert.equal(accepts({ stage: 'negotiation' }), false); // Trimmed before the length check, so trailing whitespace cannot smuggle a // one-character query past the floor and match the whole library. assert.equal(accepts({ query: ' a ' }), false); assert.equal(accepts({ query: 'x'.repeat(64) }), true); assert.equal(accepts({ query: 'x'.repeat(65) }), false); assert.equal(accepts({ query: 'x'.repeat(4000) }), false); }); test('the engagement query is bounded and refuses anything it does not name', () => { const engagement = tool('/motion/engagements'); const accepts = (input: unknown) => engagement.inputSchema.safeParse(input).success; assert.equal(accepts({}), true); assert.equal(accepts({ query: 'Halcyon' }), true); assert.equal(accepts({ query: 'a' }), false); assert.equal(accepts({ query: 'x'.repeat(65) }), false); assert.equal(accepts({ engagementId: '20000000-0000-4000-8000-000000000002' }), false); assert.equal(accepts({ limit: 500 }), false); }); /** * What the model is actually sent, rather than what the zod reads like. * * `zodToJsonSchema(..., { target: 'openAi' })` — the exact call both inference * paths make — emits an optional field as REQUIRED and nullable, so a * schema-abiding model sends `null` for every filter it does not want and * `.optional()` would reject the call. A `.describe()` applied after the * wrapper is dropped from the emitted schema entirely. */ test('an omitted motion filter arrives as the null the emitted schema asks for', () => { const library = tool('/motion/library'); assert.equal( library.inputSchema.safeParse({ kind: null, stage: null, query: null }).success, true, ); assert.equal(tool('/motion/engagements').inputSchema.safeParse({ query: null }).success, true); const emitted = zodToJsonSchema(library.inputSchema, { $refStrategy: 'none', target: 'openAi', }) as { properties?: Record; required?: string[] }; assert.deepEqual(emitted.required, ['kind', 'stage', 'query']); for (const [parameter, shape] of Object.entries(emitted.properties ?? {})) { assert.ok( shape.description && shape.description.length > 10, `${parameter} reaches the model with no description`, ); } }); // --------------------------------------------------------------------------- // The figures the tools report // --------------------------------------------------------------------------- /** * A stub handle, because the unit suite still may not execute a query. * * Drizzle's builder is a promise you can keep calling methods on, so this is * the same: every chaining method returns itself and `then` resolves the rows. * What is under test here is the shaping — which figure reaches the headline * and what denominator sits beside it — not the SQL, which * `e2e/page-tools.test.ts` covers against a real book. * * The where clauses are not evaluated: a stub that reimplemented them would be * testing itself, so a filtered read is fixtured as the rows it returned. They * ARE rendered, for the one assertion that has to see them — a denominator * taken over the whole table rather than the shared library would publish the * size of everybody's private drafts, and it would look exactly like this from * the outside. */ interface StubMotion { /** What the library read returned. Shared, non-archived, versions included. */ templates: readonly Record[]; /** `count(distinct slug)`: lineages, not rows. Fixtured, not derived. */ lineageTotal: number; /** The promotion exemplars, capped by the tool, and their exact total. */ promotionRows?: readonly Record[]; promotionTotal?: number; /** The open engagements the summary counts by stage. */ openEngagements?: readonly Record[]; /** What the engagement search matched, against every engagement on the book. */ engagementRows?: readonly Record[]; engagementTotal?: number; artifacts?: readonly Record[]; scores?: readonly Record[]; } function stubBook(book: StubMotion): { handle: Database; denominators: string[] } { const denominators: string[] = []; const rowsFor = ( table: unknown, projection: Record, where: string, ): readonly unknown[] => { const counting = Object.hasOwn(projection, 'value'); if (table === motionTemplates) { if (counting) { denominators.push(where); // Both counts are taken over `motion_templates`; only the promotion one // asks for an origin artefact, which is what tells them apart here. return where.includes('origin_artifact_id') ? [{ value: book.promotionTotal ?? 0 }] : [{ value: book.lineageTotal }]; } // The promotion exemplars are the only template read carrying a date. return Object.hasOwn(projection, 'createdAt') ? (book.promotionRows ?? []) : book.templates; } if (table === engagements) { if (counting) { denominators.push(where); return [{ value: book.engagementTotal ?? 0 }]; } // The summary asks for a stage per open engagement; the search asks for // the row. return Object.hasOwn(projection, 'id') ? (book.engagementRows ?? []) : (book.openEngagements ?? []); } if (table === engagementArtifacts) return book.artifacts ?? []; if (table === qualificationScores) return book.scores ?? []; throw new Error('the stub was asked for a table this suite does not fixture'); }; const select = (projection: Record) => ({ from: (table: unknown) => { let where = ''; const builder: Record = { where: (clause: SQL | undefined) => { if (clause) where = dialect.sqlToQuery(clause).sql; return builder; }, }; for (const method of ['limit', 'orderBy', 'groupBy', 'innerJoin', 'leftJoin']) { builder[method] = () => builder; } builder.then = (resolve: (value: readonly unknown[]) => unknown) => resolve(rowsFor(table, projection, where)); return builder; }, }); return { handle: { select } as unknown as Database, denominators }; } type Reading = Record & { headline?: string; scope?: ResultScope }; async function read( route: '/motion' | '/motion/library' | '/motion/engagements', handle: Database, input: Record = {}, ): Promise { const [only] = createPagePigTools(handle, route); assert.ok(only, `no tool for ${route}`); return (await only.execute(input)) as Reading; } function template(slug: string, stage: string, version = 1) { return { slug, kind: 'playbook', stage, version, title: `Template ${slug} v${version}`, summary: 'A shared template.', usageCount: 3, }; } /** * Twelve lineages in thirteen rows, covering six of the eight open stages. * * The thirteenth row is a second version of the first lineage, and it is there * because the two figures differ: a library reported by rows says 13 when the * number of pieces of practice anybody can choose from is 12. */ const COVERED_STAGES = ['qualification', 'legal', 'scoping', 'proposal', 'poc', 'expansion']; const SHARED_ROWS = [ ...Array.from({ length: 12 }, (_, i) => template(`starter-${i}`, COVERED_STAGES[i % COVERED_STAGES.length]!), ), template('starter-0', 'qualification', 2), ]; const LINEAGES = 12; /** Twelve promotions, of which the tool may show eight. The gap is the point. */ const PROMOTION_TOTAL = 12; const PROMOTION_ROWS = Array.from({ length: 8 }, (_, i) => ({ title: `Promoted ${i}`, kind: 'case_study', version: 2, createdAt: new Date(Date.UTC(2026, 0, i + 1)), })); const LIBRARY = stubBook({ templates: SHARED_ROWS, lineageTotal: LINEAGES, promotionRows: PROMOTION_ROWS, promotionTotal: PROMOTION_TOTAL, openEngagements: Array.from({ length: 5 }, () => ({ stage: 'poc', dealName: 'DEMO — Halcyon' })), }); test('the promotion figure is the exact total, not the length of the list beside it', async () => { const reading = await read('/motion', LIBRARY.handle); // The /motion tile counts promotions in SQL for exactly this reason. Piggy // reading the length of its own capped list would peg the answer at 8 the // moment the loop started working, and disagree with the tile on screen. assert.equal(reading.promotions, PROMOTION_TOTAL); assert.match(String(reading.headline), /12 artifact\(s\) promoted back into the library/); assert.doesNotMatch(String(reading.headline), /8 artifact\(s\) promoted/); const scope = reading.recentPromotionsScope as ResultScope; assert.equal(scope.total, PROMOTION_TOTAL); assert.equal(scope.listed, PROMOTION_ROWS.length); assert.equal((reading.recentPromotions as unknown[]).length, PROMOTION_ROWS.length); }); test('the motion summary counts lineages, not versions', async () => { const reading = await read('/motion', LIBRARY.handle); const scope = reading.scope; assert.ok(scope); // 13 rows, 12 lineages. A denominator of 13 would be a library four times the // size of the one anybody can choose from, in miniature. assert.equal(scope.total, LINEAGES); assert.equal(reading.sharedTemplates, LINEAGES); assert.notEqual(scope.total, SHARED_ROWS.length); assert.match(scope.totalLabel, /shared template lineage\(s\)/); }); test('no motion denominator is taken over the whole template table', async () => { await read('/motion', LIBRARY.handle); await read('/motion/library', LIBRARY.handle, {}); assert.ok(LIBRARY.denominators.length >= 2); for (const where of LIBRARY.denominators) { // A total that counted private rows would publish the existence and the // size of colleagues' drafts through a figure nobody thinks of as a read. assert.match(where, /"visibility" = \$\d+/, where); assert.match(where, /"archived_at" is null/, where); } }); test('the stages the library misses are named when the whole library was read', async () => { const reading = await read('/motion', LIBRARY.handle); // Six stages are covered by the fixture, so the two that are not are a piece // of work somebody can act on — which is why this is a list and not a count. assert.deepEqual(reading.uncoveredStages, ['procurement', 'deployment']); assert.match(String(reading.headline), /No shared template covers procurement, deployment\./); }); test('stage coverage is not asserted from a capped scan', async () => { // SCAN_LIMIT is 500 and the read asks for one more, so 501 rows is a library // that certainly continues past the cap. const capped = stubBook({ templates: Array.from({ length: 501 }, (_, i) => template(`over-${i}`, 'qualification')), lineageTotal: 501, promotionRows: PROMOTION_ROWS, promotionTotal: PROMOTION_TOTAL, }); const reading = await read('/motion', capped.handle); // "No shared template covers deployment" is a definite negative, and the rows // that would refute it are precisely the ones the cap dropped. Null says the // question was not answered; an empty list would say every stage is covered // and a full list would say seven are not, and both are inventions. assert.equal(reading.uncoveredStages, null); assert.doesNotMatch(String(reading.headline), /No shared template covers/); assert.doesNotMatch(String(reading.headline), /Every live demand stage/); assert.match(String(reading.headline), /row cap/); }); test('a filtered library search is counted against the whole shared library', async () => { // The stub evaluates no where clause, so what a kind filter matched is // fixtured: three lineages, out of a library that still holds twelve. const filtered = stubBook({ templates: [ template('proposal-blocks', 'proposal'), template('proposal-terms', 'procurement'), template('proposal-exec', 'proposal'), ], lineageTotal: LINEAGES, }); const reading = await read('/motion/library', filtered.handle, { kind: 'proposal' }); const scope = reading.scope; assert.ok(scope); assert.equal(scope.matched, 3); assert.equal(scope.total, LINEAGES); assert.equal(reading.count, 3); assert.equal(reading.sharedTemplates, LINEAGES); // The denominator has to reach the headline, because the headline is the // field a small model quotes: "3 shared templates" alone is the size of a // filter presented as the size of the library. assert.match(String(reading.headline), /3 of 12 shared template lineage\(s\)/); assert.match(scope.summary, /the total is 12/); assert.equal(scope.filters.kind, 'proposal'); }); test('an unfiltered library search says so rather than hedging an exact figure', async () => { const reading = await read('/motion/library', LIBRARY.handle, { kind: null, stage: null, query: null, }); const scope = reading.scope; assert.ok(scope); // Nothing was filtered out, so `matched` IS the total. Reporting the nulls a // schema-abiding model sends as filters would teach it to distrust a figure // that is exact. assert.equal(scope.matched, scope.total); assert.deepEqual(scope.filters, {}); assert.match(scope.summary, /All 12 shared template lineage\(s\) in the motion library/); }); test('an engagement search is counted against every engagement on the book', async () => { const book = stubBook({ templates: [], lineageTotal: LINEAGES, engagementTotal: 9, engagementRows: [ { id: 'engagement-1', status: 'open', summary: 'Mid POC.', openedAt: new Date(Date.UTC(2026, 1, 1)), stage: 'poc', dealName: 'DEMO — Halcyon Research', accountName: 'DEMO — Halcyon Research', }, { id: 'engagement-2', status: 'closed', summary: 'Won.', openedAt: new Date(Date.UTC(2025, 10, 1)), stage: 'expansion', dealName: 'DEMO — Halcyon Expansion', accountName: 'DEMO — Halcyon Research', }, ], artifacts: [{ engagementId: 'engagement-1', status: 'final' }], scores: [], }); const reading = await read('/motion/engagements', book.handle, { query: 'Halcyon' }); const scope = reading.scope; assert.ok(scope); assert.equal(scope.matched, 2); assert.equal(scope.total, 9); assert.equal(reading.totalEngagements, 9); assert.match(String(reading.headline), /2 of 9 engagement\(s\) on the book match "Halcyon"/); assert.equal(scope.filters.query, 'Halcyon'); }); test('every motion result carries a complete scope, and no scope outruns its own total', async () => { const readings = [ await read('/motion', LIBRARY.handle), await read('/motion/library', LIBRARY.handle, {}), await read('/motion/engagements', stubBook({ templates: [], lineageTotal: 0 }).handle), ]; for (const reading of readings) { const found = Object.entries(reading) .filter(([key]) => key === 'scope' || key.endsWith('Scope')) .map(([, value]) => value as ResultScope); assert.ok(found.length > 0, `a motion result carries no scope at all: ${reading.headline}`); for (const scope of found) { assert.ok(scope.summary.length > 0); assert.ok(scope.totalLabel.length > 0); // The denominator has to reach the sentence, because the sentence is what // gets quoted. assert.match(scope.summary, new RegExp(`\\b${scope.total}\\b`)); assert.ok(scope.matched <= scope.total, 'matched exceeds its own denominator'); assert.ok(scope.listed <= scope.matched, 'more rows listed than matched'); } } });