Skip to content

docs: record the tool input gate contract - #120

Merged
AlexMost merged 4 commits into
mainfrom
docs/gate-contract-adr
Aug 30, 2026
Merged

AlexMost merged 4 commits into
mainfrom
docs/gate-contract-adr

Conversation

@AlexMost

Copy link
Copy Markdown
Owner

Last of four PRs for #112 (after #117, #118, #119). Documentation and
spec only — no src/ or test/ 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_PARAMS with details.issues;
INVALID_ARGUMENT is reserved for semantic argument faults a schema cannot
express. A handler must not re-check what its own schema states, and tool
tests reach tools through registerTool, never buildXTool(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's value < 0 || value > 1 branch — unreachable at all
    four
    call sites (search_notes' threshold and expansion_floor,
    get_similar_notes, find_duplicates), each declaring
    z.number().min(0).max(1).
  • All three UNSUPPORTED_VALUE_TYPE throws in inferTypeAndValidate,
    excluded by set_property's value union.

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 can
now 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" said
handlers throw INVALID_ARGUMENT on bad input with no mention of the gate
that rejected malformed input two frames earlier. It now describes
registerTool's coerce → .strict() → safeParse pass, narrows the handler
bullet to semantic faults, and states the testing seam. input-coercion.md
gains the .strict() sentence — unknown keys are rejected, not stripped,
including a vault key against a single-vault server.

AGENTS.md — the one-line rule in §"Run / check".

Spec sync + archive — both delta specs land in openspec/specs/. Two
scenarios described behaviour the shipped server does not have, and the
fields one also contradicted tolerant-arguments, so openspec validate
was passing over a self-contradictory spec set.

Docs sweep

grep -rn "INVALID_ARGUMENT" docs/ README.md AGENTS.md → 7 hits. Only
mcp-server-shape.md was wrong. The other six describe genuine semantic
faults and are left alone — including docs/guide/reading-and-modifying.md:115
("empty replace"), which is correct because edit-note.ts declares
replace: z.string().optional() with no .min(1), leaving that branch
reachable.

One claim from the briefing was narrowed by grepping before writing it down:
query and filter.path_prefix are z.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-170 handles !== ''
deliberately. The ADR therefore cites only threshold for search_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/ → two
    hits, both in edit-note.test.ts, both commented, both with the
    CallToolResult envelope as their subject.
  • grep -rn "validateReadNotesInput\|VALID_CONTENT_MODES" src/ test/ → none.
  • No test asserts an unreachable code: all 36 surviving INVALID_ARGUMENT
    assertions now run on the far side of callTool, so any schema-rejectable
    input among them would return INVALID_PARAMS and fail the test. The
    property is enforced by construction rather than by inspection.

Closes #112

🤖 Generated with Claude Code

AlexMost and others added 4 commits August 30, 2026 17:35
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
AlexMost force-pushed the docs/gate-contract-adr branch from c81861d to 28437fc Compare August 30, 2026 15:47
@AlexMost
AlexMost merged commit 3ceb616 into main Aug 30, 2026
2 checks passed
@AlexMost
AlexMost deleted the docs/gate-contract-adr branch August 30, 2026 15:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Route operations tool-contract tests through the registration gate

1 participant