Files
karti 13dec6b4b8
CI / verify (push) Successful in 3m45s
CI / publish (push) Has been skipped
Rebuild the shell, add Calendar and Learn, and govern reads
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>
2026-08-13 15:02:48 -07:00

246 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Tests for quarter arithmetic and the projected event shape.
*
* The calendar has no storage of its own, so there is nothing to inspect when
* a bucket is wrong — a deal simply appears under the wrong heading and the
* quarterly number is quietly off. The cases below therefore pin the two
* decisions that a plausible-but-wrong implementation gets backwards: which
* calendar year names a fiscal year, and whether the upper bound is inclusive.
*/
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import {
CALENDAR_EVENT_KINDS,
calendarEventId,
completableSpanState,
eventState,
isCalendarEventKind,
isValidTimeZone,
parseQuarter,
quarterBounds,
quarterBoundsFor,
quarterOf,
spanState,
} from '../src/calendar';
describe('quarterOf', () => {
it('buckets calendar quarters', () => {
assert.equal(quarterOf(new Date('2026-01-01T00:00:00.000Z')), '2026-Q1');
assert.equal(quarterOf(new Date('2026-08-13T12:00:00.000Z')), '2026-Q3');
assert.equal(quarterOf(new Date('2026-12-31T23:59:59.999Z')), '2026-Q4');
});
it('names a fiscal year for the calendar year it ENDS in', () => {
// The decision. A fiscal year starting April 2026 runs to March 2027 and
// is FY2027 — the convention used by the companies whose paper PIG holds.
// Naming it FY2026 instead is the plausible-but-wrong version: it puts
// every April-to-December deal a year early, and the error is invisible
// because the quarter number is right.
assert.equal(quarterOf(new Date('2026-04-01T00:00:00.000Z'), 3), '2027-Q1');
assert.equal(quarterOf(new Date('2026-08-13T00:00:00.000Z'), 3), '2027-Q2');
assert.equal(quarterOf(new Date('2027-03-31T23:00:00.000Z'), 3), '2027-Q4');
// The next fiscal year begins the following day, and the label advances.
assert.equal(quarterOf(new Date('2027-04-01T00:00:00.000Z'), 3), '2028-Q1');
});
it('handles the year boundary under an October fiscal start', () => {
// A US-federal-style year: October 2026 is already FY2027 Q1, while
// September 2026 is still FY2026 Q4. A naive implementation that derives
// the label from the calendar year alone puts these in the same year.
assert.equal(quarterOf(new Date('2026-09-30T23:59:59.999Z'), 9), '2026-Q4');
assert.equal(quarterOf(new Date('2026-10-01T00:00:00.000Z'), 9), '2027-Q1');
assert.equal(quarterOf(new Date('2026-12-31T23:59:59.999Z'), 9), '2027-Q1');
assert.equal(quarterOf(new Date('2027-01-01T00:00:00.000Z'), 9), '2027-Q2');
});
it('buckets by the reader time zone, not by UTC', () => {
// Every temporal column in PIG is `timestamp with time zone`, so a
// quarter boundary is a local-midnight question. This instant is already
// Q1 in London and still Q4 in New York; bucketing everything in UTC
// silently reports one of the two readers a wrong quarterly total.
const newYearInLondon = new Date('2027-01-01T00:30:00.000Z');
assert.equal(quarterOf(newYearInLondon, 0, 'Europe/London'), '2027-Q1');
assert.equal(quarterOf(newYearInLondon, 0, 'America/New_York'), '2026-Q4');
});
it('falls back to UTC rather than throwing on an unusable zone', () => {
// `users.timezone` is free text and nothing validates it on write.
assert.equal(quarterOf(new Date('2026-08-13T12:00:00.000Z'), 0, 'Mars/Olympus'), '2026-Q3');
});
it('rejects a fiscal start that is not a month index', () => {
assert.throws(() => quarterOf(new Date(), 12), RangeError);
assert.throws(() => quarterOf(new Date(), -1), RangeError);
});
});
describe('quarterBounds', () => {
it('is half-open, so consecutive quarters tile without overlapping', () => {
// An inclusive upper bound files a contract expiring at exactly midnight
// on 1 October into both Q3 and Q4, and adding the two quarters together
// then counts its value twice.
const q3 = quarterBounds(2026, 3);
const q4 = quarterBounds(2026, 4);
assert.equal(q3.from.toISOString(), '2026-07-01T00:00:00.000Z');
assert.equal(q3.to.toISOString(), '2026-10-01T00:00:00.000Z');
assert.equal(q3.to.getTime(), q4.from.getTime());
assert.equal(quarterOf(q3.to), '2026-Q4', 'the upper bound belongs to the NEXT quarter');
});
it('rolls into the following calendar year for a fiscal offset', () => {
// FY2027 under an April start began in April 2026, so its Q4 is the first
// calendar quarter of 2027. Reading `year` as the starting calendar year
// puts this a whole year out.
const q1 = quarterBounds(2027, 1, 3);
const q4 = quarterBounds(2027, 4, 3);
assert.equal(q1.from.toISOString(), '2026-04-01T00:00:00.000Z');
assert.equal(q4.from.toISOString(), '2027-01-01T00:00:00.000Z');
assert.equal(q4.to.toISOString(), '2027-04-01T00:00:00.000Z');
assert.equal(q4.quarter, '2027-Q4');
});
it('round-trips with quarterOf at both ends of every fiscal offset', () => {
for (const fiscalStart of [0, 1, 3, 6, 9, 11]) {
for (const quarter of [1, 2, 3, 4] as const) {
const bounds = quarterBounds(2027, quarter, fiscalStart);
assert.equal(quarterOf(bounds.from, fiscalStart), bounds.quarter);
assert.equal(
quarterOf(new Date(bounds.to.getTime() - 1), fiscalStart),
bounds.quarter,
`last instant of ${bounds.quarter} at fiscal start ${fiscalStart}`,
);
}
}
});
it('anchors on local midnight, not on UTC midnight', () => {
// New York is five hours behind in January, so its Q1 opens at 05:00Z.
const q1 = quarterBounds(2026, 1, 0, 'America/New_York');
assert.equal(q1.from.toISOString(), '2026-01-01T05:00:00.000Z');
});
it('survives a quarter that opens across a daylight-saving transition', () => {
// Sydney's Q4 opens on the morning clocks go forward; a fixed-offset
// implementation lands an hour out and mis-buckets everything on 1 October.
const q4 = quarterBounds(2026, 4, 0, 'Australia/Sydney');
assert.equal(quarterOf(q4.from, 0, 'Australia/Sydney'), '2026-Q4');
assert.equal(
quarterOf(new Date(q4.from.getTime() - 1), 0, 'Australia/Sydney'),
'2026-Q3',
);
});
it('rejects a quarter outside 14', () => {
assert.throws(() => quarterBounds(2026, 0 as 1), RangeError);
assert.throws(() => quarterBounds(2026, 5 as 1), RangeError);
});
});
describe('quarterBoundsFor and parseQuarter', () => {
it('contains the instant it was derived from', () => {
const date = new Date('2026-08-13T12:00:00.000Z');
const bounds = quarterBoundsFor(date, 3, 'Europe/London');
assert.equal(bounds.quarter, '2027-Q2');
assert.ok(bounds.from <= date && date < bounds.to);
});
it('rejects a malformed label rather than guessing', () => {
assert.deepEqual(parseQuarter('2026-Q3'), { year: 2026, quarter: 3 });
assert.equal(parseQuarter('2026-Q5'), null);
assert.equal(parseQuarter('26-Q3'), null);
assert.equal(parseQuarter('2026Q3'), null);
});
});
describe('event state', () => {
const now = new Date('2026-08-13T12:00:00.000Z');
const inDays = (days: number) => new Date(now.getTime() + days * 86_400_000);
it('reads completion from the column, never from the clock', () => {
// A renewal notice whose date has passed is overdue, not finished.
assert.equal(eventState({ at: inDays(-3), now }), 'overdue');
assert.equal(
eventState({ at: inDays(-3), now, completedAt: inDays(-4) }),
'done',
'only a completion timestamp closes an event',
);
});
it('separates due from upcoming on the action horizon', () => {
assert.equal(eventState({ at: inDays(3), now }), 'due');
assert.equal(eventState({ at: inDays(30), now }), 'upcoming');
});
it('judges a span by its end, because a running window is not late', () => {
assert.equal(spanState({ startsAt: inDays(-10), endsAt: inDays(10), now }), 'due');
assert.equal(spanState({ startsAt: inDays(-30), endsAt: inDays(-1), now }), 'done');
assert.equal(spanState({ startsAt: inDays(60), endsAt: inDays(90), now }), 'upcoming');
});
it('never lets a clock close a span that has a completion column', () => {
// The defect this pins: a QBR scheduled last week and never held read
// `done` under `spanState`, while the same QBR entered with no end time
// read `overdue`. Whether a missed human commitment is flagged then
// depends on nothing but whether its author typed an end time.
const missed = { startsAt: inDays(-30), endsAt: inDays(-1), now };
assert.equal(completableSpanState(missed), 'overdue');
assert.equal(eventState({ at: missed.startsAt, now }), 'overdue', 'and agrees with a point');
assert.equal(completableSpanState({ ...missed, completedAt: inDays(-2) }), 'done');
assert.equal(spanState(missed), 'done', 'the windowed reading is still right for windows');
});
it('still reads a running or future completable span from its end and start', () => {
assert.equal(
completableSpanState({ startsAt: inDays(-10), endsAt: inDays(10), now }),
'due',
);
assert.equal(
completableSpanState({ startsAt: inDays(60), endsAt: inDays(90), now }),
'upcoming',
);
assert.equal(
completableSpanState({ startsAt: inDays(60), endsAt: inDays(90), now, completedAt: now }),
'done',
'completion outranks the clock in both directions',
);
});
});
describe('time zone validation', () => {
it('separates a real zone from a plausible-looking string', () => {
// The projection falls back to UTC for a stored zone it cannot use, which
// is right for `users.timezone` and wrong for a query parameter: a caller
// asking for a zone that does not exist should be told, not quietly
// answered in UTC, and the formatter cache is keyed on this string.
assert.equal(isValidTimeZone('Europe/London'), true);
assert.equal(isValidTimeZone('UTC'), true);
assert.equal(isValidTimeZone('Mars/Olympus'), false);
assert.equal(isValidTimeZone(''), false);
});
it('does not grow the formatter cache without bound', () => {
// A loop over distinct values used to add one entry per value, for the
// life of the process. Nothing observable should change from running it.
for (let index = 0; index < 2000; index += 1) {
quarterOf(new Date('2026-08-13T12:00:00.000Z'), 0, `Junk/Zone${index}`);
}
assert.equal(
quarterOf(new Date('2027-01-01T00:30:00.000Z'), 0, 'America/New_York'),
'2026-Q4',
);
});
});
describe('event identity', () => {
it('is stable without storage, and distinguishes fields on one record', () => {
const id = calendarEventId('contract', 'c1', 'expiresAt');
assert.equal(id, 'contract:c1:expiresAt');
assert.notEqual(id, calendarEventId('contract', 'c1', 'effectiveAt'));
});
it('exposes every kind through the guard', () => {
for (const kind of CALENDAR_EVENT_KINDS) assert.ok(isCalendarEventKind(kind));
assert.equal(isCalendarEventKind('renewal'), false);
});
});