@kontextmind/kxm 0.7.45 → 0.7.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,536 @@
1
+ /**
2
+ * KXM Runtime intake contract (unified plan M1 + M6, the durable part of M2).
3
+ *
4
+ * One persistent, project-scoped coordinator identity carries an authority
5
+ * ceiling; internal messages arrive against that identity with an idempotency
6
+ * key; and a project pause blocks *fresh dispatch* without losing the intent.
7
+ * These are separate facts and are recorded separately: received, persisted,
8
+ * and dispatch-ready each have their own state.
9
+ *
10
+ * Nothing here opens a model, a peer session, or an HTTP surface. The Runtime is
11
+ * the only writer, and adapters that later expose this must stay thin over it.
12
+ * Until they exist, no control is advertised — which is the honest M0 answer:
13
+ * unintegrated capability is unavailable, never a success-shaped stub.
14
+ */
15
+
16
+ import { createHash } from "node:crypto";
17
+ import { newId } from "./protocol.ts";
18
+ import {
19
+ kxmCanonicalJson,
20
+ validateCoordinator,
21
+ validateIntakeMessage,
22
+ type JsonValue,
23
+ } from "./project-config.ts";
24
+ import type { KxmRuntimeContext } from "./runtime-service.ts";
25
+ import { runtimeError, type KxmIntakeMessageRow, type KxmProjectControlRow } from "./runtime-store.ts";
26
+
27
+ /** Largest intake payload we are willing to hash and persist, in bytes. */
28
+ export const MAX_INTAKE_CONTENT_BYTES = 16_384;
29
+
30
+ export type KxmIntakeClassification = "public" | "project" | "sensitive" | "secret";
31
+ export type KxmIntakeDispatchState = "ready" | "held_paused" | "admitted" | "refused";
32
+ export type KxmIntakeSourceKind = "adapter" | "hub" | "operator" | "peer" | "schedule";
33
+
34
+ export interface KxmCoordinatorAuthority {
35
+ repositoryAccess: "none" | "read" | "write";
36
+ effects: readonly string[];
37
+ tools?: {
38
+ preset?: string;
39
+ allow?: readonly string[];
40
+ deny?: readonly string[];
41
+ };
42
+ }
43
+
44
+ export interface KxmCoordinatorRecord {
45
+ schema: "kxm.coordinator.v1";
46
+ coordinatorId: string;
47
+ projectId: string;
48
+ role: string;
49
+ channel: string;
50
+ authority: KxmCoordinatorAuthority;
51
+ boundAt: string;
52
+ boundBy: { kind: "human" | "runtime" | "hub" | "agent" | "adapter"; id: string };
53
+ configRevision: string;
54
+ ceilingHash: string;
55
+ rebindOf?: string;
56
+ rebindReason?: string;
57
+ }
58
+
59
+ export interface KxmIntakeMessage {
60
+ schema: "kxm.intake-message.v1";
61
+ messageId: string;
62
+ projectId: string;
63
+ coordinatorId: string;
64
+ receivedAt: string;
65
+ source: { kind: KxmIntakeSourceKind; id: string };
66
+ idempotencyKey: string;
67
+ contentHash: string;
68
+ content?: string;
69
+ contentOmittedReason?: "secret-classified";
70
+ classification: KxmIntakeClassification;
71
+ dispatch: {
72
+ state: KxmIntakeDispatchState;
73
+ reason?: string;
74
+ updatedAt?: string;
75
+ runId?: string;
76
+ };
77
+ }
78
+
79
+ /** The persisted shape behind the project-control columns. */
80
+ interface KxmProjectControlRecord {
81
+ schema: "kxm.project-control.v1";
82
+ projectId: string;
83
+ paused: boolean;
84
+ reason?: string;
85
+ updatedAt: string;
86
+ actor: string;
87
+ }
88
+
89
+ const ACCESS_ORDER: Record<KxmCoordinatorAuthority["repositoryAccess"], number> = {
90
+ none: 0,
91
+ read: 1,
92
+ write: 2,
93
+ };
94
+
95
+ const IDENTIFIER_RE = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/;
96
+ const COORDINATOR_ID_RE = /^[a-z][a-z0-9]{1,15}_[A-Za-z0-9][A-Za-z0-9_-]{5,127}$/;
97
+ const ACTOR_ID_RE = /^.{1,200}$/;
98
+
99
+ /** Canonical SHA-256 over the authority ceiling — the coordinator's fingerprint. */
100
+ export function kxmCeilingHash(authority: KxmCoordinatorAuthority): string {
101
+ return `sha256:${createHash("sha256").update(stableStringify(authority), "utf8").digest("hex")}`;
102
+ }
103
+
104
+ /**
105
+ * Bind the coordinator for a (project, role, channel) slot. Binding is
106
+ * create-once and idempotent: presenting the same ceiling again returns the
107
+ * existing identity rather than minting a second one.
108
+ *
109
+ * A different ceiling is a **rebind** and needs explicit policy; it may narrow
110
+ * the ceiling but never widen it, because a message or a model swap must not be
111
+ * able to grow authority.
112
+ */
113
+ export function bindKxmCoordinator(
114
+ context: KxmRuntimeContext,
115
+ input: {
116
+ role: string;
117
+ channel?: string;
118
+ authority: KxmCoordinatorAuthority;
119
+ actor: { kind: KxmCoordinatorRecord["boundBy"]["kind"]; id: string };
120
+ now?: string;
121
+ rebind?: { approvedBy: { kind: KxmCoordinatorRecord["boundBy"]["kind"]; id: string }; reason: string };
122
+ },
123
+ ): { coordinator: KxmCoordinatorRecord; created: boolean } {
124
+ const role = requireIdentifier(input.role, "role");
125
+ const channel = requireIdentifier(input.channel ?? "primary", "channel");
126
+ const authority = validateAuthority(input.authority);
127
+ const actor = validateActor(input.actor, "actor");
128
+ const now = requireTimestamp(input.now ?? new Date().toISOString(), "now");
129
+ const ceilingHash = kxmCeilingHash(authority);
130
+ const existing = context.eventStore.coordinatorInSlot(context.projectId, role, channel);
131
+
132
+ if (existing) {
133
+ const current = JSON.parse(existing.record) as KxmCoordinatorRecord;
134
+ if (current.ceilingHash === ceilingHash) {
135
+ return { coordinator: current, created: false };
136
+ }
137
+ if (!input.rebind) {
138
+ throw runtimeError(
139
+ "coordinator_rebind_requires_policy",
140
+ existing.coordinatorId,
141
+ `coordinator for role ${role}/${channel} is already bound with a different ceiling; rebinding needs explicit policy`,
142
+ );
143
+ }
144
+ assertCeilingNotWidened(current.authority, authority, existing.coordinatorId);
145
+ const reason = requireText(input.rebind.reason, "rebind.reason", 512);
146
+ const record: KxmCoordinatorRecord = {
147
+ schema: "kxm.coordinator.v1",
148
+ coordinatorId: newId("crd"),
149
+ projectId: context.projectId,
150
+ role,
151
+ channel,
152
+ authority,
153
+ boundAt: now,
154
+ boundBy: validateActor(input.rebind.approvedBy, "rebind.approvedBy"),
155
+ configRevision: current.configRevision,
156
+ ceilingHash,
157
+ rebindOf: existing.coordinatorId,
158
+ rebindReason: reason,
159
+ };
160
+ validateCoordinator(record, record.coordinatorId);
161
+ const replaced = context.eventStore.replaceCoordinatorInSlot(existing.coordinatorId, {
162
+ coordinatorId: record.coordinatorId,
163
+ projectId: record.projectId,
164
+ role: record.role,
165
+ channel: record.channel,
166
+ ceilingHash: record.ceilingHash,
167
+ configRevision: record.configRevision,
168
+ boundAt: record.boundAt,
169
+ record: kxmCanonicalJson(record as unknown as JsonValue),
170
+ });
171
+ if (!replaced) {
172
+ throw runtimeError("coordinator_write_lost", existing.coordinatorId, "the coordinator slot changed underneath this rebind");
173
+ }
174
+ return { coordinator: record, created: true };
175
+ }
176
+
177
+ if (input.rebind) {
178
+ throw runtimeError("coordinator_rebind_without_subject", role, "there is no bound coordinator to rebind");
179
+ }
180
+ const record: KxmCoordinatorRecord = {
181
+ schema: "kxm.coordinator.v1",
182
+ coordinatorId: newId("crd"),
183
+ projectId: context.projectId,
184
+ role,
185
+ channel,
186
+ authority,
187
+ boundAt: now,
188
+ boundBy: actor,
189
+ configRevision: contextConfigRevision(context),
190
+ ceilingHash,
191
+ };
192
+ persistCoordinator(context, record);
193
+ return { coordinator: record, created: true };
194
+ }
195
+
196
+ /** Resolve a coordinator by id; unknown identity fails closed. */
197
+ export function resolveKxmCoordinator(context: KxmRuntimeContext, coordinatorId: string): KxmCoordinatorRecord {
198
+ const row = context.eventStore.coordinatorById(requireText(coordinatorId, "coordinatorId", 144));
199
+ if (!row) throw runtimeError("coordinator_unknown", coordinatorId, "no coordinator is bound in this project");
200
+ return JSON.parse(row.record) as KxmCoordinatorRecord;
201
+ }
202
+
203
+ /**
204
+ * Accept one internal message at the intake boundary.
205
+ *
206
+ * Duplicate ingress (same idempotency key, same content) returns the original
207
+ * record and cannot create another task. The same key with **different** content
208
+ * is rejected: a reused key must not smuggle a different payload. Payloads
209
+ * classified `secret` are never persisted — only their hash, so intake cannot
210
+ * become a secret store. While the project is paused the message is durable with
211
+ * a held dispatch intent instead of being dropped.
212
+ */
213
+ export function acceptKxmIntakeMessage(
214
+ context: KxmRuntimeContext,
215
+ input: {
216
+ coordinatorId: string;
217
+ idempotencyKey: string;
218
+ content: string;
219
+ classification?: KxmIntakeClassification;
220
+ source: { kind: KxmIntakeSourceKind; id: string };
221
+ now?: string;
222
+ },
223
+ ): { message: KxmIntakeMessage; duplicate: boolean } {
224
+ const coordinator = resolveKxmCoordinator(context, input.coordinatorId);
225
+ const key = requireText(input.idempotencyKey, "idempotencyKey", 200);
226
+ if (typeof input.content !== "string" || input.content.length === 0) {
227
+ throw runtimeError("intake_content_missing", coordinator.coordinatorId, "intake content must be a non-empty string");
228
+ }
229
+ const classification = input.classification ?? "project";
230
+ if (!["public", "project", "sensitive", "secret"].includes(classification)) {
231
+ throw runtimeError("intake_classification_invalid", coordinator.coordinatorId, `unknown intake classification ${classification}`);
232
+ }
233
+ const sourceKind = input.source?.kind;
234
+ if (!["adapter", "hub", "operator", "peer", "schedule"].includes(String(sourceKind))) {
235
+ throw runtimeError("intake_source_invalid", coordinator.coordinatorId, `unknown intake source kind ${String(sourceKind)}`);
236
+ }
237
+ const sourceId = requireText(input.source.id, "source.id", 200);
238
+ const now = requireTimestamp(input.now ?? new Date().toISOString(), "now");
239
+ const bytes = Buffer.byteLength(input.content, "utf8");
240
+ const contentHash = `sha256:${createHash("sha256").update(input.content, "utf8").digest("hex")}`;
241
+
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) {
246
+ throw runtimeError(
247
+ "intake_payload_conflict",
248
+ existing.messageId,
249
+ `idempotency key ${key} was already used with different content`,
250
+ );
251
+ }
252
+ return { message: existing, duplicate: true };
253
+ }
254
+
255
+ if (bytes > MAX_INTAKE_CONTENT_BYTES) {
256
+ throw runtimeError(
257
+ "intake_content_too_large",
258
+ coordinator.coordinatorId,
259
+ `intake content is ${bytes} bytes; the limit is ${MAX_INTAKE_CONTENT_BYTES}`,
260
+ );
261
+ }
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 };
299
+ }
300
+ return { message, duplicate: false };
301
+ }
302
+
303
+ /** Pending intake that may be dispatched, oldest first. Empty while paused. */
304
+ export function listKxmDispatchableIntake(context: KxmRuntimeContext, limit = 50): KxmIntakeMessage[] {
305
+ if (isKxmProjectPaused(context)) return [];
306
+ return context.eventStore
307
+ .intakeInStates(context.projectId, ["ready"], limit)
308
+ .map((row) => JSON.parse(row.record) as KxmIntakeMessage);
309
+ }
310
+
311
+ /**
312
+ * Bind a ready message to its run. Admission is idempotent for the same run and
313
+ * refuses a second one, so duplicate ingress cannot create another task. While
314
+ * paused, nothing may be admitted at all.
315
+ */
316
+ export function admitKxmIntakeRun(
317
+ context: KxmRuntimeContext,
318
+ messageId: string,
319
+ input: { runId: string; now?: string },
320
+ ): KxmIntakeMessage {
321
+ const id = requireText(messageId, "messageId", 144);
322
+ const runId = requireText(input.runId, "runId", 144);
323
+ if (!COORDINATOR_ID_RE.test(runId)) throw runtimeError("intake_run_id_invalid", id, "runId is not a well-formed opaque id");
324
+ 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;
348
+ }
349
+
350
+ /**
351
+ * Pause or resume intake dispatch for the project. Pausing does not cancel work
352
+ * that is already admitted; it stops fresh dispatch and holds the intent. A
353
+ * message that arrives *during* the pause is stored as `held_paused`, and a
354
+ * resume releases exactly those, in received order. Messages that were already
355
+ * `ready` stay ready — pause blocks the dispatch decision, not the record — so
356
+ * nothing is lost or duplicated across the pause window either way.
357
+ */
358
+ export function setKxmProjectPause(
359
+ context: KxmRuntimeContext,
360
+ input: { paused: boolean; actor: { kind: KxmCoordinatorRecord["boundBy"]["kind"]; id: string }; reason?: string; now?: string },
361
+ ): { control: KxmProjectControlRow; released: KxmIntakeMessage[] } {
362
+ const actor = validateActor(input.actor, "actor");
363
+ const now = requireTimestamp(input.now ?? new Date().toISOString(), "now");
364
+ const reason = input.reason === undefined ? undefined : requireText(input.reason, "reason", 512);
365
+ const record: KxmProjectControlRecord = {
366
+ schema: "kxm.project-control.v1",
367
+ projectId: context.projectId,
368
+ paused: input.paused,
369
+ ...(reason !== undefined ? { reason } : {}),
370
+ updatedAt: now,
371
+ actor: `${actor.kind}:${actor.id}`,
372
+ };
373
+ const control: KxmProjectControlRow = {
374
+ projectId: context.projectId,
375
+ paused: input.paused,
376
+ ...(reason !== undefined ? { reason } : {}),
377
+ updatedAt: now,
378
+ actor: record.actor,
379
+ record: kxmCanonicalJson(record as unknown as JsonValue),
380
+ };
381
+ context.eventStore.putProjectControl(control);
382
+ const released = input.paused ? [] : releaseHeldIntake(context, now);
383
+ return { control, released };
384
+ }
385
+
386
+ /** Whether fresh dispatch is currently blocked for this project. */
387
+ export function isKxmProjectPaused(context: KxmRuntimeContext): boolean {
388
+ return context.eventStore.projectControl(context.projectId)?.paused === true;
389
+ }
390
+
391
+ function releaseHeldIntake(context: KxmRuntimeContext, now: string): KxmIntakeMessage[] {
392
+ const held = context.eventStore.intakeInStates(context.projectId, ["held_paused"], 500);
393
+ 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);
400
+ }
401
+ }
402
+ return released;
403
+ }
404
+
405
+ function persistCoordinator(context: KxmRuntimeContext, record: KxmCoordinatorRecord): void {
406
+ validateCoordinator(record, record.coordinatorId);
407
+ const inserted = context.eventStore.insertCoordinatorIfAbsent({
408
+ coordinatorId: record.coordinatorId,
409
+ projectId: record.projectId,
410
+ role: record.role,
411
+ channel: record.channel,
412
+ ceilingHash: record.ceilingHash,
413
+ configRevision: record.configRevision,
414
+ boundAt: record.boundAt,
415
+ record: kxmCanonicalJson(record as unknown as JsonValue),
416
+ });
417
+ if (!inserted) {
418
+ throw runtimeError("coordinator_write_lost", record.coordinatorId, "the coordinator slot was claimed underneath this bind");
419
+ }
420
+ }
421
+
422
+ function assertCeilingNotWidened(
423
+ previous: KxmCoordinatorAuthority,
424
+ next: KxmCoordinatorAuthority,
425
+ coordinatorId: string,
426
+ ): void {
427
+ if (ACCESS_ORDER[next.repositoryAccess] > ACCESS_ORDER[previous.repositoryAccess]) {
428
+ throw runtimeError("coordinator_rebind_widens_access", coordinatorId, "a rebind may not widen repository access");
429
+ }
430
+ const had = new Set(previous.effects);
431
+ const added = next.effects.filter((effect) => !had.has(effect));
432
+ if (added.length > 0) {
433
+ throw runtimeError("coordinator_rebind_widens_effects", coordinatorId, `a rebind may not add effects: ${added.join(", ")}`);
434
+ }
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(", ")}`);
439
+ }
440
+ }
441
+
442
+ function validateAuthority(authority: KxmCoordinatorAuthority): KxmCoordinatorAuthority {
443
+ if (!authority || typeof authority !== "object") {
444
+ throw runtimeError("coordinator_authority_invalid", "authority", "authority must be an object");
445
+ }
446
+ if (!(authority.repositoryAccess in ACCESS_ORDER)) {
447
+ throw runtimeError("coordinator_authority_invalid", "repositoryAccess", "repositoryAccess must be none, read or write");
448
+ }
449
+ if (!Array.isArray(authority.effects)) {
450
+ throw runtimeError("coordinator_authority_invalid", "effects", "effects must be an array of identifiers");
451
+ }
452
+ const effects = authority.effects.map((effect) => requireIdentifier(effect, "effects[]"));
453
+ if (new Set(effects).size !== effects.length) {
454
+ throw runtimeError("coordinator_authority_invalid", "effects", "effects must not repeat");
455
+ }
456
+ if (effects.length > 64) {
457
+ throw runtimeError("coordinator_authority_invalid", "effects", "effects may not exceed 64 entries");
458
+ }
459
+ const tools = authority.tools;
460
+ if (tools !== undefined) {
461
+ const lists = [tools.allow ?? [], tools.deny ?? []];
462
+ for (const list of lists) for (const tool of list) requireIdentifier(tool, "tools");
463
+ if (tools.preset !== undefined) requireIdentifier(tools.preset, "tools.preset");
464
+ }
465
+ return {
466
+ repositoryAccess: authority.repositoryAccess,
467
+ effects,
468
+ ...(tools !== undefined ? { tools } : {}),
469
+ };
470
+ }
471
+
472
+ function validateActor<T extends { kind: KxmCoordinatorRecord["boundBy"]["kind"]; id: string }>(actor: T, field: string): T {
473
+ const kinds: string[] = ["human", "runtime", "hub", "agent", "adapter"];
474
+ if (!actor || typeof actor !== "object" || !kinds.includes(actor.kind)) {
475
+ throw runtimeError("coordinator_actor_invalid", field, "actor kind must be one of " + kinds.join(", "));
476
+ }
477
+ const id = requireText(actor.id, `${field}.id`, 200);
478
+ if (!ACTOR_ID_RE.test(id)) throw runtimeError("coordinator_actor_invalid", `${field}.id`, "actor id is out of bounds");
479
+ return { ...actor, id };
480
+ }
481
+
482
+ function requireIdentifier(value: string, field: string): string {
483
+ const text = requireText(value, field, 64);
484
+ if (!IDENTIFIER_RE.test(text)) {
485
+ throw runtimeError("coordinator_identifier_invalid", field, `${text} is not a kebab/snake identifier`);
486
+ }
487
+ return text;
488
+ }
489
+
490
+ function requireText(value: unknown, field: string, max: number): string {
491
+ if (typeof value !== "string" || value.trim().length === 0) {
492
+ throw runtimeError("intake_field_invalid", field, `${field} must be a non-empty string`);
493
+ }
494
+ const text = value.trim();
495
+ if (text.length > max) throw runtimeError("intake_field_invalid", field, `${field} exceeds ${max} characters`);
496
+ return text;
497
+ }
498
+
499
+ function requireTimestamp(value: string, field: string): string {
500
+ if (!/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,9})?Z$/.test(value)) {
501
+ throw runtimeError("intake_field_invalid", field, `${field} must be an ISO8601 UTC timestamp`);
502
+ }
503
+ if (!Number.isFinite(Date.parse(value))) {
504
+ throw runtimeError("intake_field_invalid", field, `${field} is not a real timestamp`);
505
+ }
506
+ return value;
507
+ }
508
+
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
+ function stableStringify(value: unknown): string {
522
+ return JSON.stringify(sortDeep(value));
523
+ }
524
+
525
+ function sortDeep(value: unknown): unknown {
526
+ if (Array.isArray(value)) return value.map(sortDeep);
527
+ if (value && typeof value === "object") {
528
+ const out: Record<string, unknown> = {};
529
+ for (const key of Object.keys(value as Record<string, unknown>).sort()) {
530
+ const child = (value as Record<string, unknown>)[key];
531
+ if (child !== undefined) out[key] = sortDeep(child);
532
+ }
533
+ return out;
534
+ }
535
+ return value;
536
+ }
@@ -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.45";
11
+ const VERSION = "0.7.47";
12
12
  const inbox = new Map<string, MessageRecord>();
13
13
  const notifiedInbox = new Set<string>();
14
14
  let meshClient: HubClient | undefined;
@@ -167,6 +167,8 @@ export class KxmSchemaRegistry {
167
167
  readonly permissionDiffValidator: ValidateFunction;
168
168
  readonly runEventValidator: ValidateFunction;
169
169
  readonly driveReceiptValidator: ValidateFunction;
170
+ readonly coordinatorValidator: ValidateFunction;
171
+ readonly intakeMessageValidator: ValidateFunction;
170
172
 
171
173
  constructor(schemasDir = DEFAULT_SCHEMA_DIR) {
172
174
  this.schemasDir = resolve(schemasDir);
@@ -185,6 +187,8 @@ export class KxmSchemaRegistry {
185
187
  const permissionDiffFile = "permission-diff.schema.json";
186
188
  const runEventFile = "run-event.schema.json";
187
189
  const driveReceiptFile = "drive-receipt.schema.json";
190
+ const coordinatorFile = "coordinator.schema.json";
191
+ const intakeMessageFile = "intake-message.schema.json";
188
192
  this.ajv.addSchema(readJsonObject(join(this.schemasDir, localBindingsFile)));
189
193
  this.ajv.addSchema(readJsonObject(join(this.schemasDir, templateProvenanceFile)));
190
194
  this.ajv.addSchema(readJsonObject(join(this.schemasDir, initOperationFile)));
@@ -194,6 +198,8 @@ export class KxmSchemaRegistry {
194
198
  this.ajv.addSchema(readJsonObject(join(this.schemasDir, permissionDiffFile)));
195
199
  this.ajv.addSchema(readJsonObject(join(this.schemasDir, runEventFile)));
196
200
  this.ajv.addSchema(readJsonObject(join(this.schemasDir, driveReceiptFile)));
201
+ this.ajv.addSchema(readJsonObject(join(this.schemasDir, coordinatorFile)));
202
+ this.ajv.addSchema(readJsonObject(join(this.schemasDir, intakeMessageFile)));
197
203
  for (const [kind, definition] of Object.entries(RESOURCE_SCHEMA) as [KxmResourceKind, { identity: string; file: string }][]) {
198
204
  const validator = this.ajv.getSchema(`https://schemas.kxm.dev/${definition.file}`);
199
205
  if (!validator) throw new Error(`schema did not compile: ${definition.file}`);
@@ -208,6 +214,8 @@ export class KxmSchemaRegistry {
208
214
  const permissionDiffValidator = this.ajv.getSchema(`https://schemas.kxm.dev/${permissionDiffFile}`);
209
215
  const runEventValidator = this.ajv.getSchema(`https://schemas.kxm.dev/${runEventFile}`);
210
216
  const driveReceiptValidator = this.ajv.getSchema(`https://schemas.kxm.dev/${driveReceiptFile}`);
217
+ const coordinatorValidator = this.ajv.getSchema(`https://schemas.kxm.dev/${coordinatorFile}`);
218
+ const intakeMessageValidator = this.ajv.getSchema(`https://schemas.kxm.dev/${intakeMessageFile}`);
211
219
  if (!localBindingsValidator) throw new Error(`schema did not compile: ${localBindingsFile}`);
212
220
  if (!templateProvenanceValidator) throw new Error(`schema did not compile: ${templateProvenanceFile}`);
213
221
  if (!initOperationValidator) throw new Error(`schema did not compile: ${initOperationFile}`);
@@ -217,6 +225,8 @@ export class KxmSchemaRegistry {
217
225
  if (!permissionDiffValidator) throw new Error(`schema did not compile: ${permissionDiffFile}`);
218
226
  if (!runEventValidator) throw new Error(`schema did not compile: ${runEventFile}`);
219
227
  if (!driveReceiptValidator) throw new Error(`schema did not compile: ${driveReceiptFile}`);
228
+ if (!coordinatorValidator) throw new Error(`schema did not compile: ${coordinatorFile}`);
229
+ if (!intakeMessageValidator) throw new Error(`schema did not compile: ${intakeMessageFile}`);
220
230
  this.localBindingsValidator = localBindingsValidator;
221
231
  this.templateProvenanceValidator = templateProvenanceValidator;
222
232
  this.initOperationValidator = initOperationValidator;
@@ -226,6 +236,8 @@ export class KxmSchemaRegistry {
226
236
  this.permissionDiffValidator = permissionDiffValidator;
227
237
  this.runEventValidator = runEventValidator;
228
238
  this.driveReceiptValidator = driveReceiptValidator;
239
+ this.coordinatorValidator = coordinatorValidator;
240
+ this.intakeMessageValidator = intakeMessageValidator;
229
241
  }
230
242
 
231
243
  validate(kind: KxmResourceKind, value: JsonObject, file: string): KxmConfigIssue[] {
@@ -316,6 +328,38 @@ export function validateDriveReceipt(value: unknown, file: string): void {
316
328
  }
317
329
  }
318
330
 
331
+ /** Validate a coordinator identity against `kxm.coordinator.v1`. Throws `coordinator_invalid`. */
332
+ export function validateCoordinator(value: unknown, file: string): void {
333
+ const registry = (cachedRunEventRegistry ??= new KxmSchemaRegistry());
334
+ if (!value || typeof value !== "object" || Array.isArray(value) || (value as JsonObject).schema !== "kxm.coordinator.v1") {
335
+ throw new KxmConfigError([issue("schema", "coordinator_invalid", file, "expected kxm.coordinator.v1")]);
336
+ }
337
+ if (!registry.coordinatorValidator(value)) {
338
+ throw new KxmConfigError([issue(
339
+ "schema",
340
+ "coordinator_invalid",
341
+ file,
342
+ registry.ajv.errorsText(registry.coordinatorValidator.errors, { separator: "; " }),
343
+ )]);
344
+ }
345
+ }
346
+
347
+ /** Validate an intake message against `kxm.intake-message.v1`. Throws `intake_message_invalid`. */
348
+ export function validateIntakeMessage(value: unknown, file: string): void {
349
+ const registry = (cachedRunEventRegistry ??= new KxmSchemaRegistry());
350
+ if (!value || typeof value !== "object" || Array.isArray(value) || (value as JsonObject).schema !== "kxm.intake-message.v1") {
351
+ throw new KxmConfigError([issue("schema", "intake_message_invalid", file, "expected kxm.intake-message.v1")]);
352
+ }
353
+ if (!registry.intakeMessageValidator(value)) {
354
+ throw new KxmConfigError([issue(
355
+ "schema",
356
+ "intake_message_invalid",
357
+ file,
358
+ registry.ajv.errorsText(registry.intakeMessageValidator.errors, { separator: "; " }),
359
+ )]);
360
+ }
361
+ }
362
+
319
363
  function schemaIssue(file: string, error: ErrorObject): KxmConfigIssue {
320
364
  const location = error.instancePath || "/";
321
365
  const suffix = error.params && "additionalProperty" in error.params