@tekyzinc/gsd-t 4.19.13 → 4.20.10

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.
@@ -0,0 +1,566 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * GSD-T Logging Envelope Check (M100 D3)
6
+ *
7
+ * Standalone STRUCTURAL predicate enforcing BOTH the trace envelope and the
8
+ * audit envelope over PER-PROJECT-VARYING schemas — never hardcoding a
9
+ * category/action value.
10
+ *
11
+ * Contract: .gsd-t/contracts/logging-verify-gate-contract.md v1.0.0 DRAFT.
12
+ * Consumed contracts: trace-logging-contract.md, audit-logging-contract.md.
13
+ *
14
+ * Exports (per contract §"The predicate: what d3 produces"):
15
+ * checkEnvelope(record, { stream: "trace" | "audit" }) -> { ok, failures }
16
+ * checkLoggingEnvelopes({ projectDir }) -> { ok, failures }
17
+ *
18
+ * Hard rules:
19
+ * - STRUCTURAL ONLY: parse shape as shape, never text.includes(category/action).
20
+ * A novel category/action value MUST PASS.
21
+ * - Presence vs. null are TWO SEPARATE checks — never truthiness. A required
22
+ * field may be PRESENT with value `null` (legal) vs. the key ABSENT (fail).
23
+ * - No-collapse keys on the TOP-LEVEL marker set of the record, NEVER a
24
+ * key-name scan of nested `context`/`data` payloads.
25
+ * - FAIL-CLOSED: any failure blocks. Never warn-and-proceed.
26
+ */
27
+
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+
31
+ // ── Field definitions (structural — no hardcoded values) ───────────────────
32
+
33
+ const TRACE_REQUIRED_FIELDS = ['ts', 'category', 'decision', 'detail'];
34
+ const AUDIT_REQUIRED_FIELDS = ['ts', 'actor', 'action', 'target', 'before', 'after', 'context'];
35
+
36
+ // Fields whose TYPE check accepts `null` as a legal value (presence still required).
37
+ const NULLABLE_FIELDS = new Set(['decision', 'before', 'after']);
38
+
39
+ // Top-level marker sets used by the no-collapse boundary (per contract §No-collapse detector).
40
+ const TRACE_MARKERS = ['category', 'decision', 'detail'];
41
+ const AUDIT_MARKERS = ['before', 'after', 'actor', 'action'];
42
+
43
+ // ── Type checking ────────────────────────────────────────────────────────────
44
+
45
+ function _hasKey(record, key) {
46
+ return record != null && typeof record === 'object' && Object.prototype.hasOwnProperty.call(record, key);
47
+ }
48
+
49
+ function _typeOk(field, value) {
50
+ switch (field) {
51
+ case 'ts':
52
+ case 'category':
53
+ case 'detail':
54
+ case 'actor':
55
+ case 'action':
56
+ case 'target':
57
+ return typeof value === 'string';
58
+ case 'decision':
59
+ // boolean | null — presence checked separately.
60
+ return value === null || typeof value === 'boolean';
61
+ case 'before':
62
+ case 'after':
63
+ // Record<string, unknown> | null — presence checked separately.
64
+ return value === null || (typeof value === 'object' && !Array.isArray(value));
65
+ case 'context':
66
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
67
+ // Optional trace fields:
68
+ case 'key':
69
+ return typeof value === 'string';
70
+ case 'status':
71
+ return typeof value === 'number';
72
+ case 'data':
73
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
74
+ default:
75
+ return true;
76
+ }
77
+ }
78
+
79
+ // ── PII matcher — recurses into nested structures, no false positives ──────
80
+
81
+ // Real-TLD-shaped email, matched as a whole-token substring (word-boundary-safe):
82
+ // local@domain.tld (tld letters only, 2+ chars). Uses lookaround so it also matches
83
+ // a bare full-string value (whole-string IS a substring of itself). The trailing
84
+ // lookahead allows a closing quote/bracket/angle-bracket/brace immediately after
85
+ // the TLD (JSON string bodies, `Name <email>` headers, array literals) — without
86
+ // these, an email immediately followed by `"`, `>`, `]`, or `}` was NOT matched.
87
+ const EMAIL_RE = /(?<![^\s(])[^\s@()]+@[^\s@()]+\.[a-zA-Z]{2,}(?![^\s).,;:!?"'>\]}])/;
88
+ // Phone: grouped digit run with separators/parens — NOT a bare long id, and NOT
89
+ // an ISO-8601 date/timestamp shape (YYYY-MM-DD[THH:MM:SS...]) which otherwise
90
+ // false-positives against generic digit-dash grouping.
91
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T[\d:.Z+-]*)?$/;
92
+ // CONSERVATIVE phone matcher (fail-closed gate — a false positive BLOCKS a legit build,
93
+ // so we only flag CLEAR phone signals, accepting we miss exotic formats):
94
+ // +CC prefix, OR a parenthesized area code, OR the classic NNN-NNN-NNNN US shape.
95
+ // A bare undelimited digit run is NOT a phone (contract: a bare long id isn't a phone).
96
+ // Grouped internal ids / ranges (12-34-56, 100-200-300, 1234-5678-90) are NOT matched.
97
+ const PHONE_RE = /(?<!\d)(?:\+\d{1,3}[\s.-]?)?(?:\(\d{3}\)[\s.-]?\d{3}[\s.-]?\d{4}|\d{3}[.-]\d{3}[.-]\d{4})(?!\d)/;
98
+
99
+ function _isPhoneShaped(value) {
100
+ const matches = value.match(new RegExp(PHONE_RE.source, 'g')) || [];
101
+ return matches.some((m) => !ISO_DATE_RE.test(m) && !ISO_DATE_RE.test(value.trim()));
102
+ }
103
+ // Postal address: leading street number + street-name + common suffix, or a ZIP-shaped tail.
104
+ const ADDRESS_RE = /\b\d{1,6}\s+[A-Za-z0-9.'-]+(?:\s+[A-Za-z0-9.'-]+){0,4}\s+(Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Lane|Ln|Drive|Dr|Court|Ct|Way|Place|Pl)\b/i;
105
+ const ZIP_RE = /\b\d{5}(-\d{4})?\b/;
106
+
107
+ function _isPiiString(value) {
108
+ if (typeof value !== 'string') return false;
109
+ if (ISO_DATE_RE.test(value.trim())) return false; // structural timestamp, not PII
110
+ if (EMAIL_RE.test(value)) return true;
111
+ if (_isPhoneShaped(value)) return true;
112
+ if (ADDRESS_RE.test(value)) return true;
113
+ return false;
114
+ }
115
+
116
+ function _scanForPii(value, pathParts, hits, depth) {
117
+ if (depth > 12) return; // defensive recursion cap, not a real-world limit
118
+ if (value == null) return;
119
+ if (typeof value === 'string') {
120
+ if (_isPiiString(value)) hits.push(pathParts.join('.'));
121
+ return;
122
+ }
123
+ if (Array.isArray(value)) {
124
+ value.forEach((v, i) => _scanForPii(v, pathParts.concat(String(i)), hits, depth + 1));
125
+ return;
126
+ }
127
+ if (typeof value === 'object') {
128
+ for (const k of Object.keys(value)) {
129
+ _scanForPii(value[k], pathParts.concat(k), hits, depth + 1);
130
+ }
131
+ }
132
+ }
133
+
134
+ // ── checkEnvelope(record, { stream }) ───────────────────────────────────────
135
+
136
+ function checkEnvelope(record, opts) {
137
+ opts = opts || {};
138
+ const stream = opts.stream;
139
+ const failures = [];
140
+
141
+ if (record == null || typeof record !== 'object' || Array.isArray(record)) {
142
+ return { ok: false, failures: [{ rule: (stream === 'audit' ? 'audit-envelope-structural' : 'trace-envelope-structural'), stream: stream || null, detail: 'record is not an object' }] };
143
+ }
144
+
145
+ if (stream !== 'trace' && stream !== 'audit') {
146
+ return { ok: false, failures: [{ rule: 'envelope-structural', stream: stream || null, detail: 'unknown stream: ' + String(stream) }] };
147
+ }
148
+
149
+ const requiredFields = stream === 'trace' ? TRACE_REQUIRED_FIELDS : AUDIT_REQUIRED_FIELDS;
150
+ const structuralRule = stream === 'trace' ? 'trace-envelope-structural' : 'audit-envelope-structural';
151
+
152
+ for (const field of requiredFields) {
153
+ if (!_hasKey(record, field)) {
154
+ failures.push({ rule: structuralRule, stream, detail: 'missing required field: ' + field });
155
+ continue;
156
+ }
157
+ const value = record[field];
158
+ if (!_typeOk(field, value)) {
159
+ failures.push({ rule: structuralRule, stream, detail: 'wrong type for field: ' + field });
160
+ }
161
+ }
162
+
163
+ // Optional-field type checks (only when present).
164
+ if (stream === 'trace') {
165
+ for (const optField of ['key', 'status', 'data']) {
166
+ if (_hasKey(record, optField) && record[optField] != null && !_typeOk(optField, record[optField])) {
167
+ failures.push({ rule: structuralRule, stream, detail: 'wrong type for optional field: ' + optField });
168
+ }
169
+ }
170
+ }
171
+
172
+ // PII bar — trace only, recurse into every field including `data`.
173
+ // `ts` is excluded: it is a structural ISO-8601 timestamp (type-checked above),
174
+ // not free-form user content, and its digit-dash grouping otherwise false-positives
175
+ // against the phone matcher.
176
+ if (stream === 'trace') {
177
+ const hits = [];
178
+ for (const field of Object.keys(record)) {
179
+ if (field === 'ts') continue;
180
+ _scanForPii(record[field], [field], hits, 0);
181
+ }
182
+ if (hits.length > 0) {
183
+ failures.push({ rule: 'trace-pii-barred', stream, detail: 'PII-shaped value at: ' + hits.join(', ') });
184
+ }
185
+ }
186
+
187
+ // No-collapse — TOP-LEVEL marker set only, never a nested key-name scan.
188
+ const hasTraceMarker = TRACE_MARKERS.some((k) => _hasKey(record, k));
189
+ const hasAuditMarker = AUDIT_MARKERS.some((k) => _hasKey(record, k));
190
+ if (hasTraceMarker && hasAuditMarker) {
191
+ failures.push({ rule: 'no-collapse', stream, detail: 'record carries top-level markers from BOTH trace and audit streams' });
192
+ }
193
+
194
+ return { ok: failures.length === 0, failures };
195
+ }
196
+
197
+ // ── Durability + default-rule checks (M100-D3-T2) ───────────────────────────
198
+
199
+ /**
200
+ * Checks an audit module's declared surface for append-only/immutability.
201
+ * moduleSurface: { exportsUpdate?: boolean, exportsDelete?: boolean, declaresAppendOnly?: boolean }
202
+ */
203
+ function _checkAppendOnlyImmutable(moduleSurface) {
204
+ const failures = [];
205
+ if (!moduleSurface || typeof moduleSurface !== 'object') {
206
+ failures.push({ rule: 'audit-append-only-immutable', stream: 'audit', detail: 'no audit module surface discovered' });
207
+ return failures;
208
+ }
209
+ if (moduleSurface.exportsUpdate === true || moduleSurface.exportsDelete === true) {
210
+ failures.push({ rule: 'audit-append-only-immutable', stream: 'audit', detail: 'audit module exposes an update/delete path — not append-only' });
211
+ }
212
+ if (moduleSurface.declaresAppendOnly !== true) {
213
+ failures.push({ rule: 'audit-append-only-immutable', stream: 'audit', detail: 'audit module does not declare append-only/immutability' });
214
+ }
215
+ return failures;
216
+ }
217
+
218
+ /**
219
+ * Checks retention configurability.
220
+ * retentionConfig: { hardcoded?: boolean, configurable?: boolean }
221
+ */
222
+ function _checkRetentionConfigurable(retentionConfig) {
223
+ const failures = [];
224
+ if (!retentionConfig || typeof retentionConfig !== 'object' || retentionConfig.configurable !== true || retentionConfig.hardcoded === true) {
225
+ failures.push({ rule: 'audit-retention-configurable', stream: 'audit', detail: 'retention window is hardcoded or not configurable' });
226
+ }
227
+ return failures;
228
+ }
229
+
230
+ /**
231
+ * Validates the opt-out record shape per audit-logging-contract.md §opt-out-record.
232
+ */
233
+ function _isValidOptOut(optOutRecord) {
234
+ if (!optOutRecord || typeof optOutRecord !== 'object') return false;
235
+ if (optOutRecord.auditOptOut !== true) return false;
236
+ if (typeof optOutRecord.reason !== 'string' || optOutRecord.reason.trim().length === 0) return false;
237
+ return true;
238
+ }
239
+
240
+ function _checkDefaultExceptOptOut({ hasAuditStore, optOutRecord }) {
241
+ const failures = [];
242
+ if (hasAuditStore) return failures;
243
+ if (_isValidOptOut(optOutRecord)) return failures;
244
+ failures.push({ rule: 'audit-default-except-optout', stream: 'audit', detail: 'no audit store and no valid opt-out record' });
245
+ return failures;
246
+ }
247
+
248
+ /**
249
+ * Validates the TRACE opt-out record shape per trace-logging-contract.md §opt-out-record
250
+ * (M100 correction: the "trace has NO opt-out" rule was too absolute — a stateless CLI /
251
+ * library has no runtime data-flow to trace, so a symmetric opt-out exists for that class).
252
+ */
253
+ function _isValidTraceOptOut(rec) {
254
+ if (!rec || typeof rec !== 'object') return false;
255
+ if (rec.traceOptOut !== true) return false;
256
+ if (typeof rec.reason !== 'string' || rec.reason.trim().length === 0) return false;
257
+ return true;
258
+ }
259
+
260
+ // ── §discovery — checkLoggingEnvelopes({ projectDir }) ──────────────────────
261
+ //
262
+ // Real enumeration per logging-verify-gate-contract.md §discovery. Walks the
263
+ // project to locate (i) the trace module/store + records, (ii) the audit
264
+ // module/store + records, (iii) .gsd-t/audit-optout.json — then runs every
265
+ // discovered record through checkEnvelope. Never returns ok:true vacuously.
266
+
267
+ const TRACE_MODULE_CANDIDATES = [
268
+ 'src/logging/trace.ts',
269
+ 'src/logging/trace.js',
270
+ 'src/trace/index.ts',
271
+ 'src/trace/index.js',
272
+ ];
273
+
274
+ const TRACE_STORE_CANDIDATES = [
275
+ '.gsd-t/trace-records.json',
276
+ '.gsd-t/logging/trace-records.json',
277
+ ];
278
+
279
+ // SQLite store candidates — the contracts EXPLICITLY sanction "storage is
280
+ // whatever fits the stack", so a DB-backed store is first-class, not an
281
+ // unlisted edge case. When one is discovered we open it read-only and run
282
+ // checkEnvelope over its rows (better-sqlite3 is already a dependency). A
283
+ // discovered-but-UNINSPECTABLE store (unknown shape, unopenable, no known
284
+ // table) FAILS-CLOSED rather than silently passing with zero records inspected.
285
+ const TRACE_DB_CANDIDATES = [
286
+ '.gsd-t/trace.db',
287
+ '.gsd-t/trace.sqlite',
288
+ '.gsd-t/logging/trace.db',
289
+ '.gsd-t/logging/trace.sqlite',
290
+ ];
291
+ const AUDIT_DB_CANDIDATES = [
292
+ '.gsd-t/audit.db',
293
+ '.gsd-t/audit.sqlite',
294
+ '.gsd-t/logging/audit.db',
295
+ '.gsd-t/logging/audit.sqlite',
296
+ ];
297
+ // Known table names per stream (structural — the audit module template ships
298
+ // `audit_log`; trace's DB-backed convention is `trace_records`/`trace_log`).
299
+ const TRACE_DB_TABLES = ['trace_records', 'trace_log', 'trace'];
300
+ const AUDIT_DB_TABLES = ['audit_log', 'audit_records', 'audit'];
301
+
302
+ const AUDIT_MODULE_CANDIDATES = [
303
+ 'src/logging/audit-module.ts',
304
+ 'src/logging/audit.ts',
305
+ 'src/logging/audit.js',
306
+ 'src/audit/index.ts',
307
+ 'src/audit/index.js',
308
+ ];
309
+
310
+ const AUDIT_STORE_CANDIDATES = [
311
+ '.gsd-t/audit-records.json',
312
+ '.gsd-t/logging/audit-records.json',
313
+ ];
314
+
315
+ function _readJsonArrayIfExists(absPath) {
316
+ try {
317
+ if (!fs.existsSync(absPath)) return null;
318
+ const raw = fs.readFileSync(absPath, 'utf8');
319
+ const parsed = JSON.parse(raw);
320
+ return Array.isArray(parsed) ? parsed : null;
321
+ } catch (_err) {
322
+ return null;
323
+ }
324
+ }
325
+
326
+ function _firstExisting(projectDir, candidates) {
327
+ for (const rel of candidates) {
328
+ const abs = path.join(projectDir, rel);
329
+ try {
330
+ if (fs.existsSync(abs)) return abs;
331
+ } catch (_err) { /* ignore */ }
332
+ }
333
+ return null;
334
+ }
335
+
336
+ // ── Stack-adaptive SQLite store inspection ──────────────────────────────────
337
+ //
338
+ // Opens a discovered .db/.sqlite store read-only and rehydrates rows into the
339
+ // canonical envelope shape so checkEnvelope can validate them exactly like JSON
340
+ // records. Return shapes are DISTINCT so the caller can fail-closed:
341
+ // { records: [...] } → inspectable, here are the records (maybe [])
342
+ // { uninspectable: 'why' } → discovered but CANNOT inspect → caller FAILS.
343
+
344
+ function _loadBetterSqlite() {
345
+ try {
346
+ return require('better-sqlite3');
347
+ } catch (_err) {
348
+ return null;
349
+ }
350
+ }
351
+
352
+ /** Column set → canonical trace/audit record. Reads JSON-text columns back into objects. */
353
+ function _rowToRecord(row) {
354
+ const rec = {};
355
+ for (const k of Object.keys(row)) {
356
+ if (k === 'id') continue; // store-assigned key, not part of the envelope
357
+ let v = row[k];
358
+ // JSON-serialized object/array columns (before/after/context/data) come
359
+ // back as TEXT — rehydrate so checkEnvelope sees the real shape/type.
360
+ if (typeof v === 'string' && v.length > 0 && (v[0] === '{' || v[0] === '[')) {
361
+ try { v = JSON.parse(v); } catch (_e) { /* leave as string */ }
362
+ }
363
+ rec[k] = v;
364
+ }
365
+ return rec;
366
+ }
367
+
368
+ /**
369
+ * Inspect a SQLite store for a given stream. Returns { records } when a known
370
+ * table is found and read, else { uninspectable: reason } — NEVER a silent
371
+ * empty pass.
372
+ */
373
+ function _inspectSqliteStore(absDbPath, knownTables) {
374
+ const Database = _loadBetterSqlite();
375
+ if (!Database) {
376
+ return { uninspectable: 'a SQLite store was discovered but better-sqlite3 is unavailable to inspect it' };
377
+ }
378
+ let db = null;
379
+ try {
380
+ db = new Database(absDbPath, { readonly: true, fileMustExist: true });
381
+ const present = db
382
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
383
+ .all()
384
+ .map((r) => r.name);
385
+ const table = knownTables.find((t) => present.includes(t));
386
+ if (!table) {
387
+ return { uninspectable: 'a SQLite store was discovered but no recognized records table was found (saw: ' + present.join(', ') + ')' };
388
+ }
389
+ const rows = db.prepare('SELECT * FROM "' + table + '"').all();
390
+ return { records: rows.map(_rowToRecord) };
391
+ } catch (err) {
392
+ return { uninspectable: 'a SQLite store was discovered but could not be opened/read: ' + (err && err.message ? err.message : String(err)) };
393
+ } finally {
394
+ if (db) { try { db.close(); } catch (_e) { /* ignore */ } }
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Reads a module's declared surface out of its source text — best-effort,
400
+ * structural signal only (looks for documented export/declaration markers),
401
+ * never a substring scan of business content.
402
+ */
403
+ function _readModuleSurface(absModulePath) {
404
+ let text = '';
405
+ try {
406
+ text = fs.readFileSync(absModulePath, 'utf8');
407
+ } catch (_err) {
408
+ return null;
409
+ }
410
+ return {
411
+ declaresAppendOnly: /append-?only/i.test(text) || /immutable/i.test(text),
412
+ exportsUpdate: /export\s+(async\s+)?function\s+update\w*Entry|export\s+const\s+update\w*Entry/i.test(text),
413
+ exportsDelete: /export\s+(async\s+)?function\s+delete\w*Entry|export\s+const\s+delete\w*Entry/i.test(text),
414
+ retentionConfigurable: /retention/i.test(text) && (/process\.env/.test(text) || /config/i.test(text)),
415
+ retentionHardcoded: /retention/i.test(text) && !(/process\.env/.test(text) || /config/i.test(text)),
416
+ };
417
+ }
418
+
419
+ function checkLoggingEnvelopes(opts) {
420
+ opts = opts || {};
421
+ const projectDir = opts.projectDir || '.';
422
+ const failures = [];
423
+
424
+ // (i) Trace discovery — default for every project EXCEPT explicit opt-out
425
+ // (M100 correction: stateless CLI/library class has no runtime data-flow to trace,
426
+ // so a symmetric .gsd-t/trace-optout.json opt-out exists, mirroring audit's).
427
+ const traceModulePath = _firstExisting(projectDir, TRACE_MODULE_CANDIDATES);
428
+ const traceStorePath = _firstExisting(projectDir, TRACE_STORE_CANDIDATES);
429
+ const traceDbPath = _firstExisting(projectDir, TRACE_DB_CANDIDATES);
430
+ const traceRecords = traceStorePath ? _readJsonArrayIfExists(traceStorePath) : null;
431
+ let traceOptOutRecord = null;
432
+ const traceOptOutPath = path.join(projectDir, '.gsd-t', 'trace-optout.json');
433
+ try {
434
+ if (fs.existsSync(traceOptOutPath)) traceOptOutRecord = JSON.parse(fs.readFileSync(traceOptOutPath, 'utf8'));
435
+ } catch (_e) { traceOptOutRecord = null; }
436
+
437
+ if (!traceModulePath && !traceStorePath && !traceDbPath) {
438
+ if (!_isValidTraceOptOut(traceOptOutRecord)) {
439
+ failures.push({ rule: 'trace-default-except-optout', stream: 'trace', detail: 'no trace module or store discoverable and no valid trace opt-out record' });
440
+ }
441
+ } else if (Array.isArray(traceRecords)) {
442
+ for (const rec of traceRecords) {
443
+ const result = checkEnvelope(rec, { stream: 'trace' });
444
+ if (!result.ok) failures.push(...result.failures);
445
+ }
446
+ } else if (traceDbPath) {
447
+ // Stack-adaptive: a DB-backed trace store — inspect it, don't wave it through.
448
+ const inspected = _inspectSqliteStore(traceDbPath, TRACE_DB_TABLES);
449
+ if (inspected.uninspectable) {
450
+ failures.push({ rule: 'trace-store-uninspectable', stream: 'trace', detail: inspected.uninspectable + ' — the PII bar + envelope check cannot be enforced, failing closed' });
451
+ } else {
452
+ for (const rec of inspected.records) {
453
+ const result = checkEnvelope(rec, { stream: 'trace' });
454
+ if (!result.ok) failures.push(...result.failures);
455
+ }
456
+ }
457
+ } else if (traceStorePath && traceRecords === null) {
458
+ // A recognized JSON store path EXISTS but did NOT parse as a JSON array —
459
+ // it is an uninspectable shape (malformed JSON, or a non-array). The PII
460
+ // bar + envelope check cannot run over it, so FAIL-CLOSED rather than pass.
461
+ failures.push({ rule: 'trace-store-uninspectable', stream: 'trace', detail: 'trace store at ' + traceStorePath + ' is present but is not a parseable JSON array — cannot enforce the envelope/PII checks, failing closed' });
462
+ }
463
+ // else: trace MODULE present but NO store discovered at all (traceStorePath &&
464
+ // traceDbPath both absent) — a fresh project that scaffolded trace but has not
465
+ // yet emitted a record. This is the ONLY genuinely-legal no-records case: the
466
+ // module's presence satisfies trace-default-except-optout above, and there is
467
+ // legitimately nothing to validate yet.
468
+
469
+ // (ii) Audit discovery.
470
+ const auditModulePath = _firstExisting(projectDir, AUDIT_MODULE_CANDIDATES);
471
+ const auditStorePath = _firstExisting(projectDir, AUDIT_STORE_CANDIDATES);
472
+ const auditDbPath = _firstExisting(projectDir, AUDIT_DB_CANDIDATES);
473
+ const auditRecords = auditStorePath ? _readJsonArrayIfExists(auditStorePath) : null;
474
+ const hasAuditStore = !!(auditModulePath || auditStorePath || auditDbPath);
475
+
476
+ // (iii) Opt-out file.
477
+ let optOutRecord = null;
478
+ const optOutPath = path.join(projectDir, '.gsd-t', 'audit-optout.json');
479
+ try {
480
+ if (fs.existsSync(optOutPath)) {
481
+ optOutRecord = JSON.parse(fs.readFileSync(optOutPath, 'utf8'));
482
+ }
483
+ } catch (_err) {
484
+ optOutRecord = null;
485
+ }
486
+
487
+ const defaultOptOutFailures = _checkDefaultExceptOptOut({ hasAuditStore, optOutRecord });
488
+ failures.push(...defaultOptOutFailures);
489
+
490
+ if (hasAuditStore) {
491
+ if (Array.isArray(auditRecords)) {
492
+ for (const rec of auditRecords) {
493
+ const result = checkEnvelope(rec, { stream: 'audit' });
494
+ if (!result.ok) failures.push(...result.failures);
495
+ }
496
+ } else if (auditDbPath) {
497
+ // Stack-adaptive: a DB-backed audit store — inspect it, don't wave it through.
498
+ const inspected = _inspectSqliteStore(auditDbPath, AUDIT_DB_TABLES);
499
+ if (inspected.uninspectable) {
500
+ failures.push({ rule: 'audit-store-uninspectable', stream: 'audit', detail: inspected.uninspectable + ' — the envelope check cannot be enforced, failing closed' });
501
+ } else {
502
+ for (const rec of inspected.records) {
503
+ const result = checkEnvelope(rec, { stream: 'audit' });
504
+ if (!result.ok) failures.push(...result.failures);
505
+ }
506
+ }
507
+ } else if (auditStorePath && auditRecords === null) {
508
+ // A recognized JSON audit store exists but is not a parseable JSON array —
509
+ // an uninspectable shape. FAIL-CLOSED rather than silently pass.
510
+ failures.push({ rule: 'audit-store-uninspectable', stream: 'audit', detail: 'audit store at ' + auditStorePath + ' is present but is not a parseable JSON array — cannot enforce the envelope check, failing closed' });
511
+ }
512
+
513
+ if (auditModulePath) {
514
+ const surface = _readModuleSurface(auditModulePath);
515
+ failures.push(..._checkAppendOnlyImmutable({
516
+ exportsUpdate: surface ? surface.exportsUpdate : false,
517
+ exportsDelete: surface ? surface.exportsDelete : false,
518
+ declaresAppendOnly: surface ? surface.declaresAppendOnly : false,
519
+ }));
520
+ failures.push(..._checkRetentionConfigurable({
521
+ hardcoded: surface ? surface.retentionHardcoded : true,
522
+ configurable: surface ? surface.retentionConfigurable : false,
523
+ }));
524
+ } else {
525
+ // Audit store present but no module surface to inspect declared durability rules.
526
+ failures.push({ rule: 'audit-append-only-immutable', stream: 'audit', detail: 'no audit module surface discoverable to verify append-only declaration' });
527
+ failures.push({ rule: 'audit-retention-configurable', stream: 'audit', detail: 'no audit module surface discoverable to verify retention configurability' });
528
+ }
529
+ }
530
+
531
+ return { ok: failures.length === 0, failures };
532
+ }
533
+
534
+ module.exports = {
535
+ checkEnvelope,
536
+ checkLoggingEnvelopes,
537
+ // Test surface (not part of the public contract):
538
+ _hasKey,
539
+ _typeOk,
540
+ _isPiiString,
541
+ _scanForPii,
542
+ _checkAppendOnlyImmutable,
543
+ _checkRetentionConfigurable,
544
+ _isValidOptOut,
545
+ _checkDefaultExceptOptOut,
546
+ _readModuleSurface,
547
+ _inspectSqliteStore,
548
+ };
549
+
550
+ // ── CLI (invoked by the verify gate as a Track 2 worker) ────────────────────
551
+
552
+ function _parseArgv(argv) {
553
+ const out = { projectDir: '.' };
554
+ for (let i = 0; i < argv.length; i++) {
555
+ const a = argv[i];
556
+ if (a === '--project') out.projectDir = argv[++i] || '.';
557
+ }
558
+ return out;
559
+ }
560
+
561
+ if (require.main === module) {
562
+ const args = _parseArgv(process.argv.slice(2));
563
+ const result = checkLoggingEnvelopes({ projectDir: args.projectDir });
564
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
565
+ process.exit(result.ok ? 0 : 1);
566
+ }