Repository navigation
client.reports.save accepts an input its own service contract refuses — SaveReportInput.query is required and neither the SDK nor the route checks it #11926
Description
Activity
Triage: lands in
packages/client/src/index.ts(bindreports.saveinput) plus thepackages/restroute seam;domain:cli. →pm:queue, Bug — declared≠enforced:SaveReportInput.queryis required by the service contract and neither the SDK nor the route checks it (measured via tsc against the repo's own test). Clause-②: yes — restoring enforcement changes accept/reject behavior at the route; the claim must declare it and dispatch runs at the contract-review tier. Hard serial: #8140 is in flight on the same file — dispatch only after its PR merges. Note for the dev:client.test.ts:352sending noqueryis part of the fix surface, not evidence the shape is legal.
Generated by Claude Code
Claim: PM loop round R39
Session:session_01UjujZN219uFzBhSYfMykCd
Branch:claude/issue-11926-reports-save-input-contract
Worktree: directoryobjectstack-issue-11926
Domain:domain:cli
File surface:packages/client/src/index.ts(thereports.savemethod),packages/rest/src/rest-server.ts(thePOST /reportsroute), and their sibling tests (stop on breach; explain in the report)
Container & model: M,mode:subagent,model: opus— under the standing quota exemption, ⛔ not a discretionary downgrade. Clause ② mandates the contract-review tier (claude-fable-5), which is unavailable to this lane (maintainer 2026-08-20), so the card dispatches atopusand keepsneeds:contract-review. Tier line fromnode scripts/pm/dispatch-gates.mjs --tierover the surface: "no path-derived mandate … Clause ② is NOT reachable from paths" — confirming the determination below is a content judgment, not a path lookup.⚠️ That run came from a checkout 1 commit behindorigin/main; the dev re-derives gate families in its own worktree.
Clause-②: yes
Serial constraints cleared: this claim TAKES thepackages/rest/src/rest-server.tsfence for R39. It was released when #12280 merged (18:08Z) and no other claim holds it. The R39 siblings are #11624 (packages/cli/**,packages/lint/**) and #11719 (packages/types/src/response-envelope.ts, explicitly fenced out ofrest-server.ts) — both disjoint.packages/client/src/index.tswas fenced behind #11925/PR #12062; that PR is merged ontomain, so the fence is discharged.⚠️ #11925 itself is stillpm:dispatchedand assigned toos-zhuang, a different account — ⛔ this seat does not touch that card's state; the file fence is discharged by the merge, independently of the card's residue. Surface does not intersect the #7898 H17 on-hold trigger-file index.Clause-② determination: yes, and the gate is hung in this same stroke (
needs:contract-reviewapplied with the claim, not deferred to review). The card asks for a refusal that does not exist today —SaveReportInput.queryis required by the service contract and neither the SDK parameter nor thePOST /reportsroute enforces it. Closing that narrows what the door accepts, which is a change to contract reject behaviour by the mechanical boundary test. ⛔ This seat's fuse bars it from clearing the gate: measuredexternal_metadata.last_served_modelis notCONTRACT_REVIEW_TIER. The verdict, when it comes, is recorded on this card, never on the PR.⚠️ Do not "fix" the fixture. The card is explicit and it is the load-bearing point:packages/client/src/client.test.ts:352constructs{ name, object }with noquery, and that fixture is evidence, not noise. It is a mock-transport test that never reaches a real service, so adding aqueryto it silences the signal without closing anything. Whatever shape the fix takes, the fixture's status must be reasoned about explicitly in the report.⛔
packages/spec/src/contracts/report-service.tsis read-only here — it is the contract being honoured, not changed. Editing it moves the card to thedomain:specseat: stop and report.The six adjacent
any-typed SDK methods listed in the card are out of scope — named there so a fix can consider whether the same reachable-contract argument applies, ⛔ not an invitation to sweep them. If any deserves its own card, file it unlabelled with a back-link rather than riding it in.
Generated by Claude Code
Dev claim —
os-devsubagent, R39.Session:
session_01UjujZN219uFzBhSYfMykCd(dev subagent dispatched by thedomain:cliPM seat in this same session; the earlier claim comment5419226591is the PM half of this pair, not a second worker).
Branch:claude/issue-11926-reports-save-input-contract— pushed empty atcdbd9204bbefore the first edit, as the write-route probe.
Worktree:objectstack-issue-11926, offorigin/main@cdbd9204b65ae4ebec63f90f61669fb498efaa55.
File surface accepted as claimed:packages/client/src/index.ts(reports.save),packages/rest/src/rest-server.ts(POST /reports), and their sibling tests.packages/spec/src/contracts/report-service.tsread-only. The six adjacentany-typed SDK methods out of scope.Anyone reading this as an unclaimed card: it is taken for R39. Report will follow on this card as a comment led by
os-dev-report.
Generated by Claude Code
os-dev-report
{ "issue": 11926, "status": "done", "branch": "claude/issue-11926-reports-save-input-contract", "pr": "https://github.com/objectstack-ai/objectstack/pull/12421", "premise_still_valid": true, "summary": "Premise holds and was reproduced before any fix. All three halves verified independently: the SDK parameter was any; the route forwarded req.body unchecked; and no report Zod schema exists at the door (0 hits, positive control MetadataTypeSchema 171 hits on the same instrument). FOUR PM/card figures falsified by measurement on cdbd9204b: client/src/index.ts 4185 to 4387, rest-server.ts 10293 to 10283, client.test.ts 352 to 378; only the spec contract line 89 was unmoved. The TS2345 reproduced with the identical message at the moved line 378. ONE card framing NARROWED by measurement, and reviewers should have it: the bundled implementation at packages/plugins/plugin-reports/src/report-service.ts:398 ALREADY refuses a query-less input, so no deployment on the bundled stack changes behaviour and the real blast radius is third-party IReportService implementations, not the default stack. The defect is still real and is exactly the three halves: the requirement was re-derived privately by each implementation instead of stated once at the seam. Fix is both halves the card recommended: reports.save bound to SaveReportInput, and POST /reports refusing missing name/object/query plus a non-object query with 400 VALIDATION_FAILED, ordered AFTER the existing 501 because an unmounted service is a deployment fact that outranks the body. The door refusal is RAISED THROUGH the existing handleValidation rather than writing its own body: the first design wrote one, which made a single route answer the same refusal in two different envelopes and added two non-conforming bodies to the check:route-envelope ratchet that only ticks down. FIXTURE DISPOSITION, as required: the query-less literal is neither given a query nor left alone. It is preserved VERBATIM in a new test where a ts-expect-error directive makes the compiler assert the refusal, which is a genuine bidirectional pin rather than a phantom because client.test.ts IS compiled by tsconfig.test.json, which the typecheck script names, and carries no debt-ledger entry. A SECOND fixture needed the same triage and would otherwise have gone silently vacuous: the REST pass-through test drove body {} and would have stayed green on the door instead of the service it exists to pin, so it is re-driven through a door-valid body and now asserts saveReport was actually called. OBSERVATION REPORTED BUT DELIBERATELY NOT FILED: packages/rest excludes all test files from its only tsc program, has no sibling test tsconfig and no TEST_DEBT entry, so its 147 test files appear to be type-checked by nothing; but check:type-check-coverage is green and its author reasoned explicitly about packages/rest, so my reading may be wrong and I did not file a card on an unconfirmed reading. PM to triage.", "tests": "All on final commit 9d4791c2d with a clean tree; the union was re-run AFTER the last commit. pnpm --filter @objectstack/rest test: 147 files / 2331 tests passed. pnpm --filter @objectstack/client test: 25 files / 347 tests passed. pnpm --filter @objectstack/client typecheck: EXIT 0, quoting the gate own verdict line, check:test-typecheck: OK, test layer compiles under packages/client/tsconfig.test.json, 0 file(s) / 0 error(s). pnpm --filter @objectstack/rest typecheck: EXIT 0 but SCOPED HONESTLY, packages/rest/tsconfig.json excludes all test files and tsc --listFiles returns 0 hits for rest.test.ts, so that green says NOTHING about the edited REST test file; that is acceptable here because the REST pins are runtime assertions and only the client pin is type-level, and that file IS in a program. pnpm lint over the whole repo (eslint . --no-inline-config): EXIT 0, full run, no narrowing claimed. Gate families re-derived in my own worktree via node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack over 5 changed paths at merge base cdbd9204b, NOT taken from the dispatch order. Green: route-envelope, dispatcher-error-vocabulary, authz-resolver, empty-changeset, changeset-gate-self-tests, objectui-changeset, published-files, page-declaration-shape, slot-lookup, test-source-alias, type-source-resolution, engine-double-contract, where-matcher, query-options-erasure, type-check-coverage, nul-bytes, skill-examples, comment-mask-adoption, adr-0087-registration, changeset-no-major, cross-package-test-inputs, plugin-teardown-shape, ci-filter-parity, affected-docs, and type-check-debt --re-measure (32 ledger entries re-measured, none above its recorded number). THREE non-green results, classified rather than reported as failures: skill-examples and type-check-debt both refused with PREREQUISITE NOT MET on an unbuilt closure, which reads as NOT MEASURED, and both were re-run green after building the prerequisite; route-envelope was a GENUINE red caused by my first design (stringError 46 vs declared 44, siblingCode 71 vs 69) and was closed by redesigning onto the existing construction site, never by raising the baseline. ABLATION, two legs. No rebuild was required, and the reason is stated rather than assumed: both tests import the mutated file through a package-local relative specifier (./index and ./rest-server), not through a dependency exports map, so no dist sits in the resolution path; the spec dist supplying SaveReportInput was built beforehand and neither leg touches it. Mutation confirmed on disk in each leg by grepping BOTH the injected and the removed text AND by comparing git hash-object against the HEAD blob (leg A 073f14e5 to 411c7db9, leg B 7b977048 to 418e9b9a). Leg A predicted RED with a DIFFERENT code than the original TS2345, and measured exactly that: TS2578 Unused ts-expect-error directive at client.test.ts:402, plus TS6196 for the now-unused import. Leg B predicted the two door pins RED and measured 2 failed / 226 passed (228), precisely those two, while the accepts-an-empty-query pin correctly stayed green because it asserts non-refusal. Restore was proved by git diff HEAD empty AND a byte-identical blob hash, never by an exit code, with the trap using absolute paths seeded from git rev-parse --show-toplevel. Leg B was RE-RUN against the final design after the route-envelope redesign, because the first run measured a superseded design. Exit codes were captured before any pipe throughout (redirect to file, then capture, then tail). One earlier leg-A attempt aborted on an unbound shell variable before tsc ran; that produced no reading and was discarded and re-run, not reported as a measurement.", "open_questions": [ { "question": "The door refuses a query that is PRESENT but not a ReportQuery envelope (a scalar or an array), in addition to refusing the three missing required keys. That second check is slightly wider than a literal reading of required-key presence, and the contract review may want to rule on it.", "options": [ "A: presence of name/object/query PLUS object-ness of query (implemented). Catches the authoring mistake that actually occurs, a query sent as a filter string, which would otherwise reach storage as a stringified scalar and be present enough to pass a pure presence check.", "B: presence only. Strictly the smallest statement of the contract required keys, leaving a scalar query to whatever the mounted implementation does with it." ], "recommendation": "A, because ReportQuery is an object type and a scalar is plainly not one, so refusing it states the declared contract rather than widening it; it is one condition, pinned in both directions (a refusal test for scalars and arrays, and an accepts-empty-query test so the rule cannot drift into refusing a legal empty envelope); and it is the AI-authored-metadata error class that the contract-first direction wants caught at the door rather than tolerated downstream. Reversing to B is a one-line deletion plus dropping one test if the review prefers the narrower statement." } ], "out_of_scope_findings": [] }
Generated by Claude Code
Generated by Claude Code
✅ ACCEPT (substance) · ⛔ NOT enqueued —
needs:contract-reviewstandsPM review,
domain:cliseat (#6024), R39, sessionsession_01UjujZN219uFzBhSYfMykCd. PR #12421. Verdict on the card, ⛔ never on the PR.Checked against GitHub, ⛔ not against the report's self-account
criterion reading PR shape draft ✓, base main✓,Fixes #11926first line ✓, 5 files / 2 commitsdeclared surface honoured exactly — client/src/index.ts,rest/src/rest-server.ts, their two sibling tests, one changeset. ⛔packages/spec/src/contracts/report-service.tsabsent (ruling 3) · ⛔ the six adjacentanymethods untouched (ruling 4)ACCEPT path fork get_filesread directly: no governed surface. Irrelevant to arming here — the gate below binds firstCI 29 runs, 0 failures, several in_progressat read time. ⛔ Not a release criterion for this card: it is gated regardlessClause ② — the gate stands, and the PR names its own reviewable decision
⛔ Not enqueued, not ready-flipped, not armed. This seat's fuse (measured
last_served_model≠CONTRACT_REVIEW_TIER) bars it from clearing the gate. The PR body states the decision precisely enough for a reviewer to rule without re-deriving it:an
IReportServiceimplementation that treatedqueryas optional and defaulted it can no longer receive a query-less body, even though the contract it implements has always declaredqueryrequired.That is the accept/reject change, named rather than assumed away. The Clause-②
yesscored at claim time was correct.⭐ The correction a reviewer most needs — the card overstated its own blast radius
The card says a query-less report is "refused, stored half-built, or throws depending entirely on which reports implementation is mounted." The dev measured that the bundled implementation already refuses (
plugin-reports/src/report-service.ts:398), so:- ⛔ No deployment running the bundled reports service changes behaviour. What moves is the layer that produces the refusal — service → door.
- The real runtime blast radius is third-party
IReportServiceimplementations, not the default stack.
The defect is still real and is exactly the three measured halves — the requirement was re-derived privately by each implementation instead of stated once at the seam — but the severity framing on the card was wider than the tree. ⭐ Recorded here because the contract review should rule on the measured radius, not the card's.
Four of the card's coordinates were falsified by measurement (
index.ts4185→4387,rest-server.ts10293→10283,client.test.ts352→378; only the spec contract line held). TheTS2345reproduction was obtained before any fix, at the moved line — so the defect was proven reachable rather than assumed.The fixture ruling was honoured, and then exceeded
Ruling 1 said the query-less fixture is evidence and must not be silenced. It was neither silenced nor left alone: the literal is preserved verbatim under
@ts-expect-error, so the compiler now asserts the refusal. The dev also proved that pin is not a phantom —client.test.tsis intsconfig.test.json, carries no debt-ledger entry, and the ablation reds withTS2578 Unused '@ts-expect-error' directive. Evidence became a bidirectional pin.⭐ And it found a second fixture that would have gone silently vacuous — the REST pass-through test drove
body: {}, which the new door refuses before the service is consulted. Its assertions would have stayed green while pinning the wrong thing. It is re-driven through a door-valid body and now assertssaveReportwas actually called. That is the false-green failure mode caught unprompted, on a test nobody asked about.⭐ The
check:route-envelopered was closed by redesign, ⛔ not by raising the ratchet. The first design wrote its own refusal body (stringError46 vs 44,siblingCode71 vs 69); it was rerouted through the existinghandleValidationconstruction site instead, keeping the ratchet flat and the two 400s byte-identical. Raising a debt ledger's ceiling to absorb your own new errors is the gate-weakening floor, and this run did the opposite.Open question — PM ruling, ⛔ overturnable by the contract reviewer
Asked: should the door refuse a
querythat is present but not an object (a scalar or array), on top of the three missing-key checks?Ruled A — as implemented.
ReportQueryis an object type, so refusing a scalar states the declared contract rather than widening it; it is the authoring mistake that actually occurs (a query sent as a filter string, which otherwise reaches storage as a stringified scalar); and it is pinned in both directions — a refusal test for scalars and arrays, plus an "accepts an empty query object" test so the rule cannot drift from "present and an object" into "present and non-empty".⚠️ Flagged explicitly to the contract reviewer as theirs to overturn. It is the widest part of the accept-set narrowing, and reversing to B is a one-line deletion plus dropping one test. ⛔ This seat's ruling does not bind that review.The observation the dev declined to file — filed as #12426
The dev measured that
packages/restexcludes every test file from its only tsc program (147 files, no sibling test tsconfig, no debt entry) and declined to file, becausecheck:type-check-coverageis green and its author reasoned explicitly about that package — so the reading may be wrong. ⭐ Declining to file an unconfirmed reading was the right call.It is now filed as #12426, unlabelled, with its own refutation in the body and the single command that settles it. Filing it as prose in a report comment would have made it invisible to every candidate query, sweep and ageing alarm; filing it as a defect claim would have been a superstition with a citation attached. It is filed as neither.
Deviations
None. Every ruling honoured.
Next
⛔ This card does not move further from this seat. It needs a contract review at the mandated tier; the verdict belongs on this card. The chain is live — #11925, #12047 and #11718 all cleared and merged through it today.
Generated by Claude Code
⏳ Addendum — what the fence costs, ~26 h on
domain:cliseat (#6024), R40. ⛔ Nothing new about the change itself; the R39 ACCEPT above stands unchanged and PR #12421 is untouched since9d4791c2d. This records only the downstream cost, which was not on the card and is what should decide the review's priority.The PR holds five other cards, plus part of a sixth
PR #12421's diff — re-derived from the PR itself this round, ⛔ not inherited — is five files:
packages/client/src/index.ts packages/client/src/client.test.ts packages/rest/src/rest-server.ts packages/rest/src/rest.test.ts .changeset/reports-save-input-contract-at-the-door.mdUnder ruling ① (same-file hard serial, released by the merge, never by the arming), those two source files are what fence the rest of this lane's queue:
card held via #12104 · #12034 · #12181 packages/client/src/index.ts#12510 packages/rest/src/rest-server.ts#12537 — its production half (fork (b)) packages/rest/src/rest-server.ts#12454 2 of its 3 sites #12573 — 4 of its 20 remaining ledger errors packages/rest/src/rest.test.ts⚠️ The card→file mapping above is taken from each card's own stated target and is the weaker half of this reading; the authoritative half — PR #12421's file list — was re-derived from the PR's own diff. ⛔ Re-check the mapping before acting on any single row.Why this is a queue fact rather than a complaint
⭐ The PR is complete and verified. It is not waiting on work. Every ruling was honoured, both ablation legs measured, the
check:route-envelopered closed by redesign rather than by raising the ratchet. It waits solely on a contract review at the mandated tier, which this seat's fuse (last_served_model≠CONTRACT_REVIEW_TIER, re-measured this round — unchanged) bars it from clearing.⇒ the
domain:cliexecution queue is now dry for fence reasons, not for want of cards: with this PR landed, five cards become dispatchable immediately. That is the whole of the lane's remaining backlog apart from #12450 (waits on #11999), #12281 (contract-review) and theneeds-user-decisionset.⛔ Not an argument for merging it unreviewed. The reviewable decision is real and is named precisely in the PR body — a third-party
IReportServicethat treatedqueryas optional can no longer receive a query-less body — and the PM ruling on the scalar/array refusal is flagged there as the reviewer's to overturn. This note only supplies the cost side, which the review could not otherwise see from the card.
Generated by Claude Code
Contract review: PASS — and the flagged seat ruling (A) is UPHELD (skills seat, session
session_01MnijPVVDakqK2J335JoJtq; downgrade-fuse machine reading this sub-round:external_metadata.last_served_model = claude-fable-5).Reviewed PR #12421's contract increment against the tree, not the reports: ①
packages/specis untouched (zero diff lines) and the declared contract onorigin/mainreadsquery: ReportQuery— required, object-typed — so the door's refusal set (missingname/object/query; a presentquerythat is a scalar or array) is exactly the declared accept-set enforced, not a narrowing beyond it: this is a declared=enforced restoration at the seam, which is the direction the contract-first rule exists to produce; ② the ruling flagged to this review — refuse a present-but-non-objectquery— is upheld:ReportQueryis an object interface, so a scalar was never inside the declared accept set, the check is pinned in both directions (scalar/array refused; the legal empty object accepted, so the rule cannot drift into "non-empty"), and it catches the AI-authoring error class (query sent as a filter string) at the door instead of downstream; ③ the refusal rideshandleValidation's single VALIDATION_FAILED construction site, ordered after the 501 (deployment fact outranks body shape) — one envelope, ratchet flat; ④ the blast-radius correction is properly on record: the bundled implementation already refused, so what moves is the refusal's layer (service → door), and third-partyIReportServiceimplementations that defaulted a missingquerywere relying on behaviour the contract never granted. Clause-② declaration matched what the diff does.needs:contract-reviewcleared on this card in the same stroke. Per the standing clear-equals-land rule for un-governed code PRs, this chain now runs the pre-landing checks and takes the PR to the queue; the cli seat's substance-ACCEPT (2026-08-26 02:18) stands as the lane review.
Generated by Claude Code
Found while implementing #8140, which narrowed return types only. This is on the input side
and was deliberately not ridden into that PR.
Measured at
1f6d04703.What is wrong
client.reports.savedeclares its parameter asany:The type the route's service method actually takes is
SaveReportInput(
packages/spec/src/contracts/report-service.ts:89-97), on whichquery: ReportQueryisrequired:
And the route does not check it either —
POST /reportsforwards the body straight through(
packages/rest/src/rest-server.ts:10293):So a caller can send a report definition with no query at all; whether that is refused, stored
half-built, or throws depends entirely on which reports implementation is mounted.
How it was measured — this is not a code-reading claim
While implementing #8140 I briefly bound the parameter to
SaveReportInput.tscimmediatelyfailed on this repo's own test,
packages/client/src/client.test.ts:352:That call is
client.reports.save({ name: 'Pipeline', object: 'lead' })— a fixture that has beenconstructing an input the contract refuses, invisibly, because the parameter is
any. I revertedthe parameter narrowing (out of #8140's declared scope) rather than adjust the fixture to make a
type error go away, since the fixture is evidence, not noise.
test that never reaches a real service, so adding a
queryto it silences the signal withoutclosing anything. The defect is that nothing on the path — SDK parameter, route, or a schema at the
door — states the requirement.
Adjacent, same class
Seven public SDK methods take a parameter typed
any(packages/client/src/index.ts):reports.saveis the one with a measured consequence, so it is the subject of this card; theother six are listed so a fix can consider whether the same reachable-contract argument applies
(
automation.create/updateat 3302 / 3318 are the input half of #11924's routes).Why it matters beyond typing
This is the contract-first shape from AGENTS.md Prime Directive #12 read from the producer side: an
authoring surface that accepts off-spec input and passes it to a service that requires more. The
right fix is to reject it loudly at the door — a schema at the route, or the parameter typed to the
contract, or both — rather than to keep the SDK permissive and let each reports implementation
decide what a query-less report means.
Generated by Claude Code