@git-stunts/git-warp 10.7.0 → 11.2.1

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.
Files changed (71) hide show
  1. package/README.md +53 -32
  2. package/SECURITY.md +64 -0
  3. package/bin/cli/commands/check.js +168 -0
  4. package/bin/cli/commands/doctor/checks.js +422 -0
  5. package/bin/cli/commands/doctor/codes.js +46 -0
  6. package/bin/cli/commands/doctor/index.js +239 -0
  7. package/bin/cli/commands/doctor/types.js +89 -0
  8. package/bin/cli/commands/history.js +73 -0
  9. package/bin/cli/commands/info.js +139 -0
  10. package/bin/cli/commands/install-hooks.js +128 -0
  11. package/bin/cli/commands/materialize.js +99 -0
  12. package/bin/cli/commands/path.js +88 -0
  13. package/bin/cli/commands/query.js +194 -0
  14. package/bin/cli/commands/registry.js +28 -0
  15. package/bin/cli/commands/seek.js +592 -0
  16. package/bin/cli/commands/trust.js +154 -0
  17. package/bin/cli/commands/verify-audit.js +113 -0
  18. package/bin/cli/commands/view.js +45 -0
  19. package/bin/cli/infrastructure.js +336 -0
  20. package/bin/cli/schemas.js +177 -0
  21. package/bin/cli/shared.js +244 -0
  22. package/bin/cli/types.js +85 -0
  23. package/bin/presenters/index.js +214 -0
  24. package/bin/presenters/json.js +66 -0
  25. package/bin/presenters/text.js +543 -0
  26. package/bin/warp-graph.js +19 -2824
  27. package/index.d.ts +32 -2
  28. package/index.js +2 -0
  29. package/package.json +9 -7
  30. package/src/domain/WarpGraph.js +106 -3252
  31. package/src/domain/errors/QueryError.js +2 -2
  32. package/src/domain/errors/TrustError.js +29 -0
  33. package/src/domain/errors/index.js +1 -0
  34. package/src/domain/services/AuditMessageCodec.js +137 -0
  35. package/src/domain/services/AuditReceiptService.js +471 -0
  36. package/src/domain/services/AuditVerifierService.js +693 -0
  37. package/src/domain/services/HttpSyncServer.js +36 -22
  38. package/src/domain/services/MessageCodecInternal.js +3 -0
  39. package/src/domain/services/MessageSchemaDetector.js +2 -2
  40. package/src/domain/services/SyncAuthService.js +69 -3
  41. package/src/domain/services/WarpMessageCodec.js +4 -1
  42. package/src/domain/trust/TrustCanonical.js +42 -0
  43. package/src/domain/trust/TrustCrypto.js +111 -0
  44. package/src/domain/trust/TrustEvaluator.js +180 -0
  45. package/src/domain/trust/TrustRecordService.js +274 -0
  46. package/src/domain/trust/TrustStateBuilder.js +209 -0
  47. package/src/domain/trust/canonical.js +68 -0
  48. package/src/domain/trust/reasonCodes.js +64 -0
  49. package/src/domain/trust/schemas.js +160 -0
  50. package/src/domain/trust/verdict.js +42 -0
  51. package/src/domain/types/git-cas.d.ts +20 -0
  52. package/src/domain/utils/RefLayout.js +59 -0
  53. package/src/domain/warp/PatchSession.js +18 -0
  54. package/src/domain/warp/Writer.js +18 -3
  55. package/src/domain/warp/_internal.js +26 -0
  56. package/src/domain/warp/_wire.js +58 -0
  57. package/src/domain/warp/_wiredMethods.d.ts +100 -0
  58. package/src/domain/warp/checkpoint.methods.js +397 -0
  59. package/src/domain/warp/fork.methods.js +323 -0
  60. package/src/domain/warp/materialize.methods.js +188 -0
  61. package/src/domain/warp/materializeAdvanced.methods.js +339 -0
  62. package/src/domain/warp/patch.methods.js +529 -0
  63. package/src/domain/warp/provenance.methods.js +284 -0
  64. package/src/domain/warp/query.methods.js +279 -0
  65. package/src/domain/warp/subscribe.methods.js +272 -0
  66. package/src/domain/warp/sync.methods.js +549 -0
  67. package/src/infrastructure/adapters/GitGraphAdapter.js +67 -1
  68. package/src/infrastructure/adapters/InMemoryGraphAdapter.js +36 -0
  69. package/src/ports/CommitPort.js +10 -0
  70. package/src/ports/RefPort.js +17 -0
  71. package/src/hooks/post-merge.sh +0 -60
@@ -11,8 +11,8 @@ import WarpError from './WarpError.js';
11
11
  *
12
12
  * | Code | Description |
13
13
  * |------|-------------|
14
- * | `E_NO_STATE` | No cached state available; call `materialize()` first |
15
- * | `E_STALE_STATE` | Cached state is outdated; call `materialize()` to refresh |
14
+ * | `E_NO_STATE` | No materialized state available; call `materialize()` or use `autoMaterialize: true` |
15
+ * | `E_STALE_STATE` | State is stale; call `materialize()` to refresh |
16
16
  * | `E_QUERY_MATCH_TYPE` | Invalid type passed to `match()` (expected string) |
17
17
  * | `E_QUERY_WHERE_TYPE` | Invalid type passed to `where()` (expected function or object) |
18
18
  * | `E_QUERY_WHERE_VALUE` | Non-primitive value in where() object shorthand |
@@ -0,0 +1,29 @@
1
+ import WarpError from './WarpError.js';
2
+
3
+ /**
4
+ * Error class for trust operations.
5
+ *
6
+ * ## Error Codes
7
+ *
8
+ * | Code | Description |
9
+ * |------|-------------|
10
+ * | `E_TRUST_UNSUPPORTED_ALGORITHM` | Algorithm is not `ed25519` |
11
+ * | `E_TRUST_INVALID_KEY` | Public key is malformed (wrong length or bad base64) |
12
+ * | `TRUST_ERROR` | Generic/default trust error |
13
+ *
14
+ * @class TrustError
15
+ * @extends WarpError
16
+ *
17
+ * @property {string} name - Always 'TrustError' for instanceof checks
18
+ * @property {string} code - Machine-readable error code for programmatic handling
19
+ * @property {Object} context - Serializable context object with error details
20
+ */
21
+ export default class TrustError extends WarpError {
22
+ /**
23
+ * @param {string} message
24
+ * @param {{ code?: string, context?: Object }} [options={}]
25
+ */
26
+ constructor(message, options = {}) {
27
+ super(message, 'TRUST_ERROR', options);
28
+ }
29
+ }
@@ -17,5 +17,6 @@ export { default as ShardValidationError } from './ShardValidationError.js';
17
17
  export { default as StorageError } from './StorageError.js';
18
18
  export { default as SchemaUnsupportedError } from './SchemaUnsupportedError.js';
19
19
  export { default as TraversalError } from './TraversalError.js';
20
+ export { default as TrustError } from './TrustError.js';
20
21
  export { default as WriterError } from './WriterError.js';
21
22
  export { default as WormholeError } from './WormholeError.js';
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Audit message encoding and decoding for WARP audit commit messages.
3
+ *
4
+ * Handles the 'audit' message type which records the outcome of materializing
5
+ * a data commit. See {@link module:domain/services/WarpMessageCodec} for the
6
+ * facade that re-exports all codec functions.
7
+ *
8
+ * @module domain/services/AuditMessageCodec
9
+ */
10
+
11
+ import { validateGraphName, validateWriterId } from '../utils/RefLayout.js';
12
+ import {
13
+ getCodec,
14
+ MESSAGE_TITLES,
15
+ TRAILER_KEYS,
16
+ validateOid,
17
+ validateSha256,
18
+ } from './MessageCodecInternal.js';
19
+
20
+ // -----------------------------------------------------------------------------
21
+ // Encoder
22
+ // -----------------------------------------------------------------------------
23
+
24
+ /**
25
+ * Encodes an audit commit message with trailers.
26
+ *
27
+ * @param {Object} options
28
+ * @param {string} options.graph - The graph name
29
+ * @param {string} options.writer - The writer ID
30
+ * @param {string} options.dataCommit - The OID of the data commit being audited
31
+ * @param {string} options.opsDigest - SHA-256 hex digest of the canonical ops JSON
32
+ * @returns {string} The encoded commit message
33
+ * @throws {Error} If any validation fails
34
+ */
35
+ export function encodeAuditMessage({ graph, writer, dataCommit, opsDigest }) {
36
+ validateGraphName(graph);
37
+ validateWriterId(writer);
38
+ validateOid(dataCommit, 'dataCommit');
39
+ validateSha256(opsDigest, 'opsDigest');
40
+
41
+ const codec = getCodec();
42
+ return codec.encode({
43
+ title: MESSAGE_TITLES.audit,
44
+ trailers: {
45
+ [TRAILER_KEYS.dataCommit]: dataCommit,
46
+ [TRAILER_KEYS.graph]: graph,
47
+ [TRAILER_KEYS.kind]: 'audit',
48
+ [TRAILER_KEYS.opsDigest]: opsDigest,
49
+ [TRAILER_KEYS.schema]: '1',
50
+ [TRAILER_KEYS.writer]: writer,
51
+ },
52
+ });
53
+ }
54
+
55
+ // -----------------------------------------------------------------------------
56
+ // Decoder
57
+ // -----------------------------------------------------------------------------
58
+
59
+ /**
60
+ * Decodes an audit commit message.
61
+ *
62
+ * @param {string} message - The raw commit message
63
+ * @returns {{ kind: 'audit', graph: string, writer: string, dataCommit: string, opsDigest: string, schema: number }}
64
+ * @throws {Error} If the message is not a valid audit message
65
+ */
66
+ export function decodeAuditMessage(message) {
67
+ const codec = getCodec();
68
+ const decoded = codec.decode(message);
69
+ const { trailers } = decoded;
70
+
71
+ // Check for duplicate trailers (strict decode)
72
+ const keys = Object.keys(trailers);
73
+ const seen = new Set();
74
+ for (const key of keys) {
75
+ if (seen.has(key)) {
76
+ throw new Error(`Duplicate trailer rejected: ${key}`);
77
+ }
78
+ seen.add(key);
79
+ }
80
+
81
+ // Validate kind discriminator
82
+ const kind = trailers[TRAILER_KEYS.kind];
83
+ if (kind !== 'audit') {
84
+ throw new Error(`Invalid audit message: eg-kind must be 'audit', got '${kind}'`);
85
+ }
86
+
87
+ // Extract and validate required fields
88
+ const graph = trailers[TRAILER_KEYS.graph];
89
+ if (!graph) {
90
+ throw new Error('Invalid audit message: missing required trailer eg-graph');
91
+ }
92
+ validateGraphName(graph);
93
+
94
+ const writer = trailers[TRAILER_KEYS.writer];
95
+ if (!writer) {
96
+ throw new Error('Invalid audit message: missing required trailer eg-writer');
97
+ }
98
+ validateWriterId(writer);
99
+
100
+ const dataCommit = trailers[TRAILER_KEYS.dataCommit];
101
+ if (!dataCommit) {
102
+ throw new Error('Invalid audit message: missing required trailer eg-data-commit');
103
+ }
104
+ validateOid(dataCommit, 'dataCommit');
105
+
106
+ const opsDigest = trailers[TRAILER_KEYS.opsDigest];
107
+ if (!opsDigest) {
108
+ throw new Error('Invalid audit message: missing required trailer eg-ops-digest');
109
+ }
110
+ validateSha256(opsDigest, 'opsDigest');
111
+
112
+ const schemaStr = trailers[TRAILER_KEYS.schema];
113
+ if (!schemaStr) {
114
+ throw new Error('Invalid audit message: missing required trailer eg-schema');
115
+ }
116
+ if (!/^\d+$/.test(schemaStr)) {
117
+ throw new Error(
118
+ `Invalid audit message: eg-schema must be a positive integer, got '${schemaStr}'`,
119
+ );
120
+ }
121
+ const schema = Number(schemaStr);
122
+ if (!Number.isInteger(schema) || schema < 1) {
123
+ throw new Error(`Invalid audit message: eg-schema must be a positive integer, got '${schemaStr}'`);
124
+ }
125
+ if (schema > 1) {
126
+ throw new Error(`Unsupported audit schema version: ${schema}`);
127
+ }
128
+
129
+ return {
130
+ kind: 'audit',
131
+ graph,
132
+ writer,
133
+ dataCommit,
134
+ opsDigest,
135
+ schema,
136
+ };
137
+ }
@@ -0,0 +1,471 @@
1
+ /**
2
+ * AuditReceiptService — persistent, chained, tamper-evident audit receipts.
3
+ *
4
+ * When audit mode is enabled, each data commit produces a corresponding
5
+ * audit commit recording per-operation outcomes. Audit commits form an
6
+ * independent chain per (graphName, writerId) pair, linked via
7
+ * `prevAuditCommit` and Git commit parents.
8
+ *
9
+ * @module domain/services/AuditReceiptService
10
+ * @see docs/specs/AUDIT_RECEIPT.md
11
+ */
12
+
13
+ import { buildAuditRef } from '../utils/RefLayout.js';
14
+ import { encodeAuditMessage } from './AuditMessageCodec.js';
15
+
16
+ // ============================================================================
17
+ // Constants
18
+ // ============================================================================
19
+
20
+ /**
21
+ * Domain-separated prefix for opsDigest computation.
22
+ * The trailing \0 is a literal null byte (U+0000) acting as an
23
+ * unambiguous delimiter between the prefix and the JSON payload.
24
+ * @type {string}
25
+ */
26
+ export const OPS_DIGEST_PREFIX = 'git-warp:opsDigest:v1\0';
27
+
28
+ // ============================================================================
29
+ // Normative Canonicalization Helpers (DO NOT ALTER — tied to spec Sections 5.2-5.3)
30
+ // ============================================================================
31
+
32
+ /**
33
+ * JSON.stringify replacer that sorts object keys lexicographically
34
+ * at every nesting level. Produces canonical JSON per spec Section 5.2.
35
+ *
36
+ * @param {string} _key
37
+ * @param {unknown} value
38
+ * @returns {unknown}
39
+ */
40
+ export function sortedReplacer(_key, value) {
41
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
42
+ const sorted = /** @type {Record<string, unknown>} */ ({});
43
+ const obj = /** @type {Record<string, unknown>} */ (value);
44
+ for (const k of Object.keys(obj).sort()) {
45
+ sorted[k] = obj[k];
46
+ }
47
+ return sorted;
48
+ }
49
+ return value;
50
+ }
51
+
52
+ /**
53
+ * Produces canonical JSON string of an ops array per spec Section 5.2.
54
+ * Exported for testing.
55
+ *
56
+ * @param {ReadonlyArray<Readonly<import('../types/TickReceipt.js').OpOutcome>>} ops
57
+ * @returns {string}
58
+ */
59
+ export function canonicalOpsJson(ops) {
60
+ return JSON.stringify(ops, sortedReplacer);
61
+ }
62
+
63
+ /** @type {TextEncoder} */
64
+ const textEncoder = new TextEncoder();
65
+
66
+ /**
67
+ * Computes the domain-separated SHA-256 opsDigest per spec Section 5.3.
68
+ *
69
+ * @param {ReadonlyArray<Readonly<import('../types/TickReceipt.js').OpOutcome>>} ops
70
+ * @param {import('../../ports/CryptoPort.js').default} crypto - Crypto adapter
71
+ * @returns {Promise<string>} Lowercase hex SHA-256 digest
72
+ */
73
+ export async function computeOpsDigest(ops, crypto) {
74
+ const json = canonicalOpsJson(ops);
75
+ const prefix = textEncoder.encode(OPS_DIGEST_PREFIX);
76
+ const payload = textEncoder.encode(json);
77
+ const combined = new Uint8Array(prefix.length + payload.length);
78
+ combined.set(prefix);
79
+ combined.set(payload, prefix.length);
80
+ return await crypto.hash('sha256', combined);
81
+ }
82
+
83
+ // ============================================================================
84
+ // Receipt Construction
85
+ // ============================================================================
86
+
87
+ /** @type {RegExp} */
88
+ const OID_HEX_PATTERN = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
89
+
90
+ /**
91
+ * Validates and builds a frozen receipt record with keys in sorted order.
92
+ *
93
+ * @param {Object} fields
94
+ * @param {number} fields.version
95
+ * @param {string} fields.graphName
96
+ * @param {string} fields.writerId
97
+ * @param {string} fields.dataCommit
98
+ * @param {number} fields.tickStart
99
+ * @param {number} fields.tickEnd
100
+ * @param {string} fields.opsDigest
101
+ * @param {string} fields.prevAuditCommit
102
+ * @param {number} fields.timestamp
103
+ * @returns {Readonly<Record<string, unknown>>}
104
+ * @throws {Error} If any field is invalid
105
+ */
106
+ export function buildReceiptRecord(fields) {
107
+ const {
108
+ version, graphName, writerId, dataCommit,
109
+ tickStart, tickEnd, opsDigest, prevAuditCommit, timestamp,
110
+ } = fields;
111
+
112
+ // version
113
+ if (version !== 1) {
114
+ throw new Error(`Invalid version: must be 1, got ${version}`);
115
+ }
116
+
117
+ // graphName — validated by RefLayout
118
+ if (typeof graphName !== 'string' || graphName.length === 0) {
119
+ throw new Error('Invalid graphName: must be a non-empty string');
120
+ }
121
+
122
+ // writerId — validated by RefLayout
123
+ if (typeof writerId !== 'string' || writerId.length === 0) {
124
+ throw new Error('Invalid writerId: must be a non-empty string');
125
+ }
126
+
127
+ // dataCommit
128
+ const dc = dataCommit.toLowerCase();
129
+ if (!OID_HEX_PATTERN.test(dc)) {
130
+ throw new Error(`Invalid dataCommit OID: ${dataCommit}`);
131
+ }
132
+
133
+ // opsDigest
134
+ const od = opsDigest.toLowerCase();
135
+ if (!/^[0-9a-f]{64}$/.test(od)) {
136
+ throw new Error(`Invalid opsDigest: must be 64-char lowercase hex, got ${opsDigest}`);
137
+ }
138
+
139
+ // prevAuditCommit
140
+ const pac = prevAuditCommit.toLowerCase();
141
+ if (!OID_HEX_PATTERN.test(pac)) {
142
+ throw new Error(`Invalid prevAuditCommit OID: ${prevAuditCommit}`);
143
+ }
144
+
145
+ // OID length consistency
146
+ const oidLen = dc.length;
147
+ if (pac.length !== oidLen) {
148
+ throw new Error(`OID length mismatch: dataCommit=${dc.length}, prevAuditCommit=${pac.length}`);
149
+ }
150
+
151
+ // tick constraints
152
+ if (!Number.isInteger(tickStart) || tickStart < 1) {
153
+ throw new Error(`Invalid tickStart: must be integer >= 1, got ${tickStart}`);
154
+ }
155
+ if (!Number.isInteger(tickEnd) || tickEnd < tickStart) {
156
+ throw new Error(`Invalid tickEnd: must be integer >= tickStart, got ${tickEnd}`);
157
+ }
158
+ if (version === 1 && tickStart !== tickEnd) {
159
+ throw new Error(`v1 requires tickStart === tickEnd, got ${tickStart} !== ${tickEnd}`);
160
+ }
161
+
162
+ // Zero-hash sentinel only for genesis (tickStart === 1)
163
+ const zeroHash = '0'.repeat(oidLen);
164
+ if (pac === zeroHash && tickStart > 1) {
165
+ throw new Error('Non-genesis receipt cannot use zero-hash sentinel');
166
+ }
167
+
168
+ // timestamp
169
+ if (!Number.isInteger(timestamp) || timestamp < 0) {
170
+ throw new Error(`Invalid timestamp: must be non-negative safe integer, got ${timestamp}`);
171
+ }
172
+ if (!Number.isSafeInteger(timestamp)) {
173
+ throw new Error(`Invalid timestamp: exceeds Number.MAX_SAFE_INTEGER: ${timestamp}`);
174
+ }
175
+
176
+ // Build with keys in sorted order (canonical for CBOR)
177
+ return Object.freeze({
178
+ dataCommit: dc,
179
+ graphName,
180
+ opsDigest: od,
181
+ prevAuditCommit: pac,
182
+ tickEnd,
183
+ tickStart,
184
+ timestamp,
185
+ version,
186
+ writerId,
187
+ });
188
+ }
189
+
190
+ // ============================================================================
191
+ // Service
192
+ // ============================================================================
193
+
194
+ /**
195
+ * AuditReceiptService manages the audit receipt chain for a single writer.
196
+ *
197
+ * ## Lifecycle
198
+ * 1. Construct with dependencies
199
+ * 2. Call `init()` to read the current audit ref tip
200
+ * 3. Call `commit(tickReceipt)` after each data commit succeeds
201
+ *
202
+ * ## Error handling
203
+ * All errors are caught, logged with structured codes, and never propagated.
204
+ * The data commit has already succeeded — audit failures create gaps that
205
+ * are detectable by M4 verification.
206
+ */
207
+ export class AuditReceiptService {
208
+ /**
209
+ * @param {Object} options
210
+ * @param {import('../../ports/RefPort.js').default & import('../../ports/BlobPort.js').default & import('../../ports/TreePort.js').default & import('../../ports/CommitPort.js').default} options.persistence
211
+ * @param {string} options.graphName
212
+ * @param {string} options.writerId
213
+ * @param {import('../../ports/CodecPort.js').default} options.codec
214
+ * @param {import('../../ports/CryptoPort.js').default} options.crypto
215
+ * @param {import('../../ports/LoggerPort.js').default} [options.logger]
216
+ */
217
+ constructor({ persistence, graphName, writerId, codec, crypto, logger }) {
218
+ this._persistence = persistence;
219
+ this._graphName = graphName;
220
+ this._writerId = writerId;
221
+ this._codec = codec;
222
+ this._crypto = crypto;
223
+ this._logger = logger || null;
224
+ this._auditRef = buildAuditRef(graphName, writerId);
225
+
226
+ /** @type {string|null} Previous audit commit SHA (null = genesis) */
227
+ this._prevAuditCommit = null;
228
+
229
+ /** @type {string|null} Expected old ref value for CAS (null = ref doesn't exist) */
230
+ this._expectedOldRef = null;
231
+
232
+ /** @type {boolean} If true, service is degraded — skip all commits */
233
+ this._degraded = false;
234
+
235
+ /** @type {boolean} If true, currently retrying — prevents recursive retry */
236
+ this._retrying = false;
237
+
238
+ // Stats
239
+ this._committed = 0;
240
+ this._skipped = 0;
241
+ this._failed = 0;
242
+ }
243
+
244
+ /**
245
+ * Initializes the service by reading the current audit ref tip.
246
+ * Must be called before `commit()`.
247
+ * @returns {Promise<void>}
248
+ */
249
+ async init() {
250
+ try {
251
+ const tip = await this._persistence.readRef(this._auditRef);
252
+ if (tip) {
253
+ this._prevAuditCommit = tip;
254
+ this._expectedOldRef = tip;
255
+ // We don't know the tick counter from a cold start without walking the chain.
256
+ // Use 0 and let the first commit set it from the lamport clock.
257
+ }
258
+ } catch {
259
+ // Log so operators see unexpected cold starts, then start fresh
260
+ this._logger?.warn('[warp:audit]', {
261
+ code: 'AUDIT_INIT_READ_FAILED',
262
+ writerId: this._writerId,
263
+ ref: this._auditRef,
264
+ });
265
+ this._prevAuditCommit = null;
266
+ this._expectedOldRef = null;
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Creates an audit commit for the given tick receipt.
272
+ *
273
+ * DESIGN NOTE: Data commit has already succeeded at this point.
274
+ * If audit commit fails, the data is persisted but the audit chain
275
+ * has a gap. This is acceptable by design in M3 — gaps are detected
276
+ * by M4 verification coverage rules (receipt count vs data commit count).
277
+ *
278
+ * @param {import('../types/TickReceipt.js').TickReceipt} tickReceipt
279
+ * @returns {Promise<string|null>} The audit commit SHA, or null on failure
280
+ */
281
+ async commit(tickReceipt) {
282
+ if (this._degraded) {
283
+ this._skipped++;
284
+ this._logger?.warn('[warp:audit]', {
285
+ code: 'AUDIT_DEGRADED_ACTIVE',
286
+ writerId: this._writerId,
287
+ });
288
+ return null;
289
+ }
290
+
291
+ try {
292
+ return await this._commitInner(tickReceipt);
293
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): narrow catch type
294
+ this._failed++;
295
+ this._logger?.warn('[warp:audit]', {
296
+ code: 'AUDIT_COMMIT_FAILED',
297
+ writerId: this._writerId,
298
+ error: err?.message,
299
+ });
300
+ return null;
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Returns audit stats for coverage probing.
306
+ * @returns {{ committed: number, skipped: number, failed: number, degraded: boolean }}
307
+ */
308
+ getStats() {
309
+ return {
310
+ committed: this._committed,
311
+ skipped: this._skipped,
312
+ failed: this._failed,
313
+ degraded: this._degraded,
314
+ };
315
+ }
316
+
317
+ /**
318
+ * Inner commit logic. Throws on failure (caught by `commit()`).
319
+ * @param {import('../types/TickReceipt.js').TickReceipt} tickReceipt
320
+ * @returns {Promise<string>}
321
+ * @private
322
+ */
323
+ async _commitInner(tickReceipt) {
324
+ const { patchSha, writer, lamport, ops } = tickReceipt;
325
+
326
+ // Guard: reject cross-writer attribution
327
+ if (writer !== this._writerId) {
328
+ this._logger?.warn('[warp:audit]', {
329
+ code: 'AUDIT_WRITER_MISMATCH',
330
+ expected: this._writerId,
331
+ actual: writer,
332
+ patchSha,
333
+ });
334
+ throw new Error(
335
+ `Audit writer mismatch: expected '${this._writerId}', got '${writer}'`,
336
+ );
337
+ }
338
+
339
+ // Compute opsDigest
340
+ const opsDigest = await computeOpsDigest(ops, this._crypto);
341
+
342
+ // Timestamp
343
+ const timestamp = Date.now();
344
+
345
+ // Determine prevAuditCommit
346
+ const oidLen = patchSha.length;
347
+ const prevAuditCommit = this._prevAuditCommit || '0'.repeat(oidLen);
348
+
349
+ // Build receipt record
350
+ const receipt = buildReceiptRecord({
351
+ version: 1,
352
+ graphName: this._graphName,
353
+ writerId: writer,
354
+ dataCommit: patchSha,
355
+ tickStart: lamport,
356
+ tickEnd: lamport,
357
+ opsDigest,
358
+ prevAuditCommit,
359
+ timestamp,
360
+ });
361
+
362
+ // Encode to CBOR
363
+ const cborBytes = this._codec.encode(receipt);
364
+
365
+ // Write blob
366
+ let blobOid;
367
+ try {
368
+ blobOid = await this._persistence.writeBlob(Buffer.from(cborBytes));
369
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): narrow catch type
370
+ this._logger?.warn('[warp:audit]', {
371
+ code: 'AUDIT_WRITE_BLOB_FAILED',
372
+ writerId: this._writerId,
373
+ error: err?.message,
374
+ });
375
+ throw err;
376
+ }
377
+
378
+ // Write tree
379
+ let treeOid;
380
+ try {
381
+ treeOid = await this._persistence.writeTree([
382
+ `100644 blob ${blobOid}\treceipt.cbor`,
383
+ ]);
384
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): narrow catch type
385
+ this._logger?.warn('[warp:audit]', {
386
+ code: 'AUDIT_WRITE_TREE_FAILED',
387
+ writerId: this._writerId,
388
+ error: err?.message,
389
+ });
390
+ throw err;
391
+ }
392
+
393
+ // Encode commit message with trailers
394
+ const message = encodeAuditMessage({
395
+ graph: this._graphName,
396
+ writer,
397
+ dataCommit: patchSha.toLowerCase(),
398
+ opsDigest,
399
+ });
400
+
401
+ // Determine parents
402
+ const parents = this._prevAuditCommit ? [this._prevAuditCommit] : [];
403
+
404
+ // Create commit
405
+ const commitSha = await this._persistence.commitNodeWithTree({
406
+ treeOid,
407
+ parents,
408
+ message,
409
+ });
410
+
411
+ // CAS ref update
412
+ try {
413
+ await this._persistence.compareAndSwapRef(
414
+ this._auditRef,
415
+ commitSha,
416
+ this._expectedOldRef,
417
+ );
418
+ } catch {
419
+ if (this._retrying) {
420
+ // Second CAS failure during retry → degrade
421
+ throw new Error('CAS failed during retry');
422
+ }
423
+ // CAS mismatch — retry once with refreshed tip
424
+ return await this._retryAfterCasConflict(commitSha, tickReceipt);
425
+ }
426
+
427
+ // Success — update cached state
428
+ this._prevAuditCommit = commitSha;
429
+ this._expectedOldRef = commitSha;
430
+ this._committed++;
431
+ return commitSha;
432
+ }
433
+
434
+ /**
435
+ * Retry-once after CAS conflict. Reads fresh tip, rebuilds receipt, retries.
436
+ * @param {string} _failedCommitSha - The commit that failed CAS (unused, for logging)
437
+ * @param {import('../types/TickReceipt.js').TickReceipt} tickReceipt
438
+ * @returns {Promise<string>}
439
+ * @private
440
+ */
441
+ async _retryAfterCasConflict(_failedCommitSha, tickReceipt) {
442
+ this._logger?.warn('[warp:audit]', {
443
+ code: 'AUDIT_REF_CAS_CONFLICT',
444
+ writerId: this._writerId,
445
+ ref: this._auditRef,
446
+ });
447
+
448
+ // Read fresh tip
449
+ const freshTip = await this._persistence.readRef(this._auditRef);
450
+ this._prevAuditCommit = freshTip;
451
+ this._expectedOldRef = freshTip;
452
+
453
+ // Rebuild and retry (with guard against recursive retry)
454
+ this._retrying = true;
455
+ try {
456
+ const result = await this._commitInner(tickReceipt);
457
+ return result;
458
+ } catch {
459
+ // Second failure → degraded mode
460
+ this._degraded = true;
461
+ this._logger?.warn('[warp:audit]', {
462
+ code: 'AUDIT_DEGRADED_ACTIVE',
463
+ writerId: this._writerId,
464
+ reason: 'second CAS failure',
465
+ });
466
+ throw new Error('Audit service degraded after second CAS failure');
467
+ } finally {
468
+ this._retrying = false;
469
+ }
470
+ }
471
+ }