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 @@
/**
* Quarters, and the one shape everything dated turns into.
*
* PIG's argument is that one ledger answers the question, so the calendar is a
* PROJECTION over records that already carry dates — contracts, obligations,
* deals, commitments, allocations, compliance artefacts — not a second store
* that would immediately drift from them. The only rows that live in their own
* table are the ones with no other home: a meeting, a QBR, a reminder.
*
* Kept in @pig/core because the browser and the API must agree on the event
* shape and on where a quarter begins.
*
* Two decisions are load-bearing here and are pinned by tests.
*
* **A quarter is half-open, [from, to).** Every temporal column in PIG is
* `timestamp with time zone`; there is not a single `date` column. So a
* quarter is an interval of instants, and consecutive quarters must tile
* without overlapping. An inclusive upper bound puts a contract expiring at
* exactly midnight on 1 October into both Q3 and Q4, and a GTM lead adding up
* two quarters then counts it twice.
*
* **A fiscal year is named for the calendar year it ENDS in.** This is the
* dominant convention among the companies whose paper PIG holds — a fiscal
* year beginning April 2026 and ending March 2027 is FY2027. The alternative
* (naming for the starting year) is also in use, which is exactly why the
* choice is stated here once rather than assumed at each call site.
*/
export type QuarterNumber = 1 | 2 | 3 | 4;
/** A fiscal or calendar quarter label, e.g. `2026-Q3`. */
export type Quarter = `${number}-Q${QuarterNumber}`;
/** Half-open interval of instants: `from` is included, `to` is not. */
export interface QuarterBounds {
from: Date;
to: Date;
quarter: Quarter;
}
/**
* Kinds of dated thing the projection can emit.
*
* Every one of these is derived from a record that already carries the date,
* except `calendar_entry`, which is the only row type the calendar owns.
*/
export const CALENDAR_EVENT_KINDS = [
'expected_close',
'contract_effective',
'contract_expiry',
'contract_executed',
'renewal_notice',
'obligation_due',
'capacity_window',
'allocation_window',
'hold_expiry',
'supply_available_from',
'authorization_expiry',
'artifact_expiry',
'calendar_entry',
] as const;
export type CalendarEventKind = (typeof CALENDAR_EVENT_KINDS)[number];
export function isCalendarEventKind(value: string): value is CalendarEventKind {
return (CALENDAR_EVENT_KINDS as readonly string[]).includes(value);
}
/** The kinds a human-owned `calendar_entries` row may take. */
export const CALENDAR_ENTRY_KINDS = [
'meeting',
'qbr',
'reminder',
'campaign',
'internal',
] as const;
export type CalendarEntryKind = (typeof CALENDAR_ENTRY_KINDS)[number];
/**
* `done` is set by a completion column, never by the clock — an obligation
* whose due date has passed is `overdue`, not finished, and conflating the two
* is how a missed renewal notice disappears from a screen.
*/
export type CalendarEventState = 'upcoming' | 'due' | 'overdue' | 'done';
export interface CalendarEvent {
/**
* `${recordType}:${recordId}:${field}` — synthesised, never stored. A
* derived projection has no primary key of its own, and inventing one in a
* table would mean the same expiry date living in two places.
*/
id: string;
kind: CalendarEventKind;
title: string;
/**
* ISO-8601. Strings rather than `Date` because this interface crosses the
* wire: the browser receives JSON, and a shape that only typechecks before
* serialisation is a shape the front end cannot honestly claim to hold.
*/
startsAt: string;
/** Null for a point in time. */
endsAt: string | null;
isSpan: boolean;
state: CalendarEventState;
accountId: string | null;
accountName: string | null;
ownerUserId: string | null;
/** Integer cents, per the money rule. Null where the event has no value. */
amountCents: number | null;
currency: string | null;
/** The table the event was derived from, e.g. `contract`. */
recordType: string;
recordId: string;
href: string;
meta: Record<string, unknown>;
}
/** Stable, storage-free identity for a projected event. */
export function calendarEventId(
recordType: string,
recordId: string,
field: string,
): string {
return `${recordType}:${recordId}:${field}`;
}
/**
* How soon before its date an event counts as `due` rather than `upcoming`.
* Seven days is the shortest horizon in which a renewal notice or an export
* authorisation can still realistically be acted on.
*/
export const CALENDAR_DUE_HORIZON_DAYS = 7;
const DAY_MS = 86_400_000;
export function eventState(input: {
at: Date;
now: Date;
completedAt?: Date | null;
dueWithinDays?: number;
}): CalendarEventState {
if (input.completedAt) return 'done';
const at = input.at.getTime();
const now = input.now.getTime();
if (at < now) return 'overdue';
const horizon = (input.dueWithinDays ?? CALENDAR_DUE_HORIZON_DAYS) * DAY_MS;
return at - now <= horizon ? 'due' : 'upcoming';
}
/**
* A span's state reads from its END, because a window that has started is not
* late — it is running. Only a window that has closed is behind us.
*
* `done` is right here only because these spans have no completion column: a
* capacity or allocation window IS the fact, and it is over when the clock says
* so. A span nobody could fail to do cannot be overdue. For anything a person
* was supposed to do inside the window, use `completableSpanState`.
*/
export function spanState(input: {
startsAt: Date;
endsAt: Date;
now: Date;
}): CalendarEventState {
if (input.endsAt.getTime() < input.now.getTime()) return 'done';
if (input.startsAt.getTime() <= input.now.getTime()) return 'due';
return eventState({ at: input.startsAt, now: input.now });
}
/**
* The same reading of a span for a row that CAN record completion — today only
* `calendar_entries`, which carries `completed_at`.
*
* The end still decides running versus past, but a closed window with nothing
* in the completion column is `overdue`, not `done`. Reusing `spanState` here
* made a missed QBR read as finished purely because its author had typed an end
* time — the byte-identical entry without one read `overdue` — which is exactly
* the conflation the note above `CalendarEventState` forbids.
*/
export function completableSpanState(input: {
startsAt: Date;
endsAt: Date;
now: Date;
completedAt?: Date | null;
}): CalendarEventState {
if (input.completedAt) return 'done';
if (input.endsAt.getTime() < input.now.getTime()) return 'overdue';
if (input.startsAt.getTime() <= input.now.getTime()) return 'due';
return eventState({ at: input.startsAt, now: input.now });
}
// ---------------------------------------------------------------- time zones
interface ZonedParts {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
}
/**
* Constructing an `Intl.DateTimeFormat` is expensive enough to be worth
* keeping, but the key is a caller-supplied string that reaches here from a
* query parameter, so the map is capped: an unbounded one lets a loop over
* distinct values grow a long-lived API process without limit. The IANA
* database has well under 500 zones, so a real deployment never evicts;
* insertion-order eviction only ever bites junk.
*/
const FORMATTER_CACHE_LIMIT = 512;
const formatterCache = new Map<string, Intl.DateTimeFormat>();
/** Whether the runtime's ICU data recognises `timeZone` as an IANA zone. */
export function isValidTimeZone(timeZone: string): boolean {
try {
new Intl.DateTimeFormat('en-US', { timeZone });
return true;
} catch {
return false;
}
}
/**
* Falls back to UTC rather than throwing. `users.timezone` is free text set
* through a preferences endpoint that does not validate it, so a stale or
* mistyped zone must degrade to a defensible answer instead of 500ing the
* whole calendar. Request-supplied zones are rejected at the route boundary
* instead, where a 400 can still tell the caller what was wrong.
*/
function formatterFor(timeZone: string): Intl.DateTimeFormat {
const cached = formatterCache.get(timeZone);
if (cached) return cached;
let formatter: Intl.DateTimeFormat;
try {
formatter = new Intl.DateTimeFormat('en-US', {
timeZone,
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
} catch {
formatter = formatterFor('UTC');
}
if (formatterCache.size >= FORMATTER_CACHE_LIMIT) {
const oldest = formatterCache.keys().next();
if (!oldest.done) formatterCache.delete(oldest.value);
}
formatterCache.set(timeZone, formatter);
return formatter;
}
function zonedParts(instant: Date, timeZone: string): ZonedParts {
const parts = formatterFor(timeZone).formatToParts(instant);
const read = (type: Intl.DateTimeFormatPartTypes): number =>
Number(parts.find((part) => part.type === type)?.value ?? '0');
return {
year: read('year'),
month: read('month'),
day: read('day'),
hour: read('hour'),
minute: read('minute'),
second: read('second'),
};
}
function offsetMsAt(instant: Date, timeZone: string): number {
const parts = zonedParts(instant, timeZone);
const asIfUtc = Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second,
);
return asIfUtc - instant.getTime();
}
/**
* The instant at which local midnight begins on a given day in a given zone.
*
* Iterated rather than solved because the offset depends on the instant we are
* trying to find. Two passes settle every real zone including the daylight
* transitions; the third is insurance and costs nothing.
*/
function startOfZonedDay(
year: number,
month: number,
day: number,
timeZone: string,
): Date {
const wallClock = Date.UTC(year, month - 1, day);
let instant = wallClock;
for (let pass = 0; pass < 3; pass += 1) {
instant = wallClock - offsetMsAt(new Date(instant), timeZone);
}
return new Date(instant);
}
// ------------------------------------------------------------------ quarters
function assertFiscalStart(fiscalYearStartMonth: number): void {
if (
!Number.isInteger(fiscalYearStartMonth) ||
fiscalYearStartMonth < 0 ||
fiscalYearStartMonth > 11
) {
throw new RangeError(
`fiscalYearStartMonth must be an integer month index 011, got ${fiscalYearStartMonth}.`,
);
}
}
/**
* Which quarter an instant falls in.
*
* `fiscalYearStartMonth` is a zero-based month index: 0 for calendar quarters
* (the default), 3 for an April start, 9 for an October start. `timeZone`
* decides the boundary, and it matters: 31 December 23:00 in New York is
* already Q1 in London.
*/
export function quarterOf(
date: Date,
fiscalYearStartMonth = 0,
timeZone = 'UTC',
): Quarter {
assertFiscalStart(fiscalYearStartMonth);
const local = zonedParts(date, timeZone);
const monthIndex = local.month - 1;
// Months elapsed since the fiscal year began, 011.
const sinceStart = (monthIndex - fiscalYearStartMonth + 12) % 12;
const quarter = (Math.floor(sinceStart / 3) + 1) as QuarterNumber;
// Named for the year the fiscal year ends in — see the note at the top.
const startedThisCalendarYear = monthIndex >= fiscalYearStartMonth;
const fiscalYear =
fiscalYearStartMonth === 0
? local.year
: startedThisCalendarYear
? local.year + 1
: local.year;
return `${fiscalYear}-Q${quarter}`;
}
/**
* The half-open bounds of a quarter, as instants.
*
* `year` is the fiscal year label, not the calendar year in which the quarter
* starts — those differ for every non-calendar fiscal offset, which is the
* mistake this signature exists to make hard to write.
*/
export function quarterBounds(
year: number,
quarter: QuarterNumber,
fiscalYearStartMonth = 0,
timeZone = 'UTC',
): QuarterBounds {
assertFiscalStart(fiscalYearStartMonth);
if (!Number.isInteger(quarter) || quarter < 1 || quarter > 4) {
throw new RangeError(`quarter must be 14, got ${quarter}.`);
}
// Inverse of the labelling rule in quarterOf: a non-calendar fiscal year
// labelled `year` began in the previous calendar year.
const fiscalStartCalendarYear = fiscalYearStartMonth === 0 ? year : year - 1;
const startMonthIndex = fiscalYearStartMonth + (quarter - 1) * 3;
const from = startOfZonedDay(
fiscalStartCalendarYear + Math.floor(startMonthIndex / 12),
(startMonthIndex % 12) + 1,
1,
timeZone,
);
const endMonthIndex = startMonthIndex + 3;
const to = startOfZonedDay(
fiscalStartCalendarYear + Math.floor(endMonthIndex / 12),
(endMonthIndex % 12) + 1,
1,
timeZone,
);
return { from, to, quarter: `${year}-Q${quarter}` };
}
/** Splits `2026-Q3` back into its parts. Null when the label is malformed. */
export function parseQuarter(
label: string,
): { year: number; quarter: QuarterNumber } | null {
const match = /^(\d{4})-Q([1-4])$/.exec(label);
if (!match) return null;
return {
year: Number(match[1]),
quarter: Number(match[2]) as QuarterNumber,
};
}
/** The bounds of the quarter containing `date`. */
export function quarterBoundsFor(
date: Date,
fiscalYearStartMonth = 0,
timeZone = 'UTC',
): QuarterBounds {
const parsed = parseQuarter(quarterOf(date, fiscalYearStartMonth, timeZone));
if (!parsed) throw new Error('quarterOf produced an unparseable label.');
return quarterBounds(parsed.year, parsed.quarter, fiscalYearStartMonth, timeZone);
}