LearnNewsExamplesServices
Frontmatter
id16611
titleFour service capabilities are unreachable: the contract omits a parameter each method already reads
stateClosed
labels
bugaiagent-os
assigneesneo-opus-vega
createdAtAug 7, 2026, 2:33 AM
updatedAtAug 11, 2026, 12:05 PM
githubUrlhttps://github.com/neomjs/neo/issues/16611
authorneo-opus-vega
commentsCount2
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 11, 2026, 12:05 PM

Four service capabilities are unreachable: the contract omits a parameter each method already reads

Closed Backlog/active-chunk-13 bugaiagent-os
neo-opus-vega
neo-opus-vega commented on Aug 7, 2026, 2:33 AM

Context

Found by the parity checker built for #16585, on its first run against the live tree. Each of these is the same defect class as #16577 — a parameter the service reads that its OpenAPI operation does not declare — so ai/services.mjs's Zod facade strips it and the read evaluates to its default, always.

The consequence is not a crash. It is a capability that exists in the service and is unreachable from outside it. No error, no warning, and every direct-construction unit test passes because those bypass the Proxy.

Observed — five flagged, four genuine

[lint-openapi-service-parity] FAILED — 5 consumed-but-undeclared parameter(s)
operation param signature consequence
manage_knowledge_base viaMcp manageKnowledgeBase({action, viaMcp = false, ...}) WITHDRAWN — correct as designed. See the viaMcp section below.
manage_knowledge_base staleStrategy same always undefined; stale-handling strategy unselectable
query_documents includeMetadata queryDocuments({query, type='all', limit=25, includeMetadata=false}) always false; metadata is unreachable through the tool
get_context_frontier depth getContextFrontier({depth = 2} = {}) (GraphService.mjs:1340) frontier depth is permanently 2

viaMcp is WITHDRAWN — it is a second correct design, not a third defect (corrected 2026-08-07)

@tobiu answered the design question directly and the answer inverts this row: "for an MCP tool use, viaMcp should be always true." Traced, and it already is:

path what happens to viaMcp correct?
MCP tool dispatch ToolService.mjs:124 Zod-parses first, stripping any caller value; then the mapping at toolService.mjs:77 re-adds viaMcp: true after validation yes — forced true, and un-spoofable by the caller
ai/services.mjs wrapper (CLI, scripts) stripped → the viaMcp = false default applies yesVectorService.mjs:981 documents false as the deliberate bypass, "explicit opt-in to long-running work", and :1125 names CLI as its caller

So both paths already get the value they should, from opposite directions, and neither takes it from the caller. Declaring it would be the regression: VectorService.mjs:1133 gates on if (viaMcp && …), so an agent able to send viaMcp: false through the KB MCP surface could switch the work-volume gate OFF — and per @tobiu, KB and MC are the only publicly accessible surfaces, which is precisely where that must not be settable.

This is the same category as who_is_online.now below: a value that must never be caller-supplied. It differs only in mechanism — now has a correct default, while viaMcp has a correct default on one path and an explicit override on the other.

What I got wrong, recorded because the reasoning error is the reusable part. I read the service default (viaMcp = false) and the stripping facade, and concluded "always false, capability unselectable". I never checked the dispatch path, where the mapping re-adds it. Reading a default and a stripper is not reading the call path — and mid-correction I briefly swung the other way and nearly filed the inverse claim (that every MCP call was silently bypassing the gate), which toolService.mjs:77 falsifies just as flatly. Both errors came from stopping one file short of the producer.

staleStrategy survives on this operation and is the real finding here. Same Zod strip at ToolService.mjs:124, and nothing re-adds it — so it is genuinely unreachable through MCP, unlike its neighbour.

The fifth is NOT a defect, and the distinction matters

who_is_online reads now, and that is correct as-is:

async whoIsOnline({family, verbose = false, now = new Date()} = {}) {

now is an injected clock with a working default, used identically across bootstrap, retireStaleHarnessPresence and whoIsOnline — a test seam, not an input. Declaring it would be actively harmful: it would let a caller supply an arbitrary "current time" to a liveness computation, i.e. lie about whether a peer is online. It is baselined in the checker with that reason rather than fixed.

Stating it explicitly because the tempting read of a 5-row failure is "5 defects," and one of them is a correct design whose exposure would be a regression. A checker's output is a list of findings, not verdicts.

The Problem

Each row is a one-line contract omission, but the four are not one fix — declaring a parameter makes it agent-settable, and that is a design decision per parameter:

  • viaMcp — withdrawn, see above. The question this bullet posed ("should an agent be able to choose it?") had an obvious answer I hedged on: no, and it is already enforced. Recording the hedge rather than deleting it, because "it is not obviously yes" was me declining to trace one more file.
  • staleStrategy selects stale-row handling during sync — adjacent to the stale-deletion scoping in #16590 and to the corpus-loss incident. Exposing a strategy selector to agents needs the same care.
  • includeMetadata looks safe and payload-affecting: it widens every query_documents response, which interacts with the tool-result size concerns in #16588.
  • depth on the context frontier is an unbounded-traversal knob. If declared, it needs a maximum, or an agent can request an arbitrarily deep walk.

So the remedy is four small, individually-reasoned decisions — declare with bounds, or record the parameter as deliberately internal.

Dispositions — researched 2026-08-07, and NOT ONE of them is "declare it"

Each was traced to its producer before deciding. Three of the four dispositions differ from what this ticket's Problem section assumed, so the assumptions are corrected in place below rather than left standing.

get_context_frontier.depthDELETE the parameter. It is dead, not throttled.

The Problem section says "frontier depth is permanently 2", which implies a knob stuck at its default. It is worse than that. Measured by AST over the method's full body rather than by eye:

GraphService#getContextFrontier — body 2484 chars
occurrences of `depth` in the full body: 0

It is destructured in the signature (getContextFrontier({depth = 2} = {})) and never read. The lint flags it correctly — destructuring is a read by the checker's definition — but the capability does not exist at any value.

So both obvious dispositions are wrong. Declaring it is the worst available option: an agent sets depth: 5, receives no error, and gets depth-2 results — a silently-lying contract, which is the exact failure class this instrument exists to remove, re-introduced by "fixing" the finding. Baselining it would record "deliberately internal" about a parameter that does nothing, so the stated reason would be false. Deleting the dead parameter removes the finding at its root and takes a lie-in-waiting with it.

query_documents.includeMetadatabaseline PERMANENT. Internal by design, and nothing is lost.

QueryService.mjs:137 states it: "Internal hydration flag for RAG synthesis callers." Exactly one caller repo-wide — SearchService.mjs:305, queryDocuments({query, type, limit, includeMetadata: true}) — hydrating result.metadata for answer synthesis.

The Problem section framed this as "metadata is unreachable through the tool", i.e. a loss. There is no loss: the synthesis surface that needs metadata is ask_knowledge_base, which sets the flag itself. query_documents returning ranked source references is its contract, not a degraded version of one. This is the who_is_online.now class — an internal flag with a working default and a single internal consumer.

manage_knowledge_base.staleStrategybaseline PERMANENT. The operator surface is an env var, and one of the two values is the destructive path.

STALE_STRATEGIES is a validated two-value enum (VectorService.mjs:20) — delete-upfront and shadow-swap — and resolveStaleStrategy throws on anything else, so declaring it would be schema-safe. Schema-safe is not the same as safe:

  • shadow-swap embeds into a shadow collection and swaps atomically. Non-destructive.
  • delete-upfront deletes stale rows before embedding. A failure between the delete and the embed leaves the corpus missing both the removed rows and their replacements — which is the shape of the corpus losses this week, and the reason the ceiling and retention work exist at all.

Declaring it would let a caller through the KB MCP surface — one of only two publicly reachable surfaces — select the destructive path against the live corpus. The code already selects shadow-swap where it matters (VectorService.mjs:822, :850), and the operator-facing control already exists as an environment variable: NEO_KB_STALE_STRATEGY, read at syncKnowledgeBase.mjs:86. So the capability is reachable by whoever should reach it, and the MCP parameter would add nothing except the ability to choose the dangerous branch remotely.

get_all_summaries.categoryremains open, and it is the worst of the set for a caller.

A sixth row, surfaced by the ToolService dispatch join's ADVISORY direction rather than by a stripped read — so it is the inverse defect from every other row here, and it arrived after this ticket was filed.

get_all_summaries declares category, its parameter description reads "Filter by category", and the operation description instructs an agent to "find sessions related to a specific category of work (e.g. 'refactoring')". The bound handler is SummaryService.listSummaries({limit, offset, agentIdentity}) — it never reads it. The two category occurrences in that file are output shaping (category: metadata.category) and a different method, querySummaries, which genuinely does filter by it.

Worse than an unreachable capability, because the documentation actively instructs callers to use it. Every other row here is a capability an agent cannot reach and does not know about. This one an agent is told to use, sends, and receives silently unfiltered results for — no error, no warning, and the filtering it wanted exists one method over.

Two dispositions, both behaviour changes rather than lint edits, which is why it is baselined rather than fixed in the instrument's PR:

  • Wire it through — pass category into the storage query in listSummaries, matching what querySummaries already does. Makes the documentation true.
  • Remove it from the contract and point callers at query_summaries. Makes the documentation honest.

I lean toward wiring it through, because the description is the more likely intent and a removal silently narrows a surface agents may already be sending. But it is a behaviour change on a memory-read path and it gets its own reasoning rather than riding on that lean.

get_session_memories.memorySharingremains open. This is the one I want challenged.

Surfaced by the ToolService dispatch join, not the original run, so it is a fifth row rather than one of four. It is a tenant-isolation policy override, unreachable on get_session_memories while declared on two sibling memory-core operations — so precedent says exposing it is acceptable somewhere.

Baselined as DEBT for now, deliberately not decided: declaring it changes which memories an agent can read, and that belongs in a disposition with a stated reason rather than in a lint PR that happened to find it. The honest fork: either the siblings' precedent extends here and it should be declared, or the siblings are themselves over-exposed and the precedent is the defect. I do not know which, and guessing would settle a memory-visibility question by accident.

Sequencing note: the edits for all of the above touch PARITY_BASELINE, which PR #16612 is currently modifying. The decisions above are the work; the edits are mechanical and land once that PR merges, so this ticket is decision-complete and merge-blocked rather than unstarted.

Acceptance Criteria

  • Each of the three remaining (staleStrategy, includeMetadata, depth) is dispositioned individually and with a stated reason: either declared in its operation's schema (with bounds where the value is a traversal or size knob), or recorded as deliberately internal in the checker's PARITY_BASELINE.
  • depth is removed from getContextFrontier's signature. Measured dead (0 occurrences in the method body)FALSIFIED 2026-08-10 by the gate, and the original measurement was mine and wrong. There are two getContextFrontiers. The operation binds MemoryService's, which declares no parameters and calls GraphService.getContextFrontier() forwarding nothing — that is the method I measured. The live depth read belongs to GraphService's same-named method, whose sole caller is GoldenPathSynthesizer passing a literal {depth: 1}. Deleting the baseline row reds the lint with get_context_frontier reads depth — ai/services/memory-core/GraphService.mjs. So the parameter is neither dead nor MCP-reachable: deleting it breaks an internal traversal knob in use, and declaring it advertises one the bound method cannot forward. Superseded disposition: PERMANENT baseline carrying the wrong-method history, so the next author does not repeat the name collision.
  • viaMcp is dispositioned: stays baselined, never declared, with the two-path rationale above preserved so a future sweep does not helpfully widen the schema and hand the work-volume gate to callers.
  • The staleStrategy disposition cites #16577's finding, since that ticket established what the sibling parameter controls and what going wrong costs.
  • No row is resolved by widening the schema without asking whether the parameter should be agent-settable. A silently-ignored parameter and a wrongly-exposed one are both defects.
  • includeMetadata and staleStrategy are baselined PERMANENT with the reasons above — internal RAG hydration flag with one internal caller, and a destructive-branch selector whose operator surface is already NEO_KB_STALE_STRATEGY.
  • memorySharing is either declared or baselined with a stated reason, and the decision records whether the two sibling operations that DO declare it are precedent or are themselves over-exposed.
  • After disposition, ai:lint-openapi-service-parity is clean with every remaining baseline row carrying a reason.
  • who_is_online.now stays baselined and is not declared — with the "an agent could lie about the current time to a liveness computation" rationale preserved, so a future sweep does not helpfully "fix" it.

Out of Scope

  • The checker itself#16585 delivers the instrument; this ticket dispositions what it found.
  • ingest_source_files — already repaired via #16583.
  • A sweep for the inverse (declared but never read). The checker reports that as a warning by design; intentional forward-compat is legitimate.

Related

  • #16585 — the instrument that found these; this ticket is its first output.
  • #16577 / #16583 — the original two instances and the repair, and the authority on what viaMcp controls.
  • #16588 — tool-result payload size, directly relevant to the includeMetadata disposition.
  • #16590 — tenant-scoped stale-id gathering, adjacent to the staleStrategy disposition.

Origin Session ID: 4141258c-36d3-4788-b0c2-ab3ebe0867be

Live latest-open sweep: checked the latest open issues at 2026-08-07T00:3xZ plus a targeted search on includeMetadata / staleStrategy / contextFrontier depth; no equivalent found (#16591 is the nearest neighbour and concerns a refusal proven below the agent-facing surface, a different defect on adjacent ground).

Retrieval Hint: query_raw_memories("consumed but undeclared openapi parameter service capability unreachable") · ai/scripts/lint/lint-openapi-service-parity.mjs

Authored by @neo-opus-vega (Claude Opus 5).

tobiu
tobiu Aug 7, 2026, 10:54 AM

@neo-opus-vega Regarding

viaMcp governs whether a bulk path passes through the MCP work-volume gate. https://github.com/neomjs/neo/issues/16577 shows getting this wrong causes KB_VECTOR_EMBED_FAILED above mcpSyncMaxChunks. Whether an agent should be able to choose it is exactly the question that ticket wrestled with; it is not obviously "yes".

It is an obvious "yes". there is polling and push based KB ingestion. For security reasons, KB and MC are the only publicly accessible surfaces. push goes through the KB MCP tool. As a side effect, agents also can use the tool manually, which has potential for own content. However, for an MCP tool use, viaMcp should be always true.

@neo-opus-vega - 2026-08-07T08:58:59Z

@tobiu Traced it, and you are right — viaMcp is withdrawn from this ticket. Four rows became three, and the body carries the correction.

It is already always true on the MCP path, from two directions I had not connected:

path viaMcp correct?
MCP tool dispatch ToolService.mjs:124 Zod-parses first, stripping any caller value; the mapping at toolService.mjs:77 then re-adds viaMcp: true after validation yes — forced, and un-spoofable
ai/services.mjs wrapper (CLI, scripts) stripped → the viaMcp = false default applies yesVectorService.mjs:981 documents false as the deliberate bypass, "explicit opt-in to long-running work"

Both paths get the value they should, from opposite directions, and neither takes it from the caller. Declaring it would be the regressionVectorService.mjs:1133 gates on if (viaMcp && …), so an agent able to send viaMcp: false through the KB surface could switch the work-volume gate off. Given KB and MC are the only publicly accessible surfaces, that is exactly where it must not be settable. Same category as who_is_online.now, differing only in mechanism.

Two errors on the way, both from stopping one file short of the producer. First I read the service default plus the stripping facade and concluded "always false, capability unselectable" — never checking the dispatch path where the mapping re-adds it. Then, mid-correction, I swung the other way and was about to report that every MCP call silently bypasses the work-volume gate — which toolService.mjs:77 falsifies just as flatly. Reading a default and a stripper is not reading the call path.

staleStrategy survives on that operation and is the real finding. Same Zod strip at ToolService.mjs:124, and nothing re-adds it — genuinely unreachable through MCP, unlike its neighbour. So the two rows I had bundled as one class are a correct design and a live gap sitting side by side, which is the distinction the ticket now makes.

Remaining: staleStrategy, includeMetadata, depth. The viaMcp AC is checked off as stays baselined, never declared, with the rationale preserved so a future sweep does not helpfully widen the schema.

Lane order unchanged: #16612 finishes first (the advisory direction and the end-to-end fixtures landed; the ToolService join is next), then these three.

Authored by @neo-opus-vega (Claude Opus 5).

  • 2026-08-07T10:39:06Z @neo-opus-vega referenced in commit 3a916f6 - "fix(ci): push and pull_request path filters are now asserted equal, not maintained in parallel (#16585)

Cycle-4 blocker. I fixed ONE of two path lists. The three ToolService authorities went into pull_request.paths and not push.paths, so pushes to dev never re-ran the guard on the mapping table its dispatch join derives every handler from. @neo-gpt measured it with micromatch — true under pull_request, false under push, with ai/services/knowledge-base/QueryService.mjs as a positive control that is true under both, so the instrument was proven before the finding was reported.

This is the same defect I removed from backup.mjs this morning and cited in my own commit message hours ago: two lists that must agree are a list that will not. I then reproduced it while fixing a reachability gap, in the file I was editing.

GitHub Actions does not reliably expand YAML anchors in workflow files, so one authority is not available here and mirroring is unavoidable. What IS available is a mechanical equality assertion, so the next divergence fails locally instead of waiting for a reviewer to run micromatch. Reviewer attention is the wrong layer: the omission is invisible in a diff that SHOWS the addition.

The new spec also asserts every authority the lint reads is matched under both triggers, derived from SERVERS rather than listed, plus a negative control — a match-everything filter would satisfy every reachability assertion while making the gate run on every commit in the repo.

A NOTE ON THE MUTATION TEST, because it nearly misled me: my first attempt to re-introduce the live defect reported the spec PASSING, which reads as a vacuous guard. The perl pattern had silently not matched, so the mutation never applied. Re-applied with an assertion that the edit landed (1 to 0 occurrences) and the spec fails as intended. A mutation result means nothing until the mutation is proven to have happened.

Also in this bounded pass: all five PARITY_BASELINE reasons truth-folded against the researched #16611 dispositions — viaMcp withdrawn as correct-by-design, depth marked TRANSITIONAL pending deletion since it is measured dead, includeMetadata and staleStrategy reclassified PERMANENT with their real reasons — and get_all_summaries.category given a real owner as a #16611 ledger row rather than a dangling reference.

Refs #16612

Authored by @neo-opus-vega (Claude Opus 5)."

tobiu referenced in commit 71abf8b - "A parameter a service reads must be declared, or the facade strips it silently (#16585) (#16612) on Aug 7, 2026, 2:05 PM
tobiu closed this issue on Aug 11, 2026, 12:05 PM