@kontextmind/kxm 0.7.47 → 0.7.49

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.
@@ -11,7 +11,7 @@
11
11
  "name": "kxm",
12
12
  "source": "./plugins/kxm",
13
13
  "description": "Durable workflows, peer agents, and kxm tui",
14
- "version": "0.7.47",
14
+ "version": "0.7.49",
15
15
  "category": "development",
16
16
  "tags": ["kxm", "multi-agent", "workflows", "mcp"]
17
17
  }
package/CHANGELOG.md CHANGED
@@ -41,6 +41,18 @@ All notable user-facing changes are documented here. The project follows [Semant
41
41
  - **`kxm hub start` no longer generates and persists an admin token when it is
42
42
  about to refuse** because another hub already owns the claim. A refused start
43
43
  used to leave behind credentials the running hub never issued.
44
+ - **Runtime intake contract fixes (released in 0.7.46, found in review):**
45
+ a lost intake insert race accepted different content under an already-used
46
+ idempotency key; resume drained only one page, so held intent beyond 500
47
+ messages was stranded; pause, ingress and admission were not atomic, which could
48
+ strand a message as `held_paused` in an unpaused project; a coordinator rebind
49
+ could widen a tool ceiling by lifting a denial, changing the preset, or dropping
50
+ the tool policy; relabelling a stored message's classification on a duplicate is
51
+ now refused; coordinators now record the real loaded configuration revision
52
+ instead of a hash of project path and runtime id; dispatch order is Runtime
53
+ arrival order, so a backdated timestamp cannot jump the queue; persisted records
54
+ are cross-checked against every duplicated column on read; and the intake schema
55
+ no longer admits contradictory states.
44
56
 
45
57
  ## 0.7.0 - 2026-09-11
46
58
 
@@ -57,6 +57,16 @@ and models are admitted with strict permission and vendor boundaries:
57
57
 
58
58
  ## 2. The developer loop
59
59
 
60
+ > **Environment.** These recipes do **not** auto-load a `.env` from the working
61
+ > directory, and neither the source text nor `just --dump` is allowed to enable it. An
62
+ > untracked, gitignored file must not be able to set `NODE_OPTIONS`
63
+ > and execute code before the runner validates anything — `just --dotenv-path /abs/.env assign …` is the explicit
64
+ > opt-in (`--dotenv-path` both selects and locates the file in `just` 1.58). Arguments are passed
65
+ > positionally and quoted, so a path is never re-read as shell source; the runner's
66
+ > absolute-path requirement is a separate validation rule, not what makes quoting
67
+ > safe. `accept` prints JSON by default but takes no `--json` flag, and its optional
68
+ > `--observed-pr` / `--observed-ci` need the direct script form shown in Step 5.
69
+
60
70
  The standard progression follows a slim four-step lifecycle:
61
71
 
62
72
  ```text
@@ -85,7 +85,7 @@ exercised from `node_modules`.
85
85
  | Quorum parser boundaries and definition identity | `plugins/kxm/src/workflow.ts`, `test/core/workflow-quorum.test.ts`, `test/core/workflow-definition-hash.test.ts` | Rejects impossible peer pools, verifies degradation bounds, and proves secret-free semantic hash stamping plus credential-rotation invariance |
86
86
  | Artifact existence and containment gate | `plugins/kxm/src/artifacts-exist.ts`, `test/core/artifacts-exist.test.ts` | Non-empty regular files pass; missing, empty, non-file, lexical escape, and real-path escape cases fail closed (host-permitted symlink coverage) |
87
87
  | Harness inventory probe | `plugins/kxm/src/harness.ts`, `test/core/harness.test.ts` | Detect/auth/dispatch for the builtin catalog; win32 `.exe` / inner npm-package `.exe` / `.cmd` candidate order for npm shims (issue #168); `windows_shim` issue only when the shim answered; allowlisted `name.cmd` shell spawn only; rejected metacharacter commands; Linux still `not_detected` when the bare command is missing. |
88
- | Headless harness helper | `scripts/harness-run.mjs`, `justfile`, `test/core/harness-run.test.ts` | Offline auth success/logout/garbage, role/mode/pair/provider refusals before spawn, missing brief/schema fail closed with zero spawn, invocation-cwd relative `prompt_file`/`output_schema` vs `request.cwd` (absolute argv tokens; Claude/Codex stdin matches the brief), Pi JSONL multi-`message_end` sums, Claude auxiliary usage, native error-on-exit-0, timeout/empty payload, sidecar-only stderr/answer/error, shell:false argv metacharacters, win32 `.cmd` rejection, and win32 npm inner `claude.exe` / Pi `node.exe`+`cli.js` unwrap. Result v2 transport vs closed model claims, dispatch-before-spawn, grok/codex isolation flags, `max_turns` validation, obsolete v1 diagnosis without rewrite or unknown-schema echo, partial usage on fail/interrupt, signaled null `exitCode` plus exact `signal`, exit-before-stdio-close drain vs bounded linger, type-closed usage/cost (no object leak or zero-coercion), malformed optional text as run-stage failure with retained spend, stdin/pid-record write failures not completed, bounded timeout settle without descendant-death claims, spawn/write-failure stage and spend, and capability-fixture parser evidence (Codex `--ignore-user-config` is not invented in top-level help; captured help bytes are not rescrubbed). Recipe quoting is covered from the justfile body without a just binary (POSIX `sh` + positional argv; Windows uses the recipe's `node -e` / argv shape). Real just integration is optional and skipped when the binary is absent. No live paid smoke. |
88
+ | Headless harness helper | `scripts/harness-run.mjs`, `justfile`, `test/core/harness-run.test.ts` | Offline auth success/logout/garbage, role/mode/pair/provider refusals before spawn, missing brief/schema fail closed with zero spawn, invocation-cwd relative `prompt_file`/`output_schema` vs `request.cwd` (absolute argv tokens; Claude/Codex stdin matches the brief), Pi JSONL multi-`message_end` sums, Claude auxiliary usage, native error-on-exit-0, timeout/empty payload, sidecar-only stderr/answer/error, shell:false argv metacharacters, win32 `.cmd` rejection, and win32 npm inner `claude.exe` / Pi `node.exe`+`cli.js` unwrap. Result v2 transport vs closed model claims, dispatch-before-spawn, grok/codex isolation flags, `max_turns` validation, obsolete v1 diagnosis without rewrite or unknown-schema echo, partial usage on fail/interrupt, signaled null `exitCode` plus exact `signal`, exit-before-stdio-close drain vs bounded linger, type-closed usage/cost (no object leak or zero-coercion), malformed optional text as run-stage failure with retained spend, stdin/pid-record write failures not completed, bounded timeout settle without descendant-death claims, spawn/write-failure stage and spend, and capability-fixture parser evidence (Codex `--ignore-user-config` is not invented in top-level help; captured help bytes are not rescrubbed). Recipe quoting is covered from the justfile body without a just binary (POSIX `sh` + positional argv; Windows uses the recipe's `node -e` / argv shape). The developer entry points are gated three ways: docs-to-recipe parity (a documented `just` verb in command form, across inline code or a fenced line with an optional `#`, tolerant of stray whitespace, pipe-separated alternations, leading interpreter options and `~~~` fences, reading a shell pipe as one command rather than an alternation, skipping a glob family like `review-*` instead of demanding a recipe, and *not* parsing prose, headings or captured listing output); per-recipe boundaries (**the load-bearing gate parses nothing**: every line that mentions `assignment-run.mjs` must be one of the seven pinned proof bodies and there must be exactly seven, so a header form no parser recognises still cannot hide a call site; column-0 comments are documentation and excluded), plus a normalized-text pass (`\`-continuations folded, comments stripped) and a second pass over `just --dump`, the interpreter's own rendering, skipped with a visible reason without the binary; the recipe name set must equal a pinned list, duplicate headers and duplicate `run :=` bindings are refused, `set allow-duplicate*`, `import`/`mod` in any spelling and `alias` are refused, `run :=` and `dispatch` are asserted exactly, and non-proof bodies are token-checked against raw text because comment-stripping first hid an executable suffix that `--dump` reproduced verbatim. Stated limit: drift protection, not a sandbox — a body can assemble its command at runtime, and anyone who can edit the file can already do what it does. Auto-loading a working-directory dotenv file is refused by a brake on any `set dotenv*` spelling, plus a real-`just` probe whose preload module writes a marker file itself — so the assertion is that injected code executed — paired with controls on the same entry point: explicit `--dotenv-path`, a justfile with the setting re-added, and a real `witness` recipe run both ways. Real just integration is optional and skipped when the binary is absent. No live paid smoke. |
89
89
  | Long-lived headless coordinator | `scripts/kxm-worker.mjs` | Restart limits, spawn failure, collision-resistant ownership, exact resource and tool loading, raw-output isolation, bounded RPC framing, bounded drain, hung-tool recovery, provider/model fallback, and `--continue` fallback are automated; the opt-in real-Pi gate verifies two workers, discovery, request/reply, fanout, durable restart/resume, journal, and checkpoint |
90
90
  | GitHub check signal adapter | `plugins/kxm/src/github-watch.ts` | Deterministic pagination, conclusion, retry, and per-wait delivery-generation states in `test/core/github-watch.test.ts` |
91
91
  | Operator CLI | `scripts/kxm.mjs` | Isolated workspace commands in `test/core/cli.test.ts`; the packed artifact is installed locally and with the documented global `--omit=peer` path by `test/core/package-install.test.ts` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontextmind/kxm",
3
- "version": "0.7.47",
3
+ "version": "0.7.49",
4
4
  "description": "KXM local-first multi-agent orchestration and operator dashboard",
5
5
  "type": "module",
6
6
  "author": "KontextMind",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "kxm",
4
4
  "displayName": "KXM",
5
- "version": "0.7.47",
5
+ "version": "0.7.49",
6
6
  "description": "Headless multi-agent orchestration, durable workflows, and a live operator dashboard for Pi and Claude Code",
7
7
  "author": {
8
8
  "name": "KontextMind",
@@ -17121,7 +17121,7 @@ async function deliverInboxNotification(messageId, delivered, notify) {
17121
17121
  }
17122
17122
 
17123
17123
  // plugins/kxm/src/mcp-server.ts
17124
- var VERSION = "0.7.47";
17124
+ var VERSION = "0.7.49";
17125
17125
  var inbox = /* @__PURE__ */ new Map();
17126
17126
  var notifiedInbox = /* @__PURE__ */ new Set();
17127
17127
  var meshClient;
@@ -18558,14 +18558,14 @@ var KxmRunEventStore = class {
18558
18558
  `).get(projectId, coordinatorId, idempotencyKey);
18559
18559
  return row ? intakeFromRow(row) : void 0;
18560
18560
  }
18561
- /** Intake rows in the given dispatch states, oldest first (stable, replay-safe order). */
18561
+ /** Intake rows in the given dispatch states, in arrival order (replay-safe). */
18562
18562
  intakeInStates(projectId, states, limit = 100) {
18563
18563
  if (states.length === 0) return [];
18564
18564
  const placeholders = states.map(() => "?").join(", ");
18565
18565
  const rows = this.database.prepare(`
18566
18566
  SELECT * FROM intake_messages
18567
18567
  WHERE project_id = ? AND dispatch_state IN (${placeholders})
18568
- ORDER BY received_at ASC, message_id ASC
18568
+ ORDER BY rowid ASC
18569
18569
  LIMIT ?
18570
18570
  `).all(projectId, ...states, limit);
18571
18571
  return rows.map(intakeFromRow);
@@ -18839,8 +18839,11 @@ var KxmRunEventStore = class {
18839
18839
  };
18840
18840
  function coordinatorFromRow(row) {
18841
18841
  const parsed = JSON.parse(row.record);
18842
+ if (row.schema !== "kxm.coordinator.v1") {
18843
+ throw runtimeError("coordinator_record_divergent", row.coordinator_id, `unexpected coordinator schema ${row.schema}`);
18844
+ }
18842
18845
  validateCoordinator(parsed, row.coordinator_id);
18843
- if (parsed.coordinatorId !== row.coordinator_id || parsed.ceilingHash !== row.ceiling_hash || parsed.configRevision !== row.config_revision || parsed.boundAt !== row.bound_at) {
18846
+ if (parsed.coordinatorId !== row.coordinator_id || parsed.projectId !== row.project_id || parsed.role !== row.role || parsed.channel !== row.channel || parsed.ceilingHash !== row.ceiling_hash || parsed.configRevision !== row.config_revision || parsed.boundAt !== row.bound_at) {
18844
18847
  throw runtimeError("coordinator_record_divergent", row.coordinator_id, "coordinator columns do not match the persisted record");
18845
18848
  }
18846
18849
  return {
@@ -18856,8 +18859,11 @@ function coordinatorFromRow(row) {
18856
18859
  }
18857
18860
  function intakeFromRow(row) {
18858
18861
  const parsed = JSON.parse(row.record);
18862
+ if (row.schema !== "kxm.intake-message.v1") {
18863
+ throw runtimeError("intake_record_divergent", row.message_id, `unexpected intake schema ${row.schema}`);
18864
+ }
18859
18865
  validateIntakeMessage(parsed, row.message_id);
18860
- if (parsed.messageId !== row.message_id || parsed.contentHash !== row.content_hash || parsed.receivedAt !== row.received_at || parsed.dispatch?.state !== row.dispatch_state) {
18866
+ if (parsed.messageId !== row.message_id || parsed.projectId !== row.project_id || parsed.coordinatorId !== row.coordinator_id || parsed.idempotencyKey !== row.idempotency_key || parsed.contentHash !== row.content_hash || parsed.receivedAt !== row.received_at || parsed.dispatch?.state !== row.dispatch_state) {
18861
18867
  throw runtimeError("intake_record_divergent", row.message_id, "intake columns do not match the persisted record");
18862
18868
  }
18863
18869
  return {
@@ -22191,6 +22197,7 @@ function openKxmRuntimeContext(projectRoot, options) {
22191
22197
  projectId: registration.projectId,
22192
22198
  homeRuntimeId: registration.homeRuntimeId,
22193
22199
  eventStore,
22200
+ configRevision: bundle.configRevision,
22194
22201
  ...options.budgetClock ? { budgetClock: options.budgetClock } : {}
22195
22202
  };
22196
22203
  } catch (error) {
@@ -18998,14 +18998,14 @@ var KxmRunEventStore = class {
18998
18998
  `).get(projectId, coordinatorId, idempotencyKey);
18999
18999
  return row ? intakeFromRow(row) : void 0;
19000
19000
  }
19001
- /** Intake rows in the given dispatch states, oldest first (stable, replay-safe order). */
19001
+ /** Intake rows in the given dispatch states, in arrival order (replay-safe). */
19002
19002
  intakeInStates(projectId, states, limit = 100) {
19003
19003
  if (states.length === 0) return [];
19004
19004
  const placeholders = states.map(() => "?").join(", ");
19005
19005
  const rows = this.database.prepare(`
19006
19006
  SELECT * FROM intake_messages
19007
19007
  WHERE project_id = ? AND dispatch_state IN (${placeholders})
19008
- ORDER BY received_at ASC, message_id ASC
19008
+ ORDER BY rowid ASC
19009
19009
  LIMIT ?
19010
19010
  `).all(projectId, ...states, limit);
19011
19011
  return rows.map(intakeFromRow);
@@ -19279,8 +19279,11 @@ var KxmRunEventStore = class {
19279
19279
  };
19280
19280
  function coordinatorFromRow(row) {
19281
19281
  const parsed = JSON.parse(row.record);
19282
+ if (row.schema !== "kxm.coordinator.v1") {
19283
+ throw runtimeError("coordinator_record_divergent", row.coordinator_id, `unexpected coordinator schema ${row.schema}`);
19284
+ }
19282
19285
  validateCoordinator(parsed, row.coordinator_id);
19283
- if (parsed.coordinatorId !== row.coordinator_id || parsed.ceilingHash !== row.ceiling_hash || parsed.configRevision !== row.config_revision || parsed.boundAt !== row.bound_at) {
19286
+ if (parsed.coordinatorId !== row.coordinator_id || parsed.projectId !== row.project_id || parsed.role !== row.role || parsed.channel !== row.channel || parsed.ceilingHash !== row.ceiling_hash || parsed.configRevision !== row.config_revision || parsed.boundAt !== row.bound_at) {
19284
19287
  throw runtimeError("coordinator_record_divergent", row.coordinator_id, "coordinator columns do not match the persisted record");
19285
19288
  }
19286
19289
  return {
@@ -19296,8 +19299,11 @@ function coordinatorFromRow(row) {
19296
19299
  }
19297
19300
  function intakeFromRow(row) {
19298
19301
  const parsed = JSON.parse(row.record);
19302
+ if (row.schema !== "kxm.intake-message.v1") {
19303
+ throw runtimeError("intake_record_divergent", row.message_id, `unexpected intake schema ${row.schema}`);
19304
+ }
19299
19305
  validateIntakeMessage(parsed, row.message_id);
19300
- if (parsed.messageId !== row.message_id || parsed.contentHash !== row.content_hash || parsed.receivedAt !== row.received_at || parsed.dispatch?.state !== row.dispatch_state) {
19306
+ if (parsed.messageId !== row.message_id || parsed.projectId !== row.project_id || parsed.coordinatorId !== row.coordinator_id || parsed.idempotencyKey !== row.idempotency_key || parsed.contentHash !== row.content_hash || parsed.receivedAt !== row.received_at || parsed.dispatch?.state !== row.dispatch_state) {
19301
19307
  throw runtimeError("intake_record_divergent", row.message_id, "intake columns do not match the persisted record");
19302
19308
  }
19303
19309
  return {
@@ -22634,6 +22640,7 @@ function openKxmRuntimeContext(projectRoot, options) {
22634
22640
  projectId: registration.projectId,
22635
22641
  homeRuntimeId: registration.homeRuntimeId,
22636
22642
  eventStore,
22643
+ configRevision: bundle.configRevision,
22637
22644
  ...options.budgetClock ? { budgetClock: options.budgetClock } : {}
22638
22645
  };
22639
22646
  } catch (error) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plugin",
3
- "version": "0.7.47",
3
+ "version": "0.7.49",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
@@ -169,6 +169,13 @@ export function bindKxmCoordinator(
169
169
  record: kxmCanonicalJson(record as unknown as JsonValue),
170
170
  });
171
171
  if (!replaced) {
172
+ // Another process got the same policy through. Return its identity when it
173
+ // reached the ceiling we asked for; anything else is a real conflict.
174
+ const winner = context.eventStore.coordinatorInSlot(context.projectId, role, channel);
175
+ const record2 = winner ? (JSON.parse(winner.record) as KxmCoordinatorRecord) : undefined;
176
+ if (record2 && record2.ceilingHash === ceilingHash) {
177
+ return { coordinator: record2, created: false };
178
+ }
172
179
  throw runtimeError("coordinator_write_lost", existing.coordinatorId, "the coordinator slot changed underneath this rebind");
173
180
  }
174
181
  return { coordinator: record, created: true };
@@ -186,11 +193,10 @@ export function bindKxmCoordinator(
186
193
  authority,
187
194
  boundAt: now,
188
195
  boundBy: actor,
189
- configRevision: contextConfigRevision(context),
196
+ configRevision: context.configRevision,
190
197
  ceilingHash,
191
198
  };
192
- persistCoordinator(context, record);
193
- return { coordinator: record, created: true };
199
+ return { ...persistCoordinator(context, record), created: true };
194
200
  }
195
201
 
196
202
  /** Resolve a coordinator by id; unknown identity fails closed. */
@@ -239,65 +245,92 @@ export function acceptKxmIntakeMessage(
239
245
  const bytes = Buffer.byteLength(input.content, "utf8");
240
246
  const contentHash = `sha256:${createHash("sha256").update(input.content, "utf8").digest("hex")}`;
241
247
 
242
- const existingRow = context.eventStore.intakeBySlot(context.projectId, coordinator.coordinatorId, key);
243
- if (existingRow) {
244
- const existing = JSON.parse(existingRow.record) as KxmIntakeMessage;
245
- if (existing.contentHash !== contentHash) {
248
+ // One transaction covers the slot probe, the pause read and the write, so a
249
+ // concurrent resume cannot strand this row in an unpaused project and a racing
250
+ // writer with different content cannot be mistaken for a duplicate.
251
+ return context.eventStore.transaction(() => {
252
+ const existingRow = context.eventStore.intakeBySlot(context.projectId, coordinator.coordinatorId, key);
253
+ if (existingRow) {
254
+ return { message: requireSamePayload(existingRow, contentHash, classification, key), duplicate: true };
255
+ }
256
+
257
+ if (bytes > MAX_INTAKE_CONTENT_BYTES) {
246
258
  throw runtimeError(
247
- "intake_payload_conflict",
248
- existing.messageId,
249
- `idempotency key ${key} was already used with different content`,
259
+ "intake_content_too_large",
260
+ coordinator.coordinatorId,
261
+ `intake content is ${bytes} bytes; the limit is ${MAX_INTAKE_CONTENT_BYTES}`,
250
262
  );
251
263
  }
252
- return { message: existing, duplicate: true };
253
- }
254
264
 
255
- if (bytes > MAX_INTAKE_CONTENT_BYTES) {
265
+ // Only secrets are withheld, and that guarantee is about *this layer's* storage
266
+ // decision: classification is supplied by the caller, so an untrusted adapter
267
+ // can still label a credential "project" and have it persisted here. Widening
268
+ // this to detection is a separate, reviewed slice.
269
+ const persistContent = classification !== "secret";
270
+ const paused = isKxmProjectPaused(context);
271
+ const message: KxmIntakeMessage = {
272
+ schema: "kxm.intake-message.v1",
273
+ messageId: newId("msg"),
274
+ projectId: context.projectId,
275
+ coordinatorId: coordinator.coordinatorId,
276
+ receivedAt: now,
277
+ source: { kind: sourceKind, id: sourceId },
278
+ idempotencyKey: key,
279
+ contentHash,
280
+ ...(persistContent ? { content: input.content } : { contentOmittedReason: "secret-classified" as const }),
281
+ classification,
282
+ dispatch: paused
283
+ ? { state: "held_paused", reason: "project_paused", updatedAt: now }
284
+ : { state: "ready" },
285
+ };
286
+ validateIntakeMessage(message, message.messageId);
287
+
288
+ const inserted = context.eventStore.insertIntakeMessageIfAbsent({
289
+ messageId: message.messageId,
290
+ projectId: message.projectId,
291
+ coordinatorId: message.coordinatorId,
292
+ idempotencyKey: message.idempotencyKey,
293
+ contentHash: message.contentHash,
294
+ receivedAt: message.receivedAt,
295
+ dispatchState: message.dispatch.state,
296
+ record: kxmCanonicalJson(message as unknown as JsonValue),
297
+ });
298
+ if (!inserted) {
299
+ // Lost the race for the slot. The winner is only a duplicate if it carried
300
+ // the same content; anything else is the same conflict we refuse serially.
301
+ const winner = context.eventStore.intakeBySlot(context.projectId, coordinator.coordinatorId, key);
302
+ if (!winner) throw runtimeError("intake_write_lost", coordinator.coordinatorId, "intake write lost its slot and left no record");
303
+ return { message: requireSamePayload(winner, contentHash, classification, key), duplicate: true };
304
+ }
305
+ return { message, duplicate: false };
306
+ });
307
+ }
308
+
309
+ function requireSamePayload(
310
+ row: { record: string },
311
+ contentHash: string,
312
+ classification: KxmIntakeClassification,
313
+ key: string,
314
+ ): KxmIntakeMessage {
315
+ const message = JSON.parse(row.record) as KxmIntakeMessage;
316
+ if (message.contentHash !== contentHash) {
256
317
  throw runtimeError(
257
- "intake_content_too_large",
258
- coordinator.coordinatorId,
259
- `intake content is ${bytes} bytes; the limit is ${MAX_INTAKE_CONTENT_BYTES}`,
318
+ "intake_payload_conflict",
319
+ message.messageId,
320
+ `idempotency key ${key} was already used with different content`,
260
321
  );
261
322
  }
262
-
263
- // Only secrets are withheld. Everything else is the project's own bounded,
264
- // owner-only store; omitting project content would just lose the work.
265
- const persistContent = classification !== "secret";
266
- const paused = isKxmProjectPaused(context);
267
- const message: KxmIntakeMessage = {
268
- schema: "kxm.intake-message.v1",
269
- messageId: newId("msg"),
270
- projectId: context.projectId,
271
- coordinatorId: coordinator.coordinatorId,
272
- receivedAt: now,
273
- source: { kind: sourceKind, id: sourceId },
274
- idempotencyKey: key,
275
- contentHash,
276
- ...(persistContent ? { content: input.content } : { contentOmittedReason: "secret-classified" as const }),
277
- classification,
278
- dispatch: paused
279
- ? { state: "held_paused", reason: "project_paused", updatedAt: now }
280
- : { state: "ready" },
281
- };
282
- validateIntakeMessage(message, message.messageId);
283
-
284
- const inserted = context.eventStore.insertIntakeMessageIfAbsent({
285
- messageId: message.messageId,
286
- projectId: message.projectId,
287
- coordinatorId: message.coordinatorId,
288
- idempotencyKey: message.idempotencyKey,
289
- contentHash: message.contentHash,
290
- receivedAt: message.receivedAt,
291
- dispatchState: message.dispatch.state,
292
- record: kxmCanonicalJson(message as unknown as JsonValue),
293
- });
294
- if (!inserted) {
295
- // Lost the race against an identical key: report the winner as the duplicate.
296
- const winner = context.eventStore.intakeBySlot(context.projectId, coordinator.coordinatorId, key);
297
- if (!winner) throw runtimeError("intake_write_lost", coordinator.coordinatorId, "intake write lost its slot and left no record");
298
- return { message: JSON.parse(winner.record) as KxmIntakeMessage, duplicate: true };
323
+ // Re-labelling the same payload is not a no-op: a retry marked `secret` must not
324
+ // quietly hand back a stored plaintext record, and a retry marked `project` must
325
+ // not launder a row that was withheld.
326
+ if (message.classification !== classification) {
327
+ throw runtimeError(
328
+ "intake_classification_conflict",
329
+ message.messageId,
330
+ `idempotency key ${key} was already recorded as ${message.classification}, not ${classification}`,
331
+ );
299
332
  }
300
- return { message, duplicate: false };
333
+ return message;
301
334
  }
302
335
 
303
336
  /** Pending intake that may be dispatched, oldest first. Empty while paused. */
@@ -322,29 +355,33 @@ export function admitKxmIntakeRun(
322
355
  const runId = requireText(input.runId, "runId", 144);
323
356
  if (!COORDINATOR_ID_RE.test(runId)) throw runtimeError("intake_run_id_invalid", id, "runId is not a well-formed opaque id");
324
357
  const now = requireTimestamp(input.now ?? new Date().toISOString(), "now");
325
- if (isKxmProjectPaused(context)) {
326
- throw runtimeError("intake_paused", id, "the project is paused; no fresh dispatch may be admitted");
327
- }
328
- const row = context.eventStore.intakeMessage(id);
329
- if (!row) throw runtimeError("intake_message_unknown", id, "no such intake message in this project");
330
- const message = JSON.parse(row.record) as KxmIntakeMessage;
331
- if (message.dispatch.state === "admitted") {
332
- if (message.dispatch.runId === runId) return message;
333
- throw runtimeError("intake_second_admission", id, `message already admitted as ${message.dispatch.runId}`);
334
- }
335
- if (message.dispatch.state !== "ready") {
336
- throw runtimeError("intake_not_dispatchable", id, `intake state ${message.dispatch.state} cannot be admitted`);
337
- }
338
- const next: KxmIntakeMessage = { ...message, dispatch: { state: "admitted", runId, updatedAt: now } };
339
- validateIntakeMessage(next, next.messageId);
340
- const updated = context.eventStore.updateIntakeDispatch(id, "ready", { state: "admitted", record: kxmCanonicalJson(next as unknown as JsonValue) });
341
- if (!updated) {
342
- const racer = context.eventStore.intakeMessage(id);
343
- const current = racer ? (JSON.parse(racer.record) as KxmIntakeMessage) : undefined;
344
- if (current?.dispatch.state === "admitted" && current.dispatch.runId === runId) return current;
345
- throw runtimeError("intake_dispatch_race", id, "the intake record changed underneath this admission");
346
- }
347
- return next;
358
+ // The pause read, the state read and the guarded transition commit together, so
359
+ // a pause cannot slip in between "not paused" and "admitted".
360
+ return context.eventStore.transaction(() => {
361
+ if (isKxmProjectPaused(context)) {
362
+ throw runtimeError("intake_paused", id, "the project is paused; no fresh dispatch may be admitted");
363
+ }
364
+ const row = context.eventStore.intakeMessage(id);
365
+ if (!row) throw runtimeError("intake_message_unknown", id, "no such intake message in this project");
366
+ const message = JSON.parse(row.record) as KxmIntakeMessage;
367
+ if (message.dispatch.state === "admitted") {
368
+ if (message.dispatch.runId === runId) return message;
369
+ throw runtimeError("intake_second_admission", id, `message already admitted as ${message.dispatch.runId}`);
370
+ }
371
+ if (message.dispatch.state !== "ready") {
372
+ throw runtimeError("intake_not_dispatchable", id, `intake state ${message.dispatch.state} cannot be admitted`);
373
+ }
374
+ const next: KxmIntakeMessage = { ...message, dispatch: { state: "admitted", runId, updatedAt: now } };
375
+ validateIntakeMessage(next, next.messageId);
376
+ const updated = context.eventStore.updateIntakeDispatch(id, "ready", { state: "admitted", record: kxmCanonicalJson(next as unknown as JsonValue) });
377
+ if (!updated) {
378
+ const racer = context.eventStore.intakeMessage(id);
379
+ const current = racer ? (JSON.parse(racer.record) as KxmIntakeMessage) : undefined;
380
+ if (current?.dispatch.state === "admitted" && current.dispatch.runId === runId) return current;
381
+ throw runtimeError("intake_dispatch_race", id, "the intake record changed underneath this admission");
382
+ }
383
+ return next;
384
+ });
348
385
  }
349
386
 
350
387
  /**
@@ -378,9 +415,13 @@ export function setKxmProjectPause(
378
415
  actor: record.actor,
379
416
  record: kxmCanonicalJson(record as unknown as JsonValue),
380
417
  };
381
- context.eventStore.putProjectControl(control);
382
- const released = input.paused ? [] : releaseHeldIntake(context, now);
383
- return { control, released };
418
+ // Control write and the held-intent release are one transaction: a crash leaves
419
+ // either a paused project with held rows, or a resumed project with none.
420
+ return context.eventStore.transaction(() => {
421
+ context.eventStore.putProjectControl(control);
422
+ const released = input.paused ? [] : releaseHeldIntake(context, now);
423
+ return { control, released };
424
+ });
384
425
  }
385
426
 
386
427
  /** Whether fresh dispatch is currently blocked for this project. */
@@ -389,20 +430,27 @@ export function isKxmProjectPaused(context: KxmRuntimeContext): boolean {
389
430
  }
390
431
 
391
432
  function releaseHeldIntake(context: KxmRuntimeContext, now: string): KxmIntakeMessage[] {
392
- const held = context.eventStore.intakeInStates(context.projectId, ["held_paused"], 500);
393
433
  const released: KxmIntakeMessage[] = [];
394
- for (const row of held) {
395
- const message = JSON.parse(row.record) as KxmIntakeMessage;
396
- const next: KxmIntakeMessage = { ...message, dispatch: { state: "ready", updatedAt: now } };
397
- validateIntakeMessage(next, next.messageId);
398
- if (context.eventStore.updateIntakeDispatch(row.messageId, "held_paused", { state: "ready", record: kxmCanonicalJson(next as unknown as JsonValue) })) {
399
- released.push(next);
434
+ // Drain every held row. A paging loop, not a single capped page: stranding the
435
+ // 501st message behind a "resume releases held intent" claim is a lie of omission.
436
+ for (let page = 0; page < 1000; page += 1) {
437
+ const held = context.eventStore.intakeInStates(context.projectId, ["held_paused"], 500);
438
+ if (held.length === 0) break;
439
+ for (const row of held) {
440
+ const message = JSON.parse(row.record) as KxmIntakeMessage;
441
+ const next: KxmIntakeMessage = { ...message, dispatch: { state: "ready", updatedAt: now } };
442
+ validateIntakeMessage(next, next.messageId);
443
+ if (context.eventStore.updateIntakeDispatch(row.messageId, "held_paused", { state: "ready", record: kxmCanonicalJson(next as unknown as JsonValue) })) {
444
+ released.push(next);
445
+ }
400
446
  }
447
+ const stillHeld = context.eventStore.intakeInStates(context.projectId, ["held_paused"], 1).length;
448
+ if (stillHeld === 0) break;
401
449
  }
402
450
  return released;
403
451
  }
404
452
 
405
- function persistCoordinator(context: KxmRuntimeContext, record: KxmCoordinatorRecord): void {
453
+ function persistCoordinator(context: KxmRuntimeContext, record: KxmCoordinatorRecord): { coordinator: KxmCoordinatorRecord } {
406
454
  validateCoordinator(record, record.coordinatorId);
407
455
  const inserted = context.eventStore.insertCoordinatorIfAbsent({
408
456
  coordinatorId: record.coordinatorId,
@@ -414,9 +462,14 @@ function persistCoordinator(context: KxmRuntimeContext, record: KxmCoordinatorRe
414
462
  boundAt: record.boundAt,
415
463
  record: kxmCanonicalJson(record as unknown as JsonValue),
416
464
  });
417
- if (!inserted) {
418
- throw runtimeError("coordinator_write_lost", record.coordinatorId, "the coordinator slot was claimed underneath this bind");
419
- }
465
+ if (inserted) return { coordinator: record };
466
+ // Lost the race for an empty slot: binding is create-once, so the winner is the
467
+ // answer whenever it reached the same ceiling. Only a different ceiling is a
468
+ // conflict worth reporting.
469
+ const winner = context.eventStore.coordinatorInSlot(record.projectId, record.role, record.channel);
470
+ const won = winner ? (JSON.parse(winner.record) as KxmCoordinatorRecord) : undefined;
471
+ if (won && won.ceilingHash === record.ceilingHash) return { coordinator: won };
472
+ throw runtimeError("coordinator_write_lost", record.coordinatorId, "the coordinator slot was claimed by a different ceiling");
420
473
  }
421
474
 
422
475
  function assertCeilingNotWidened(
@@ -432,10 +485,28 @@ function assertCeilingNotWidened(
432
485
  if (added.length > 0) {
433
486
  throw runtimeError("coordinator_rebind_widens_effects", coordinatorId, `a rebind may not add effects: ${added.join(", ")}`);
434
487
  }
435
- const allowedBefore = new Set(previous.tools?.allow ?? []);
436
- const newlyAllowed = (next.tools?.allow ?? []).filter((tool) => !allowedBefore.has(tool));
437
- if (newlyAllowed.length > 0) {
438
- throw runtimeError("coordinator_rebind_widens_tools", coordinatorId, `a rebind may not allow new tools: ${newlyAllowed.join(", ")}`);
488
+ // Tool ceilings are only narrowing-safe if every restriction is checked. An
489
+ // omitted list or a changed preset can select a broader default, so those moves
490
+ // are refused outright until their semantics are defined here.
491
+ if ((previous.tools === undefined) !== (next.tools === undefined)) {
492
+ throw runtimeError("coordinator_rebind_widens_tools", coordinatorId, "a rebind may not add or remove the tool policy");
493
+ }
494
+ if (previous.tools && next.tools) {
495
+ const previousTools = previous.tools;
496
+ const nextTools = next.tools;
497
+ if (previousTools.preset !== nextTools.preset) {
498
+ throw runtimeError("coordinator_rebind_changes_preset", coordinatorId, "a rebind may not change the tool preset; its default is not defined here");
499
+ }
500
+ const allowedBefore = new Set(previousTools.allow ?? []);
501
+ const newlyAllowed = (nextTools.allow ?? []).filter((tool) => !allowedBefore.has(tool));
502
+ if (newlyAllowed.length > 0) {
503
+ throw runtimeError("coordinator_rebind_widens_tools", coordinatorId, `a rebind may not allow new tools: ${newlyAllowed.join(", ")}`);
504
+ }
505
+ const deniedBefore = new Set(previousTools.deny ?? []);
506
+ const undenied = [...deniedBefore].filter((tool) => !(nextTools.deny ?? []).includes(tool));
507
+ if (undenied.length > 0) {
508
+ throw runtimeError("coordinator_rebind_removes_denials", coordinatorId, `a rebind may not lift denials: ${undenied.join(", ")}`);
509
+ }
439
510
  }
440
511
  }
441
512
 
@@ -459,16 +530,35 @@ function validateAuthority(authority: KxmCoordinatorAuthority): KxmCoordinatorAu
459
530
  const tools = authority.tools;
460
531
  if (tools !== undefined) {
461
532
  const lists = [tools.allow ?? [], tools.deny ?? []];
462
- for (const list of lists) for (const tool of list) requireIdentifier(tool, "tools");
533
+ for (const list of lists) {
534
+ for (const tool of list) requireIdentifier(tool, "tools");
535
+ if (new Set(list).size !== list.length) {
536
+ throw runtimeError("coordinator_authority_invalid", "tools", "tool lists must not repeat");
537
+ }
538
+ }
463
539
  if (tools.preset !== undefined) requireIdentifier(tools.preset, "tools.preset");
464
540
  }
541
+ // Sets are stored canonically: order and repeats carry no authority, and leaving
542
+ // them as supplied would let an equivalent ceiling masquerade as a rebind.
465
543
  return {
466
544
  repositoryAccess: authority.repositoryAccess,
467
- effects,
468
- ...(tools !== undefined ? { tools } : {}),
545
+ effects: canonicalSet(effects),
546
+ ...(tools !== undefined
547
+ ? {
548
+ tools: {
549
+ ...(tools.preset !== undefined ? { preset: tools.preset } : {}),
550
+ ...(tools.allow !== undefined ? { allow: canonicalSet(tools.allow) } : {}),
551
+ ...(tools.deny !== undefined ? { deny: canonicalSet(tools.deny) } : {}),
552
+ },
553
+ }
554
+ : {}),
469
555
  };
470
556
  }
471
557
 
558
+ function canonicalSet(values: readonly string[]): string[] {
559
+ return [...new Set(values)].sort();
560
+ }
561
+
472
562
  function validateActor<T extends { kind: KxmCoordinatorRecord["boundBy"]["kind"]; id: string }>(actor: T, field: string): T {
473
563
  const kinds: string[] = ["human", "runtime", "hub", "agent", "adapter"];
474
564
  if (!actor || typeof actor !== "object" || !kinds.includes(actor.kind)) {
@@ -506,18 +596,6 @@ function requireTimestamp(value: string, field: string): string {
506
596
  return value;
507
597
  }
508
598
 
509
- function contextConfigRevision(context: KxmRuntimeContext): string {
510
- const revision = (context as { configRevision?: string }).configRevision;
511
- if (typeof revision === "string" && /^sha256:[a-f0-9]{64}$/.test(revision)) return revision;
512
- // The context does not carry one: fingerprint its own identity inputs instead of
513
- // inventing a revision, so drift across a reopen is still detectable.
514
- return `sha256:${createHash("sha256").update(stableStringify({
515
- projectId: context.projectId,
516
- projectRoot: context.projectRoot,
517
- homeRuntimeId: context.homeRuntimeId,
518
- }), "utf8").digest("hex")}`;
519
- }
520
-
521
599
  function stableStringify(value: unknown): string {
522
600
  return JSON.stringify(sortDeep(value));
523
601
  }
@@ -8,7 +8,7 @@ import { AGENT_COMMANDS_MAP, enforceToolPolicy, getMcpTools, reconcileInbox } fr
8
8
  import { deliverInboxNotification } from "./inbox.ts";
9
9
  import type { HubEvent, MessageRecord } from "./protocol.ts";
10
10
 
11
- const VERSION = "0.7.47";
11
+ const VERSION = "0.7.49";
12
12
  const inbox = new Map<string, MessageRecord>();
13
13
  const notifiedInbox = new Set<string>();
14
14
  let meshClient: HubClient | undefined;
@@ -234,6 +234,8 @@ export interface KxmRuntimeContext {
234
234
  projectId: string;
235
235
  homeRuntimeId: string;
236
236
  eventStore: KxmRunEventStore;
237
+ /** Deterministic revision of the loaded project configuration (`sha256:...`). */
238
+ readonly configRevision: string;
237
239
  /**
238
240
  * Optional clock for **run-duration budget accounting only**. Budgets are
239
241
  * measured from the log's `runningSince`; this answers "what time is it now"
@@ -306,6 +308,7 @@ export function openKxmRuntimeContext(
306
308
  projectId: registration.projectId,
307
309
  homeRuntimeId: registration.homeRuntimeId,
308
310
  eventStore,
311
+ configRevision: bundle.configRevision,
309
312
  ...(options.budgetClock ? { budgetClock: options.budgetClock } : {}),
310
313
  };
311
314
  } catch (error) {
@@ -1255,14 +1255,14 @@ export class KxmRunEventStore {
1255
1255
  return row ? intakeFromRow(row) : undefined;
1256
1256
  }
1257
1257
 
1258
- /** Intake rows in the given dispatch states, oldest first (stable, replay-safe order). */
1258
+ /** Intake rows in the given dispatch states, in arrival order (replay-safe). */
1259
1259
  intakeInStates(projectId: string, states: readonly KxmIntakeMessageRow["dispatchState"][], limit = 100): KxmIntakeMessageRow[] {
1260
1260
  if (states.length === 0) return [];
1261
1261
  const placeholders = states.map(() => "?").join(", ");
1262
1262
  const rows = this.database.prepare(`
1263
1263
  SELECT * FROM intake_messages
1264
1264
  WHERE project_id = ? AND dispatch_state IN (${placeholders})
1265
- ORDER BY received_at ASC, message_id ASC
1265
+ ORDER BY rowid ASC
1266
1266
  LIMIT ?
1267
1267
  `).all(projectId, ...states, limit) as unknown as IntakeSqlRow[];
1268
1268
  return rows.map(intakeFromRow);
@@ -1554,10 +1554,19 @@ interface ControlSqlRow {
1554
1554
  * whose index and payload disagree fails closed instead of trusting either.
1555
1555
  */
1556
1556
  function coordinatorFromRow(row: CoordinatorSqlRow): KxmCoordinatorRow {
1557
- const parsed = JSON.parse(row.record) as { coordinatorId?: string; ceilingHash?: string; configRevision?: string; boundAt?: string };
1557
+ const parsed = JSON.parse(row.record) as {
1558
+ coordinatorId?: string; projectId?: string; role?: string; channel?: string;
1559
+ ceilingHash?: string; configRevision?: string; boundAt?: string;
1560
+ };
1561
+ if (row.schema !== "kxm.coordinator.v1") {
1562
+ throw runtimeError("coordinator_record_divergent", row.coordinator_id, `unexpected coordinator schema ${row.schema}`);
1563
+ }
1558
1564
  validateCoordinator(parsed, row.coordinator_id);
1559
1565
  if (
1560
1566
  parsed.coordinatorId !== row.coordinator_id
1567
+ || parsed.projectId !== row.project_id
1568
+ || parsed.role !== row.role
1569
+ || parsed.channel !== row.channel
1561
1570
  || parsed.ceilingHash !== row.ceiling_hash
1562
1571
  || parsed.configRevision !== row.config_revision
1563
1572
  || parsed.boundAt !== row.bound_at
@@ -1577,10 +1586,19 @@ function coordinatorFromRow(row: CoordinatorSqlRow): KxmCoordinatorRow {
1577
1586
  }
1578
1587
 
1579
1588
  function intakeFromRow(row: IntakeSqlRow): KxmIntakeMessageRow {
1580
- const parsed = JSON.parse(row.record) as { messageId?: string; contentHash?: string; receivedAt?: string; dispatch?: { state?: string } };
1589
+ const parsed = JSON.parse(row.record) as {
1590
+ messageId?: string; projectId?: string; coordinatorId?: string; idempotencyKey?: string;
1591
+ contentHash?: string; receivedAt?: string; dispatch?: { state?: string };
1592
+ };
1593
+ if (row.schema !== "kxm.intake-message.v1") {
1594
+ throw runtimeError("intake_record_divergent", row.message_id, `unexpected intake schema ${row.schema}`);
1595
+ }
1581
1596
  validateIntakeMessage(parsed, row.message_id);
1582
1597
  if (
1583
1598
  parsed.messageId !== row.message_id
1599
+ || parsed.projectId !== row.project_id
1600
+ || parsed.coordinatorId !== row.coordinator_id
1601
+ || parsed.idempotencyKey !== row.idempotency_key
1584
1602
  || parsed.contentHash !== row.content_hash
1585
1603
  || parsed.receivedAt !== row.received_at
1586
1604
  || parsed.dispatch?.state !== row.dispatch_state
@@ -41,9 +41,9 @@
41
41
  "additionalProperties": false,
42
42
  "allOf": [
43
43
  {
44
- "if": { "properties": { "classification": { "enum": ["public", "project", "sensitive"] } }, "required": ["classification"] },
45
- "then": { "required": ["content"] },
46
- "else": { "not": { "required": ["content"] } }
44
+ "if": { "properties": { "classification": { "const": "secret" } }, "required": ["classification"] },
45
+ "then": { "required": ["contentOmittedReason"], "not": { "required": ["content"] } },
46
+ "else": { "required": ["content"], "not": { "required": ["contentOmittedReason"] } }
47
47
  }
48
48
  ],
49
49
  "$defs": {
@@ -62,6 +62,10 @@
62
62
  "if": { "properties": { "state": { "const": "admitted" } }, "required": ["state"] },
63
63
  "then": { "required": ["runId", "updatedAt"] }
64
64
  },
65
+ {
66
+ "if": { "properties": { "state": { "not": { "const": "admitted" } } }, "required": ["state"] },
67
+ "then": { "not": { "required": ["runId"] } }
68
+ },
65
69
  {
66
70
  "if": { "properties": { "state": { "enum": ["held_paused", "refused"] } }, "required": ["state"] },
67
71
  "then": { "required": ["reason"] }