Rebuild the shell, add Calendar and Learn, and govern reads
CI / verify (push) Successful in 3m45s
CI / publish (push) Has been skipped

Seven parallel agents and an adversarial verification pass. The three things
worth knowing before reading the diff:

RBAC WAS ALREADY BUILT. docs/build-plan.md marks F2 and F3 outstanding and is
stale — packages/core/src/permissions.ts and lib/mutation.ts shipped long ago.
So this does not rebuild them; it closes the gaps an audit found. The big one
is that reads were entirely ungoverned: every GET was "any authenticated
member", so a junior demand rep and a research contractor could both pull
per-block supplier cost and break-even prices from /api/capacity/margin, and
every contract's negotiated terms. For a company whose margin is the business,
that was the hole that mattered. Adds book:read / economics:read / team:read,
a readGuard middleware, and a `viewer` role below member.

THE BUTTON AND THE 403 DISAGREED — the exact thing F3 said must never happen.
Contracts.tsx never called can() at all, so its save button was always enabled
against a server requiring contract:sign; Capacity.tsx gated commitment
creation on deal:write/demand while the server wanted commitment:write/supply.

POST /api/activities was the one write bypassing executeMutation: no capability
check, and any member could mutate accounts.lastActivityAt as a side effect.
It is now a proper mutation() behind activity:write.

The shell becomes three panes — a collapsible shadcn sidebar with an account
switcher on the Piggy accent, a header with real search, and Piggy docked to
the right, page-aware and persistent across navigation. The phone keeps its
bottom tab bar, which is the thing this product already beat trycompai/crm on,
and gains the sidebar as a sheet.

Calendar is a projection over thirteen dated sources rather than a new table,
because a table would duplicate dates that already live on contracts, deals and
commitments and would drift — and one ledger answering the question is the
whole argument. It surfaces export_authorizations and compliance_artifacts,
which had indexed expires_at columns, schema comments saying they must be
alerted on, and no read endpoint or UI anywhere.

Learn carries two tracks. Concepts are members-only; the platform track can be
opened with a share code by someone with no account. The code mints a scoped
learn-only token and never a Principal — every route here resolves a principal
and then checks capabilities, so a principal-minting code would be one missing
check away from leaking the book. "Only platform-track rows may be code-visible"
is a database CHECK constraint as well as a write-path rule, and a test asserts
a valid learn token still gets 401 on /api/dashboard, /api/accounts and
/api/contracts — the same invariant scripts/deploy.sh refuses to ship without.

CD becomes tag-to-ship. CI publishes an image to the Gitea registry on a
release-* tag and cloud-2 pulls it, so no credential on the shared runner can
execute anything on production — by construction rather than by policy. Both
halves of deploy.sh's original rule survive: nothing on the runner reaches the
host, and a human still decides when it ships. deploy.sh gains a rollback and a
public-origin check, and PIG_IMAGE now reaches compose through `sudo env`,
without which sudo's env_reset silently resolved every release to pig:local.

Tests 141 -> 261.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-13 15:02:48 -07:00
parent 6cf80747cc
commit 13dec6b4b8
102 changed files with 28638 additions and 913 deletions
+408
View File
@@ -0,0 +1,408 @@
/**
* The page tools, executed against a real Postgres.
*
* `test/chat-tools.test.ts` passes `{} as Database` and asserts on tool names,
* which is the right shape for a selection test and no shape at all for the
* five `execute` bodies underneath: every where clause, every `Number(gpuHours)`
* coercion and every date comparison was covered by tsc alone. The defect that
* prompted this suite — a headline quoting `list.length` from a capped query as
* if it were a total — typechecks perfectly.
*
* It lives in `e2e/` rather than `test/` for one reason: the unit suite runs in
* CI BEFORE the migration step, against a database with no tables. `test:e2e`
* runs after migrate and seed, which is the only point at which a query here
* can mean anything.
*
* Every assertion is a DELTA against a reading taken before the fixture is
* inserted. The tools are book-wide by design — there is no tenant to scope
* them to — so they see the seed, the demo book and whatever the API's own E2E
* left behind. Absolute figures would be a test of the seed, not of the tool.
*/
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import test, { after, before } from 'node:test';
import { eq, inArray } from 'drizzle-orm';
import {
accounts,
allocations,
capacityCommitments,
contractObligations,
contracts,
createDatabase,
demandDeals,
type Database,
} from '@pig/db';
import { createPagePigTools } from '../src/page-tools';
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) throw new Error('DATABASE_URL is required for the Piggy page-tool E2E tests.');
const db: Database = createDatabase({ url: databaseUrl, max: 4 });
const MINUTE = 60_000;
const HOUR = 3_600_000;
const DAY = 86_400_000;
/** Marks every fixture row so cleanup can never reach somebody else's data. */
const marker = `PIGGY-E2E-${randomUUID()}`;
/** How many exemplars each result may carry — mirrors EXEMPLARS in page-tools. */
const EXEMPLARS = 8;
interface Reading {
headline: string;
truncated?: unknown;
[key: string]: unknown;
}
async function run(route: '/margin' | '/capacity' | '/demand' | '/calendar' | '/'): Promise<Reading> {
const [tool] = createPagePigTools(db, route);
assert.ok(tool, `no tool for ${route}`);
// The calendar tool is the only one taking input, and its default is 30.
return (await tool.execute({})) as Reading;
}
const created = {
accountId: '',
contractId: '',
commitmentId: '',
dealIds: [] as string[],
};
/**
* The fixture is deliberately lopsided.
*
* Twenty obligations fall due inside the horizon and twelve are already late,
* because the capped exemplar lists hold sixteen and eight — a headline that
* reports the list length rather than the count cannot survive those numbers.
*/
const UPCOMING_OBLIGATIONS = 20;
const OVERDUE_OBLIGATIONS = 12;
let before30: Reading;
let after30: Reading;
let marginBefore: Reading;
let marginAfter: Reading;
let idleBefore: Reading;
let idleAfter: Reading;
let pipelineBefore: Reading;
let pipelineAfter: Reading;
let workspaceBefore: Reading;
let workspaceAfter: Reading;
before(async () => {
const now = Date.now();
[before30, marginBefore, idleBefore, pipelineBefore, workspaceBefore] = await Promise.all([
run('/calendar'),
run('/margin'),
run('/capacity'),
run('/demand'),
run('/'),
]);
const [account] = await db
.insert(accounts)
.values({ name: `${marker} counterparty`, side: 'supply' })
.returning();
assert.ok(account);
created.accountId = account.id;
const [contract] = await db
.insert(contracts)
.values({
accountId: account.id,
type: 'msa',
status: 'executed',
side: 'demand',
title: `${marker} master agreement`,
// Inside the 30-day horizon, so the projection must emit a
// contract_expiry — a kind the old two-table version could not see.
expiresAt: new Date(now + 10 * DAY),
// Auto-renewal with notice puts a renewal_notice inside the horizon too.
isAutoRenew: true,
noticeDays: 5,
})
.returning();
assert.ok(contract);
created.contractId = contract.id;
await db.insert(contractObligations).values([
...Array.from({ length: UPCOMING_OBLIGATIONS }, (_, i) => ({
contractId: contract.id,
title: `${marker} upcoming ${i}`,
kind: 'milestone',
dueAt: new Date(now + (i + 1) * MINUTE),
})),
...Array.from({ length: OVERDUE_OBLIGATIONS }, (_, i) => ({
contractId: contract.id,
title: `${marker} overdue ${i}`,
kind: 'milestone',
// i = 0 lapsed a minute ago, i = 11 twelve minutes ago.
dueAt: new Date(now - (i + 1) * MINUTE),
})),
{
contractId: contract.id,
title: `${marker} already done`,
kind: 'milestone',
dueAt: new Date(now + 2 * DAY),
// Completed work is a dated fact, not a workload; it must not be counted.
completedAt: new Date(now - DAY),
},
]);
// 1,000 GPU-hours bought at 100c. Half sells at exactly cost, 100 more are
// held; the block therefore loses money on the hours nobody bought, which is
// the arithmetic PIG exists to keep honest.
const [commitment] = await db
.insert(capacityCommitments)
.values({
accountId: account.id,
name: `${marker} block`,
gpuType: 'H100',
gpuCount: 8,
startsAt: new Date(now - DAY),
endsAt: new Date(now + 20 * DAY),
totalGpuHours: '1000.00',
costPerGpuHourCents: 100,
})
.returning();
assert.ok(commitment);
created.commitmentId = commitment.id;
const [openDeal, closingDeal] = await db
.insert(demandDeals)
.values([
{
accountId: account.id,
name: `${marker} open deal`,
stage: 'proposal',
acvCents: 1_000_000,
tcvCents: 2_500_000,
},
{
accountId: account.id,
name: `${marker} closing deal`,
stage: 'procurement',
acvCents: 4_000_000,
tcvCents: 4_000_000,
probability: '0.50',
expectedCloseDate: new Date(now + 3 * DAY),
},
])
.returning();
assert.ok(openDeal && closingDeal);
created.dealIds = [openDeal.id, closingDeal.id];
await db.insert(allocations).values([
{
capacityCommitmentId: commitment.id,
demandDealId: openDeal.id,
gpuHours: '500.00',
pricePerGpuHourCents: 100,
startsAt: new Date(now - HOUR),
endsAt: new Date(now + 10 * DAY),
status: 'committed',
},
{
capacityCommitmentId: commitment.id,
demandDealId: closingDeal.id,
gpuHours: '100.00',
pricePerGpuHourCents: 300,
startsAt: new Date(now - HOUR),
endsAt: new Date(now + 10 * DAY),
status: 'planned',
// A live hold: removed from availability, never revenue. Inside the
// horizon, so it is also a hold_expiry event on the calendar.
holdExpiresAt: new Date(now + 4 * DAY),
},
]);
[after30, marginAfter, idleAfter, pipelineAfter, workspaceAfter] = await Promise.all([
run('/calendar'),
run('/margin'),
run('/capacity'),
run('/demand'),
run('/'),
]);
});
after(async () => {
if (created.commitmentId) {
await db
.delete(allocations)
.where(eq(allocations.capacityCommitmentId, created.commitmentId));
await db.delete(capacityCommitments).where(eq(capacityCommitments.id, created.commitmentId));
}
if (created.dealIds.length > 0) {
await db.delete(demandDeals).where(inArray(demandDeals.id, created.dealIds));
}
// Obligations cascade from the contract.
if (created.contractId) await db.delete(contracts).where(eq(contracts.id, created.contractId));
if (created.accountId) await db.delete(accounts).where(eq(accounts.id, created.accountId));
await db.$client.end();
});
// ---------------------------------------------------------------------------
// The calendar
// ---------------------------------------------------------------------------
interface CalendarReading extends Reading {
exactTotals: { obligationsDue: number; dealsExpectedToClose: number };
upcoming: { count: number; byKind: Record<string, number>; events: { startsAt: string }[] };
overdue: {
count: number;
byKind: Record<string, number>;
events: { title: string; startsAt: string }[];
};
}
test('the calendar headline counts the whole set, not the capped exemplar list', () => {
const from = before30 as CalendarReading;
const to = after30 as CalendarReading;
// The defect, pinned. Twenty obligations were added and the exemplar list
// holds sixteen; a headline built from `list.length` reports sixteen.
assert.equal(
to.exactTotals.obligationsDue - from.exactTotals.obligationsDue,
UPCOMING_OBLIGATIONS,
);
assert.equal(
(to.upcoming.byKind.obligation_due ?? 0) - (from.upcoming.byKind.obligation_due ?? 0),
UPCOMING_OBLIGATIONS,
);
assert.equal(to.upcoming.events.length, EXEMPLARS * 2);
assert.ok(to.upcoming.count > to.upcoming.events.length);
assert.match(to.headline, new RegExp(`${to.exactTotals.obligationsDue} obligation\\(s\\) due`));
assert.equal(
(to.overdue.byKind.obligation_due ?? 0) - (from.overdue.byKind.obligation_due ?? 0),
OVERDUE_OBLIGATIONS,
);
assert.equal(to.overdue.events.length, EXEMPLARS);
assert.ok(to.overdue.count > to.overdue.events.length);
assert.match(to.headline, new RegExp(`${to.overdue.count} item\\(s\\) overdue`));
});
test('a completed obligation is a dated fact, not a workload', () => {
const to = after30 as CalendarReading;
const from = before30 as CalendarReading;
// Twenty-one obligations were inserted inside the horizon; the completed one
// is excluded from both the SQL total and the state-filtered list.
assert.equal(
to.exactTotals.obligationsDue - from.exactTotals.obligationsDue,
UPCOMING_OBLIGATIONS,
);
const states = (to.upcoming as unknown as { byState: Record<string, number> }).byState;
assert.equal(states.done, undefined);
});
test('the projection reaches the kinds the old two-table version could not', () => {
const to = after30 as CalendarReading;
const from = before30 as CalendarReading;
const gained = (kind: string): number =>
(to.upcoming.byKind[kind] ?? 0) - (from.upcoming.byKind[kind] ?? 0);
assert.equal(gained('contract_expiry'), 1);
assert.equal(gained('renewal_notice'), 1);
assert.equal(gained('hold_expiry'), 1);
assert.equal(gained('expected_close'), 1);
// The commitment window overlaps the horizon on both edges.
assert.ok(gained('capacity_window') >= 1);
assert.ok(gained('allocation_window') >= 1);
assert.equal(to.exactTotals.dealsExpectedToClose - from.exactTotals.dealsExpectedToClose, 1);
});
test('overdue exemplars are the most recently lapsed, not the oldest in the book', () => {
const to = after30 as CalendarReading;
const dates = to.overdue.events.map((event) => event.startsAt);
assert.deepEqual(dates, [...dates].sort().reverse(), 'overdue exemplars run newest first');
const titles = to.overdue.events.map((event) => event.title);
// Lapsed one minute ago: present. Lapsed twelve minutes ago: cut, because
// twelve rows were inserted and only eight are carried.
assert.ok(titles.includes(`${marker} overdue 0`));
assert.ok(!titles.includes(`${marker} overdue ${OVERDUE_OBLIGATIONS - 1}`));
});
test('a book this size is not truncated, and says so', () => {
assert.equal(after30.truncated, false);
assert.doesNotMatch(after30.headline, /at least/);
});
// ---------------------------------------------------------------------------
// The book
// ---------------------------------------------------------------------------
interface MarginReading extends Reading {
totals: { revenueCents: number; costCents: number; grossMarginCents: number };
liveCommitments: number;
}
test('margin charges cost against the full commitment and coerces numeric strings', () => {
const from = marginBefore as MarginReading;
const to = marginAfter as MarginReading;
// 500 sold hours × 100c. The held 100 are not revenue.
assert.equal(to.totals.revenueCents - from.totals.revenueCents, 50_000);
// 1,000 committed hours × 100c — not the 500 that sold.
assert.equal(to.totals.costCents - from.totals.costCents, 100_000);
// Charging cost against the sold hours alone would report break-even here
// instead of a 50,000c hole, which is the reading the rule forbids.
assert.equal(to.totals.grossMarginCents - from.totals.grossMarginCents, -50_000);
assert.equal(to.liveCommitments - from.liveCommitments, 1);
// `gpuHours` arrives as a string. Concatenation would give "1000.00500.00"
// and a revenue in the billions rather than a delta of exactly 50,000c.
assert.ok(Number.isSafeInteger(to.totals.revenueCents));
});
test('the idle block appears with its break-even price', () => {
const from = idleBefore as Reading & { totalIdleCostCents: number; blocks: unknown[] };
const to = idleAfter as Reading & {
totalIdleCostCents: number;
blocks: { name: string; idleGpuHours: number; breakEvenPricePerGpuHourCents: number }[];
};
// 50% unsold, well past the 25% threshold: 500 idle hours at 100c.
assert.equal(to.totalIdleCostCents - from.totalIdleCostCents, 50_000);
const mine = to.blocks.find((block) => block.name === `${marker} block`);
assert.ok(mine, 'the fixture block is idle enough to be listed');
assert.equal(mine.idleGpuHours, 500);
// The remaining 500 hours must fetch 100c each to cover the whole block.
assert.equal(mine.breakEvenPricePerGpuHourCents, 100);
});
// ---------------------------------------------------------------------------
// The pipelines
// ---------------------------------------------------------------------------
interface PipelineReading extends Reading {
demand: { openDeals: number; valueCents: number; byStage: Record<string, number> };
supply: { openDeals: number };
}
test('the pipeline totals TCV where it is known and reports open stages', () => {
const from = pipelineBefore as PipelineReading;
const to = pipelineAfter as PipelineReading;
assert.equal(to.demand.openDeals - from.demand.openDeals, 2);
// 2,500,000 + 4,000,000, both by TCV.
assert.equal(to.demand.valueCents - from.demand.valueCents, 6_500_000);
assert.equal((to.demand.byStage.proposal ?? 0) - (from.demand.byStage.proposal ?? 0), 1);
assert.equal((to.demand.byStage.procurement ?? 0) - (from.demand.byStage.procurement ?? 0), 1);
assert.deepEqual(to.truncated, { demandDeals: false, supplyDeals: false });
});
test('the workspace summary agrees with the tools it summarises', () => {
const from = workspaceBefore as Reading & { book: { costCents: number }; openDemandDeals: number };
const to = workspaceAfter as Reading & { book: { costCents: number }; openDemandDeals: number };
assert.equal(to.book.costCents - from.book.costCents, 100_000);
assert.equal(to.openDemandDeals - from.openDemandDeals, 2);
assert.deepEqual(to.truncated, {
commitments: false,
demandDeals: false,
supplyDeals: false,
});
});