@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.
- package/.github/workflows/ci.yml +7 -0
- package/AGENTS.md +25 -1
- package/CHANGELOG.md +7 -0
- package/app/blackboard.test.ts +75 -1
- package/app/blackboard.ts +144 -14
- package/app/contractReconcile.test.ts +94 -0
- package/app/contractReconcile.ts +134 -0
- package/app/contracts.test.ts +111 -0
- package/app/contracts.ts +446 -0
- package/docs/adr/0004-shared-contract-coordination.md +108 -0
- package/openapi.yaml +24 -0
- package/operations/appendBlackboard.ts +29 -9
- package/operations/blackboard.test.ts +49 -0
- package/package.json +3 -1
- package/scripts/check-contracts.test.ts +58 -0
- package/scripts/check-contracts.ts +151 -0
- package/scripts/reconcile-contracts.test.ts +38 -0
- package/scripts/reconcile-contracts.ts +104 -0
package/.github/workflows/ci.yml
CHANGED
|
@@ -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
|
|
package/app/blackboard.test.ts
CHANGED
|
@@ -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 `
|
|
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 {
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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), \`
|
|
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
|
|
291
|
-
|
|
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
|
+
}
|