@nanobpm/agentic 0.1.0

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.
Files changed (228) hide show
  1. package/README.md +22 -0
  2. package/dist/blackboard/family.d.ts +40 -0
  3. package/dist/blackboard/family.js +151 -0
  4. package/dist/blackboard/index.d.ts +21 -0
  5. package/dist/blackboard/index.js +19 -0
  6. package/dist/blackboard/schema.d.ts +30 -0
  7. package/dist/blackboard/schema.js +42 -0
  8. package/dist/blackboard/store.d.ts +138 -0
  9. package/dist/blackboard/store.js +216 -0
  10. package/dist/blackboard/test-db.d.ts +5 -0
  11. package/dist/blackboard/test-db.js +42 -0
  12. package/dist/channel/auth.d.ts +41 -0
  13. package/dist/channel/auth.js +67 -0
  14. package/dist/channel/clock.d.ts +11 -0
  15. package/dist/channel/clock.js +4 -0
  16. package/dist/channel/connection.d.ts +75 -0
  17. package/dist/channel/connection.js +14 -0
  18. package/dist/channel/dispatch.d.ts +46 -0
  19. package/dist/channel/dispatch.js +86 -0
  20. package/dist/channel/hub.d.ts +78 -0
  21. package/dist/channel/hub.js +157 -0
  22. package/dist/channel/index.d.ts +27 -0
  23. package/dist/channel/index.js +20 -0
  24. package/dist/channel/registry.d.ts +68 -0
  25. package/dist/channel/registry.js +84 -0
  26. package/dist/channel/ws-transport.d.ts +23 -0
  27. package/dist/channel/ws-transport.js +178 -0
  28. package/dist/cockpit/boot.d.ts +68 -0
  29. package/dist/cockpit/boot.js +202 -0
  30. package/dist/cockpit/fake-dom.d.ts +37 -0
  31. package/dist/cockpit/fake-dom.js +73 -0
  32. package/dist/cockpit/index.d.ts +27 -0
  33. package/dist/cockpit/index.js +27 -0
  34. package/dist/cockpit/relay-client.d.ts +52 -0
  35. package/dist/cockpit/relay-client.js +192 -0
  36. package/dist/cockpit/render.d.ts +58 -0
  37. package/dist/cockpit/render.js +122 -0
  38. package/dist/cockpit/terminal-session.d.ts +95 -0
  39. package/dist/cockpit/terminal-session.js +123 -0
  40. package/dist/cockpit/view.d.ts +79 -0
  41. package/dist/cockpit/view.js +58 -0
  42. package/dist/demand/c8-rest.d.ts +77 -0
  43. package/dist/demand/c8-rest.js +123 -0
  44. package/dist/demand/index.d.ts +24 -0
  45. package/dist/demand/index.js +24 -0
  46. package/dist/demand/model.d.ts +68 -0
  47. package/dist/demand/model.js +118 -0
  48. package/dist/demand/taskdef.d.ts +40 -0
  49. package/dist/demand/taskdef.js +67 -0
  50. package/dist/index.d.ts +17 -0
  51. package/dist/index.js +17 -0
  52. package/dist/presence/family.d.ts +40 -0
  53. package/dist/presence/family.js +166 -0
  54. package/dist/presence/index.d.ts +19 -0
  55. package/dist/presence/index.js +17 -0
  56. package/dist/presence/schema.d.ts +20 -0
  57. package/dist/presence/schema.js +32 -0
  58. package/dist/presence/store.d.ts +130 -0
  59. package/dist/presence/store.js +191 -0
  60. package/dist/presence/test-db.d.ts +5 -0
  61. package/dist/presence/test-db.js +42 -0
  62. package/dist/protocol/conformance/frames.d.ts +24 -0
  63. package/dist/protocol/conformance/frames.js +116 -0
  64. package/dist/protocol/conformance/index.d.ts +13 -0
  65. package/dist/protocol/conformance/index.js +13 -0
  66. package/dist/protocol/conformance/malformed.d.ts +14 -0
  67. package/dist/protocol/conformance/malformed.js +44 -0
  68. package/dist/protocol/conformance/tokens.d.ts +19 -0
  69. package/dist/protocol/conformance/tokens.js +49 -0
  70. package/dist/protocol/conformance/vocab.d.ts +23 -0
  71. package/dist/protocol/conformance/vocab.js +97 -0
  72. package/dist/protocol/families.d.ts +32 -0
  73. package/dist/protocol/families.js +45 -0
  74. package/dist/protocol/frame.d.ts +45 -0
  75. package/dist/protocol/frame.js +114 -0
  76. package/dist/protocol/hex.d.ts +7 -0
  77. package/dist/protocol/hex.js +26 -0
  78. package/dist/protocol/index.d.ts +23 -0
  79. package/dist/protocol/index.js +23 -0
  80. package/dist/protocol/lanes.d.ts +36 -0
  81. package/dist/protocol/lanes.js +40 -0
  82. package/dist/protocol/payloads.d.ts +64 -0
  83. package/dist/protocol/payloads.js +122 -0
  84. package/dist/protocol/token.d.ts +34 -0
  85. package/dist/protocol/token.js +81 -0
  86. package/dist/protocol/vocab/schema.d.ts +51 -0
  87. package/dist/protocol/vocab/schema.js +218 -0
  88. package/dist/relay/incarnation.d.ts +17 -0
  89. package/dist/relay/incarnation.js +50 -0
  90. package/dist/relay/index.d.ts +25 -0
  91. package/dist/relay/index.js +22 -0
  92. package/dist/relay/relay-family.d.ts +68 -0
  93. package/dist/relay/relay-family.js +272 -0
  94. package/dist/relay/ring.d.ts +49 -0
  95. package/dist/relay/ring.js +105 -0
  96. package/dist/relay/scheduler.d.ts +72 -0
  97. package/dist/relay/scheduler.js +180 -0
  98. package/dist/relay/validate.d.ts +29 -0
  99. package/dist/relay/validate.js +39 -0
  100. package/dist/transcript/index.d.ts +18 -0
  101. package/dist/transcript/index.js +17 -0
  102. package/dist/transcript/schema.d.ts +32 -0
  103. package/dist/transcript/schema.js +48 -0
  104. package/dist/transcript/store.d.ts +192 -0
  105. package/dist/transcript/store.js +347 -0
  106. package/dist/transcript/test-db.d.ts +5 -0
  107. package/dist/transcript/test-db.js +41 -0
  108. package/dist/vocab/core-vocab.d.ts +26 -0
  109. package/dist/vocab/core-vocab.js +67 -0
  110. package/dist/vocab/diversity.d.ts +78 -0
  111. package/dist/vocab/diversity.js +89 -0
  112. package/dist/vocab/index.d.ts +22 -0
  113. package/dist/vocab/index.js +22 -0
  114. package/dist/vocab/merge.d.ts +9 -0
  115. package/dist/vocab/merge.js +104 -0
  116. package/dist/vocab/requires.d.ts +49 -0
  117. package/dist/vocab/requires.js +107 -0
  118. package/dist/vocab/resolver.d.ts +62 -0
  119. package/dist/vocab/resolver.js +149 -0
  120. package/dist/vocab/serve.d.ts +39 -0
  121. package/dist/vocab/serve.js +36 -0
  122. package/package.json +108 -0
  123. package/page/cockpit.css +114 -0
  124. package/page/cockpit.page.json +33 -0
  125. package/page/embed.html +40 -0
  126. package/page/mount.js +78 -0
  127. package/page/standalone.html +43 -0
  128. package/src/blackboard/family.test.ts +280 -0
  129. package/src/blackboard/family.ts +208 -0
  130. package/src/blackboard/index.ts +42 -0
  131. package/src/blackboard/schema.test.ts +60 -0
  132. package/src/blackboard/schema.ts +44 -0
  133. package/src/blackboard/store.test.ts +189 -0
  134. package/src/blackboard/store.ts +331 -0
  135. package/src/blackboard/test-db.ts +47 -0
  136. package/src/channel/auth.test.ts +64 -0
  137. package/src/channel/auth.ts +101 -0
  138. package/src/channel/clock.ts +14 -0
  139. package/src/channel/connection.ts +77 -0
  140. package/src/channel/dispatch.test.ts +83 -0
  141. package/src/channel/dispatch.ts +102 -0
  142. package/src/channel/hub.test.ts +335 -0
  143. package/src/channel/hub.ts +222 -0
  144. package/src/channel/index.ts +55 -0
  145. package/src/channel/registry.test.ts +73 -0
  146. package/src/channel/registry.ts +137 -0
  147. package/src/channel/ws-transport.test.ts +234 -0
  148. package/src/channel/ws-transport.ts +212 -0
  149. package/src/cockpit/boot.test.ts +374 -0
  150. package/src/cockpit/boot.ts +280 -0
  151. package/src/cockpit/fake-dom.ts +90 -0
  152. package/src/cockpit/index.ts +63 -0
  153. package/src/cockpit/relay-client.test.ts +359 -0
  154. package/src/cockpit/relay-client.ts +234 -0
  155. package/src/cockpit/render.test.ts +149 -0
  156. package/src/cockpit/render.ts +194 -0
  157. package/src/cockpit/terminal-session.test.ts +252 -0
  158. package/src/cockpit/terminal-session.ts +194 -0
  159. package/src/cockpit/view.test.ts +117 -0
  160. package/src/cockpit/view.ts +140 -0
  161. package/src/demand/c8-rest.test.ts +140 -0
  162. package/src/demand/c8-rest.ts +167 -0
  163. package/src/demand/index.ts +42 -0
  164. package/src/demand/model.test.ts +197 -0
  165. package/src/demand/model.ts +183 -0
  166. package/src/demand/taskdef.test.ts +85 -0
  167. package/src/demand/taskdef.ts +78 -0
  168. package/src/index.ts +17 -0
  169. package/src/presence/family.test.ts +252 -0
  170. package/src/presence/family.ts +205 -0
  171. package/src/presence/index.ts +26 -0
  172. package/src/presence/schema.test.ts +53 -0
  173. package/src/presence/schema.ts +34 -0
  174. package/src/presence/store.test.ts +190 -0
  175. package/src/presence/store.ts +287 -0
  176. package/src/presence/test-db.test.ts +57 -0
  177. package/src/presence/test-db.ts +47 -0
  178. package/src/protocol/conformance/corpus.test.ts +66 -0
  179. package/src/protocol/conformance/frames.ts +142 -0
  180. package/src/protocol/conformance/index.ts +29 -0
  181. package/src/protocol/conformance/malformed.ts +59 -0
  182. package/src/protocol/conformance/tokens.ts +70 -0
  183. package/src/protocol/conformance/vocab.ts +122 -0
  184. package/src/protocol/families.ts +54 -0
  185. package/src/protocol/frame.test.ts +116 -0
  186. package/src/protocol/frame.ts +171 -0
  187. package/src/protocol/hex.ts +28 -0
  188. package/src/protocol/index.ts +84 -0
  189. package/src/protocol/lanes.test.ts +82 -0
  190. package/src/protocol/lanes.ts +54 -0
  191. package/src/protocol/payloads.test.ts +91 -0
  192. package/src/protocol/payloads.ts +201 -0
  193. package/src/protocol/token.test.ts +57 -0
  194. package/src/protocol/token.ts +123 -0
  195. package/src/protocol/vocab/schema.test.ts +67 -0
  196. package/src/protocol/vocab/schema.ts +281 -0
  197. package/src/relay/incarnation.test.ts +53 -0
  198. package/src/relay/incarnation.ts +54 -0
  199. package/src/relay/index.ts +34 -0
  200. package/src/relay/integration.test.ts +135 -0
  201. package/src/relay/relay-family.test.ts +236 -0
  202. package/src/relay/relay-family.ts +336 -0
  203. package/src/relay/ring.test.ts +138 -0
  204. package/src/relay/ring.ts +136 -0
  205. package/src/relay/scheduler.test.ts +233 -0
  206. package/src/relay/scheduler.ts +208 -0
  207. package/src/relay/validate.test.ts +43 -0
  208. package/src/relay/validate.ts +44 -0
  209. package/src/transcript/index.ts +33 -0
  210. package/src/transcript/integration.test.ts +108 -0
  211. package/src/transcript/schema.test.ts +69 -0
  212. package/src/transcript/schema.ts +51 -0
  213. package/src/transcript/store.test.ts +285 -0
  214. package/src/transcript/store.ts +530 -0
  215. package/src/transcript/test-db.ts +46 -0
  216. package/src/vocab/core-vocab.test.ts +34 -0
  217. package/src/vocab/core-vocab.ts +88 -0
  218. package/src/vocab/diversity.test.ts +153 -0
  219. package/src/vocab/diversity.ts +169 -0
  220. package/src/vocab/index.ts +55 -0
  221. package/src/vocab/merge.test.ts +73 -0
  222. package/src/vocab/merge.ts +117 -0
  223. package/src/vocab/requires.test.ts +69 -0
  224. package/src/vocab/requires.ts +155 -0
  225. package/src/vocab/resolver.test.ts +118 -0
  226. package/src/vocab/resolver.ts +187 -0
  227. package/src/vocab/serve.test.ts +64 -0
  228. package/src/vocab/serve.ts +66 -0
@@ -0,0 +1,82 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ MESSAGE_FAMILIES,
5
+ FAMILY_CODES,
6
+ familyForCode,
7
+ isMessageFamily,
8
+ } from "./families.ts";
9
+ import {
10
+ QOS_LANES,
11
+ LANE_CODES,
12
+ laneForCode,
13
+ lanePriority,
14
+ isQosLane,
15
+ compareFrameOrder,
16
+ type QosLane,
17
+ } from "./lanes.ts";
18
+
19
+ test("MESSAGE_FAMILIES is the exact canonical set", () => {
20
+ assert.deepEqual(
21
+ [...MESSAGE_FAMILIES],
22
+ ["register", "heartbeat", "deregister", "serve", "demand", "blackboard", "relay"],
23
+ );
24
+ });
25
+
26
+ test("family codes are a bijection with the family set", () => {
27
+ const codes = MESSAGE_FAMILIES.map((f) => FAMILY_CODES[f]);
28
+ assert.equal(new Set(codes).size, MESSAGE_FAMILIES.length, "codes must be unique");
29
+ for (const family of MESSAGE_FAMILIES) {
30
+ assert.equal(familyForCode(FAMILY_CODES[family]), family);
31
+ }
32
+ });
33
+
34
+ test("isMessageFamily gates unknown values", () => {
35
+ assert.ok(isMessageFamily("relay"));
36
+ assert.ok(!isMessageFamily("gossip"));
37
+ assert.ok(!isMessageFamily(4));
38
+ });
39
+
40
+ test("familyForCode returns undefined for unknown codes", () => {
41
+ assert.equal(familyForCode(99), undefined);
42
+ });
43
+
44
+ test("QOS_LANES are in strict priority order control > interactive > bulk", () => {
45
+ assert.deepEqual([...QOS_LANES], ["control", "interactive", "bulk"]);
46
+ assert.ok(lanePriority("control") < lanePriority("interactive"));
47
+ assert.ok(lanePriority("interactive") < lanePriority("bulk"));
48
+ });
49
+
50
+ test("lane codes are a bijection with the lane set", () => {
51
+ for (const lane of QOS_LANES) {
52
+ assert.equal(laneForCode(LANE_CODES[lane]), lane);
53
+ }
54
+ assert.equal(laneForCode(9), undefined);
55
+ });
56
+
57
+ test("isQosLane gates unknown values", () => {
58
+ assert.ok(isQosLane("bulk"));
59
+ assert.ok(!isQosLane("urgent"));
60
+ });
61
+
62
+ test("compareFrameOrder: a bulk storm never head-of-line-blocks control/interactive", () => {
63
+ // A queue where a bulk storm is enqueued first, then a single control
64
+ // heartbeat and an interactive blackboard write. Draining by the scheduler
65
+ // ordering MUST surface control first, interactive second, bulk last —
66
+ // regardless of enqueue order or seq.
67
+ const queue: Array<{ lane: QosLane; seq: number; tag: string }> = [
68
+ { lane: "bulk", seq: 1, tag: "bulk-a" },
69
+ { lane: "bulk", seq: 2, tag: "bulk-b" },
70
+ { lane: "bulk", seq: 3, tag: "bulk-c" },
71
+ { lane: "control", seq: 50, tag: "heartbeat" },
72
+ { lane: "interactive", seq: 40, tag: "blackboard" },
73
+ { lane: "bulk", seq: 0, tag: "bulk-d" },
74
+ ];
75
+ const drained = [...queue].sort(compareFrameOrder).map((f) => f.tag);
76
+ assert.deepEqual(drained, ["heartbeat", "blackboard", "bulk-d", "bulk-a", "bulk-b", "bulk-c"]);
77
+ });
78
+
79
+ test("compareFrameOrder: within a lane, lower seq drains first", () => {
80
+ assert.ok(compareFrameOrder({ lane: "bulk", seq: 1 }, { lane: "bulk", seq: 2 }) < 0);
81
+ assert.equal(compareFrameOrder({ lane: "bulk", seq: 5 }, { lane: "bulk", seq: 5 }), 0);
82
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The three QoS lanes carried on the one agentic channel, in STRICT priority
3
+ * order: control/facts > interactive > bulk.
4
+ *
5
+ * The ordering is the contract: a bulk-output storm (relay chunks) must never
6
+ * head-of-line-block a heartbeat or a blackboard write. The scheduler that
7
+ * enforces this lives in S5; this module owns the canonical lane set, their
8
+ * wire codes, and the priority comparison every scheduler derives from.
9
+ */
10
+ export const QOS_LANES = ["control", "interactive", "bulk"] as const;
11
+
12
+ export type QosLane = (typeof QOS_LANES)[number];
13
+
14
+ const LANE_SET: ReadonlySet<string> = new Set(QOS_LANES);
15
+
16
+ export function isQosLane(value: unknown): value is QosLane {
17
+ return typeof value === "string" && LANE_SET.has(value);
18
+ }
19
+
20
+ /**
21
+ * On-wire lane codes. The numeric value is also the priority rank: a LOWER
22
+ * code is HIGHER priority (control=0 outranks bulk=2). Do not renumber.
23
+ */
24
+ export const LANE_CODES = {
25
+ control: 0,
26
+ interactive: 1,
27
+ bulk: 2,
28
+ } as const satisfies Record<QosLane, number>;
29
+
30
+ const CODE_TO_LANE: ReadonlyMap<number, QosLane> = new Map(
31
+ QOS_LANES.map((lane) => [LANE_CODES[lane], lane]),
32
+ );
33
+
34
+ export function laneForCode(code: number): QosLane | undefined {
35
+ return CODE_TO_LANE.get(code);
36
+ }
37
+
38
+ /** Priority rank of a lane; lower is dispatched first. */
39
+ export function lanePriority(lane: QosLane): number {
40
+ return LANE_CODES[lane];
41
+ }
42
+
43
+ /**
44
+ * Canonical scheduler ordering derived from the lane priorities: control before
45
+ * interactive before bulk, ties broken by ascending `seq` so a single lane
46
+ * drains in emission order. Returns <0 when `a` should be dispatched before `b`.
47
+ */
48
+ export function compareFrameOrder(
49
+ a: { readonly lane: QosLane; readonly seq: number },
50
+ b: { readonly lane: QosLane; readonly seq: number },
51
+ ): number {
52
+ const byLane = lanePriority(a.lane) - lanePriority(b.lane);
53
+ return byLane !== 0 ? byLane : a.seq - b.seq;
54
+ }
@@ -0,0 +1,91 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { validatePayload } from "./payloads.ts";
4
+ import { GOLDEN_FRAMES } from "./conformance/frames.ts";
5
+
6
+ test("every golden frame payload satisfies its family contract", () => {
7
+ for (const golden of GOLDEN_FRAMES) {
8
+ // The null-payload envelope frame is a codec-level fixture, not a valid
9
+ // family payload; skip it for the payload-contract check.
10
+ if (golden.frame.payload === null) continue;
11
+ const result = validatePayload(golden.frame.family, golden.frame.payload);
12
+ assert.ok(
13
+ result.ok,
14
+ `${golden.name}: ${result.ok ? "" : JSON.stringify(result.errors)}`,
15
+ );
16
+ }
17
+ });
18
+
19
+ test("register requires a non-empty instance and a capability object", () => {
20
+ assert.ok(validatePayload("register", { instance: "w", capability: {} }).ok);
21
+ assert.ok(!validatePayload("register", { instance: "", capability: {} }).ok);
22
+ assert.ok(!validatePayload("register", { instance: "w" }).ok);
23
+ assert.ok(!validatePayload("register", { instance: "w", capability: "opus" }).ok);
24
+ });
25
+
26
+ test("register validates known capability field types when present", () => {
27
+ assert.ok(
28
+ validatePayload("register", {
29
+ instance: "w",
30
+ capability: { cognition: "reason", weight: 3, family: "opus", host: "h1" },
31
+ }).ok,
32
+ );
33
+ for (const capability of [
34
+ { weight: "3" },
35
+ { cognition: 1 },
36
+ { family: true },
37
+ { host: 0 },
38
+ ]) {
39
+ const bad = validatePayload("register", { instance: "w", capability });
40
+ assert.ok(!bad.ok, JSON.stringify(capability));
41
+ if (!bad.ok) assert.ok(bad.errors.some((e) => e.code === "bad-capability"));
42
+ }
43
+ });
44
+
45
+ test("serve rejects an invalid routing token in tokens[]", () => {
46
+ assert.ok(validatePayload("serve", { instance: "w", tokens: ["planning.decide"] }).ok);
47
+ const bad = validatePayload("serve", { instance: "w", tokens: ["Bad Token"] });
48
+ assert.ok(!bad.ok);
49
+ if (!bad.ok) assert.ok(bad.errors.some((e) => e.code === "bad-token"));
50
+ });
51
+
52
+ test("blackboard op must be append or read", () => {
53
+ assert.ok(validatePayload("blackboard", { op: "append" }).ok);
54
+ assert.ok(validatePayload("blackboard", { op: "read", since: 0 }).ok);
55
+ assert.ok(!validatePayload("blackboard", { op: "delete" }).ok);
56
+ assert.ok(!validatePayload("blackboard", { op: "read", since: -1 }).ok);
57
+ });
58
+
59
+ test("blackboard files must be an array of only strings when present", () => {
60
+ assert.ok(validatePayload("blackboard", { op: "append", files: [] }).ok);
61
+ assert.ok(validatePayload("blackboard", { op: "append", files: ["a.rs", "b.rs"] }).ok);
62
+ const notArray = validatePayload("blackboard", { op: "append", files: "a.rs" });
63
+ assert.ok(!notArray.ok);
64
+ if (!notArray.ok) assert.ok(notArray.errors.some((e) => e.code === "bad-files"));
65
+ // A mixed array must be rejected outright, not silently coerced to its string subset:
66
+ // dropping the non-string element would store an incomplete file-claim and skew conflict detection.
67
+ const mixed = validatePayload("blackboard", { op: "append", files: ["a.rs", 123] });
68
+ assert.ok(!mixed.ok);
69
+ if (!mixed.ok) assert.ok(mixed.errors.some((e) => e.code === "bad-files"));
70
+ });
71
+
72
+ test("relay requires stream, non-negative integer offset, and string chunk", () => {
73
+ assert.ok(validatePayload("relay", { stream: "s", offset: 0, chunk: "" }).ok);
74
+ assert.ok(!validatePayload("relay", { stream: "s", offset: -1, chunk: "" }).ok);
75
+ assert.ok(!validatePayload("relay", { stream: "s", offset: 1.5, chunk: "" }).ok);
76
+ assert.ok(!validatePayload("relay", { stream: "", offset: 0, chunk: "" }).ok);
77
+ });
78
+
79
+ test("deregister accepts optional string reason and rejects non-string reason", () => {
80
+ assert.ok(validatePayload("deregister", { instance: "w" }).ok);
81
+ assert.ok(validatePayload("deregister", { instance: "w", reason: "shutdown" }).ok);
82
+ const bad = validatePayload("deregister", { instance: "w", reason: 42 });
83
+ assert.ok(!bad.ok);
84
+ if (!bad.ok) assert.ok(bad.errors.some((e) => e.code === "bad-reason"));
85
+ });
86
+
87
+ test("non-object payloads are rejected for every family", () => {
88
+ assert.ok(!validatePayload("heartbeat", null).ok);
89
+ assert.ok(!validatePayload("heartbeat", "w").ok);
90
+ assert.ok(!validatePayload("relay", []).ok);
91
+ });
@@ -0,0 +1,201 @@
1
+ import { isMessageFamily, type MessageFamily } from "./families.ts";
2
+ import { isValidToken } from "./token.ts";
3
+
4
+ /**
5
+ * Minimal per-family payload contract. This is the wire-level shape every
6
+ * family module (S1 hub, S2 presence, S3 vocab, S5 relay, S7 blackboard) builds
7
+ * on — the single source of truth for the payload of each message family, held
8
+ * to by both this repo's codec and the c8ctl client.
9
+ *
10
+ * Validators are STRUCTURAL and forward-compatible: they require the core
11
+ * fields with correct types and otherwise tolerate additional properties, so a
12
+ * later slice may enrich a payload without breaking older peers.
13
+ *
14
+ * Capability (cognition/weight/family/host) travels on `register` as an
15
+ * enrolment attribute — it is NEVER a routing token.
16
+ */
17
+ export interface Capability {
18
+ readonly cognition?: string;
19
+ readonly weight?: number;
20
+ readonly family?: string;
21
+ readonly host?: string;
22
+ }
23
+
24
+ export interface RegisterPayload {
25
+ readonly instance: string;
26
+ readonly capability: Capability;
27
+ }
28
+
29
+ export interface HeartbeatPayload {
30
+ readonly instance: string;
31
+ }
32
+
33
+ export interface DeregisterPayload {
34
+ readonly instance: string;
35
+ readonly reason?: string;
36
+ }
37
+
38
+ export interface ServePayload {
39
+ readonly instance: string;
40
+ readonly tokens: readonly string[];
41
+ }
42
+
43
+ export interface DemandPayload {
44
+ readonly network: string;
45
+ readonly missing: readonly string[];
46
+ }
47
+
48
+ export type BlackboardOp = "append" | "read";
49
+
50
+ export interface BlackboardPayload {
51
+ readonly op: BlackboardOp;
52
+ readonly dedupeKey?: string;
53
+ readonly since?: number;
54
+ }
55
+
56
+ export interface RelayPayload {
57
+ readonly stream: string;
58
+ readonly offset: number;
59
+ readonly chunk: string;
60
+ }
61
+
62
+ export interface PayloadError {
63
+ readonly code: string;
64
+ readonly message: string;
65
+ }
66
+
67
+ export type PayloadValidationResult =
68
+ | { readonly ok: true }
69
+ | { readonly ok: false; readonly errors: readonly PayloadError[] };
70
+
71
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
72
+ return typeof value === "object" && value !== null && !Array.isArray(value);
73
+ }
74
+
75
+ function nonEmptyString(value: unknown): value is string {
76
+ return typeof value === "string" && value.length > 0;
77
+ }
78
+
79
+ function validateRegister(p: Record<string, unknown>, errors: PayloadError[]): void {
80
+ if (!nonEmptyString(p.instance)) {
81
+ errors.push({ code: "bad-instance", message: "register.instance must be a non-empty string" });
82
+ }
83
+ if (!isPlainObject(p.capability)) {
84
+ errors.push({ code: "bad-capability", message: "register.capability must be an object" });
85
+ return;
86
+ }
87
+ const cap = p.capability;
88
+ for (const field of ["cognition", "family", "host"] as const) {
89
+ if (field in cap && typeof cap[field] !== "string") {
90
+ errors.push({ code: "bad-capability", message: `register.capability.${field} must be a string when present` });
91
+ }
92
+ }
93
+ if ("weight" in cap && typeof cap.weight !== "number") {
94
+ errors.push({ code: "bad-capability", message: "register.capability.weight must be a number when present" });
95
+ }
96
+ }
97
+
98
+ function validateInstanceOnly(family: string, p: Record<string, unknown>, errors: PayloadError[]): void {
99
+ if (!nonEmptyString(p.instance)) {
100
+ errors.push({ code: "bad-instance", message: `${family}.instance must be a non-empty string` });
101
+ }
102
+ }
103
+
104
+ function validateDeregister(p: Record<string, unknown>, errors: PayloadError[]): void {
105
+ validateInstanceOnly("deregister", p, errors);
106
+ if ("reason" in p && typeof p.reason !== "string") {
107
+ errors.push({ code: "bad-reason", message: "deregister.reason must be a string when present" });
108
+ }
109
+ }
110
+
111
+ function validateServe(p: Record<string, unknown>, errors: PayloadError[]): void {
112
+ if (!nonEmptyString(p.instance)) {
113
+ errors.push({ code: "bad-instance", message: "serve.instance must be a non-empty string" });
114
+ }
115
+ const tokens = p.tokens;
116
+ if (!Array.isArray(tokens)) {
117
+ errors.push({ code: "bad-tokens", message: "serve.tokens must be an array" });
118
+ return;
119
+ }
120
+ tokens.forEach((token, index) => {
121
+ if (typeof token !== "string" || !isValidToken(token)) {
122
+ errors.push({ code: "bad-token", message: `serve.tokens[${index}] is not a valid routing token` });
123
+ }
124
+ });
125
+ }
126
+
127
+ function validateDemand(p: Record<string, unknown>, errors: PayloadError[]): void {
128
+ if (!nonEmptyString(p.network)) {
129
+ errors.push({ code: "bad-network", message: "demand.network must be a non-empty string" });
130
+ }
131
+ const missing = p.missing;
132
+ if (!Array.isArray(missing) || !missing.every((entry) => typeof entry === "string")) {
133
+ errors.push({ code: "bad-missing", message: "demand.missing must be an array of strings" });
134
+ }
135
+ }
136
+
137
+ function validateBlackboard(p: Record<string, unknown>, errors: PayloadError[]): void {
138
+ if (p.op !== "append" && p.op !== "read") {
139
+ errors.push({ code: "bad-op", message: "blackboard.op must be 'append' or 'read'" });
140
+ }
141
+ if ("dedupeKey" in p && typeof p.dedupeKey !== "string") {
142
+ errors.push({ code: "bad-dedupe-key", message: "blackboard.dedupeKey must be a string when present" });
143
+ }
144
+ if ("since" in p && (typeof p.since !== "number" || !Number.isInteger(p.since) || p.since < 0)) {
145
+ errors.push({ code: "bad-since", message: "blackboard.since must be a non-negative integer when present" });
146
+ }
147
+ if ("files" in p && (!Array.isArray(p.files) || !p.files.every((f) => typeof f === "string"))) {
148
+ errors.push({ code: "bad-files", message: "blackboard.files must be an array of strings when present" });
149
+ }
150
+ }
151
+
152
+ function validateRelay(p: Record<string, unknown>, errors: PayloadError[]): void {
153
+ if (!nonEmptyString(p.stream)) {
154
+ errors.push({ code: "bad-stream", message: "relay.stream must be a non-empty string" });
155
+ }
156
+ if (typeof p.offset !== "number" || !Number.isInteger(p.offset) || p.offset < 0) {
157
+ errors.push({ code: "bad-offset", message: "relay.offset must be a non-negative integer" });
158
+ }
159
+ if (typeof p.chunk !== "string") {
160
+ errors.push({ code: "bad-chunk", message: "relay.chunk must be a string" });
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Validate a decoded payload against its family's minimal contract.
166
+ */
167
+ export function validatePayload(family: MessageFamily, payload: unknown): PayloadValidationResult {
168
+ if (!isMessageFamily(family)) {
169
+ return { ok: false, errors: [{ code: "unknown-family", message: `unknown family: ${String(family)}` }] };
170
+ }
171
+ if (!isPlainObject(payload)) {
172
+ return { ok: false, errors: [{ code: "not-object", message: `${family} payload must be an object` }] };
173
+ }
174
+
175
+ const errors: PayloadError[] = [];
176
+ switch (family) {
177
+ case "register":
178
+ validateRegister(payload, errors);
179
+ break;
180
+ case "heartbeat":
181
+ validateInstanceOnly("heartbeat", payload, errors);
182
+ break;
183
+ case "deregister":
184
+ validateDeregister(payload, errors);
185
+ break;
186
+ case "serve":
187
+ validateServe(payload, errors);
188
+ break;
189
+ case "demand":
190
+ validateDemand(payload, errors);
191
+ break;
192
+ case "blackboard":
193
+ validateBlackboard(payload, errors);
194
+ break;
195
+ case "relay":
196
+ validateRelay(payload, errors);
197
+ break;
198
+ }
199
+
200
+ return errors.length === 0 ? { ok: true } : { ok: false, errors };
201
+ }
@@ -0,0 +1,57 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { formatToken, parseToken, isValidToken, TokenParseError } from "./token.ts";
4
+ import { VALID_TOKENS, INVALID_TOKENS } from "./conformance/tokens.ts";
5
+
6
+ test("valid tokens parse into the expected decomposition", () => {
7
+ for (const vector of VALID_TOKENS) {
8
+ assert.deepEqual(parseToken(vector.token), vector.parsed, vector.name);
9
+ assert.ok(isValidToken(vector.token), vector.name);
10
+ }
11
+ });
12
+
13
+ test("valid tokens round-trip through formatToken", () => {
14
+ for (const vector of VALID_TOKENS) {
15
+ assert.equal(formatToken(parseToken(vector.token)), vector.token, vector.name);
16
+ }
17
+ });
18
+
19
+ test("formatToken rejects subnetworks without a network", () => {
20
+ assert.throws(
21
+ () => formatToken({ subnetworks: ["sub"], role: "decide" }),
22
+ /subnetworks present without a network/,
23
+ );
24
+ });
25
+
26
+ test("formatToken rejects a RoutingToken with an invalid segment", () => {
27
+ assert.throws(
28
+ () => formatToken({ network: "Net", subnetworks: [], role: "decide" }),
29
+ /invalid segment: Net/,
30
+ );
31
+ assert.throws(
32
+ () => formatToken({ network: "mesh", subnetworks: ["Bad_Sub"], role: "decide" }),
33
+ /invalid segment: Bad_Sub/,
34
+ );
35
+ });
36
+
37
+ test("formatToken rejects a RoutingToken with an invalid seat label", () => {
38
+ assert.throws(
39
+ () => formatToken({ subnetworks: [], role: "decide", seat: "Seat#1" }),
40
+ /invalid seat label: Seat#1/,
41
+ );
42
+ });
43
+
44
+ test("invalid tokens reject with the specified error code", () => {
45
+ for (const vector of INVALID_TOKENS) {
46
+ assert.throws(
47
+ () => parseToken(vector.token),
48
+ (error: unknown) => {
49
+ assert.ok(error instanceof TokenParseError, `${vector.name}: expected TokenParseError`);
50
+ assert.equal(error.code, vector.expected, vector.name);
51
+ return true;
52
+ },
53
+ vector.name,
54
+ );
55
+ assert.ok(!isValidToken(vector.token), vector.name);
56
+ }
57
+ });
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Routing-token grammar: `network[.subnetwork…].role[#seat]`.
3
+ *
4
+ * The token is what the engine matches 1:1. Capability — cognition, weight,
5
+ * family, host — is NEVER encoded in the token; it is an enrolment attribute
6
+ * and a registry gate. Do not smuggle capability into a segment.
7
+ *
8
+ * Grammar:
9
+ * token = segment ("." segment)* ("#" seat)?
10
+ * segment = [a-z][a-z0-9-]* (first is the network, last is the role,
11
+ * any in between are subnetworks)
12
+ * seat = [a-z0-9-]+
13
+ *
14
+ * A single-segment token (e.g. `decide`) is a bare role with no network and no
15
+ * subnetworks (`network` is absent).
16
+ */
17
+ export interface RoutingToken {
18
+ readonly network?: string;
19
+ readonly subnetworks: readonly string[];
20
+ readonly role: string;
21
+ readonly seat?: string;
22
+ }
23
+
24
+ const SEGMENT_RE = /^[a-z][a-z0-9-]*$/;
25
+ const SEAT_RE = /^[a-z0-9-]+$/;
26
+
27
+ /** True when `name` is a valid network/subnetwork/role segment. */
28
+ export function isSegmentName(name: string): boolean {
29
+ return SEGMENT_RE.test(name);
30
+ }
31
+
32
+ /** True when `seat` is a valid seat label. */
33
+ export function isSeatLabel(seat: string): boolean {
34
+ return SEAT_RE.test(seat);
35
+ }
36
+
37
+ export type TokenParseErrorCode =
38
+ | "empty"
39
+ | "whitespace"
40
+ | "multiple-seat-markers"
41
+ | "empty-seat"
42
+ | "bad-seat"
43
+ | "empty-segment"
44
+ | "bad-segment";
45
+
46
+ export class TokenParseError extends Error {
47
+ readonly code: TokenParseErrorCode;
48
+ constructor(code: TokenParseErrorCode, message: string) {
49
+ super(message);
50
+ this.name = "TokenParseError";
51
+ this.code = code;
52
+ }
53
+ }
54
+
55
+ export function parseToken(token: string): RoutingToken {
56
+ if (token.length === 0) {
57
+ throw new TokenParseError("empty", "routing token must not be empty");
58
+ }
59
+ if (/\s/.test(token)) {
60
+ throw new TokenParseError("whitespace", "routing token must not contain whitespace");
61
+ }
62
+
63
+ const hashParts = token.split("#");
64
+ if (hashParts.length > 2) {
65
+ throw new TokenParseError("multiple-seat-markers", "routing token has more than one '#'");
66
+ }
67
+ const pathPart = hashParts[0];
68
+ const seatPart = hashParts.length === 2 ? hashParts[1] : undefined;
69
+
70
+ if (seatPart !== undefined) {
71
+ if (seatPart.length === 0) {
72
+ throw new TokenParseError("empty-seat", "seat after '#' must not be empty");
73
+ }
74
+ if (!isSeatLabel(seatPart)) {
75
+ throw new TokenParseError("bad-seat", `invalid seat label: ${seatPart}`);
76
+ }
77
+ }
78
+
79
+ const segments = pathPart.split(".");
80
+ for (const segment of segments) {
81
+ if (segment.length === 0) {
82
+ throw new TokenParseError("empty-segment", "routing token has an empty '.' segment");
83
+ }
84
+ if (!isSegmentName(segment)) {
85
+ throw new TokenParseError("bad-segment", `invalid token segment: ${segment}`);
86
+ }
87
+ }
88
+
89
+ const role = segments[segments.length - 1];
90
+ const base =
91
+ segments.length === 1
92
+ ? { subnetworks: [], role }
93
+ : { network: segments[0], subnetworks: segments.slice(1, -1), role };
94
+ return seatPart === undefined ? base : { ...base, seat: seatPart };
95
+ }
96
+
97
+ export function isValidToken(token: string): boolean {
98
+ try {
99
+ parseToken(token);
100
+ return true;
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ export function formatToken(token: RoutingToken): string {
107
+ if (token.network === undefined && token.subnetworks.length > 0) {
108
+ throw new Error("invalid RoutingToken: subnetworks present without a network");
109
+ }
110
+ const segments = token.network === undefined
111
+ ? [token.role]
112
+ : [token.network, ...token.subnetworks, token.role];
113
+ for (const segment of segments) {
114
+ if (!isSegmentName(segment)) {
115
+ throw new Error(`invalid RoutingToken: invalid segment: ${segment}`);
116
+ }
117
+ }
118
+ if (token.seat !== undefined && !isSeatLabel(token.seat)) {
119
+ throw new Error(`invalid RoutingToken: invalid seat label: ${token.seat}`);
120
+ }
121
+ const path = segments.join(".");
122
+ return token.seat === undefined ? path : `${path}#${token.seat}`;
123
+ }
@@ -0,0 +1,67 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { validateVocabDocument } from "./schema.ts";
4
+ import { VALID_VOCABS, INVALID_VOCABS } from "../conformance/vocab.ts";
5
+
6
+ test("valid vocab documents pass validation and narrow to the document", () => {
7
+ for (const vector of VALID_VOCABS) {
8
+ const result = validateVocabDocument(vector.document);
9
+ assert.ok(result.ok, `${vector.name}: expected ok, got ${result.ok ? "" : JSON.stringify(result.errors)}`);
10
+ if (result.ok) {
11
+ assert.deepEqual(result.value, vector.document, vector.name);
12
+ }
13
+ }
14
+ });
15
+
16
+ test("invalid vocab documents fail with the expected error code", () => {
17
+ for (const vector of INVALID_VOCABS) {
18
+ const result = validateVocabDocument(vector.document);
19
+ assert.ok(!result.ok, `${vector.name}: expected failure`);
20
+ if (!result.ok) {
21
+ const codes = result.errors.map((e) => e.code);
22
+ assert.ok(
23
+ codes.includes(vector.expectedCode),
24
+ `${vector.name}: expected code ${vector.expectedCode} among ${JSON.stringify(codes)}`,
25
+ );
26
+ }
27
+ }
28
+ });
29
+
30
+ test("subnetwork roles are validated recursively", () => {
31
+ const result = validateVocabDocument({
32
+ version: 1,
33
+ networks: {
34
+ implementation: {
35
+ subnetworks: { ci: { roles: { fix: { weight: "bad" } } } },
36
+ },
37
+ },
38
+ });
39
+ assert.ok(!result.ok);
40
+ if (!result.ok) {
41
+ assert.ok(result.errors.some((e) => e.code === "bad-weight"));
42
+ assert.ok(result.errors.some((e) => e.path === "$.networks.implementation.subnetworks.ci.roles.fix.weight"));
43
+ }
44
+ });
45
+
46
+ test("narrowed document does not share array references with the input", () => {
47
+ const requires = ["relay"];
48
+ const seats = ["a", "b"];
49
+ const input = {
50
+ version: 1,
51
+ networks: { impl: { roles: { fix: { requires, seats } } } },
52
+ };
53
+ const result = validateVocabDocument(input);
54
+ assert.ok(result.ok);
55
+ if (result.ok) {
56
+ const role = result.value.networks.impl?.roles?.fix;
57
+ assert.ok(role);
58
+ assert.notStrictEqual(role.requires, requires);
59
+ assert.deepEqual(role.requires, requires);
60
+ assert.notStrictEqual(role.seats, seats);
61
+ assert.deepEqual(role.seats, seats);
62
+ requires.push("mutated");
63
+ seats.push("mutated");
64
+ assert.deepEqual(role.requires, ["relay"]);
65
+ assert.deepEqual(role.seats, ["a", "b"]);
66
+ }
67
+ });