@gregiteen/ssss-cli 0.9.2 → 0.9.4

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/src/http.mjs CHANGED
@@ -1,5 +1,24 @@
1
1
  /** Reference transport adapters. HTTP is a façade over kernel.execute, never a writer. */
2
2
 
3
+ /** Canonical HTTP status for each symbolic failure code (spec §6.5). */
4
+ export const ERROR_STATUS = Object.freeze({
5
+ invalid_request: 400,
6
+ unauthorized: 401,
7
+ forbidden: 403,
8
+ not_found: 404,
9
+ lease_conflict: 409,
10
+ version_conflict: 409,
11
+ idempotency_conflict: 409,
12
+ validation_failed: 422,
13
+ internal_error: 500,
14
+ });
15
+
16
+ /** Map a kernel response to its HTTP status: 201 commit, 200 replay/dry run, else by error code. */
17
+ export function statusForResponse(response) {
18
+ if (response?.success) return response.replay || response.dry_run ? 200 : 201;
19
+ return ERROR_STATUS[response?.error?.code] || 500;
20
+ }
21
+
3
22
  export function createCommandHandler({ kernel, authenticate, parseBody = async (request) => request.json() }) {
4
23
  if (!kernel?.execute || typeof authenticate !== 'function') throw new Error('kernel and authenticate(request) are required.');
5
24
  return async function handle(request) {
@@ -7,10 +26,7 @@ export function createCommandHandler({ kernel, authenticate, parseBody = async (
7
26
  const principal = await authenticate(request);
8
27
  const envelope = await parseBody(request);
9
28
  const response = await kernel.execute(envelope, { principal, request });
10
- const status = response.success ? (response.replay ? 200 : response.dry_run ? 200 : 201) :
11
- response.validation?.errors?.some((error) => error.includes('Access denied')) ? 403 :
12
- response.validation?.errors?.some((error) => error.includes('Idempotency')) ? 409 : 422;
13
- return { status, headers: { 'content-type': 'application/json' }, body: response };
29
+ return { status: statusForResponse(response), headers: { 'content-type': 'application/json' }, body: response };
14
30
  } catch (error) {
15
31
  return { status: 400, headers: { 'content-type': 'application/json' }, body: { success: false, error: { code: 'invalid_request', message: error.message } } };
16
32
  }
package/src/kernel.mjs CHANGED
@@ -5,15 +5,26 @@
5
5
  import crypto from 'node:crypto';
6
6
  import { parseDocument, serializeDocument } from './frontmatter.mjs';
7
7
  import { createValidator } from './validator.mjs';
8
- import { isAppendType, resolvePrimitiveDefinition } from './registry.mjs';
9
- import { createCapabilityAuthorizer } from './authorization.mjs';
10
- import { createCanonicalEvent } from './events.mjs';
8
+ import { isAppendType, isSafeDocumentPath, resolvePrimitiveDefinition } from './registry.mjs';
9
+ import { createCapabilityAuthorizer, validateVerifiedPrincipal } from './authorization.mjs';
10
+ import { createCanonicalEvent, idempotentEventId } from './events.mjs';
11
11
  import { MemoryIdempotencyStore } from './idempotency.mjs';
12
12
 
13
13
  export { MemoryIdempotencyStore } from './idempotency.mjs';
14
14
 
15
15
  const COMMAND_TYPES = new Set(['operation', 'patch', 'event', 'delete']);
16
16
 
17
+ /**
18
+ * Symbolic failure codes (spec §6.5). Every failed response carries exactly one
19
+ * in `error.code`; transports map codes to status without parsing messages.
20
+ */
21
+ export const ERROR_CODES = Object.freeze([
22
+ 'invalid_request', 'unauthorized', 'forbidden', 'not_found', 'lease_conflict',
23
+ 'version_conflict', 'idempotency_conflict', 'validation_failed', 'internal_error',
24
+ ]);
25
+
26
+ function isVfsError(error, name, code) { return error?.name === name || error?.code === code; }
27
+
17
28
  function stable(value) {
18
29
  if (Array.isArray(value)) return value.map(stable);
19
30
  if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
@@ -34,9 +45,10 @@ export function canonicalRequestHash(envelope, principal) {
34
45
  return `sha256:${crypto.createHash('sha256').update(JSON.stringify(stable(clean))).digest('hex')}`;
35
46
  }
36
47
 
37
- function failure(envelope, operationId, errors, fieldErrors = []) {
48
+ function failure(code, envelope, operationId, errors, fieldErrors = []) {
38
49
  return {
39
50
  success: false,
51
+ error: { code, message: errors[0] || code },
40
52
  type: envelope?.type || null,
41
53
  operation_id: operationId,
42
54
  path: envelope?.path || '',
@@ -70,6 +82,29 @@ export function createKernel(options = {}) {
70
82
  const validator = options.validator || createValidator(options);
71
83
  const registrySet = validator.registry;
72
84
 
85
+ // Registry `references` are enforced on every write (spec §9). A host may
86
+ // supply its own resolver; otherwise targets are read through the VFS.
87
+ async function referenceResolver(definition, data, context) {
88
+ if (context.resolveReference) return context.resolveReference;
89
+ const fields = Object.keys(definition?.references || {});
90
+ if (!fields.length || !data) return undefined;
91
+ const found = new Map();
92
+ for (const field of fields) {
93
+ const target = data[field];
94
+ if (!isSafeDocumentPath(target) || found.has(target)) continue;
95
+ let entry = null;
96
+ try {
97
+ const file = await vfs.read(target);
98
+ if (file) {
99
+ const content = file.bytes.toString('utf8');
100
+ entry = { content, data: parseDocument(content).data };
101
+ }
102
+ } catch {}
103
+ found.set(target, entry);
104
+ }
105
+ return (target) => found.get(target) || null;
106
+ }
107
+
73
108
  async function execute(rawEnvelope, context = {}) {
74
109
  const envelope = { ...rawEnvelope };
75
110
  delete envelope.actor;
@@ -77,32 +112,45 @@ export function createKernel(options = {}) {
77
112
  const principal = context.principal;
78
113
 
79
114
  if (!COMMAND_TYPES.has(envelope.type)) {
80
- return failure(envelope, operationId, [`Invalid command type '${envelope.type}'.`], [{ field: 'type', issue: 'Use operation, patch, event, or delete.' }]);
115
+ return failure('invalid_request', envelope, operationId, [`Invalid command type '${envelope.type}'.`], [{ field: 'type', issue: 'Use operation, patch, event, or delete.' }]);
81
116
  }
82
117
  for (const field of ['workspace_id', 'idempotency_key', 'path']) {
83
118
  if (typeof envelope[field] !== 'string' || !envelope[field]) {
84
- return failure(envelope, operationId, [`Missing required field '${field}'.`], [{ field, issue: 'Required.' }]);
119
+ return failure('invalid_request', envelope, operationId, [`Missing required field '${field}'.`], [{ field, issue: 'Required.' }]);
85
120
  }
86
121
  }
87
122
  if (envelope.type === 'operation' && typeof envelope.content !== 'string') {
88
- return failure(envelope, operationId, ['Operation content must be a string.'], [{ field: 'content', issue: 'Required string.' }]);
123
+ return failure('invalid_request', envelope, operationId, ['Operation content must be a string.'], [{ field: 'content', issue: 'Required string.' }]);
89
124
  }
90
125
  if (envelope.type === 'patch' && (!envelope.patches || typeof envelope.patches !== 'object')) {
91
- return failure(envelope, operationId, ['Patch requires a patches object.'], [{ field: 'patches', issue: 'Required object.' }]);
126
+ return failure('invalid_request', envelope, operationId, ['Patch requires a patches object.'], [{ field: 'patches', issue: 'Required object.' }]);
127
+ }
128
+
129
+ // Authenticate before touching storage, so an anonymous caller learns
130
+ // nothing about the vault. The id and kind are what every event records.
131
+ if (!principal || typeof principal.id !== 'string' || !principal.id || typeof principal.kind !== 'string' || !principal.kind) {
132
+ return failure('unauthorized', envelope, operationId, ['Access denied: Verified principal is required.'], [{ field: 'principal', issue: 'Verified principal is required.' }]);
92
133
  }
93
134
 
94
135
  const requestHash = canonicalRequestHash(envelope, principal);
95
- const replay = await idempotencyStore.get(envelope.workspace_id, envelope.idempotency_key);
96
- if (replay) {
97
- if (replay.request_hash !== requestHash) {
98
- return failure(envelope, operationId, ['Idempotency key was already used for a different request.'], [{ field: 'idempotency_key', issue: 'Request hash conflict.' }]);
136
+ // A stored result for this key: replay it, or refuse a different request.
137
+ const settled = async () => {
138
+ const stored = await idempotencyStore.get(envelope.workspace_id, envelope.idempotency_key);
139
+ if (!stored) return null;
140
+ if (stored.request_hash !== requestHash) {
141
+ return failure('idempotency_conflict', envelope, operationId, ['Idempotency key was already used for a different request.'], [{ field: 'idempotency_key', issue: 'Request hash conflict.' }]);
99
142
  }
100
- return { ...replay.response, replay: true };
101
- }
143
+ return { ...stored.response, replay: true };
144
+ };
145
+ const replay = await settled();
146
+ if (replay) return replay;
102
147
 
103
148
  let current;
104
149
  try { current = await vfs.read(envelope.path); }
105
- catch (error) { return failure(envelope, operationId, [error.message], [{ field: 'path', issue: error.message }]); }
150
+ catch (error) {
151
+ const code = isVfsError(error, 'VfsPathError', 'SSSS_VFS_PATH') ? 'invalid_request' : 'internal_error';
152
+ return failure(code, envelope, operationId, [error.message], [{ field: 'path', issue: error.message }]);
153
+ }
106
154
 
107
155
  let definition = null;
108
156
  let declaredType = envelope.primitive_type || null;
@@ -112,7 +160,7 @@ export function createKernel(options = {}) {
112
160
 
113
161
  if (current) {
114
162
  try { currentData = parseDocument(current.bytes.toString('utf8')).data; }
115
- catch (error) { return failure(envelope, operationId, [`Existing document is invalid: ${error.message}`]); }
163
+ catch (error) { return failure('internal_error', envelope, operationId, [`Existing document is invalid: ${error.message}`]); }
116
164
  declaredType ||= currentData.type;
117
165
  }
118
166
  if (envelope.type === 'operation') {
@@ -122,27 +170,35 @@ export function createKernel(options = {}) {
122
170
  nextContent = envelope.content;
123
171
  nextData = {};
124
172
  } else {
125
- const validation = validator.validateDocument(envelope.content, { resolveReference: context.resolveReference });
126
- if (!validation.valid) return failure(envelope, operationId, validation.errors, validation.field_errors);
173
+ let draft = null;
174
+ try { draft = parseDocument(envelope.content).data; } catch {}
175
+ const draftDefinition = draft && resolvePrimitiveDefinition(registrySet, draft.type);
176
+ const resolveReference = await referenceResolver(draftDefinition, draft, context);
177
+ const validation = validator.validateDocument(envelope.content, { resolveReference });
178
+ if (!validation.valid) return failure('validation_failed', envelope, operationId, validation.errors, validation.field_errors);
127
179
  declaredType = validation.declared_type;
128
180
  definition = resolvePrimitiveDefinition(registrySet, declaredType);
129
181
  nextContent = envelope.content;
130
182
  nextData = validation.data;
131
183
  }
132
184
  if (currentData?.type && currentData.type !== nextData.type) {
133
- return failure(envelope, operationId, ['Operation may not change an existing document type.'], [{ field: 'type', issue: 'Use an explicit migration.' }]);
185
+ return failure('validation_failed', envelope, operationId, ['Operation may not change an existing document type.'], [{ field: 'type', issue: 'Use an explicit migration.' }]);
186
+ }
187
+ // A full replace would rewrite recorded history (spec §5.3).
188
+ if (current && isAppendType(definition)) {
189
+ return failure('validation_failed', envelope, operationId, [`Append-only primitive '${declaredType}' cannot be replaced.`], [{ field: 'content', issue: 'Append with a patch carrying __body__.' }]);
134
190
  }
135
191
  } else if (envelope.type === 'patch') {
136
- if (!current) return failure(envelope, operationId, ['Patch target does not exist.'], [{ field: 'path', issue: 'Target not found.' }]);
192
+ if (!current) return failure('not_found', envelope, operationId, ['Patch target does not exist.'], [{ field: 'path', issue: 'Target not found.' }]);
137
193
  definition = resolvePrimitiveDefinition(registrySet, declaredType);
138
- if (!definition) return failure(envelope, operationId, [`Unknown SSSS primitive '${declaredType}'.`], [{ field: 'type', issue: 'Unknown primitive.' }]);
194
+ if (!definition) return failure('validation_failed', envelope, operationId, [`Unknown SSSS primitive '${declaredType}'.`], [{ field: 'type', issue: 'Unknown primitive.' }]);
139
195
  if (Object.hasOwn(envelope.patches, 'type') && envelope.patches.type !== currentData.type) {
140
- return failure(envelope, operationId, ['Patch may not change document type.'], [{ field: 'type', issue: 'Use an explicit migration.' }]);
196
+ return failure('validation_failed', envelope, operationId, ['Patch may not change document type.'], [{ field: 'type', issue: 'Use an explicit migration.' }]);
141
197
  }
142
198
  nextData = { ...currentData, ...Object.fromEntries(Object.entries(envelope.patches).filter(([key]) => key !== '__body__')) };
143
199
  for (const field of definition.immutable_fields || []) {
144
200
  if (Object.hasOwn(envelope.patches, field) && JSON.stringify(currentData[field]) !== JSON.stringify(nextData[field])) {
145
- return failure(envelope, operationId, [`Patch may not change immutable field '${field}'.`], [{ field, issue: 'Use an explicit migration.' }]);
201
+ return failure('validation_failed', envelope, operationId, [`Patch may not change immutable field '${field}'.`], [{ field, issue: 'Use an explicit migration.' }]);
146
202
  }
147
203
  }
148
204
  const parsedCurrent = parseDocument(current.bytes.toString('utf8'));
@@ -151,24 +207,29 @@ export function createKernel(options = {}) {
151
207
  if (envelope.patches.__body__ !== undefined) {
152
208
  body = appendOnly ? `${body.replace(/\s+$/, '')}\n${envelope.patches.__body__}` : String(envelope.patches.__body__);
153
209
  }
154
- const validation = validator.validateData(nextData, { resolveReference: context.resolveReference });
155
- if (!validation.valid) return failure(envelope, operationId, validation.errors, validation.field_errors);
210
+ const resolveReference = await referenceResolver(definition, nextData, context);
211
+ const validation = validator.validateData(nextData, { resolveReference });
212
+ if (!validation.valid) return failure('validation_failed', envelope, operationId, validation.errors, validation.field_errors);
156
213
  nextContent = serializeDocument(nextData, body);
157
214
  } else if (envelope.type === 'delete') {
158
- if (!current) return failure(envelope, operationId, ['Delete target does not exist.'], [{ field: 'path', issue: 'Target not found.' }]);
215
+ if (!current) return failure('not_found', envelope, operationId, ['Delete target does not exist.'], [{ field: 'path', issue: 'Target not found.' }]);
159
216
  definition = resolvePrimitiveDefinition(registrySet, declaredType);
160
- if (!definition) return failure(envelope, operationId, [`Unknown SSSS primitive '${declaredType}'.`]);
161
- if (isAppendType(definition)) return failure(envelope, operationId, [`Append-only primitive '${declaredType}' cannot be deleted.`]);
217
+ if (!definition) return failure('validation_failed', envelope, operationId, [`Unknown SSSS primitive '${declaredType}'.`]);
218
+ if (isAppendType(definition)) return failure('validation_failed', envelope, operationId, [`Append-only primitive '${declaredType}' cannot be deleted.`]);
162
219
  } else {
163
220
  definition = { type: 'event', qualified_type: 'ssss:event', primitive_version: 1, capabilities: { event: ['write:event'] } };
164
221
  try { if (typeof envelope.content === 'string') JSON.parse(envelope.content); }
165
- catch { return failure(envelope, operationId, ['Event content must be valid JSON.'], [{ field: 'content', issue: 'Expected JSON string.' }]); }
222
+ catch { return failure('invalid_request', envelope, operationId, ['Event content must be valid JSON.'], [{ field: 'content', issue: 'Expected JSON string.' }]); }
166
223
  }
167
224
 
168
225
  const appendOnly = isAppendType(definition);
169
226
  const action = actionFor(envelope, !!current, appendOnly);
170
227
  const auth = await authorize({ principal, workspaceId: envelope.workspace_id, definition, action, context });
171
- if (!auth.allowed) return failure(envelope, operationId, [`Access denied: ${auth.reason}`], [{ field: 'principal', issue: auth.reason }]);
228
+ if (!auth.allowed) {
229
+ // An absent or malformed principal is an authentication failure, not a permission one.
230
+ const code = validateVerifiedPrincipal(principal).valid ? 'forbidden' : 'unauthorized';
231
+ return failure(code, envelope, operationId, [`Access denied: ${auth.reason}`], [{ field: 'principal', issue: auth.reason }]);
232
+ }
172
233
 
173
234
  if ((definition?.lease_required || context.requireLease) && leaseStore) {
174
235
  let lease;
@@ -181,18 +242,30 @@ export function createKernel(options = {}) {
181
242
  lease_id: envelope.lease_id,
182
243
  });
183
244
  } catch (error) {
184
- return failure(envelope, operationId, [`Lease state is unreadable: ${error.message}`], [{ field: 'lease_id', issue: 'Lease state is unreadable.' }]);
245
+ return failure('internal_error', envelope, operationId, [`Lease state is unreadable: ${error.message}`], [{ field: 'lease_id', issue: 'Lease state is unreadable.' }]);
185
246
  }
186
- if (!lease.valid) return failure(envelope, operationId, [lease.reason], [{ field: 'lease_id', issue: lease.reason }]);
247
+ if (!lease.valid) return failure('lease_conflict', envelope, operationId, [lease.reason], [{ field: 'lease_id', issue: lease.reason }]);
187
248
  }
188
249
 
189
250
  let resourceState = null;
251
+ let prepared = false;
190
252
  if (resourceCoordinator?.prepare) {
191
- try { resourceState = await resourceCoordinator.prepare({ envelope, principal, definition, action, current, nextContent }); }
192
- catch (error) { return failure(envelope, operationId, [`Resource prepare failed: ${error.message}`]); }
253
+ try {
254
+ resourceState = await resourceCoordinator.prepare({ envelope, principal, definition, action, current, nextContent });
255
+ prepared = true;
256
+ }
257
+ catch (error) { return failure('internal_error', envelope, operationId, [`Resource prepare failed: ${error.message}`]); }
193
258
  }
194
259
 
260
+ // Every prepare ends in exactly one finalize or reconcile; a dry run never commits.
261
+ const reconcile = async (phase, error) => {
262
+ if (!resourceCoordinator?.reconcile) return null;
263
+ try { await resourceCoordinator.reconcile({ resourceState, error, envelope, principal, phase }); return null; }
264
+ catch (reconcileError) { return reconcileError; }
265
+ };
266
+
195
267
  if (envelope.dry_run) {
268
+ if (prepared) await reconcile('dry_run', null);
196
269
  return {
197
270
  success: true, type: envelope.type, operation_id: operationId, path: envelope.path,
198
271
  committed_at: null, dry_run: true,
@@ -201,6 +274,7 @@ export function createKernel(options = {}) {
201
274
  }
202
275
 
203
276
  let commit = null;
277
+ let event;
204
278
  try {
205
279
  if (envelope.type === 'operation') {
206
280
  commit = await vfs.writeAtomic(envelope.path, nextContent, current ? { version: current.version } : { ifAbsent: true });
@@ -210,7 +284,10 @@ export function createKernel(options = {}) {
210
284
  commit = await vfs.remove(envelope.path, { version: current.version });
211
285
  }
212
286
 
213
- const event = createCanonicalEvent({
287
+ event = createCanonicalEvent({
288
+ // `event` envelopes have no VFS compare-and-swap, so a key-derived id
289
+ // lets the event store reject a concurrent request with the same key.
290
+ event_id: envelope.type === 'event' ? idempotentEventId(envelope.workspace_id, envelope.idempotency_key) : undefined,
214
291
  workspace_id: envelope.workspace_id,
215
292
  primitive_id: definition.qualified_type,
216
293
  primitive_version: definition.primitive_version || 1,
@@ -223,37 +300,57 @@ export function createKernel(options = {}) {
223
300
  before_hash: current?.hash || null,
224
301
  after_hash: commit?.hash || null,
225
302
  changed_fields: envelope.type === 'patch' ? Object.keys(envelope.patches).sort() : [],
226
- resource_status: resourceState ? 'prepared' : null,
303
+ resource_status: prepared ? 'prepared' : null,
227
304
  payload: envelope.type === 'event' && envelope.content ? JSON.parse(envelope.content) : null,
228
305
  }, { clock });
229
306
  await eventStore.append(event);
230
-
231
- if (resourceCoordinator?.finalize) await resourceCoordinator.finalize({ resourceState, event });
232
- const projectionResults = projectionCoordinator ? await projectionCoordinator.dispatch(event) : [];
233
- const warnings = projectionResults.filter((item) => !item.success).map((item) => `Projection '${item.id}' failed: ${item.error}`);
234
- const response = {
235
- success: true, type: envelope.type, operation_id: operationId, path: envelope.path,
236
- committed_at: event.timestamp, dry_run: false,
237
- event_id: event.event_id,
238
- validation: { valid: true, type: definition.qualified_type, errors: [], warnings },
239
- };
240
- await idempotencyStore.put(envelope.workspace_id, envelope.idempotency_key, { request_hash: requestHash, response });
241
- return response;
242
307
  } catch (error) {
243
- if (resourceCoordinator?.reconcile) {
244
- try { await resourceCoordinator.reconcile({ resourceState, error, envelope, principal }); } catch {}
245
- }
308
+ if (prepared) await reconcile('commit', error);
246
309
  // Roll back a VFS commit when event persistence fails. Event envelopes have no VFS commit.
247
310
  if (commit && envelope.type !== 'event') {
248
311
  try {
249
312
  if (!current) await vfs.remove(envelope.path, { version: commit.version });
250
313
  else await vfs.writeAtomic(envelope.path, current.bytes, commit.version ? { version: commit.version } : {});
251
314
  } catch (rollbackError) {
252
- return failure(envelope, operationId, [`Commit failed: ${error.message}; rollback failed: ${rollbackError.message}`]);
315
+ return failure('internal_error', envelope, operationId, [`Commit failed: ${error.message}; rollback failed: ${rollbackError.message}`]);
253
316
  }
254
317
  }
255
- return failure(envelope, operationId, [`Commit failed: ${error.message}`]);
318
+ // A concurrent request with the same key may have won the commit (VFS
319
+ // compare-and-swap or duplicate event_id); answer with its result.
320
+ try {
321
+ const winner = await settled();
322
+ if (winner) return winner;
323
+ } catch {}
324
+ const code = isVfsError(error, 'VfsConflictError', 'SSSS_VFS_CONFLICT') ? 'version_conflict' : 'internal_error';
325
+ return failure(code, envelope, operationId, [`Commit failed: ${error.message}`]);
326
+ }
327
+
328
+ // The appended event is the commit point (spec §6.3): nothing after it may
329
+ // roll the mutation back. Post-commit failures become warnings.
330
+ const warnings = [];
331
+ if (resourceCoordinator?.finalize) {
332
+ try { await resourceCoordinator.finalize({ resourceState, event }); }
333
+ catch (error) {
334
+ warnings.push(`Resource finalize failed: ${error.message}`);
335
+ const reconcileError = await reconcile('finalize', error);
336
+ if (reconcileError) warnings.push(`Resource reconcile failed: ${reconcileError.message}`);
337
+ }
338
+ }
339
+ let projectionResults = [];
340
+ if (projectionCoordinator) {
341
+ try { projectionResults = await projectionCoordinator.dispatch(event); }
342
+ catch (error) { warnings.push(`Projection dispatch failed: ${error.message}`); }
256
343
  }
344
+ warnings.push(...projectionResults.filter((item) => !item.success).map((item) => `Projection '${item.id}' failed: ${item.error}`));
345
+ const response = {
346
+ success: true, type: envelope.type, operation_id: operationId, path: envelope.path,
347
+ committed_at: event.timestamp, dry_run: false,
348
+ event_id: event.event_id,
349
+ validation: { valid: true, type: definition.qualified_type, errors: [], warnings },
350
+ };
351
+ try { await idempotencyStore.put(envelope.workspace_id, envelope.idempotency_key, { request_hash: requestHash, response }); }
352
+ catch (error) { warnings.push(`Idempotency record failed: ${error.message}`); }
353
+ return response;
257
354
  }
258
355
 
259
356
  return { execute, registry: registrySet, validator };
package/types/kernel.d.ts CHANGED
@@ -21,8 +21,14 @@ export interface SsssCommand {
21
21
  dry_run?: boolean;
22
22
  }
23
23
 
24
+ export type SsssErrorCode =
25
+ | 'invalid_request' | 'unauthorized' | 'forbidden' | 'not_found' | 'lease_conflict'
26
+ | 'version_conflict' | 'idempotency_conflict' | 'validation_failed' | 'internal_error';
27
+
24
28
  export interface SsssResponse {
25
29
  success: boolean;
30
+ /** Present exactly when success is false (spec §6.5). */
31
+ error?: { code: SsssErrorCode; message: string };
26
32
  type: SsssCommandType | null;
27
33
  operation_id: string;
28
34
  path: string;
@@ -40,6 +46,7 @@ export interface SsssKernel {
40
46
  validator: unknown;
41
47
  }
42
48
 
49
+ export const ERROR_CODES: readonly SsssErrorCode[];
43
50
  export function canonicalRequestHash(command: SsssCommand, principal: VerifiedPrincipal): string;
44
51
  export class MemoryIdempotencyStore {
45
52
  get(workspaceId: string, idempotencyKey: string): Promise<unknown>;