@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,69 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import {
4
+ parseRequires,
5
+ parseRequiresList,
6
+ RequiresParseError,
7
+ satisfiesPredicate,
8
+ satisfiesRequires,
9
+ } from "./requires.ts";
10
+
11
+ test("parses equality, inequality and ordering predicates", () => {
12
+ assert.deepEqual(parseRequires("cognition=planning"), {
13
+ field: "cognition",
14
+ op: "=",
15
+ value: "planning",
16
+ source: "cognition=planning",
17
+ });
18
+ assert.deepEqual(parseRequires("weight>=4"), { field: "weight", op: ">=", value: 4, source: "weight>=4" });
19
+ assert.equal(parseRequires("family != acme").op, "!=");
20
+ assert.equal(parseRequires("family != acme").value, "acme");
21
+ });
22
+
23
+ test("tolerates surrounding whitespace", () => {
24
+ const p = parseRequires(" cognition == qa ");
25
+ assert.equal(p.field, "cognition");
26
+ assert.equal(p.op, "==");
27
+ assert.equal(p.value, "qa");
28
+ });
29
+
30
+ test("rejects malformed predicates", () => {
31
+ assert.throws(() => parseRequires("nonsense"), RequiresParseError);
32
+ assert.throws(() => parseRequires("host"), RequiresParseError);
33
+ assert.throws(() => parseRequires("colour=red"), RequiresParseError);
34
+ });
35
+
36
+ test("ordering operators are weight-only; string ordering rejected", () => {
37
+ assert.throws(() => parseRequires("cognition>=planning"), RequiresParseError);
38
+ assert.throws(() => parseRequires("weight>=notanumber"), RequiresParseError);
39
+ });
40
+
41
+ test("satisfiesPredicate is fail-closed on absent fields (except !=)", () => {
42
+ assert.equal(satisfiesPredicate(parseRequires("cognition=planning"), {}), false);
43
+ assert.equal(satisfiesPredicate(parseRequires("weight>=3"), {}), false);
44
+ // Absent field provably is not the forbidden value.
45
+ assert.equal(satisfiesPredicate(parseRequires("family!=acme"), {}), true);
46
+ });
47
+
48
+ test("numeric comparisons evaluate against weight", () => {
49
+ assert.equal(satisfiesPredicate(parseRequires("weight>=4"), { weight: 5 }), true);
50
+ assert.equal(satisfiesPredicate(parseRequires("weight>=4"), { weight: 4 }), true);
51
+ assert.equal(satisfiesPredicate(parseRequires("weight>=4"), { weight: 3 }), false);
52
+ assert.equal(satisfiesPredicate(parseRequires("weight<2"), { weight: 1 }), true);
53
+ });
54
+
55
+ test("string comparisons evaluate against the field", () => {
56
+ assert.equal(satisfiesPredicate(parseRequires("cognition=qa"), { cognition: "qa" }), true);
57
+ assert.equal(satisfiesPredicate(parseRequires("cognition=qa"), { cognition: "planning" }), false);
58
+ assert.equal(satisfiesPredicate(parseRequires("family!=acme"), { family: "acme" }), false);
59
+ assert.equal(satisfiesPredicate(parseRequires("family!=acme"), { family: "globex" }), true);
60
+ });
61
+
62
+ test("satisfiesRequires requires EVERY predicate; empty list is open", () => {
63
+ const preds = parseRequiresList(["cognition=implementation", "weight>=4"]);
64
+ assert.equal(satisfiesRequires(preds, { cognition: "implementation", weight: 5 }), true);
65
+ assert.equal(satisfiesRequires(preds, { cognition: "implementation", weight: 2 }), false);
66
+ assert.equal(satisfiesRequires(preds, { cognition: "qa", weight: 9 }), false);
67
+ assert.equal(satisfiesRequires(parseRequiresList(undefined), {}), true);
68
+ assert.equal(satisfiesRequires(parseRequiresList([]), { cognition: "anything" }), true);
69
+ });
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The enrolment-capability gate: `requires` predicates.
3
+ *
4
+ * A vocab role's `requires` list is the REGISTRY GATE — it decides WHO may fill
5
+ * the role based on the declared enrolment capability (cognition / weight /
6
+ * family / host). It is deliberately NOT part of the routing token: capability
7
+ * never rides the token (S0 invariant 3); it gates enrolment here.
8
+ *
9
+ * Each `requires` entry is a single predicate over one capability field:
10
+ *
11
+ * predicate = field op value
12
+ * field = cognition | weight | family | host
13
+ * op = "=" | "==" | "!=" | ">=" | "<=" | ">" | "<"
14
+ *
15
+ * Ordering/numeric operators (`>=`, `<=`, `>`, `<`) apply to `weight` only (the
16
+ * one numeric capability field); the string fields support only `=`/`==`/`!=`.
17
+ * A role with no `requires` (or an empty list) is open to any capability.
18
+ *
19
+ * Match semantics are FAIL-CLOSED for a gate: an absent field fails every
20
+ * positive predicate (`=`,`==`,`>=`,`<=`,`>`,`<`); only `!=` is satisfied by an
21
+ * absent field (the worker's field is provably not the forbidden value). A
22
+ * capability satisfies a role iff it satisfies EVERY predicate.
23
+ */
24
+ import type { Capability } from "../protocol/index.ts";
25
+
26
+ /** The capability fields a `requires` predicate may gate on. */
27
+ export const REQUIRES_FIELDS = ["cognition", "weight", "family", "host"] as const;
28
+ export type RequiresField = (typeof REQUIRES_FIELDS)[number];
29
+
30
+ export type RequiresOp = "=" | "==" | "!=" | ">=" | "<=" | ">" | "<";
31
+
32
+ /** The numeric field; ordering operators are valid only for it. */
33
+ const NUMERIC_FIELD: RequiresField = "weight";
34
+ const ORDERING_OPS: ReadonlySet<string> = new Set([">=", "<=", ">", "<"]);
35
+ const STRING_FIELDS: ReadonlySet<string> = new Set(["cognition", "family", "host"]);
36
+
37
+ export interface RequiresPredicate {
38
+ readonly field: RequiresField;
39
+ readonly op: RequiresOp;
40
+ /** The compared value: a number for `weight`, a string otherwise. */
41
+ readonly value: string | number;
42
+ /** The original source text, for diagnostics. */
43
+ readonly source: string;
44
+ }
45
+
46
+ /** Raised when a `requires` entry is not a well-formed predicate. */
47
+ export class RequiresParseError extends Error {
48
+ readonly source: string;
49
+ constructor(source: string, detail: string) {
50
+ super(`invalid requires predicate "${source}": ${detail}`);
51
+ this.name = "RequiresParseError";
52
+ this.source = source;
53
+ }
54
+ }
55
+
56
+ // Longest operators first so `>=` is not mis-split as `>`; `==` before `=`.
57
+ const PREDICATE_RE = /^(cognition|weight|family|host)\s*(>=|<=|==|!=|=|>|<)\s*(.+?)\s*$/;
58
+
59
+ const FIELD_SET: ReadonlySet<string> = new Set(REQUIRES_FIELDS);
60
+
61
+ function isRequiresField(value: string): value is RequiresField {
62
+ return FIELD_SET.has(value);
63
+ }
64
+
65
+ function isRequiresOp(value: string): value is RequiresOp {
66
+ return value === "=" || value === "==" || value === "!=" || value === ">=" || value === "<=" || value === ">" || value === "<";
67
+ }
68
+
69
+ /** Parse one `requires` entry into a predicate, or throw {@link RequiresParseError}. */
70
+ export function parseRequires(source: string): RequiresPredicate {
71
+ const match = PREDICATE_RE.exec(source.trim());
72
+ if (match === null) {
73
+ throw new RequiresParseError(source, "expected `field op value` (field: cognition|weight|family|host)");
74
+ }
75
+ const field = match[1];
76
+ const op = match[2];
77
+ const rawValue = match[3];
78
+ if (!isRequiresField(field) || !isRequiresOp(op)) {
79
+ throw new RequiresParseError(source, "unrecognised field or operator");
80
+ }
81
+
82
+ if (field === NUMERIC_FIELD) {
83
+ const value = Number(rawValue);
84
+ if (!Number.isFinite(value)) {
85
+ throw new RequiresParseError(source, "weight predicate needs a finite numeric value");
86
+ }
87
+ return { field, op, value, source };
88
+ }
89
+
90
+ // String field: reject ordering operators (no total order on strings here).
91
+ if (ORDERING_OPS.has(op)) {
92
+ throw new RequiresParseError(source, `operator ${op} is only valid for the numeric field "weight"`);
93
+ }
94
+ if (!STRING_FIELDS.has(field)) {
95
+ throw new RequiresParseError(source, `field ${field} is not a string field`);
96
+ }
97
+ return { field, op, value: rawValue, source };
98
+ }
99
+
100
+ /** Parse every entry of a role's `requires` list. */
101
+ export function parseRequiresList(requires: readonly string[] | undefined): RequiresPredicate[] {
102
+ if (requires === undefined) return [];
103
+ return requires.map(parseRequires);
104
+ }
105
+
106
+ function evalNumeric(op: RequiresOp, actual: number, expected: number): boolean {
107
+ switch (op) {
108
+ case "=":
109
+ case "==":
110
+ return actual === expected;
111
+ case "!=":
112
+ return actual !== expected;
113
+ case ">=":
114
+ return actual >= expected;
115
+ case "<=":
116
+ return actual <= expected;
117
+ case ">":
118
+ return actual > expected;
119
+ case "<":
120
+ return actual < expected;
121
+ }
122
+ }
123
+
124
+ function evalString(op: RequiresOp, actual: string, expected: string): boolean {
125
+ switch (op) {
126
+ case "=":
127
+ case "==":
128
+ return actual === expected;
129
+ case "!=":
130
+ return actual !== expected;
131
+ // Ordering operators are rejected at parse time for string fields.
132
+ default:
133
+ return false;
134
+ }
135
+ }
136
+
137
+ /** True when `capability` satisfies a single predicate (fail-closed on absent fields). */
138
+ export function satisfiesPredicate(predicate: RequiresPredicate, capability: Capability): boolean {
139
+ const actual = capability[predicate.field];
140
+ if (actual === undefined) {
141
+ // An absent field can only satisfy a "must NOT equal" predicate.
142
+ return predicate.op === "!=";
143
+ }
144
+ if (predicate.field === NUMERIC_FIELD) {
145
+ if (typeof actual !== "number" || typeof predicate.value !== "number") return false;
146
+ return evalNumeric(predicate.op, actual, predicate.value);
147
+ }
148
+ if (typeof actual !== "string" || typeof predicate.value !== "string") return false;
149
+ return evalString(predicate.op, actual, predicate.value);
150
+ }
151
+
152
+ /** True when `capability` satisfies EVERY predicate (an empty list is open). */
153
+ export function satisfiesRequires(predicates: readonly RequiresPredicate[], capability: Capability): boolean {
154
+ return predicates.every((predicate) => satisfiesPredicate(predicate, capability));
155
+ }
@@ -0,0 +1,118 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { parseToken } from "../protocol/index.ts";
4
+ import { VALID_VOCABS } from "../protocol/conformance/vocab.ts";
5
+ import { CORE_VOCAB } from "./core-vocab.ts";
6
+ import { VocabDocumentError, VocabResolver } from "./resolver.ts";
7
+
8
+ test("derives leaf tokens for every core role, sorted and unique", () => {
9
+ const resolver = new VocabResolver(CORE_VOCAB);
10
+ const tokens = resolver.tokens();
11
+ assert.deepEqual([...tokens].sort(), tokens, "tokens must be sorted");
12
+ assert.equal(new Set(tokens).size, tokens.length, "tokens must be unique");
13
+ assert.ok(tokens.includes("planning.planner"));
14
+ assert.ok(tokens.includes("planning.reviewer"));
15
+ assert.ok(tokens.includes("qa.tester"));
16
+ assert.ok(tokens.includes("implementation.senior"));
17
+ assert.ok(tokens.includes("ci.runner"));
18
+ });
19
+
20
+ test("a self-named top-level role collapses to a bare single-segment token", () => {
21
+ const resolver = new VocabResolver(CORE_VOCAB);
22
+ assert.ok(resolver.tokens().includes("decide"), "decide must be a bare token");
23
+ // And it is a valid single-segment routing token (network-less).
24
+ const parsed = parseToken("decide");
25
+ assert.equal(parsed.network, undefined);
26
+ assert.equal(parsed.role, "decide");
27
+ });
28
+
29
+ test("resolve is deterministic: a capability yields a stable SERVE token set", () => {
30
+ const resolver = new VocabResolver(CORE_VOCAB);
31
+ const cap = { cognition: "planning", family: "acme" };
32
+ const a = resolver.resolve(cap);
33
+ const b = resolver.resolve(cap);
34
+ assert.deepEqual(a.tokens, b.tokens);
35
+ assert.deepEqual(a.tokens, ["planning.planner", "planning.reviewer"]);
36
+ });
37
+
38
+ test("the weight gate excludes an under-weight senior but not a junior", () => {
39
+ const resolver = new VocabResolver(CORE_VOCAB);
40
+ const heavy = resolver.resolve({ cognition: "implementation", weight: 5 });
41
+ assert.ok(heavy.tokens.includes("implementation.senior"));
42
+ assert.ok(heavy.tokens.includes("implementation.junior"));
43
+
44
+ const light = resolver.resolve({ cognition: "implementation", weight: 2 });
45
+ assert.ok(!light.tokens.includes("implementation.senior"), "weight<4 fails the senior gate");
46
+ assert.ok(light.tokens.includes("implementation.junior"));
47
+ });
48
+
49
+ test("a non-matching cognition serves nothing", () => {
50
+ const resolver = new VocabResolver(CORE_VOCAB);
51
+ assert.deepEqual(resolver.resolve({ cognition: "marketing" }).tokens, []);
52
+ assert.deepEqual(resolver.resolve({}).tokens, []);
53
+ });
54
+
55
+ test("roleForToken normalises spelling and returns role metadata", () => {
56
+ const resolver = new VocabResolver(CORE_VOCAB);
57
+ const role = resolver.roleForToken("planning.reviewer");
58
+ assert.ok(role !== undefined);
59
+ assert.equal(role?.seatsDistinctFamily, true);
60
+ assert.deepEqual(role?.seats, ["red", "blue"]);
61
+ assert.equal(resolver.roleForToken("decide")?.role, "decide");
62
+ assert.equal(resolver.roleForToken("nope.nope"), undefined);
63
+ assert.equal(resolver.roleForToken("!!bad!!"), undefined);
64
+ });
65
+
66
+ test("construction rejects an invalid vocab document", () => {
67
+ const bad = JSON.parse('{"version":0,"networks":{}}');
68
+ assert.throws(() => new VocabResolver(bad), VocabDocumentError);
69
+ });
70
+
71
+ test("construction rejects a malformed requires predicate, naming the offending token", () => {
72
+ const doc = JSON.parse('{"version":1,"networks":{"n":{"roles":{"r":{"requires":["not a predicate"]}}}}}');
73
+ assert.throws(
74
+ () => new VocabResolver(doc),
75
+ (error: unknown) => {
76
+ assert.ok(error instanceof VocabDocumentError);
77
+ assert.equal(error.errors.length, 1);
78
+ assert.equal(error.errors[0]?.path, "n.r");
79
+ assert.match(error.errors[0]?.message ?? "", /not a predicate/);
80
+ return true;
81
+ },
82
+ );
83
+ });
84
+
85
+ test("a self-named collapse does not collide with a network-qualified token of the same leaf", () => {
86
+ // A self-named top-level `decide` role collapses to the bare token `decide`,
87
+ // while a `decide` role under network `x` stays `x.decide`. The two are
88
+ // distinct routing tokens, so construction succeeds and exposes both.
89
+ //
90
+ // A genuine duplicate token is structurally unreachable through the schema:
91
+ // every network/subnetwork/role key is unique, and the only collapse is a
92
+ // top-level self-named role — which can fire at most once per bare token — so
93
+ // no two roles can derive the same string. The constructor's duplicate-token
94
+ // guard is therefore defensive, and this test pins the near-miss it protects.
95
+ const doc = JSON.parse(
96
+ '{"version":1,"networks":{"decide":{"roles":{"decide":{}}},"x":{"roles":{"decide":{}}}}}',
97
+ );
98
+ const resolver = new VocabResolver(doc);
99
+ assert.deepEqual([...resolver.tokens()].sort(), ["decide", "x.decide"]);
100
+ assert.equal(resolver.roleForToken("decide")?.role, "decide");
101
+ assert.equal(resolver.roleForToken("x.decide")?.role, "decide");
102
+ });
103
+
104
+ test("every VALID_VOCABS conformance sample constructs a resolver without throwing", () => {
105
+ // Guard the defect class: the conformance corpus advertises these documents as
106
+ // resolver-valid ("held to the same schema this repo's validator enforces"), so
107
+ // a sample that passes `validateVocabDocument` but carries an unparseable
108
+ // `requires` gate (e.g. the `field:value` colon form instead of `field=value`)
109
+ // would still throw `RequiresParseError` the moment a consumer builds a
110
+ // resolver. Constructing a resolver over every sample parses each gate up front,
111
+ // catching that drift categorically rather than one predicate at a time.
112
+ for (const vector of VALID_VOCABS) {
113
+ assert.doesNotThrow(
114
+ () => new VocabResolver(vector.document),
115
+ `${vector.name}: every VALID_VOCABS sample must build a resolver`,
116
+ );
117
+ }
118
+ });
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The vocab resolver — S3's REGISTER→SERVE core.
3
+ *
4
+ * The versioned vocab artifact (S0 {@link VocabDocument}) is the ONE
5
+ * capability→token map; no map is ever baked into a worker. This resolver reads
6
+ * it and, given a declared enrolment capability, produces the deterministic
7
+ * SERVE token set the worker is entitled to fill — the `serve` reply to a
8
+ * `register` handshake.
9
+ *
10
+ * Token derivation walks `networks → subnetworks → roles`, joining segments into
11
+ * a routing token `network[.subnetwork…].role` (never a seat, never a
12
+ * capability). One exception encodes a BARE (network-less) role such as
13
+ * `decide`: a top-level role whose name equals its containing network and that
14
+ * has no subnetworks collapses to the single-segment token `role` — the only way
15
+ * the S0 network→role schema can express the bare tokens the grammar allows. So
16
+ * `networks.decide.roles.decide` serves the token `decide`.
17
+ */
18
+ import { formatToken, parseToken, validateVocabDocument } from "../protocol/index.ts";
19
+ import type { Capability, VocabDocument, VocabNetwork, VocabRole } from "../protocol/index.ts";
20
+ import { parseRequiresList, RequiresParseError, satisfiesRequires, type RequiresPredicate } from "./requires.ts";
21
+
22
+ /** A role flattened out of the vocab tree, with its derived routing token. */
23
+ export interface ResolvedRole {
24
+ /** The leaf routing token (`network[.subnetwork…].role`, or a bare role). */
25
+ readonly token: string;
26
+ /** The network segment, absent for a bare role. */
27
+ readonly network?: string;
28
+ /** The subnetwork segments between network and role (possibly empty). */
29
+ readonly subnetworks: readonly string[];
30
+ /** The role segment. */
31
+ readonly role: string;
32
+ /** Cognition weight for the role, if declared. */
33
+ readonly weight?: number;
34
+ /** Normalised seats: a non-negative count, or the explicit named-seat list. */
35
+ readonly seats: number | readonly string[];
36
+ /** Diversity SLO opt-in: when true, seats must be filled by distinct families. */
37
+ readonly seatsDistinctFamily: boolean;
38
+ /** The parsed enrolment gate for the role. */
39
+ readonly requires: readonly RequiresPredicate[];
40
+ }
41
+
42
+ /** The result of resolving one capability against the vocab. */
43
+ export interface Resolution {
44
+ /** The SERVE token set — sorted, de-duplicated leaf tokens. */
45
+ readonly tokens: readonly string[];
46
+ /** The matched roles (sorted by token) the tokens came from. */
47
+ readonly roles: readonly ResolvedRole[];
48
+ }
49
+
50
+ /** Raised when the document handed to the resolver is not a valid vocab artifact. */
51
+ export class VocabDocumentError extends Error {
52
+ readonly errors: readonly { path: string; message: string }[];
53
+ constructor(errors: readonly { path: string; message: string }[]) {
54
+ super(`invalid vocab document: ${errors.map((e) => `${e.path}: ${e.message}`).join("; ")}`);
55
+ this.name = "VocabDocumentError";
56
+ this.errors = errors;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Parse a role's `requires` gate, tagging any failure with the routing token so a
62
+ * malformed predicate names the role that carries it (e.g. when merging author
63
+ * extensions) instead of surfacing a bare, context-free {@link RequiresParseError}.
64
+ */
65
+ function parseRequires(token: string, requires: readonly string[] | undefined): RequiresPredicate[] {
66
+ try {
67
+ return parseRequiresList(requires);
68
+ } catch (error) {
69
+ if (error instanceof RequiresParseError) {
70
+ throw new VocabDocumentError([{ path: token, message: error.message }]);
71
+ }
72
+ throw error;
73
+ }
74
+ }
75
+
76
+ function deriveToken(network: string, subnetworks: readonly string[], role: string): string {
77
+ // A self-named top-level role denotes a rootless (network-less) role: the only
78
+ // way the network→role schema can express a single-segment token like `decide`.
79
+ if (subnetworks.length === 0 && network === role) {
80
+ return formatToken({ subnetworks: [], role });
81
+ }
82
+ return formatToken({ network, subnetworks: [...subnetworks], role });
83
+ }
84
+
85
+ function normaliseSeats(seats: VocabRole["seats"]): number | readonly string[] {
86
+ if (seats === undefined) return 1;
87
+ if (typeof seats === "number") return seats;
88
+ return [...seats];
89
+ }
90
+
91
+ export class VocabResolver {
92
+ readonly #roles: readonly ResolvedRole[];
93
+ readonly #byToken: ReadonlyMap<string, ResolvedRole>;
94
+ readonly #version: number;
95
+
96
+ /**
97
+ * Build a resolver over a vocab document. The document is re-validated against
98
+ * the S0 schema and every role's `requires` gate is parsed up front, so a
99
+ * malformed gate fails loudly at construction rather than silently at match.
100
+ */
101
+ constructor(doc: VocabDocument) {
102
+ const validation = validateVocabDocument(doc);
103
+ if (!validation.ok) {
104
+ throw new VocabDocumentError(validation.errors.map((e) => ({ path: e.path, message: e.message })));
105
+ }
106
+ const document = validation.value;
107
+ this.#version = document.version;
108
+
109
+ const roles: ResolvedRole[] = [];
110
+ const collect = (network: string, subnetworks: readonly string[], node: VocabNetwork): void => {
111
+ if (node.roles !== undefined) {
112
+ for (const [roleName, role] of Object.entries(node.roles)) {
113
+ const token = deriveToken(network, subnetworks, roleName);
114
+ roles.push({
115
+ token,
116
+ ...(subnetworks.length === 0 && network === roleName ? {} : { network }),
117
+ subnetworks: [...subnetworks],
118
+ role: roleName,
119
+ ...(role.weight === undefined ? {} : { weight: role.weight }),
120
+ seats: normaliseSeats(role.seats),
121
+ seatsDistinctFamily: role.seatsDistinctFamily === true,
122
+ requires: parseRequires(token, role.requires),
123
+ });
124
+ }
125
+ }
126
+ if (node.subnetworks !== undefined) {
127
+ for (const [subName, sub] of Object.entries(node.subnetworks)) {
128
+ collect(network, [...subnetworks, subName], sub);
129
+ }
130
+ }
131
+ };
132
+ for (const [networkName, network] of Object.entries(document.networks)) {
133
+ collect(networkName, [], network);
134
+ }
135
+
136
+ // Deterministic order + a token→role index; a duplicate token is a modelling
137
+ // error (two roles cannot claim one routing token).
138
+ roles.sort((a, b) => (a.token < b.token ? -1 : a.token > b.token ? 1 : 0));
139
+ const byToken = new Map<string, ResolvedRole>();
140
+ for (const role of roles) {
141
+ if (byToken.has(role.token)) {
142
+ throw new VocabDocumentError([{ path: role.token, message: `duplicate routing token: ${role.token}` }]);
143
+ }
144
+ byToken.set(role.token, role);
145
+ }
146
+ this.#roles = roles;
147
+ this.#byToken = byToken;
148
+ }
149
+
150
+ /** The vocab artifact version this resolver was built from. */
151
+ get version(): number {
152
+ return this.#version;
153
+ }
154
+
155
+ /** Every role in the vocab, sorted by token. */
156
+ roles(): readonly ResolvedRole[] {
157
+ return this.#roles;
158
+ }
159
+
160
+ /** Every leaf token in the vocab, sorted. */
161
+ tokens(): readonly string[] {
162
+ return this.#roles.map((role) => role.token);
163
+ }
164
+
165
+ /** Look up a role by its routing token. */
166
+ roleForToken(token: string): ResolvedRole | undefined {
167
+ // Normalise through the grammar so `planning.planner` and any equivalent
168
+ // spelling resolve identically.
169
+ let key: string;
170
+ try {
171
+ key = formatToken(parseToken(token));
172
+ } catch {
173
+ return undefined;
174
+ }
175
+ return this.#byToken.get(key);
176
+ }
177
+
178
+ /**
179
+ * Resolve a declared enrolment capability to its SERVE token set: every role
180
+ * whose `requires` gate the capability satisfies. The token list is sorted and
181
+ * de-duplicated, so the same capability always yields the same SERVE.
182
+ */
183
+ resolve(capability: Capability): Resolution {
184
+ const matched = this.#roles.filter((role) => satisfiesRequires(role.requires, capability));
185
+ return { tokens: matched.map((role) => role.token), roles: matched };
186
+ }
187
+ }
@@ -0,0 +1,64 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { decodeFrame, encodeFrame, type Frame, MAX_SEQ, validatePayload } from "../protocol/index.ts";
4
+ import { CORE_VOCAB } from "./core-vocab.ts";
5
+ import { VocabResolver } from "./resolver.ts";
6
+ import { buildServeFrame, buildServePayload, serveCapability, type ServeSink } from "./serve.ts";
7
+
8
+ test("buildServePayload copies tokens into a serve payload", () => {
9
+ const payload = buildServePayload("w-1", ["planning.planner"]);
10
+ assert.deepEqual(payload, { instance: "w-1", tokens: ["planning.planner"] });
11
+ assert.equal(validatePayload("serve", payload).ok, true);
12
+ });
13
+
14
+ test("buildServeFrame rides the control lane and round-trips through the codec", () => {
15
+ const frame = buildServeFrame("w-1", ["planning.planner", "planning.reviewer"], 3);
16
+ assert.equal(frame.lane, "control");
17
+ assert.equal(frame.family, "serve");
18
+ assert.equal(frame.seq, 3);
19
+ const decoded = decodeFrame(encodeFrame(frame));
20
+ assert.deepEqual(decoded.payload, { instance: "w-1", tokens: ["planning.planner", "planning.reviewer"] });
21
+ });
22
+
23
+ test("buildServeFrame refuses an invalid routing token", () => {
24
+ assert.throws(() => buildServeFrame("w-1", ["Not A Token"]), /invalid serve frame/);
25
+ });
26
+
27
+ test("buildServeFrame refuses a seq outside the wire's uint32 bound", () => {
28
+ for (const badSeq of [-1, 1.5, MAX_SEQ + 1, Number.NaN, Number.POSITIVE_INFINITY]) {
29
+ assert.throws(() => buildServeFrame("w-1", ["planning.planner"], badSeq), /invalid seq/);
30
+ }
31
+ });
32
+
33
+ test("buildServeFrame accepts the seq boundary values the wire allows", () => {
34
+ for (const seq of [0, MAX_SEQ]) {
35
+ const frame = buildServeFrame("w-1", ["planning.planner"], seq);
36
+ assert.equal(frame.seq, seq);
37
+ assert.deepEqual(decodeFrame(encodeFrame(frame)).payload, { instance: "w-1", tokens: ["planning.planner"] });
38
+ }
39
+ });
40
+
41
+ test("serveCapability resolves a capability and emits the serve frame", () => {
42
+ const resolver = new VocabResolver(CORE_VOCAB);
43
+ const sent: Frame[] = [];
44
+ const sink: ServeSink = { send: (frame) => sent.push(frame) };
45
+
46
+ const resolution = serveCapability(resolver, sink, "w-1", { cognition: "planning", family: "acme" }, 1);
47
+
48
+ assert.deepEqual(resolution.tokens, ["planning.planner", "planning.reviewer"]);
49
+ assert.equal(sent.length, 1);
50
+ assert.equal(sent[0]?.family, "serve");
51
+ assert.equal(sent[0]?.lane, "control");
52
+ assert.deepEqual(sent[0]?.payload, {
53
+ instance: "w-1",
54
+ tokens: ["planning.planner", "planning.reviewer"],
55
+ });
56
+ });
57
+
58
+ test("serveCapability serves an empty token set for an unqualified capability", () => {
59
+ const resolver = new VocabResolver(CORE_VOCAB);
60
+ const sent: Frame[] = [];
61
+ const resolution = serveCapability(resolver, { send: (f) => sent.push(f) }, "w-2", { cognition: "unknown" });
62
+ assert.deepEqual(resolution.tokens, []);
63
+ assert.deepEqual(sent[0]?.payload, { instance: "w-2", tokens: [] });
64
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The REGISTER→SERVE handshake mechanics.
3
+ *
4
+ * S2 owns the `register` family on the hub (one family, one owning module — this
5
+ * slice does NOT claim it). S3 supplies the other half of the handshake: given a
6
+ * declared enrolment capability, resolve it (via {@link VocabResolver}) to its
7
+ * SERVE token set and emit a `serve` frame back to the worker.
8
+ *
9
+ * The `serve` reply rides the CONTROL lane — it is a control/facts message, so a
10
+ * bulk relay storm can never delay a worker learning which tokens it may serve
11
+ * (S0 invariant 5). The composition root wires this after presence register
12
+ * (e.g. from S2's register path or an app-level adapter); keeping it a pure,
13
+ * connection-agnostic helper is what lets it compose without editing S2.
14
+ */
15
+ import type { Capability, Frame, ServePayload } from "../protocol/index.ts";
16
+ import { MAX_SEQ, validatePayload } from "../protocol/index.ts";
17
+ import type { Resolution, VocabResolver } from "./resolver.ts";
18
+
19
+ /** The minimal per-connection sink a serve reply needs — S1's `HubConnection.send`. */
20
+ export interface ServeSink {
21
+ send(frame: Frame): void;
22
+ }
23
+
24
+ /** Build the `serve` payload for an instance and its resolved token set. */
25
+ export function buildServePayload(instance: string, tokens: readonly string[]): ServePayload {
26
+ return { instance, tokens: [...tokens] };
27
+ }
28
+
29
+ /**
30
+ * Build the `serve` frame carrying an instance's SERVE token set. Rides the
31
+ * control lane; `seq` defaults to 0 (the caller owns per-connection sequencing).
32
+ * The built payload is validated against the S0 `serve` contract, so a
33
+ * malformed token set fails loudly here rather than on the wire. `seq` is
34
+ * likewise checked against the wire's uint32 bound (the same {@link MAX_SEQ}
35
+ * `encodeFrame` enforces) so a bad sequence number fails here — at construction
36
+ * — rather than later during encoding.
37
+ */
38
+ export function buildServeFrame(instance: string, tokens: readonly string[], seq = 0): Frame {
39
+ if (!Number.isInteger(seq) || seq < 0 || seq > MAX_SEQ) {
40
+ throw new Error(`refusing to build serve frame with invalid seq (must be a uint32): ${String(seq)}`);
41
+ }
42
+ const payload = buildServePayload(instance, tokens);
43
+ const check = validatePayload("serve", payload);
44
+ if (!check.ok) {
45
+ throw new Error(`refusing to build invalid serve frame: ${check.errors.map((e) => e.message).join("; ")}`);
46
+ }
47
+ return { lane: "control", family: "serve", seq, payload };
48
+ }
49
+
50
+ /**
51
+ * Resolve `capability` and send the resulting `serve` frame to `sink`. Returns
52
+ * the {@link Resolution} so the caller can also record the assigned tokens (e.g.
53
+ * for demand×supply or diversity correlation). This is the server side of
54
+ * `REGISTER {capability}` → `SERVE [leaf tokens]`.
55
+ */
56
+ export function serveCapability(
57
+ resolver: VocabResolver,
58
+ sink: ServeSink,
59
+ instance: string,
60
+ capability: Capability,
61
+ seq = 0,
62
+ ): Resolution {
63
+ const resolution = resolver.resolve(capability);
64
+ sink.send(buildServeFrame(instance, resolution.tokens, seq));
65
+ return resolution;
66
+ }