@ultimat3/action 11.3.0 → 12.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -34,7 +34,10 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
34
34
  | `deprecation.ts` | `Deprecation` + the RFC 9745/8594 render + the `deprecated_calls_total` counter |
35
35
  | `policy-gate.ts` | **the only** runtime edge to `@ultimat3/policy` (`errors.ts` takes `SurfaceDenial` as a type, which erases) |
36
36
  | `cache-gate.ts` | the post-commit bust — **the only** file that calls `invalidateTags` |
37
- | `audit.ts` | the audit seam: `AuditRecord`, `AuditSink`, the memory sink, the installed-sink store |
37
+ | `audit.ts` | the audit seam: `AuditRecord`, `AuditSink`, the installed-sink store |
38
+ | `audit-memory.ts` | the process default: a bounded ring that DROPS, and counts what it dropped |
39
+ | `audit-postgres.ts` | the DURABLE sink — one append-only `x_audit` table, one insert per record |
40
+ | `audit-input.ts` | what may be written DOWN: an `input` redacted through core's table and made JSON-representable on every path |
38
41
  | `audit-gate.ts` | **the only** file that calls a sink, and where the two failure policies live |
39
42
  | `type-pins.ts` | compile-time assertions `tsc` checks — what the erased view projects, and why `client()` is not part of it |
40
43
  | `naming.ts`, `validate.ts`, `json-schema.ts`, `stable.ts` | pure helpers. `stable.ts` is the DOCUMENT serializer plus a re-export of core's `isJsonObject` — the hash form is `@ultimat3/core`'s `canonicalJson`/`fingerprint` |
@@ -339,10 +342,19 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
339
342
  around its own handler could ever see one. What reaches the sink is what `invoke` already holds
340
343
  (`at` from `ctx.now()`, the name, the mutator brand, the surface, the whole `ctx`, the parsed
341
344
  input, the namespaced idempotency key, `replayed`, the outcome, the failure code). What does
342
- **not** ship, ever: an audit entity, a schema, a retention policy, a storage backend, a hash
343
- chain, a subject index, or an opinion on what "who" means under impersonation — four apps model
344
- those four ways, so by axiom 8's own test they are business convention and shipping one makes
345
- three of them wrong. `result` is absent for the same reason and one more: a handler's return is
345
+ **not** ship, ever: an audit entity, a retention policy, a hash chain, a subject index, or an
346
+ opinion on what "who" means under impersonation — four apps model those four ways, so by axiom
347
+ 8's own test they are business convention and shipping one makes three of them wrong.
348
+ **"a storage backend" was on that list until 2026-08-24 and is off it**, because the list was
349
+ answering a different question than it appeared to. What four apps model four ways is the ROW —
350
+ which of their own facts it carries, how long they keep it, whether it chains. Where the record
351
+ the FRAMEWORK already defines is put is not one of those: `x_audit`'s columns are the fields of
352
+ `AuditRecord` and nothing else, which is the same relationship `idempotency-postgres.ts` has to
353
+ `IdempotencyRecord` and `@ultimat3/http`'s `postgresRateLimitStore` to its `Bucket`. Leaving it
354
+ off meant the only sink that shipped was a ring that drops, so the shortest edit clearing
355
+ `X_AUDIT_SINK_MISSING` was `setAuditSink(memoryAuditSink())` — compliant in dev, silently
356
+ amnesiac in production, which `docs/idea/20-large-app-readiness.md` scores as **Ship**. An app
357
+ that wants columns of its own still writes its own sink; the seam is one method. `result` is absent for the same reason and one more: a handler's return is
346
358
  reachable from the handler itself, so shipping it would be this package deciding a row carries
347
359
  an after-image, which is `@ultimat3/admin`'s `diff` convention arriving one tier down.
348
360
  - **The audit vocabulary is `@ultimat3/admin`'s, shared by name and not by import.** `AuditOutcome`
@@ -353,6 +365,47 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
353
365
  Unifying them means lifting the vocabulary into `@ultimat3/core` — the only tier both reach —
354
366
  and rebuilding `admin/audit.ts` on this seam. Not done here: `admin` is a shipped public API
355
367
  and its `AuditEntry` is a different shape.
368
+ - **The memory sink DROPS, and both halves of that sentence are enforced.** It was a plain array
369
+ with a `push` — the one memory implementation in the framework with no cap, beside five that
370
+ have one (`memoryRateLimitStore`, `MemoryIdempotencyStore`, `createLimiter`,
371
+ `createTotpReplayGuard`, `createMemoryEventBus`) — and a record pins a whole `Ctx`, so at 50
372
+ audited writes a second it is 4.3M immortal records a day and the pod OOMs holding the trail it
373
+ was retaining. It is now a ring at `DEFAULT_MAX_AUDIT_RECORDS`, evicting the OLDEST (the
374
+ direction `createMemoryEventBus` evicts in: refusing new writes would answer "nothing has
375
+ happened since" for a process that has been serving all day). `dropped` is what makes "it drops"
376
+ checkable in a running process instead of a sentence in a header — a non-zero count on a real
377
+ deployment is the sink saying it is the wrong one. A `maxRecords` of `0`, negative or `NaN`
378
+ falls back to the default: there is no spelling of "no bound", because that spelling was the bug.
379
+ - **What a DURABLE sink may write down is decided in `audit-input.ts`, and it is two rules.**
380
+ A record's `input` is the PARSED input, which is exactly where a password, a bearer token or a
381
+ card number lives, so `postgresAuditSink` redacts it through `@ultimat3/core`'s `isRedactedKey`
382
+ — the SAME table `defineEnv({ secret: true })` extends, never a copy of the list, because a copy
383
+ is how a value that is `[redacted]` in a log line becomes plaintext in a table. `isSecret`
384
+ redacts by VALUE beside it, for a credential travelling under a harmless name. The second rule
385
+ is that the answer is always JSON-representable: a `bigint`, a `NaN`, a function and a cycle all
386
+ become a NAMED marker rather than a throw, because `auditSettled` turns a sink throw into a
387
+ failed invocation for a handler that has already committed — and because `JSON.stringify` over a
388
+ cycle takes ~4.6s in Bun 1.4 before it raises, so leaving the detection to the serializer stalls
389
+ the audited path either way. `toJSON` is never called: it is app code in the frame that owes the
390
+ caller a record.
391
+ - **The `Ctx` is never walked, and never will be.** `createContext` spreads every installed
392
+ service ONTO the context object and an HTTP surface's value is a `RequestContext` carrying the
393
+ request's own `Authorization` and `Cookie`, so a projection that iterated it would write an
394
+ app's database clients and its caller's credentials into an audit table. `postgresAuditSink`
395
+ reads an allow-list of framework-owned fields (`requestId`, `traceId`, `locale`, `tz`,
396
+ `buildId`, `role`, and the actor's `id`/`kind`/`orgId`/`onBehalfOf`) and nothing else.
397
+ `failure.error` is not among them — the row keeps `failure.code`, because a throwable's stack is
398
+ worth reading and is not worth storing, and rendering one into a column is the trap
399
+ `renderThrowable` exists for.
400
+ - **`x_audit` ships no purge, and it is the only framework table that does not.**
401
+ `x_idempotency` and `x_rate_limit` both ship one because a stale row there is meaningless; a
402
+ stale audit row IS the record, and "how long" is a legal answer that is seven years for one app
403
+ and thirty days for the next. Shipping a `delete` would be shipping one of those answers.
404
+ - **`SQL_AUDIT_INSERT` is positional, so its parameter order is pinned by a test and not by a
405
+ type.** `audit-parity.test.ts` names every column once and compares both sinks' answer for every
406
+ string field with a DIFFERENT value per field — two columns holding the same word cannot catch a
407
+ slip, and a `locale` in the `tz` slot type-checks perfectly. Proven by mutation: the first draft
408
+ of that test did NOT catch a swapped `locale`/`tz` and was widened until it did.
356
409
  - **A sink may not silently swallow, and the two failure policies are deliberate opposites.**
357
410
  `X_AUDIT_SINK_MISSING` is raised *before* the input parse, so an audited action nothing can
358
411
  record refuses with no committed write behind it — there is deliberately no logger-backed
package/README.md CHANGED
@@ -249,7 +249,10 @@ name-sorted, and reads no clock, env or random source — same registry ⇒ same
249
249
  ## `rateLimit:` is the enforced limit
250
250
 
251
251
  ```ts
252
- rateLimit: { limit: 5, windowMs: 600_000 }, // 5 held, one back every two minutes
252
+ import type { ActionRateLimit } from '@ultimat3/action';
253
+
254
+ // The `rateLimit:` key of an `action()`: 5 held, one back every two minutes.
255
+ const rateLimit: ActionRateLimit = { limit: 5, windowMs: 600_000 };
253
256
  ```
254
257
 
255
258
  One declaration, three places it lands: the bucket the limiter runs on (named after the action,
@@ -298,6 +301,9 @@ import {
298
301
  postgresIdempotencyStore,
299
302
  setIdempotencyStore,
300
303
  } from '@ultimat3/action';
304
+ import { db } from '@ultimat3/db';
305
+
306
+ const client = db();
301
307
 
302
308
  // NOT `executor: Bun.sql` — `Bun.sql.query` is `undefined` `As of 2026-08` (it is a tagged
303
309
  // template whose positional form is `unsafe`), so that line compiles and throws on the first
@@ -348,7 +354,14 @@ A `query` has none and never will: a read has nothing to be idempotent about.
348
354
  ## `deprecated:` — a compat window, not a version
349
355
 
350
356
  ```ts
351
- deprecated: { since: '2026-08-01T00:00:00Z', sunset: '2026-12-31T23:59:59Z', replacedBy: 'searchOrders' },
357
+ import type { Deprecation } from '@ultimat3/action';
358
+
359
+ // The `deprecated:` key of an `action()`.
360
+ const deprecated: Deprecation = {
361
+ since: '2026-08-01T00:00:00Z',
362
+ sunset: '2026-12-31T23:59:59Z',
363
+ replacedBy: 'searchOrders',
364
+ };
352
365
  ```
353
366
 
354
367
  Four things at once: `Deprecation: @1754006400` (RFC 9745) and `Sunset: Wed, 31 Dec 2026 …`
@@ -391,9 +404,53 @@ What the framework supplies is what it genuinely knows:
391
404
  | `outcome` | `allowed` \| `denied` \| `failed` |
392
405
  | `failure` | the `X_*` code and the thrown value, on every outcome but `allowed` |
393
406
 
394
- What it does **not** supply: an audit entity, a schema, a retention policy, a storage backend, a
395
- hash chain, a subject index, or an opinion on what "who" means under impersonation. Four apps
396
- model those four ways; shipping one would make three of them wrong.
407
+ What it does **not** supply: an audit entity, a retention policy, a hash chain, a subject index,
408
+ or an opinion on what "who" means under impersonation. Four apps model those four ways; shipping
409
+ one would make three of them wrong.
410
+
411
+ ### Two sinks ship, and only one of them keeps anything
412
+
413
+ | Sink | Keeps | Use it for |
414
+ |---|---|---|
415
+ | `memoryAuditSink({ maxRecords })` | the newest `DEFAULT_MAX_AUDIT_RECORDS` (1,000) records, verbatim. **It DROPS** — `dropped` counts what it discarded | `x dev`, tests |
416
+ | `postgresAuditSink({ executor })` | one append-only `x_audit` row per attempt. Drops nothing | anything that has to keep its trail |
417
+
418
+ The memory sink is bounded because a record pins a whole `Ctx`: at 50 audited writes a second an
419
+ unbounded array is 4.3M immortal records a day and the pod dies holding the trail it was
420
+ retaining. The trap it names out loud is that the shortest edit clearing `X_AUDIT_SINK_MISSING`
421
+ is `setAuditSink(memoryAuditSink())`, and nothing at that call site says the result is amnesiac.
422
+
423
+ ```ts
424
+ // apps/web/server.ts — the app owns the connection, so the app installs the sink
425
+ import { postgresAuditSink, setAuditSink } from '@ultimat3/action';
426
+ import { db } from '@ultimat3/db';
427
+
428
+ const client = db();
429
+ setAuditSink(
430
+ postgresAuditSink({ executor: { query: (text, values) => client.query({ text, values }) } }),
431
+ );
432
+ ```
433
+
434
+ **The table is applied by the boot; the sink is not.** `startQueue` runs `SQL_AUDIT_TABLE` on
435
+ every start — `x dev`, the container's `web`/`worker`, and the release-phase `ROLE=migrate` — the
436
+ way `SQL_IDEMPOTENCY_TABLE` is applied, because a package holding no database dependency cannot
437
+ apply its own schema. Installing a sink stays your one line, deliberately: there is no default, so
438
+ `audit: true` with none installed keeps refusing with `X_AUDIT_SINK_MISSING` instead of recording
439
+ into a ring. `executor` is a client that already speaks `(text, values)` — never `Bun.sql`, whose
440
+ `.query` is `undefined`.
441
+
442
+ `x_audit` carries the framework's own facts as columns — the action, the surface, the outcome,
443
+ the actor, the correlation ids, the idempotency key — and the parsed `input` as `jsonb`, redacted
444
+ through **core's own** `isRedactedKey` table, the one `defineEnv({ secret: true })` extends. So a
445
+ value that renders `[redacted]` in a log line cannot be plaintext in the audit table, and a
446
+ boxed `Secret` is redacted by value wherever its key sits. What never reaches a column: the `Ctx`
447
+ itself (`createContext` spreads every installed service onto it, and an HTTP surface's is a
448
+ `RequestContext` carrying the caller's `Authorization` and `Cookie`), and the thrown value behind
449
+ a failure — the row keeps `failure.code`, never the throwable.
450
+
451
+ The table has **no purge**, deliberately, and it is the one framework table that does not: a
452
+ stale idempotency row is meaningless while a stale audit row *is* the record, and "how long" is a
453
+ legal answer that differs per app. Pruning or partitioning `x_audit` is yours.
397
454
 
398
455
  A denial is recorded because `invoke` wraps the whole path — `guard` throws **before** `handle`,
399
456
  so nothing you could write around your own handler would ever see one. That is the reason this
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/action",
3
- "version": "11.3.0",
3
+ "version": "12.0.0",
4
4
  "description": "The action primitive: one declaration projected to route, OpenAPI, client, MCP tool, job handle, tests",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,10 +34,10 @@
34
34
  "test": "bun test"
35
35
  },
36
36
  "dependencies": {
37
- "@ultimat3/cache": "11.3.0",
38
- "@ultimat3/core": "11.3.0",
39
- "@ultimat3/http": "11.3.0",
40
- "@ultimat3/policy": "11.3.0",
41
- "@ultimat3/schema": "11.3.0"
37
+ "@ultimat3/cache": "12.0.0",
38
+ "@ultimat3/core": "12.0.0",
39
+ "@ultimat3/http": "12.0.0",
40
+ "@ultimat3/policy": "12.0.0",
41
+ "@ultimat3/schema": "12.0.0"
42
42
  }
43
43
  }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * One job: turn an `AuditRecord.input` into something a durable sink may write down — redacted
3
+ * through core's own table, and representable as JSON on every path. Both halves are safety, not
4
+ * formatting: a stored credential is a leak, and a sink that throws on the caller's input fails an
5
+ * invocation whose handler has already committed.
6
+ */
7
+
8
+ import { isRedactedKey, isSecret, REDACTED } from '@ultimat3/core';
9
+
10
+ /**
11
+ * What a value this cannot represent becomes. A NAME and never `null`: `JSON.stringify` writes
12
+ * `null` for `NaN` and `±Infinity` and drops a function entirely, so an auditor reading the row
13
+ * could not tell "the field was absent" from "the field held something unwritable".
14
+ */
15
+ export const UNREPRESENTABLE = '[unrepresentable]';
16
+
17
+ /**
18
+ * How deep the walk goes. The input is schema-parsed, so its shape is the app's declaration — but
19
+ * `t.record` and a recursive schema have no depth of their own, and an overflow HERE lands in the
20
+ * sink, after the handler committed. Anything past this is `UNREPRESENTABLE`, which is the honest
21
+ * answer: it was there and this row does not carry it.
22
+ */
23
+ export const AUDIT_INPUT_MAX_DEPTH = 12;
24
+
25
+ /**
26
+ * `undefined` in, `undefined` out — an input that never parsed is a row with no input, not a row
27
+ * whose input was null.
28
+ *
29
+ * A cycle is CUT rather than raised on, and that is a cost decision as much as a correctness one:
30
+ * `JSON.stringify` over a self-referential value takes ~4.6s in Bun 1.4 before it throws, so
31
+ * leaving the detection to the serializer stalls the audited path whether or not the throw is
32
+ * caught. The ancestor set is the path, not everything seen — a value appearing twice as siblings
33
+ * is repetition and is written twice, exactly as `JSON.stringify` writes it.
34
+ *
35
+ * **`toJSON` is never called.** It is app code inside the frame that owes the caller a record, and
36
+ * one that throws is the second failure `jsonResult` already names; a `Map`, a `Set` and a `URL`
37
+ * therefore walk as their own enumerable keys, which is what `JSON.stringify` makes of them too.
38
+ */
39
+ export function auditableInput(value: unknown): unknown {
40
+ if (value === undefined) return undefined;
41
+ return walk(value, 0, new Set());
42
+ }
43
+
44
+ function walk(value: unknown, depth: number, ancestors: Set<object>): unknown {
45
+ if (value === null) return null;
46
+ const kind = typeof value;
47
+ if (kind === 'string' || kind === 'boolean') return value;
48
+ if (kind === 'number') return Number.isFinite(value) ? value : UNREPRESENTABLE;
49
+ // `bigint`, `function`, `symbol`, and `undefined` reached through an array hole.
50
+ if (kind !== 'object') return UNREPRESENTABLE;
51
+
52
+ const object = value as object;
53
+ if (isSecret(object)) return REDACTED;
54
+ if (object instanceof Date) {
55
+ return Number.isNaN(object.getTime()) ? UNREPRESENTABLE : object.toISOString();
56
+ }
57
+ if (depth >= AUDIT_INPUT_MAX_DEPTH || ancestors.has(object)) return UNREPRESENTABLE;
58
+
59
+ ancestors.add(object);
60
+ try {
61
+ if (Array.isArray(object)) {
62
+ return object.map((item) => walk(item, depth + 1, ancestors));
63
+ }
64
+ const out: Record<string, unknown> = {};
65
+ // `Object.entries`, so only OWN enumerable keys are read: a prototype member is not this
66
+ // record's data, and reading one would put `Object.prototype`'s members in every audit row.
67
+ for (const [key, item] of Object.entries(object)) {
68
+ // The key decides before the value does, so a credential under a redacted name is never
69
+ // walked at all — `isRedactedKey` is core's, the same table `defineEnv({ secret: true })`
70
+ // extends, so a value that is `[redacted]` in a log line cannot be plaintext in a table.
71
+ if (isRedactedKey(key)) out[key] = REDACTED;
72
+ else if (item !== undefined) out[key] = walk(item, depth + 1, ancestors);
73
+ }
74
+ return out;
75
+ } finally {
76
+ ancestors.delete(object);
77
+ }
78
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The audit seam's process-memory sink: a bounded ring that DROPS, for tests and `x dev`. Split
3
+ * from `audit.ts` on the seam `idempotency.ts` / `idempotency-memory.ts` already draw, so the file
4
+ * declaring what a record IS is not also the file deciding how many are kept.
5
+ */
6
+
7
+ import type { AuditRecord, AuditSink } from './audit';
8
+
9
+ /**
10
+ * Records held at once. A record pins a whole `Ctx` — the actor, the service bag, the parsed
11
+ * input — so its cost is the request's, not a row's: at 50 audited writes a second an unbounded
12
+ * array is 4.3M immortal records a day and the pod dies holding the trail it was retaining. This
13
+ * sink was the one memory implementation in the framework with no cap, beside five that have one
14
+ * (`memoryRateLimitStore`, `MemoryIdempotencyStore`, `createLimiter`, `createTotpReplayGuard`,
15
+ * `createMemoryEventBus`).
16
+ */
17
+ export const DEFAULT_MAX_AUDIT_RECORDS = 1_000;
18
+
19
+ export interface MemoryAuditSinkOptions {
20
+ /** Records held at once. Absent, zero, negative or NaN all read as the default — never "no cap". */
21
+ readonly maxRecords?: number | undefined;
22
+ }
23
+
24
+ /**
25
+ * The seam's memory implementation, for tests and `x dev`. **Not a system of record, and not
26
+ * merely because it is not durable: it DISCARDS.** Past `maxRecords` the oldest record is dropped
27
+ * on every write, so an audited action can run, succeed, be recorded, and leave nothing behind —
28
+ * which is exactly what an audit trail must never do. The trap this shape exists to make visible
29
+ * is that the shortest edit clearing `X_AUDIT_SINK_MISSING` is `setAuditSink(memoryAuditSink())`,
30
+ * and nothing about the call site says the result is amnesiac. A deployment that must keep its
31
+ * trail installs `postgresAuditSink({ executor })`, which drops nothing.
32
+ *
33
+ * `dropped` is what makes that statement checkable in a running process rather than a sentence
34
+ * here: a non-zero count on a real deployment is the sink saying it is the wrong one.
35
+ */
36
+ export interface MemoryAuditSink extends AuditSink {
37
+ /** The retained window, oldest first. A copy — the log cannot be mutated through it. */
38
+ records(): readonly AuditRecord[];
39
+ /** Retained right now — the bound, observable. */
40
+ readonly size: number;
41
+ /** Records this sink has DISCARDED since the last `clear()`. Never a number to ignore. */
42
+ readonly dropped: number;
43
+ clear(): void;
44
+ }
45
+
46
+ /**
47
+ * The OLDEST goes, which is the same direction `createMemoryEventBus` evicts in and the opposite
48
+ * of refusing new writes: a sink that stopped recording at the cap would answer "nothing has
49
+ * happened since" for a process that has been serving all day, and the most recent attempts are
50
+ * the ones anyone reading `x dev` is looking at.
51
+ */
52
+ export function memoryAuditSink(options: MemoryAuditSinkOptions = {}): MemoryAuditSink {
53
+ const declared = options.maxRecords;
54
+ const maxRecords =
55
+ typeof declared === 'number' && Number.isFinite(declared) && declared >= 1
56
+ ? Math.floor(declared)
57
+ : DEFAULT_MAX_AUDIT_RECORDS;
58
+ const log: AuditRecord[] = [];
59
+ let dropped = 0;
60
+
61
+ return {
62
+ write(record: AuditRecord): void {
63
+ log.push(record);
64
+ // `shift` in a loop, not a slice: the cap is only ever exceeded by one per write, so this
65
+ // runs at most once — and it releases the evicted record's `Ctx` rather than copying the
66
+ // array, which would hold both windows alive for the length of the copy.
67
+ while (log.length > maxRecords) {
68
+ log.shift();
69
+ dropped += 1;
70
+ }
71
+ },
72
+ records: (): readonly AuditRecord[] => [...log],
73
+ get size(): number {
74
+ return log.length;
75
+ },
76
+ get dropped(): number {
77
+ return dropped;
78
+ },
79
+ clear: (): void => {
80
+ log.length = 0;
81
+ dropped = 0;
82
+ },
83
+ };
84
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * The durable audit sink: one append-only Postgres table, one insert per record. Without it the
3
+ * shortest edit that clears `X_AUDIT_SINK_MISSING` is `setAuditSink(memoryAuditSink())`, which
4
+ * reads durable at the call site and is a ring that DROPS — compliant in dev, amnesiac in
5
+ * production. Statements are spelled out so an agent can run the exact one it saw in a log.
6
+ */
7
+
8
+ import type { Actor } from '@ultimat3/core';
9
+ import { uuid } from '@ultimat3/core';
10
+ import type { AuditRecord, AuditSink } from './audit';
11
+ import { auditableInput } from './audit-input';
12
+ import type { PgExecutor } from './idempotency-postgres';
13
+
14
+ /**
15
+ * Applied by the boot, never by an app migration — the rule `SQL_IDEMPOTENCY_TABLE` follows, and
16
+ * for the same reason: this package holds no database dependency and cannot apply its own schema.
17
+ * `create table if not exists` is a no-op against a database that already has it, so a new column
18
+ * is added by `alter table … add column if not exists` and never by editing the `create`.
19
+ *
20
+ * **Two indexes and no third.** "Who did what, when" is the only question an audit table is opened
21
+ * for, and an index is a write cost paid on the audited path. A SUBJECT index — which row the
22
+ * action was about — is deliberately absent: the framework does not know a record's subject, and
23
+ * guessing one is the audit ENTITY this seam refuses to ship.
24
+ *
25
+ * **`at` is the one nullable column that looks like it should not be.** It is `ctx.now()`, and a
26
+ * `Clock` is injectable, so an app can hand this seam an Invalid Date — whose `toISOString()`
27
+ * THROWS. A sink that raises fails an invocation whose handler has already committed, so an
28
+ * unrepresentable instant is written as "this process could not say when" and `recorded_at`, the
29
+ * database's own clock, still stamps the row. `not null` would have traded a lost row for a lost
30
+ * write.
31
+ *
32
+ * **No retention, no purge, and that is the difference from every other table this framework
33
+ * owns.** `x_idempotency` and `x_rate_limit` both ship a purge because a stale row there is
34
+ * meaningless; a stale audit row is the record. How long a trail is kept is a legal question with
35
+ * a different answer per app — seven years for one, thirty days for the next — so shipping a
36
+ * `delete` would be shipping one of those answers. The table grows until the app prunes or
37
+ * partitions it, and that is stated rather than solved.
38
+ */
39
+ export const SQL_AUDIT_TABLE = `
40
+ create table if not exists x_audit (
41
+ id uuid primary key,
42
+ at timestamptz,
43
+ action text not null,
44
+ mutator boolean not null,
45
+ surface text not null,
46
+ outcome text not null,
47
+ replayed boolean not null,
48
+ idempotency_key text,
49
+ failure_code text,
50
+ actor_id text not null,
51
+ actor_kind text not null,
52
+ org_id text,
53
+ on_behalf_of_id text,
54
+ on_behalf_of_kind text,
55
+ request_id text not null,
56
+ trace_id text not null,
57
+ locale text not null,
58
+ tz text not null,
59
+ build_id text not null,
60
+ role text not null,
61
+ input jsonb,
62
+ recorded_at timestamptz not null default now()
63
+ );
64
+
65
+ create index if not exists x_audit_at_idx on x_audit (at desc);
66
+
67
+ create index if not exists x_audit_actor_at_idx on x_audit (actor_id, at desc);
68
+ `;
69
+
70
+ /**
71
+ * `id` is generated per ROW and is not an idempotency key: two identical attempts are two events
72
+ * and an audit trail that collapsed them would be lying about how many times something was tried.
73
+ * There is deliberately no `on conflict` — an append-only table has nothing to reconcile.
74
+ *
75
+ * `recorded_at` defaults to `now()` and is not a parameter: `at` is the caller's clock
76
+ * (`ctx.now()`) and this is the database's, so the gap between them is the audit lag, and a
77
+ * process whose clock has drifted is visible instead of invisible.
78
+ */
79
+ export const SQL_AUDIT_INSERT = `
80
+ insert into x_audit (
81
+ id, at, action, mutator, surface, outcome, replayed, idempotency_key, failure_code,
82
+ actor_id, actor_kind, org_id, on_behalf_of_id, on_behalf_of_kind,
83
+ request_id, trace_id, locale, tz, build_id, role, input
84
+ ) values (
85
+ $1::uuid, $2::timestamptz, $3, $4, $5, $6, $7, $8, $9,
86
+ $10, $11, $12, $13, $14,
87
+ $15, $16, $17, $18, $19, $20, $21::jsonb
88
+ )
89
+ `;
90
+
91
+ export interface PostgresAuditSinkOptions {
92
+ readonly executor: PgExecutor;
93
+ }
94
+
95
+ export interface PostgresAuditSink extends AuditSink {
96
+ write(record: AuditRecord): Promise<void>;
97
+ }
98
+
99
+ /**
100
+ * **Install it at boot, beside the store that declares the rest of this app's durability.** The
101
+ * app owes one line in `apps/web/server.ts`, over the client this process already opened:
102
+ *
103
+ * ```ts
104
+ * const client = db();
105
+ * setAuditSink(
106
+ * postgresAuditSink({
107
+ * executor: { query: (text, values) => client.query({ text, values }) },
108
+ * }),
109
+ * );
110
+ * ```
111
+ *
112
+ * `Bun.sql` does not satisfy `PgExecutor` — `Bun.sql.query` is `undefined`; see that interface.
113
+ *
114
+ * What is written is an ALLOW-LIST of the facts the framework itself owns, never the `Ctx`. That
115
+ * is not tidiness: `createContext` spreads every installed service onto the context object, and on
116
+ * an HTTP surface the value is a `RequestContext` carrying the request's own `Authorization` and
117
+ * `Cookie` headers — so a projection that walked it would write an app's database clients and its
118
+ * caller's credentials into an audit table. An app that wants more columns writes its own
119
+ * `AuditSink`; the seam is one method, and that is the extension point.
120
+ */
121
+ export function postgresAuditSink(options: PostgresAuditSinkOptions): PostgresAuditSink {
122
+ const exec = options.executor;
123
+ return {
124
+ async write(record: AuditRecord): Promise<void> {
125
+ const actor = record.ctx.actor;
126
+ const at = record.at instanceof Date && !Number.isNaN(record.at.getTime()) ? record.at : null;
127
+ const onBehalfOf = onBehalfOfOf(actor);
128
+ const input = auditableInput(record.input);
129
+ await exec.query(SQL_AUDIT_INSERT, [
130
+ uuid(),
131
+ at === null ? null : at.toISOString(),
132
+ record.action,
133
+ record.mutator,
134
+ record.surface,
135
+ record.outcome,
136
+ record.replayed,
137
+ record.idempotencyKey,
138
+ record.failure?.code ?? null,
139
+ actor.id,
140
+ actor.kind,
141
+ actor.orgId ?? null,
142
+ onBehalfOf?.actorId ?? null,
143
+ onBehalfOf?.actorKind ?? null,
144
+ record.ctx.requestId,
145
+ record.ctx.traceId,
146
+ record.ctx.locale,
147
+ record.ctx.tz,
148
+ record.ctx.buildId,
149
+ record.ctx.role,
150
+ // `undefined` in means a parse that never produced an input, which is a NULL column and
151
+ // not the four characters `JSON.stringify(undefined)` does not produce either.
152
+ input === undefined ? null : JSON.stringify(input),
153
+ ]);
154
+ },
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Both halves of an impersonation, recorded and interpreted as NEITHER. `actor_id` is who the
160
+ * framework ran the attempt as and `on_behalf_of_*` is what `impersonate()` recorded; which of
161
+ * them an app calls "who did this" is the convention four apps model four ways, so the row carries
162
+ * the two facts and takes no position between them.
163
+ *
164
+ * Read through a guard rather than off the type: `Actor.onBehalfOf` is optional, and an actor
165
+ * minted by an app's own `resolveToken` is a plain object nothing in this package validated.
166
+ */
167
+ function onBehalfOfOf(actor: Actor): { actorId: string; actorKind: string } | null {
168
+ const origin: unknown = actor.onBehalfOf;
169
+ if (typeof origin !== 'object' || origin === null) return null;
170
+ const record = origin as Record<string, unknown>;
171
+ const actorId = record['actorId'];
172
+ const actorKind = record['actorKind'];
173
+ if (typeof actorId !== 'string' || typeof actorKind !== 'string') return null;
174
+ return { actorId, actorKind };
175
+ }
package/src/audit.ts CHANGED
@@ -53,6 +53,12 @@ export interface AuditRecord {
53
53
  * a sink needs to write a row at all. Carried whole rather than projected into `actorId` +
54
54
  * `requestId` fields, because choosing WHICH context facts an audit row keeps is precisely the
55
55
  * convention four apps modelled four ways.
56
+ *
57
+ * **A sink that PERSISTS must project it, and `audit-postgres.ts` is where that is done.**
58
+ * `createContext` spreads every installed service onto this object and an HTTP surface's value
59
+ * is a `RequestContext` carrying the caller's `Authorization` and `Cookie`, so writing it down
60
+ * whole puts an app's database clients and its caller's credentials in a table. Whole here,
61
+ * allow-listed there — the seam hands over everything and each sink decides what it keeps.
56
62
  */
57
63
  readonly ctx: Ctx;
58
64
  /**
@@ -82,26 +88,6 @@ export interface AuditSink {
82
88
  write(record: AuditRecord): Promise<void> | void;
83
89
  }
84
90
 
85
- /** The seam's memory implementation, for tests and `x dev`. Not a system of record. */
86
- export interface MemoryAuditSink extends AuditSink {
87
- /** In the order `invoke` produced them. A copy — the log cannot be mutated through it. */
88
- records(): readonly AuditRecord[];
89
- clear(): void;
90
- }
91
-
92
- export function memoryAuditSink(): MemoryAuditSink {
93
- const log: AuditRecord[] = [];
94
- return {
95
- write(record: AuditRecord): void {
96
- log.push(record);
97
- },
98
- records: (): readonly AuditRecord[] => [...log],
99
- clear: (): void => {
100
- log.length = 0;
101
- },
102
- };
103
- }
104
-
105
91
  /**
106
92
  * No default. A logger-backed default would satisfy `audit: true` with a line nobody stores,
107
93
  * which is the silent pass this seam exists to remove: an audited action with no sink installed
package/src/index.ts CHANGED
@@ -67,9 +67,18 @@ export type {
67
67
  AuditOutcome,
68
68
  AuditRecord,
69
69
  AuditSink,
70
- MemoryAuditSink,
71
70
  } from './audit';
72
- export { getAuditSink, memoryAuditSink, resetAuditSink, setAuditSink } from './audit';
71
+ export { getAuditSink, resetAuditSink, setAuditSink } from './audit';
72
+ export { AUDIT_INPUT_MAX_DEPTH, auditableInput, UNREPRESENTABLE } from './audit-input';
73
+ export type { MemoryAuditSink, MemoryAuditSinkOptions } from './audit-memory';
74
+ export { DEFAULT_MAX_AUDIT_RECORDS, memoryAuditSink } from './audit-memory';
75
+ /**
76
+ * The DURABLE sink, and the only one an app that must keep its trail may install. The statements
77
+ * are exported beside it because the table is applied the way `SQL_IDEMPOTENCY_TABLE` is — by the
78
+ * boot, never by an app migration.
79
+ */
80
+ export type { PostgresAuditSink, PostgresAuditSinkOptions } from './audit-postgres';
81
+ export { postgresAuditSink, SQL_AUDIT_INSERT, SQL_AUDIT_TABLE } from './audit-postgres';
73
82
  export type {
74
83
  ActionLike,
75
84
  ActionMap,