@volter/world-core 3.0.152 → 3.0.154

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,4 @@
1
+ import type { EventTransport } from './events.js';
2
+ /** Dispatch one newly recorded packet once. Legacy rows, existing attempts and
3
+ * interrupted sending claims never turn into an unrequested second send. */
4
+ export declare function sendRecordedEvent(service: string, deliveryType: string, deliveryId: string, root: string | undefined, transport: EventTransport, trace: Record<string, string>): Promise<void>;
@@ -0,0 +1,70 @@
1
+ // FIRST DECLARED-EVENT OUTCOMES (architecture, "What an author writes, and how").
2
+ // A queued packet is not evidence its receiver answered. Keep the first attempt
3
+ // under the shared action lock; this is not the originating-write outbox or a
4
+ // substitute for a vendor's declared retry/redelivery policy.
5
+ import { applyTwinWriteAtomic } from "./serve.js";
6
+ import { worldNow } from "./world-clock.js";
7
+ const ATTEMPT = '_world_event_delivery_attempt';
8
+ /** Dispatch one newly recorded packet once. Legacy rows, existing attempts and
9
+ * interrupted sending claims never turn into an unrequested second send. */
10
+ export async function sendRecordedEvent(service, deliveryType, deliveryId, root, transport, trace) {
11
+ const { value: claim } = await applyTwinWriteAtomic(service, (resources) => {
12
+ const row = resources.find((r) => r.type === deliveryType && r.id === deliveryId);
13
+ if (!row || row.deleted === true || row.delivery_protocol !== 1 || row.delivery_state !== 'queued'
14
+ || row.attempt_count !== 0 || row.current_attempt !== null)
15
+ return { kind: 'skip', value: undefined };
16
+ const attemptId = `${deliveryType}:${deliveryId}:1`;
17
+ if (resources.some((r) => r.type === ATTEMPT && r.id === attemptId))
18
+ return { kind: 'skip', value: undefined };
19
+ if (typeof row.url !== 'string' || typeof row.body !== 'string'
20
+ || !row.headers || typeof row.headers !== 'object' || Array.isArray(row.headers)
21
+ || Object.values(row.headers).some((v) => typeof v !== 'string')) {
22
+ throw new Error('A retained declared-event packet is incomplete; it cannot be dispatched.');
23
+ }
24
+ const occurredAt = worldNow();
25
+ return { kind: 'write', value: {
26
+ attemptId, url: row.url, body: row.body, headers: { ...row.headers },
27
+ }, write: {
28
+ operation: 'world_event_delivery.claim', subjectType: deliveryType, subjectId: deliveryId,
29
+ fields: { delivery_state: 'sending', attempt_count: 1, current_attempt: attemptId },
30
+ projection: { creates: [{ type: ATTEMPT, id: attemptId, fields: {
31
+ delivery_type: deliveryType, delivery_id: deliveryId, attempt: 1,
32
+ state: 'sending', started_at: Date.parse(occurredAt), finished_at: null, transport_status: null,
33
+ } }] },
34
+ occurredAt, actor: { kind: 'system' },
35
+ } };
36
+ }, root);
37
+ if (!claim)
38
+ return;
39
+ let status = null;
40
+ try {
41
+ const answer = await transport(claim.url, claim.body, { ...claim.headers, ...trace });
42
+ if (typeof answer === 'number' && Number.isInteger(answer) && answer >= 100 && answer <= 599)
43
+ status = answer;
44
+ }
45
+ catch {
46
+ // A thrown request can have reached the receiver. Preserve uncertainty and
47
+ // never copy exception strings, response bodies or credentials into history.
48
+ }
49
+ const state = status === null ? 'unknown' : status >= 200 && status < 300 ? 'succeeded' : 'failed';
50
+ const occurredAt = worldNow(), finishedAt = Date.parse(occurredAt);
51
+ await applyTwinWriteAtomic(service, (resources) => {
52
+ const attempt = resources.find((r) => r.type === ATTEMPT && r.id === claim.attemptId);
53
+ if (!attempt || attempt.deleted === true || attempt.state !== 'sending'
54
+ || attempt.delivery_type !== deliveryType || attempt.delivery_id !== deliveryId)
55
+ return { kind: 'skip', value: undefined };
56
+ const row = resources.find((r) => r.type === deliveryType && r.id === deliveryId);
57
+ const current = row && row.deleted !== true && row.delivery_protocol === 1
58
+ && row.delivery_state === 'sending' && row.current_attempt === claim.attemptId;
59
+ return { kind: 'write', value: undefined, write: {
60
+ operation: 'world_event_delivery.finish', subjectType: ATTEMPT, subjectId: claim.attemptId,
61
+ fields: { state, finished_at: finishedAt, transport_status: status },
62
+ ...(current && row ? { projection: { updates: [{ type: deliveryType, id: deliveryId, fields: {
63
+ delivery_state: state, finished_at: finishedAt, transport_status: status,
64
+ // A future manual attempt must not erase earlier positive acceptance.
65
+ accepted_at: state === 'succeeded' ? row.accepted_at ?? finishedAt : row.accepted_at ?? null,
66
+ } }] } } : {}),
67
+ occurredAt, actor: { kind: 'system' },
68
+ } };
69
+ }, root);
70
+ }
@@ -110,7 +110,8 @@ export type EventsDecl = {
110
110
  envelope: unknown;
111
111
  /** Where the World's endpoints are kept: one kind, or several (GitHub's repository and organization hooks). */
112
112
  endpoints: EndpointsDecl | ReadonlyArray<EndpointsDecl>;
113
- /** The bookkeeping type each delivery is kept as (`_webhook_message`): what a World reads to see what was sent. */
113
+ /** The bookkeeping type each delivery is kept as (`_webhook_message`), otherwise `_world_event_delivery`.
114
+ * Queuing and the first transport attempt/outcome are separate retained facts. */
114
115
  record?: string;
115
116
  };
116
117
  /** An event's further envelope values (`$`-named) the pack computes for a write, from the vendor's own state (the
@@ -131,7 +132,7 @@ export type EventWrite = {
131
132
  * it: the pack's `semantics/events.ts`, under the handler contract's read-only context, for each type the write sends
132
133
  * (a Slack message renders as `message` and as `app_mention`). */
133
134
  export type EventRender<C> = (ctx: C, write: EventWrite, type: string) => Record<string, unknown>;
134
- /** A delivery made: where it went and exactly what was sent. */
135
+ /** A rendered delivery packet. Its transport attempt and acceptance are separate retained facts. */
135
136
  export type EventDelivery = {
136
137
  url: string;
137
138
  body: string;
@@ -140,11 +141,11 @@ export type EventDelivery = {
140
141
  /** The transport a delivery is made over; the World's by default, a test's own when given. */
141
142
  export type EventTransport = (url: string, body: string, headers: Record<string, string>) => Promise<number | void>;
142
143
  /** A signature scheme's headers for one delivery: deterministic (the id is derived from the delivery itself). */
143
- /** `seq` tells apart two identical deliveries at one instant (a frozen World clock): the World's count of deliveries
144
- * made before this one, so a receiver that deduplicates by id drops neither. */
145
- export declare function signEvent(scheme: EventScheme, secret: string, body: string, seconds: number, url: string, seq?: number): Record<string, string>;
144
+ /** `seq` tells apart deliveries at one instant; the declared-event path reserves it durably. `identity` supplies that
145
+ * occurrence's namespace. Omitted, existing direct callers retain their deterministic signing contract. */
146
+ export declare function signEvent(scheme: EventScheme, secret: string, body: string, seconds: number, url: string, seq?: number, identity?: string): Record<string, string>;
146
147
  /** Every scheme's headers for one delivery, merged. */
147
- export declare function signEventAll(schemes: EventScheme | ReadonlyArray<EventScheme>, secret: string, body: string, seconds: number, url: string, seq?: number): Record<string, string>;
148
+ export declare function signEventAll(schemes: EventScheme | ReadonlyArray<EventScheme>, secret: string, body: string, seconds: number, url: string, seq?: number, identity?: string): Record<string, string>;
148
149
  /** A delivery's signature checked as its receiver checks it (a Standard Webhooks verifier, a timestamp-v1 verifier, a plain
149
150
  * HMAC header): true when one of its signatures is the one the secret makes (constant time), and its timestamp,
150
151
  * when the scheme sends one, is within `tolerance` seconds of `now`. */
@@ -163,8 +164,8 @@ export declare function eventTypeOf(decl: Pick<EventsDecl, 'types' | 'known'>, o
163
164
  /** Whether an endpoint's list of event types takes a type: every type when the list is empty, `*` or `<family>.*`
164
165
  * its patterns, else the type itself. */
165
166
  export declare function takesEvent(list: unknown, type: string): boolean;
166
- /** Who watches what the World sends (a walk reading each delivery as its receiver would): told of every delivery as it
167
- * is made, before its transport. Set and restored by the walk, as its World store is; unset, nothing watches. */
167
+ /** Who watches signed packets before dispatch (a walk reading each as its receiver would). This is not evidence of
168
+ * transport or acceptance. Set and restored by the walk, as its World store is; unset, nothing watches. */
168
169
  export type DeliveryObserver = (delivery: EventDelivery & {
169
170
  service: string;
170
171
  }) => void;
@@ -7,9 +7,12 @@
7
7
  // Imports stay free of side effects: the delivery's transport (Node's http, the World's egress policy) is loaded when a
8
8
  // delivery is made, never when a browser client imports the kernel.
9
9
  import { createHash, createHmac } from 'node:crypto';
10
- import { applyTwinWrite, twinResources } from "./serve.js";
10
+ import { applyTwinWrite, applyTwinWriteAtomic, twinResources } from "./serve.js";
11
11
  import { equalSecrets as same, ksuidFrom } from "./signing.js";
12
12
  import { deliveryTraceHeaders } from "./trace-context.js";
13
+ import { sendRecordedEvent } from "./event-delivery.js";
14
+ import { getActiveWorldStore, withWorldStore } from "./world-store.js";
15
+ import { worldPaths } from "./storage.js";
13
16
  /** Whether an endpoint takes an event by its `match`: every field of one match (any, when several) equal to the event's
14
17
  * value, or, a list, holding it. */
15
18
  function matches(row, match, filled) {
@@ -25,16 +28,15 @@ function matches(row, match, filled) {
25
28
  });
26
29
  return Array.isArray(match) ? match.some(one) : one(match);
27
30
  }
28
- const digest = (s) => createHash('sha256').update(s).digest('hex');
29
31
  /** A `webhook-id` scheme's key: the base64 part of the secret, after its prefix when it carries one. */
30
32
  const webhookKey = (scheme, secret) => scheme.secretKey === 'utf8' ? Buffer.from(secret, 'utf8') : Buffer.from(scheme.secretPrefix && secret.startsWith(scheme.secretPrefix) ? secret.slice(scheme.secretPrefix.length) : secret, 'base64');
31
33
  /** A signature scheme's headers for one delivery: deterministic (the id is derived from the delivery itself). */
32
- /** `seq` tells apart two identical deliveries at one instant (a frozen World clock): the World's count of deliveries
33
- * made before this one, so a receiver that deduplicates by id drops neither. */
34
- export function signEvent(scheme, secret, body, seconds, url, seq = 0) {
34
+ /** `seq` tells apart deliveries at one instant; the declared-event path reserves it durably. `identity` supplies that
35
+ * occurrence's namespace. Omitted, existing direct callers retain their deterministic signing contract. */
36
+ export function signEvent(scheme, secret, body, seconds, url, seq = 0, identity) {
35
37
  if (scheme.kind === 'webhook-id') {
36
38
  // Svix's `msg_` and a KSUID (its ids' form), derived from the delivery itself
37
- const id = `msg_${ksuidFrom(seconds, seq, `${url}|${seconds}|${seq}|${body}`)}`;
39
+ const id = `msg_${ksuidFrom(seconds, seq, identity ?? `${url}|${seconds}|${seq}|${body}`)}`;
38
40
  const p = scheme.prefix.toLowerCase();
39
41
  const signature = createHmac('sha256', webhookKey(scheme, secret)).update(`${id}.${seconds}.${body}`, 'utf8').digest('base64');
40
42
  return { [`${p}-id`]: id, [`${p}-timestamp`]: String(seconds), [`${p}-signature`]: `v1,${signature}` };
@@ -58,8 +60,24 @@ const hashOf = (scheme, secret, body) => {
58
60
  /** What an `hmac` scheme signs: its `signed` template, else `${t}.${body}` with a timestamp header, else the body. */
59
61
  const hmacBase = (scheme, seconds, body) => scheme.signed !== undefined ? scheme.signed.replaceAll('{t}', String(seconds)).replaceAll('{body}', body) : scheme.timestampHeader ? `${seconds}.${body}` : body;
60
62
  /** Every scheme's headers for one delivery, merged. */
61
- export function signEventAll(schemes, secret, body, seconds, url, seq = 0) {
62
- return Object.assign({}, ...(Array.isArray(schemes) ? schemes : [schemes]).map((scheme) => signEvent(scheme, secret, body, seconds, url, seq)));
63
+ export function signEventAll(schemes, secret, body, seconds, url, seq = 0, identity) {
64
+ return Object.assign({}, ...(Array.isArray(schemes) ? schemes : [schemes]).map((scheme) => signEvent(scheme, secret, body, seconds, url, seq, identity)));
65
+ }
66
+ /** Reserve occurrences under the same cross-process lock that appends actions. This private retained row is never a
67
+ * vendor resource. Deleted event/delivery rows, concurrent renders and restarts cannot give its ordinals back. */
68
+ async function reserveEventOrdinals(service, root, occurredAt, count) {
69
+ const { value } = await applyTwinWriteAtomic(service, (resources) => {
70
+ const counter = resources.find((r) => r.type === '_world_event_sequence' && r.id === 'sequence');
71
+ const last = counter === undefined ? 0 : counter.last_ordinal;
72
+ if (typeof last !== 'number' || !Number.isSafeInteger(last) || last < 0 || !Number.isSafeInteger(count) || count < 1 || !Number.isSafeInteger(last + count)) {
73
+ throw new Error('Invalid retained declared-event sequence; occurrence ids cannot be reused.');
74
+ }
75
+ return { kind: 'write', value: last + 1, write: {
76
+ operation: 'world_event_sequence.reserve', subjectType: '_world_event_sequence', subjectId: 'sequence',
77
+ fields: { last_ordinal: last + count }, input: { clock: true }, occurredAt, actor: { kind: 'system' },
78
+ } };
79
+ }, root);
80
+ return value;
63
81
  }
64
82
  /** A delivery's signature checked as its receiver checks it (a Standard Webhooks verifier, a timestamp-v1 verifier, a plain
65
83
  * HMAC header): true when one of its signatures is the one the secret makes (constant time), and its timestamp,
@@ -282,11 +300,19 @@ async function deliverEventType(service, decl, write, data, transport, values, t
282
300
  const seconds = Math.floor(millis / 1000);
283
301
  const eventData = await data(type);
284
302
  const extra = values ? await values(type) : {};
285
- // the World's count of events kept so far tells apart two identical events at one instant (a frozen clock)
286
- const kept = decl.store !== undefined ? all.filter((r) => r.type === decl.store).length : 0;
303
+ // Reserve one event and at most one delivery per live endpoint before assigning identities. Unused ordinals are
304
+ // harmless gaps; a failed render/store never gives them back. This is not an atomic originating-write outbox.
305
+ const ordinal = await reserveEventOrdinals(service, write.root, write.occurredAt, 1 + live.length);
306
+ // Fresh/forked Worlds can reserve the same ordinal at a pinned clock. Keep
307
+ // content binding as well as the lineage's occurrence ordinal, so different
308
+ // event payloads cannot reuse a receiver's event/webhook id across those Worlds.
309
+ const eventContent = createHash('sha256').update(JSON.stringify({
310
+ operation: write.operation, type, data: eventData, envelope: decl.envelope,
311
+ values: extra, request: requestValues(write.request),
312
+ }), 'utf8').digest('hex');
287
313
  const filled = {
288
314
  $type: type, $data: eventData,
289
- $id: `evt_${ksuidFrom(seconds, kept, `${type}|${write.occurredAt}|${kept}|${JSON.stringify(eventData)}`)}`,
315
+ $id: `evt_${ksuidFrom(seconds, ordinal, `world-events:v1:${service}:${type}:${ordinal}:${eventContent}`)}`,
290
316
  '$time.ms': millis, '$time.s': seconds, '$time.iso': write.occurredAt,
291
317
  ...requestValues(write.request),
292
318
  ...extra,
@@ -305,31 +331,44 @@ async function deliverEventType(service, decl, write, data, transport, values, t
305
331
  const shared = JSON.stringify(envelope);
306
332
  const each = perEndpoint(decl.envelope);
307
333
  const sent = [];
308
- const before = decl.record !== undefined ? all.filter((r) => r.type === decl.record).length : 0;
334
+ const recorded = [];
309
335
  for (const [i, { row, kind }] of endpoints.entries()) {
310
336
  const body = each ? JSON.stringify(fillEnvelope(decl.envelope, filled, row)) : shared;
311
337
  const url = String(at(row, kind.url));
312
- const seq = before + i;
338
+ const seq = ordinal + 1 + i;
339
+ const deliveryContent = createHash('sha256').update(url, 'utf8').update('\0').update(body, 'utf8').digest('hex');
340
+ const identity = `world-events:v1:${service}:${seq}:${deliveryContent}`;
313
341
  const extraHeaders = decl.headers ? headersFor(decl.headers, filled, row) : {};
314
- const headers = { ...extraHeaders, ...signEventAll(decl.scheme, String(at(row, kind.secret)), body, seconds, url, seq) };
342
+ const headers = { ...extraHeaders, ...signEventAll(decl.scheme, String(at(row, kind.secret)), body, seconds, url, seq, identity) };
315
343
  sent.push({ url, body, headers });
316
344
  // kept with the write, by an id of the delivery itself (two made at once never take the same one)
317
- if (decl.record !== undefined) {
318
- await applyTwinWrite(service, {
319
- operation: `${decl.record.slice(1)}.record`, subjectType: decl.record, subjectId: `${decl.record.slice(1)}_${digest(`${url}|${seconds}|${seq}|${body}`).slice(0, 16)}`,
320
- fields: { url, body, headers, event_type: type, sent_at: millis }, occurredAt: write.occurredAt, actor: { kind: 'system' },
321
- }, write.root);
322
- }
345
+ const record = decl.record ?? '_world_event_delivery';
346
+ const id = `${record.slice(1)}_v1_${seq}`;
347
+ await applyTwinWrite(service, {
348
+ operation: `${record.slice(1)}.record`, subjectType: record, subjectId: id,
349
+ fields: { url, body, headers, event_type: type, sent_at: millis,
350
+ delivery_protocol: 1, event_id: String(filled.$id),
351
+ endpoint_type: kind.storedAs, endpoint_id: String(row.id),
352
+ delivery_state: 'queued', attempt_count: 0, current_attempt: null,
353
+ transport_status: null, finished_at: null, accepted_at: null,
354
+ }, occurredAt: write.occurredAt, actor: { kind: 'system' },
355
+ }, write.root);
356
+ recorded.push({ type: record, id });
323
357
  }
324
358
  // sent after the write's answer, as a vendor sends it: the caller never waits on its receiver, and a receiver that is
325
359
  // slow or down fails no request
326
360
  // the write's trace is read now, inside its request, and carried on each delivery (W3C trace continuation)
327
361
  const trace = deliveryTraceHeaders();
362
+ // A timer must retain the request's store/root even if another World becomes
363
+ // active before it runs. Transport completion writes to the same retained state.
364
+ const store = getActiveWorldStore(), deliveryRoot = worldPaths(service, write.root).root;
328
365
  for (const d of sent)
329
366
  deliveryObserver?.({ ...d, service });
330
- for (const { url, body: payload, headers } of sent)
367
+ for (const packet of recorded)
331
368
  setTimeout(() => {
332
- void transport(url, payload, { ...headers, ...trace }).catch(() => { });
369
+ // Persistence failure leaves a queued/sending claim for explicit recovery;
370
+ // it must not fail the already-committed vendor write or cause a blind resend.
371
+ void withWorldStore(store, () => sendRecordedEvent(service, packet.type, packet.id, deliveryRoot, transport, trace)).catch(() => { });
333
372
  }, 0);
334
373
  return sent;
335
374
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-core",
3
- "version": "3.0.152",
3
+ "version": "3.0.154",
4
4
  "description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
5
5
  "keywords": [
6
6
  "twin",
@@ -0,0 +1,76 @@
1
+ // FIRST DECLARED-EVENT OUTCOMES (architecture, "What an author writes, and how").
2
+ // A queued packet is not evidence its receiver answered. Keep the first attempt
3
+ // under the shared action lock; this is not the originating-write outbox or a
4
+ // substitute for a vendor's declared retry/redelivery policy.
5
+ import { applyTwinWriteAtomic } from './serve.ts';
6
+ import { worldNow } from './world-clock.ts';
7
+ import type { EventDelivery, EventTransport } from './events.ts';
8
+
9
+ const ATTEMPT = '_world_event_delivery_attempt';
10
+ type Claimed = EventDelivery & { attemptId: string };
11
+
12
+ /** Dispatch one newly recorded packet once. Legacy rows, existing attempts and
13
+ * interrupted sending claims never turn into an unrequested second send. */
14
+ export async function sendRecordedEvent(
15
+ service: string,
16
+ deliveryType: string,
17
+ deliveryId: string,
18
+ root: string | undefined,
19
+ transport: EventTransport,
20
+ trace: Record<string, string>,
21
+ ): Promise<void> {
22
+ const { value: claim } = await applyTwinWriteAtomic<Claimed | undefined>(service, (resources) => {
23
+ const row = resources.find((r) => r.type === deliveryType && r.id === deliveryId);
24
+ if (!row || row.deleted === true || row.delivery_protocol !== 1 || row.delivery_state !== 'queued'
25
+ || row.attempt_count !== 0 || row.current_attempt !== null) return { kind: 'skip', value: undefined };
26
+ const attemptId = `${deliveryType}:${deliveryId}:1`;
27
+ if (resources.some((r) => r.type === ATTEMPT && r.id === attemptId)) return { kind: 'skip', value: undefined };
28
+ if (typeof row.url !== 'string' || typeof row.body !== 'string'
29
+ || !row.headers || typeof row.headers !== 'object' || Array.isArray(row.headers)
30
+ || Object.values(row.headers).some((v) => typeof v !== 'string')) {
31
+ throw new Error('A retained declared-event packet is incomplete; it cannot be dispatched.');
32
+ }
33
+ const occurredAt = worldNow();
34
+ return { kind: 'write', value: {
35
+ attemptId, url: row.url, body: row.body, headers: { ...row.headers } as Record<string, string>,
36
+ }, write: {
37
+ operation: 'world_event_delivery.claim', subjectType: deliveryType, subjectId: deliveryId,
38
+ fields: { delivery_state: 'sending', attempt_count: 1, current_attempt: attemptId },
39
+ projection: { creates: [{ type: ATTEMPT, id: attemptId, fields: {
40
+ delivery_type: deliveryType, delivery_id: deliveryId, attempt: 1,
41
+ state: 'sending', started_at: Date.parse(occurredAt), finished_at: null, transport_status: null,
42
+ } }] },
43
+ occurredAt, actor: { kind: 'system' as const },
44
+ } };
45
+ }, root);
46
+ if (!claim) return;
47
+
48
+ let status: number | null = null;
49
+ try {
50
+ const answer = await transport(claim.url, claim.body, { ...claim.headers, ...trace });
51
+ if (typeof answer === 'number' && Number.isInteger(answer) && answer >= 100 && answer <= 599) status = answer;
52
+ } catch {
53
+ // A thrown request can have reached the receiver. Preserve uncertainty and
54
+ // never copy exception strings, response bodies or credentials into history.
55
+ }
56
+ const state = status === null ? 'unknown' : status >= 200 && status < 300 ? 'succeeded' : 'failed';
57
+ const occurredAt = worldNow(), finishedAt = Date.parse(occurredAt);
58
+ await applyTwinWriteAtomic(service, (resources) => {
59
+ const attempt = resources.find((r) => r.type === ATTEMPT && r.id === claim.attemptId);
60
+ if (!attempt || attempt.deleted === true || attempt.state !== 'sending'
61
+ || attempt.delivery_type !== deliveryType || attempt.delivery_id !== deliveryId) return { kind: 'skip', value: undefined };
62
+ const row = resources.find((r) => r.type === deliveryType && r.id === deliveryId);
63
+ const current = row && row.deleted !== true && row.delivery_protocol === 1
64
+ && row.delivery_state === 'sending' && row.current_attempt === claim.attemptId;
65
+ return { kind: 'write', value: undefined, write: {
66
+ operation: 'world_event_delivery.finish', subjectType: ATTEMPT, subjectId: claim.attemptId,
67
+ fields: { state, finished_at: finishedAt, transport_status: status },
68
+ ...(current && row ? { projection: { updates: [{ type: deliveryType, id: deliveryId, fields: {
69
+ delivery_state: state, finished_at: finishedAt, transport_status: status,
70
+ // A future manual attempt must not erase earlier positive acceptance.
71
+ accepted_at: state === 'succeeded' ? row.accepted_at ?? finishedAt : row.accepted_at ?? null,
72
+ } }] } } : {}),
73
+ occurredAt, actor: { kind: 'system' as const },
74
+ } };
75
+ }, root);
76
+ }
package/src/events.ts CHANGED
@@ -7,9 +7,12 @@
7
7
  // Imports stay free of side effects: the delivery's transport (Node's http, the World's egress policy) is loaded when a
8
8
  // delivery is made, never when a browser client imports the kernel.
9
9
  import { createHash, createHmac } from 'node:crypto';
10
- import { applyTwinWrite, twinResources } from './serve.ts';
10
+ import { applyTwinWrite, applyTwinWriteAtomic, twinResources } from './serve.ts';
11
11
  import { equalSecrets as same, ksuidFrom } from './signing.ts';
12
12
  import { deliveryTraceHeaders } from './trace-context.ts';
13
+ import { sendRecordedEvent } from './event-delivery.ts';
14
+ import { getActiveWorldStore, withWorldStore } from './world-store.ts';
15
+ import { worldPaths } from './storage.ts';
13
16
 
14
17
  /** How the vendor signs a delivery. */
15
18
  export type EventScheme =
@@ -99,7 +102,8 @@ export type EventsDecl = {
99
102
  envelope: unknown;
100
103
  /** Where the World's endpoints are kept: one kind, or several (GitHub's repository and organization hooks). */
101
104
  endpoints: EndpointsDecl | ReadonlyArray<EndpointsDecl>;
102
- /** The bookkeeping type each delivery is kept as (`_webhook_message`): what a World reads to see what was sent. */
105
+ /** The bookkeeping type each delivery is kept as (`_webhook_message`), otherwise `_world_event_delivery`.
106
+ * Queuing and the first transport attempt/outcome are separate retained facts. */
103
107
  record?: string;
104
108
  };
105
109
 
@@ -117,25 +121,23 @@ export type EventWrite = { operation: string; storedType: string; body: Record<s
117
121
  * (a Slack message renders as `message` and as `app_mention`). */
118
122
  export type EventRender<C> = (ctx: C, write: EventWrite, type: string) => Record<string, unknown>;
119
123
 
120
- /** A delivery made: where it went and exactly what was sent. */
124
+ /** A rendered delivery packet. Its transport attempt and acceptance are separate retained facts. */
121
125
  export type EventDelivery = { url: string; body: string; headers: Record<string, string> };
122
126
 
123
127
  /** The transport a delivery is made over; the World's by default, a test's own when given. */
124
128
  export type EventTransport = (url: string, body: string, headers: Record<string, string>) => Promise<number | void>;
125
129
 
126
- const digest = (s: string): string => createHash('sha256').update(s).digest('hex');
127
-
128
130
  /** A `webhook-id` scheme's key: the base64 part of the secret, after its prefix when it carries one. */
129
131
  const webhookKey = (scheme: { secretPrefix?: string; secretKey?: 'base64' | 'utf8' }, secret: string): Buffer =>
130
132
  scheme.secretKey === 'utf8' ? Buffer.from(secret, 'utf8') : Buffer.from(scheme.secretPrefix && secret.startsWith(scheme.secretPrefix) ? secret.slice(scheme.secretPrefix.length) : secret, 'base64');
131
133
 
132
134
  /** A signature scheme's headers for one delivery: deterministic (the id is derived from the delivery itself). */
133
- /** `seq` tells apart two identical deliveries at one instant (a frozen World clock): the World's count of deliveries
134
- * made before this one, so a receiver that deduplicates by id drops neither. */
135
- export function signEvent(scheme: EventScheme, secret: string, body: string, seconds: number, url: string, seq = 0): Record<string, string> {
135
+ /** `seq` tells apart deliveries at one instant; the declared-event path reserves it durably. `identity` supplies that
136
+ * occurrence's namespace. Omitted, existing direct callers retain their deterministic signing contract. */
137
+ export function signEvent(scheme: EventScheme, secret: string, body: string, seconds: number, url: string, seq = 0, identity?: string): Record<string, string> {
136
138
  if (scheme.kind === 'webhook-id') {
137
139
  // Svix's `msg_` and a KSUID (its ids' form), derived from the delivery itself
138
- const id = `msg_${ksuidFrom(seconds, seq, `${url}|${seconds}|${seq}|${body}`)}`;
140
+ const id = `msg_${ksuidFrom(seconds, seq, identity ?? `${url}|${seconds}|${seq}|${body}`)}`;
139
141
  const p = scheme.prefix.toLowerCase();
140
142
  const signature = createHmac('sha256', webhookKey(scheme, secret)).update(`${id}.${seconds}.${body}`, 'utf8').digest('base64');
141
143
  return { [`${p}-id`]: id, [`${p}-timestamp`]: String(seconds), [`${p}-signature`]: `v1,${signature}` };
@@ -161,8 +163,25 @@ const hmacBase = (scheme: { timestampHeader?: string; signed?: string }, seconds
161
163
  scheme.signed !== undefined ? scheme.signed.replaceAll('{t}', String(seconds)).replaceAll('{body}', body) : scheme.timestampHeader ? `${seconds}.${body}` : body;
162
164
 
163
165
  /** Every scheme's headers for one delivery, merged. */
164
- export function signEventAll(schemes: EventScheme | ReadonlyArray<EventScheme>, secret: string, body: string, seconds: number, url: string, seq = 0): Record<string, string> {
165
- return Object.assign({}, ...(Array.isArray(schemes) ? schemes : [schemes]).map((scheme: EventScheme) => signEvent(scheme, secret, body, seconds, url, seq)));
166
+ export function signEventAll(schemes: EventScheme | ReadonlyArray<EventScheme>, secret: string, body: string, seconds: number, url: string, seq = 0, identity?: string): Record<string, string> {
167
+ return Object.assign({}, ...(Array.isArray(schemes) ? schemes : [schemes]).map((scheme: EventScheme) => signEvent(scheme, secret, body, seconds, url, seq, identity)));
168
+ }
169
+
170
+ /** Reserve occurrences under the same cross-process lock that appends actions. This private retained row is never a
171
+ * vendor resource. Deleted event/delivery rows, concurrent renders and restarts cannot give its ordinals back. */
172
+ async function reserveEventOrdinals(service: string, root: string | undefined, occurredAt: string, count: number): Promise<number> {
173
+ const { value } = await applyTwinWriteAtomic(service, (resources) => {
174
+ const counter = resources.find((r) => r.type === '_world_event_sequence' && r.id === 'sequence');
175
+ const last = counter === undefined ? 0 : (counter as Record<string, unknown>).last_ordinal;
176
+ if (typeof last !== 'number' || !Number.isSafeInteger(last) || last < 0 || !Number.isSafeInteger(count) || count < 1 || !Number.isSafeInteger(last + count)) {
177
+ throw new Error('Invalid retained declared-event sequence; occurrence ids cannot be reused.');
178
+ }
179
+ return { kind: 'write', value: last + 1, write: {
180
+ operation: 'world_event_sequence.reserve', subjectType: '_world_event_sequence', subjectId: 'sequence',
181
+ fields: { last_ordinal: last + count }, input: { clock: true }, occurredAt, actor: { kind: 'system' },
182
+ } };
183
+ }, root);
184
+ return value;
166
185
  }
167
186
 
168
187
  /** A delivery's signature checked as its receiver checks it (a Standard Webhooks verifier, a timestamp-v1 verifier, a plain
@@ -287,8 +306,8 @@ const worldTransport: EventTransport = async (url, body, headers) => {
287
306
  try { return ((await appFetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body })) as { status: number }).status; } catch { return 0; }
288
307
  };
289
308
 
290
- /** Who watches what the World sends (a walk reading each delivery as its receiver would): told of every delivery as it
291
- * is made, before its transport. Set and restored by the walk, as its World store is; unset, nothing watches. */
309
+ /** Who watches signed packets before dispatch (a walk reading each as its receiver would). This is not evidence of
310
+ * transport or acceptance. Set and restored by the walk, as its World store is; unset, nothing watches. */
292
311
  export type DeliveryObserver = (delivery: EventDelivery & { service: string }) => void;
293
312
  let deliveryObserver: DeliveryObserver | undefined;
294
313
  export function setDeliveryObserver(observer: DeliveryObserver | undefined): DeliveryObserver | undefined {
@@ -386,11 +405,19 @@ async function deliverEventType(
386
405
  const seconds = Math.floor(millis / 1000);
387
406
  const eventData = await data(type);
388
407
  const extra = values ? await values(type) : {};
389
- // the World's count of events kept so far tells apart two identical events at one instant (a frozen clock)
390
- const kept = decl.store !== undefined ? all.filter((r) => r.type === decl.store).length : 0;
408
+ // Reserve one event and at most one delivery per live endpoint before assigning identities. Unused ordinals are
409
+ // harmless gaps; a failed render/store never gives them back. This is not an atomic originating-write outbox.
410
+ const ordinal = await reserveEventOrdinals(service, write.root, write.occurredAt, 1 + live.length);
411
+ // Fresh/forked Worlds can reserve the same ordinal at a pinned clock. Keep
412
+ // content binding as well as the lineage's occurrence ordinal, so different
413
+ // event payloads cannot reuse a receiver's event/webhook id across those Worlds.
414
+ const eventContent = createHash('sha256').update(JSON.stringify({
415
+ operation: write.operation, type, data: eventData, envelope: decl.envelope,
416
+ values: extra, request: requestValues(write.request),
417
+ }), 'utf8').digest('hex');
391
418
  const filled: Record<string, unknown> = {
392
419
  $type: type, $data: eventData,
393
- $id: `evt_${ksuidFrom(seconds, kept, `${type}|${write.occurredAt}|${kept}|${JSON.stringify(eventData)}`)}`,
420
+ $id: `evt_${ksuidFrom(seconds, ordinal, `world-events:v1:${service}:${type}:${ordinal}:${eventContent}`)}`,
394
421
  '$time.ms': millis, '$time.s': seconds, '$time.iso': write.occurredAt,
395
422
  ...requestValues(write.request),
396
423
  ...extra,
@@ -407,29 +434,42 @@ async function deliverEventType(
407
434
  const shared = JSON.stringify(envelope);
408
435
  const each = perEndpoint(decl.envelope);
409
436
  const sent: EventDelivery[] = [];
410
- const before = decl.record !== undefined ? all.filter((r) => r.type === decl.record).length : 0;
437
+ const recorded: Array<{ type: string; id: string }> = [];
411
438
  for (const [i, { row, kind }] of endpoints.entries()) {
412
439
  const body = each ? JSON.stringify(fillEnvelope(decl.envelope, filled, row)) : shared;
413
440
  const url = String(at(row, kind.url));
414
- const seq = before + i;
441
+ const seq = ordinal + 1 + i;
442
+ const deliveryContent = createHash('sha256').update(url, 'utf8').update('\0').update(body, 'utf8').digest('hex');
443
+ const identity = `world-events:v1:${service}:${seq}:${deliveryContent}`;
415
444
  const extraHeaders = decl.headers ? headersFor(decl.headers, filled, row) : {};
416
- const headers = { ...extraHeaders, ...signEventAll(decl.scheme, String(at(row, kind.secret)), body, seconds, url, seq) };
445
+ const headers = { ...extraHeaders, ...signEventAll(decl.scheme, String(at(row, kind.secret)), body, seconds, url, seq, identity) };
417
446
  sent.push({ url, body, headers });
418
447
  // kept with the write, by an id of the delivery itself (two made at once never take the same one)
419
- if (decl.record !== undefined) {
420
- await applyTwinWrite(service, {
421
- operation: `${decl.record.slice(1)}.record`, subjectType: decl.record, subjectId: `${decl.record.slice(1)}_${digest(`${url}|${seconds}|${seq}|${body}`).slice(0, 16)}`,
422
- fields: { url, body, headers, event_type: type, sent_at: millis }, occurredAt: write.occurredAt, actor: { kind: 'system' },
423
- }, write.root);
424
- }
448
+ const record = decl.record ?? '_world_event_delivery';
449
+ const id = `${record.slice(1)}_v1_${seq}`;
450
+ await applyTwinWrite(service, {
451
+ operation: `${record.slice(1)}.record`, subjectType: record, subjectId: id,
452
+ fields: { url, body, headers, event_type: type, sent_at: millis,
453
+ delivery_protocol: 1, event_id: String(filled.$id),
454
+ endpoint_type: kind.storedAs, endpoint_id: String(row.id),
455
+ delivery_state: 'queued', attempt_count: 0, current_attempt: null,
456
+ transport_status: null, finished_at: null, accepted_at: null,
457
+ }, occurredAt: write.occurredAt, actor: { kind: 'system' },
458
+ }, write.root);
459
+ recorded.push({ type: record, id });
425
460
  }
426
461
  // sent after the write's answer, as a vendor sends it: the caller never waits on its receiver, and a receiver that is
427
462
  // slow or down fails no request
428
463
  // the write's trace is read now, inside its request, and carried on each delivery (W3C trace continuation)
429
464
  const trace = deliveryTraceHeaders();
465
+ // A timer must retain the request's store/root even if another World becomes
466
+ // active before it runs. Transport completion writes to the same retained state.
467
+ const store = getActiveWorldStore(), deliveryRoot = worldPaths(service, write.root).root;
430
468
  for (const d of sent) deliveryObserver?.({ ...d, service });
431
- for (const { url, body: payload, headers } of sent) setTimeout(() => {
432
- void transport(url, payload, { ...headers, ...trace }).catch(() => {});
469
+ for (const packet of recorded) setTimeout(() => {
470
+ // Persistence failure leaves a queued/sending claim for explicit recovery;
471
+ // it must not fail the already-committed vendor write or cause a blind resend.
472
+ void withWorldStore(store, () => sendRecordedEvent(service, packet.type, packet.id, deliveryRoot, transport, trace)).catch(() => {});
433
473
  }, 0);
434
474
  return sent;
435
475
  }