Context
Split from #16584 on @neo-opus-grace's review of PR #16590. The hazard that ticket's AC 5 named — a default-strategy manage_knowledge_base call deleting corpus rows unmetered — is closed by that PR, one layer down: the work-volume gate now counts idsToDelete and precedes every deletion, so a delete-heavy MCP call is refused before any row is removed.
What is not delivered is that AC's stated evidence location: a spec at the tool boundary rather than at VectorService. Merging #16590 with that criterion in the list would have closed a ticket whose evidence bar was not met, so it is split here rather than quietly dropped.
The Problem
The guarantee agents actually depend on is "manage_knowledge_base cannot wipe the corpus", and today that is proven only two layers below the surface an agent touches:
toolService.mjs:77 manage_knowledge_base -> DatabaseService.manageKnowledgeBase({...args, viaMcp: true})
DatabaseService:579 action 'embed'|'sync' -> embedKnowledgeBase({viaMcp, staleStrategy})
DatabaseService:668 embedKnowledgeBase -> VectorService.embed(dataPath, {viaMcp, staleStrategy})staleStrategy is optional at every hop, so omitting it inherits the destructive default. The gate protects the outcome, but nothing pins the contract at the boundary — a future refactor could thread viaMcp differently, or add a caller that bypasses embedKnowledgeBase, and every existing spec would stay green.
The Architectural Reality
The reason this is its own ticket rather than three more lines in #16590: the MCP dispatch path cannot be exercised naively. services.mjs wraps each service in a Zod-validating Proxy (makeSafe), and a closure-injected viaMcp: true is stripped before it reaches the gate unless the flag is declared in the OpenAPI contract. KBBackupRestoreWipe.integration.spec.mjs already documents that exact trap for the sibling path, and the input-side strip class is #16585.
So a faithful boundary spec must choose deliberately between:
DatabaseService.manageKnowledgeBase — one layer above VectorService, the tool's own service entry, no Proxy in the way. Cheap and honest, but not literally the MCP surface.
- Full
callTool('manage_knowledge_base', ...) dispatch — the real surface, but it must first establish that viaMcp survives makeSafe, which is contract work rather than test work.
Picking between those, with the reasoning recorded, is part of this ticket.
The Fix
- A spec proving
manage_knowledge_base {action: 'embed'} and {action: 'sync'} without staleStrategy cannot delete corpus rows: assert the rows still exist after the call, not merely that a refusal payload came back.
- A negative control: the same call with an explicit destructive strategy and a below-threshold volume still deletes, so the spec is not passing because the path is unreachable.
- Record which boundary was chosen and why the other was rejected.
Decision Record impact
none. Test coverage for an existing contract; no runtime behaviour changes.
Acceptance Criteria
Out of Scope
- The scoping fix and the gate changes themselves — delivered by #16590.
- Declaring
viaMcp in any additional contract, or the parity guard for undeclared params — #16585.
- The
neo/neo shared-stamp collision — epic #16566.
Related
- #16584 / PR #16590 — the fix whose AC 5 evidence bar this carries.
- #16585 — the Zod-strip class that makes the dispatch path awkward to test.
- #16587 — the same strip mechanism, in production.
Origin Session ID: 6004a4aa-2089-4b14-b73f-b58c08cf53d9
Retrieval Hint: query_raw_memories("manage_knowledge_base tool boundary spec delete refusal") · DatabaseService.mjs:579-668
Authored by @neo-opus-vega (Claude Opus 5).
Context
Split from #16584 on @neo-opus-grace's review of PR #16590. The hazard that ticket's AC 5 named — a default-strategy
manage_knowledge_basecall deleting corpus rows unmetered — is closed by that PR, one layer down: the work-volume gate now countsidsToDeleteand precedes every deletion, so a delete-heavy MCP call is refused before any row is removed.What is not delivered is that AC's stated evidence location: a spec at the tool boundary rather than at
VectorService. Merging #16590 with that criterion in the list would have closed a ticket whose evidence bar was not met, so it is split here rather than quietly dropped.The Problem
The guarantee agents actually depend on is "
manage_knowledge_basecannot wipe the corpus", and today that is proven only two layers below the surface an agent touches:toolService.mjs:77 manage_knowledge_base -> DatabaseService.manageKnowledgeBase({...args, viaMcp: true}) DatabaseService:579 action 'embed'|'sync' -> embedKnowledgeBase({viaMcp, staleStrategy}) DatabaseService:668 embedKnowledgeBase -> VectorService.embed(dataPath, {viaMcp, staleStrategy})staleStrategyis optional at every hop, so omitting it inherits the destructive default. The gate protects the outcome, but nothing pins the contract at the boundary — a future refactor could threadviaMcpdifferently, or add a caller that bypassesembedKnowledgeBase, and every existing spec would stay green.The Architectural Reality
The reason this is its own ticket rather than three more lines in #16590: the MCP dispatch path cannot be exercised naively.
services.mjswraps each service in a Zod-validating Proxy (makeSafe), and a closure-injectedviaMcp: trueis stripped before it reaches the gate unless the flag is declared in the OpenAPI contract.KBBackupRestoreWipe.integration.spec.mjsalready documents that exact trap for the sibling path, and the input-side strip class is #16585.So a faithful boundary spec must choose deliberately between:
DatabaseService.manageKnowledgeBase— one layer aboveVectorService, the tool's own service entry, no Proxy in the way. Cheap and honest, but not literally the MCP surface.callTool('manage_knowledge_base', ...)dispatch — the real surface, but it must first establish thatviaMcpsurvivesmakeSafe, which is contract work rather than test work.Picking between those, with the reasoning recorded, is part of this ticket.
The Fix
manage_knowledge_base {action: 'embed'}and{action: 'sync'}withoutstaleStrategycannot delete corpus rows: assert the rows still exist after the call, not merely that a refusal payload came back.Decision Record impact
none. Test coverage for an existing contract; no runtime behaviour changes.Acceptance Criteria
manage_knowledge_baseembed/sync with a largeidsToDeleteis refused, and the rows are still present afterwards.viaMcpreaching the gate is proven rather than assumed — otherwise the spec silently tests the un-gated path.Out of Scope
viaMcpin any additional contract, or the parity guard for undeclared params — #16585.neo/neoshared-stamp collision — epic #16566.Related
Origin Session ID: 6004a4aa-2089-4b14-b73f-b58c08cf53d9
Retrieval Hint:
query_raw_memories("manage_knowledge_base tool boundary spec delete refusal")·DatabaseService.mjs:579-668Authored by @neo-opus-vega (Claude Opus 5).