@quolu/lattice 0.52.3 → 0.53.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 (188) hide show
  1. package/LICENSE +147 -147
  2. package/README.ja.md +355 -355
  3. package/README.md +258 -258
  4. package/bin/lattice-bridge.mjs +25 -0
  5. package/bin/lattice-hub.mjs +67 -0
  6. package/bin/lattice-mcp.mjs +0 -0
  7. package/bin/lattice-scripted-adapter.mjs +0 -0
  8. package/bin/lattice-scripted-worker.mjs +0 -0
  9. package/bin/lattice-work-order-adapter.mjs +0 -0
  10. package/bin/lattice.mjs +0 -0
  11. package/docs/bridge-setup.md +132 -132
  12. package/docs/schemas/lattice.executor_packet.v1.schema.json +57 -57
  13. package/docs/schemas/lattice.executor_receipt.v1.schema.json +66 -66
  14. package/docs/schemas/lattice.phase_todo_revision.v3.schema.json +360 -360
  15. package/docs/schemas/lattice.plan_create_input.v1.schema.json +56 -56
  16. package/docs/schemas/lattice.plan_create_input.v2.schema.json +72 -72
  17. package/docs/schemas/lattice.plan_create_input.v3.schema.json +81 -81
  18. package/docs/schemas/lattice.plan_create_input.v4.schema.json +85 -85
  19. package/docs/schemas/lattice.run_request.v1.schema.json +238 -238
  20. package/docs/schemas/lattice.runtime_adapter_capabilities.v2.schema.json +55 -55
  21. package/docs/schemas/lattice.runtime_adapter_registration_input.v1.schema.json +78 -78
  22. package/docs/schemas/lattice.runtime_adapter_registration_input.v2.schema.json +86 -86
  23. package/docs/schemas/lattice.todo_extraction.v2.schema.json +298 -298
  24. package/docs/schemas/lattice.todo_extraction.v3.schema.json +146 -146
  25. package/docs/schemas/lattice.todo_revision.v2.schema.json +260 -260
  26. package/docs/schemas/lattice.todo_revision_set.v3.schema.json +363 -363
  27. package/package.json +103 -103
  28. package/sensor/LICENSE +21 -21
  29. package/sensor/NOTICE +19 -19
  30. package/sensor/dist/bin/lattice-sensor.js +9 -9
  31. package/sensor/dist/db/index.js +24 -24
  32. package/sensor/dist/db/migrations.js +41 -41
  33. package/sensor/dist/db/queries.js +164 -164
  34. package/sensor/dist/db/schema.sql +205 -205
  35. package/sensor/dist/directory.js +5 -5
  36. package/sensor/dist/extraction/wasm/tree-sitter-c_sharp.wasm +0 -0
  37. package/sensor/dist/extraction/wasm/tree-sitter-cfml.wasm +0 -0
  38. package/sensor/dist/extraction/wasm/tree-sitter-cfquery.wasm +0 -0
  39. package/sensor/dist/extraction/wasm/tree-sitter-cfscript.wasm +0 -0
  40. package/sensor/dist/extraction/wasm/tree-sitter-cobol.wasm +0 -0
  41. package/sensor/dist/extraction/wasm/tree-sitter-erlang.wasm +0 -0
  42. package/sensor/dist/extraction/wasm/tree-sitter-go.wasm +0 -0
  43. package/sensor/dist/extraction/wasm/tree-sitter-java.wasm +0 -0
  44. package/sensor/dist/extraction/wasm/tree-sitter-javascript.wasm +0 -0
  45. package/sensor/dist/extraction/wasm/tree-sitter-nix.wasm +0 -0
  46. package/sensor/dist/extraction/wasm/tree-sitter-pascal.wasm +0 -0
  47. package/sensor/dist/extraction/wasm/tree-sitter-python.wasm +0 -0
  48. package/sensor/dist/extraction/wasm/tree-sitter-tsx.wasm +0 -0
  49. package/sensor/dist/extraction/wasm/tree-sitter-typescript.wasm +0 -0
  50. package/sensor/dist/extraction/wasm/tree-sitter-vbnet.wasm +0 -0
  51. package/sensor/dist/mcp/liveness-watchdog.js +53 -53
  52. package/sensor/dist/mcp/server-instructions.js +95 -95
  53. package/sensor/package.json +56 -56
  54. package/src/artifact-contracts-v2.mjs +325 -325
  55. package/src/artifact-contracts.mjs +895 -895
  56. package/src/boundary-compiler.mjs +712 -712
  57. package/src/boundary-observation-compiler-v2.mjs +344 -344
  58. package/src/bounded-seam.mjs +230 -230
  59. package/src/bridge-address.mjs +107 -107
  60. package/src/bridge-cli.mjs +303 -297
  61. package/src/bridge-config.mjs +382 -346
  62. package/src/bridge-daemon.mjs +378 -378
  63. package/src/bridge-hub-heartbeat.mjs +170 -0
  64. package/src/bridge-hub-protocol.mjs +198 -0
  65. package/src/bridge-hub-server.mjs +544 -0
  66. package/src/bridge-launch-agent.mjs +323 -323
  67. package/src/bridge-registrar.mjs +102 -102
  68. package/src/bridge-server.mjs +376 -376
  69. package/src/cli-help.mjs +308 -308
  70. package/src/cli-stdio.mjs +40 -40
  71. package/src/control-compiler.mjs +532 -532
  72. package/src/dag-chain.mjs +261 -261
  73. package/src/factory-diagnostics.mjs +188 -188
  74. package/src/git-process.mjs +72 -0
  75. package/src/hash-chain.mjs +76 -76
  76. package/src/hooks-cli.mjs +1057 -1057
  77. package/src/isolation-runner.mjs +409 -409
  78. package/src/node-version-guard.mjs +44 -44
  79. package/src/project-cli.mjs +3 -3
  80. package/src/project-identity.mjs +130 -130
  81. package/src/rc1-black-box-oracle.mjs +906 -906
  82. package/src/rc1-comparison.mjs +154 -154
  83. package/src/rc1-evidence-bundle.mjs +456 -456
  84. package/src/rc1-v4-campaign.mjs +708 -708
  85. package/src/rc1-v4-transform.mjs +467 -467
  86. package/src/rc1-v5-artifact-set.mjs +855 -855
  87. package/src/rc1-v5-behavior-evidence.mjs +594 -594
  88. package/src/rc1-v5-campaign.mjs +797 -797
  89. package/src/rc1-v5-transform.mjs +421 -421
  90. package/src/rc1-v6-artifact-set.mjs +807 -807
  91. package/src/rc1-v6-behavior-evidence.mjs +273 -273
  92. package/src/rc1-v6-campaign.mjs +623 -623
  93. package/src/rc1-v6-causal-binding.mjs +473 -473
  94. package/src/rc1-v6-measurement.mjs +313 -313
  95. package/src/rc2-artifact-set.mjs +1584 -1584
  96. package/src/rc2-campaign.mjs +1506 -1506
  97. package/src/rc2-delivery-policy-front-end.mjs +1079 -1079
  98. package/src/rc2-delivery-policy-oracle.mjs +134 -134
  99. package/src/rc2-delivery-policy-transform.mjs +1127 -1127
  100. package/src/rc2-rc1-transfer-front-end.mjs +511 -511
  101. package/src/rc3-actual-dogfood.mjs +652 -652
  102. package/src/rc3-dogfood-scaffold.mjs +315 -315
  103. package/src/rc3-scripted-campaign.mjs +1383 -1383
  104. package/src/rc4-stage1-dogfood.mjs +673 -673
  105. package/src/runtime-adapter-registry.mjs +524 -524
  106. package/src/runtime-cli.mjs +4588 -4588
  107. package/src/runtime-contracts.mjs +824 -824
  108. package/src/runtime-control-store.mjs +604 -604
  109. package/src/runtime-controller-protocol.mjs +587 -587
  110. package/src/runtime-decision-verifier.mjs +701 -701
  111. package/src/runtime-diff-observer.mjs +361 -361
  112. package/src/runtime-direct-os-observer.mjs +301 -301
  113. package/src/runtime-driver-state.mjs +166 -166
  114. package/src/runtime-engine.mjs +779 -779
  115. package/src/runtime-errors.mjs +356 -356
  116. package/src/runtime-event-store.mjs +189 -189
  117. package/src/runtime-front-end.mjs +925 -925
  118. package/src/runtime-gate-store.mjs +481 -481
  119. package/src/runtime-hold-recompile.mjs +916 -916
  120. package/src/runtime-io-sentinel.mjs +391 -391
  121. package/src/runtime-lifecycle-lock.mjs +294 -294
  122. package/src/runtime-managed-supervisor.mjs +1490 -1490
  123. package/src/runtime-multi-epoch-store.mjs +838 -838
  124. package/src/runtime-projection.mjs +269 -269
  125. package/src/runtime-pull-intake.mjs +1192 -1192
  126. package/src/runtime-scripted-adapter-controller.mjs +1160 -1160
  127. package/src/runtime-scripted-executor.mjs +163 -163
  128. package/src/runtime-scripted-worktree.mjs +104 -104
  129. package/src/runtime-seam-resolve.mjs +428 -428
  130. package/src/runtime-seam-treatment.mjs +173 -173
  131. package/src/runtime-socket-owner.mjs +125 -125
  132. package/src/runtime-work-order-contracts.mjs +91 -91
  133. package/src/runtime-work-order-controller.mjs +1171 -1171
  134. package/src/runtime-worktree-executor.mjs +199 -199
  135. package/src/schedulability-compiler-v2.mjs +303 -303
  136. package/src/schedulability-verifier-v2.mjs +317 -317
  137. package/src/seam-apply.mjs +549 -549
  138. package/src/seam-commit-shared.mjs +22 -22
  139. package/src/seam-commit-transform.mjs +81 -81
  140. package/src/seam-commit.mjs +18 -18
  141. package/src/seam-cost.mjs +322 -322
  142. package/src/seam-derivation.mjs +188 -188
  143. package/src/seam-gate.mjs +146 -146
  144. package/src/seam-proposal-contracts.mjs +446 -446
  145. package/src/seam-proposal-queries.mjs +521 -521
  146. package/src/seam-proposal.mjs +2011 -2011
  147. package/src/seam-ref.mjs +33 -33
  148. package/src/seam-rewrite.mjs +286 -286
  149. package/src/seam-transform.mjs +554 -554
  150. package/src/seam-verification.mjs +260 -260
  151. package/src/sensor-adapter.mjs +432 -432
  152. package/src/sensor-cli.mjs +139 -139
  153. package/src/sensor-diff.mjs +661 -661
  154. package/src/sensor-node-runtime.mjs +53 -53
  155. package/src/sensor-runtime.mjs +52 -52
  156. package/src/timestamp-contract.mjs +8 -8
  157. package/src/todo-audit-pending.mjs +91 -91
  158. package/src/todo-chain.mjs +178 -178
  159. package/src/todo-cli.mjs +8 -8
  160. package/src/todo-contracts.mjs +728 -728
  161. package/src/todo-dashboard-registry.mjs +573 -573
  162. package/src/todo-dispatch-shape.mjs +190 -190
  163. package/src/todo-gantt-html-independence.mjs +239 -239
  164. package/src/todo-gantt-html-shared.mjs +226 -226
  165. package/src/todo-gantt-html-style.mjs +131 -131
  166. package/src/todo-gantt-html.mjs +248 -248
  167. package/src/todo-gantt-layout.mjs +974 -974
  168. package/src/todo-gantt-live.mjs +361 -361
  169. package/src/todo-gantt-nested.mjs +263 -263
  170. package/src/todo-gantt-presentation.mjs +217 -217
  171. package/src/todo-gantt-scope.mjs +123 -123
  172. package/src/todo-gantt-svg.mjs +353 -353
  173. package/src/todo-independence-contracts.mjs +595 -595
  174. package/src/todo-independence-guidance.mjs +322 -322
  175. package/src/todo-independence.mjs +640 -640
  176. package/src/todo-markdown-renderer.mjs +260 -260
  177. package/src/todo-migration.mjs +448 -448
  178. package/src/todo-narrative-anchor.mjs +130 -130
  179. package/src/todo-note-store.mjs +629 -629
  180. package/src/todo-parallel-candidates.mjs +114 -114
  181. package/src/todo-revision.mjs +995 -995
  182. package/src/todo-split.mjs +472 -472
  183. package/src/todo-status.mjs +690 -690
  184. package/src/todo-store-git-transaction.mjs +418 -418
  185. package/src/todo-store.mjs +42 -21
  186. package/src/treatment-compiler.mjs +728 -728
  187. package/src/treatment-runner.mjs +656 -656
  188. package/src/witness-scaffold.mjs +180 -180
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Terminal-side bridge-hub heartbeat client (bh3).
3
+ *
4
+ * Wires the pure wire contract in `bridge-hub-protocol.mjs` (bh1) around this
5
+ * terminal's own state: a persisted terminal identity, the locally active
6
+ * project set (`todo-dashboard-registry.mjs`), and the configured hub origin
7
+ * (`bridge-config.mjs`'s `hub` field). It sends `POST /__lattice/hub/register`
8
+ * on `bridge-hub-server.mjs`'s (bh2) contract — the request body is the raw
9
+ * `lattice.bridge_hub_registration_request.v1` object, unmodified in transit.
10
+ *
11
+ * DHCP addresses are never read or sent here: ADR 0162 has the hub derive
12
+ * `address` from the registration connection's own source, the same safety
13
+ * property `bridge-registrar.mjs` relies on. A moved lease is invisible to
14
+ * this module by design — the next heartbeat just arrives from a new source.
15
+ */
16
+
17
+ import { randomBytes } from 'node:crypto';
18
+ import { hostname } from 'node:os';
19
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
20
+ import path from 'node:path';
21
+
22
+ import { bridgeConfigPaths } from './bridge-config.mjs';
23
+ import { BRIDGE_HUB_HEARTBEAT_INTERVAL_MS, validateBridgeHubRegistrationRequest } from './bridge-hub-protocol.mjs';
24
+ import { readActiveTodoDashboardProjects } from './todo-dashboard-registry.mjs';
25
+
26
+ export { BRIDGE_HUB_HEARTBEAT_INTERVAL_MS };
27
+
28
+ const TERMINAL_IDENTITY_SCHEMA = 'lattice.bridge_hub_terminal_identity.v1';
29
+ const HEARTBEAT_RESULT_SCHEMA = 'lattice.bridge_hub_heartbeat_result.v1';
30
+ const REGISTRATION_REQUEST_SCHEMA = 'lattice.bridge_hub_registration_request.v1';
31
+ const REGISTER_PATH = '__lattice/hub/register';
32
+ const DEFAULT_TIMEOUT_MS = 5_000;
33
+ const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
34
+
35
+ export class BridgeHubHeartbeatError extends Error {
36
+ constructor(code, message, detail = undefined, cause = undefined) {
37
+ super(message, { cause });
38
+ this.name = 'BridgeHubHeartbeatError';
39
+ this.code = code;
40
+ if (detail !== undefined) this.detail = detail;
41
+ }
42
+ }
43
+
44
+ function terminalIdentityPath(env) {
45
+ return path.join(bridgeConfigPaths(env).root, 'bridge-hub-terminal.json');
46
+ }
47
+
48
+ /**
49
+ * This terminal's stable bridge-hub identity, created once and reused across
50
+ * restarts. It must survive restarts: registration is a full-state
51
+ * reconciliation keyed by terminal_id (ADR 0162 Decision 4), so a fresh id on
52
+ * every daemon start would make the hub see "a new terminal" contesting the
53
+ * same project_ids the old id still owns until its TTL lapses — a
54
+ * self-inflicted `BRIDGE_HUB_PROJECT_CONFLICT`.
55
+ */
56
+ export async function readOrCreateBridgeHubTerminalId({ env = process.env } = {}) {
57
+ const refs = bridgeConfigPaths(env);
58
+ await mkdir(refs.root, { recursive: true, mode: 0o700 });
59
+ const ref = terminalIdentityPath(env);
60
+ for (;;) {
61
+ try {
62
+ const value = JSON.parse(await readFile(ref, 'utf8'));
63
+ if (value?.schema === TERMINAL_IDENTITY_SCHEMA && IDENTIFIER.test(value.terminal_id)) return value.terminal_id;
64
+ throw new BridgeHubHeartbeatError('BRIDGE_HUB_TERMINAL_IDENTITY_INVALID',
65
+ 'bridge hub terminal identity file is invalid');
66
+ } catch (error) {
67
+ if (error?.code !== 'ENOENT') throw error;
68
+ }
69
+ const terminalId = randomBytes(16).toString('hex');
70
+ try {
71
+ await writeFile(ref, `${JSON.stringify({ schema: TERMINAL_IDENTITY_SCHEMA, terminal_id: terminalId })}\n`,
72
+ { encoding: 'utf8', mode: 0o600, flag: 'wx' });
73
+ return terminalId;
74
+ } catch (error) {
75
+ if (error?.code !== 'EEXIST') throw error;
76
+ // Lost the create race to another process; loop back and read what it wrote.
77
+ }
78
+ }
79
+ }
80
+
81
+ /**
82
+ * The terminal's own display name, shown for every project it registers
83
+ * (registry entries are per-project but `display_name` is terminal-wide —
84
+ * ADR 0162 Decision 2). Hostname, not a project name: the active project set
85
+ * changes heartbeat to heartbeat but the terminal's identity does not.
86
+ */
87
+ function terminalDisplayName() {
88
+ return hostname().slice(0, 128) || 'terminal';
89
+ }
90
+
91
+ /** Build and validate one registration/heartbeat request. Throws rather than
92
+ * sending a request the hub would reject as malformed. */
93
+ export function buildBridgeHubRegistrationRequest({ terminalId, port, projectIds, adopt = [] }) {
94
+ const request = {
95
+ schema: REGISTRATION_REQUEST_SCHEMA,
96
+ terminal_id: terminalId,
97
+ display_name: terminalDisplayName(),
98
+ port,
99
+ project_ids: [...new Set(projectIds)].sort((left, right) => left.localeCompare(right, 'en')),
100
+ adopt: [...adopt],
101
+ };
102
+ if (!validateBridgeHubRegistrationRequest(request)) {
103
+ throw new BridgeHubHeartbeatError('BRIDGE_HUB_HEARTBEAT_REQUEST_INVALID',
104
+ 'constructed bridge hub registration request is invalid', { request });
105
+ }
106
+ return request;
107
+ }
108
+
109
+ /**
110
+ * Send one heartbeat. Never throws for a remote or network failure — the
111
+ * bridge daemon loop calling this must keep serving locally even when the
112
+ * hub is unreachable, matching `bridge-registrar.mjs`'s posture. Failures are
113
+ * returned typed so callers can surface or log them instead of losing them.
114
+ */
115
+ export async function sendBridgeHubHeartbeat({ hubUrl, request, fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS }) {
116
+ let response;
117
+ try {
118
+ response = await fetchImpl(new URL(REGISTER_PATH, hubUrl), {
119
+ method: 'POST',
120
+ headers: { 'content-type': 'application/json' },
121
+ body: JSON.stringify(request),
122
+ signal: AbortSignal.timeout(timeoutMs),
123
+ });
124
+ } catch (error) {
125
+ return { schema: HEARTBEAT_RESULT_SCHEMA, state: 'unreachable',
126
+ detail: (error?.message ?? 'network error').slice(0, 500) };
127
+ }
128
+ let body = null;
129
+ try { body = await response.json(); } catch { body = null; }
130
+ if (response.status !== 200) {
131
+ return { schema: HEARTBEAT_RESULT_SCHEMA, state: 'rejected', status: response.status, detail: body };
132
+ }
133
+ return { schema: HEARTBEAT_RESULT_SCHEMA, state: 'accepted', result: body };
134
+ }
135
+
136
+ /**
137
+ * Periodic controller for the bridge daemon's own poll loop
138
+ * (`bin/lattice-bridge.mjs`). Call `tick({ config })` on every iteration; it
139
+ * self-throttles to `intervalMs` and is a no-op (no disk or network access)
140
+ * whenever the terminal has no hub configured, so callers pay only a null
141
+ * check on the common path.
142
+ */
143
+ export function createBridgeHubHeartbeatController({
144
+ env = process.env, fetchImpl = fetch, now = () => Date.now(),
145
+ intervalMs = BRIDGE_HUB_HEARTBEAT_INTERVAL_MS,
146
+ readActiveProjects = readActiveTodoDashboardProjects,
147
+ } = {}) {
148
+ let lastSentAt = null;
149
+ let lastResult = null;
150
+ return Object.freeze({
151
+ async tick({ config }) {
152
+ if (config?.hub == null) { lastSentAt = null; lastResult = null; return null; }
153
+ const nowMs = now();
154
+ if (lastSentAt !== null && nowMs - lastSentAt < intervalMs) return lastResult;
155
+ lastSentAt = nowMs;
156
+ const projects = await readActiveProjects({ env });
157
+ if (projects.length === 0) {
158
+ lastResult = { schema: HEARTBEAT_RESULT_SCHEMA, state: 'skipped_no_projects' };
159
+ return lastResult;
160
+ }
161
+ const terminalId = await readOrCreateBridgeHubTerminalId({ env });
162
+ const request = buildBridgeHubRegistrationRequest({
163
+ terminalId, port: config.listen.port, projectIds: projects.map((project) => project.project_id),
164
+ });
165
+ lastResult = await sendBridgeHubHeartbeat({ hubUrl: config.hub.url, request, fetchImpl });
166
+ return lastResult;
167
+ },
168
+ lastHeartbeatResult: () => lastResult,
169
+ });
170
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Bridge hub registration protocol — the wire contract between a terminal's
3
+ * `lattice bridge` and the hub that aggregates them for the public dashboard.
4
+ *
5
+ * This module owns validation and pure registry mutation only. It does not open
6
+ * a socket, read a clock, or touch disk — that belongs to the hub server (bh2)
7
+ * and the terminal heartbeat client (bh3). Keeping the contract pure lets it be
8
+ * characterization-tested before either side exists, and keeps bh2/bh3 from
9
+ * inventing their own conflict or staleness rules.
10
+ *
11
+ * Design decisions this module encodes (see docs/adr/0162 for the full record):
12
+ *
13
+ * - The registry is keyed by `project_id`, not `terminal_id`, because hub routes
14
+ * `/projects/<id>/*` by project. A terminal that owns several projects appears
15
+ * as several entries sharing the same terminal_id/address/port.
16
+ * - The request never carries an address. The caller passes the address the
17
+ * registration connection actually arrived from (`remoteAddress`); a terminal
18
+ * can only ever register itself, the same safety property bridge-registrar.mjs
19
+ * already relies on for the ssh-based upstream registrar.
20
+ * - A registration call declares a terminal's *complete* current project
21
+ * portfolio and replaces that terminal's prior contribution wholesale. A
22
+ * project the terminal owned before but omits now is released — there is no
23
+ * separate "deregister" verb, and no partial application: a heartbeat either
24
+ * lands in full or is rejected in full.
25
+ * - A project_id already owned by a *different* terminal is a conflict unless
26
+ * named in `adopt`. Conflicts are collected and reported together — a batch
27
+ * never partially lands, so a caller is never left unsure which half won.
28
+ * - Staleness is read-time only. `projectBridgeHubRegistry` marks an entry
29
+ * 'offline' once `last_seen_at` exceeds the TTL; it never deletes the entry.
30
+ * The plan's fail-closed requirement is "show offline", not "make it vanish".
31
+ */
32
+
33
+ const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
34
+ const MAX_PROJECT_IDS = 256;
35
+
36
+ /** Suggested terminal heartbeat cadence. Not part of the wire contract — the hub
37
+ * owns both constants and can retune them without a protocol version bump. */
38
+ export const BRIDGE_HUB_HEARTBEAT_INTERVAL_MS = 30_000;
39
+ /** Grace window after the last heartbeat before a project is shown offline. */
40
+ export const BRIDGE_HUB_HEARTBEAT_TTL_MS = 90_000;
41
+
42
+ export class BridgeHubProtocolError extends Error {
43
+ constructor(code, message, detail = null) {
44
+ super(message);
45
+ this.name = 'BridgeHubProtocolError';
46
+ this.code = code;
47
+ this.detail = detail;
48
+ }
49
+ }
50
+
51
+ function identifier(value) {
52
+ return typeof value === 'string' && IDENTIFIER.test(value);
53
+ }
54
+
55
+ function uniqueIdentifierArray(value, { min = 0, max = MAX_PROJECT_IDS } = {}) {
56
+ return Array.isArray(value) && value.length >= min && value.length <= max
57
+ && value.every(identifier) && new Set(value).size === value.length;
58
+ }
59
+
60
+ function sorted(ids) {
61
+ return [...ids].sort((left, right) => left.localeCompare(right, 'en'));
62
+ }
63
+
64
+ /** A terminal's registration/heartbeat request, as it crosses the wire. */
65
+ export function validateBridgeHubRegistrationRequest(value) {
66
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
67
+ && Object.keys(value).sort().join(',') === 'adopt,display_name,port,project_ids,schema,terminal_id'
68
+ && value.schema === 'lattice.bridge_hub_registration_request.v1'
69
+ && identifier(value.terminal_id)
70
+ && typeof value.display_name === 'string' && value.display_name.length > 0
71
+ && value.display_name === value.display_name.trim()
72
+ && Number.isSafeInteger(value.port) && value.port > 0 && value.port <= 65_535
73
+ && uniqueIdentifierArray(value.project_ids, { min: 1 })
74
+ && uniqueIdentifierArray(value.adopt, { max: value.project_ids.length })
75
+ && value.adopt.every((projectId) => value.project_ids.includes(projectId));
76
+ }
77
+
78
+ /** A single project's routing entry as stored in the hub registry. */
79
+ function validRegistryEntry(entry) {
80
+ return entry !== null && typeof entry === 'object' && !Array.isArray(entry)
81
+ && Object.keys(entry).sort().join(',')
82
+ === 'address,display_name,last_seen_at,port,project_id,registered_at,terminal_id'
83
+ && identifier(entry.project_id) && identifier(entry.terminal_id)
84
+ && typeof entry.display_name === 'string' && entry.display_name.length > 0
85
+ && typeof entry.address === 'string' && entry.address.length > 0
86
+ && Number.isSafeInteger(entry.port) && entry.port > 0 && entry.port <= 65_535
87
+ && Number.isFinite(Date.parse(entry.registered_at)) && Number.isFinite(Date.parse(entry.last_seen_at));
88
+ }
89
+
90
+ export function validateBridgeHubRegistryEntry(value) {
91
+ return validRegistryEntry(value);
92
+ }
93
+
94
+ function validRegistry(registry) {
95
+ return Array.isArray(registry) && registry.every(validRegistryEntry);
96
+ }
97
+
98
+ /**
99
+ * Apply one registration/heartbeat call to a registry snapshot. Pure: takes the
100
+ * current entries and returns the next ones plus a result summary, mutating
101
+ * nothing. Throws `BridgeHubProtocolError` for an invalid request or an
102
+ * unresolved project_id conflict; on throw, `registry` is guaranteed untouched
103
+ * because nothing is written until every conflict check has passed.
104
+ */
105
+ export function applyBridgeHubRegistration({ registry, request, remoteAddress, now = new Date() }) {
106
+ if (!validRegistry(registry)) {
107
+ throw new BridgeHubProtocolError('BRIDGE_HUB_REGISTRY_INVALID', 'bridge hub registry is invalid');
108
+ }
109
+ if (!validateBridgeHubRegistrationRequest(request)) {
110
+ throw new BridgeHubProtocolError('BRIDGE_HUB_REGISTRATION_INVALID', 'bridge hub registration request is invalid');
111
+ }
112
+ if (typeof remoteAddress !== 'string' || remoteAddress.length === 0) {
113
+ throw new BridgeHubProtocolError('BRIDGE_HUB_REGISTRATION_INVALID', 'remote address is required');
114
+ }
115
+ if (!(now instanceof Date) || !Number.isFinite(now.getTime())) {
116
+ throw new BridgeHubProtocolError('BRIDGE_HUB_REGISTRATION_INVALID', 'now must be a valid Date');
117
+ }
118
+ const { terminal_id: terminalId, display_name: displayName, port, project_ids: projectIds, adopt } = request;
119
+ const requestedSet = new Set(projectIds);
120
+ const adoptSet = new Set(adopt);
121
+
122
+ const conflicts = registry
123
+ .filter((entry) => requestedSet.has(entry.project_id)
124
+ && entry.terminal_id !== terminalId && !adoptSet.has(entry.project_id))
125
+ .map((entry) => ({ project_id: entry.project_id, owning_terminal_id: entry.terminal_id }));
126
+ if (conflicts.length > 0) {
127
+ throw new BridgeHubProtocolError('BRIDGE_HUB_PROJECT_CONFLICT',
128
+ 'one or more project_ids are already owned by a different terminal',
129
+ {
130
+ conflicts: conflicts.sort((left, right) => left.project_id.localeCompare(right.project_id, 'en')),
131
+ next_action: 're-register naming the conflicting project_ids in adopt',
132
+ });
133
+ }
134
+
135
+ const registeredAtByProject = new Map(registry
136
+ .filter((entry) => entry.terminal_id === terminalId)
137
+ .map((entry) => [entry.project_id, entry.registered_at]));
138
+ const adopted = sorted(projectIds.filter((projectId) => adoptSet.has(projectId)
139
+ && !registeredAtByProject.has(projectId)));
140
+ // Full-state reconciliation: every entry this terminal owned is dropped, then
141
+ // rebuilt from `projectIds`. A project omitted from this call — dropped
142
+ // locally, or never re-sent after a crash — is released, not left as a
143
+ // phantom no future heartbeat can retract.
144
+ const others = registry.filter((entry) => entry.terminal_id !== terminalId && !requestedSet.has(entry.project_id));
145
+ const mine = projectIds.map((projectId) => ({
146
+ project_id: projectId,
147
+ terminal_id: terminalId,
148
+ display_name: displayName,
149
+ address: remoteAddress,
150
+ port,
151
+ registered_at: registeredAtByProject.get(projectId) ?? now.toISOString(),
152
+ last_seen_at: now.toISOString(),
153
+ }));
154
+ const nextRegistry = [...others, ...mine]
155
+ .sort((left, right) => left.project_id.localeCompare(right.project_id, 'en'));
156
+
157
+ return {
158
+ registry: nextRegistry,
159
+ result: {
160
+ schema: 'lattice.bridge_hub_registration_result.v1',
161
+ terminal_id: terminalId,
162
+ address: remoteAddress,
163
+ port,
164
+ registered: sorted(projectIds),
165
+ adopted,
166
+ },
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Read-time projection for the aggregate `/projects/` view and per-project
172
+ * routing. Never mutates or drops entries — a terminal that stopped
173
+ * heartbeating shows as 'offline' forever, not gone, until it either
174
+ * re-registers or a different terminal explicitly adopts the project.
175
+ */
176
+ export function projectBridgeHubRegistry({ registry, now = new Date(), ttlMs = BRIDGE_HUB_HEARTBEAT_TTL_MS }) {
177
+ if (!validRegistry(registry)) {
178
+ throw new BridgeHubProtocolError('BRIDGE_HUB_REGISTRY_INVALID', 'bridge hub registry is invalid');
179
+ }
180
+ if (!(now instanceof Date) || !Number.isFinite(now.getTime())) {
181
+ throw new BridgeHubProtocolError('BRIDGE_HUB_REGISTRATION_INVALID', 'now must be a valid Date');
182
+ }
183
+ if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) {
184
+ throw new BridgeHubProtocolError('BRIDGE_HUB_REGISTRATION_INVALID', 'ttlMs must be a positive integer');
185
+ }
186
+ return registry
187
+ .map((entry) => ({
188
+ schema: 'lattice.bridge_hub_registry_projection_entry.v1',
189
+ project_id: entry.project_id,
190
+ terminal_id: entry.terminal_id,
191
+ display_name: entry.display_name,
192
+ address: entry.address,
193
+ port: entry.port,
194
+ status: now.getTime() - Date.parse(entry.last_seen_at) <= ttlMs ? 'online' : 'offline',
195
+ last_seen_at: entry.last_seen_at,
196
+ }))
197
+ .sort((left, right) => left.project_id.localeCompare(right.project_id, 'en'));
198
+ }