// 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;
}