@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.
@@ -54,6 +54,13 @@ jobs:
54
54
  - name: Check migration prefixes (no collisions)
55
55
  run: npm run check:migrations
56
56
 
57
+ # Contract-registry gate (issue #227): every config-family env key must be declared in the ONE
58
+ # typed schema (app/contracts.ts), no retired synonym (e.g. NANO_PR_BASE_URL, #223) may reappear
59
+ # in code, and the registry must reconcile against itself (no synonyms/contradictions). This is
60
+ # what makes a duplicate/synonymous contract a build failure instead of a silent runtime fallback.
61
+ - name: Check contract registry (no synonyms / undeclared keys)
62
+ run: npm run check:contracts
63
+
57
64
  # Runs the full *.test.ts suite under Node's built-in test runner (node:test), which strips
58
65
  # TypeScript types on the fly (Node >= 22.6) — no build step.
59
66
  - name: Test (Node)
package/AGENTS.md CHANGED
@@ -59,6 +59,29 @@ Before starting planned work, check for an existing issue or PR. If one is
59
59
  already in progress, stop and flag it with a link. Otherwise create and claim an
60
60
  issue before writing code.
61
61
 
62
+ ## Shared contracts: one registry, one typed env schema (issue #227, ADR 0004)
63
+
64
+ Parallel/sliced work keeps producing **two divergent representations of one contract** — an env-key
65
+ synonym (the canonical `NANO_WORKFORCE_BASE_URL` vs. retired names like `NANO_PR_PUBLIC_BASE_URL`/its
66
+ phantom `NANO_PR_BASE_URL` fallback, #226/#223), a wire-shape drift (nano-ide #234), two type names
67
+ for one shape — each authored against a mock, discovered only at runtime. Prevent it at authoring
68
+ time:
69
+
70
+ - **Consult the durable registry FIRST — `app/contracts.ts`.** Before introducing a new **env/config
71
+ key**, **wire-frame shape**, **shared exported type**, or **capability-URL scheme**, check the
72
+ registry. If a semantically-equivalent contract exists, **reuse it**; otherwise declare it there
73
+ (owner + semantics per entry).
74
+ - **Env keys go through the ONE typed schema** (`ENV_CONTRACTS` + `readEnv`/`readEnvOr`). Every
75
+ config-family key (`NANO_*`, `NANOBPMN_*`, `CAMUNDA_*`, `PR_REVIEW_*`) MUST be declared; a synonym or
76
+ an undeclared key is a **CI failure** (`npm run check:contracts`). A **retired synonym** (e.g.
77
+ `NANO_PR_BASE_URL`) reappearing in code is a hard failure — never reintroduce a phantom fallback.
78
+ - **Signal in-flight on the blackboard.** When introducing/consuming a cross-cutting contract, POST a
79
+ `kind:"contract"` entry (`dedupe_key` as `<category>:<name>`, e.g. `env:NANO_X`) so siblings see it
80
+ before they reinvent it. The write-time guard reports near-duplicate-declaration `contractConflicts`.
81
+ - **Reconcile.** `npm run reconcile:contracts` reads the whole blackboard + registry and reports
82
+ synonyms / contradictions / mock-vs-real skew (advisory). `npm run check:contracts` is the hard,
83
+ registry-only gate (also in CI).
84
+
62
85
  ## BPMN: author the semantic model, generate the diagram
63
86
 
64
87
  **The `.bpmn` files under `resources/processes/` are hand-authored semantic
@@ -226,11 +249,12 @@ npm run check # urban check (manifest va
226
249
  npm run layout:check # BPMN diagram freshness (no drift)
227
250
  npm run check:prompts # agent-prompt template resolution
228
251
  npm run check:migrations # migration prefixes (no collisions)
252
+ npm run check:contracts # contract registry (no synonyms / undeclared env keys)
229
253
  npm test # unit tests (node --test)
230
254
  ```
231
255
 
232
256
  CI (`.github/workflows/ci.yml`) gates lint, typecheck, `urban check`, `layout:check`,
233
- the prompt check, the migration-prefix check, and the Node test suite. Run `npm run layout <file.bpmn>` after
257
+ the prompt check, the migration-prefix check, the contract-registry check, and the Node test suite. Run `npm run layout <file.bpmn>` after
234
258
  any BPMN flow change and commit the regenerated diagram — the `layout:check`
235
259
  gate fails the build otherwise.
236
260
 
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [0.72.0](https://github.com/nanobpm/nano-workforce/compare/v0.71.0...v0.72.0) (2026-08-15)
2
+
3
+
4
+ ### Features
5
+
6
+ * coordinate shared contracts via a durable registry + blackboard signal + reconciliation pass ([#229](https://github.com/nanobpm/nano-workforce/issues/229)) ([2c250b5](https://github.com/nanobpm/nano-workforce/commit/2c250b51596e1a062c4e09c6aef638010abea793)), closes [#223](https://github.com/nanobpm/nano-workforce/issues/223) [#234](https://github.com/nanobpm/nano-workforce/issues/234) [214/#217](https://github.com/nanobpm/nano-workforce/issues/217) [#223](https://github.com/nanobpm/nano-workforce/issues/223) [#227](https://github.com/nanobpm/nano-workforce/issues/227) [#227](https://github.com/nanobpm/nano-workforce/issues/227)
7
+
1
8
  # [0.71.0](https://github.com/nanobpm/nano-workforce/compare/v0.70.2...v0.71.0) (2026-08-15)
2
9
 
3
10
 
@@ -8,20 +8,49 @@ import { test } from "node:test";
8
8
  import { assert, assertEquals, assertStringIncludes } from "#test-assert";
9
9
  import { memBlackboardData } from "../test/blackboardDb.ts";
10
10
  import {
11
+ APP_BLACKBOARD_KINDS,
11
12
  appendEntry,
12
13
  blackboardUrl,
14
+ detectContractDeclarationConflicts,
13
15
  detectFileClaimConflicts,
14
16
  isUniqueViolation,
15
17
  mintBlackboardToken,
18
+ normalizeAppKind,
16
19
  normalizeKind,
20
+ parseContractRef,
17
21
  planKeyForToken,
18
22
  planKeyForTokenSync,
19
23
  publicBaseUrl,
20
24
  readBlackboard,
21
25
  readBlackboardPage,
22
26
  renderCoordinationBrief,
27
+ toSafeRowId,
23
28
  } from "./blackboard.ts";
24
29
 
30
+ test("toSafeRowId: passes through safe numbers/bigints, throws above MAX_SAFE_INTEGER (PR #229)", () => {
31
+ assertEquals(toSafeRowId(42), 42);
32
+ assertEquals(toSafeRowId(0), 0);
33
+ // A bigint within the safe range narrows to an identical number.
34
+ assertEquals(toSafeRowId(9007199254740991n), Number.MAX_SAFE_INTEGER);
35
+ // Beyond 2^53 a Number(...) coercion would silently lose precision as a `since` cursor — fail loud.
36
+ let threw = false;
37
+ try {
38
+ toSafeRowId(9007199254740993n);
39
+ } catch {
40
+ threw = true;
41
+ }
42
+ assert(threw, "a bigint id above MAX_SAFE_INTEGER must throw, not silently lose precision");
43
+ // The number branch guards the same boundary: a driver-returned non-safe-integer must also fail
44
+ // loud rather than pass through as a lossy cursor (#229).
45
+ let threwNum = false;
46
+ try {
47
+ toSafeRowId(Number.MAX_SAFE_INTEGER + 2);
48
+ } catch {
49
+ threwNum = true;
50
+ }
51
+ assert(threwNum, "a number id that is not a safe integer must throw, not silently pass through");
52
+ });
53
+
25
54
  test("mintBlackboardToken: URL-safe, unguessable, unique", () => {
26
55
  const a = mintBlackboardToken();
27
56
  const b = mintBlackboardToken();
@@ -36,7 +65,7 @@ test("publicBaseUrl: honours the env override and trims a trailing slash", () =>
36
65
  });
37
66
 
38
67
  test("publicBaseUrl: a blank/whitespace override falls back instead of yielding a bad URL", () => {
39
- // Explicit override args bypass the `process.env.NANO_WORKFORCE_BASE_URL` default, so this test
68
+ // Explicit override args bypass the `NANO_WORKFORCE_BASE_URL` default, so this test
40
69
  // needs no env manipulation — the env-read path is covered by the dedicated test below.
41
70
  assertEquals(publicBaseUrl(""), "http://localhost:3000");
42
71
  assertEquals(publicBaseUrl(" "), "http://localhost:3000");
@@ -71,6 +100,30 @@ test("normalizeKind: valid passes through, anything else becomes note", () => {
71
100
  assertEquals(normalizeKind(undefined), "note");
72
101
  });
73
102
 
103
+ test("normalizeAppKind: passes contract through (the store's own normaliser would coerce it to note)", () => {
104
+ assertEquals(normalizeAppKind("contract"), "contract");
105
+ assertEquals(normalizeAppKind("file-claim"), "file-claim");
106
+ assertEquals(normalizeAppKind("bogus"), "note");
107
+ assert(APP_BLACKBOARD_KINDS.includes("contract"), "contract is an app-recognised kind");
108
+ assert(APP_BLACKBOARD_KINDS.includes("note"), "app kinds are a superset of the store's kinds");
109
+ });
110
+
111
+ test("parseContractRef: parses the <category>:<name> dedupe_key convention", () => {
112
+ assertEquals(parseContractRef("env:NANO_X"), { category: "env", name: "NANO_X" });
113
+ assertEquals(parseContractRef("type:BlackboardEntry"), { category: "type", name: "BlackboardEntry" });
114
+ assertEquals(parseContractRef("bogus:X"), undefined);
115
+ assertEquals(parseContractRef("nocolon"), undefined);
116
+ assertEquals(parseContractRef(undefined), undefined);
117
+ });
118
+
119
+ test("detectContractDeclarationConflicts: flags a retired synonym; clean for a genuinely new key", () => {
120
+ const rejected = detectContractDeclarationConflicts({ dedupe_key: "env:NANO_PR_BASE_URL", body: "base url" });
121
+ assertEquals(rejected[0]?.kind, "rejected-synonym");
122
+ assertEquals(detectContractDeclarationConflicts({ dedupe_key: "env:NANO_TOTALLY_NEW", body: "an unrelated brand new thing about caching" }), []);
123
+ // No convention → nothing to reconcile against.
124
+ assertEquals(detectContractDeclarationConflicts({ body: "freeform" }), []);
125
+ });
126
+
74
127
  test("renderCoordinationBrief: leads with a separator and teaches the protocol + URL", () => {
75
128
  const url = "https://h/app/api/hooks/blackboard?token=abc";
76
129
  const brief = renderCoordinationBrief(url);
@@ -89,6 +142,13 @@ test("renderCoordinationBrief: leads with a separator and teaches the protocol +
89
142
  // Learnings: teaches reading prior gotchas and posting a reusable `learning`.
90
143
  assertStringIncludes(brief, "learning");
91
144
  assertStringIncludes(brief, "Share what you learn");
145
+ // Contracts (#227): teaches consulting the registry + blackboard and posting a `contract` entry.
146
+ assertStringIncludes(brief, "contract");
147
+ assertStringIncludes(brief, "app/contracts.ts");
148
+ assertStringIncludes(brief, "kind\":\"contract\"");
149
+ // The contract-POST write-time guard surfaces overlaps via `contractConflicts` (a `file-claim`
150
+ // POST uses the separate `conflicts` array); the brief must name the right field.
151
+ assertStringIncludes(brief, "contractConflicts");
92
152
  });
93
153
 
94
154
  test("planKeyForToken: resolves a token to its plan, undefined otherwise (async + sync agree)", async () => {
@@ -166,6 +226,20 @@ test("appendEntry: a blank body is rejected", async () => {
166
226
  assert(threw, "blank body must throw");
167
227
  });
168
228
 
229
+ test("appendEntry: a contract append patches the kind even when it collapses onto an existing row (deterministic round-trip)", async () => {
230
+ const { data } = memBlackboardData();
231
+ // A prior append under this dedupe_key stored kind `note` (the store's default for an unknown kind).
232
+ const first = await appendEntry(data, "p", { author_task: "t", body: "seed", kind: "note", dedupe_key: "env:NANO_X" });
233
+ assertEquals(first.inserted, true);
234
+ // A later `contract` append with the SAME dedupe_key is a no-op insert (`inserted: false`) — but it
235
+ // must still leave the row readable as `contract`, not lingering as `note`.
236
+ const again = await appendEntry(data, "p", { author_task: "t", body: "seed", kind: "contract", dedupe_key: "env:NANO_X" });
237
+ assertEquals(again.inserted, false, "same dedupe_key is a no-op insert");
238
+ const entries = await readBlackboard(data, "p");
239
+ assertEquals(entries.length, 1);
240
+ assertEquals(entries[0].kind, "contract", "the contract kind is patched deterministically regardless of res.inserted");
241
+ });
242
+
169
243
  test("readBlackboard: since returns only newer entries (incremental poll)", async () => {
170
244
  const { data } = memBlackboardData();
171
245
  await appendEntry(data, "p", { body: "one" });
package/app/blackboard.ts CHANGED
@@ -28,8 +28,20 @@
28
28
  // handle (`data.source().db`) — the same physical database the record gateway (`data.table`) uses,
29
29
  // so the HTTP hook and the agentic channel share one connection and one table.
30
30
 
31
- import { BlackboardStore, type SqliteDb } from "@nanobpm/agentic/blackboard";
31
+ import {
32
+ BLACKBOARD_TABLE,
33
+ BlackboardStore,
34
+ normalizeKind as normalizeStoreKind,
35
+ type SqliteDb,
36
+ BLACKBOARD_KINDS as STORE_KINDS,
37
+ } from "@nanobpm/agentic/blackboard";
32
38
  import type { DataLayer } from "@nanobpm/urban";
39
+ import {
40
+ type ContractCategory,
41
+ type DeclarationConflict,
42
+ detectDeclarationConflicts,
43
+ readEnvOr,
44
+ } from "./contracts.ts";
33
45
 
34
46
  // Re-export the storage vocabulary from the shared package so there is ONE canonical definition of
35
47
  // the kinds, the kind-normaliser, and the unique-violation predicate — the app never keeps a
@@ -37,6 +49,23 @@ import type { DataLayer } from "@nanobpm/urban";
37
49
  export type { BlackboardKind } from "@nanobpm/agentic/blackboard";
38
50
  export { BLACKBOARD_KINDS, isUniqueViolation, normalizeKind } from "@nanobpm/agentic/blackboard";
39
51
 
52
+
53
+ /** The app-recognised blackboard kinds: the shared store's kinds PLUS `contract` — the live,
54
+ * in-flight coordination signal introduced by issue #227 ("I am introducing / consuming contract
55
+ * X"). It is DERIVED from the store's set (spread, never re-typed), so the two never drift; `contract`
56
+ * is the only app-local addition, until the shared package promotes it. The durable source of truth
57
+ * for a contract is the registry (`app/contracts.ts`); this kind is the real-time heads-up that lets
58
+ * siblings in a wave see a new contract before they independently invent a synonym. */
59
+ export const APP_BLACKBOARD_KINDS = [...STORE_KINDS, "contract"] as const;
60
+ export type AppBlackboardKind = (typeof APP_BLACKBOARD_KINDS)[number];
61
+
62
+ /** The app-side kind normaliser: passes `contract` through (the store's own normaliser would coerce
63
+ * it to `note`), and defers to the shared normaliser for every other value so unknown kinds still
64
+ * default to `note`. ONE place decides the app's kind vocabulary. */
65
+ export function normalizeAppKind(kind: unknown): AppBlackboardKind {
66
+ return kind === "contract" ? "contract" : normalizeStoreKind(kind);
67
+ }
68
+
40
69
  /** The parsed, agent-facing view of an entry (files decoded to an array). Snake_case is the
41
70
  * app/HTTP-hook boundary contract every existing caller and agent already consumes. */
42
71
  export interface BlackboardEntry {
@@ -69,15 +98,17 @@ export function mintBlackboardToken(): string {
69
98
  }
70
99
 
71
100
  /** The externally-reachable base URL agents use to reach this app. Must resolve from WHEREVER the
72
- * agent runs (co-located or remote/containerised), so it is configured, never hardcoded. */
73
- export function publicBaseUrl(env: string | undefined = process.env.NANO_WORKFORCE_BASE_URL): string {
74
- // Skip the override if it is unset OR blank/whitespace, so an explicitly-set-but-empty
75
- // NANO_WORKFORCE_BASE_URL can't yield a malformed capability URL.
76
- const base =
77
- [env, "http://localhost:3000"]
78
- .map((v) => v?.trim())
79
- .find((v): v is string => Boolean(v)) ?? "http://localhost:3000";
80
- return base.replace(/\/+$/, "");
101
+ * agent runs (co-located or remote/containerised), so it is configured, never hardcoded. Read
102
+ * through the ONE typed env schema ({@link readEnvOr} over `NANO_WORKFORCE_BASE_URL`) there is no
103
+ * second name for this value. The retired synonyms `NANO_PR_PUBLIC_BASE_URL` (coalesced into the
104
+ * canonical name in #226) and the phantom `NANO_PR_BASE_URL` fallback (introduced in #53, cleaned
105
+ * up per #223) are recorded as rejected synonyms in `app/contracts.ts`, so neither can be
106
+ * reintroduced as a silent runtime fallback. */
107
+ export function publicBaseUrl(base: string = readEnvOr("NANO_WORKFORCE_BASE_URL")): string {
108
+ // Skip a blank/whitespace value so an explicitly-set-but-empty NANO_WORKFORCE_BASE_URL can't yield
109
+ // a malformed capability URL; fall back to the schema-declared default.
110
+ const resolved = base.trim() || readEnvOr("NANO_WORKFORCE_BASE_URL");
111
+ return resolved.replace(/\/+$/, "");
81
112
  }
82
113
 
83
114
  /** The capability URL for a plan's blackboard: the token rides the query string, so the agent can
@@ -122,7 +153,9 @@ from re-discovering it the hard way.
122
153
 
123
154
  \`kind\` is one of: \`file-claim\` (you now edit a file outside your original slice),
124
155
  \`constraint-change\` (you discovered a constraint that changes another task's direction),
125
- \`scope-change\` (your contract/scope shifted), \`learning\` (see below), or \`note\`. Set
156
+ \`scope-change\` (your contract/scope shifted), \`contract\` (you are introducing/consuming a shared
157
+ env key / wire shape / type / capability-URL scheme — see below), \`learning\` (see below), or
158
+ \`note\`. Set
126
159
  \`author_task\` to your task id. If a retry might make you re-POST the same fact, include a stable
127
160
  \`"dedupe_key"\` so it collapses to one entry.
128
161
 
@@ -139,6 +172,29 @@ one entry across retries and siblings:
139
172
 
140
173
  A \`learning\` is advisory and never blocks anyone; it is knowledge for the fleet, not a claim on a file.
141
174
 
175
+ **Coordinate SHARED CONTRACTS before you invent one (issue #227).** A *contract* is anything two
176
+ slices must agree on but that each of you could author independently against a mock: an **env/config
177
+ key**, a **wire-frame shape** (a message/relay payload), a **shared exported type/interface name**, or
178
+ a **capability-URL scheme**. Divergent copies of one contract (an env-key synonym, a producer/hub
179
+ wire-shape drift) are only discovered at runtime, after both sides are green against their own mock.
180
+ Prevent it:
181
+
182
+ - **Consult the durable contract registry FIRST** (\`app/contracts.ts\` — env keys go through the ONE
183
+ typed \`ENV_CONTRACTS\` schema; wire/type/capability-URL contracts are declared alongside). If a
184
+ semantically-equivalent contract already exists, **reuse it** (import the shared type, read the
185
+ existing env key) — never author a synonym. A retired synonym (e.g. \`NANO_PR_BASE_URL\`) is a hard
186
+ CI failure, not a fallback.
187
+ - **Consult the blackboard \`contract\` entries** below for what siblings are introducing *right now*.
188
+ - **When you introduce OR consume a cross-cutting contract, POST a \`contract\` entry** so siblings see
189
+ it before they reinvent it, and add the durable declaration to \`app/contracts.ts\`:
190
+
191
+ curl -s -X POST "${url}" -H 'content-type: application/json' \\
192
+ -d '{"author_task":"<your-task-id>","kind":"contract","dedupe_key":"env:NANO_X","body":"introducing env key NANO_X — <owner> — <semantics + default>"}'
193
+
194
+ The write-time guard reports a \`contractConflicts\` array on a \`contract\` POST when your declaration looks
195
+ like a synonym of, or contradicts, an existing contract — reconcile before proceeding. (A \`file-claim\`
196
+ POST reports its own overlaps in a separate \`conflicts\` array — see below.)
197
+
142
198
  **Stay in sync while you work (this matters most while siblings run in parallel).** The GET
143
199
  response includes a \`"cursor"\`. Re-read incrementally — before you start each new file, and at
144
200
  least every few minutes on long tasks — passing the last cursor back as \`since\` so you fetch only
@@ -280,15 +336,46 @@ export async function detectFileClaimConflicts(
280
336
  }));
281
337
  }
282
338
 
339
+ /** Narrow a SQLite row id (`number | bigint`) to a JS number, failing loudly if it exceeds the
340
+ * safe-integer range. Blackboard rowids are monotonic and stay far below 2^53 in practice, but the
341
+ * driver types the id as `number | bigint`; coercing a huge bigint via `Number(...)` would silently
342
+ * lose precision and corrupt the id where it is used as a `since` cursor. We narrow ONCE here so the
343
+ * union never escapes `appendEntry` and no caller re-coerces. Both branches guard the safe-integer
344
+ * boundary: a `bigint` above `MAX_SAFE_INTEGER`, or a driver-returned `number` that is somehow not a
345
+ * safe integer, both throw rather than silently yield a lossy cursor. */
346
+ export function toSafeRowId(id: number | bigint): number {
347
+ if (typeof id === "bigint") {
348
+ if (id > BigInt(Number.MAX_SAFE_INTEGER)) {
349
+ throw new Error(
350
+ `blackboard row id ${id} exceeds Number.MAX_SAFE_INTEGER; unsafe to narrow to a JS number cursor`,
351
+ );
352
+ }
353
+ return Number(id);
354
+ }
355
+ if (!Number.isSafeInteger(id)) {
356
+ throw new Error(
357
+ `blackboard row id ${id} is not a safe integer; unsafe to use as a JS number cursor`,
358
+ );
359
+ }
360
+ return id;
361
+ }
362
+
283
363
  /** Append an entry, idempotently. A blank `body` is rejected. When a `dedupe_key` is supplied and
284
364
  * an entry already exists for it on this plan, the write is a no-op and the existing id is
285
- * returned (`inserted: false`) — so an engine job retry re-POSTing the same fact never duplicates. */
365
+ * returned (`inserted: false`) — so an engine job retry re-POSTing the same fact never duplicates.
366
+ *
367
+ * The `contract` kind (issue #227) is the app-local addition: the shared store's normaliser would
368
+ * coerce it to `note`, so after the store's insert (which owns all the idempotency/dedupe semantics)
369
+ * we patch the ONE row's kind column back to `contract`. We reuse the store's insert rather than
370
+ * hand-writing a parallel one, so there is no drift in the append logic — only the kind vocabulary is
371
+ * app-extended. */
286
372
  export async function appendEntry(
287
373
  data: DataLayer,
288
374
  planKey: string,
289
375
  input: BlackboardInput,
290
- ): Promise<{ inserted: boolean; id: number | bigint }> {
291
- return storeFor(data).append(planKey, {
376
+ ): Promise<{ inserted: boolean; id: number }> {
377
+ const appKind = normalizeAppKind(input.kind);
378
+ const res = storeFor(data).append(planKey, {
292
379
  authorTask: input.author_task,
293
380
  kind: input.kind,
294
381
  files: input.files,
@@ -296,4 +383,47 @@ export async function appendEntry(
296
383
  wave: input.wave,
297
384
  dedupeKey: input.dedupe_key,
298
385
  });
386
+ if (appKind === "contract") {
387
+ // The store defaulted `kind` to `note` (its normaliser doesn't know `contract`); restore it on
388
+ // the resolved row. Patch on EVERY `contract` append — not only when `res.inserted` — so an
389
+ // idempotent retry (or a race) that resolves to an existing id (`inserted: false`), or a row
390
+ // first inserted under a different kind for the same `dedupe_key`, still reads back as `contract`
391
+ // rather than lingering as `note`. The UPDATE is idempotent on an already-`contract` row.
392
+ // Bind `res.id` as-is (it is `number | bigint`) — coercing a bigint id via `Number(...)` could
393
+ // lose precision above `Number.MAX_SAFE_INTEGER` and patch the wrong row; SQLite binds bigint natively.
394
+ data.source().db.run(`UPDATE ${BLACKBOARD_TABLE} SET kind = 'contract' WHERE id = ?`, [res.id]);
395
+ }
396
+ return { inserted: res.inserted, id: toSafeRowId(res.id) };
397
+ }
398
+
399
+ /** Parse a `contract` entry's `dedupe_key` convention `<category>:<name>` (e.g. `env:NANO_X`,
400
+ * `type:BlackboardEntry`). Returns the category+name when it parses, else undefined. */
401
+ export function parseContractRef(
402
+ dedupeKey: string | undefined,
403
+ ): { category: ContractCategory; name: string } | undefined {
404
+ if (!dedupeKey) return undefined;
405
+ const idx = dedupeKey.indexOf(":");
406
+ if (idx <= 0) return undefined;
407
+ const category = dedupeKey.slice(0, idx).trim();
408
+ const name = dedupeKey.slice(idx + 1).trim();
409
+ if (!name) return undefined;
410
+ if (category === "env" || category === "wire" || category === "type" || category === "capability-url") {
411
+ return { category, name };
412
+ }
413
+ return undefined;
414
+ }
415
+
416
+ /** Write-time near-duplicate DECLARATION detection for a `contract` POST (issue #227, AC4). Extends
417
+ * the store's conflict reporting (`file-claim`) to the declaration surface: given a proposed contract
418
+ * (its `<category>:<name>` from `dedupe_key`, its semantics from `body`), report any registry entry
419
+ * that is a synonym (same thing, different name), a contradiction (same name, different meaning), or a
420
+ * rejected synonym (a retired name). Advisory — surfaced to the writer, never a lock. Returns `[]`
421
+ * when the entry doesn't carry the `<category>:<name>` convention (nothing to reconcile against). */
422
+ export function detectContractDeclarationConflicts(opts: {
423
+ dedupe_key?: string;
424
+ body: string;
425
+ }): DeclarationConflict[] {
426
+ const ref = parseContractRef(opts.dedupe_key);
427
+ if (!ref) return [];
428
+ return detectDeclarationConflicts({ category: ref.category, name: ref.name, semantics: opts.body });
299
429
  }
@@ -0,0 +1,94 @@
1
+ // Unit tests for the contract reconciliation pass (issue #227, ADR 0004).
2
+ import { test } from "node:test";
3
+ import { assert, assertEquals } from "#test-assert";
4
+ import type { Contract } from "./contracts.ts";
5
+ import {
6
+ formatReconciliationReport,
7
+ reconcileContracts,
8
+ reconcileRegistry,
9
+ } from "./contractReconcile.ts";
10
+
11
+ test("reconcileRegistry: the committed registry reconciles clean (no synonyms/contradictions)", () => {
12
+ assertEquals(reconcileRegistry(), []);
13
+ });
14
+
15
+ test("reconcileRegistry: two same-category entries with equivalent semantics are flagged as synonyms", () => {
16
+ const contracts: Contract[] = [
17
+ { category: "env", name: "NANO_A_URL", owner: "a", semantics: "externally reachable base url agents use to reach this app" },
18
+ { category: "env", name: "NANO_B_URL", owner: "b", semantics: "externally reachable base url agents use to reach this app" },
19
+ ];
20
+ const findings = reconcileRegistry(contracts);
21
+ assertEquals(findings.length, 1, "one symmetric synonym pair, reported once");
22
+ assertEquals(findings[0].kind, "synonym");
23
+ assertEquals(findings[0].names.sort(), ["NANO_A_URL", "NANO_B_URL"]);
24
+ });
25
+
26
+ test("reconcileContracts: an in-flight signal for a contract not in the registry is mock-vs-real skew", () => {
27
+ const report = reconcileContracts([
28
+ { authorTask: "task-x", category: "env", name: "NANO_GHOST", body: "some new key nobody landed" },
29
+ ]);
30
+ assert(
31
+ report.findings.some((f) => f.kind === "mock-vs-real-skew" && f.names.includes("NANO_GHOST")),
32
+ "a signalled contract absent from the durable registry must be flagged as skew",
33
+ );
34
+ });
35
+
36
+ test("reconcileContracts: a signal reintroducing a rejected synonym is flagged", () => {
37
+ const report = reconcileContracts([
38
+ { authorTask: "task-y", category: "env", name: "NANO_PR_BASE_URL", body: "the base url" },
39
+ ]);
40
+ assert(
41
+ report.findings.some((f) => f.kind === "rejected-synonym"),
42
+ "a rejected synonym signalled on the blackboard must be reconciled",
43
+ );
44
+ // A rejected synonym must NOT also be reported as mock-vs-real skew: the right action is to reuse
45
+ // the canonical name, not to "land" the retired name in the registry — a skew finding there is
46
+ // noise that contradicts the rejected-synonym advice.
47
+ assertEquals(
48
+ report.findings.filter((f) => f.kind === "mock-vs-real-skew"),
49
+ [],
50
+ "a rejected synonym must not double-report as skew",
51
+ );
52
+ });
53
+
54
+ test("reconcileContracts: a synonym signal (different name, same thing) is flagged synonym, not skew", () => {
55
+ const report = reconcileContracts([
56
+ {
57
+ authorTask: "task-w",
58
+ category: "env",
59
+ name: "NANO_APP_PUBLIC_URL",
60
+ body: "Externally-reachable base URL agents use to reach this app; drives every plan's blackboard capability URL.",
61
+ },
62
+ ]);
63
+ assert(
64
+ report.findings.some((f) => f.kind === "synonym"),
65
+ "a differently-named duplicate of an existing contract must be flagged as a synonym",
66
+ );
67
+ assertEquals(
68
+ report.findings.filter((f) => f.kind === "mock-vs-real-skew"),
69
+ [],
70
+ "a synonym must not double-report as skew — reuse the canonical, don't land the new name",
71
+ );
72
+ });
73
+
74
+ test("reconcileContracts: a signal for an existing registry contract, reused correctly, is clean of skew", () => {
75
+ const report = reconcileContracts([
76
+ {
77
+ authorTask: "task-z",
78
+ category: "env",
79
+ name: "NANO_WORKFORCE_BASE_URL",
80
+ body: "Externally-reachable base URL agents use to reach this app; drives every plan's blackboard capability URL.",
81
+ },
82
+ ]);
83
+ assertEquals(report.findings.filter((f) => f.kind === "mock-vs-real-skew"), []);
84
+ });
85
+
86
+ test("formatReconciliationReport: clean report reads clean, dirty report lists findings", () => {
87
+ assert(formatReconciliationReport({ findings: [], clean: true }).includes("no synonyms"));
88
+ const dirty = formatReconciliationReport({
89
+ findings: [{ kind: "synonym", detail: "x looks like y", names: ["x", "y"], source: "registry" }],
90
+ clean: false,
91
+ });
92
+ assert(dirty.includes("1 issue"));
93
+ assert(dirty.includes("[synonym]"));
94
+ });
@@ -0,0 +1,134 @@
1
+ // nano-workforce — the contract reconciliation pass (issue #227, ADR 0004).
2
+ //
3
+ // A sibling to the L2 epic retro / cross-epic pass. Where the retro distils *learnings*, this pass
4
+ // reconciles *contracts*: it reads the whole blackboard (every `contract` signal siblings posted)
5
+ // alongside the durable contract registry (`app/contracts.ts`) and flags —
6
+ //
7
+ // - SYNONYMS: two declarations of one thing under different names (the #223 env-key failure mode),
8
+ // - CONTRADICTIONS: one name declared with two different meanings/owners,
9
+ // - MOCK-vs-REAL SKEW: a contract signalled in-flight on the blackboard that never landed in the
10
+ // durable registry (a live signal with no durable truth — the pair the issue says must both hold).
11
+ //
12
+ // The pass is PURE and advisory: it returns a report. The caller (a reconciliation agent, or the CI
13
+ // check `scripts/check-contracts.ts` for the registry-only static half) decides whether to surface an
14
+ // escalation / merge candidate. Nothing here mutates the blackboard or the registry.
15
+
16
+ import {
17
+ allContracts,
18
+ type Contract,
19
+ type ContractCategory,
20
+ detectDeclarationConflicts,
21
+ rejectedEnvSynonyms,
22
+ } from "./contracts.ts";
23
+
24
+ /** An in-flight contract signal, as posted to the blackboard `contract` kind. `category`/`name` come
25
+ * from the `<category>:<name>` `dedupe_key` convention; `body` carries the human semantics. */
26
+ export interface ContractSignal {
27
+ readonly authorTask: string;
28
+ readonly category?: ContractCategory;
29
+ readonly name?: string;
30
+ readonly body: string;
31
+ }
32
+
33
+ /** One reconciliation finding. */
34
+ export interface ReconciliationFinding {
35
+ readonly kind: "synonym" | "contradiction" | "rejected-synonym" | "mock-vs-real-skew";
36
+ readonly detail: string;
37
+ /** The contract name(s) the finding concerns. */
38
+ readonly names: string[];
39
+ /** Whoever raised the divergence, when known (a blackboard author, or "registry" for a static one). */
40
+ readonly source: string;
41
+ }
42
+
43
+ export interface ReconciliationReport {
44
+ readonly findings: ReconciliationFinding[];
45
+ /** Convenience: true when there is nothing to reconcile (findings is empty). */
46
+ readonly clean: boolean;
47
+ }
48
+
49
+ /** Reconcile the durable registry against itself (the static half): synonyms among registry entries
50
+ * of one category, and any rejected synonym that somehow re-entered the registry. Used both by the
51
+ * full pass and by the CI check (which runs with no blackboard). */
52
+ export function reconcileRegistry(contracts: Contract[] = allContracts()): ReconciliationFinding[] {
53
+ const findings: ReconciliationFinding[] = [];
54
+ const rejected = rejectedEnvSynonyms();
55
+ for (const c of contracts) {
56
+ if (c.category === "env" && rejected.has(c.name)) {
57
+ findings.push({
58
+ kind: "rejected-synonym",
59
+ detail: `Registry entry '${c.name}' is a retired synonym of '${rejected.get(c.name)}' — remove it.`,
60
+ names: [c.name, rejected.get(c.name) ?? ""],
61
+ source: "registry",
62
+ });
63
+ }
64
+ }
65
+ // Pairwise synonym detection within the registry: reuse the declaration detector so the definition
66
+ // of "synonym" lives in ONE place (app/contracts.ts). Compare each entry against the others; dedupe
67
+ // symmetric pairs by only reporting name < otherName.
68
+ for (const c of contracts) {
69
+ const others = contracts.filter((o) => o.name !== c.name);
70
+ for (const conflict of detectDeclarationConflicts({ category: c.category, name: c.name, semantics: c.semantics }, others)) {
71
+ if (conflict.kind !== "synonym") continue;
72
+ if (c.name >= conflict.existingName) continue; // report each pair once
73
+ findings.push({
74
+ kind: "synonym",
75
+ detail: conflict.detail,
76
+ names: [c.name, conflict.existingName],
77
+ source: "registry",
78
+ });
79
+ }
80
+ }
81
+ return findings;
82
+ }
83
+
84
+ /** The full pass: reconcile the accumulated blackboard `contract` signals against the durable
85
+ * registry, plus the registry against itself. */
86
+ export function reconcileContracts(
87
+ signals: ContractSignal[],
88
+ contracts: Contract[] = allContracts(),
89
+ ): ReconciliationReport {
90
+ const findings: ReconciliationFinding[] = [...reconcileRegistry(contracts)];
91
+ const byName = new Map(contracts.map((c) => [c.name, c]));
92
+
93
+ for (const sig of signals) {
94
+ // A structured signal (has category+name) can be checked against the registry precisely.
95
+ if (sig.category && sig.name) {
96
+ const conflicts = detectDeclarationConflicts(
97
+ { category: sig.category, name: sig.name, semantics: sig.body },
98
+ contracts,
99
+ );
100
+ for (const conflict of conflicts) {
101
+ findings.push({
102
+ kind: conflict.kind === "synonym" ? "synonym" : conflict.kind === "contradiction" ? "contradiction" : "rejected-synonym",
103
+ detail: `${sig.authorTask}: ${conflict.detail}`,
104
+ names: [conflict.proposedName, conflict.existingName].filter(Boolean),
105
+ source: sig.authorTask,
106
+ });
107
+ }
108
+ // Mock-vs-real skew: an in-flight signal for a contract that never landed in the durable
109
+ // registry. The blackboard is the live signal; the registry is the durable truth. A signal
110
+ // with no registry entry is exactly the divergence-only-at-runtime risk the issue names.
111
+ // BUT a signal already flagged as a synonym or rejected synonym must NOT also be reported as
112
+ // skew: the correct action is to reuse the existing canonical contract, not to "land" the
113
+ // proposed (synonymous/retired) name in the registry — so a skew finding there is noise that
114
+ // contradicts the synonym advice.
115
+ const isSynonymish = conflicts.some((c) => c.kind === "synonym" || c.kind === "rejected-synonym");
116
+ if (!byName.has(sig.name) && !isSynonymish) {
117
+ findings.push({
118
+ kind: "mock-vs-real-skew",
119
+ detail: `${sig.authorTask}: signalled contract '${sig.category}:${sig.name}' on the blackboard but it is not in the durable registry — land it in app/contracts.ts or it stays a mock-only agreement.`,
120
+ names: [sig.name],
121
+ source: sig.authorTask,
122
+ });
123
+ }
124
+ }
125
+ }
126
+ return { findings, clean: findings.length === 0 };
127
+ }
128
+
129
+ /** Render a report as a short, human/agent-readable text block (for an escalation body or a log). */
130
+ export function formatReconciliationReport(report: ReconciliationReport): string {
131
+ if (report.clean) return "Contract reconciliation: no synonyms, contradictions, or mock-vs-real skew found.";
132
+ const lines = report.findings.map((f) => `- [${f.kind}] ${f.detail}`);
133
+ return `Contract reconciliation found ${report.findings.length} issue(s):\n${lines.join("\n")}`;
134
+ }