// nano-workforce — the durable contract registry (issue #227, ADR 0004). // // THE PROBLEM this exists to kill: parallel/sliced agent work keeps producing *two divergent // representations of one contract* — an env-key synonym (the canonical `NANO_WORKFORCE_BASE_URL` // vs. retired names like `NANO_PR_PUBLIC_BASE_URL`/`NANO_PR_BASE_URL`, #226/#223), a wire-shape // drift (a producer emitting a legacy frame the hub no longer accepts, nano-ide #234), two type // names for one shape — each authored independently against a mock, with the divergence only // discovered at runtime. // // THE FIX: a first-class, *committed and executable* source of truth for every cross-cutting // contract. Declare-once entries (env/config keys, wire-frame shapes, shared exported type/interface // names, capability-URL schemes) with an owner + semantics per entry. Because the registry is code: // - env/config keys are parsed through ONE typed schema ({@link ENV_CONTRACTS} + {@link readEnv}), // so a duplicate or synonymous key is a compile/lint failure (`scripts/check-contracts.ts`), // never a silent runtime fallback — the #223 cascade cannot be reintroduced; // - a rejected synonym (an old name we deliberately retired) is recorded here, so its reappearance // in code is a hard CI failure, not a phantom fallback; // - the reconciliation pass (`app/contractReconcile.ts`) reads this registry alongside the whole // blackboard and flags synonyms / contradictions / mock-vs-real skew. // // The registry is the DURABLE truth; the blackboard `contract` kind (app/blackboard.ts) is the // LIVE, in-flight signal ("I am introducing / consuming contract X") so siblings in a wave see a new // contract *before* they independently invent a synonym. Neither alone suffices. /** The kinds of cross-cutting contract the registry coordinates. */ export type ContractCategory = "env" | "wire" | "type" | "capability-url"; /** Fields shared by every contract entry: a stable name, an owning subsystem, and human semantics. */ interface ContractBase { /** The canonical name (env var, wire op/frame, exported type, or URL scheme id). */ readonly name: string; /** The subsystem/module that owns this contract (where its canonical definition lives). */ readonly owner: string; /** What the contract means — enough for a sibling to decide "is mine the same as this?". */ readonly semantics: string; } /** An env/config key. Parsed through the one typed schema; `default` documents the fallback. */ export interface EnvContract extends ContractBase { readonly category: "env"; /** The documented default when the key is unset/blank (omit for a required secret). */ readonly default?: string; /** Names we DELIBERATELY retired for this value. Their reappearance in code is a CI failure — a * retired synonym must never come back as a silent fallback (the #223 failure mode). */ readonly rejectedSynonyms?: readonly string[]; /** True for a secret/credential whose value must never be logged or defaulted. */ readonly secret?: boolean; } /** A wire-frame shape that crosses a process/transport boundary (e.g. a relay control frame). */ export interface WireContract extends ContractBase { readonly category: "wire"; /** A concise, executable-where-possible description of the frame shape both sides must share. */ readonly shape: string; } /** A shared exported type/interface name that more than one slice depends on. */ export interface TypeContract extends ContractBase { readonly category: "type"; /** The module the canonical definition is exported from — the ONE import both sides must use. */ readonly module: string; } /** A capability-URL scheme: how a token-scoped side-channel URL is assembled. */ export interface CapabilityUrlContract extends ContractBase { readonly category: "capability-url"; /** The URL template (documented), e.g. `/app/api/hooks/blackboard?token=`. */ readonly scheme: string; } export type Contract = EnvContract | WireContract | TypeContract | CapabilityUrlContract; // --------------------------------------------------------------------------------------------- // The one typed env schema. EVERY config-family env key MUST be declared here; the CI check // (`scripts/check-contracts.ts`) fails the build if code reads a config key that is not declared, // or reads a rejected synonym. This makes the schema the single source of truth for config keys. // --------------------------------------------------------------------------------------------- export const ENV_CONTRACTS = { NANO_WORKFORCE_BASE_URL: { category: "env", name: "NANO_WORKFORCE_BASE_URL", owner: "app/blackboard.ts", semantics: "Externally-reachable base URL agents use to reach this app (must resolve from wherever the agent runs). Drives every plan's blackboard capability URL.", default: "http://localhost:3000", // Retired synonyms, recorded so their reintroduction is a CI failure rather than a silent second // name for one value: `NANO_PR_PUBLIC_BASE_URL` was coalesced into this canonical name in #226; // `NANO_PR_BASE_URL` was a phantom fallback introduced in #53 (2dcfb8a) and cleaned up per #223. rejectedSynonyms: ["NANO_PR_PUBLIC_BASE_URL", "NANO_PR_BASE_URL"], }, NANO_PR_POLL_MS: { category: "env", name: "NANO_PR_POLL_MS", owner: "main.ts", semantics: "Poller cadence in milliseconds for the self-scheduling reconciliation loop.", default: "60000", }, NANO_PR_CLONE_TIMEOUT_MS: { category: "env", name: "NANO_PR_CLONE_TIMEOUT_MS", owner: "app/repoEnvelope.ts", semantics: "Clone timeout in milliseconds emitted as `repository.cloneTimeoutMs` in the agent-task envelope so the c8ctl harness raises its 120s default for large-repo provisioning (branch-scoped blobless clones of big monorepos still approach/exceed 120s; issue #694). Inherited by both the review-round and merge paths via the one `repoEnvelopeVars` builder.", default: "600000", }, NANO_PR_MAX_ROUNDS: { category: "env", name: "NANO_PR_MAX_ROUNDS", owner: "app/service.ts", semantics: "Maximum review rounds before a PR escalates.", default: "20", }, NANO_PR_MAX_CI_FIX_ROUNDS: { category: "env", name: "NANO_PR_MAX_CI_FIX_ROUNDS", owner: "app/service.ts", semantics: "Maximum CI-fix attempts per PR.", default: "3", }, NANO_PR_MAX_REBASE_ROUNDS: { category: "env", name: "NANO_PR_MAX_REBASE_ROUNDS", owner: "app/service.ts", semantics: "Maximum rebase attempts per PR.", default: "3", }, NANO_PR_MAX_MERGE_RETRIES: { category: "env", name: "NANO_PR_MAX_MERGE_RETRIES", owner: "app/service.ts", semantics: "Maximum transient base/head-moved merge-race retries per PR before escalating.", default: "5", }, NANO_PR_MAX_ACK_RETRIES: { category: "env", name: "NANO_PR_MAX_ACK_RETRIES", owner: "app/service.ts", semantics: "Maximum bounded review-round re-dispatches the convergence loop makes to auto-ack unacked suppressed advisories before escalating to a human (#796); 0 escalates on the first ack-only block.", default: "2", }, NANO_PR_MAX_MERGE_STALL_ROUNDS: { category: "env", name: "NANO_PR_MAX_MERGE_STALL_ROUNDS", owner: "app/service.ts", semantics: "Maximum mergeable-wait-timeout stall-probe re-derivations (dead-poller backstop, #636) AND `waiting`-verdict re-poll probes (#774) before escalating; 0 escalates on the first stall.", default: "3", }, NANO_PR_REVIEW_WAIT_TIMEOUT: { category: "env", name: "NANO_PR_REVIEW_WAIT_TIMEOUT", owner: "app/service.ts", semantics: "How long to wait for a review before nudging/escalating (FEEL/ISO-8601 duration).", default: "PT30M", }, NANO_PR_REVIEW_NUDGE_MINUTES: { category: "env", name: "NANO_PR_REVIEW_NUDGE_MINUTES", owner: "app/service.ts", semantics: "Minutes between review nudges.", }, NANO_PR_MERGE_LANDED_WAIT_TIMEOUT: { category: "env", name: "NANO_PR_MERGE_LANDED_WAIT_TIMEOUT", owner: "app/service.ts", semantics: "How long the merge loop waits for a queued PR to actually land before escalating (FEEL/ISO-8601 duration).", }, NANO_PR_MERGEABLE_WAIT_TIMEOUT: { category: "env", name: "NANO_PR_MERGEABLE_WAIT_TIMEOUT", owner: "app/service.ts", semantics: "How long the merge loop waits for the poller's `merge-ready` before the stall-probe timer arm fires (dead-poller backstop, #636; FEEL/ISO-8601 duration).", }, NANO_PR_MERGEABLE_REPOLL_INTERVAL: { category: "env", name: "NANO_PR_MERGEABLE_REPOLL_INTERVAL", owner: "app/service.ts", semantics: "How long the merge loop waits before re-deriving mergeability when `gw-mergeable` sees an async-`UNKNOWN` `\"waiting\"` verdict, instead of escalating to a human (#774; bounded by `NANO_PR_MAX_MERGE_STALL_ROUNDS`; FEEL/ISO-8601 duration).", }, NANO_PR_AUTO_MERGE: { category: "env", name: "NANO_PR_AUTO_MERGE", owner: "app/service.ts", semantics: "Whether the app auto-merges a converged PR (1/0).", default: "1", }, NANO_PR_MERGE_METHOD: { category: "env", name: "NANO_PR_MERGE_METHOD", owner: "app/service.ts", semantics: "Merge method for auto-merge (squash|merge|rebase).", default: "squash", }, NANO_PR_MERGE_ADMIN: { category: "env", name: "NANO_PR_MERGE_ADMIN", owner: "app/service.ts", semantics: "Whether to merge with admin override (1/0).", default: "0", }, NANO_PR_GITHUB_TRANSPORT: { category: "env", name: "NANO_PR_GITHUB_TRANSPORT", owner: "app/github.ts", semantics: "GitHub transport selector (auto|cli|rest).", default: "auto", }, NANO_PR_WEBHOOK_SECRET: { category: "env", name: "NANO_PR_WEBHOOK_SECRET", owner: "operations/*.ts (agentic HTTP hooks)", semantics: "Shared secret authenticating the agentic supply HTTP-hook operations (getAgenticSupply / getAgentInstructions / listActivePrs / getVersion / agentCompleteEscalation / revertEscalationCompletion). Also the fallback secret for NANO_AGENTIC_SECRET.", secret: true, }, NANO_AGENTIC_SECRET: { category: "env", name: "NANO_AGENTIC_SECRET", owner: "main.ts", semantics: "Secret authenticating the agentic supply endpoint; falls back to NANO_PR_WEBHOOK_SECRET when unset.", secret: true, }, NANO_AGENTIC: { category: "env", name: "NANO_AGENTIC", owner: "main.ts", semantics: "Feature flag for the agentic supply endpoint; a value of 0/off/false/no disables it (enabled when unset).", }, NANO_AGENTIC_MIN_HARNESS_PROTOCOL: { category: "env", name: "NANO_AGENTIC_MIN_HARNESS_PROTOCOL", owner: "app/harnessProtocol.ts", semantics: "Minimum worker-harness protocol version a worker must advertise at enrolment to be considered healthy (issue #802). A worker advertising a version below this — or advertising NO version at all (absent = stale) — is flagged stale in getAgenticSupply / the registry and, under NANO_AGENTIC_STALE_HARNESS_POLICY=refuse, is refused agent-job routing. Non-integer/blank degrades to the default.", default: "1", }, NANO_AGENTIC_STALE_HARNESS_POLICY: { category: "env", name: "NANO_AGENTIC_STALE_HARNESS_POLICY", owner: "app/harnessProtocol.ts", semantics: "How the app treats a stale worker harness (issue #802): 'flag' (default) only marks it stale for observability/drain; 'refuse' additionally withholds its SERVE tokens at enrol so it wins no job leases. Anything other than the exact token 'refuse' is treated as 'flag' so a typo never silently drains the fleet.", default: "flag", }, NANO_WORKFORCE_GIT_SHA: { category: "env", name: "NANO_WORKFORCE_GIT_SHA", owner: "app/version.ts", semantics: "Explicit git SHA override for version reporting on deploys shipped without a .git directory; version derivation reads .git when unset.", }, NANO_AUTO_RETRO: { category: "env", name: "NANO_AUTO_RETRO", owner: "app/retro.ts", semantics: "Opt-out toggle for the epic retrospective stage (0/false disables).", default: "1", }, NANO_ESCALATION_SLA_TIMEOUT: { category: "env", name: "NANO_ESCALATION_SLA_TIMEOUT", owner: "app/plan.ts", semantics: "SLA timeout for an escalation user task (FEEL/ISO-8601 duration).", }, NANO_PR_AGENT_SLA_TIMEOUT: { category: "env", name: "NANO_PR_AGENT_SLA_TIMEOUT", owner: "app/service.ts", semantics: "SLA timeout for an agent (service) task before its boundary timer fires and the PR escalates for human attention (ISO-8601 duration). A malformed value falls back to the default.", default: "PT2H", }, NANO_CAPS_WAIT_TIMEOUT: { category: "env", name: "NANO_CAPS_WAIT_TIMEOUT", owner: "app/plan.ts", semantics: "Bounded wait (FEEL/ISO-8601 duration) a plan-fanout task may park at the wait-caps-resolved capability barrier before the event-based gateway's timer arm fires and it escalates to an operator. Bounds a permanently-unresolvable capability handle (UnresolvableCapabilityRefError) so it can never silently wedge the epic. A malformed value falls back to the default.", default: "P1D", }, NANO_READINESS_POLL_TIMEOUT: { category: "env", name: "NANO_READINESS_POLL_TIMEOUT", owner: "app/readiness.ts", semantics: "Default bounded timeout (FEEL/ISO-8601 duration) for a ReadinessProbe wait-gate when the probe descriptor declares no poll.timeoutMs. The gate's event-based-gateway timer arm fires after it and escalates, so a probe that never goes green can never wedge a plan. A malformed value falls back to the default.", default: "PT30M", }, NANO_READINESS_POLL_EVERY_MS: { category: "env", name: "NANO_READINESS_POLL_EVERY_MS", owner: "workers/readiness-probe/worker.ts", semantics: "Default interval in milliseconds between ReadinessProbe attempts when the probe descriptor declares no poll.everyMs.", default: "15000", }, NANO_APP_DB_URL: { category: "env", name: "NANO_APP_DB_URL", owner: "nano.app.json / DataLayer", semantics: "Connection URL for the app SQLite DataLayer.", }, NANOBPMN_BASE_URL: { category: "env", name: "NANOBPMN_BASE_URL", owner: "app/agentGuide.ts", semantics: "Base URL of the nanobpmn engine REST API (used to derive CAMUNDA_REST_ADDRESS).", default: "http://localhost:8080", }, PR_REVIEW_PORT: { category: "env", name: "PR_REVIEW_PORT", owner: "main.ts", semantics: "TCP port the app HTTP server binds.", default: "3000", }, GITHUB_TOKEN: { category: "env", name: "GITHUB_TOKEN", owner: "app/github.ts", semantics: "GitHub API credential the app uses for all GitHub calls.", secret: true, }, CAMUNDA_REST_ADDRESS: { category: "env", name: "CAMUNDA_REST_ADDRESS", owner: "app/agentGuide.ts", semantics: "Explicit REST address of the engine (overrides NANOBPMN_BASE_URL derivation).", }, CAMUNDA_TOKEN: { category: "env", name: "CAMUNDA_TOKEN", owner: "app/agentGuide.ts", semantics: "Bearer token for the engine REST API.", secret: true, }, CAMUNDA_TRANSPORT: { category: "env", name: "CAMUNDA_TRANSPORT", owner: "app/agentGuide.ts", semantics: "Engine transport selector.", }, NANO_WORKFORCE_PERMISSION_ESCALATION: { category: "env", name: "NANO_WORKFORCE_PERMISSION_ESCALATION", owner: "app/agentic/permission-bridge.ts", semantics: "Per-hire opt-in master switch for the ACP permission-escalation bridge (issue #559, ADR 0056). When on ('1'/'true'/'on'/'yes'), an escalate-policy session/request_permission is bridged to a nano-workforce Tasks-inbox escalation and the operator's Allow/Deny answer is flowed back down the relay as a permission RESOLUTION. Default OFF → yolo auto-allow: no user task, no prompt. A yolo-policy request never reaches the bridge regardless.", default: "off", }, } as const satisfies Record; /** The set of declared config-key names — the single typed vocabulary of env keys. */ export type EnvKey = keyof typeof ENV_CONTRACTS; /** Whether `name` is a declared {@link EnvKey}. A runtime-narrowing guard so a value carried in as * a plain string (e.g. a probe descriptor's `credentialEnv`) can be validated against the ONE * schema before it is read through {@link readEnv} — an undeclared key is rejected, never read. */ export function isEnvKey(name: string): name is EnvKey { return Object.hasOwn(ENV_CONTRACTS, name); } /** Every declared env contract, widened to {@link EnvContract} (assignment-widening — no `as`), so * callers can read the optional `default`/`rejectedSynonyms`/`secret` fields on any entry. */ export function envContracts(): EnvContract[] { const list: EnvContract[] = Object.values(ENV_CONTRACTS); return list; } /** The declared entry for a key, widened to {@link EnvContract} so callers can read `default`/ * `rejectedSynonyms` (the `as const satisfies` above keeps each entry's narrow literal type, on * which those optional fields don't exist for every member). Assignment-widening — no `as` cast. */ export function envContract(key: EnvKey): EnvContract { const entry: EnvContract = ENV_CONTRACTS[key]; return entry; } /** Read a declared env key through the one schema. `key` is a compile-time-checked {@link EnvKey}, * so a typo or a synonymous key (e.g. the retired `NANO_PR_BASE_URL`) is a TYPE error here — it can * never resolve to a silent runtime fallback. Returns the trimmed value, or `undefined` when unset * or blank/whitespace (so an explicitly-empty key never yields a malformed value). */ export function readEnv( key: EnvKey, env: Record = process.env, ): string | undefined { const trimmed = env[key]?.trim(); return trimmed ? trimmed : undefined; } /** Read a declared env key, falling back to its registered `default` (then to `fallback`) when * unset/blank. Keeps the default in ONE place — the registry entry — not scattered at call sites. */ export function readEnvOr( key: EnvKey, fallback = "", env: Record = process.env, ): string { return readEnv(key, env) ?? envContract(key).default ?? fallback; } // --------------------------------------------------------------------------------------------- // The non-env contracts. These are the shared shapes/types/schemes that parallel slices must // converge on. Kept alongside the env schema so ONE registry answers "does a contract for X // already exist?" for every category. // --------------------------------------------------------------------------------------------- export const WIRE_CONTRACTS = { "relay.produce": { category: "wire", name: "relay.produce", owner: "@nanobpm/agentic/relay", semantics: "Op-tagged relay control frame a worker terminal chunk producer emits and the hub consumes. The op-tagged shape superseded the legacy positional `{stream, offset, chunk}` frame (nano-ide #234/#236); a producer must emit the op-tagged shape or the hub rejects it as `malformed relay message payload`.", shape: '{ op: "produce", incarnation: number, stream: string, offset: number, chunk: string }', }, "io.nanobpm.agentTask.repository": { category: "wire", name: "io.nanobpm.agentTask.repository", owner: "app/repoEnvelope.ts", semantics: "Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`, app/repoEnvelope.ts) and the c8ctl worker harness consumes to provision an isolated clone — instead of the agent inheriting the worker's launch dir (issue #684). `ref` is the branch checked out: the PR HEAD branch on the PR-based paths (review-round / fix-ci / rebase), or — on the PRE-PR implementation path (feature.bpmn / plan-fanout's `implement-cell`, issue #684; the delivery-graph runner's agent cells, issue #686) — the BASE branch, off which the harness cuts a new feature branch named by the optional `branch.create` (the deterministic `feat/`, emitted for a single-task feature run AND — per issue #776 — the deterministic `feat/` for each single-instance delivery-graph agent cell, injected per-cell by `agentNodeRepoEnvelope`/`app/deliveryRunner.ts` so a forgetful agent can never be left committing on the base branch and stranding its run on a non-ff push; the epic plan-fanout seed still omits it because its MI children each cut a per-child `feat/` the app can't name at compile time, so those agents branch themselves). Beyond `{provider,url,ref}`, it carries clone-shaping fields for large monorepos (issue #287): `singleBranch:true` + `filter:\"blob:none\"` (a branch-scoped, blobless partial clone — trees fetched up-front, blobs lazily, no `--depth 1` so the merge-base/3-dot diff stays valid) and an optional `baseRef` (the PR base branch, emitted only when resolvable, so the harness fetches its tip and keeps `origin/` reachable) and a `cloneTimeoutMs` (from `NANO_PR_CLONE_TIMEOUT_MS`, default 600000 = 10 min) that raises the harness's 120s default so a large monorepo's blobless single-branch clone provisions instead of dying at 120s (issue #694). World-restore (issue #324, ADR 0062 Slice 4/5): an optional `sha` — the last durable push-checkpoint — is emitted so a REPLACEMENT activation on a fresh worktree reconstructs the tree to the EXACT pushed SHA (inverting the round's `git push` into `git fetch && git checkout `), omitted when the PR has no checkpoint yet. The field is named `sha` because that is the field the c8ctl harness's `provisionRepo` reads to drive the checkout — an earlier `commitSha` key was a silent no-op (issue #695). Alongside the `repository` slice the envelope carries a sibling `task.allowPr: true` (issue #770): c8ctl-plugin-nano (≥1.60.2) only resolves the git credential (GITHUB_TOKEN, or the `gh` default) for repo provisioning behind that flag, so every repo-backed envelope sets it or the clone dies with `unable to get password from user`; the repoless path emits no envelope and so no `task`. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91, branch-cut guard jwulf/c8ctl-plugin-nano#231).", shape: 'io.nanobpm.agentTask: { repository: { provider: "github", url: string, ref?: string, singleBranch: true, filter: "blob:none", cloneTimeoutMs: number, baseRef?: string, sha?: string, branch?: { create: string } }, task: { allowPr: true } }', }, "epicSet.submit": { category: "wire", name: "epicSet.submit", owner: "operations/startEpicSet.ts", semantics: "Set/batch admission payload POSTed to /actions/start/epic-set (issue #292, slice S2). Submits a whole set of epics plus the inter-epic dependency edges between them in one all-or-nothing call. Each `epics[]` member carries the same per-epic admission inputs as PlanStart (issue|url + baseBranch + allowSharedBase/confirmDefaultBase); each `deps[]` edge declares `consumer` waits for `producer`'s published { package, capabilityRef } capability, both endpoints naming epics in the set. Declared in openapi.yaml as EpicSetStart. S2 admits + STAGES the set into its own FK-free `admitted_epics` / `admitted_plan_deps` (043) — it writes NEITHER `plans` NOR `plan_deps`; slice S3 (lowering) reads that staging to materialize the durable graph, and S4 (visibility) builds on it — consume this ONE shape, do not re-declare a synonym.", shape: '{ epics: Array<{ issue|url: string, baseBranch: string, allowSharedBase?: boolean, confirmDefaultBase?: boolean }>, deps?: Array<{ consumer: string, producer: string, package: string, capabilityRef: string }> }', }, "world.checkpoint": { category: "wire", name: "world.checkpoint", owner: "app/world/checkpoint.ts", semantics: "The mind/world checkpoint JOIN shape (issue #324, ADR 0062 Slice 4/5, the WORLD half). At each push the app derives ONE `{commitSha, effectLedger}` and records it in the durable world store (`world_checkpoints`/`world_effects`) AND passes the SAME object to the mind's `session.checkpoint(commitSha, effectLedger)` (Slice 1, `@nanobpm/agentic/session`), so mind + world commit at the SAME per-PR monotonic offset — closing the divergence failure (harness thinks it hasn't pushed but the push landed, or vice-versa). `effectLedger` entries carry a fence idempotency key (push→commit SHA, PR comment→comment id, `gh merge`→merge key); on a re-lease `restoreWorld` inverts the push (`git fetch && git checkout `) then fence-replays the tail so an already-applied effect is skipped, not repeated. Consume this ONE shape from app/world — do not re-declare a synonym.", shape: '{ commitSha: string, effectLedger: Array<{ kind: "push"|"pr-comment"|"merge", idempotencyKey: string, description?: string }> }', }, DeliveryGraph: { category: "wire", name: "DeliveryGraph", owner: "app/deliveryGraph.ts", semantics: "The agent-authored delivery graph (ADR 0005, slice S0) — the SINGLE agent-facing artifact for a heterogeneous, partly-human, cross-repo delivery runbook, crossing the ingest boundary as DATA (a JSON DAG, never an executable artifact). Declared in openapi.yaml as `DeliveryGraph`; ingest validates the SHAPE there and the SEMANTICS (acyclicity, edge integrity, fact resolution) in the pure `validateDeliveryGraph` (`app/deliveryGraph.ts`). Nodes each name a `kind` from a CLOSED allowlist (`agent`/`wait`/`human`/`connector` — Decision 1/2, the trust boundary) plus their typed `emits[]`; edges name DISCOVERED facts (Decision 3) — `from` is a bare `` or a qualified `.`. Later slices (compiler/dispatch/execution) build on this ONE shape — consume it, do not re-declare a synonym.", shape: '{ name?: string, nodes: Array<{ id: string, kind: "agent"|"wait"|"human"|"connector", emits?: Array<{ name: string, type: "string"|"number"|"boolean"|"artifact"|"version"|"url", description?: string }>, agent?: { jobType: string, prompt?: string }, wait?: ReadinessProbe, human?: { formKey?: string, prompt?: string }, connector?: { target: string, dedupeKey?: string, payload?: object } }>, edges?: Array<{ from: string, to: string }> }', }, "deliveryGraph.compose.fill": { category: "wire", name: "deliveryGraph.compose.fill", owner: "pages/delivery-graphs/mount.js", semantics: "The INBOUND reuse-fill host-bridge message that loads a saved `DeliveryGraph` JSON into the Delivery Graphs COMPOSE App-View textarea (`#dg-json`) — issue #523, epic #519 S4. The compose mount (consumer) registers a same-origin `window` `message` listener for this shape and routes it through its single `fillComposer()` seam; the producer is the Library App-View **Reuse** action (#523), which posts it across the App-View iframe boundary (the INBOUND twin of the existing OUTBOUND `nano-navigate` DI-preview bridge). The filesystem **Import** control (#524) is NOT a producer of this message — it lives in the same compose mount and fills directly through `fillComposer()`, no cross-frame hop. The `type` string is exported ONCE as `DG_COMPOSE_FILL_MESSAGE` from pages/delivery-graphs/mount.js — the Reuse producer imports it, never re-declares a synonym.", shape: '{ type: "nano-delivery-graph-compose-fill", graphJson: string, token?: string }', }, "deliveryGraph.compose.fill.ack": { category: "wire", name: "deliveryGraph.compose.fill.ack", owner: "pages/delivery-graphs/mount.js", semantics: "The ACK half of the reuse-fill host-bridge message (issue #645). The compose App-View posts it back UP to the host — relayed across to the Library sibling App-View by the Urban App-View relay (nano-ide #518) — the moment it has actually filled `#dg-json` from a `deliveryGraph.compose.fill`. It exists to make the Library's success toast EARNED, not optimistic: the Library shows \"✓ Loaded…\" ONLY on this ack (matching its correlation `token`) and a clear \"Couldn't reach the composer\" on a short timeout, closing the #645 false-positive-toast defect. Its `token` echoes the producer's fill `token` so a stale ack from a prior Reuse can't complete a newer one. The `type` string is exported ONCE as `DG_COMPOSE_FILL_ACK_MESSAGE` from pages/delivery-graphs/mount.js — the Library consumer imports it, never re-declares a synonym.", shape: '{ type: "nano-delivery-graph-compose-fill-ack", token: string | null }', }, "nano.navigate.ack": { category: "wire", name: "nano.navigate.ack", owner: "pages/delivery-graphs/mount.js", semantics: "The host's acknowledgment of a `nano-navigate` (issue #645). \"Preview generated DI\" posts `nano-navigate` UP to the console (forwarded to the host explorer by the Urban App-View relay, nano-ide #518), but the host does not synchronously confirm it navigated — so the old \"✓ Opening…\" toast printed right after the post claimed success even when the message was dropped (standalone, no relay, a console that never navigated). The compose view now treats Preview as fire-to-host with a bounded budget: a NEUTRAL in-progress status, resolved to \"✓ Opened…\" ONLY on this same-origin ack from the parent for the matching `target`, or to \"Couldn't reach the console explorer\" on a short timeout. Consumed same-origin from `window.parent`; the `type` string is `NANO_NAVIGATE_ACK_MESSAGE` in pages/delivery-graphs/mount.js.", shape: '{ type: "nano-navigate-ack", target: string }', }, "deliveryGraph.library.import.submit": { category: "wire", name: "deliveryGraph.library.import.submit", owner: "operations/importToLibrary.ts", semantics: "Filesystem-import request body POSTed to /actions/delivery-graph/library/import (issue #524, epic #519 S5). Declared in openapi.yaml as `ImportToLibrarySubmit`; the compose App-View's `` reads the picked file's text client-side and POSTs it here as the raw `graphJson` string. The door validates + compiles it through the SAME `parseAndCompileText` pipeline preview/stage/save use, then persists `source: imported` — an uncompilable graph is a clean 400 and NOTHING is written. Its `name` defaults to the imported graph's own `name`; an explicit `name` overrides it (an unnamed graph with no override is a clean 400 — the library id is name-derived). Related to but DISTINCT from `SaveToLibrarySubmit` (which is graphJson-OR-digest and needs no required file text); consume this ONE shape across the openapi edge, the door, and the compose mount — do not re-declare a synonym.", shape: "{ graphJson: string, name?: string, description?: string }", }, "transcript.permission": { category: "wire", name: "transcript.permission", owner: "app/agentic/transcript-events.ts", semantics: "The `permission` transcript-event envelope (issue #559) modelling ACP's `session/request_permission`, decoded by the ONE parser (`parseTranscriptEvent`) and folded by the ONE fold (`deriveView`) in app/agentic/transcript-events.ts. Two phases share the `kind:\"permission\"` discriminant, distinguished by `phase`. A REQUEST carries a stable `callId` (pairs the resolution back, like tool-call/tool-result), the producer-tagged `policy` (\"escalate\" = must ask a human, \"yolo\" = auto-allowed), the offered `options` (a NON-EMPTY array of ACP `{optionId,name,kind}` where kind is allow-once/allow-always/reject-once/reject-always — the decoder rejects a missing or empty `options`), and optional `toolName`/`title`/`reason`. A RESOLUTION carries the same `callId`, the chosen `optionId`, a boolean `allowed`, and optional `by` provenance (operator/auto). deriveView surfaces these as `DerivedPermission` (paired by callId) on `DerivedView.permissions` and `DerivedTurn.permissions`. The cockpit-render and escalation-bridge slices CONSUME this exact wire shape — do not re-declare a synonym.", shape: '{ nwfTranscriptEvent: 1, kind: "permission", phase: "request", callId: string, policy: "escalate"|"yolo", options: [{ optionId: string, name: string, kind: "allow-once"|"allow-always"|"reject-once"|"reject-always" }, ...Array<{ optionId: string, name: string, kind: "allow-once"|"allow-always"|"reject-once"|"reject-always" }>], toolName?: string, title?: string, reason?: string } | { nwfTranscriptEvent: 1, kind: "permission", phase: "resolution", callId: string, optionId: string, allowed: boolean, by?: "operator"|"auto" }', }, "transcript.lifecycleClose": { category: "wire", name: "transcript.lifecycleClose", owner: "app/agentic/families/relay.family.ts", semantics: "The harness's job-end `phase:\"close\"` transcript `lifecycle` event (issue #710, harness half jwulf/c8ctl-plugin-nano#150) — the closing twin of the `phase:\"open\"` RELAY_OPEN_CHUNK the harness emits at relay-session open. The harness emits it on the job's instance-scoped `composeStreamId(instance, jobKey)` relay stream (issue #738) through agentic's own `encodeTranscriptEvent` (never hand-rolled), right before `job.complete`/`job.fail` and AFTER draining its outbound relay buffer, so it is the deterministic \"all this job's bytes are here, it is done\" signal. The app recognizes it in `isTerminalLifecycleChunk` (relay.family.ts) to `completeStream()` — flush the durable past-session transcript and release job⇄instance correlation — at job-completion time, fixing the truncated-tail defect where the flush waited for a supersede/disconnect. `close` is NOT a core agentic `LifecycleEvent` phase (the contract's phases are open|completed|exited); the app decodes it via an ADDITIVE `mergeTranscriptVocab` extension (`RELAY_TERMINAL_VOCAB`) that maps it onto the terminal `completed` phase — never a forked wire shape, and scoped to the relay job-end detector so the cockpit derive (CORE vocab) keeps the close chunk byte-faithful. The `close` trigger is ADDITIVE: supersede + disconnect remain as fallbacks and the `state.completed` guard keeps a close-then-disconnect (or duplicate close) idempotent. Consume this ONE marker — do not re-declare a synonym or a second job-end signal.", shape: '{ nwfTranscriptEvent: 1, kind: "lifecycle", phase: "close" }', }, "transcript.readUrl": { category: "wire", name: "transcript.readUrl", owner: "app/agentic/transcript-url.ts", semantics: "The proxy-safe single-stream transcript READ URL (issue #744): `GET /app/api/agentic/transcripts?stream=&from=` — the stream id rides a QUERY value, NEVER a path segment, because the Nano Console gateway proxy peels one percent-encoding layer before the app routes: an encoded slash (%2F) in a PATH segment arrives as a real / and splits a slash-bearing worker-instance id (`34:/`) into an extra segment, so the legacy `GET /app/api/agentic/transcripts/{stream}` route 404s behind the proxy (the cockpit past-session replay rendered empty). The path form stays served for back-compat and is proxy-safe ONLY for the slash-free `job:` ids it is seeded with (the worker-emitted `transcriptUrl` = `transcriptUrlBaseFor()` + `jobStream()`, a bare concatenation, must remain resolvable both directly and behind the proxy). ONE builder: `transcriptReadUrlFor()` in app/agentic/transcript-url.ts, served by ONE canonical read (`readSingleTranscript` in app/agentic/transcript-read.ts) shared by both routes; the browser adapter pages/cockpit/mount.js carries a hand-maintained twin (it cannot import server modules). Never put a stream id in a path segment again — do not re-declare a synonym scheme.", shape: "GET ?stream=[&from=] → AgenticTranscriptData | ErrorBody", }, "agentTask.agentDefinition": { category: "wire", name: "agentTask.agentDefinition", owner: "resources/processes/*.bpmn", semantics: "The engine-native AgentTask marker (issue #745, umbrella #746 — Camunda 8.10 parity). Every `senior:*` agent service task carries `` INSIDE its ``, COEXISTING with the existing `` dispatch verb (the verb stays, per #464). The marker makes the element eligible for engine-native AgentInstance minting by the worker harness (jwulf/c8ctl-plugin-nano#194): the harness mints Create/Update/Complete AgentInstance/AgentHistory records against the pinned engine (`@nanobpm/engine-wasm` 0.8.6, broker REST, SDK) while the element still emits its NORMAL `senior:*` job. `agentType=\"external\"` means the agent runs OUTSIDE the engine (a remote fleet worker), not an engine-embedded model call. This is the PRODUCER half; the durable AgentInstance/AgentHistory it mints is read back by the Cockpit historical view via `searchAgentInstanceHistory` (the CONSUMER half — see the `agentTask.historyRead` contract; the read path landed on `@nanobpm/urban`'s EngineClient in urban 0.93 / nanobpm/nano-ide#563). It is authored in the hand-written BPMN semantic model, NOT the generated `` DI, and survives `npm run layout` untouched. Add the marker to a NEW `senior:*` agent task — never a second/synonym marker element.", shape: ' (sibling of in a senior:* service task\'s extensionElements)', }, "agentTask.autoSubscribe": { category: "wire", name: "agentTask.autoSubscribe", owner: "resources/processes/*.bpmn", semantics: "The `--auto` opt-OUT marker (issue #779, harness jwulf/c8ctl-plugin-nano#235). The ONE agentic-task signal the harness `--auto` reconciliation scans is `` (the `agentTask.agentDefinition` marker) — it replaces the legacy `linkName=\"prompt\"` / header dual signal so both sides converge on a single convention. This marker is the escape hatch: a `` INSIDE an agent task's `` declares the task is EXCLUDED from `--auto` auto-discovery and is served ONLY by a worker that explicitly subscribes (`--job-type ` / a profile capability). Absence of the marker (or any value other than the literal string `\"false\"`) means the task auto-subscribes as normal — opt-out is explicit and fail-safe. The `zeebe:property` is INERT to the engine (no runtime/behaviour change, no migration). Authored in the hand-written BPMN semantic model, NOT the generated `` DI, and survives `npm run layout` untouched. The scan helper `agentTaskTypesOptedOutOfAuto(xml)` in app/agentic/vocab/job-types.ts is the ONE reader of this marker (mirrors `agentTaskTypesMissingExternalMarker`); a CI guard asserts its shape/placement. Use THIS one marker to opt a task out — never a second/synonym opt-out property.", shape: ' (the nested in a wrapper inside a senior:* agent service task\'s — a bare directly under extensionElements is NOT the accepted shape; see SPEC.md)', }, "agentTask.historyRead": { category: "wire", name: "agentTask.historyRead", owner: "app/agentic/agent-history.ts", semantics: "The engine-native AgentInstance/AgentHistory READ path (issue #745/#747, umbrella #746 — the CONSUMER half of the PRODUCER `agentTask.agentDefinition` marker). The Cockpit HISTORICAL transcript + per-turn/instance metrics are sourced from the engine read model through the SINGLE engine-read seam — `@nanobpm/urban`'s `EngineClient.searchAgentInstances`/`searchAgentInstanceHistory`/`getAgentInstance` (added in urban 0.93 / nanobpm/nano-ide#563; the escalation's option (a) — NO `orchestration-cluster-api-js` fork / no second broker-REST client, so the read path is exercised by the testkit WASM double, which records read-as-absence — an empty list / null — while a live engine validates the behavioural parity). ONE narrow reader (`AgentHistoryReader`) + ONE projection (`listAgentInstances`/`readAgentHistory`) in app/agentic/agent-history.ts, served by `GET /agentic/agent-instances` + `GET /agentic/agent-instances/{agentInstanceKey}/history`. Correlation keys are the AGENT-INSTANCE / PROCESS-INSTANCE / ELEMENT-INSTANCE keys (the same #544 per-occupancy element-instance handle the relay correlation uses) — NEVER the slash-bearing `job:` relay stream id, so the #744 gateway-proxy bug class is moot for settled history. The token-granular relay stays the LIVE overlay only (settled history = engine, live tail = relay). Advisory read-only (ADR 0056): it observes the engine read model, never activates/completes a job or gates a sequence flow. The wire shapes (`AgentInstance`/`AgentInstanceList`/`AgentHistoryRecord`/`AgentHistory` + the metrics shapes) are declared in openapi.yaml and reuse the `@nanobpm/agentic/transcript` parity conversation grammar the transcript store already models. Consume this ONE read path — do not add a second agent-history client or a synonym endpoint.", shape: "GET /agentic/agent-instances[?processInstanceKey&rootProcessInstanceKey&elementId&status] → AgentInstanceList; GET /agentic/agent-instances/{agentInstanceKey}/history[?role&loopIteration&elementInstanceKey] → AgentHistory", }, } as const satisfies Record; export const TYPE_CONTRACTS = { PermissionPolicy: { category: "type", name: "PermissionPolicy", owner: "app/agentic/transcript-events.ts", semantics: "The role's permission policy a `permission` transcript-event REQUEST is tagged with (issue #559): `\"escalate\"` (a human must be asked — cockpit renders an Allow/Deny prompt, the escalation bridge raises a user task) vs `\"yolo\"` (auto-allowed, never prompts). Exported from app/agentic/transcript-events.ts alongside the shared permission contract types (`PermissionOption`, `PermissionOptionKind`, `PermissionRequestEvent`, `PermissionResolutionEvent`, and the derived `DerivedPermission` surface on `DerivedView`/`DerivedTurn`). The cockpit-render and escalation-bridge siblings IMPORT these — they must not reinvent a divergent permission shape or a synonym policy enum.", module: "app/agentic/transcript-events.ts", }, BlackboardEntry: { category: "type", name: "BlackboardEntry", owner: "app/blackboard.ts", semantics: "The snake_case, agent-facing view of a blackboard entry — the HTTP-hook boundary shape every caller and agent consumes. Both the read and write halves import this ONE definition.", module: "app/blackboard.ts", }, PlanDep: { category: "type", name: "PlanDep", owner: "app/plan.ts", semantics: "One INTER-epic dependency edge (issue #292): dependent epic `plan_key` waits for producer epic `depends_on_plan_key`, gated by the producer's `{ package, capability_ref }` capability descriptor. This ONE row shape backs BOTH the durable `plan_deps` table (materialized by planner lowering S3) AND its FK-free admission-staging twin `admitted_plan_deps` (staged by the S2 door). Set admission (S2), planner lowering (S3), and operator visibility (S4) all import it from app/plan.ts — no re-declared synonym.", module: "app/plan.ts", }, SessionCheckpoint: { category: "type", name: "SessionCheckpoint", owner: "app/world/checkpoint.ts", semantics: "The mind/world checkpoint contract shape (issue #324, ADR 0062 Slice 4/5). `{ commitSha, effectLedger }` — the ONE type both the world marker (recorded in `world_checkpoints`/`world_effects`) and the mind checkpoint (Slice 1's `session.checkpoint`) derive from, so a single derivation feeds both halves and they cannot diverge. Its `effectLedger` is `Effect[]` (the fence-keyed irreversible-action ledger). The world half imports it from app/world; when Slice 1's harness-side `@nanobpm/agentic/session` lands it MUST reuse this shape, not re-declare a synonym.", module: "app/world/checkpoint.ts", }, DurableResumeRegistry: { category: "type", name: "DurableResumeRegistry", owner: "app/durableResume.ts", semantics: "The `durable-resume` ENROLMENT GATE (issue #325, ADR 0062 Slice 5/5, the INTEGRATION slice). `durable-resume` is a worker attribute declared at enrolment (ADR 0056 §7 — capability gates enrolment, NEVER the routing token `network.role#seat`), recorded per worker instance in `worker_durable_resume` (migration 052). The enrol door (`operations/enrolAgenticWorker.ts`) records it via `recordEnrolment`; `app/service.ts` consults `fleetSupportsDurableResume` before emitting the world-restore `commitSha` (the `io.nanobpm.agentTask.repository` envelope) so a re-leased `senior:pr-review` round RESUMES only on a participating fleet and gracefully DEGRADES (redriven from scratch) otherwise. Consume this ONE module for the durable-resume gate — do not re-declare a synonym or read the flag off a second store.", module: "app/durableResume.ts", }, HarnessProtocolRegistry: { category: "type", name: "HarnessProtocolRegistry", owner: "app/harnessProtocol.ts", semantics: "The durable registry of per-worker advertised harness protocol version (issue #802), over `worker_harness_protocol` (migration 107) through the RAD `Table` surface — mirroring {@link DurableResumeRegistry}. `harness-protocol` is a worker ATTRIBUTE advertised at enrolment (ADR 0056 §7 — capability gates enrolment, NEVER the routing token `network.role#seat`), recorded per worker instance by `recordEnrolment` from the enrol door (`operations/enrolAgenticWorker.ts`); a MISSING version is first-class STALE. It is the ONE shared source consumed by enrolment (record), supply (`getAgenticSupply` staleness verdict) and registry reporting (`computeRegistryReport` → `staleWorkers`, which folds a non-empty stale set into the overall red drain signal). The bounded `protocolsFor(instances)` read scopes to the live presence keys via a single `WHERE instance IN (…)` query — never an N+1 per-worker `findOne`. Consume this ONE module for the harness-protocol gate — do not re-declare a synonym or read the version off a second store.", module: "app/harnessProtocol.ts", }, } as const satisfies Record; export const CAPABILITY_URL_CONTRACTS = { blackboard: { category: "capability-url", name: "blackboard", owner: "app/blackboard.ts", semantics: "Per-plan blackboard side-channel. The per-plan token IS the credential; it rides the query string so the agent GET/POSTs the exact string it was handed with no header assembly. The base is `NANO_WORKFORCE_BASE_URL` (one env contract), never hardcoded.", scheme: "/app/api/hooks/blackboard?token=", }, } as const satisfies Record; /** Every contract, across all categories — the flat list the reconciliation pass and CI check walk. */ export function allContracts(): Contract[] { return [ ...Object.values(ENV_CONTRACTS), ...Object.values(WIRE_CONTRACTS), ...Object.values(TYPE_CONTRACTS), ...Object.values(CAPABILITY_URL_CONTRACTS), ]; } /** All names deliberately retired as synonyms of a live env contract, mapped to the canonical name * that replaced them. A read of any synonym is a CI failure — the #223 phantom-fallback failure * mode, guarded categorically. */ export function rejectedEnvSynonyms(): Map { const out = new Map(); for (const c of envContracts()) { for (const syn of c.rejectedSynonyms ?? []) out.set(syn, c.name); } return out; } // --------------------------------------------------------------------------------------------- // Near-duplicate DECLARATION detection — the write-time / declare-time guard. Given a proposed new // contract, is there an EXISTING one that is the same thing under a different name (a synonym), or a // contradicting one (same name, different semantics/owner)? Surfaced to the writer so a duplicate is // caught at authoring time, not at runtime. // --------------------------------------------------------------------------------------------- /** A near-duplicate finding between a proposed declaration and an existing registry contract. */ export interface DeclarationConflict { /** `synonym`: same category + equivalent semantics under a different name (two names, one thing). * `contradiction`: same name but different semantics/owner (one name, two meanings). * `rejected-synonym`: the proposed name is a retired synonym of a live env contract. */ readonly kind: "synonym" | "contradiction" | "rejected-synonym"; readonly proposedName: string; readonly existingName: string; readonly detail: string; } /** Normalise free-text semantics to a comparable token bag: lowercased, punctuation stripped, * short stopwords dropped. Deliberately crude — it only needs to catch "two names for one value". */ function semanticTokens(text: string): Set { const STOP = new Set([ "the", "a", "an", "of", "to", "for", "and", "or", "is", "it", "this", "that", "with", "on", "in", "at", "by", "as", "per", "its", "so", "one", "value", "used", "use", ]); return new Set( text .toLowerCase() .replace(/[^a-z0-9]+/g, " ") .split(" ") .filter((w) => w.length > 2 && !STOP.has(w)), ); } /** Jaccard overlap of two token bags (0..1). */ function overlap(a: Set, b: Set): number { if (a.size === 0 || b.size === 0) return 0; let inter = 0; for (const w of a) if (b.has(w)) inter++; return inter / (a.size + b.size - inter); } /** The semantics-overlap threshold above which two DIFFERENTLY-named contracts of the same category * are flagged as probable synonyms. Tuned to be advisory (surface for a human/agent decision), not a * hard gate. */ export const SYNONYM_THRESHOLD = 0.6; /** Detect near-duplicate declarations of `proposed` against `existing` (defaults to the registry). * Returns every conflict found so a writer sees synonyms AND contradictions AND rejected synonyms. */ export function detectDeclarationConflicts( proposed: { category: ContractCategory; name: string; semantics: string }, existing: Contract[] = allContracts(), ): DeclarationConflict[] { const out: DeclarationConflict[] = []; const rejected = rejectedEnvSynonyms(); if (proposed.category === "env" && rejected.has(proposed.name)) { out.push({ kind: "rejected-synonym", proposedName: proposed.name, existingName: rejected.get(proposed.name) ?? "", detail: `'${proposed.name}' is a retired synonym of '${rejected.get(proposed.name)}'; reuse the canonical key, do not reintroduce the fallback.`, }); } const proposedTokens = semanticTokens(proposed.semantics); for (const c of existing) { if (c.name === proposed.name) { if (c.category === proposed.category && overlap(proposedTokens, semanticTokens(c.semantics)) < SYNONYM_THRESHOLD) { out.push({ kind: "contradiction", proposedName: proposed.name, existingName: c.name, detail: `'${proposed.name}' already exists (owner ${c.owner}) with different semantics; reconcile before redeclaring.`, }); } continue; } if (c.category !== proposed.category) continue; if (overlap(proposedTokens, semanticTokens(c.semantics)) >= SYNONYM_THRESHOLD) { out.push({ kind: "synonym", proposedName: proposed.name, existingName: c.name, detail: `'${proposed.name}' looks semantically equivalent to existing '${c.name}' (owner ${c.owner}); reuse it instead of authoring a synonym.`, }); } } return out; }