@nanobpm/nano-workforce 0.71.0 → 0.72.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.
@@ -0,0 +1,111 @@
1
+ // Unit tests for the durable contract registry + typed env schema (issue #227, ADR 0004).
2
+ import { test } from "node:test";
3
+ import { assert, assertEquals } from "#test-assert";
4
+ import {
5
+ allContracts,
6
+ detectDeclarationConflicts,
7
+ ENV_CONTRACTS,
8
+ envContract,
9
+ readEnv,
10
+ readEnvOr,
11
+ rejectedEnvSynonyms,
12
+ } from "./contracts.ts";
13
+
14
+ test("readEnv: trims, treats blank/whitespace as unset, reads the injected env", () => {
15
+ assertEquals(readEnv("NANO_WORKFORCE_BASE_URL", { NANO_WORKFORCE_BASE_URL: " https://x " }), "https://x");
16
+ assertEquals(readEnv("NANO_WORKFORCE_BASE_URL", { NANO_WORKFORCE_BASE_URL: " " }), undefined);
17
+ assertEquals(readEnv("NANO_WORKFORCE_BASE_URL", {}), undefined);
18
+ });
19
+
20
+ test("readEnvOr: falls back to the registry default, then to the given fallback", () => {
21
+ assertEquals(readEnvOr("NANO_WORKFORCE_BASE_URL", "x", {}), "http://localhost:3000");
22
+ assertEquals(readEnvOr("NANO_WORKFORCE_BASE_URL", "x", { NANO_WORKFORCE_BASE_URL: "https://y" }), "https://y");
23
+ // A key with no registered default falls through to the caller's fallback.
24
+ assertEquals(readEnvOr("NANO_ESCALATION_SLA_TIMEOUT", "PT24H", {}), "PT24H");
25
+ });
26
+
27
+ test("rejectedEnvSynonyms: maps retired base-URL names to the canonical key (#226/#223)", () => {
28
+ const rejected = rejectedEnvSynonyms();
29
+ assertEquals(rejected.get("NANO_PR_PUBLIC_BASE_URL"), "NANO_WORKFORCE_BASE_URL");
30
+ assertEquals(rejected.get("NANO_PR_BASE_URL"), "NANO_WORKFORCE_BASE_URL");
31
+ });
32
+
33
+ test("envContract: exposes owner + semantics + default for a declared key", () => {
34
+ const c = envContract("NANO_WORKFORCE_BASE_URL");
35
+ assertEquals(c.name, "NANO_WORKFORCE_BASE_URL");
36
+ assertEquals(c.default, "http://localhost:3000");
37
+ assert(c.semantics.length > 0, "an env contract must carry semantics");
38
+ });
39
+
40
+ test("allContracts: includes env, wire, type, and capability-url entries", () => {
41
+ const cats = new Set(allContracts().map((c) => c.category));
42
+ for (const cat of ["env", "wire", "type", "capability-url"] as const) {
43
+ assert(cats.has(cat), `registry must carry a ${cat} contract`);
44
+ }
45
+ });
46
+
47
+ test("detectDeclarationConflicts: a differently-named env key with equivalent semantics is a synonym", () => {
48
+ const conflicts = detectDeclarationConflicts({
49
+ category: "env",
50
+ name: "NANO_PR_EXTERNAL_BASE_URL",
51
+ semantics:
52
+ "Externally-reachable base URL agents use to reach this app; drives every plan's blackboard capability URL.",
53
+ });
54
+ assert(
55
+ conflicts.some((c) => c.kind === "synonym" && c.existingName === "NANO_WORKFORCE_BASE_URL"),
56
+ "a semantically-equivalent env key must be flagged as a synonym of the existing one",
57
+ );
58
+ });
59
+
60
+ test("detectDeclarationConflicts: the same name with different semantics is a contradiction", () => {
61
+ const conflicts = detectDeclarationConflicts({
62
+ category: "env",
63
+ name: "NANO_PR_POLL_MS",
64
+ semantics: "completely unrelated meaning about widget colours and fonts",
65
+ });
66
+ assert(
67
+ conflicts.some((c) => c.kind === "contradiction"),
68
+ "a redeclaration of an existing name with different semantics must contradict",
69
+ );
70
+ });
71
+
72
+ test("detectDeclarationConflicts: a retired synonym is flagged as rejected-synonym", () => {
73
+ const conflicts = detectDeclarationConflicts({
74
+ category: "env",
75
+ name: "NANO_PR_BASE_URL",
76
+ semantics: "the base url",
77
+ });
78
+ assertEquals(conflicts[0].kind, "rejected-synonym");
79
+ assertEquals(conflicts[0].existingName, "NANO_WORKFORCE_BASE_URL");
80
+ });
81
+
82
+ test("detectDeclarationConflicts: a genuinely new, distinct contract is clean", () => {
83
+ const conflicts = detectDeclarationConflicts({
84
+ category: "env",
85
+ name: "NANO_WIDGET_TIMEOUT",
86
+ semantics: "milliseconds a widget waits before giving up on a render",
87
+ });
88
+ assertEquals(conflicts, []);
89
+ });
90
+
91
+ test("every declared env key's registry name matches its key (no internal drift)", () => {
92
+ for (const [key, value] of Object.entries(ENV_CONTRACTS)) {
93
+ assertEquals(value.name, key, `ENV_CONTRACTS['${key}'].name must equal its key`);
94
+ }
95
+ });
96
+
97
+ test("config-family keys read via envVar() are declared (the check:contracts blind spot, PR #229)", () => {
98
+ // These are read in production through the `envVar("KEY")` helper (app/version.ts, operations/*,
99
+ // main.ts), which bypasses the typed `readEnv` path. They MUST still be declared, or they are an
100
+ // unregistered second source of truth — exactly the drift the registry exists to kill (#227).
101
+ for (const key of [
102
+ "NANO_PR_WEBHOOK_SECRET",
103
+ "NANO_AGENTIC_SECRET",
104
+ "NANO_AGENTIC",
105
+ "NANO_WORKFORCE_GIT_SHA",
106
+ ] as const) {
107
+ assert(key in ENV_CONTRACTS, `${key} must be declared in ENV_CONTRACTS`);
108
+ }
109
+ assertEquals(envContract("NANO_PR_WEBHOOK_SECRET").secret, true);
110
+ assertEquals(envContract("NANO_AGENTIC_SECRET").secret, true);
111
+ });
@@ -0,0 +1,446 @@
1
+ // nano-workforce — the durable contract registry (issue #227, ADR 0004).
2
+ //
3
+ // THE PROBLEM this exists to kill: parallel/sliced agent work keeps producing *two divergent
4
+ // representations of one contract* — an env-key synonym (the canonical `NANO_WORKFORCE_BASE_URL`
5
+ // vs. retired names like `NANO_PR_PUBLIC_BASE_URL`/`NANO_PR_BASE_URL`, #226/#223), a wire-shape
6
+ // drift (a producer emitting a legacy frame the hub no longer accepts, nano-ide #234), two type
7
+ // names for one shape — each authored independently against a mock, with the divergence only
8
+ // discovered at runtime.
9
+ //
10
+ // THE FIX: a first-class, *committed and executable* source of truth for every cross-cutting
11
+ // contract. Declare-once entries (env/config keys, wire-frame shapes, shared exported type/interface
12
+ // names, capability-URL schemes) with an owner + semantics per entry. Because the registry is code:
13
+ // - env/config keys are parsed through ONE typed schema ({@link ENV_CONTRACTS} + {@link readEnv}),
14
+ // so a duplicate or synonymous key is a compile/lint failure (`scripts/check-contracts.ts`),
15
+ // never a silent runtime fallback — the #223 cascade cannot be reintroduced;
16
+ // - a rejected synonym (an old name we deliberately retired) is recorded here, so its reappearance
17
+ // in code is a hard CI failure, not a phantom fallback;
18
+ // - the reconciliation pass (`app/contractReconcile.ts`) reads this registry alongside the whole
19
+ // blackboard and flags synonyms / contradictions / mock-vs-real skew.
20
+ //
21
+ // The registry is the DURABLE truth; the blackboard `contract` kind (app/blackboard.ts) is the
22
+ // LIVE, in-flight signal ("I am introducing / consuming contract X") so siblings in a wave see a new
23
+ // contract *before* they independently invent a synonym. Neither alone suffices.
24
+
25
+ /** The kinds of cross-cutting contract the registry coordinates. */
26
+ export type ContractCategory = "env" | "wire" | "type" | "capability-url";
27
+
28
+ /** Fields shared by every contract entry: a stable name, an owning subsystem, and human semantics. */
29
+ interface ContractBase {
30
+ /** The canonical name (env var, wire op/frame, exported type, or URL scheme id). */
31
+ readonly name: string;
32
+ /** The subsystem/module that owns this contract (where its canonical definition lives). */
33
+ readonly owner: string;
34
+ /** What the contract means — enough for a sibling to decide "is mine the same as this?". */
35
+ readonly semantics: string;
36
+ }
37
+
38
+ /** An env/config key. Parsed through the one typed schema; `default` documents the fallback. */
39
+ export interface EnvContract extends ContractBase {
40
+ readonly category: "env";
41
+ /** The documented default when the key is unset/blank (omit for a required secret). */
42
+ readonly default?: string;
43
+ /** Names we DELIBERATELY retired for this value. Their reappearance in code is a CI failure — a
44
+ * retired synonym must never come back as a silent fallback (the #223 failure mode). */
45
+ readonly rejectedSynonyms?: readonly string[];
46
+ /** True for a secret/credential whose value must never be logged or defaulted. */
47
+ readonly secret?: boolean;
48
+ }
49
+
50
+ /** A wire-frame shape that crosses a process/transport boundary (e.g. a relay control frame). */
51
+ export interface WireContract extends ContractBase {
52
+ readonly category: "wire";
53
+ /** A concise, executable-where-possible description of the frame shape both sides must share. */
54
+ readonly shape: string;
55
+ }
56
+
57
+ /** A shared exported type/interface name that more than one slice depends on. */
58
+ export interface TypeContract extends ContractBase {
59
+ readonly category: "type";
60
+ /** The module the canonical definition is exported from — the ONE import both sides must use. */
61
+ readonly module: string;
62
+ }
63
+
64
+ /** A capability-URL scheme: how a token-scoped side-channel URL is assembled. */
65
+ export interface CapabilityUrlContract extends ContractBase {
66
+ readonly category: "capability-url";
67
+ /** The URL template (documented), e.g. `<base>/app/api/hooks/blackboard?token=<token>`. */
68
+ readonly scheme: string;
69
+ }
70
+
71
+ export type Contract = EnvContract | WireContract | TypeContract | CapabilityUrlContract;
72
+
73
+ // ---------------------------------------------------------------------------------------------
74
+ // The one typed env schema. EVERY config-family env key MUST be declared here; the CI check
75
+ // (`scripts/check-contracts.ts`) fails the build if code reads a config key that is not declared,
76
+ // or reads a rejected synonym. This makes the schema the single source of truth for config keys.
77
+ // ---------------------------------------------------------------------------------------------
78
+
79
+ export const ENV_CONTRACTS = {
80
+ NANO_WORKFORCE_BASE_URL: {
81
+ category: "env",
82
+ name: "NANO_WORKFORCE_BASE_URL",
83
+ owner: "app/blackboard.ts",
84
+ semantics:
85
+ "Externally-reachable base URL agents use to reach this app (must resolve from wherever the agent runs). Drives every plan's blackboard capability URL.",
86
+ default: "http://localhost:3000",
87
+ // Retired synonyms, recorded so their reintroduction is a CI failure rather than a silent second
88
+ // name for one value: `NANO_PR_PUBLIC_BASE_URL` was coalesced into this canonical name in #226;
89
+ // `NANO_PR_BASE_URL` was a phantom fallback introduced in #53 (2dcfb8a) and cleaned up per #223.
90
+ rejectedSynonyms: ["NANO_PR_PUBLIC_BASE_URL", "NANO_PR_BASE_URL"],
91
+ },
92
+ NANO_PR_POLL_MS: {
93
+ category: "env",
94
+ name: "NANO_PR_POLL_MS",
95
+ owner: "main.ts",
96
+ semantics: "Poller cadence in milliseconds for the self-scheduling reconciliation loop.",
97
+ default: "60000",
98
+ },
99
+ NANO_PR_MAX_ROUNDS: {
100
+ category: "env",
101
+ name: "NANO_PR_MAX_ROUNDS",
102
+ owner: "app/service.ts",
103
+ semantics: "Maximum review rounds before a PR escalates.",
104
+ default: "20",
105
+ },
106
+ NANO_PR_MAX_CI_FIX_ROUNDS: {
107
+ category: "env",
108
+ name: "NANO_PR_MAX_CI_FIX_ROUNDS",
109
+ owner: "app/service.ts",
110
+ semantics: "Maximum CI-fix attempts per PR.",
111
+ default: "3",
112
+ },
113
+ NANO_PR_MAX_REBASE_ROUNDS: {
114
+ category: "env",
115
+ name: "NANO_PR_MAX_REBASE_ROUNDS",
116
+ owner: "app/service.ts",
117
+ semantics: "Maximum rebase attempts per PR.",
118
+ default: "3",
119
+ },
120
+ NANO_PR_REVIEW_WAIT_TIMEOUT: {
121
+ category: "env",
122
+ name: "NANO_PR_REVIEW_WAIT_TIMEOUT",
123
+ owner: "app/service.ts",
124
+ semantics: "How long to wait for a review before nudging/escalating (FEEL/ISO-8601 duration).",
125
+ },
126
+ NANO_PR_REVIEW_NUDGE_MINUTES: {
127
+ category: "env",
128
+ name: "NANO_PR_REVIEW_NUDGE_MINUTES",
129
+ owner: "app/service.ts",
130
+ semantics: "Minutes between review nudges.",
131
+ },
132
+ NANO_PR_AUTO_MERGE: {
133
+ category: "env",
134
+ name: "NANO_PR_AUTO_MERGE",
135
+ owner: "app/service.ts",
136
+ semantics: "Whether the app auto-merges a converged PR (1/0).",
137
+ default: "1",
138
+ },
139
+ NANO_PR_MERGE_METHOD: {
140
+ category: "env",
141
+ name: "NANO_PR_MERGE_METHOD",
142
+ owner: "app/service.ts",
143
+ semantics: "Merge method for auto-merge (squash|merge|rebase).",
144
+ default: "squash",
145
+ },
146
+ NANO_PR_MERGE_ADMIN: {
147
+ category: "env",
148
+ name: "NANO_PR_MERGE_ADMIN",
149
+ owner: "app/service.ts",
150
+ semantics: "Whether to merge with admin override (1/0).",
151
+ default: "0",
152
+ },
153
+ NANO_PR_GITHUB_TRANSPORT: {
154
+ category: "env",
155
+ name: "NANO_PR_GITHUB_TRANSPORT",
156
+ owner: "app/github.ts",
157
+ semantics: "GitHub transport selector (auto|cli|rest).",
158
+ default: "auto",
159
+ },
160
+ NANO_PR_WEBHOOK_SECRET: {
161
+ category: "env",
162
+ name: "NANO_PR_WEBHOOK_SECRET",
163
+ owner: "operations/*.ts (agentic HTTP hooks)",
164
+ semantics:
165
+ "Shared secret authenticating the agentic supply HTTP-hook operations (getAgenticSupply / getAgentInstructions / listActivePrs / getVersion / agentCompleteEscalation / revertEscalationCompletion). Also the fallback secret for NANO_AGENTIC_SECRET.",
166
+ secret: true,
167
+ },
168
+ NANO_AGENTIC_SECRET: {
169
+ category: "env",
170
+ name: "NANO_AGENTIC_SECRET",
171
+ owner: "main.ts",
172
+ semantics:
173
+ "Secret authenticating the agentic supply endpoint; falls back to NANO_PR_WEBHOOK_SECRET when unset.",
174
+ secret: true,
175
+ },
176
+ NANO_AGENTIC: {
177
+ category: "env",
178
+ name: "NANO_AGENTIC",
179
+ owner: "main.ts",
180
+ semantics:
181
+ "Feature flag for the agentic supply endpoint; a value of 0/off/false/no disables it (enabled when unset).",
182
+ },
183
+ NANO_WORKFORCE_GIT_SHA: {
184
+ category: "env",
185
+ name: "NANO_WORKFORCE_GIT_SHA",
186
+ owner: "app/version.ts",
187
+ semantics:
188
+ "Explicit git SHA override for version reporting on deploys shipped without a .git directory; version derivation reads .git when unset.",
189
+ },
190
+ NANO_AUTO_RETRO: {
191
+ category: "env",
192
+ name: "NANO_AUTO_RETRO",
193
+ owner: "app/retro.ts",
194
+ semantics: "Opt-out toggle for the epic retrospective stage (0/false disables).",
195
+ default: "1",
196
+ },
197
+ NANO_ESCALATION_SLA_TIMEOUT: {
198
+ category: "env",
199
+ name: "NANO_ESCALATION_SLA_TIMEOUT",
200
+ owner: "app/plan.ts",
201
+ semantics: "SLA timeout for an escalation user task (FEEL/ISO-8601 duration).",
202
+ },
203
+ NANO_PR_AGENT_SLA_TIMEOUT: {
204
+ category: "env",
205
+ name: "NANO_PR_AGENT_SLA_TIMEOUT",
206
+ owner: "app/service.ts",
207
+ semantics:
208
+ "SLA timeout for an agent (service) task before its boundary timer fires and the PR escalates for human attention (ISO-8601 duration). A malformed value falls back to the default.",
209
+ default: "PT2H",
210
+ },
211
+ NANO_APP_DB_URL: {
212
+ category: "env",
213
+ name: "NANO_APP_DB_URL",
214
+ owner: "nano.app.json / DataLayer",
215
+ semantics: "Connection URL for the app SQLite DataLayer.",
216
+ },
217
+ NANOBPMN_BASE_URL: {
218
+ category: "env",
219
+ name: "NANOBPMN_BASE_URL",
220
+ owner: "app/agentGuide.ts",
221
+ semantics: "Base URL of the nanobpmn engine REST API (used to derive CAMUNDA_REST_ADDRESS).",
222
+ default: "http://localhost:8080",
223
+ },
224
+ PR_REVIEW_PORT: {
225
+ category: "env",
226
+ name: "PR_REVIEW_PORT",
227
+ owner: "main.ts",
228
+ semantics: "TCP port the app HTTP server binds.",
229
+ default: "3000",
230
+ },
231
+ GITHUB_TOKEN: {
232
+ category: "env",
233
+ name: "GITHUB_TOKEN",
234
+ owner: "app/github.ts",
235
+ semantics: "GitHub API credential the app uses for all GitHub calls.",
236
+ secret: true,
237
+ },
238
+ CAMUNDA_REST_ADDRESS: {
239
+ category: "env",
240
+ name: "CAMUNDA_REST_ADDRESS",
241
+ owner: "app/agentGuide.ts",
242
+ semantics: "Explicit REST address of the engine (overrides NANOBPMN_BASE_URL derivation).",
243
+ },
244
+ CAMUNDA_TOKEN: {
245
+ category: "env",
246
+ name: "CAMUNDA_TOKEN",
247
+ owner: "app/agentGuide.ts",
248
+ semantics: "Bearer token for the engine REST API.",
249
+ secret: true,
250
+ },
251
+ CAMUNDA_TRANSPORT: {
252
+ category: "env",
253
+ name: "CAMUNDA_TRANSPORT",
254
+ owner: "app/agentGuide.ts",
255
+ semantics: "Engine transport selector.",
256
+ },
257
+ } as const satisfies Record<string, EnvContract>;
258
+
259
+ /** The set of declared config-key names — the single typed vocabulary of env keys. */
260
+ export type EnvKey = keyof typeof ENV_CONTRACTS;
261
+
262
+ /** Every declared env contract, widened to {@link EnvContract} (assignment-widening — no `as`), so
263
+ * callers can read the optional `default`/`rejectedSynonyms`/`secret` fields on any entry. */
264
+ export function envContracts(): EnvContract[] {
265
+ const list: EnvContract[] = Object.values(ENV_CONTRACTS);
266
+ return list;
267
+ }
268
+
269
+ /** The declared entry for a key, widened to {@link EnvContract} so callers can read `default`/
270
+ * `rejectedSynonyms` (the `as const satisfies` above keeps each entry's narrow literal type, on
271
+ * which those optional fields don't exist for every member). Assignment-widening — no `as` cast. */
272
+ export function envContract(key: EnvKey): EnvContract {
273
+ const entry: EnvContract = ENV_CONTRACTS[key];
274
+ return entry;
275
+ }
276
+
277
+ /** Read a declared env key through the one schema. `key` is a compile-time-checked {@link EnvKey},
278
+ * so a typo or a synonymous key (e.g. the retired `NANO_PR_BASE_URL`) is a TYPE error here — it can
279
+ * never resolve to a silent runtime fallback. Returns the trimmed value, or `undefined` when unset
280
+ * or blank/whitespace (so an explicitly-empty key never yields a malformed value). */
281
+ export function readEnv(
282
+ key: EnvKey,
283
+ env: Record<string, string | undefined> = process.env,
284
+ ): string | undefined {
285
+ const trimmed = env[key]?.trim();
286
+ return trimmed ? trimmed : undefined;
287
+ }
288
+
289
+ /** Read a declared env key, falling back to its registered `default` (then to `fallback`) when
290
+ * unset/blank. Keeps the default in ONE place — the registry entry — not scattered at call sites. */
291
+ export function readEnvOr(
292
+ key: EnvKey,
293
+ fallback = "",
294
+ env: Record<string, string | undefined> = process.env,
295
+ ): string {
296
+ return readEnv(key, env) ?? envContract(key).default ?? fallback;
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------------------------
300
+ // The non-env contracts. These are the shared shapes/types/schemes that parallel slices must
301
+ // converge on. Kept alongside the env schema so ONE registry answers "does a contract for X
302
+ // already exist?" for every category.
303
+ // ---------------------------------------------------------------------------------------------
304
+
305
+ export const WIRE_CONTRACTS = {
306
+ "relay.produce": {
307
+ category: "wire",
308
+ name: "relay.produce",
309
+ owner: "@nanobpm/agentic/relay",
310
+ semantics:
311
+ "Op-tagged relay control frame a worker terminal chunk producer emits and the hub consumes. The op-tagged shape superseded the legacy positional `{stream, offset, chunk}` frame (nano-ide #234/#236); a producer must emit the op-tagged shape or the hub rejects it as `malformed relay message payload`.",
312
+ shape: '{ op: "produce", incarnation: number, stream: string, offset: number, chunk: string }',
313
+ },
314
+ } as const satisfies Record<string, WireContract>;
315
+
316
+ export const TYPE_CONTRACTS = {
317
+ BlackboardEntry: {
318
+ category: "type",
319
+ name: "BlackboardEntry",
320
+ owner: "app/blackboard.ts",
321
+ semantics:
322
+ "The snake_case, agent-facing view of a blackboard entry — the HTTP-hook boundary shape every caller and agent consumes. Both the read and write halves import this ONE definition.",
323
+ module: "app/blackboard.ts",
324
+ },
325
+ } as const satisfies Record<string, TypeContract>;
326
+
327
+ export const CAPABILITY_URL_CONTRACTS = {
328
+ blackboard: {
329
+ category: "capability-url",
330
+ name: "blackboard",
331
+ owner: "app/blackboard.ts",
332
+ semantics:
333
+ "Per-plan blackboard side-channel. The per-plan token IS the credential; it rides the query string so the agent GET/POSTs the exact string it was handed with no header assembly. The base is `NANO_WORKFORCE_BASE_URL` (one env contract), never hardcoded.",
334
+ scheme: "<NANO_WORKFORCE_BASE_URL>/app/api/hooks/blackboard?token=<token>",
335
+ },
336
+ } as const satisfies Record<string, CapabilityUrlContract>;
337
+
338
+ /** Every contract, across all categories — the flat list the reconciliation pass and CI check walk. */
339
+ export function allContracts(): Contract[] {
340
+ return [
341
+ ...Object.values(ENV_CONTRACTS),
342
+ ...Object.values(WIRE_CONTRACTS),
343
+ ...Object.values(TYPE_CONTRACTS),
344
+ ...Object.values(CAPABILITY_URL_CONTRACTS),
345
+ ];
346
+ }
347
+
348
+ /** All names deliberately retired as synonyms of a live env contract, mapped to the canonical name
349
+ * that replaced them. A read of any synonym is a CI failure — the #223 phantom-fallback failure
350
+ * mode, guarded categorically. */
351
+ export function rejectedEnvSynonyms(): Map<string, string> {
352
+ const out = new Map<string, string>();
353
+ for (const c of envContracts()) {
354
+ for (const syn of c.rejectedSynonyms ?? []) out.set(syn, c.name);
355
+ }
356
+ return out;
357
+ }
358
+
359
+ // ---------------------------------------------------------------------------------------------
360
+ // Near-duplicate DECLARATION detection — the write-time / declare-time guard. Given a proposed new
361
+ // contract, is there an EXISTING one that is the same thing under a different name (a synonym), or a
362
+ // contradicting one (same name, different semantics/owner)? Surfaced to the writer so a duplicate is
363
+ // caught at authoring time, not at runtime.
364
+ // ---------------------------------------------------------------------------------------------
365
+
366
+ /** A near-duplicate finding between a proposed declaration and an existing registry contract. */
367
+ export interface DeclarationConflict {
368
+ /** `synonym`: same category + equivalent semantics under a different name (two names, one thing).
369
+ * `contradiction`: same name but different semantics/owner (one name, two meanings).
370
+ * `rejected-synonym`: the proposed name is a retired synonym of a live env contract. */
371
+ readonly kind: "synonym" | "contradiction" | "rejected-synonym";
372
+ readonly proposedName: string;
373
+ readonly existingName: string;
374
+ readonly detail: string;
375
+ }
376
+
377
+ /** Normalise free-text semantics to a comparable token bag: lowercased, punctuation stripped,
378
+ * short stopwords dropped. Deliberately crude — it only needs to catch "two names for one value". */
379
+ function semanticTokens(text: string): Set<string> {
380
+ const STOP = new Set([
381
+ "the", "a", "an", "of", "to", "for", "and", "or", "is", "it", "this", "that", "with",
382
+ "on", "in", "at", "by", "as", "per", "its", "so", "one", "value", "used", "use",
383
+ ]);
384
+ return new Set(
385
+ text
386
+ .toLowerCase()
387
+ .replace(/[^a-z0-9]+/g, " ")
388
+ .split(" ")
389
+ .filter((w) => w.length > 2 && !STOP.has(w)),
390
+ );
391
+ }
392
+
393
+ /** Jaccard overlap of two token bags (0..1). */
394
+ function overlap(a: Set<string>, b: Set<string>): number {
395
+ if (a.size === 0 || b.size === 0) return 0;
396
+ let inter = 0;
397
+ for (const w of a) if (b.has(w)) inter++;
398
+ return inter / (a.size + b.size - inter);
399
+ }
400
+
401
+ /** The semantics-overlap threshold above which two DIFFERENTLY-named contracts of the same category
402
+ * are flagged as probable synonyms. Tuned to be advisory (surface for a human/agent decision), not a
403
+ * hard gate. */
404
+ export const SYNONYM_THRESHOLD = 0.6;
405
+
406
+ /** Detect near-duplicate declarations of `proposed` against `existing` (defaults to the registry).
407
+ * Returns every conflict found so a writer sees synonyms AND contradictions AND rejected synonyms. */
408
+ export function detectDeclarationConflicts(
409
+ proposed: { category: ContractCategory; name: string; semantics: string },
410
+ existing: Contract[] = allContracts(),
411
+ ): DeclarationConflict[] {
412
+ const out: DeclarationConflict[] = [];
413
+ const rejected = rejectedEnvSynonyms();
414
+ if (proposed.category === "env" && rejected.has(proposed.name)) {
415
+ out.push({
416
+ kind: "rejected-synonym",
417
+ proposedName: proposed.name,
418
+ existingName: rejected.get(proposed.name) ?? "",
419
+ detail: `'${proposed.name}' is a retired synonym of '${rejected.get(proposed.name)}'; reuse the canonical key, do not reintroduce the fallback.`,
420
+ });
421
+ }
422
+ const proposedTokens = semanticTokens(proposed.semantics);
423
+ for (const c of existing) {
424
+ if (c.name === proposed.name) {
425
+ if (c.category === proposed.category && overlap(proposedTokens, semanticTokens(c.semantics)) < SYNONYM_THRESHOLD) {
426
+ out.push({
427
+ kind: "contradiction",
428
+ proposedName: proposed.name,
429
+ existingName: c.name,
430
+ detail: `'${proposed.name}' already exists (owner ${c.owner}) with different semantics; reconcile before redeclaring.`,
431
+ });
432
+ }
433
+ continue;
434
+ }
435
+ if (c.category !== proposed.category) continue;
436
+ if (overlap(proposedTokens, semanticTokens(c.semantics)) >= SYNONYM_THRESHOLD) {
437
+ out.push({
438
+ kind: "synonym",
439
+ proposedName: proposed.name,
440
+ existingName: c.name,
441
+ detail: `'${proposed.name}' looks semantically equivalent to existing '${c.name}' (owner ${c.owner}); reuse it instead of authoring a synonym.`,
442
+ });
443
+ }
444
+ }
445
+ return out;
446
+ }
@@ -0,0 +1,108 @@
1
+ # ADR 0004 — Coordinate shared contracts through a durable registry + a blackboard signal + a reconciliation pass
2
+
3
+ Status: **Proposed.**
4
+ Date: 2026-08-14.
5
+
6
+ > **Scope note.** A **nano-workforce-local** ADR — it governs how *this app* coordinates cross-cutting
7
+ > contracts across parallel/sliced agent work. Platform-wide ADRs live in
8
+ > `Magikcraft/nano-bpm/docs/adr` (referenced by number + repo). Continues nano-workforce's series after
9
+ > ADR 0001–0003.
10
+
11
+ Relates to:
12
+ issue **#227** (the framing + spec this ADR records the decision for),
13
+ issue **#223** (the concrete `NANO_PR_*` env-key synonym cleanup — this ADR makes its cascade impossible
14
+ to reintroduce),
15
+ nano-ide **#234 / #236** (the relay producer/hub wire-shape drift — the same failure mode in a wire
16
+ contract),
17
+ issues **#214** (real-entrypoint integration test) and **#217** (retro verifies acceptance) — the
18
+ after-the-fact verification this ADR complements with an authoring-time preventive,
19
+ and in this repo: `app/contracts.ts` (the registry + typed env schema + declaration-conflict
20
+ detection), `app/contractReconcile.ts` (the reconciliation pass), `app/blackboard.ts`
21
+ (the `contract` kind + coordination brief), `operations/appendBlackboard.ts`,
22
+ `scripts/check-contracts.ts` (the CI gate) and `scripts/reconcile-contracts.ts` (the advisory pass).
23
+
24
+ ## Context
25
+
26
+ Parallel and sliced agent work keeps producing **two divergent representations of a single contract**,
27
+ with no mechanism that binds them at authoring time. Two live examples of the *same* failure mode:
28
+
29
+ 1. **Config-key synonym** — `publicBaseUrl()` read `NANO_PR_PUBLIC_BASE_URL` and fell back to a phantom
30
+ `NANO_PR_BASE_URL` (introduced in the same commit, #53/2dcfb8a; nothing set it, the unit test even
31
+ exercised the wrong name). Two names for one value (#223). (`NANO_PR_PUBLIC_BASE_URL` was itself
32
+ later coalesced into the canonical `NANO_WORKFORCE_BASE_URL` in #226; both retired names are now
33
+ registered rejected synonyms.)
34
+ 2. **Wire-shape drift** — a relay *producer* kept emitting the legacy `{stream, offset, chunk}` frame
35
+ while the *hub* had adopted an op-tagged `{op:"produce", …}` sub-protocol; the hub rejected every
36
+ worker terminal chunk. Every isolated slice test was green because each side tested against its own
37
+ fake (nano-ide #234/#236).
38
+
39
+ The through-line: **a contract (an env key, a wire shape, a shared type name, a capability-URL scheme) is
40
+ authored independently by parallel workers, each against a mock or a local assumption, and the divergence
41
+ is discovered only at runtime.** Existing issues are *after-the-fact verification* (#214, #217) or fix a
42
+ *single symptom* (#223). None coordinate the shared contract surface **while** siblings are authoring it,
43
+ nor reconcile the accumulated blackboard for duplicate/synonymous declarations.
44
+
45
+ ## Decision
46
+
47
+ Coordinate shared contracts with **three complementary mechanisms**, at deliberately different lifetimes.
48
+
49
+ ### 1. A durable, executable contract registry (source of truth)
50
+
51
+ `app/contracts.ts` is a committed, reviewed, first-class registry of cross-cutting contracts —
52
+ env/config keys, wire-frame shapes, shared exported type/interface names, and capability-URL schemes —
53
+ each with an **owner** + **semantics**. It is *executable where possible*:
54
+
55
+ - **Env/config keys are parsed through ONE typed schema** (`ENV_CONTRACTS` + `readEnv`/`readEnvOr`).
56
+ `readEnv(key)` takes a compile-time-checked `EnvKey`, so a synonymous or misspelled key is a **type
57
+ error**, never a silent runtime fallback. Each entry may record `rejectedSynonyms` — names we
58
+ deliberately retired (e.g. `NANO_PR_BASE_URL`); their reappearance in code is a **CI failure**
59
+ (`scripts/check-contracts.ts`). The base-URL boundary is the migrated reference: `publicBaseUrl()` now
60
+ reads the schema and the phantom fallback is gone, so the #223 cascade **cannot be reintroduced**.
61
+ - **Wire/type/capability-URL contracts** are declared alongside so ONE registry answers "does a contract
62
+ for X already exist?" for every category. The blackboard's `BlackboardEntry` snake_case shape and the
63
+ blackboard capability-URL scheme are the seed entries.
64
+
65
+ ### 2. A blackboard `contract` kind (the live, in-flight signal)
66
+
67
+ `app/blackboard.ts` adds an app-recognised `contract` kind, **derived** from the shared store's kinds
68
+ (`APP_BLACKBOARD_KINDS = [...BLACKBOARD_KINDS, "contract"]`) so the two never drift. An agent posts a
69
+ `contract` entry ("I am introducing / consuming env key / wire op / type X") **as soon as it is true**,
70
+ so siblings in a wave see a new contract *before* they independently invent a synonym. The coordination
71
+ brief (`renderCoordinationBrief`) now requires: before introducing a new env key / wire field / shared
72
+ type, **consult the registry and the blackboard `contract` entries**; if a semantically-equivalent one
73
+ exists, **reuse it**; otherwise declare it in both. The registry is the durable truth; the blackboard is
74
+ the live signal. **Neither alone suffices.**
75
+
76
+ ### 3. A de-duplication / reconciliation pass
77
+
78
+ - **Write-time.** A `contract` POST runs near-duplicate *declaration* detection
79
+ (`detectDeclarationConflicts`, surfaced through `operations/appendBlackboard.ts` alongside the existing
80
+ `file-claim` conflict reporting): it flags a **synonym** (same semantics, different name), a
81
+ **contradiction** (same name, different meaning), or a **rejected synonym**, so the writer reconciles
82
+ at authoring time.
83
+ - **Reconciliation pass** (`app/contractReconcile.ts`, a sibling to the L2 retro). It reads the *whole*
84
+ blackboard + the registry and flags synonyms, contradictions, and **mock-vs-real skew** (a contract
85
+ signalled in-flight that never landed in the durable registry). Advisory: it emits a report as an
86
+ escalation / merge candidate (`npm run reconcile:contracts`) rather than silently accumulating. The
87
+ mechanically-enforceable, registry-only half is the hard CI gate `npm run check:contracts`.
88
+
89
+ ## Consequences
90
+
91
+ - The #223 failure mode is **categorically** closed for env keys: the synonym is a compile error and a
92
+ rejected synonym is a CI failure — not a runtime fallback.
93
+ - A new cross-cutting contract has ONE place to be declared and ONE way to be read; the coordination brief
94
+ routes agents through it, and the reconciliation pass catches what slips through.
95
+ - A CI gate (`check:contracts`) and an advisory pass (`reconcile:contracts`) make the coordination
96
+ observable rather than a hope.
97
+
98
+ ## Open questions / follow-ups
99
+
100
+ - **Promote the `contract` kind into `@nanobpm/agentic/blackboard`.** The shared store's normaliser coerces
101
+ unknown kinds to `note`; the app persists `contract` by patching the row after the store's insert
102
+ (reusing the store's append so the idempotency logic is not duplicated). The durable home for this kind
103
+ is the shared package — a follow-up version bump removes the app-local patch.
104
+ - **Migrate the remaining env call sites** (`app/service.ts`, `app/plan.ts`, `main.ts`, …) onto
105
+ `readEnv`/`readEnvOr`. The registry already declares every config key and the CI gate enforces
106
+ declaration; routing every read through the typed schema is a mechanical follow-up.
107
+ - **Wire the reconciliation pass into the retro process** as an automatic cross-epic step, rather than an
108
+ operator-run script.