@gregiteen/ssss-cli 0.9.2 → 0.9.3

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/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.3] - 2026-09-24
11
+ ### Fixed
12
+ - Two processes sending an `event` envelope with the same idempotency key at the
13
+ same moment could both append an event, because the kernel records the result
14
+ only after committing and `event` envelopes have no VFS compare-and-swap. The
15
+ kernel now derives an `event` envelope's `event_id` from `(workspace_id,
16
+ idempotency_key)` (`idempotentEventId`, UUIDv5), so the event store rejects
17
+ the second append. Any request that loses at commit re-reads the idempotency
18
+ store and returns the winner's result as a replay, or an idempotency conflict
19
+ when its request differs. If a process dies after appending an event but
20
+ before recording its result, retries of that key now fail instead of
21
+ appending a second event.
22
+
23
+ ### Added
24
+ - `idempotentEventId(workspaceId, idempotencyKey)` in `@gregiteen/ssss-cli/events`.
25
+ - Conformance checks racing six kernel processes on one `event` idempotency key,
26
+ with identical and with differing content.
27
+
10
28
  ## [0.9.2] - 2026-09-24
11
29
  ### Fixed
12
30
  - The file adapters are now safe to share between processes. Each one checked
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.9.2
1
+ 0.9.3
@@ -106,7 +106,7 @@
106
106
  "file_count": 14,
107
107
  "provenance": {
108
108
  "content_hash": "sha256:7febd673938a3dc04c068e0bf08097a9783c09e612606d2df9a4db8dcb8907f5",
109
- "exporter": "@gregiteen/ssss-cli@0.9.2"
109
+ "exporter": "@gregiteen/ssss-cli@0.9.3"
110
110
  }
111
111
  },
112
112
  "files": [
@@ -13,6 +13,23 @@ most one racer wins:
13
13
 
14
14
  `.ssss-locks/` is reserved: VFS paths under it are rejected and `list()` skips it.
15
15
 
16
+ ## Same idempotency key from two processes
17
+
18
+ The kernel records a request's result only after it commits, so two processes
19
+ can both miss the stored result for one key. The commit step then decides:
20
+
21
+ - `operation`, `patch`, `delete`: the VFS compare-and-swap lets one commit; the
22
+ other fails its precondition.
23
+ - `event`: the event's `event_id` is derived from `(workspace_id,
24
+ idempotency_key)` (`idempotentEventId`, a UUIDv5), so the event store rejects
25
+ the second append.
26
+
27
+ The loser then re-reads the idempotency store: if the winner has already
28
+ recorded its result, the loser returns it as a replay (or an idempotency
29
+ conflict when its request differs); otherwise it fails and a retry replays. If a
30
+ process dies after appending an event but before recording the result, retries
31
+ of that key fail rather than append a second event.
32
+
16
33
  ## Crashed holders
17
34
 
18
35
  A lock records its holder's pid and host. A waiter breaks the lock when the
@@ -31,10 +48,6 @@ Symlinked lock files are refused.
31
48
 
32
49
  - Locks are advisory: only writers using these adapters take them. Writers from
33
50
  `@gregiteen/ssss-cli` 0.9.1 and earlier do not.
34
- - The kernel's idempotency check for `event` envelopes is read before commit and
35
- written after, so two processes sending the same idempotency key at the same
36
- moment can each append an event. `operation`, `patch`, and `delete` are
37
- protected by the VFS compare-and-swap.
38
51
  - Network filesystems must support exclusive create (`O_EXCL`).
39
52
  - Dead-holder detection assumes processes that share the filesystem have
40
53
  distinct hostnames (containers with the same hostname and separate pid
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gregiteen/ssss-cli",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "CLI tools and the reference engine for the Structured Semantic Syntax System",
5
5
  "main": "src/engine.mjs",
6
6
  "exports": {
@@ -13,7 +13,7 @@ import {
13
13
  } from '../src/registry.mjs';
14
14
  import { createValidator } from '../src/validator.mjs';
15
15
  import { FileSystemVfs, MemoryVfs, runVfsContract } from '../src/vfs.mjs';
16
- import { JsonlEventStore, MemoryEventStore, createCanonicalEvent } from '../src/events.mjs';
16
+ import { JsonlEventStore, MemoryEventStore, createCanonicalEvent, idempotentEventId } from '../src/events.mjs';
17
17
  import {
18
18
  ProjectionCoordinator,
19
19
  createSqlProjectionAdapter,
@@ -119,6 +119,33 @@ async function checkFileAdapterRaces(root, principal, check) {
119
119
  const stored = await new FileIdempotencyStore(idempotencyRoot).get('acme', 'race');
120
120
  check('filesystem idempotency store accepts one of several processes putting the same key',
121
121
  winners(puts).length === 1 && stored?.request_hash === `racer-${winners(puts)[0]?.racer}`, describe(puts));
122
+
123
+ const kernelRoot = path.join(root, 'kernel');
124
+ const kernelPrincipal = { ...principal, capabilities: ['*:*'] };
125
+ const stores = () => ({
126
+ vfs: new FileSystemVfs(kernelRoot),
127
+ eventStore: new JsonlEventStore(path.join(kernelRoot, '.events')),
128
+ idempotencyStore: new FileIdempotencyStore(path.join(kernelRoot, '.idempotency')),
129
+ });
130
+ const envelope = (key, content) => ({ type: 'event', workspace_id: 'acme', idempotency_key: key, path: 'events/race.md', content });
131
+ const kernelRace = (key, contentExpression) => raceProcesses(racers, {
132
+ createKernel: '../src/kernel.mjs', FileSystemVfs: '../src/vfs.mjs', JsonlEventStore: '../src/events.mjs', FileIdempotencyStore: '../src/idempotency.mjs',
133
+ }, [
134
+ `const kernel = createKernel({ vfs: new FileSystemVfs(${JSON.stringify(kernelRoot)}), eventStore: new JsonlEventStore(${JSON.stringify(path.join(kernelRoot, '.events'))}), idempotencyStore: new FileIdempotencyStore(${JSON.stringify(path.join(kernelRoot, '.idempotency'))}) })`,
135
+ `const result = await kernel.execute({ type: 'event', workspace_id: 'acme', idempotency_key: ${JSON.stringify(key)}, path: 'events/race.md', content: ${contentExpression} }, { principal: ${JSON.stringify(kernelPrincipal)} })`,
136
+ "if (!result.success) throw new Error(result.validation.errors.join('; '))",
137
+ ].join(';\n'));
138
+ const eventsFor = (key) => fs.readFileSync(path.join(kernelRoot, '.events', 'acme.jsonl'), 'utf8')
139
+ .split('\n').filter(Boolean).map((line) => JSON.parse(line)).filter((event) => event.idempotency_key === key);
140
+ const same = await kernelRace('race-same', 'JSON.stringify({ n: 1 })');
141
+ const retried = await createKernel(stores()).execute(envelope('race-same', JSON.stringify({ n: 1 })), { principal: kernelPrincipal });
142
+ check('kernel appends one event when several processes send the same event envelope',
143
+ eventsFor('race-same').length === 1 && winners(same).length >= 1 && retried.replay === true, describe(same));
144
+ const differing = await kernelRace('race-differ', 'JSON.stringify({ racer })');
145
+ check('kernel appends one event when several processes reuse a key with different content',
146
+ eventsFor('race-differ').length === 1 && winners(differing).length === 1, describe(differing));
147
+ check('kernel derives event envelope ids from the idempotency key',
148
+ eventsFor('race-same')[0]?.event_id === idempotentEventId('acme', 'race-same'));
122
149
  }
123
150
 
124
151
  async function checkFileLocks(root, principal, check) {
package/src/events.mjs CHANGED
@@ -4,6 +4,22 @@ import path from 'node:path';
4
4
  import crypto from 'node:crypto';
5
5
  import { withFileLock } from './file-lock.mjs';
6
6
 
7
+ // UUIDv5 namespace for event ids derived from idempotency keys.
8
+ const IDEMPOTENT_EVENT_NAMESPACE = Buffer.from('6f1c2d9e4b7a4e0c9f35a1d8c3b2e7f4', 'hex');
9
+
10
+ /**
11
+ * Deterministic event_id (UUIDv5) for one (workspace_id, idempotency_key).
12
+ * The kernel uses it for `event` envelopes so a store's duplicate-event_id
13
+ * check stops two concurrent requests with the same key from both appending.
14
+ */
15
+ export function idempotentEventId(workspaceId, idempotencyKey) {
16
+ const hash = crypto.createHash('sha1').update(IDEMPOTENT_EVENT_NAMESPACE).update(JSON.stringify([workspaceId, idempotencyKey])).digest();
17
+ hash[6] = (hash[6] & 0x0f) | 0x50;
18
+ hash[8] = (hash[8] & 0x3f) | 0x80;
19
+ const hex = hash.subarray(0, 16).toString('hex');
20
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
21
+ }
22
+
7
23
  export function createCanonicalEvent(input, options = {}) {
8
24
  const required = ['workspace_id', 'action', 'subject', 'operation_id', 'idempotency_key'];
9
25
  for (const field of required) if (typeof input?.[field] !== 'string' || !input[field]) throw new Error(`Event ${field} is required.`);
package/src/kernel.mjs CHANGED
@@ -7,7 +7,7 @@ import { parseDocument, serializeDocument } from './frontmatter.mjs';
7
7
  import { createValidator } from './validator.mjs';
8
8
  import { isAppendType, resolvePrimitiveDefinition } from './registry.mjs';
9
9
  import { createCapabilityAuthorizer } from './authorization.mjs';
10
- import { createCanonicalEvent } from './events.mjs';
10
+ import { createCanonicalEvent, idempotentEventId } from './events.mjs';
11
11
  import { MemoryIdempotencyStore } from './idempotency.mjs';
12
12
 
13
13
  export { MemoryIdempotencyStore } from './idempotency.mjs';
@@ -92,13 +92,17 @@ export function createKernel(options = {}) {
92
92
  }
93
93
 
94
94
  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) {
95
+ // A stored result for this key: replay it, or refuse a different request.
96
+ const settled = async () => {
97
+ const stored = await idempotencyStore.get(envelope.workspace_id, envelope.idempotency_key);
98
+ if (!stored) return null;
99
+ if (stored.request_hash !== requestHash) {
98
100
  return failure(envelope, operationId, ['Idempotency key was already used for a different request.'], [{ field: 'idempotency_key', issue: 'Request hash conflict.' }]);
99
101
  }
100
- return { ...replay.response, replay: true };
101
- }
102
+ return { ...stored.response, replay: true };
103
+ };
104
+ const replay = await settled();
105
+ if (replay) return replay;
102
106
 
103
107
  let current;
104
108
  try { current = await vfs.read(envelope.path); }
@@ -211,6 +215,9 @@ export function createKernel(options = {}) {
211
215
  }
212
216
 
213
217
  const event = createCanonicalEvent({
218
+ // `event` envelopes have no VFS compare-and-swap, so a key-derived id
219
+ // lets the event store reject a concurrent request with the same key.
220
+ event_id: envelope.type === 'event' ? idempotentEventId(envelope.workspace_id, envelope.idempotency_key) : undefined,
214
221
  workspace_id: envelope.workspace_id,
215
222
  primitive_id: definition.qualified_type,
216
223
  primitive_version: definition.primitive_version || 1,
@@ -252,6 +259,12 @@ export function createKernel(options = {}) {
252
259
  return failure(envelope, operationId, [`Commit failed: ${error.message}; rollback failed: ${rollbackError.message}`]);
253
260
  }
254
261
  }
262
+ // A concurrent request with the same key may have won the commit (VFS
263
+ // compare-and-swap or duplicate event_id); answer with its result.
264
+ try {
265
+ const winner = await settled();
266
+ if (winner) return winner;
267
+ } catch {}
255
268
  return failure(envelope, operationId, [`Commit failed: ${error.message}`]);
256
269
  }
257
270
  }