Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
281 changes: 185 additions & 96 deletions docs/compliance/erasure-heuristic-limits.md

Large diffs are not rendered by default.

142 changes: 138 additions & 4 deletions scripts/check-erasure-manifest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,19 @@
* column appears anywhere in the Drizzle schema without either a covering
* manifest rule or an explicit ERASURE_OUT_OF_SCOPE entry.
*
* ⚠️ READ `docs/compliance/erasure-heuristic-limits.md` BEFORE TRUSTING A GREEN
* RUN. "PII column" here means a column matching PII_HEURISTIC below, and that
* pattern is a list of shapes someone thought of — every column it was not told
* about is invisible to this gate and reads as correct. The document names what
* is structurally out of reach (free prose, addresses, anything whose
* sensitivity is contextual rather than lexical) and carries a worked example
* that is currently open. A limits document nobody finds next to the gate is
* exactly the failure mode it describes, which is why this pointer is here.
*
* This guard is COMPLEMENTARY to:
* - tests/unit/erasure-manifest-coverage.spec.ts (manifest <-> orchestrator
* binding drift) — that proves every rule is realized by the executor.
* - tests/unit/privacy/erasure-manifest-coverage.spec.ts (manifest <->
* orchestrator binding drift) — that proves every rule is realized by the
* executor.
* - This lint proves every rule is well-formed AND that NO schema table
* grows an un-cataloged PII column unnoticed.
*
Expand Down Expand Up @@ -39,22 +49,74 @@
* The heuristic deliberately includes `recipient` (automation_logs.recipient
* holds emails and E.164 numbers — renamed from recipient_email, which is how
* it escaped the original pattern) and bare `ip`.
*
* `address` was added only AFTER the address family was ruled on, and the order
* was the point. Widening the pattern first would have turned the gate red on
* twelve columns at once and made twelve out-of-scope entries the cheapest way
* back to green — converting an open question into a recorded decision nobody
* would revisit. `docs/compliance/erasure-heuristic-limits.md` says the same
* thing at more length, and names what this gate still cannot see: read it
* before treating a green run as coverage. Whatever the next widening is, rule
* on the columns first, then widen.
*/
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";

const ROOT = new URL("..", import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1");
// The manifest and its out-of-scope register are two files and one document:
// a column is covered by a rule in the first or excused by an entry in the
// second, and neither array means anything without the other. They are read as
// one concatenated source so `arrayBody` finds whichever it is asked for, and
// so splitting the register out for line-count reasons could not quietly halve
// what this gate sees. A missing file throws here rather than parsing as empty.
const MANIFEST = join(ROOT, "server", "lib", "compliance", "erasure-manifest.ts");
const OUT_OF_SCOPE = join(ROOT, "server", "lib", "compliance", "erasure-out-of-scope.ts");
const SCHEMA_DIR = join(ROOT, "server", "lib", "db", "schema");

const VALID_ACTIONS = new Set(["delete", "null", "hash", "retain", "anonymize"]);
const REQUIRES_BASIS = new Set(["anonymize", "retain"]);
const PII_HEURISTIC = /(email|phone|ip_address|user_agent|signature|client_name|full_name|recipient)/;
const VALID_ENFORCEMENT = new Set(["enforced", "pending"]);

/**
* The ONLY rules allowed to say `enforcementStatus: 'pending'`.
*
* A `retain` rule that promises a bounded `retention` and has nothing to expire
* it is not a bounded retain — it is a permanent one that reads as temporary,
* which is the blanket exclusion this manifest exists to avoid. Existing
* remediation is allowed to be in flight; ADDING another one is not a thing a
* developer should be able to do by typing a keyword. Landing here is a diff
* somebody has to approve.
*
* The check runs BOTH ways: a pending rule missing from this list fails, and a
* list entry with no matching pending rule also fails. The second direction is
* what stops the list decaying into a blanket permit after the rules it named
* are gone.
*
* To remove an entry: build the enforcement, flip the rule to
* `enforcementStatus: 'enforced'`, delete the line here.
*/
const PENDING_ENFORCEMENT = new Set([
// The property address family. Retained under Art. 17(3)(e) for the tenant's
// record window; `retention-sweep.ts` does not reach `inspections` yet, so
// nothing expires them. See the NOT YET ENFORCED block in the manifest for
// the two blockers and why the deadline is where it is.
"inspections.property_address",
"inspections.address_place_id",
"inspections.address_street",
"inspections.address_city",
"inspections.address_state",
"inspections.address_zip",
"inspections.address_county",
"inspections.address_lat",
"inspections.address_lng",
"inspection_requests.property_address",
]);
const PII_HEURISTIC = /(email|phone|ip_address|user_agent|signature|client_name|full_name|recipient|address)/;
const isPiiColumn = (col) => PII_HEURISTIC.test(col) || col === "ip";

const errors = [];

const src = readFileSync(MANIFEST, "utf8");
const src = `${readFileSync(MANIFEST, "utf8")}\n${readFileSync(OUT_OF_SCOPE, "utf8")}`;

/** Extract the body of a top-level `export const NAME = [ ... ];` array. */
function arrayBody(text, name) {
Expand Down Expand Up @@ -141,6 +203,78 @@ rules.forEach((rule, i) => {
}
});

// ── Enforcement of bounded retention ─────────────────────────────────────────
// A `retain` rule that names a period is a promise the data goes away when the
// period elapses. Nothing in this repo can prove a sweep actually runs, so what
// is checked instead is that somebody SAID which it is, and that "not yet" is
// bounded by a list and a date rather than by nobody looking.
const seenPending = new Set();
rules.forEach((rule, i) => {
const key = `${rule.table}.${rule.column}`;
const label = `rule #${i + 1} (${key})`;

if (rule.enforcementStatus && !VALID_ENFORCEMENT.has(rule.enforcementStatus)) {
errors.push(
`${label}: invalid enforcementStatus '${rule.enforcementStatus}' (allowed: ${[...VALID_ENFORCEMENT].join(", ")}).`,
);
return;
}

// The default is REFUSAL, not "enforced". A new bounded retain has to declare
// what expires it; that is the whole point of this block.
if (rule.action === "retain" && rule.retention && !rule.enforcementStatus) {
errors.push(
`${label}: 'retain' with retention '${rule.retention}' must declare enforcementStatus ` +
`('enforced' if a sweep expires it, 'pending' if that is not built yet). A bounded ` +
`retain nothing enforces is an unbounded retain.`,
);
}

if (rule.enforcementStatus !== "pending") return;
seenPending.add(key);

if (!PENDING_ENFORCEMENT.has(key)) {
errors.push(
`${label}: NEW unenforced retain rule. '${key}' is marked pending but is not in ` +
`PENDING_ENFORCEMENT in this script. Existing remediation may be in flight; adding ` +
`another one is a reviewed decision, so put it on that list in the same change or ` +
`build the enforcement instead.`,
);
}

if (!/^\d{4}-\d{2}-\d{2}$/.test(rule.enforcementDeadline ?? "")) {
errors.push(
`${label}: pending rules require an 'enforcementDeadline' as YYYY-MM-DD. Without a date, ` +
`'pending' becomes permanent.`,
);
return;
}
// Deadline in the past → FAIL. A deadline that cannot act is not a deadline;
// this is the same "expiry acts" principle the rule itself is about, applied
// to our own promise about it. Moving the date is allowed and visible.
const due = Date.parse(`${rule.enforcementDeadline}T23:59:59Z`);
if (Number.isNaN(due)) {
errors.push(`${label}: enforcementDeadline '${rule.enforcementDeadline}' is not a real date.`);
} else if (Date.now() > due) {
errors.push(
`${label}: enforcement deadline ${rule.enforcementDeadline} has PASSED and the retention ` +
`is still not enforced. Build it, or move the date deliberately and say why — an ` +
`expired "pending" is the unbounded retain this check exists to prevent.`,
);
}
});

// The list must not outlive the rules it names, or it quietly becomes a blanket
// permit for whatever lands on it next.
for (const key of PENDING_ENFORCEMENT) {
if (!seenPending.has(key)) {
errors.push(
`PENDING_ENFORCEMENT lists '${key}', but no manifest rule is marked pending for it. ` +
`If the enforcement shipped, delete the line; if the rule moved, update it.`,
);
}
}

// ── Out-of-scope set (table.column the manifest deliberately skips) ───────────
// Every entry MUST carry a reason — an out-of-scope declaration without one is
// indistinguishable from a shrug, and the reason is what a DSAR audit reads.
Expand Down
12 changes: 7 additions & 5 deletions scripts/check-tz-safety.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
*
* SCOPED to the calendar surface on purpose: every real bug lives here, while
* legitimate `.toISOString().slice(0,10)` uses (server UTC-today, report year,
* QBO document-creation dates) live elsewhere. QBO *payment* TxnDate is NOT on
* that list any more: it books an accounting period, so it derives from the
* ledger row's occurred_at in the tenant zone via epochMsToWallClockYmd (see
* recordPayment in server/services/qbo/invoice-sync.ts). A line opts out with a
* trailing — or immediately preceding — `// tz-lint-ok: <reason>` comment.
* QBO document-creation dates) live elsewhere. QBO *money-movement* TxnDate is
* NOT on that list any more — neither the payment nor the credit memo: both
* book an accounting period, so both derive from the ledger row's occurred_at
* in the tenant zone via epochMsToWallClockYmd (see txnDateFor in
* server/services/qbo/invoice-sync.ts, which is the one date path they share).
* A line opts out with a trailing — or immediately preceding —
* `// tz-lint-ok: <reason>` comment.
*
* Flags:
* P1 hardcoded-Z instant composed from a civil date + wall-clock time
Expand Down
28 changes: 27 additions & 1 deletion server/api/inspections/cancellation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,35 @@ const cancellationRoutes = createApiRouter()
const userId = (c.get('user') as { sub?: string } | undefined)?.sub ?? null;
const refund = await applyCancellationRefund(db, tenantId, quote, userId);

// THE SEAM. All three refund writers reach money through
// `applyCancellationRefund`, and this is the only production entry to
// it, so one push here covers every refund that exists — rather than a
// push inside each writer, where `server/services/invoice/refund.ts` has
// no QBO service, no `env` and no `executionCtx`, and where an outbound
// HTTP call would sit inside the refund's own path.
//
// `waitUntil` is what makes the non-negotiable structural: the refund
// row is already committed above, and QuickBooks being down can only
// lose the memo, never the refund. `createCreditMemo` catches and files
// a sync error besides, so the tenant is told rather than the failure
// vanishing.
//
// `invoiceId` null means a held deposit — see AppliedCancellationRefund
// for why that one is not postable, and is not silently postable either.
if (c.env.QBO_CLIENT_ID && refund?.invoiceId) {
c.executionCtx.waitUntil(
c.var.services.qbo.createCreditMemo(
tenantId, refund.invoiceId,
// DOLLARS. The QBO payload puts this straight on Line[0].Amount.
refund.row.amountCents / 100,
refund.row.id, refund.row.occurredAt,
),
);
}

return c.json({
success: true as const,
data: { outcome: flatten(quote), refundPaymentId: refund?.id ?? null },
data: { outcome: flatten(quote), refundPaymentId: refund?.row.id ?? null },
}, 200);
});

Expand Down
93 changes: 93 additions & 0 deletions server/lib/ai/capability-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* What this product OFFERS: whether a given AI capability may run at all on a
* given set of credentials.
*
* This is a different question from the two that already had answers.
* `resolve-provider.ts` decides WHICH key a call would run on; `metering.ts`
* counts what a call consumed. Neither one ever asks whether the capability is
* something the product currently ships on that key — so nothing did, and the
* answer was whatever the runtime happened to make possible.
*
* WHY THIS EXISTS EVEN THOUGH IT CHANGES NO BEHAVIOR TODAY.
* The managed path is dark right now for one reason: no deployment has
* provisioned a platform key. That is an operational fact, not a product
* decision, and it reverses the instant someone runs a single
* `wrangler secret put` — an action taken by whoever is provisioning
* infrastructure, who is not choosing what the product ships. A posture that
* holds only while a secret is missing is not a posture; it is an accident that
* has not been corrected yet. Written down here, the same answer survives the
* key being configured, and turning a capability on becomes an edit to this
* table that shows up in a diff.
*
* WHY IT IS COMPILED IN AND NOT AN ENVIRONMENT SWITCH.
* What the product offers is a release decision. An env flag would let one
* deployment quietly ship a capability that was never released anywhere else,
* and the source would no longer describe the product. Flipping any line below
* is a code change, reviewed like one.
*
* CURRENT POSTURE:
* - `assist` on the tenant's OWN key → offered. Unchanged; this is the
* only combination any caller reaches today.
* - `assist` on platform credentials → not offered. Report assistance
* runs on the tenant's own provider account, so the tenant picks the
* provider and owns that relationship directly rather than through us.
* - `translate`, on any credentials → not offered. The capability has a
* usage metric reserved for it and no released surface. A reserved slot
* must not double as an unlocked door.
*
* Pure and synchronous, like `resolveAi`: no I/O, the whole policy fits on one
* screen, and it is testable without a database.
*/
import type { AiUsageKind } from '../usage/period';
import type { AiCredentialSource } from './resolve-provider';

/** Why a capability was refused. Machine-readable so a caller can tell
* "not shipped yet" apart from "not on these credentials" without matching
* on message text. */
export type AiCapabilityDenialReason =
/** The capability itself has no released surface in this product. */
| 'capability_not_released'
/** The capability ships, but not funded by these credentials. */
| 'source_not_offered';

export type AiCapabilityDecision =
| { allowed: true }
| {
allowed: false;
reason: AiCapabilityDenialReason;
capability: AiUsageKind;
source: AiCredentialSource;
/** Phrased for the inspector who triggered the call, not for a log. */
message: string;
};

export function checkAiCapability(
capability: AiUsageKind,
source: AiCredentialSource,
): AiCapabilityDecision {
// Capability first, credentials second. Translation is refused on the
// tenant's own key too — the gate is about what the product offers, and a
// tenant supplying their own key does not release a feature.
if (capability === 'translate') {
return {
allowed: false,
reason: 'capability_not_released',
capability,
source,
message: 'AI translation is not available in this product yet.',
};
}

if (source === 'managed') {
return {
allowed: false,
reason: 'source_not_offered',
capability,
source,
message:
'AI assistance runs on your own provider key. Add one in Settings → Advanced → AI.',
};
}

return { allowed: true };
}
Loading
Loading