@kontextmind/kxm 0.7.44 → 0.7.46

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.
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { DatabaseSync } from "./sqlite.ts";
5
- import { KxmConfigError, validateDriveReceipt, validateRunEvent, kxmCanonicalJson, type JsonValue, type KxmConfigIssue, type KxmConfigOptions } from "./project-config.ts";
5
+ import { KxmConfigError, validateCoordinator, validateDriveReceipt, validateIntakeMessage, validateRunEvent, kxmCanonicalJson, type JsonValue, type KxmConfigIssue, type KxmConfigOptions } from "./project-config.ts";
6
6
  import { kxmUserStateRoot } from "./bindings.ts";
7
7
 
8
8
  /* ------------------------------------------------------------------ *
@@ -550,7 +550,7 @@ export interface KxmCommandRecord {
550
550
  recordedAt: string;
551
551
  }
552
552
 
553
- export const KXM_EVENT_STORE_SCHEMA_VERSION = 4;
553
+ export const KXM_EVENT_STORE_SCHEMA_VERSION = 5;
554
554
  export const KXM_DRIVE_RECEIPT_SCHEMA = "kxm.drive-receipt.v1";
555
555
  export const DRIVE_RECEIPT_MAX_BYTES = 8 * 1024;
556
556
 
@@ -578,8 +578,23 @@ const EVENT_STORE_TABLES = {
578
578
  "assignment_id", "effect_id", "evidence_key", "kind", "expect", "outcome", "settled_event_id", "content_hash",
579
579
  ],
580
580
  drive_receipts: ["drive_id", "run_id", "project_id", "opened_sequence", "last_sequence", "closed_at", "schema", "receipt"],
581
+ coordinators: [
582
+ "coordinator_id", "project_id", "role", "channel", "ceiling_hash", "config_revision", "bound_at", "schema", "record",
583
+ ],
584
+ intake_messages: [
585
+ "message_id", "project_id", "coordinator_id", "idempotency_key", "content_hash", "received_at", "dispatch_state",
586
+ "schema", "record",
587
+ ],
588
+ project_controls: ["project_id", "paused", "reason", "updated_at", "actor", "schema", "record"],
581
589
  } as const;
582
590
 
591
+ /**
592
+ * The authoritative table set of a run event store, sorted. Tests assert against
593
+ * this instead of a hand-copied list, so adding a table cannot silently leave a
594
+ * stale expectation behind.
595
+ */
596
+ export const KXM_EVENT_STORE_TABLE_NAMES: string[] = Object.keys(EVENT_STORE_TABLES).sort();
597
+
583
598
  const EVENT_STORE_SCHEMA = `
584
599
  CREATE TABLE runs (
585
600
  run_id TEXT PRIMARY KEY,
@@ -731,6 +746,77 @@ CREATE TABLE drive_receipts (
731
746
  receipt TEXT NOT NULL
732
747
  ) STRICT;
733
748
  CREATE INDEX drive_receipts_run ON drive_receipts(run_id);
749
+ CREATE TABLE coordinators (
750
+ coordinator_id TEXT PRIMARY KEY,
751
+ project_id TEXT NOT NULL,
752
+ role TEXT NOT NULL,
753
+ channel TEXT NOT NULL,
754
+ ceiling_hash TEXT NOT NULL,
755
+ config_revision TEXT NOT NULL,
756
+ bound_at TEXT NOT NULL,
757
+ schema TEXT NOT NULL,
758
+ record TEXT NOT NULL
759
+ ) STRICT;
760
+ CREATE UNIQUE INDEX coordinators_slot ON coordinators(project_id, role, channel);
761
+ CREATE TABLE intake_messages (
762
+ message_id TEXT PRIMARY KEY,
763
+ project_id TEXT NOT NULL,
764
+ coordinator_id TEXT NOT NULL,
765
+ idempotency_key TEXT NOT NULL,
766
+ content_hash TEXT NOT NULL,
767
+ received_at TEXT NOT NULL,
768
+ dispatch_state TEXT NOT NULL CHECK (dispatch_state IN ('ready','held_paused','admitted','refused')),
769
+ schema TEXT NOT NULL,
770
+ record TEXT NOT NULL
771
+ ) STRICT;
772
+ CREATE UNIQUE INDEX intake_idempotency ON intake_messages(project_id, coordinator_id, idempotency_key);
773
+ CREATE INDEX intake_dispatch ON intake_messages(project_id, dispatch_state, received_at, message_id);
774
+ CREATE TABLE project_controls (
775
+ project_id TEXT PRIMARY KEY,
776
+ paused INTEGER NOT NULL CHECK (paused IN (0, 1)),
777
+ reason TEXT,
778
+ updated_at TEXT NOT NULL,
779
+ actor TEXT NOT NULL,
780
+ schema TEXT NOT NULL,
781
+ record TEXT NOT NULL
782
+ ) STRICT;
783
+ `;
784
+
785
+ const COORDINATOR_INTAKE_DDL = `
786
+ CREATE TABLE IF NOT EXISTS coordinators (
787
+ coordinator_id TEXT PRIMARY KEY,
788
+ project_id TEXT NOT NULL,
789
+ role TEXT NOT NULL,
790
+ channel TEXT NOT NULL,
791
+ ceiling_hash TEXT NOT NULL,
792
+ config_revision TEXT NOT NULL,
793
+ bound_at TEXT NOT NULL,
794
+ schema TEXT NOT NULL,
795
+ record TEXT NOT NULL
796
+ ) STRICT;
797
+ CREATE UNIQUE INDEX IF NOT EXISTS coordinators_slot ON coordinators(project_id, role, channel);
798
+ CREATE TABLE IF NOT EXISTS intake_messages (
799
+ message_id TEXT PRIMARY KEY,
800
+ project_id TEXT NOT NULL,
801
+ coordinator_id TEXT NOT NULL,
802
+ idempotency_key TEXT NOT NULL,
803
+ content_hash TEXT NOT NULL,
804
+ received_at TEXT NOT NULL,
805
+ dispatch_state TEXT NOT NULL CHECK (dispatch_state IN ('ready','held_paused','admitted','refused')),
806
+ schema TEXT NOT NULL,
807
+ record TEXT NOT NULL
808
+ ) STRICT;
809
+ CREATE UNIQUE INDEX IF NOT EXISTS intake_idempotency ON intake_messages(project_id, coordinator_id, idempotency_key);
810
+ CREATE INDEX IF NOT EXISTS intake_dispatch ON intake_messages(project_id, dispatch_state, received_at, message_id);
811
+ CREATE TABLE IF NOT EXISTS project_controls (
812
+ project_id TEXT PRIMARY KEY,
813
+ paused INTEGER NOT NULL CHECK (paused IN (0, 1)),
814
+ reason TEXT,
815
+ updated_at TEXT NOT NULL,
816
+ actor TEXT NOT NULL,
817
+ schema TEXT NOT NULL,
818
+ record TEXT NOT NULL
819
+ ) STRICT;
734
820
  `;
735
821
 
736
822
  const DRIVE_RECEIPTS_DDL = `
@@ -755,8 +841,49 @@ const EVENT_STORE_MIGRATIONS = [
755
841
  database.exec(DRIVE_RECEIPTS_DDL);
756
842
  },
757
843
  },
844
+ {
845
+ fromVersion: 4,
846
+ toVersion: 5,
847
+ migrate(database: DatabaseSync): void {
848
+ database.exec(COORDINATOR_INTAKE_DDL);
849
+ },
850
+ },
758
851
  ];
759
852
 
853
+ /** One persisted coordinator identity (`kxm.coordinator.v1`). */
854
+ export interface KxmCoordinatorRow {
855
+ coordinatorId: string;
856
+ projectId: string;
857
+ role: string;
858
+ channel: string;
859
+ ceilingHash: string;
860
+ configRevision: string;
861
+ boundAt: string;
862
+ record: string;
863
+ }
864
+
865
+ /** One persisted intake message (`kxm.intake-message.v1`). */
866
+ export interface KxmIntakeMessageRow {
867
+ messageId: string;
868
+ projectId: string;
869
+ coordinatorId: string;
870
+ idempotencyKey: string;
871
+ contentHash: string;
872
+ receivedAt: string;
873
+ dispatchState: "ready" | "held_paused" | "admitted" | "refused";
874
+ record: string;
875
+ }
876
+
877
+ /** The project's intake control: paused or running, with who said so. */
878
+ export interface KxmProjectControlRow {
879
+ projectId: string;
880
+ paused: boolean;
881
+ reason?: string;
882
+ updatedAt: string;
883
+ actor: string;
884
+ record: string;
885
+ }
886
+
760
887
  export class KxmRunEventStore {
761
888
  readonly path: string;
762
889
  private readonly database: DatabaseSync;
@@ -1031,6 +1158,158 @@ export class KxmRunEventStore {
1031
1158
  });
1032
1159
  }
1033
1160
 
1161
+ /** Insert a coordinator unless the (project, role, channel) slot is taken. */
1162
+ insertCoordinatorIfAbsent(row: KxmCoordinatorRow): boolean {
1163
+ const result = this.database.prepare(`
1164
+ INSERT OR IGNORE INTO coordinators
1165
+ (coordinator_id, project_id, role, channel, ceiling_hash, config_revision, bound_at, schema, record)
1166
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1167
+ `).run(
1168
+ row.coordinatorId,
1169
+ row.projectId,
1170
+ row.role,
1171
+ row.channel,
1172
+ row.ceilingHash,
1173
+ row.configRevision,
1174
+ row.boundAt,
1175
+ "kxm.coordinator.v1",
1176
+ row.record,
1177
+ );
1178
+ return Number(result.changes) === 1;
1179
+ }
1180
+
1181
+ coordinatorById(coordinatorId: string): KxmCoordinatorRow | undefined {
1182
+ const row = this.database.prepare("SELECT * FROM coordinators WHERE coordinator_id = ?").get(coordinatorId) as
1183
+ | CoordinatorSqlRow
1184
+ | undefined;
1185
+ return row ? coordinatorFromRow(row) : undefined;
1186
+ }
1187
+
1188
+ coordinatorInSlot(projectId: string, role: string, channel: string): KxmCoordinatorRow | undefined {
1189
+ const row = this.database.prepare(`
1190
+ SELECT * FROM coordinators WHERE project_id = ? AND role = ? AND channel = ?
1191
+ `).get(projectId, role, channel) as CoordinatorSqlRow | undefined;
1192
+ return row ? coordinatorFromRow(row) : undefined;
1193
+ }
1194
+
1195
+ /**
1196
+ * Replace the coordinator holding a (project, role, channel) slot, guarded by
1197
+ * the identity that is currently there. A rebind therefore cannot clobber a
1198
+ * record that changed underneath it, and cannot leave two live identities for
1199
+ * one slot.
1200
+ */
1201
+ replaceCoordinatorInSlot(
1202
+ expectedCoordinatorId: string,
1203
+ row: KxmCoordinatorRow,
1204
+ ): boolean {
1205
+ const result = this.database.prepare(`
1206
+ UPDATE coordinators
1207
+ SET coordinator_id = ?, ceiling_hash = ?, config_revision = ?, bound_at = ?, schema = ?, record = ?
1208
+ WHERE project_id = ? AND role = ? AND channel = ? AND coordinator_id = ?
1209
+ `).run(
1210
+ row.coordinatorId,
1211
+ row.ceilingHash,
1212
+ row.configRevision,
1213
+ row.boundAt,
1214
+ "kxm.coordinator.v1",
1215
+ row.record,
1216
+ row.projectId,
1217
+ row.role,
1218
+ row.channel,
1219
+ expectedCoordinatorId,
1220
+ );
1221
+ return Number(result.changes) === 1;
1222
+ }
1223
+
1224
+ /** Insert an intake message unless its idempotency slot is taken. */
1225
+ insertIntakeMessageIfAbsent(row: KxmIntakeMessageRow): boolean {
1226
+ const result = this.database.prepare(`
1227
+ INSERT OR IGNORE INTO intake_messages
1228
+ (message_id, project_id, coordinator_id, idempotency_key, content_hash, received_at, dispatch_state, schema, record)
1229
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1230
+ `).run(
1231
+ row.messageId,
1232
+ row.projectId,
1233
+ row.coordinatorId,
1234
+ row.idempotencyKey,
1235
+ row.contentHash,
1236
+ row.receivedAt,
1237
+ row.dispatchState,
1238
+ "kxm.intake-message.v1",
1239
+ row.record,
1240
+ );
1241
+ return Number(result.changes) === 1;
1242
+ }
1243
+
1244
+ intakeMessage(messageId: string): KxmIntakeMessageRow | undefined {
1245
+ const row = this.database.prepare("SELECT * FROM intake_messages WHERE message_id = ?").get(messageId) as
1246
+ | IntakeSqlRow
1247
+ | undefined;
1248
+ return row ? intakeFromRow(row) : undefined;
1249
+ }
1250
+
1251
+ intakeBySlot(projectId: string, coordinatorId: string, idempotencyKey: string): KxmIntakeMessageRow | undefined {
1252
+ const row = this.database.prepare(`
1253
+ SELECT * FROM intake_messages WHERE project_id = ? AND coordinator_id = ? AND idempotency_key = ?
1254
+ `).get(projectId, coordinatorId, idempotencyKey) as IntakeSqlRow | undefined;
1255
+ return row ? intakeFromRow(row) : undefined;
1256
+ }
1257
+
1258
+ /** Intake rows in the given dispatch states, oldest first (stable, replay-safe order). */
1259
+ intakeInStates(projectId: string, states: readonly KxmIntakeMessageRow["dispatchState"][], limit = 100): KxmIntakeMessageRow[] {
1260
+ if (states.length === 0) return [];
1261
+ const placeholders = states.map(() => "?").join(", ");
1262
+ const rows = this.database.prepare(`
1263
+ SELECT * FROM intake_messages
1264
+ WHERE project_id = ? AND dispatch_state IN (${placeholders})
1265
+ ORDER BY received_at ASC, message_id ASC
1266
+ LIMIT ?
1267
+ `).all(projectId, ...states, limit) as unknown as IntakeSqlRow[];
1268
+ return rows.map(intakeFromRow);
1269
+ }
1270
+
1271
+ /** Rewrite one intake row's dispatch state and record. Returns false when it raced away. */
1272
+ updateIntakeDispatch(
1273
+ messageId: string,
1274
+ expectState: KxmIntakeMessageRow["dispatchState"],
1275
+ next: { state: KxmIntakeMessageRow["dispatchState"]; record: string },
1276
+ ): boolean {
1277
+ const result = this.database.prepare(`
1278
+ UPDATE intake_messages SET dispatch_state = ?, record = ?, schema = ?
1279
+ WHERE message_id = ? AND dispatch_state = ?
1280
+ `).run(next.state, next.record, "kxm.intake-message.v1", messageId, expectState);
1281
+ return Number(result.changes) === 1;
1282
+ }
1283
+
1284
+ projectControl(projectId: string): KxmProjectControlRow | undefined {
1285
+ const row = this.database.prepare("SELECT * FROM project_controls WHERE project_id = ?").get(projectId) as
1286
+ | ControlSqlRow
1287
+ | undefined;
1288
+ return row ? controlFromRow(row) : undefined;
1289
+ }
1290
+
1291
+ putProjectControl(row: KxmProjectControlRow): void {
1292
+ this.database.prepare(`
1293
+ INSERT INTO project_controls (project_id, paused, reason, updated_at, actor, schema, record)
1294
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1295
+ ON CONFLICT(project_id) DO UPDATE SET
1296
+ paused = excluded.paused,
1297
+ reason = excluded.reason,
1298
+ updated_at = excluded.updated_at,
1299
+ actor = excluded.actor,
1300
+ schema = excluded.schema,
1301
+ record = excluded.record
1302
+ `).run(
1303
+ row.projectId,
1304
+ row.paused ? 1 : 0,
1305
+ row.reason ?? null,
1306
+ row.updatedAt,
1307
+ row.actor,
1308
+ "kxm.project-control.v1",
1309
+ row.record,
1310
+ );
1311
+ }
1312
+
1034
1313
  insertCapability(row: KxmAttemptCapabilityRow): void {
1035
1314
  this.database.prepare(`
1036
1315
  INSERT INTO attempt_capabilities (attempt_id, run_id, assignment_id, step_id, step_attempt, producer_id, capability_hash, state)
@@ -1235,6 +1514,115 @@ export class KxmRunEventStore {
1235
1514
  }
1236
1515
  }
1237
1516
 
1517
+ interface CoordinatorSqlRow {
1518
+ coordinator_id: string;
1519
+ project_id: string;
1520
+ role: string;
1521
+ channel: string;
1522
+ ceiling_hash: string;
1523
+ config_revision: string;
1524
+ bound_at: string;
1525
+ schema: string;
1526
+ record: string;
1527
+ }
1528
+
1529
+ interface IntakeSqlRow {
1530
+ message_id: string;
1531
+ project_id: string;
1532
+ coordinator_id: string;
1533
+ idempotency_key: string;
1534
+ content_hash: string;
1535
+ received_at: string;
1536
+ dispatch_state: KxmIntakeMessageRow["dispatchState"];
1537
+ schema: string;
1538
+ record: string;
1539
+ }
1540
+
1541
+ interface ControlSqlRow {
1542
+ project_id: string;
1543
+ paused: number;
1544
+ reason: string | null;
1545
+ updated_at: string;
1546
+ actor: string;
1547
+ schema: string;
1548
+ record: string;
1549
+ }
1550
+
1551
+ /**
1552
+ * Read a persisted coordinator. The record is revalidated against
1553
+ * `kxm.coordinator.v1` and cross-checked against its own columns, so a store
1554
+ * whose index and payload disagree fails closed instead of trusting either.
1555
+ */
1556
+ function coordinatorFromRow(row: CoordinatorSqlRow): KxmCoordinatorRow {
1557
+ const parsed = JSON.parse(row.record) as { coordinatorId?: string; ceilingHash?: string; configRevision?: string; boundAt?: string };
1558
+ validateCoordinator(parsed, row.coordinator_id);
1559
+ if (
1560
+ parsed.coordinatorId !== row.coordinator_id
1561
+ || parsed.ceilingHash !== row.ceiling_hash
1562
+ || parsed.configRevision !== row.config_revision
1563
+ || parsed.boundAt !== row.bound_at
1564
+ ) {
1565
+ throw runtimeError("coordinator_record_divergent", row.coordinator_id, "coordinator columns do not match the persisted record");
1566
+ }
1567
+ return {
1568
+ coordinatorId: row.coordinator_id,
1569
+ projectId: row.project_id,
1570
+ role: row.role,
1571
+ channel: row.channel,
1572
+ ceilingHash: row.ceiling_hash,
1573
+ configRevision: row.config_revision,
1574
+ boundAt: row.bound_at,
1575
+ record: row.record,
1576
+ };
1577
+ }
1578
+
1579
+ function intakeFromRow(row: IntakeSqlRow): KxmIntakeMessageRow {
1580
+ const parsed = JSON.parse(row.record) as { messageId?: string; contentHash?: string; receivedAt?: string; dispatch?: { state?: string } };
1581
+ validateIntakeMessage(parsed, row.message_id);
1582
+ if (
1583
+ parsed.messageId !== row.message_id
1584
+ || parsed.contentHash !== row.content_hash
1585
+ || parsed.receivedAt !== row.received_at
1586
+ || parsed.dispatch?.state !== row.dispatch_state
1587
+ ) {
1588
+ throw runtimeError("intake_record_divergent", row.message_id, "intake columns do not match the persisted record");
1589
+ }
1590
+ return {
1591
+ messageId: row.message_id,
1592
+ projectId: row.project_id,
1593
+ coordinatorId: row.coordinator_id,
1594
+ idempotencyKey: row.idempotency_key,
1595
+ contentHash: row.content_hash,
1596
+ receivedAt: row.received_at,
1597
+ dispatchState: row.dispatch_state,
1598
+ record: row.record,
1599
+ };
1600
+ }
1601
+
1602
+ /** The control record is its columns; a stored payload that disagrees is drift. */
1603
+ function controlFromRow(row: ControlSqlRow): KxmProjectControlRow {
1604
+ const paused = row.paused === 1;
1605
+ const expected = kxmCanonicalJson({
1606
+ schema: "kxm.project-control.v1",
1607
+ projectId: row.project_id,
1608
+ paused,
1609
+ ...(row.reason !== null ? { reason: row.reason } : {}),
1610
+ updatedAt: row.updated_at,
1611
+ actor: row.actor,
1612
+ } as JsonValue);
1613
+ if (row.record !== expected) {
1614
+ throw runtimeError("project_control_divergent", row.project_id, "project control record does not match its columns");
1615
+ }
1616
+ return {
1617
+ projectId: row.project_id,
1618
+ paused,
1619
+ ...(row.reason !== null ? { reason: row.reason } : {}),
1620
+ updatedAt: row.updated_at,
1621
+ actor: row.actor,
1622
+ record: row.record,
1623
+ };
1624
+ }
1625
+
1238
1626
  function parseDriveReceipt(raw: string): KxmDriveReceipt {
1239
1627
  const parsed = JSON.parse(raw) as unknown;
1240
1628
  validateDriveReceipt(parsed, "drive-receipt");
package/schemas/README.md CHANGED
@@ -19,6 +19,8 @@ Runtime-local records, and JSON events/results for the planned KXM contract.
19
19
  | `migration-receipt.schema.json` | `kxm.migration-receipt.v1` |
20
20
  | `permission-diff.schema.json` | `kxm.permission-diff.v1` |
21
21
  | `run-event.schema.json` | `kxm.run-event.v1` |
22
+ | `coordinator.schema.json` | `kxm.coordinator.v1` |
23
+ | `intake-message.schema.json` | `kxm.intake-message.v1` |
22
24
  | `sync-event.schema.json` | `kxm.sync-event.v1` |
23
25
  | `assignment-result.schema.json` | `kxm.assignment-result.v1` |
24
26
  | `delivery-manifest.schema.json` | `kxm.delivery-manifest.v1` |
@@ -0,0 +1,54 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://schemas.kxm.dev/coordinator.schema.json",
4
+ "title": "KXM project coordinator identity",
5
+ "description": "A persistent, project-scoped coordinator: an authority ceiling bound to a role and channel, not an always-running model. Rebinding a stable coordinator requires explicit policy and may never widen its ceiling.",
6
+ "type": "object",
7
+ "required": [
8
+ "schema",
9
+ "coordinatorId",
10
+ "projectId",
11
+ "role",
12
+ "channel",
13
+ "authority",
14
+ "boundAt",
15
+ "boundBy",
16
+ "configRevision",
17
+ "ceilingHash"
18
+ ],
19
+ "properties": {
20
+ "schema": { "const": "kxm.coordinator.v1" },
21
+ "coordinatorId": { "$ref": "common.schema.json#/$defs/opaqueId" },
22
+ "projectId": { "$ref": "common.schema.json#/$defs/opaqueId" },
23
+ "role": { "$ref": "common.schema.json#/$defs/identifier" },
24
+ "channel": { "$ref": "common.schema.json#/$defs/identifier" },
25
+ "authority": {
26
+ "type": "object",
27
+ "required": ["repositoryAccess", "effects"],
28
+ "properties": {
29
+ "repositoryAccess": { "$ref": "common.schema.json#/$defs/repositoryAccess" },
30
+ "effects": {
31
+ "type": "array",
32
+ "items": { "$ref": "common.schema.json#/$defs/identifier" },
33
+ "uniqueItems": true,
34
+ "maxItems": 64
35
+ },
36
+ "tools": { "$ref": "common.schema.json#/$defs/toolPolicy" }
37
+ },
38
+ "additionalProperties": false
39
+ },
40
+ "boundAt": { "$ref": "common.schema.json#/$defs/timestamp" },
41
+ "boundBy": { "$ref": "common.schema.json#/$defs/actor" },
42
+ "rebindOf": { "$ref": "common.schema.json#/$defs/opaqueId" },
43
+ "rebindReason": { "type": "string", "minLength": 1, "maxLength": 512 },
44
+ "configRevision": { "$ref": "common.schema.json#/$defs/sha256" },
45
+ "ceilingHash": { "$ref": "common.schema.json#/$defs/sha256" }
46
+ },
47
+ "additionalProperties": false,
48
+ "allOf": [
49
+ {
50
+ "if": { "properties": { "rebindOf": {} }, "required": ["rebindOf"] },
51
+ "then": { "required": ["rebindReason"] }
52
+ }
53
+ ]
54
+ }
@@ -0,0 +1,72 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://schemas.kxm.dev/intake-message.schema.json",
4
+ "title": "KXM internal intake message",
5
+ "description": "One durable internal message at the Runtime intake boundary. Receipts stay separate facts: received, persisted, and dispatch-intent are different states, and content classified secret is never persisted — only its hash.",
6
+ "type": "object",
7
+ "required": [
8
+ "schema",
9
+ "messageId",
10
+ "projectId",
11
+ "coordinatorId",
12
+ "receivedAt",
13
+ "source",
14
+ "idempotencyKey",
15
+ "contentHash",
16
+ "classification",
17
+ "dispatch"
18
+ ],
19
+ "properties": {
20
+ "schema": { "const": "kxm.intake-message.v1" },
21
+ "messageId": { "$ref": "common.schema.json#/$defs/opaqueId" },
22
+ "projectId": { "$ref": "common.schema.json#/$defs/opaqueId" },
23
+ "coordinatorId": { "$ref": "common.schema.json#/$defs/opaqueId" },
24
+ "receivedAt": { "$ref": "common.schema.json#/$defs/timestamp" },
25
+ "source": {
26
+ "type": "object",
27
+ "required": ["kind", "id"],
28
+ "properties": {
29
+ "kind": { "enum": ["adapter", "hub", "operator", "peer", "schedule"] },
30
+ "id": { "type": "string", "minLength": 1, "maxLength": 200 }
31
+ },
32
+ "additionalProperties": false
33
+ },
34
+ "idempotencyKey": { "type": "string", "minLength": 1, "maxLength": 200 },
35
+ "contentHash": { "$ref": "common.schema.json#/$defs/sha256" },
36
+ "content": { "type": "string", "minLength": 1, "maxLength": 16384 },
37
+ "contentOmittedReason": { "const": "secret-classified" },
38
+ "classification": { "enum": ["public", "project", "sensitive", "secret"] },
39
+ "dispatch": { "$ref": "#/$defs/dispatch" }
40
+ },
41
+ "additionalProperties": false,
42
+ "allOf": [
43
+ {
44
+ "if": { "properties": { "classification": { "enum": ["public", "project", "sensitive"] } }, "required": ["classification"] },
45
+ "then": { "required": ["content"] },
46
+ "else": { "not": { "required": ["content"] } }
47
+ }
48
+ ],
49
+ "$defs": {
50
+ "dispatch": {
51
+ "type": "object",
52
+ "required": ["state"],
53
+ "properties": {
54
+ "state": { "enum": ["ready", "held_paused", "admitted", "refused"] },
55
+ "reason": { "type": "string", "minLength": 1, "maxLength": 512 },
56
+ "updatedAt": { "$ref": "common.schema.json#/$defs/timestamp" },
57
+ "runId": { "$ref": "common.schema.json#/$defs/opaqueId" }
58
+ },
59
+ "additionalProperties": false,
60
+ "allOf": [
61
+ {
62
+ "if": { "properties": { "state": { "const": "admitted" } }, "required": ["state"] },
63
+ "then": { "required": ["runId", "updatedAt"] }
64
+ },
65
+ {
66
+ "if": { "properties": { "state": { "enum": ["held_paused", "refused"] } }, "required": ["state"] },
67
+ "then": { "required": ["reason"] }
68
+ }
69
+ ]
70
+ }
71
+ }
72
+ }