Skip to content

Vocabulary drift that needs a decision, not a patch (9 items, reproduced) #954

Description

@drewstone

Vocabulary drift that needs a decision, not a patch

A census over src/ extracted every closed term list — 79 union aliases, 206 property-level unions, 33 as const arrays, 13 JSON-Schema enums, 25 literal switches — and compared every list against every other list sharing at least two members, then classified all 7878 distinct string literals by AST role into product writers, product readers, and test-only writers.

Twenty-five candidates came out. Twelve were refuted by reading the code and are recorded at the bottom so nobody re-opens them. Three are shipping as their own pull requests. The nine below are real, reproduced at file:line, and each needs a product decision or a change larger than a patch — so they are filed here rather than fixed silently.

Every item states what a user loses today, so the decision can be made on cost rather than on tidiness.


1. SpawnOpts.restart is accepted and never read

src/runtime/supervise/types.ts:649 declares the OTP restart class Restart = 'temporary' | 'transient' | 'permanent', and :675 puts readonly restart?: Restart on SpawnOpts. Grepping .restart across src/, bench/ and examples/ returns nothing — only restartCount, a different field on the settlement record, hard-coded to 0 at both construction sites (scope.ts:1217, scope.ts:1237).

What a user loses: a caller writes scope.spawn({ restart: 'permanent' }), expecting a failed child to be restarted, and the supervisor drops the option without a word. The type promises supervision semantics the Supervisor does not implement.

The decision: implement restart in Supervisor, or delete Restart and the field. Deleting is a public break (both are exported from ./kernel), so it needs a version and a line in the changelog either way.

2. An in-process worker is recorded as a sandbox sibling

src/mcp/executor.ts:38 types DelegationExecutor.placement as 'sibling' | 'fleet' | 'in-process', and src/mcp/in-process-executor.ts:224 sets placement: 'in-process'. Ten lines earlier its own describePlacement returns kind: 'sibling' (:215), because the two trace types it feeds — LoopSandboxPlacement.kind (src/runtime/types.ts:434) and LoopIterationDispatchPayload.placement (:533) — admit only 'sibling' | 'fleet'. run-loop.ts:728 writes placement: placement.kind straight into the loop.iteration.dispatch trace.

What a user loses: every local worktree-CLI iteration is recorded as a sandbox sibling, so any cost or latency breakdown split by placement mixes local runs into the sandbox bucket. The number is wrong, not missing, which is the harder failure to notice.

The decision: widen the two trace types to carry in-process, or drop the third member from DelegationExecutor.placement and stop claiming it. Both trace types are marked @stable, so widening is an API decision.

3. improve() can produce a surface a proposal cannot name

src/improvement/improve-types.ts:26 declares ImproveSurface with a rollout-policy member, implemented end to end — read at profile-surface.ts:78, applied at :260, path resolved at agent/surfaces.ts:201. The reporting vocabulary is a different type in a different package: AgentImprovementSurface (@tangle-network/agent-interface, dist/agent-candidate.d.ts:712) has knowledge where ImproveSurface has rollout-policy, and it is what changedSurfaceOrder (improvement-surfaces.ts:28), improvementSurfaceValues and AgentImprovementProposal.changedSurfaces all use.

What a user loses: a rollout-policy improvement cannot be named in a proposal, so it cannot flow through the intelligence spine to a review or a gate. Symmetrically, knowledge is proposable but improve() cannot produce it. Nothing in this repository can make the two agree.

The decision: which vocabulary is canonical, and in which package. Cross-repository.

4. The bridge reasoning check asserts a label for eleven harnesses it does not know

expectedBridgeAppliedReasoning (src/runtime/supervise/runtime.ts:3869) switches on five harnesses — pi, claude-code, codex, kimi-code, gemini — and its default arm makes a positive assertion for everything else. HarnessType has sixteen members. The result is compared at runtime.ts:3582 and a mismatch throws ValidationError at :3583.

The four swept harnesses (CODING_HARNESSES) are all covered, so the blast radius is the eleven outside it: nanoclaw, prime, hermes, openclaw, amp, factory-droids, forge, cursor, acp, cli-base, opencode.

What a user loses: for one of those harnesses, either a legitimate run is refused or a mis-applied reasoning effort passes as correct — and which of the two it is cannot be determined from this repository.

The decision: needs the cli-bridge side. Which of the eleven does the bridge actually serve, and how does each spell its effort flag?

5. Two spellings of "this failed" in one projection file

src/durable/observer-projection.ts:14 declares PursuitRunStatus = 'running' | 'done' | 'failed'; :100 declares PursuitNodeStatus = 'running' | 'done' | 'down'. Same file, same concept, two words. The downstream copies split the same way: WaterfallSpan.status (src/runtime/waterfall.ts:19) and the TUI's WorkerView.status (src/tui/top-model.ts:105) both use down.

What a user loses: a consumer joining run rows to node rows on status silently splits failed from down and reports two failure populations where there is one.

The decision: these are public projection shapes, so picking one word is a versioned break for whichever side loses.

6. A "not started" interactive reason nothing can emit

WorkerInteractiveUnavailableReason (src/runtime/supervise/types.ts:227) has five members. Four are reachable. 'interactive-session-not-started' (:239) — "The runner supports interactive sessions but this execution was not started in one" — is emitted nowhere in src/. Every failure path in scope.ts returns 'executor-exposes-no-interactive-session' (:1324, :1331, :1337), and the only other emitter is the bridge executor's 'provider-has-no-interactive-contract' (runtime.ts:1976).

What a user loses: a sandbox-backed worker on a provider that does support interactive sessions, started non-interactively, is told the executor exposes none. The operator reads that as "this harness can never be attached to" and stops looking, when the real answer is "start it interactively".

The decision: emitting the honest reason means the sandbox executor arm has to implement Executor.interactive() and ask the provider whether it supports control. That is a feature, not a patch. The alternative — deleting the member — is a public break, and an out-of-repo Executor implementation may already return it.

7. CoderReview.recommendation is documented as surfaced and is read by nothing

src/mcp/delegates.ts:99 says "Reviewer's recommendation — surfaced in traces", over a four-member union at :100. Nothing reads .recommendation on a CoderReview: the selection path reads review.approved (:356) and review.readiness (:357). The only .recommendation readers in src/ belong to auditIntent, a different type (src/runtime/audit-intent.ts:61).

What a user loses: a consumer-supplied reviewer that answers changes-requested produces an outcome indistinguishable from one that answers reject. The field asks a reviewer to think and then discards the answer.

The decision: emit it on the iteration trace as the docstring promises, or drop the field and the claim. Either changes a public type.

8. Two dead public vocabularies, one of them declared twice

  • UsageClass = 'inference' | 'intelligence' (src/intelligence/index.ts:245) — the declaration is its only occurrence in src/, tests/, bench/ and examples/. The neighbouring UsageSplit already carries the same idea as two named number fields.
  • ResearchSource = 'web' | 'corpus' | 'twitter' | 'github' | 'docs' — declared twice, byte-identically, at src/mcp/types.ts:69 and src/profiles/researcher.ts:38, exported from both ./mcp and ./profiles. There is no delegate_research tool: src/mcp/tools/ holds coordination, delegate, delegate-feedback, delegate-ui-audit, delegation-history and delegation-status. delegate.ts:6 records why — the per-profile tools were replaced by one generic delegate verb.

What a user loses: nothing at runtime. What they lose is time: two public names that look like contracts and are not, and a duplicated union that will drift the moment one copy is edited.

The decision: deleting a public export needs a version and a changelog line. Whether the second ResearchSource should be a re-export of the first is a smaller call that can go in the same change.

9. A memory-store manifest may declare a backend nothing provisions

src/intelligence/capability.ts:132 admits provision: 'sqlite' | 'neo4j' | 'vector'. Neither neo4j nor vector occurs anywhere else in src/, tests/ or bench/, and the file's own resolver (:290-320) handles only mcp-stdio and mcp-remote.

What a user loses: a manifest legally declares provision: 'neo4j', nothing provisions it, and the failure surfaces later as a missing store rather than as a refused manifest.

The decision: the file already uses an explicit EXTENSION POINTS label for wasm and a2a. Either label these the same way — and refuse them at load with a message naming the two that work — or implement them.


Shipping as pull requests, not filed here

  • The toolPartDecoders registry keyed a decoder under kimi, a name no caller can produce, and mapped it to the decoder for only one of the two shapes kimi emits.
  • delegation_history refuses ui-auditor, the only profile any code writes, and accepts two profiles nothing writes.
  • createExecutor returns a working factory for a backend it does not implement, and the TypeError lands one call later.

Refuted — do not re-open without new evidence

Claim Why it is not a defect
SpawnEvent.cancelled has 8 readers and 0 writers A cancelled node is journaled — as settled with status: 'down' (scope.ts:1785), the shape conformance/capabilities.json registers under cancellation-acknowledgement and tests/kernel/worker-cancellation.test.ts pins. The member is the replay vocabulary for external SpawnJournal implementors; deleting it produces 12 type errors in the replay and tree-view arms. The real drift was one stale sentence in supervisor.ts, fixed in a pull request.
otel-spans handles 3 of 7 hook targets It handles 3 of the 3 its input stream carries. A real depth-2 supervised run emits agent.spawn, agent.child, agent.turn and nothing else; the other four reach buildLoopOtelSpans / buildRuntimeEventOtelSpans on different channels. RuntimeHookTarget is an open union (`
bridge-worktree is an unowned eighth backend It is not a member of ExecutorConfig['backend'] at all. It is a label on ExecutorMaterialization.backend, which is string by design because providers and BYO executors stamp their own names. It has a producer (runtime.ts:4286), a consumer that names it by hand (stream-agent-turn.ts:319) and a passing test (tests/runtime/worktree-cli-executor.test.ts:709).
costProvenance has 3 members and two copies have 2 Both narrower copies are documented as deliberately narrow: a catalog estimate is not a receipt, so it belongs on the unknown arm (runtime.ts:3448, supervise/types.ts:373).
RuntimeHookPhase vs the openSandboxRun emit phase The emit helper is a private local type wrapping paired before/after/error hooks; it has no point-in-time event. Both general emitters carry the full four.
ResolvedAgentCandidateContainer.source has 0 product writers Written by the consumer-supplied AgentCandidateContainerPort.resolve; prepare.ts:731 cross-checks the external answer.
QuestionPolicy has 0 writers for 3 of 4 members Caller-supplied (opts.questionPolicy ?? 'auto'); failClosed is the residual else-branch at coordination.ts:1724, not a named case.
EvalRunEvent.status / .gateDecision: 6 of 11 members appear nowhere A wire type for an external eval harness. The one 'finished' comparison at top-model.ts:987 is a TUI log-line kind — a name collision, not a use.
priorFindingsStrategy default 'per-kind' is never compared agent-eval's selectPriorFindings filters a flat array by analyst_id, so the flat array is the per-kind form.
observe.ts:119 severity vs UiFindingSeverity Two unrelated scales. observe.ts matches agent-eval's AnalystSeverity exactly; all four UI-auditor copies agree with each other and with the validator at output-adapter.ts:86.
NodeStatus (7) vs TrajectoryNode.status (5) The journal only ever assigns the 5; acquiring and running are live-only. countStatuses is exactly exhaustive.
ContinuityMode (2) vs the journal's continuity (3) Deliberately wider, documented at supervise/types.ts:1189: the stamp covers mid-run deliveries too.

CoordinationEvent['type'] versus awaitableEventKinds is worth naming as the pattern the nine items above should adopt: it is already single-sourced with as const satisfies ReadonlyArray<CoordinationEvent['type']> (coordination.ts:618), read by both the JSON schema and the runtime filter, and its comment names this exact defect class.

Census blind spots

Stated so the absence of a finding is not read as evidence of absence:

  1. Value flow through a variable is invisible — a validate-then-forward vocabulary looks writer-less.
  2. Members are counted as strings, not as members: auto, done, high, local each belong to several lists.
  3. Consumers outside this repository are invisible. Ports, wire types and MCP schemas can be written entirely elsewhere.
  4. A registry meant to cover a union but typed Record<string, …> cannot be matched to that union at all — which is exactly how the kimi key survived.
  5. Open unions (| (string & {})) do not parse as literal unions, so drift among their well-known members is unmeasured.
  6. Every default: arm reads as an incomplete switch; separating an intentional default from a forgotten case needed reading.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions