Skip to content

State SaveReportInput's requirements at the reports.save door - #12421

Merged
os-litant merged 2 commits into
mainfrom
claude/issue-11926-reports-save-input-contract
Aug 27, 2026
Merged

State SaveReportInput's requirements at the reports.save door#12421
os-litant merged 2 commits into
mainfrom
claude/issue-11926-reports-save-input-contract

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes#11926

IReportService.saveReport takes a SaveReportInput, on which name, object and query are all required. Nothing on the path said so. This states it at the door.

What I measured (every line number in the card had moved)

Re-measured on origin/main @ cdbd9204b. The card was measured at 1f6d04703; three of its four coordinates are stale, and I report what I measured rather than reconciling:

CardMeasured on cdbd9204b
packages/client/src/index.ts:41854387
packages/rest/src/rest-server.ts:1029310283
packages/client/src/client.test.ts:352378
packages/spec/src/contracts/report-service.ts:8989 — unmoved

The reproduction, before any fix. Binding the parameter to SaveReportInput and leaving the test untouched reproduced the card's evidence exactly — same code, same message, moved line:

src/client.test.ts(378,35): error TS2345: Argument of type '{ name: string; object: string; }'
is not assignable to parameter of type 'SaveReportInput'.
Property 'query' is missing in type '{ name: string; object: string; }' but required in
type 'SaveReportInput'.

All three halves of the card's central claim hold, verified independently: the SDK parameter was any; the route forwarded req.body ?? {} unchecked; and there is no schema at the door — zero report Zod schemas in packages/spec/src, against a positive control of 171 hits for MetadataTypeSchema on the same instrument, so the zero is a reading rather than a broken grep.

One thing the card overstates, and reviewers should have it. The card says a query-less report is "refused, stored half-built, or throws depending entirely on which reports implementation is mounted". The bundled implementation does not leave it open — packages/plugins/plugin-reports/src/report-service.ts:398 already refuses:

if(!input.query)thrownewError('VALIDATION_FAILED: query is required');

So no deployment running the bundled reports service changes behaviour; what moves is the layer that produces the refusal. The defect is real and is exactly the three halves above — the requirement was re-derived privately by each implementation instead of being stated once at the seam — but the runtime blast radius is third-party IReportService implementations, not the default stack.

The fix

  • client.reports.save takes SaveReportInput instead of any. Omitting a required key is now a compile error at the call site. The SDK adds no runtime validation — it is a transport, not a second validator.
  • POST /api/v1/reports refuses a body missing name, object or query, and a query that is not a ReportQuery envelope, with 400 / VALIDATION_FAILED — so a JavaScript or curl caller that never sees the type is refused too.

Two ordering decisions worth review:

  1. The refusal sits after the existing 501 for an unmounted reports service. "No reports service on this deployment" is a deployment fact and outranks anything about the body. The existing 501 test pins this.
  2. It throwsVALIDATION_FAILED: … into the route's existing handleValidation rather than writing its own body. Writing a second body made one route answer the same refusal in two different envelopes depending on who raised it, and put two new non-conforming bodies on the check:route-envelope ratchet, which only ticks down (stringError 46 vs 44, siblingCode 71 vs 69). Routing through the single existing construction site keeps the ratchet flat and the two 400s byte-identical. That gate is green here; the second commit is the conversion.

The fixture at client.test.ts — reasoned about, not repaired

Per the card and the claim, the query-less fixture is evidence, not noise, and adding a query to it would have silenced the signal without closing anything. It is neither silenced nor left alone:

  • The routing pin (reports.save pins POST /reports) keeps its job and now runs on a contract-valid input, which is what it was always actually asserting.
  • The query-less literal is preserved verbatim in a new test where the compiler asserts the refusal, under @ts-expect-error.

That turns the evidence into a bidirectional pin. client.test.ts is compiled by tsconfig.test.json, which this package's typecheck script names, and it carries no test-typecheck-debt.json entry — so it is not a phantom @ts-expect-error in a file no tsc program reads. Widen the parameter back to any and tsc reds with TS2578: Unused '@ts-expect-error' directive (measured, below).

One REST fixture needed the same triage: POST /reports surfaces VALIDATION_FAILED as 400 drove body: {}, which the door now refuses before the service is consulted. Its assertions would have stayed green while pinning the door instead of the pass-through they exist for — green for the wrong reason. It is re-driven through a door-valid body with a service-level throw, and now asserts saveReport was actually called so it cannot go vacuous silently again.

Reverse verification

Both legs mutate, prove the mutation landed on disk by grepping the injected and removed text and comparing blob hashes, run, then restore under a trap with absolute paths — restore proved by git diff HEAD empty and a byte-identical blob hash, not by an exit code. Neither leg needs a rebuild: both tests import the mutated file through a package-local relative specifier (./index, ./rest-server), not through a dependency's exports, so no dist is in the resolution path.

LegPredictedMeasured
Parameter widened back to anyRED, and with a different code than the original TS2345TS2578 Unused '@ts-expect-error' directive at client.test.ts:402 (+ TS6196 unused import)
Door check call site deletedthe two door pins RED2 failed / 226 passed (228) — exactly the two refusal pins

The "accepts an empty query object" pin correctly stays green under the second leg: it asserts non-refusal, so ablating the refusal cannot break it. That asymmetry is the point of having it — it guards the door against drifting from "present and an object" into "present and non-empty".

Verification

All on 9d4791c2d, the final commit.

  • pnpm --filter @objectstack/rest test147 files / 2331 tests passed
  • pnpm --filter @objectstack/client test25 files / 347 tests passed
  • pnpm --filter @objectstack/client typecheck — green; gate's own line: check:test-typecheck: OK — @objectstack/client's test layer compiles under packages/client/tsconfig.test.json; 0 file(s) / 0 error(s)
  • pnpm --filter @objectstack/rest typecheck — green. ⚠️ Scoped honestly: packages/rest/tsconfig.json excludes **/*.test.ts, and --listFiles returns 0 hits for rest.test.ts, so that green says nothing about the edited REST test file. It does not need to — the REST pins are runtime assertions; only the client pin is type-level, and that file is in a program.
  • pnpm lint (full repo, eslint . --no-inline-config) — green, no narrowing claimed
  • Gate families re-derived in this worktree with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (5 changed paths, merge base cdbd9204b). Green: check:route-envelope, check:dispatcher-error-vocabulary, check:authz-resolver, check:empty-changeset, check:changeset-gate-self-tests, check:objectui-changeset, check:published-files, check:page-declaration-shape, check:slot-lookup, check:test-source-alias, check:type-source-resolution, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:type-check-coverage, check:nul-bytes, check:skill-examples, check-comment-mask-adoption, check-adr-0087-registration, check-changeset-no-major, check-cross-package-test-inputs, check-plugin-teardown-shape, check-ci-filter-parity, check-affected-docs, and check:type-check-debt --re-measure (32 ledger entries, none above recorded — run after building the closure, since it refuses outright on an unbuilt one rather than measure a different world).

Scope

packages/spec/src/contracts/report-service.ts is untouched — the contract is being honoured, not changed. The six adjacent any-typed SDK methods are untouched. No new issue filed for them: the card already lists them, and re-filing would duplicate it.

⚠️This narrows what the door accepts and is deliberately held for the needs:contract-review gate on the card. The reviewable decision is precise: an IReportService implementation that treated query as optional and defaulted it can no longer receive a query-less body, even though the contract it implements has always declared query required. That is the accept/reject change, named rather than assumed away.


Generated by Claude Code

….save door
`IReportService.saveReport` takes a `SaveReportInput`, on which `name`,
`object` and `query` are all required. Nothing on the path said so: the SDK
method declared its parameter `any` and the route forwarded `req.body ?? {}`
unchecked, so the requirement held only as far as each reports implementation
chose to re-derive it privately.
- `client.reports.save` now takes `SaveReportInput` instead of `any`.
- `POST /api/v1/reports` refuses a body missing any of the three required keys,
and a `query` that is not a `ReportQuery` envelope, with 400 /
VALIDATION_FAILED — ordered after the existing 501 for an unmounted service.
The query-less literal that `client.test.ts` had been constructing invisibly is
preserved verbatim and becomes a `@ts-expect-error` pin asserting the refusal.
The REST pass-through test is re-driven through a door-valid body so it keeps
pinning the service-raised VALIDATION_FAILED mapping instead of going vacuous.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
The door check wrote its own 400 body. That put a second VALIDATION_FAILED
construction site on one route — so the same refusal could reach a client in
two different envelopes depending on whether the door or the service raised it
— and added two non-conforming bodies to the `check:route-envelope` ratchet,
which only ticks down (stringError 46 vs 44, siblingCode 71 vs 69).
It now throws `VALIDATION_FAILED: …` from inside the existing try, so the
route's single `handleValidation` builds the body exactly as it already did for
a service-raised refusal. No new response body; the ratchet is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

⚠️1 changed file(s) yielded no anchor (packages/rest/src/rest-server.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files. Nothing else in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 2 changed package(s)).

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/rest/src/rest-server.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2e24115710640fba45938baf24a31fd9cc80694fpackageMentionDocs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 26, 2026
This was referenced Aug 26, 2026
@os-litant
os-litant marked this pull request as ready for review August 27, 2026 04:15
@os-litant
os-litant added this pull request to the merge queueAug 27, 2026
Merged via the queue into main with commit 3519f8dAug 27, 2026
34 checks passed
@os-litant
os-litant deleted the claude/issue-11926-reports-save-input-contract branch August 27, 2026 04:38
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

client.reports.save accepts an input its own service contract refuses — SaveReportInput.query is required and neither the SDK nor the route checks it

2 participants

@os-litant@claude