docs: record the tool input gate contract - #120
Merged
Merged
Conversation
ADR-0015: zod owns every constraint a schema can express and fails as INVALID_PARAMS with details.issues; INVALID_ARGUMENT is reserved for semantic argument faults a schema cannot express. Handlers must not re-check what their own schema states, and tool tests reach a tool through registerTool rather than buildXTool(deps).handler. Names the three sites where the old split produced unreachable validation: read_notes' four post-gate checks, readThreshold's range branch (unreachable at all four call sites), and the three UNSUPPORTED_VALUE_TYPE throws excluded by set_property's value union. Refines ADR-0003 without superseding it — the ToolHandlerError envelope is unchanged. Refs #112 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mcp-server-shape.md §"Tool handler contract" said handlers throw INVALID_ARGUMENT on bad input, with no mention of the gate that rejected malformed input two frames earlier. Describe registerTool's wrapSchemaWithCoercion + .strict() + safeParse pass and the INVALID_PARAMS it produces, narrow the handler bullet to the semantic faults a schema cannot express, and state the testing seam: tool tests go through registerTool and test/_gate.ts, never .handler. input-coercion.md described the wrapper as coercion only; add that it also returns z.object(shape).strict(), so unknown keys are rejected rather than stripped — including a vault key against a single-vault server, where vaultParamShape contributes no vault parameter at all. Refs #112 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the one-line rule to AGENTS.md §"Run / check" so the convention is in the file agents read first, and marks tasks 1.8 and 4.1-4.5 complete. The docs sweep for the same wrong claim found seven INVALID_ARGUMENT mentions across docs/, README.md, and AGENTS.md. Only mcp-server-shape.md needed correcting (done in the previous commit); the rest describe genuine semantic faults a schema cannot express — exactly-one-of name/path, an empty `replace` (edit-note.ts declares `z.string().optional()` with no .min(1), so that branch is reachable), INVALID_FILTER mapping, and the reader's "never produced here" note. ADR-0003's own wording is left intact: an ADR is an immutable record, and the refinement lives in its INDEX status cell and in ADR-0015. Refs #112 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Syncs both delta specs into openspec/specs/: - read-notes-content-modes — the content enum, the paths union and its 1-50 bound are now stated as gate-enforced, so a violation fails with INVALID_PARAMS before the handler runs. The invalid-content scenario moves from INVALID_ARGUMENT to INVALID_PARAMS, the legacy fields scenario from "has no effect" to "is rejected as an unrecognized key", and two scenarios are added for the out-of-range paths bound and the stringified paths array. - tolerant-arguments — two new requirements: the coercing, unknown-key-rejecting boundary applies uniformly to every registered tool with no per-tool exemption, and a vault argument is an unknown key in single-vault mode. Both scenarios corrected here described behaviour the shipped server does not have; the fields one also contradicted tolerant-arguments, so openspec validate was passing over a self-contradictory spec set. Adds verify.md and retrospective.md, and moves the change directory to openspec/changes/archive/2026-08-30-operations-tests-through-gate. openspec validate --all: 23/23. Refs #112 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AlexMost
force-pushed
the
docs/gate-contract-adr
branch
from
August 30, 2026 15:47
c81861d to
28437fc
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Last of four PRs for #112 (after #117, #118, #119). Documentation and
spec only — no
src/ortest/file is touched.What this adds
ADR-0015 — zod owns
every constraint a schema can express (type, enum, bound, unknown key,
coercion) and fails as
INVALID_PARAMSwithdetails.issues;INVALID_ARGUMENTis reserved for semantic argument faults a schema cannotexpress. A handler must not re-check what its own schema states, and tool
tests reach tools through
registerTool, neverbuildXTool(deps).handler.The ADR names three places where the old split produced unreachable
validation, each verified against source rather than assumed:
read_notes' four post-gate checks (deleted in test(operations): route read_notes through the registration gate #117).readThreshold'svalue < 0 || value > 1branch — unreachable at allfour call sites (
search_notes'thresholdandexpansion_floor,get_similar_notes,find_duplicates), each declaringz.number().min(0).max(1).UNSUPPORTED_VALUE_TYPEthrows ininferTypeAndValidate,excluded by
set_property'svalueunion.The dead code itself is deliberately left in place — this PR is docs-only.
A follow-up should remove those branches together with
OperationsErrorCode.UNSUPPORTED_VALUE_TYPE, which no reachable path cannow emit.
ADR-0003 keeps its body intact; the refinement is recorded in its INDEX
status cell, matching the partial-supersession formatting 0001 already uses.
Architecture pages —
mcp-server-shape.md§"Tool handler contract" saidhandlers throw
INVALID_ARGUMENTon bad input with no mention of the gatethat rejected malformed input two frames earlier. It now describes
registerTool's coerce →.strict()→safeParsepass, narrows the handlerbullet to semantic faults, and states the testing seam.
input-coercion.mdgains the
.strict()sentence — unknown keys are rejected, not stripped,including a
vaultkey against a single-vault server.AGENTS.md— the one-line rule in §"Run / check".Spec sync + archive — both delta specs land in
openspec/specs/. Twoscenarios described behaviour the shipped server does not have, and the
fieldsone also contradictedtolerant-arguments, soopenspec validatewas passing over a self-contradictory spec set.
Docs sweep
grep -rn "INVALID_ARGUMENT" docs/ README.md AGENTS.md→ 7 hits. Onlymcp-server-shape.mdwas wrong. The other six describe genuine semanticfaults and are left alone — including
docs/guide/reading-and-modifying.md:115("empty
replace"), which is correct becauseedit-note.tsdeclaresreplace: z.string().optional()with no.min(1), leaving that branchreachable.
One claim from the briefing was narrowed by grepping before writing it down:
queryandfilter.path_prefixarez.union([z.string(), z.array(...).min(1)]),so those bounds constrain only the array branch — an empty string query
still reaches the handler, and
search-notes.ts:164-170handles!== ''deliberately. The ADR therefore cites only
thresholdforsearch_notes.Gates
npm test(106 files / 1333 tests),npm run lint,npm run typecheck,npm run format,openspec validate --all(23/23 post-archive) — all pass.Acceptance conditions from
design.md:grep -rn '\.handler(' test/operations/tools/ test/semantic/tools/→ twohits, both in
edit-note.test.ts, both commented, both with theCallToolResultenvelope as their subject.grep -rn "validateReadNotesInput\|VALID_CONTENT_MODES" src/ test/→ none.INVALID_ARGUMENTassertions now run on the far side of
callTool, so any schema-rejectableinput among them would return
INVALID_PARAMSand fail the test. Theproperty is enforced by construction rather than by inspection.
Closes #112
🤖 Generated with Claude Code