pgserve 2.6.1 → 2.6.5

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.
@@ -872,20 +872,24 @@ async function cmdInstall(args, ctx) {
872
872
  } catch (err) {
873
873
  if (err.code === 'EADDRINUSE') {
874
874
  process.stderr.write(`${err.message}\n`);
875
- // Belt-and-suspenders for the QA loop-2/2 finding: in QA's test
876
- // environment, the synchronous `process.exit(1)` path was
877
- // observed to NOT terminate the process before the install
878
- // function continued + resolved the wrapper's promise with
879
- // undefined, which the wrapper then mapped to `process.exit(0)`.
880
- // Three guarantees here:
881
- // 1. process.exitCode = 1 default exit code becomes 1 even
882
- // if explicit exit is somehow trapped/delayed
883
- // 2. process.exit(1) force termination (preferred path)
884
- // 3. throw err → if exit is delayed, the async
885
- // function rejects, wrapper's rejection handler does its
886
- // own process.exit(1), guaranteeing non-zero exit
875
+ // CV103-2 (v2.6.2): drop the synchronous `process.exit(1)` here.
876
+ // QA's 9-variant repro on v2.6.1 isolated a stdio-pipe race:
877
+ // when stdout is piped (e.g. `pgserve install | cat`) but
878
+ // stderr is inherited, `process.exit(1)` runs Node's shutdown
879
+ // sequence faster than the libuv stderr buffer can flush, and
880
+ // Node has been observed to terminate with exit code 0 instead
881
+ // of 1. Node's own docs flag this:
882
+ //
883
+ // process.exit() will force the process to exit as quickly as
884
+ // possible including I/O operations to process.stdout and
885
+ // process.stderr.
886
+ // (https://nodejs.org/api/process.html#processexitcode_1)
887
+ //
888
+ // Recommended pattern: set process.exitCode and let Node exit
889
+ // gracefully on its own once the event loop drains. We also
890
+ // throw so the wrapper's rejection handler can suppress its
891
+ // duplicate stderr write — see bin/pgserve-wrapper.cjs.
887
892
  process.exitCode = 1;
888
- process.exit(1);
889
893
  throw err;
890
894
  }
891
895
  throw err;
@@ -1304,6 +1308,13 @@ function dispatch(subcommand, args, ctx) {
1304
1308
  // idempotency-driven serialization (see provision.js header for
1305
1309
  // why no advisory lock). Pure node + psql shellout.
1306
1310
  return import('./commands/provision.js').then((mod) => mod.runProvision(args));
1311
+ case 'create-app':
1312
+ // autopg-distribution-cutover-finalize (v2.6) — wish Group 3.
1313
+ // Registers a consumer slug + writes admin.json/manifest.json,
1314
+ // freezing TRUSTED_IDENTITIES into autopg_meta.locked_roots at
1315
+ // create time. Idempotent: re-runs touch last_updated only and
1316
+ // preserve the original locked_roots snapshot (BRIEF v5 A6).
1317
+ return import('./commands/create-app.js').then((mod) => mod.runCreateApp(args));
1307
1318
  default:
1308
1319
  throw new Error(`pgserve: dispatch called with unknown subcommand "${subcommand}"`);
1309
1320
  }
@@ -0,0 +1,387 @@
1
+ /**
2
+ * `pgserve create-app <slug>` — autopg-distribution-cutover-finalize G3 verb.
3
+ *
4
+ * Registers a consumer app with pgserve:
5
+ * 1. Bootstraps the `public.autopg_meta` table (idempotent IF NOT EXISTS).
6
+ * 2. INSERTs a row keyed by sanitized slug, freezing the current
7
+ * `TRUSTED_IDENTITIES` snapshot into `locked_roots` JSONB at the
8
+ * moment of creation (the LOCK).
9
+ * 3. Writes the per-consumer cache pair to disk:
10
+ * ~/.autopg/<slug>/admin.json (mode 0600)
11
+ * ~/.autopg/<slug>/manifest.json (mode 0600)
12
+ * under a 0700 directory.
13
+ *
14
+ * Idempotency contract (BRIEF v5 A6):
15
+ * On re-run with the same slug, the verb touches `last_updated` only.
16
+ * It does NOT re-lock `locked_roots` to the current TRUSTED_IDENTITIES
17
+ * — the original snapshot from first-create is preserved. This is what
18
+ * makes the upgrade-after-trust-rotation invariant pass: an existing
19
+ * slug's verifier continues to use its frozen lock even after operators
20
+ * mutate the live trust list via `pgserve trust add` / `remove`.
21
+ *
22
+ * Composes:
23
+ * - src/schema/autopg-meta.js → bootstrapAutopgMeta + columns
24
+ * - src/admin/admin-bootstrap.js → bootstrapConsumerAdmin
25
+ * - src/cosign/trust-list.js → TRUSTED_IDENTITIES (the lock)
26
+ * - src/lib/admin-json.js → readAdminJson (port discovery)
27
+ * - src/lib/pg-query.js → pgQuery + quoteLiteral
28
+ *
29
+ * Exit codes:
30
+ * 0 registered (or no-op idempotent re-run)
31
+ * 1 user error (bad flags, empty slug, slug sanitizes to empty)
32
+ * 2 pgserve postmaster unreachable / cannot bootstrap autopg_meta
33
+ * 3 postgres error during create / select / update sequence
34
+ */
35
+
36
+ import { readAdminJson } from '../lib/admin-json.js';
37
+ import { bootstrapAutopgMeta } from '../schema/autopg-meta.js';
38
+ import { bootstrapConsumerAdmin } from '../admin/admin-bootstrap.js';
39
+ import { TRUSTED_IDENTITIES } from '../cosign/trust-list.js';
40
+ import { pgQuery, quoteLiteral } from '../lib/pg-query.js';
41
+ import { sanitizeSlug } from '../provision/db-naming.js';
42
+
43
+ const USAGE = `Usage: pgserve create-app <slug> [options]
44
+
45
+ <slug> consumer slug (sanitized via sanitizeSlug;
46
+ e.g. "@demo/app" -> "demo_app").
47
+
48
+ --port <N> override the postgres port (default: read
49
+ ~/.autopg/admin.json or 5432).
50
+ --json emit a JSON summary on stdout.
51
+ -h, --help show this help.
52
+
53
+ Idempotent: re-running with the same slug touches last_updated only.
54
+ The locked_roots snapshot from first-create is preserved — this is what
55
+ keeps existing consumers verifiable after operator-driven trust rotation.
56
+
57
+ Source-of-truth split:
58
+ public.autopg_meta is authoritative.
59
+ The per-consumer admin.json + manifest.json are derived caches.
60
+ On divergence, re-run \`pgserve create-app <slug>\` to regenerate them
61
+ from the table (the v2.4 read-only doctor surface flags divergence;
62
+ --fix tiered modes are deferred to a future wave).`;
63
+
64
+ function parseFlags(argv) {
65
+ const out = { json: false, port: undefined, positional: [] };
66
+ for (let i = 0; i < argv.length; i++) {
67
+ const a = argv[i];
68
+ switch (a) {
69
+ case '--json':
70
+ out.json = true;
71
+ break;
72
+ case '--help':
73
+ case '-h':
74
+ out.help = true;
75
+ break;
76
+ case '--port':
77
+ case '-p': {
78
+ const v = Number(argv[++i]);
79
+ if (!Number.isInteger(v) || v <= 0 || v > 65535) {
80
+ throw new Error('--port requires an integer in [1, 65535]');
81
+ }
82
+ out.port = v;
83
+ break;
84
+ }
85
+ default:
86
+ if (a.startsWith('--')) {
87
+ throw new Error(`unknown flag: ${a}`);
88
+ }
89
+ out.positional.push(a);
90
+ }
91
+ }
92
+ return out;
93
+ }
94
+
95
+ function resolvePort(opts) {
96
+ if (typeof opts.port === 'number') return opts.port;
97
+ try {
98
+ const admin = readAdminJson();
99
+ if (admin && Number.isInteger(admin.port) && admin.port > 0) return admin.port;
100
+ } catch {
101
+ /* admin.json absent — fall through */
102
+ }
103
+ return 5432;
104
+ }
105
+
106
+ /**
107
+ * Adapter: shape `pgQuery` to the node-postgres-compatible
108
+ * `client.query(sql)` contract that bootstrapAutopgMeta expects.
109
+ * Mirrors src/commands/provision.js#makePsqlClient.
110
+ */
111
+ function makePsqlClient({ port, db }) {
112
+ return {
113
+ query: async (sql) => pgQuery({ sql, port, db }),
114
+ };
115
+ }
116
+
117
+ async function ensureAutopgMetaSchema({ port }) {
118
+ const client = makePsqlClient({ port, db: 'postgres' });
119
+ await bootstrapAutopgMeta(client);
120
+ }
121
+
122
+ /**
123
+ * Look up an existing autopg_meta row for the slug. Returns
124
+ * `{ slug, manifestPath, lockedRoots, createdAt, lastUpdated }` or null.
125
+ *
126
+ * `locked_roots` is JSONB; psql returns it as a JSON string, parsed
127
+ * here. Timestamps are returned as ISO 8601 (psql `TIMESTAMPTZ` default
128
+ * format); we re-emit them through `new Date().toISOString()` so the
129
+ * cache-write side gets a stable shape.
130
+ */
131
+ function selectAutopgMetaRow({ port, slug }) {
132
+ const out = pgQuery({
133
+ sql: [
134
+ 'SELECT',
135
+ " slug,",
136
+ " manifest_path,",
137
+ " locked_roots::text,",
138
+ " to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"'),",
139
+ " to_char(last_updated AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"')",
140
+ 'FROM public.autopg_meta',
141
+ `WHERE slug = ${quoteLiteral(slug)}`,
142
+ 'LIMIT 1',
143
+ ].join('\n'),
144
+ port,
145
+ captureStdout: true,
146
+ });
147
+ if (!out) return null;
148
+ const [foundSlug, manifestPath, lockedRootsJson, createdAt, lastUpdated] = out.split('\t');
149
+ if (!foundSlug) return null;
150
+ let lockedRoots;
151
+ try {
152
+ lockedRoots = JSON.parse(lockedRootsJson);
153
+ } catch (err) {
154
+ const wrap = new Error(
155
+ `pgserve create-app: failed to parse locked_roots for slug "${foundSlug}": ${err.message}`,
156
+ );
157
+ wrap.cause = err;
158
+ throw wrap;
159
+ }
160
+ return {
161
+ slug: foundSlug,
162
+ manifestPath,
163
+ lockedRoots,
164
+ createdAt,
165
+ lastUpdated,
166
+ };
167
+ }
168
+
169
+ function insertAutopgMetaRow({ port, slug, manifestPath, lockedRoots, nowIso }) {
170
+ pgQuery({
171
+ sql: [
172
+ 'INSERT INTO public.autopg_meta',
173
+ ' (slug, manifest_path, locked_roots, created_at, last_updated)',
174
+ 'VALUES (',
175
+ ` ${quoteLiteral(slug)},`,
176
+ ` ${quoteLiteral(manifestPath)},`,
177
+ ` ${quoteLiteral(JSON.stringify(lockedRoots))}::jsonb,`,
178
+ ` ${quoteLiteral(nowIso)}::timestamptz,`,
179
+ ` ${quoteLiteral(nowIso)}::timestamptz`,
180
+ ')',
181
+ ].join('\n'),
182
+ port,
183
+ });
184
+ }
185
+
186
+ function touchAutopgMetaRow({ port, slug, manifestPath, nowIso }) {
187
+ // Update `last_updated` + `manifest_path` (the latter may have shifted
188
+ // if the operator re-ran with a different AUTOPG_CONFIG_DIR). Crucially
189
+ // does NOT touch `locked_roots` — that's the lock-preservation
190
+ // invariant per BRIEF v5 A6.
191
+ pgQuery({
192
+ sql: [
193
+ 'UPDATE public.autopg_meta SET',
194
+ ` manifest_path = ${quoteLiteral(manifestPath)},`,
195
+ ` last_updated = ${quoteLiteral(nowIso)}::timestamptz`,
196
+ `WHERE slug = ${quoteLiteral(slug)}`,
197
+ ].join('\n'),
198
+ port,
199
+ });
200
+ }
201
+
202
+ function deepCloneRoots(lockedRoots) {
203
+ return JSON.parse(JSON.stringify(lockedRoots));
204
+ }
205
+
206
+ function emit({ json, summary, humanLines }) {
207
+ if (json) {
208
+ process.stdout.write(`${JSON.stringify(summary)}\n`);
209
+ } else {
210
+ for (const line of humanLines) process.stdout.write(`${line}\n`);
211
+ }
212
+ }
213
+
214
+ export async function runCreateApp(argv = []) {
215
+ let opts;
216
+ try {
217
+ opts = parseFlags(argv);
218
+ } catch (err) {
219
+ process.stderr.write(`pgserve create-app: ${err.message}\n\n${USAGE}\n`);
220
+ return 1;
221
+ }
222
+ if (opts.help) {
223
+ process.stdout.write(`${USAGE}\n`);
224
+ return 0;
225
+ }
226
+ const inputSlug = opts.positional[0];
227
+ if (typeof inputSlug !== 'string' || inputSlug.trim().length === 0) {
228
+ process.stderr.write(`pgserve create-app: <slug> is required\n\n${USAGE}\n`);
229
+ return 1;
230
+ }
231
+ const sanitized = sanitizeSlug(inputSlug);
232
+ if (sanitized.length === 0) {
233
+ process.stderr.write(
234
+ `pgserve create-app: slug "${inputSlug}" sanitizes to empty; pick a slug `
235
+ + 'with at least one alphanumeric character\n',
236
+ );
237
+ return 1;
238
+ }
239
+
240
+ const port = resolvePort(opts);
241
+ const summary = {
242
+ slug: sanitized,
243
+ inputSlug,
244
+ port,
245
+ action: 'unknown',
246
+ };
247
+
248
+ // Step 1 — bootstrap the autopg_meta table.
249
+ try {
250
+ await ensureAutopgMetaSchema({ port });
251
+ } catch (err) {
252
+ process.stderr.write(`pgserve create-app: cannot bootstrap autopg_meta: ${err.message}\n`);
253
+ summary.action = 'error';
254
+ summary.error = err.message;
255
+ if (opts.json) emit({ json: true, summary });
256
+ return 2;
257
+ }
258
+
259
+ // Step 2 — look for an existing row.
260
+ let existing;
261
+ try {
262
+ existing = selectAutopgMetaRow({ port, slug: sanitized });
263
+ } catch (err) {
264
+ process.stderr.write(`pgserve create-app: ${err.message}\n`);
265
+ summary.action = 'error';
266
+ summary.error = err.message;
267
+ if (opts.json) emit({ json: true, summary });
268
+ return 3;
269
+ }
270
+
271
+ const nowIso = new Date().toISOString();
272
+
273
+ if (existing) {
274
+ // Idempotent re-run path. Preserve `locked_roots` from the table
275
+ // (do NOT re-snapshot live TRUSTED_IDENTITIES — BRIEF v5 A6 lock
276
+ // preservation). Touch last_updated; rewrite the cache files.
277
+ let writeResult;
278
+ try {
279
+ writeResult = bootstrapConsumerAdmin({
280
+ slug: inputSlug,
281
+ lockedRoots: existing.lockedRoots,
282
+ createdAt: existing.createdAt,
283
+ lastUpdated: nowIso,
284
+ });
285
+ } catch (err) {
286
+ process.stderr.write(`pgserve create-app: failed to write cache files: ${err.message}\n`);
287
+ summary.action = 'error';
288
+ summary.error = err.message;
289
+ if (opts.json) emit({ json: true, summary });
290
+ return 1;
291
+ }
292
+
293
+ try {
294
+ touchAutopgMetaRow({
295
+ port,
296
+ slug: sanitized,
297
+ manifestPath: writeResult.manifestPath,
298
+ nowIso,
299
+ });
300
+ } catch (err) {
301
+ process.stderr.write(`pgserve create-app: ${err.message}\n`);
302
+ summary.action = 'error';
303
+ summary.error = err.message;
304
+ if (opts.json) emit({ json: true, summary });
305
+ return 3;
306
+ }
307
+
308
+ summary.action = 'touched';
309
+ summary.createdAt = existing.createdAt;
310
+ summary.lastUpdated = nowIso;
311
+ summary.lockedRoots = existing.lockedRoots;
312
+ summary.adminPath = writeResult.adminPath;
313
+ summary.manifestPath = writeResult.manifestPath;
314
+ emit({
315
+ json: opts.json,
316
+ summary,
317
+ humanLines: [
318
+ `pgserve create-app: slug "${sanitized}" already registered (touched).`,
319
+ ` locked_roots preserved (${existing.lockedRoots.length} entries from createdAt=${existing.createdAt})`,
320
+ ` admin: ${writeResult.adminPath}`,
321
+ ` manifest: ${writeResult.manifestPath}`,
322
+ ],
323
+ });
324
+ return 0;
325
+ }
326
+
327
+ // Step 3 — fresh registration. Snapshot TRUSTED_IDENTITIES into the
328
+ // table + cache files. The deep-clone strips Object.freeze wrappers
329
+ // so the JSONB write is plain JSON.
330
+ const lockedRoots = deepCloneRoots(TRUSTED_IDENTITIES);
331
+
332
+ let writeResult;
333
+ try {
334
+ writeResult = bootstrapConsumerAdmin({
335
+ slug: inputSlug,
336
+ lockedRoots,
337
+ createdAt: nowIso,
338
+ lastUpdated: nowIso,
339
+ });
340
+ } catch (err) {
341
+ process.stderr.write(`pgserve create-app: failed to write cache files: ${err.message}\n`);
342
+ summary.action = 'error';
343
+ summary.error = err.message;
344
+ if (opts.json) emit({ json: true, summary });
345
+ return 1;
346
+ }
347
+
348
+ try {
349
+ insertAutopgMetaRow({
350
+ port,
351
+ slug: sanitized,
352
+ manifestPath: writeResult.manifestPath,
353
+ lockedRoots,
354
+ nowIso,
355
+ });
356
+ } catch (err) {
357
+ process.stderr.write(`pgserve create-app: ${err.message}\n`);
358
+ summary.action = 'error';
359
+ summary.error = err.message;
360
+ if (opts.json) emit({ json: true, summary });
361
+ return 3;
362
+ }
363
+
364
+ summary.action = 'created';
365
+ summary.createdAt = nowIso;
366
+ summary.lastUpdated = nowIso;
367
+ summary.lockedRoots = lockedRoots;
368
+ summary.adminPath = writeResult.adminPath;
369
+ summary.manifestPath = writeResult.manifestPath;
370
+ emit({
371
+ json: opts.json,
372
+ summary,
373
+ humanLines: [
374
+ `pgserve create-app: registered slug "${sanitized}".`,
375
+ ` locked_roots: snapshotted ${lockedRoots.length} entries from TRUSTED_IDENTITIES`,
376
+ ` admin: ${writeResult.adminPath}`,
377
+ ` manifest: ${writeResult.manifestPath}`,
378
+ ],
379
+ });
380
+ return 0;
381
+ }
382
+
383
+ export const __testInternals = Object.freeze({
384
+ parseFlags,
385
+ resolvePort,
386
+ deepCloneRoots,
387
+ });
@@ -42,6 +42,7 @@ import { getAdminFilePath, readAdminJson, SUPERVISOR_VALUES } from '../lib/admin
42
42
  import { resolveSocketDir } from '../lib/socket-dir.js';
43
43
  import { readRuntimeJson, isLiveRuntime } from '../lib/runtime-json.js';
44
44
  import { findBlocked } from '../security/blocked-versions.js';
45
+ import { pgQuery } from '../lib/pg-query.js';
45
46
 
46
47
  const SEVERITY = Object.freeze({ PASS: 'PASS', WARN: 'WARN', FAIL: 'FAIL' });
47
48
 
@@ -374,6 +375,68 @@ function checkTcpReachable(admin) {
374
375
  });
375
376
  }
376
377
 
378
+ /**
379
+ * B7 (v2.6.3): surface the audit posture as a doctor finding so operators
380
+ * know whether their postmaster is running pgaudit (structured audit
381
+ * classes) or the log_statement=all fallback (capture-everything via
382
+ * postgres-native logging). Pre-fix the fallback fired silently — the
383
+ * WARN appeared once at startup in pm2 logs and was lost to operators
384
+ * who didn't tail the logs at the right moment.
385
+ *
386
+ * PASS pgaudit shows up in shared_preload_libraries on the live postmaster
387
+ * WARN pgaudit absent (fallback active) OR cannot probe (postmaster
388
+ * unreachable / psql missing)
389
+ *
390
+ * The check fires SQL via the cohort-canonical pgQuery (psql shellout),
391
+ * so it inherits its env contract (PGPASSWORD literal-postgres fallback
392
+ * for fresh-install hosts per CV-1) and times out via psql's own
393
+ * connection timeout. Failure modes are mapped to WARN, never FAIL —
394
+ * this is a posture diagnostic, not a connectivity gate.
395
+ */
396
+ function checkPgauditLoaded(admin) {
397
+ if (!admin || !Number.isInteger(admin.port) || admin.port <= 0) {
398
+ return check(
399
+ 'pgaudit_loaded',
400
+ 'audit posture not probed',
401
+ SEVERITY.WARN,
402
+ 'admin.json missing or has no port; cannot connect to postmaster to probe SHOW shared_preload_libraries',
403
+ 'run `pgserve install` (Tier A) or `autopg service install` (Tier B) first',
404
+ );
405
+ }
406
+ let preloadLibs;
407
+ try {
408
+ preloadLibs = pgQuery({
409
+ sql: 'SHOW shared_preload_libraries',
410
+ port: admin.port,
411
+ captureStdout: true,
412
+ });
413
+ } catch (err) {
414
+ return check(
415
+ 'pgaudit_loaded',
416
+ 'audit posture probe failed',
417
+ SEVERITY.WARN,
418
+ `psql shellout failed: ${err?.stderr?.trim?.() || err?.message || err}`,
419
+ 'verify postmaster is up (`pgserve status`) and psql is on PATH',
420
+ );
421
+ }
422
+ const value = (preloadLibs || '').trim();
423
+ if (/(^|,)\s*pgaudit\s*(,|$)/i.test(value)) {
424
+ return check(
425
+ 'pgaudit_loaded',
426
+ 'pgaudit loaded — structured audit posture',
427
+ SEVERITY.PASS,
428
+ `shared_preload_libraries=${value || '(empty)'}`,
429
+ );
430
+ }
431
+ return check(
432
+ 'pgaudit_loaded',
433
+ 'pgaudit NOT loaded — fallback to log_statement=all',
434
+ SEVERITY.WARN,
435
+ `shared_preload_libraries=${value || '(empty)'}; audit data captured via log_statement=all (postgres-native), not pgaudit's structured classes`,
436
+ 'bundle pgaudit.so with the embedded postgres binaries (Branch A in QA-RECIPE-B7.md) to switch the postmaster onto pgaudit; the current fallback is functional for compliance but noisier than pgaudit',
437
+ );
438
+ }
439
+
377
440
  // ─── orchestration ────────────────────────────────────────────────────
378
441
 
379
442
  /**
@@ -392,6 +455,7 @@ export async function runChecks() {
392
455
  findings.push(checkSupervisorLiveness(admin));
393
456
  findings.push(checkRuntimeJson(admin));
394
457
  findings.push(await checkUdsReachable(admin));
458
+ findings.push(checkPgauditLoaded(admin));
395
459
  findings.push(await checkTcpReachable(admin));
396
460
 
397
461
  return findings;
@@ -460,6 +524,7 @@ export const __testInternals = Object.freeze({
460
524
  checkAdminJsonShape,
461
525
  checkRuntimeJson,
462
526
  checkSupervisorLiveness,
527
+ checkPgauditLoaded,
463
528
  exitCodeFor,
464
529
  SEVERITY,
465
530
  });
@@ -31,7 +31,7 @@
31
31
 
32
32
  import { readAdminJson } from '../lib/admin-json.js';
33
33
  import { classifyOrphans } from '../gc/orphan-detection.js';
34
- import { writeGcAudit } from '../gc/audit-log.js';
34
+ import { writeGcAudit, rotateGcAuditLogs } from '../gc/audit-log.js';
35
35
  import {
36
36
  selectMetaRows,
37
37
  selectExistingDbs,
@@ -164,6 +164,21 @@ export async function runGc(argv = []) {
164
164
  detail: `mode=${opts.apply ? 'apply' : 'dry-run'} port=${port} staleAfterDays=${opts.staleAfterDays}`,
165
165
  });
166
166
 
167
+ // Rotate audit logs older than 90 days — runs on every gc invocation
168
+ // (dry-run or apply). Boundary guard preserves the current day's log.
169
+ // Errors are surfaced via the audit log itself; never aborts the gc run.
170
+ try {
171
+ const rotation = rotateGcAuditLogs();
172
+ if (rotation.deleted.length > 0 || rotation.errors.length > 0) {
173
+ writeGcAudit({
174
+ action: 'rotate-summary',
175
+ detail: `deleted=${rotation.deleted.length} kept=${rotation.kept.length} errors=${rotation.errors.length}`,
176
+ });
177
+ }
178
+ } catch (err) {
179
+ writeGcAudit({ action: 'error', reason: 'rotate_failed', detail: err.message });
180
+ }
181
+
167
182
  let metaRows;
168
183
  try {
169
184
  metaRows = selectMetaRows({ port });