@sigrun/ext-gateway 0.1.1 → 0.1.2

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,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.2] - 2026-10-28
11
+
12
+ ### Fixed
13
+ - The interaction notifier and chat answer correlation now inspect
14
+ records through core's non-consuming `peekInteraction` instead of the
15
+ one-shot delivery read. The early-answer race is closed: a secret
16
+ interaction answered in the webui before the notifier's first pass no
17
+ longer loses the owning extension's raw-secret delivery (the gateway
18
+ owns no delivery; regression-tested with a fixture owner).
19
+ - Home-DM settled notices report failed/aborted runs truthfully:
20
+ `Reply ready` only for answered runs; unanswered runs read `Run ended
21
+ without a reply` with the settle reason (empty reasons included).
22
+ Recipient, dedup, bound-conversation suppression, and `notify.home`
23
+ gating are unchanged.
24
+
10
25
  ## [0.1.1] - 2026-10-27
11
26
  ### Fixed
12
27
  - First real publish. `0.1.0` was registered with the npm name bootstrap but
package/README.md CHANGED
@@ -179,11 +179,34 @@ buttons; a later tap reports that outcome rather than answering again.
179
179
  Unbound conversations notify each write-access user's home DMs, while
180
180
  bound conversations prompt each bound chat once.
181
181
 
182
- **Known gap (secret timing):** a secret interaction answered in the
183
- webui before the gateway's notifier first runs can lose that one-shot
184
- secret delivery — the notifier's record read is the delivery-consuming
185
- read. Answering after the prompt rendered is safe. The durable fix is a
186
- core-provided non-consuming record read.
182
+ Every gateway interaction read is a non-consuming `peekInteraction`:
183
+ the gateway owns no secret delivery (secret records are webui-plan), so
184
+ inspection can never eat the owning extension's one-shot raw-secret
185
+ read. A secret interaction answered in the webui before the notifier's
186
+ first pass is safe — the notifier peeks masked data and the owner still
187
+ receives the secret exactly once.
188
+
189
+ Home-DM settled notices are truthful about the outcome: an answered run
190
+ reads `Reply ready`; a failed or aborted run reads `Run ended without a
191
+ reply` with the settle reason, never reply-ready language.
192
+
193
+ ### Prompt retention
194
+
195
+ Platform prompt messages are never deleted by the gateway. What the
196
+ gateway retains locally is the correlation/edit metadata in
197
+ `prompts.json`: message refs and answer plans expire seven days after
198
+ the interaction settles, so a settlement edit (or a late delivery
199
+ receipt's self-heal edit) is only possible inside that window. Compact
200
+ settled tombstones — recipient identity, settlement time, and outcome
201
+ text — persist permanently for replay suppression: a delayed receipt
202
+ or replay after the row expired recreates nothing and can never make
203
+ an answered prompt answerable again. Pending, unsettled prompts are
204
+ never age-pruned. Refs/plans are therefore bounded by active prompts
205
+ plus seven days of settled history; the small tombstone set grows with
206
+ interaction history by design (the file as a whole is NOT bounded).
207
+ Legacy unversioned state migrates on startup without losing refs or
208
+ re-opening old prompts; a wholly malformed `prompts.json` fails loudly
209
+ rather than silently resetting interaction state.
187
210
 
188
211
  ## Delivery, state, and shutdown
189
212
 
package/dist/engine.js CHANGED
@@ -39,7 +39,7 @@ import { Ledger } from "./state/ledger.js";
39
39
  import { DeliveryWorker } from "./outbound/delivery.js";
40
40
  import { onRunSettled } from "./outbound/settled.js";
41
41
  import { createNotifier } from "./notify.js";
42
- import { SessionMetaDoc } from "./session-meta.js";
42
+ import { SessionMetaDoc, readJobMeta } from "./session-meta.js";
43
43
  import { StreamRegistry } from "./outbound/stream.js";
44
44
  import { hashId } from "./log.js";
45
45
  import { collectAttachments } from "./inbound/media.js";
@@ -272,7 +272,7 @@ export function createEngine(host, deps = {}) {
272
272
  // The event bus's narrowed snapshot API works in harness-only
273
273
  // embedders; host.conversations.meta would wait for a server
274
274
  // bridge that those embedders never create.
275
- readMeta: async (conversationId) => await api.document(SessionMetaDoc, conversationId, context),
275
+ readMeta: async (conversationId) => await readJobMeta(async () => await api.document(SessionMetaDoc, conversationId, context), (message) => { log.once("session-meta-read", "warn", message); }),
276
276
  });
277
277
  const notifyDeps = () => ({
278
278
  host,
@@ -511,7 +511,7 @@ export function createEngine(host, deps = {}) {
511
511
  },
512
512
  resolveFiles: (entry) => ledger.resolveFiles(host, entry),
513
513
  onInteractionReceipt: async (entry, receipt) => {
514
- await receiptForInteraction(prompts, entry, receipt);
514
+ await receiptForInteraction(prompts, entry, receipt, interactionDeps());
515
515
  },
516
516
  });
517
517
  workers.set(id, worker);
@@ -15,9 +15,10 @@ export async function answerFromChat(deps, answer) {
15
15
  if (access !== "write")
16
16
  return "You do not have write access to this conversation.";
17
17
  // Terminal-snapshot invariant: secret-bearing records are webui-plan,
18
- // so no chat answer reaches this consuming read. Widening the planner
19
- // requires a non-consuming snapshot before any terminal path reads one.
20
- const record = await deps.host.snapshotInteraction(answer.conversationId, answer.callId, deps.context ?? { abortSignal: new AbortController().signal });
18
+ // so no chat answer reaches a secret record. The gateway owns NO
19
+ // secret delivery regardless — this correlation/terminal read only
20
+ // INSPECTS, so it is a peek (non-consuming, masked) by construction.
21
+ const record = await deps.host.peekInteraction(answer.conversationId, answer.callId, deps.context ?? { abortSignal: new AbortController().signal });
21
22
  if (record !== undefined && TERMINAL.has(record.status)) {
22
23
  return `Already ${outcomeOf(record)}.`;
23
24
  }
@@ -18,3 +18,11 @@ export interface InteractionChatDeps extends AnswerDeps {
18
18
  export declare function createInteractionNotifier(deps: InteractionChatDeps): NotifierContribution;
19
19
  /** Enqueue outcome edits/notices and retire numbered/reply-to matching. */
20
20
  export declare function onInteractionSettled(event: ExtensionLifecyclePayload["interactionSettled"], deps: InteractionChatDeps, context: HandlerContext): Promise<void>;
21
+ /**
22
+ * Enqueue settlement edits and per-binding fallback outcome notices
23
+ * (a binding without an editable ref gets a notice). Called at settlement AND from the receipt
24
+ * path when a real delivery ref arrives for an ALREADY-settled prompt:
25
+ * settlement that beats the receipt leaves no editable ref, so the
26
+ * late receipt is the last chance to replace the live buttons.
27
+ */
28
+ export declare function enqueueSettlementEdits(deps: InteractionChatDeps, conversationId: number, callId: string, outcome: string): Promise<void>;
@@ -39,24 +39,32 @@ async function notifyInteraction(event, deps) {
39
39
  targets.push({ key: `user:${event.user}`, channel: home.channel, chat: home.chat });
40
40
  }
41
41
  }
42
- // Resolve and dedupe targets BEFORE reading the record. snapshotInteraction
43
- // consumes a settled secret's one-shot delivery, even when we then return.
44
- // Final-review follow-up: core should offer a non-consuming snapshot read.
42
+ // Resolve and dedupe targets BEFORE reading the record. The read is a
43
+ // PEEK: the gateway owns NO secret delivery (secret records are
44
+ // webui-plan), so it must never consume one — peekInteraction is the
45
+ // non-consuming inspection read (a settled secret's one-shot delivery
46
+ // belongs to the owning extension's snapshotInteraction alone).
45
47
  const pendingTargets = targets.filter((target) => !deps.prompts.has(event.conversationId, event.callId, target.key));
46
48
  if (pendingTargets.length === 0)
47
49
  return;
48
50
  const context = deps.context ?? { abortSignal: new AbortController().signal };
49
- const record = await deps.host.snapshotInteraction(event.conversationId, event.callId, context);
51
+ const record = await deps.host.peekInteraction(event.conversationId, event.callId, context);
50
52
  if (record === undefined || record.status !== "pending")
51
53
  return;
54
+ // A settle can land while the render above awaited: re-check BEFORE
55
+ // the first enqueue so an already-settled interaction never gets a
56
+ // live prompt (the buttons would only answer "Already answered.").
57
+ const still = await deps.host.peekInteraction(event.conversationId, event.callId, context);
58
+ if (still === undefined || still.status !== "pending")
59
+ return;
52
60
  for (const target of pendingTargets) {
53
61
  const channel = deps.channelOf(target.channel);
54
62
  if (channel === undefined) {
55
63
  deps.log.warn(`interaction prompt skipped: no channel "${target.channel}"`);
56
64
  continue;
57
65
  }
58
- const plan = planInteraction(record, channel.capabilities);
59
- const rendered = renderInteraction(record, channel.capabilities, {
66
+ const plan = planInteraction(still, channel.capabilities);
67
+ const rendered = renderInteraction(still, channel.capabilities, {
60
68
  conversationId: event.conversationId,
61
69
  conversationName: (await deps.host.conversations.meta(event.conversationId)).name ?? `Conversation ${event.conversationId}`,
62
70
  ...(event.url === undefined ? {} : { link: event.url }),
@@ -64,8 +72,17 @@ async function notifyInteraction(event, deps) {
64
72
  ? { ...action, conversationId: event.conversationId }
65
73
  : action),
66
74
  });
67
- await deps.prompts.markEnqueued(event.conversationId, event.callId, target.key, plan);
68
- await deps.ledger.enqueue({
75
+ // The ledger enqueue precedes the prompt row: the ledger tag is
76
+ // the durable dedupe, so a throwing enqueue must NOT leave a row
77
+ // that would suppress this target on every replay.
78
+ // Re-check per target immediately before its enqueue: rendering
79
+ // and meta lookups await, and a settle in that window must not
80
+ // deliver another live prompt. markEnqueued independently checks
81
+ // the durable call-level settlement marker as the final guard.
82
+ const current = await deps.host.peekInteraction(event.conversationId, event.callId, context);
83
+ if (current === undefined || current.status !== "pending")
84
+ return;
85
+ const enqueued = await deps.ledger.enqueue({
69
86
  channel: target.channel,
70
87
  ...(target.bindingKey === undefined ? {} : { bindingKey: target.bindingKey }),
71
88
  chat: target.chat,
@@ -73,6 +90,15 @@ async function notifyInteraction(event, deps) {
73
90
  message: { chat: target.chat, ...rendered },
74
91
  tag: `interaction:${event.conversationId}:${event.callId}:${target.key}`,
75
92
  });
93
+ await deps.prompts.markEnqueued(event.conversationId, event.callId, target.key, plan);
94
+ // A settlement between the pending peek and markEnqueued writes the
95
+ // call-level tombstone, so no row was reserved. Compensate the lost
96
+ // reservation by cancelling the still-pending prompt: otherwise its
97
+ // delivery would leave live buttons and duplicate the outcome notice.
98
+ if (enqueued !== undefined && !deps.prompts.has(event.conversationId, event.callId, target.key)) {
99
+ await deps.ledger.cancel(enqueued.id);
100
+ return;
101
+ }
76
102
  }
77
103
  }
78
104
  /** Enqueue outcome edits/notices and retire numbered/reply-to matching. */
@@ -84,58 +110,76 @@ export async function onInteractionSettled(event, deps, context) {
84
110
  // the extension's only chance to receive the secret. The widened C6
85
111
  // event carries everything chat rendering needs (the stored answer
86
112
  // is masked by construction — secrets never persist).
87
- const who = event.answeredByUser === undefined ? "" : ` by ${event.answeredByUser}`;
88
- // SAFETY: the stored answer's values are validated JsonValue (the
89
- // record's answer shape); the payload types them loosely because
90
- // the contract cannot import the json module's runtime shape.
91
- const answerValues = (event.answer ?? {});
92
- const outcome = event.status === "answered"
93
- ? `answered${who}: ${summarizeValues(answerValues)}`
94
- : `${event.status}${who}`;
95
- const editableChannels = new Set();
96
- const refs = deps.prompts.refsOf(event.conversationId, event.callId);
97
- for (const ref of refs) {
98
- const id = channelForRef(deps, event.conversationId, event.callId, ref);
99
- const channel = id === undefined ? undefined : deps.channelOf(id);
100
- if (channel?.capabilities.edit !== undefined && channel.edit !== undefined && ref.messageId !== "") {
101
- editableChannels.add(channel.id);
102
- const bindingKey = deps.bindings.keysOf(event.conversationId).find((key) => {
103
- const binding = deps.bindings.get(key);
104
- return binding?.chat.chatId === ref.chat.chatId && binding.chat.threadId === ref.chat.threadId;
105
- });
106
- await deps.ledger.enqueue({
107
- channel: channel.id,
108
- ...(bindingKey === undefined ? {} : { bindingKey }),
109
- chat: ref.chat,
110
- kind: "edit",
111
- ref,
112
- message: { chat: ref.chat, text: truncateInteractionText(`Interaction ${outcome}.`, channel.capabilities.maxMessageLength) },
113
- tag: `interaction-settled:${event.conversationId}:${event.callId}:${channel.id}:${ref.messageId}`,
114
- });
115
- }
116
- }
117
- if (editableChannels.size === 0) {
118
- for (const key of deps.bindings.keysOf(event.conversationId)) {
119
- const binding = deps.bindings.get(key);
120
- const channelId = key.split(":")[0];
121
- if (binding === undefined || binding.dead === true)
122
- continue;
123
- await deps.ledger.enqueue({
124
- channel: channelId,
125
- bindingKey: key,
126
- chat: binding.chat,
127
- kind: "notice",
128
- message: { chat: binding.chat, text: `Interaction ${outcome}.` },
129
- tag: `interaction-settled-notice:${event.conversationId}:${event.callId}:${key}`,
130
- });
131
- }
132
- }
133
- await deps.prompts.settle(event.conversationId, event.callId);
113
+ const outcome = outcomeText(event);
114
+ await enqueueSettlementEdits(deps, event.conversationId, event.callId, outcome);
115
+ await deps.prompts.settle(event.conversationId, event.callId, outcome);
134
116
  }
135
117
  catch (error) {
136
118
  deps.log.error(`interaction settlement delivery failed (${String(event.conversationId)}:${event.callId})`, error);
137
119
  }
138
120
  }
121
+ /** The outcome summary the settlement edit/notice carries. */
122
+ function outcomeText(event) {
123
+ const who = event.answeredByUser === undefined ? "" : ` by ${event.answeredByUser}`;
124
+ // SAFETY: the stored answer's values are validated JsonValue (the
125
+ // record's answer shape); the payload types them loosely because
126
+ // the contract cannot import the json module's runtime shape.
127
+ const answerValues = (event.answer ?? {});
128
+ return event.status === "answered"
129
+ ? `answered${who}: ${summarizeValues(answerValues)}`
130
+ : `${event.status}${who}`;
131
+ }
132
+ /**
133
+ * Enqueue settlement edits and per-binding fallback outcome notices
134
+ * (a binding without an editable ref gets a notice). Called at settlement AND from the receipt
135
+ * path when a real delivery ref arrives for an ALREADY-settled prompt:
136
+ * settlement that beats the receipt leaves no editable ref, so the
137
+ * late receipt is the last chance to replace the live buttons.
138
+ */
139
+ export async function enqueueSettlementEdits(deps, conversationId, callId, outcome) {
140
+ const editableBindings = new Set();
141
+ const refs = deps.prompts.refsOf(conversationId, callId);
142
+ for (const ref of refs) {
143
+ const id = channelForRef(deps, conversationId, callId, ref);
144
+ const channel = id === undefined ? undefined : deps.channelOf(id);
145
+ const bindingKey = deps.bindings.keysOf(conversationId).find((key) => {
146
+ const binding = deps.bindings.get(key);
147
+ return binding?.chat.chatId === ref.chat.chatId && binding.chat.threadId === ref.chat.threadId;
148
+ });
149
+ if (channel?.capabilities.edit !== undefined && channel.edit !== undefined && ref.messageId !== "") {
150
+ if (bindingKey !== undefined)
151
+ editableBindings.add(bindingKey);
152
+ await deps.ledger.enqueue({
153
+ channel: channel.id,
154
+ ...(bindingKey === undefined ? {} : { bindingKey }),
155
+ chat: ref.chat,
156
+ kind: "edit",
157
+ ref,
158
+ message: { chat: ref.chat, text: truncateInteractionText(`Interaction ${outcome}.`, channel.capabilities.maxMessageLength) },
159
+ tag: `interaction-settled:${conversationId}:${callId}:${channel.id}:${ref.messageId}`,
160
+ });
161
+ }
162
+ }
163
+ // Fallback eligibility is PER BINDING: an editable ref on one channel
164
+ // must not suppress the outcome notice owed to another binding whose
165
+ // channel cannot edit (or whose prompt has no editable ref).
166
+ for (const key of deps.bindings.keysOf(conversationId)) {
167
+ if (editableBindings.has(key))
168
+ continue;
169
+ const binding = deps.bindings.get(key);
170
+ const channelId = key.split(":")[0];
171
+ if (binding === undefined || binding.dead === true)
172
+ continue;
173
+ await deps.ledger.enqueue({
174
+ channel: channelId,
175
+ bindingKey: key,
176
+ chat: binding.chat,
177
+ kind: "notice",
178
+ message: { chat: binding.chat, text: `Interaction ${outcome}.` },
179
+ tag: `interaction-settled-notice:${conversationId}:${callId}:${key}`,
180
+ });
181
+ }
182
+ }
139
183
  /** Recover the channel from the binding key/home prompt target. */
140
184
  function channelForRef(deps, conversationId, callId, ref) {
141
185
  for (const key of deps.bindings.keysOf(conversationId)) {
@@ -1,21 +1,40 @@
1
1
  /**
2
2
  * Durable chat-prompt bookkeeping. Ledger delivery receipts become
3
3
  * MessageRefs here; pending reads stay synchronous for inbound stage 8.
4
- * Settled prompts stop matching numbered/reply-to answers; their refs
5
- * remain so settlement can edit the delivered platform message.
4
+ * Settled prompts stop matching numbered/reply-to answers.
5
+ *
6
+ * Retention (release-readiness spec 4 §§2–4): the persisted file is
7
+ * version 2 — `prompts` rows plus a `settled` tombstone map carrying
8
+ * the settlement timestamp and outcome text. Seven days after
9
+ * settlement the bulky row (refs/plan) expires; the compact tombstone
10
+ * persists PERMANENTLY so a delayed receipt or replay can never
11
+ * resurrect an answerable prompt. Pending rows are never age-pruned.
12
+ * Pruning runs at store initialization and at most once per write (no
13
+ * timer), through the one `prune` helper and its boundary predicate.
14
+ * Refs/plans are therefore bounded by active prompts plus seven days
15
+ * of settled history; the tombstone set is deliberately unbounded.
6
16
  */
7
17
  import type { MessageRef } from "@sigrun/sigrun/extension";
18
+ import type { EngineLog } from "../log.js";
8
19
  import type { ChatAnswerPlan } from "./render.js";
20
+ /** Settled-row retention (matches the delivery ledger's seven days). */
21
+ export declare const PROMPT_RETENTION_MS = 604800000;
9
22
  export interface PendingPrompt {
10
23
  readonly conversationId: number;
11
24
  readonly callId: string;
12
25
  readonly plan: ChatAnswerPlan;
13
26
  }
27
+ export interface PromptStoreOptions {
28
+ readonly now?: () => number;
29
+ readonly log?: EngineLog;
30
+ }
14
31
  export declare class PromptStore {
15
32
  private readonly file;
33
+ private readonly now;
34
+ private readonly log;
16
35
  private rows;
17
36
  private settled;
18
- constructor(path: string);
37
+ constructor(path: string, options?: PromptStoreOptions);
19
38
  /** Record a delivered prompt ref (idempotent for one recipient key). */
20
39
  record(conversationId: number, callId: string, key: string, ref: MessageRef, plan: ChatAnswerPlan): Promise<void>;
21
40
  /** All delivered message refs for settlement edits (including settled). */
@@ -37,6 +56,13 @@ export declare class PromptStore {
37
56
  markEnqueued(conversationId: number, callId: string, key: string, plan: ChatAnswerPlan): Promise<void>;
38
57
  /** Install the real refs after the interaction ledger entry is delivered. */
39
58
  recordReceipt(conversationId: number, callId: string, key: string, chat: MessageRef["chat"], messageIds: readonly string[]): Promise<void>;
40
- /** Retire pending answers while preserving refs for the settlement edit. */
41
- settle(conversationId: number, callId: string): Promise<void>;
59
+ /** Retire pending answers; the refs stay until retention expires them. */
60
+ settle(conversationId: number, callId: string, outcome?: string): Promise<void>;
61
+ /**
62
+ * The outcome text recorded at settlement, when this identity is
63
+ * settled. The receipt path reads it to self-heal the prompt edit;
64
+ * the tombstone outlives the bulky row, so the answer survives
65
+ * pruning even when no editable ref remains.
66
+ */
67
+ settledOutcome(conversationId: number, callId: string, key: string): string | undefined;
42
68
  }
@@ -1,37 +1,90 @@
1
1
  /**
2
2
  * Durable chat-prompt bookkeeping. Ledger delivery receipts become
3
3
  * MessageRefs here; pending reads stay synchronous for inbound stage 8.
4
- * Settled prompts stop matching numbered/reply-to answers; their refs
5
- * remain so settlement can edit the delivered platform message.
4
+ * Settled prompts stop matching numbered/reply-to answers.
5
+ *
6
+ * Retention (release-readiness spec 4 §§2–4): the persisted file is
7
+ * version 2 — `prompts` rows plus a `settled` tombstone map carrying
8
+ * the settlement timestamp and outcome text. Seven days after
9
+ * settlement the bulky row (refs/plan) expires; the compact tombstone
10
+ * persists PERMANENTLY so a delayed receipt or replay can never
11
+ * resurrect an answerable prompt. Pending rows are never age-pruned.
12
+ * Pruning runs at store initialization and at most once per write (no
13
+ * timer), through the one `prune` helper and its boundary predicate.
14
+ * Refs/plans are therefore bounded by active prompts plus seven days
15
+ * of settled history; the tombstone set is deliberately unbounded.
6
16
  */
7
17
  import { JsonFile } from "../state/json-file.js";
18
+ /** Settled-row retention (matches the delivery ledger's seven days). */
19
+ export const PROMPT_RETENTION_MS = 604_800_000;
20
+ /** The persisted schema version (v1 = unversioned, `settled: string[]`). */
21
+ const PROMPT_FILE_VERSION = 2;
8
22
  export class PromptStore {
9
23
  file;
24
+ now;
25
+ log;
10
26
  rows;
11
27
  settled;
12
- constructor(path) {
13
- this.file = new JsonFile(path, () => ({ prompts: [], settled: [] }));
28
+ constructor(path, options = {}) {
29
+ this.now = options.now ?? (() => Date.now());
30
+ this.log = options.log;
31
+ this.file = new JsonFile(path, () => ({
32
+ version: PROMPT_FILE_VERSION,
33
+ prompts: [],
34
+ settled: {},
35
+ }));
14
36
  const state = this.file.readSync();
15
- this.rows = state.prompts;
16
- this.settled = new Set(state.settled);
37
+ const migrated = migrate(state, this.now(), this.log);
38
+ // Initialization pruning rides the same helper the writes use.
39
+ prune(migrated, this.now());
40
+ // Persist the normalized/pruned shape ONLY when something changed:
41
+ // a legacy or repaired file must not re-migrate on every boot,
42
+ // while a healthy v2 file needs no rewrite at construction.
43
+ if (JSON.stringify(state) !== JSON.stringify(migrated)) {
44
+ this.file.updateSync((draft) => {
45
+ draft.version = migrated.version;
46
+ draft.prompts = migrated.prompts;
47
+ draft.settled = migrated.settled;
48
+ });
49
+ }
50
+ this.rows = migrated.prompts;
51
+ this.settled = migrated.settled;
17
52
  }
18
53
  /** Record a delivered prompt ref (idempotent for one recipient key). */
19
54
  async record(conversationId, callId, key, ref, plan) {
20
55
  const state = await this.file.update((draft) => {
21
56
  const identity = `${conversationId}:${callId}:${key}`;
22
- // A settlement raced the receipt: preserve the newly delivered
23
- // ref for the edit, never re-open the prompt for answers.
24
- if (draft.settled.includes(identity) && ref.messageId === "")
57
+ const tombstone = draft.settled[identity];
58
+ const existing = draft.prompts.find((row) => identityOf(row) === identity);
59
+ // Settlement is CALL-scoped, not only row-scoped. A settlement
60
+ // landing before the first row exists still records a marker;
61
+ // otherwise a later fan-out/receipt could create an answerable
62
+ // prompt for an already-settled interaction. A real receipt may
63
+ // still install refs on a surviving row for the settlement edit.
64
+ if (existing === undefined && draft.settled[`${conversationId}:${callId}:`] !== undefined)
25
65
  return;
26
- const existing = draft.prompts.find((row) => row.conversationId === conversationId && row.callId === callId && row.key === key);
66
+ if (tombstone !== undefined) {
67
+ // A settlement raced this record. An EMPTY ref never replaces
68
+ // a delivered one; a REAL ref still installs while the row
69
+ // survives so the late receipt can self-heal the edit. Once
70
+ // the row is pruned, BOTH shapes are ignored — no resurrection.
71
+ const existing = draft.prompts.find((row) => identityOf(row) === identity);
72
+ if (existing === undefined)
73
+ return;
74
+ if (ref.messageId === "")
75
+ return;
76
+ if (existing.ref.messageId !== "")
77
+ return;
78
+ }
27
79
  const next = { conversationId, callId, key, ref, plan };
28
80
  if (existing === undefined)
29
81
  draft.prompts.push(next);
30
82
  else
31
83
  draft.prompts[draft.prompts.indexOf(existing)] = next;
84
+ prune(draft, this.now());
32
85
  });
33
86
  this.rows = state.prompts;
34
- this.settled = new Set(state.settled);
87
+ this.settled = state.settled;
35
88
  }
36
89
  /** All delivered message refs for settlement edits (including settled). */
37
90
  refsOf(conversationId, callId) {
@@ -58,14 +111,14 @@ export class PromptStore {
58
111
  /** Unsettled choice/text prompts in one chat (bound key or user home key). */
59
112
  pendingInChat(bindingKey) {
60
113
  return this.rows
61
- .filter((row) => row.key === bindingKey && !this.settled.has(identityOf(row)))
114
+ .filter((row) => row.key === bindingKey && this.settled[identityOf(row)] === undefined)
62
115
  .map((row) => ({ conversationId: row.conversationId, callId: row.callId, plan: row.plan }));
63
116
  }
64
117
  /** Find the prompt behind a reply-to platform message id. */
65
118
  byReplyTo(bindingKey, messageId) {
66
119
  const row = this.rows.find((candidate) => candidate.key === bindingKey &&
67
120
  candidate.ref.messageId === messageId &&
68
- !this.settled.has(identityOf(candidate)));
121
+ this.settled[identityOf(candidate)] === undefined);
69
122
  return row === undefined
70
123
  ? undefined
71
124
  : { conversationId: row.conversationId, callId: row.callId, plan: row.plan };
@@ -79,18 +132,115 @@ export class PromptStore {
79
132
  const existing = this.rows.find((row) => row.conversationId === conversationId && row.callId === callId && row.key === key);
80
133
  await this.record(conversationId, callId, key, { chat, messageId: messageIds[0] ?? "" }, existing?.plan ?? { kind: "webui" });
81
134
  }
82
- /** Retire pending answers while preserving refs for the settlement edit. */
83
- async settle(conversationId, callId) {
135
+ /** Retire pending answers; the refs stay until retention expires them. */
136
+ async settle(conversationId, callId, outcome = "answered") {
84
137
  const state = await this.file.update((draft) => {
85
- const keys = draft.prompts
86
- .filter((row) => row.conversationId === conversationId && row.callId === callId)
87
- .map(identityOf);
88
- draft.settled = [...new Set([...draft.settled, ...keys])];
138
+ const keys = [
139
+ `${conversationId}:${callId}:`,
140
+ ...draft.prompts
141
+ .filter((row) => row.conversationId === conversationId && row.callId === callId)
142
+ .map(identityOf),
143
+ ];
144
+ for (const identity of keys) {
145
+ // Re-settling an already-settled interaction keeps the FIRST
146
+ // settlement's timestamp and outcome: retention is measured
147
+ // from settlement, never extended by later writes.
148
+ if (draft.settled[identity] !== undefined)
149
+ continue;
150
+ draft.settled[identity] = { at: this.now(), outcome };
151
+ }
152
+ prune(draft, this.now());
89
153
  });
90
154
  this.rows = state.prompts;
91
- this.settled = new Set(state.settled);
155
+ this.settled = state.settled;
156
+ }
157
+ /**
158
+ * The outcome text recorded at settlement, when this identity is
159
+ * settled. The receipt path reads it to self-heal the prompt edit;
160
+ * the tombstone outlives the bulky row, so the answer survives
161
+ * pruning even when no editable ref remains.
162
+ */
163
+ settledOutcome(conversationId, callId, key) {
164
+ return this.settled[`${conversationId}:${callId}:${key}`]?.outcome
165
+ ?? this.settled[`${conversationId}:${callId}:`]?.outcome;
166
+ }
167
+ }
168
+ /**
169
+ * THE retention helper (init and every write call this, at most once
170
+ * per write): a SETTLED row expires at `settledAt + RETENTION_MS <= now`.
171
+ * The tombstone itself is never pruned — it is what stops a delayed
172
+ * receipt from recreating an answerable prompt. Pending rows are
173
+ * never age-pruned.
174
+ */
175
+ function prune(draft, now) {
176
+ draft.prompts = draft.prompts.filter((row) => {
177
+ const tombstone = draft.settled[identityOf(row)];
178
+ if (tombstone === undefined)
179
+ return true;
180
+ return tombstone.at + PROMPT_RETENTION_MS > now;
181
+ });
182
+ }
183
+ /**
184
+ * Normalize whatever was on disk to the v2 shape. An unversioned v1
185
+ * file keeps every prompt row and stamps its settled identities with
186
+ * the MIGRATION time (one cohort, expiring together seven days later).
187
+ * Individually malformed tombstones are dropped with a log line each;
188
+ * valid unrelated rows are never discarded. A wholly malformed JSON
189
+ * document never reaches here — JsonFile throws naming the path.
190
+ */
191
+ function migrate(state, now, log) {
192
+ const prompts = Array.isArray(state.prompts) ? state.prompts : [];
193
+ const settled = {};
194
+ const raw = state.settled;
195
+ if (Array.isArray(raw)) {
196
+ // v1: bare identities, age unknown — the migration timestamp is
197
+ // the conservative choice (one further retention window).
198
+ for (const identity of raw) {
199
+ if (typeof identity !== "string")
200
+ continue;
201
+ settled[identity] = { at: now, outcome: "answered" };
202
+ }
203
+ }
204
+ else if (raw !== null && typeof raw === "object") {
205
+ for (const [identity, value] of Object.entries(raw)) {
206
+ const tombstone = value;
207
+ if (tombstone === null || typeof tombstone !== "object" ||
208
+ typeof tombstone.at !== "number" || !Number.isFinite(tombstone.at) ||
209
+ typeof tombstone.outcome !== "string") {
210
+ log?.warn(`prompts: dropping malformed settled tombstone "${identity}" (its row, if any, stays live)`);
211
+ continue;
212
+ }
213
+ if (!validIdentity(identity)) {
214
+ log?.warn(`prompts: dropping settled tombstone with a malformed identity "${identity}"`);
215
+ continue;
216
+ }
217
+ settled[identity] = { at: tombstone.at, outcome: tombstone.outcome };
218
+ }
219
+ }
220
+ else if (raw !== undefined) {
221
+ log?.warn("prompts: ignoring malformed settled container (expected an array or map)");
92
222
  }
223
+ return { version: PROMPT_FILE_VERSION, prompts, settled };
93
224
  }
94
225
  function identityOf(row) {
95
226
  return `${row.conversationId}:${row.callId}:${row.key}`;
96
227
  }
228
+ /**
229
+ * An identity is `<conversationId>:<callId>:<key>` — a positive integer
230
+ * conversation id, a non-empty callId without colons, and a non-empty
231
+ * key (opaque binding/home keys may themselves contain colons).
232
+ */
233
+ function validIdentity(identity) {
234
+ const separator = identity.indexOf(":");
235
+ if (separator <= 0)
236
+ return false;
237
+ if (!/^[1-9][0-9]*$/.test(identity.slice(0, separator)))
238
+ return false;
239
+ const rest = identity.slice(separator + 1);
240
+ const second = rest.indexOf(":");
241
+ // The empty key is the call-level settlement marker, written when
242
+ // settlement precedes prompt-row creation.
243
+ if (second === rest.length - 1)
244
+ return true;
245
+ return second > 0;
246
+ }
@@ -1,6 +1,7 @@
1
1
  /** Map a delivered interaction ledger row back to its recipient key. */
2
2
  import type { LedgerEntry } from "../state/ledger.js";
3
3
  import type { PromptStore } from "./prompts.js";
4
+ import { type InteractionChatDeps } from "./chat.js";
4
5
  export declare function receiptForInteraction(prompts: PromptStore, entry: LedgerEntry, receipt: {
5
6
  readonly messageIds: readonly string[];
6
- }): Promise<void>;
7
+ }, deps?: InteractionChatDeps): Promise<void>;
@@ -1,5 +1,6 @@
1
1
  /** Map a delivered interaction ledger row back to its recipient key. */
2
- export async function receiptForInteraction(prompts, entry, receipt) {
2
+ import { enqueueSettlementEdits } from "./chat.js";
3
+ export async function receiptForInteraction(prompts, entry, receipt, deps) {
3
4
  // The receipt is authoritative: an early settlement may have removed
4
5
  // the pending row, but the delivered message still needs an edit ref.
5
6
  if (entry.bindingKey !== undefined) {
@@ -10,7 +11,9 @@ export async function receiptForInteraction(prompts, entry, receipt) {
10
11
  if (!match[2].endsWith(suffix))
11
12
  return;
12
13
  const callId = match[2].slice(0, -suffix.length);
13
- await prompts.recordReceipt(Number(match[1]), callId, entry.bindingKey, entry.chat, receipt.messageIds);
14
+ const conversationId = Number(match[1]);
15
+ await prompts.recordReceipt(conversationId, callId, entry.bindingKey, entry.chat, receipt.messageIds);
16
+ await healSettledPrompt(prompts, deps, conversationId, callId, entry.bindingKey, receipt.messageIds);
14
17
  return;
15
18
  }
16
19
  const tag = entry.tag;
@@ -31,6 +34,36 @@ export async function receiptForInteraction(prompts, entry, receipt) {
31
34
  if (afterConversation !== suffix)
32
35
  continue;
33
36
  await prompts.recordReceipt(conversationId, row.callId, row.key, entry.chat, receipt.messageIds);
37
+ await healSettledPrompt(prompts, deps, conversationId, row.callId, row.key, receipt.messageIds);
34
38
  return;
35
39
  }
36
40
  }
41
+ /**
42
+ * A real receipt for an ALREADY-settled prompt self-heals the live
43
+ * buttons: settlement ran with an empty ref (no edit possible, so a
44
+ * notice went out), and this delivered message is the one that now
45
+ * needs the outcome edit. The edit tag is the same the settlement
46
+ * path would have used, so a repeat receipt stays a no-op.
47
+ */
48
+ async function healSettledPrompt(prompts, deps, conversationId, callId, key, messageIds) {
49
+ if (deps === undefined)
50
+ return;
51
+ if (messageIds.length === 0 || messageIds[0] === "")
52
+ return;
53
+ const outcome = prompts.settledOutcome(conversationId, callId, key);
54
+ if (outcome === undefined)
55
+ return;
56
+ // Tombstones outlive their bulky rows. A receipt AFTER pruning must
57
+ // be a true no-op: with no surviving row/ref, the settlement helper
58
+ // would otherwise take its fallback and enqueue duplicate notices.
59
+ const ref = prompts.refsOf(conversationId, callId)
60
+ .find((candidate) => candidate.messageId === messageIds[0]);
61
+ if (ref === undefined)
62
+ return;
63
+ try {
64
+ await enqueueSettlementEdits(deps, conversationId, callId, outcome);
65
+ }
66
+ catch (error) {
67
+ deps.log.error(`settled prompt receipt heal failed (${conversationId}:${callId})`, error);
68
+ }
69
+ }
package/dist/notify.js CHANGED
@@ -32,19 +32,26 @@ async function notify(event, deps) {
32
32
  if (event.conversationId === undefined)
33
33
  return;
34
34
  // The settled fan-out owns bound conversations; a second notice
35
- // here would duplicate the answer the chat just received.
36
- if (deps.bindings.keysOf(event.conversationId).length > 0)
35
+ // here would duplicate the answer the chat just received. Only
36
+ // LIVE bindings suppress: a dead binding is skipped by the
37
+ // fan-out, so counting it would drop the answer with no log.
38
+ const live = deps.bindings.keysOf(event.conversationId)
39
+ .filter((key) => deps.bindings.get(key)?.dead !== true);
40
+ if (live.length > 0)
37
41
  return;
38
42
  const meta = await deps.host.conversations.meta(event.conversationId);
39
43
  // New-conversation jobs are unbound, but their delivery and
40
- // job.failed paths own notices. Core's run.settled carries no
41
- // outcome, so the false success notice on a failed job run needs
42
- // a core-contract event change and is ledgered for final review.
44
+ // job.failed paths own notices. The settled outcome (Task 4) is
45
+ // structured on the event: only an ANSWERED run may read as a
46
+ // ready reply; a failed/aborted run is a truthful non-success
47
+ // notice carrying the run's own reason.
43
48
  if (meta.jobId !== undefined)
44
49
  return;
45
50
  }
46
51
  const text = event.type === "run.settled"
47
- ? `Reply ready — ${event.body}${event.url === undefined ? "" : `\n${event.url}`}`
52
+ ? event.outcome.status === "answered"
53
+ ? `Reply ready — ${event.body}${event.url === undefined ? "" : `\n${event.url}`}`
54
+ : `Run ended without a reply — ${event.body}${event.outcome.reason === "" ? "" : ` (${event.outcome.reason})`}${event.url === undefined ? "" : `\n${event.url}`}`
48
55
  : `${event.title}\n${event.body}${event.url === undefined ? "" : `\n${event.url}`}`;
49
56
  for (const home of deps.identities.homesOf(event.user)) {
50
57
  await deps.ledger.enqueue({
@@ -42,6 +42,7 @@ export interface DeliveryDeps {
42
42
  readonly counts?: {
43
43
  delivered(): void;
44
44
  failed(): void;
45
+ cancelled?(): void;
45
46
  };
46
47
  /** Persist a delivered interaction prompt's platform refs for edits/replies. */
47
48
  readonly onInteractionReceipt?: (entry: LedgerEntry, receipt: {
@@ -110,7 +111,13 @@ export declare class DeliveryWorker {
110
111
  * Returns false when a retry wait was armed (the caller parks).
111
112
  */
112
113
  private onSendError;
113
- /** A ledger mark with its own error isolation (never throws). */
114
+ /**
115
+ * A ledger mark with its own error isolation (never throws). Returns
116
+ * whether the transition APPLIED: a refusal means the entry left
117
+ * `pending` behind our back (a settlement cancelled it), so the caller
118
+ * must skip its failure/retry side effects rather than count a failure
119
+ * for a cancelled entry or arm a retry that re-pulls it.
120
+ */
114
121
  private markLedger;
115
122
  /**
116
123
  * The classification read (adapter's `classifyError` when present,
@@ -160,7 +160,14 @@ export class DeliveryWorker {
160
160
  // failing ledger write is LOGGED and the send does not run (an
161
161
  // unmarked entry must not be sent — the crash window would
162
162
  // duplicate it on recovery).
163
- await this.ledger.mark(entry.id, "attempting");
163
+ // A refused claim means the entry is no longer pending (a
164
+ // settlement cancelled it between the loop's read and here).
165
+ // Sending it anyway would deliver a live prompt for a decided
166
+ // interaction, so stop: the cancellation is the barrier.
167
+ if (!(await this.ledger.mark(entry.id, "attempting"))) {
168
+ this.deps.counts?.cancelled?.();
169
+ return true;
170
+ }
164
171
  const receipt = entry.kind === "edit"
165
172
  ? await this.boundedEdit(entry, message)
166
173
  : await this.boundedSend(message);
@@ -270,8 +277,8 @@ export class DeliveryWorker {
270
277
  }
271
278
  this.deps.log.warn(`delivery: chat gone for "${entry.channel}:${hashId(entry.chat.chatId)}" — binding marked dead`);
272
279
  this.deps.log.debug(`delivery: chat gone for "${entry.channel}:${entry.chat.chatId}" — binding marked dead`);
273
- await this.markLedger(entry.id, "failed");
274
- this.deps.counts?.failed();
280
+ if (await this.markLedger(entry.id, "failed"))
281
+ this.deps.counts?.failed();
275
282
  return true;
276
283
  }
277
284
  if (kind === "permanent") {
@@ -280,8 +287,8 @@ export class DeliveryWorker {
280
287
  : "";
281
288
  this.deps.log.error(`delivery: permanent failure ("${entry.kind}" to "${hashId(entry.chat.chatId)}")${fileDetail}`, error);
282
289
  this.deps.log.debug(`delivery: permanent failure ("${entry.kind}" to "${entry.chat.chatId}")${fileDetail}`);
283
- await this.markLedger(entry.id, "failed");
284
- this.deps.counts?.failed();
290
+ if (await this.markLedger(entry.id, "failed"))
291
+ this.deps.counts?.failed();
285
292
  return true;
286
293
  }
287
294
  // Transient: the retry ladder. attempts was already incremented by
@@ -291,8 +298,8 @@ export class DeliveryWorker {
291
298
  if (retriesUsed >= this.deps.delays.length) {
292
299
  this.deps.log.error(`delivery: retries exhausted ("${entry.kind}" to "${hashId(entry.chat.chatId)}"): ${detail}`);
293
300
  this.deps.log.debug(`delivery: retries exhausted ("${entry.kind}" to "${entry.chat.chatId}"): ${detail}`);
294
- await this.markLedger(entry.id, "failed");
295
- this.deps.counts?.failed();
301
+ if (await this.markLedger(entry.id, "failed"))
302
+ this.deps.counts?.failed();
296
303
  return true;
297
304
  }
298
305
  const delay = this.deps.delays[retriesUsed] ?? 0;
@@ -300,7 +307,10 @@ export class DeliveryWorker {
300
307
  // The entry returns to pending (the retry re-pulls it); the timer
301
308
  // re-kicks the loop. A stop cancels the timer — the entry stays
302
309
  // pending for the next boot's recovery.
303
- await this.markLedger(entry.id, "pending");
310
+ // A refused re-arm means the entry was cancelled while we were
311
+ // classifying the failure: do not arm a retry that would re-pull it.
312
+ if (!(await this.markLedger(entry.id, "pending")))
313
+ return true;
304
314
  if (this.stopped)
305
315
  return false;
306
316
  this.cancelRetry?.();
@@ -310,13 +320,20 @@ export class DeliveryWorker {
310
320
  }, delay);
311
321
  return false;
312
322
  }
313
- /** A ledger mark with its own error isolation (never throws). */
323
+ /**
324
+ * A ledger mark with its own error isolation (never throws). Returns
325
+ * whether the transition APPLIED: a refusal means the entry left
326
+ * `pending` behind our back (a settlement cancelled it), so the caller
327
+ * must skip its failure/retry side effects rather than count a failure
328
+ * for a cancelled entry or arm a retry that re-pulls it.
329
+ */
314
330
  async markLedger(id, state) {
315
331
  try {
316
- await this.ledger.mark(id, state);
332
+ return await this.ledger.mark(id, state);
317
333
  }
318
334
  catch (markError) {
319
335
  this.deps.log.error("delivery: ledger mark failed", markError);
336
+ return false;
320
337
  }
321
338
  }
322
339
  /**
@@ -23,6 +23,7 @@
23
23
  */
24
24
  import type { ChannelContribution, RunSettledEvent, SigrunHost } from "@sigrun/sigrun/extension";
25
25
  import type { SubmissionId } from "@earendil-works/pi-durable";
26
+ import type { SessionMeta } from "@sigrun/shared/session-meta";
26
27
  import type { ChannelConfig } from "../config.js";
27
28
  import type { BindingStore } from "../state/bindings.js";
28
29
  import type { Ledger } from "../state/ledger.js";
@@ -44,17 +45,17 @@ export interface Streams {
44
45
  finalize(conversationId: number, chunks: string[]): Promise<boolean>;
45
46
  abandon(conversationId: number): void;
46
47
  }
48
+ /**
49
+ * The job-delivery metadata the settled fan-out reads. Derived from the
50
+ * canonical shared session-meta wire type (type-only: no runtime core
51
+ * dependency) so field drift fails the contract fixture's typecheck.
52
+ */
53
+ export type GatewayJobMeta = Pick<SessionMeta, "owner" | "name" | "autoName" | "jobId" | "deliver">;
47
54
  /** onRunSettled's injectable dependencies. */
48
55
  export interface SettledDeps {
49
56
  readonly host: SigrunHost;
50
57
  /** The event bus's bridge-free document read (factory-supplied). */
51
- readonly readMeta?: (conversationId: number) => Promise<{
52
- readonly owner?: string;
53
- readonly name?: string;
54
- readonly autoName?: string;
55
- readonly jobId?: string;
56
- readonly deliver?: string[];
57
- } | undefined>;
58
+ readonly readMeta?: (conversationId: number) => Promise<GatewayJobMeta | undefined>;
58
59
  /** The engine-wide bindings (the delivery fan-out read). */
59
60
  readonly bindings: BindingStore;
60
61
  /** The engine-wide identity links (the binding's user resolution). */
@@ -2,7 +2,17 @@
2
2
  * The core session-meta document token, re-declared structurally from
3
3
  * the shared wire type. Standalone doc tokens compare by kind/scope;
4
4
  * declaring it here avoids a runtime import of core's server modules.
5
- * Core-contract follow-up: version drift here silently reads job meta as absent.
5
+ * Version drift is real: pi-durable's checkRecordVersion throws BOTH
6
+ * directions (a newer stored doc, or an older one with no migration),
7
+ * so the engine reads job meta through readJobMeta — a throwing read
8
+ * degrades to absent-meta (logged once) rather than failing the
9
+ * settled fan-out and losing the job delivery.
6
10
  */
7
11
  import type { SessionMeta } from "@sigrun/shared/views";
8
12
  export declare const SessionMetaDoc: import("@earendil-works/pi-durable").ConversationDocToken<SessionMeta>;
13
+ /**
14
+ * Read job meta conservatively: a version-drift throw (or any other
15
+ * document-read failure) logs the cause ONCE and returns undefined,
16
+ * so job delivery skips cleanly instead of breaking the fan-out.
17
+ */
18
+ export declare function readJobMeta<T>(read: () => Promise<T>, warn: (message: string) => void): Promise<T | undefined>;
@@ -2,7 +2,11 @@
2
2
  * The core session-meta document token, re-declared structurally from
3
3
  * the shared wire type. Standalone doc tokens compare by kind/scope;
4
4
  * declaring it here avoids a runtime import of core's server modules.
5
- * Core-contract follow-up: version drift here silently reads job meta as absent.
5
+ * Version drift is real: pi-durable's checkRecordVersion throws BOTH
6
+ * directions (a newer stored doc, or an older one with no migration),
7
+ * so the engine reads job meta through readJobMeta — a throwing read
8
+ * degrades to absent-meta (logged once) rather than failing the
9
+ * settled fan-out and losing the job delivery.
6
10
  */
7
11
  import { defineDoc } from "@earendil-works/pi-durable";
8
12
  export const SessionMetaDoc = defineDoc({
@@ -19,3 +23,17 @@ export const SessionMetaDoc = defineDoc({
19
23
  source: "user",
20
24
  }),
21
25
  });
26
+ /**
27
+ * Read job meta conservatively: a version-drift throw (or any other
28
+ * document-read failure) logs the cause ONCE and returns undefined,
29
+ * so job delivery skips cleanly instead of breaking the fan-out.
30
+ */
31
+ export async function readJobMeta(read, warn) {
32
+ try {
33
+ return await read();
34
+ }
35
+ catch (error) {
36
+ warn(`session-meta read failed; job meta treated as absent: ${String(error)}`);
37
+ return undefined;
38
+ }
39
+ }
@@ -14,7 +14,11 @@ import type { EngineLog } from "../log.js";
14
14
  export type LinkVia = "pairing" | "self";
15
15
  /** Core's one provider-key convention for platform senders. */
16
16
  export declare function chatProviderOf(channel: string): string;
17
- /** Core admits only this channel-id shape inside a chat provider. */
17
+ /**
18
+ * Core admits only this channel-id shape inside a chat provider. This
19
+ * matches the loader's grammar exactly: every binding-key and target
20
+ * parse splits on ":", so a wider alphabet would mis-attribute chats.
21
+ */
18
22
  export declare const CHANNEL_ID_PATTERN: RegExp;
19
23
  /** The result of a `userFor` resolution. */
20
24
  export interface ResolvedUser {
@@ -12,8 +12,12 @@ import { JsonFile } from "./json-file.js";
12
12
  export function chatProviderOf(channel) {
13
13
  return `chat:${channel}`;
14
14
  }
15
- /** Core admits only this channel-id shape inside a chat provider. */
16
- export const CHANNEL_ID_PATTERN = /^[a-z0-9][a-z0-9._:-]*$/;
15
+ /**
16
+ * Core admits only this channel-id shape inside a chat provider. This
17
+ * matches the loader's grammar exactly: every binding-key and target
18
+ * parse splits on ":", so a wider alphabet would mis-attribute chats.
19
+ */
20
+ export const CHANNEL_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
17
21
  /** Core-backed sender resolution and engine-owned home-chat persistence. */
18
22
  export class IdentityStore {
19
23
  host;
@@ -36,7 +36,7 @@ export interface SerializedOutbound {
36
36
  }[];
37
37
  }
38
38
  /** One ledger row's lifecycle. */
39
- export type LedgerState = "pending" | "attempting" | "delivered" | "failed";
39
+ export type LedgerState = "pending" | "attempting" | "delivered" | "failed" | "cancelled";
40
40
  /** What the entry is for (the worker's send-shape discrimination). */
41
41
  export type LedgerKind = "final" | "notice" | "interaction" | "edit";
42
42
  /**
@@ -123,8 +123,32 @@ export declare class Ledger {
123
123
  * (the worker's loop is a tick, not an await point).
124
124
  */
125
125
  nextPending(channel: string): LedgerEntry | undefined;
126
- /** Transition one entry's state (attempting/delivered/failed). */
127
- mark(id: string, state: LedgerState, receipt?: readonly string[]): Promise<void>;
126
+ /**
127
+ * Cancel an entry that has not started delivery. The transition is
128
+ * atomic with the ledger write: an attempting/delivered entry cannot
129
+ * be cancelled after the platform may already have received it.
130
+ * Returns whether this call cancelled the pending entry.
131
+ */
132
+ cancel(id: string): Promise<boolean>;
133
+ /**
134
+ * Whether a transition may leave the entry's current state. The rule
135
+ * this exists for is narrow and deliberate: `cancelled` is TERMINAL,
136
+ * so a cancelled entry can never be resurrected by a late `mark` —
137
+ * that is what makes `cancel` a barrier rather than a request, and it
138
+ * is what stops a settlement-cancelled interaction prompt from being
139
+ * delivered anyway. Every other edge stays permissive, because the
140
+ * ledger is a state MACHINE with legitimate direct transitions
141
+ * (`pending → delivered` for a single-attempt success,
142
+ * `attempting → pending` for the retry ladder, `pending → failed`).
143
+ */
144
+ private static canLeave;
145
+ /**
146
+ * Transition one entry's state (attempting/delivered/failed). A
147
+ * transition out of `cancelled` is refused, and a refused transition
148
+ * reports `false` so the caller can stop before acting on the entry
149
+ * (the delivery worker must not send an entry it failed to claim).
150
+ */
151
+ mark(id: string, state: LedgerState, receipt?: readonly string[]): Promise<boolean>;
128
152
  /**
129
153
  * Restart recovery: every `attempting` entry becomes `pending` with
130
154
  * the `recovered` flag (the worker prefixes the marker at send time).
@@ -83,13 +83,61 @@ export class Ledger {
83
83
  nextPending(channel) {
84
84
  return this.entries.find((entry) => entry.channel === channel && entry.state === "pending");
85
85
  }
86
- /** Transition one entry's state (attempting/delivered/failed). */
86
+ /**
87
+ * Cancel an entry that has not started delivery. The transition is
88
+ * atomic with the ledger write: an attempting/delivered entry cannot
89
+ * be cancelled after the platform may already have received it.
90
+ * Returns whether this call cancelled the pending entry.
91
+ */
92
+ async cancel(id) {
93
+ let cancelled = false;
94
+ const state = await this.file.update((draft) => {
95
+ const index = draft.entries.findIndex((row) => row.id === id);
96
+ if (index === -1)
97
+ return;
98
+ const row = draft.entries[index];
99
+ if (row.state !== "pending")
100
+ return;
101
+ draft.entries[index] = {
102
+ ...row,
103
+ state: "cancelled",
104
+ stateSince: this.now(),
105
+ };
106
+ cancelled = true;
107
+ });
108
+ this.entries = state.entries;
109
+ return cancelled;
110
+ }
111
+ /**
112
+ * Whether a transition may leave the entry's current state. The rule
113
+ * this exists for is narrow and deliberate: `cancelled` is TERMINAL,
114
+ * so a cancelled entry can never be resurrected by a late `mark` —
115
+ * that is what makes `cancel` a barrier rather than a request, and it
116
+ * is what stops a settlement-cancelled interaction prompt from being
117
+ * delivered anyway. Every other edge stays permissive, because the
118
+ * ledger is a state MACHINE with legitimate direct transitions
119
+ * (`pending → delivered` for a single-attempt success,
120
+ * `attempting → pending` for the retry ladder, `pending → failed`).
121
+ */
122
+ static canLeave(from, to) {
123
+ return from !== "cancelled" || to === "cancelled";
124
+ }
125
+ /**
126
+ * Transition one entry's state (attempting/delivered/failed). A
127
+ * transition out of `cancelled` is refused, and a refused transition
128
+ * reports `false` so the caller can stop before acting on the entry
129
+ * (the delivery worker must not send an entry it failed to claim).
130
+ */
87
131
  async mark(id, state, receipt) {
132
+ let applied = false;
88
133
  await this.file.update((draft) => {
89
134
  const index = draft.entries.findIndex((row) => row.id === id);
90
135
  if (index === -1)
91
136
  return;
92
137
  const row = draft.entries[index];
138
+ if (!Ledger.canLeave(row.state, state))
139
+ return;
140
+ applied = true;
93
141
  const next = {
94
142
  ...row,
95
143
  state,
@@ -104,6 +152,7 @@ export class Ledger {
104
152
  draft.entries[index] = next;
105
153
  this.entries = draft.entries;
106
154
  });
155
+ return applied;
107
156
  }
108
157
  /**
109
158
  * Restart recovery: every `attempting` entry becomes `pending` with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigrun/ext-gateway",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Gateway engine extension for sigrun (pi-durable): chat-transport supervision — channel adapters, inbound pipeline, delivery",
5
5
  "type": "module",
6
6
  "engines": {
@@ -27,7 +27,7 @@
27
27
  "peerDependencies": {
28
28
  "@earendil-works/chord": "1.3.0",
29
29
  "@earendil-works/pi-durable": "1.3.0",
30
- "@sigrun/shared": "0.2.0"
30
+ "@sigrun/shared": "0.2.1"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@earendil-works/chord": "1.3.0",