@nanobpm/nano-workforce 0.70.2 → 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.
Files changed (38) hide show
  1. package/.github/workflows/ci.yml +7 -0
  2. package/AGENTS.md +25 -1
  3. package/CHANGELOG.md +14 -0
  4. package/SPEC.md +7 -2
  5. package/app/agentCompletion.test.ts +69 -0
  6. package/app/agentCompletion.ts +78 -0
  7. package/app/blackboard.test.ts +75 -1
  8. package/app/blackboard.ts +144 -14
  9. package/app/contractReconcile.test.ts +94 -0
  10. package/app/contractReconcile.ts +134 -0
  11. package/app/contracts.test.ts +111 -0
  12. package/app/contracts.ts +446 -0
  13. package/app/instance-tracking.test.ts +44 -1
  14. package/app/pollUserTasks.test.ts +151 -0
  15. package/app/service.ts +229 -2
  16. package/app/trialMerge.ts +5 -0
  17. package/app/userTasks.test.ts +208 -0
  18. package/app/userTasks.ts +217 -0
  19. package/db/migrations/034_user_tasks_inbox.sql +51 -0
  20. package/docs/adr/0004-shared-contract-coordination.md +108 -0
  21. package/nano.app.json +2 -1
  22. package/openapi.yaml +77 -0
  23. package/operations/appendBlackboard.ts +29 -9
  24. package/operations/blackboard.test.ts +49 -0
  25. package/operations/completeUserTask.test.ts +146 -0
  26. package/operations/completeUserTask.ts +72 -0
  27. package/package.json +3 -1
  28. package/pages/cockpit.page.json +1 -0
  29. package/pages/epic-detail.page.json +1 -0
  30. package/pages/epic.page.json +1 -0
  31. package/pages/feature.page.json +1 -0
  32. package/pages/home.page.json +4 -0
  33. package/pages/overview.page.json +1 -0
  34. package/pages/tasks.page.json +297 -0
  35. package/scripts/check-contracts.test.ts +58 -0
  36. package/scripts/check-contracts.ts +151 -0
  37. package/scripts/reconcile-contracts.test.ts +38 -0
  38. package/scripts/reconcile-contracts.ts +104 -0
@@ -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,17 @@
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
+
8
+ # [0.71.0](https://github.com/nanobpm/nano-workforce/compare/v0.70.2...v0.71.0) (2026-08-15)
9
+
10
+
11
+ ### Features
12
+
13
+ * add a Tasks page to resolve native user-task escalations in the UI ([#238](https://github.com/nanobpm/nano-workforce/issues/238)) ([4dcd1a1](https://github.com/nanobpm/nano-workforce/commit/4dcd1a1ca1756bec45997ce619ed92bff31d03d6)), closes [210/#220](https://github.com/nanobpm/nano-workforce/issues/220)
14
+
1
15
  ## [0.70.2](https://github.com/nanobpm/nano-workforce/compare/v0.70.1...v0.70.2) (2026-08-15)
2
16
 
3
17
 
package/SPEC.md CHANGED
@@ -264,8 +264,12 @@ generic Urban **page runtime** (`@nanobpm/app`, ADR 0042) — no hand-written SP
264
264
  The page defines status-filtered tabs (active vs. history), a submit form, a
265
265
  per-row **Cancel** action, and an expandable detail with the round/escalation
266
266
  child grids and a lazily-loaded transcript. The round/escalation grids are
267
- read-only audit; escalations are answered through the **task inbox** surface at
268
- `/tasks` (native `userTask`s), not an inline form on this page.
267
+ read-only audit. Open native user-task escalations are additionally resolved
268
+ app-side from the **Tasks** page (`pages/tasks.page.json`, issue #236) a nav
269
+ tab whose per-kind `dataGrid`s list every open escalation (feature / plan-review
270
+ / trial-merge / PR review / blocked-run) off the `user_tasks` read-model and
271
+ submit the typed decision to the canonical human completer — so an operator no
272
+ longer depends on Urban's read-only `taskInbox` stub at `/tasks`.
269
273
 
270
274
  The app-specific business-logic endpoints are **OpenAPI operations** mounted
271
275
  under `api.base` (`/app/api`), each implemented by a delegate module in
@@ -282,6 +286,7 @@ The full, authoritative contract is `openapi.yaml` (Swagger UI at
282
286
  | `POST` | `/app/api/actions/start/convergence-loop` | parse the PR ref → create the aggregate + start the process (the ONE submit door — page + external callers) |
283
287
  | `POST` | `/app/api/actions/start/plan-fanout` | parse the issue ref → start a plan fan-out run (the ONE plan door) |
284
288
  | `POST` | `/app/api/actions/message` (`escalation-answered`) | answer an open merge-loop escalation → publish `escalation-answered` (the four #156 escalation kinds are native user tasks answered via the task inbox) |
289
+ | `POST` | `/app/api/actions/complete-user-task` | complete an open native user-task escalation from the Tasks page (plan-review / trial-merge / PR `wait-answer`) → `completeEscalationAsHuman` (the same resume path the task inbox uses) |
285
290
  | `GET`/`POST` | `/app/api/hooks/blackboard` | per-plan coordination blackboard (capability-token side-channel) |
286
291
  | `GET` | `/app/api/hooks/abandon` | cooperative abandon check (per-PR capability token) |
287
292
 
@@ -21,6 +21,7 @@ import {
21
21
  latestCompletion,
22
22
  revertAgentCompletion,
23
23
  type TaskCompletion,
24
+ validateEscalationVariables,
24
25
  } from "./agentCompletion.ts";
25
26
 
26
27
  /** A minimal in-memory `Table<T>`: AUTOINCREMENT ids on insert, structural `find`, `get`, `update`. */
@@ -386,3 +387,71 @@ test("latestCompletion returns the newest row by id regardless of insertion orde
386
387
  assertEquals(newest.id, 3, "the highest-id row for the key wins, not the first found");
387
388
  assertEquals(stores.task_completions.rows[0].id, 3, "the backing array is not reordered");
388
389
  });
390
+
391
+ // --- Form-contract enforcement (issue #236 review advisory): a completion must satisfy the linked
392
+ // `.form`'s required-field + select allowed-value contract BEFORE the engine resumes, so a missing or
393
+ // invalid decision can never park the process in an invalid state. Derived from the canonical `.form`,
394
+ // exercised through BOTH completers so agent and human paths reject invalid input identically.
395
+
396
+ test("completer rejects a completion missing a required form field (no engine resume, no ledger row)", async () => {
397
+ const stores = { task_completions: { rows: [] as any[], key: "id" } };
398
+ const data = memData(stores);
399
+ const { engine, completed } = fakeEngine([{ userTaskKey: "ut-1", elementId: "wait-answer" }]);
400
+
401
+ const r = await completeEscalationAsHuman(data, engine, {
402
+ userTaskKey: "ut-1",
403
+ operatorId: "alice",
404
+ variables: { answer: " " }, // required, but blank
405
+ });
406
+
407
+ assertEquals(r.ok, false);
408
+ assert(String(r.reason).includes("answer"), "the reason names the missing required field");
409
+ assertEquals(completed.length, 0, "an invalid completion never resumes the process");
410
+ assertEquals(stores.task_completions.rows.length, 0, "and no attribution row is written");
411
+ });
412
+
413
+ test("completer rejects a select value outside the form's allowed set", async () => {
414
+ const stores = { task_completions: { rows: [] as any[], key: "id" } };
415
+ const data = memData(stores);
416
+ const { engine, completed } = fakeEngine([{ userTaskKey: "ut-2", elementId: "trial-merge-decision" }]);
417
+
418
+ const r = await completeEscalationAsAgent(data, engine, {
419
+ userTaskKey: "ut-2",
420
+ agentId: "bot",
421
+ variables: { action: "explode" }, // not one of proceed/rebase/abandon
422
+ });
423
+
424
+ assertEquals(r.ok, false);
425
+ assert(String(r.reason).includes("action"), "the reason names the invalid select field");
426
+ assertEquals(completed.length, 0, "an out-of-range decision never resumes the process");
427
+ });
428
+
429
+ test("completer accepts variables that satisfy the form contract (required present + allowed value)", async () => {
430
+ const stores = { task_completions: { rows: [] as any[], key: "id" } };
431
+ const data = memData(stores);
432
+ const { engine, completed } = fakeEngine([{ userTaskKey: "ut-3", elementId: "plan-review-decision" }]);
433
+
434
+ const r = await completeEscalationAsHuman(data, engine, {
435
+ userTaskKey: "ut-3",
436
+ operatorId: "alice",
437
+ variables: { directive: "revise", notes: "narrow scope" },
438
+ });
439
+
440
+ assertEquals(r.ok, true);
441
+ assertEquals(completed.length, 1, "a contract-valid completion resumes the process");
442
+ assertEquals(completed[0].variables, { directive: "revise", notes: "narrow scope" });
443
+ });
444
+
445
+ test("validateEscalationVariables derives its contract from the canonical .form files", async () => {
446
+ // wait-answer -> pr-escalation.form (answer required)
447
+ assertEquals(validateEscalationVariables("wait-answer", { answer: "ok" }), null);
448
+ assert(validateEscalationVariables("wait-answer", {}) !== null);
449
+ // trial-merge-decision -> action required, allowed proceed/rebase/abandon
450
+ assertEquals(validateEscalationVariables("trial-merge-decision", { action: "abandon" }), null);
451
+ assert(validateEscalationVariables("trial-merge-decision", { action: "nope" }) !== null);
452
+ // plan-review-decision -> directive required, allowed proceed/revise
453
+ assertEquals(validateEscalationVariables("plan-review-decision", { directive: "proceed" }), null);
454
+ assert(validateEscalationVariables("plan-review-decision", { directive: "" }) !== null);
455
+ // an element with no linked form contract is not enforced
456
+ assertEquals(validateEscalationVariables("some-other-task", { whatever: 1 }), null);
457
+ });
@@ -20,6 +20,7 @@
20
20
  // SAME `completeUserTaskAttributed` (as `human`), so there is exactly one implementation of "complete
21
21
  // an escalation user task" — the agent path is an extension of it, not a parallel copy.
22
22
 
23
+ import { readFileSync } from "node:fs";
23
24
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
24
25
 
25
26
  const now = () => new Date().toISOString();
@@ -69,6 +70,77 @@ export const ESCALATION_TASK_ELEMENTS: ReadonlySet<string> = new Set([
69
70
  "wait-answer", // PR review-loop escalation (convergence-loop.bpmn, U3)
70
71
  ]);
71
72
 
73
+ /** Each escalation `elementId` → the `.form` whose contract governs its completion variables (the
74
+ * BPMN `zeebe:formDefinition formId`). Kept beside `ESCALATION_TASK_ELEMENTS` so the completer
75
+ * validates against the SAME `.form` the task inbox renders — one contract, no second field list. */
76
+ const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
77
+ "feature-escalation": "feature-escalation",
78
+ "plan-review-decision": "plan-review-decision",
79
+ "trial-merge-decision": "trial-merge-decision",
80
+ "wait-answer": "pr-escalation",
81
+ };
82
+
83
+ interface FormContract {
84
+ /** Field keys marked `validate.required` in the `.form`. */
85
+ required: string[];
86
+ /** `select` field key → its allowed `values`. */
87
+ allowed: Record<string, string[]>;
88
+ }
89
+
90
+ const formContractCache = new Map<string, FormContract>();
91
+
92
+ /** Derive a `.form`'s required-field + select allowed-value contract, cached per formId. The `.form`
93
+ * files (`resources/forms/*.form`, deployed via nano.app.json) are the CANONICAL contract, so this
94
+ * reads them rather than re-encoding the field lists — no drift surface. */
95
+ function formContract(formId: string): FormContract {
96
+ const cached = formContractCache.get(formId);
97
+ if (cached) return cached;
98
+ const raw: {
99
+ components?: { key?: string; validate?: { required?: boolean }; values?: { value?: string }[] }[];
100
+ } = JSON.parse(readFileSync(new URL(`../resources/forms/${formId}.form`, import.meta.url), "utf8"));
101
+ const required: string[] = [];
102
+ const allowed: Record<string, string[]> = {};
103
+ for (const c of raw.components ?? []) {
104
+ if (!c.key) continue;
105
+ if (c.validate?.required) required.push(c.key);
106
+ if (c.values?.length) {
107
+ allowed[c.key] = c.values.map((v) => v.value ?? "").filter((v) => v !== "");
108
+ }
109
+ }
110
+ const contract: FormContract = { required, allowed };
111
+ formContractCache.set(formId, contract);
112
+ return contract;
113
+ }
114
+
115
+ /** Validate completion `variables` against the escalation's `.form` contract (required fields present
116
+ * + `select` values within the allowed set), so a completion can never resume the process with a
117
+ * missing/invalid decision (e.g. a `wait-answer` with no `answer`, or a `trial-merge-decision` with
118
+ * an `action` outside proceed/rebase/abandon). Returns a human-readable reason on violation, or
119
+ * `null` when the variables satisfy the contract. Derived from the canonical `.form` — the same
120
+ * contract the task inbox renders — so both the agent and human completers reject invalid input the
121
+ * exact same way, with one implementation. An element with no linked form contract is not enforced. */
122
+ export function validateEscalationVariables(
123
+ elementId: string,
124
+ variables: Record<string, unknown>,
125
+ ): string | null {
126
+ const formId = ESCALATION_FORM_BY_ELEMENT[elementId];
127
+ if (!formId) return null;
128
+ const { required, allowed } = formContract(formId);
129
+ for (const key of required) {
130
+ const v = variables[key];
131
+ if (v === undefined || v === null || (typeof v === "string" && v.trim() === "")) {
132
+ return `${elementId}: "${key}" is required`;
133
+ }
134
+ }
135
+ for (const [key, values] of Object.entries(allowed)) {
136
+ const v = variables[key];
137
+ if (v !== undefined && v !== null && !values.includes(String(v))) {
138
+ return `${elementId}: "${key}" must be one of ${values.join(", ")}`;
139
+ }
140
+ }
141
+ return null;
142
+ }
143
+
72
144
  /** The canonical attributed completer. Records an attribution row in `task_completions` (reversible
73
145
  * iff the actor is an agent) and THEN completes the user task with the exact typed `variables` — so
74
146
  * the ledger row can never be lost by a resume that fires before the write. If the engine
@@ -183,6 +255,9 @@ export async function completeEscalationAsAgent(
183
255
  const resolved = await resolveEscalationTask(engine, userTaskKey);
184
256
  if (!resolved.ok) return resolved;
185
257
 
258
+ const invalid = validateEscalationVariables(resolved.elementId, input.variables);
259
+ if (invalid) return { ok: false, reason: invalid };
260
+
186
261
  const { completionId } = await completeUserTaskAttributed(
187
262
  data,
188
263
  engine,
@@ -212,6 +287,9 @@ export async function completeEscalationAsHuman(
212
287
  const resolved = await resolveEscalationTask(engine, userTaskKey);
213
288
  if (!resolved.ok) return resolved;
214
289
 
290
+ const invalid = validateEscalationVariables(resolved.elementId, input.variables);
291
+ if (invalid) return { ok: false, reason: invalid };
292
+
215
293
  const { completionId } = await completeUserTaskAttributed(
216
294
  data,
217
295
  engine,
@@ -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
  }