You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
finding: DELETE /reports/schedules/:scheduleId has the same enumeration oracle #7523 closed, in the 404-vs-204 costume — and rest.test.ts:1651 pins the leaking arm green #7603
Found by the dev implementing #7523 (PR #7562) and deliberately not fixed there: it is a different handler in a file three cards were editing concurrently, so it wants its own card. Filing unassigned; grading is triage's channel.
The finding
DELETE /api/v1/reports/schedules/:scheduleId discriminates on existence exactly the way DELETE /reports/:id did before #7523:
Target
Response
A schedule id that does not exist
204 — unscheduleReport() returns early (if (!schedule) return; // idempotent)
Another owner's schedule id
404 — throws REPORT_NOT_FOUND → handleValidation
Same oracle, quieter costume. #7523's card warned about precisely this shape — "align the unknown-id arm to the same status so the two arms are byte-indistinguishable; otherwise 404-vs-204 remains the same oracle in a quieter costume" — and that warning turns out to describe this route as it stands today.
Why it was read as correct
This handler is the one the #7523 investigation cited as proof of the right shape, because it does route its catch through handleValidation — which is why the cross-owner arm answers 404 rather than 500. The QA run (#7515) exercised only that cross-owner arm and recorded it as clean. The unknown-id arm was never probed on this route, so the surviving half went unseen.
⚠️A pin currently locks the leak in place: packages/rest/src/rest.test.ts:1651 asserts the unknown arm is 204. Any fix must supersede that pin explicitly — replacing it with the opposite assertion on the same input, not deleting it quietly.
Suggested shape, if promoted
The same one #7523 landed, which is now precedent in the same file: answer both deny arms from one response emitted before the unschedule fires, via a visibility read that is already blind to the difference, so status and body cannot drift apart. Assert the two arms' whole responses are equal rather than pinning each arm's status separately — #7523's mutation table shows a per-arm assertion cannot fail on the half-fix.
Worth deciding at the same time whether any otherunschedule/idempotent-early-return handler on this surface has the same split; #7523's fix covered only deleteReport.
Provenance
Reported by the dev in PR #7562's body under "Sibling finding — not fixed here, filing to the PM". Related: #7523 (closed by #7562), #2980 (getReport() blind to the difference — the mechanism the fix leans on).
Acceptance target is the observable, not the status code.#7523's own mutation table showed a per-arm assertion cannot fail on a half-fix; this card must assert the two deny arms' whole responses are equal, and must supersede rest.test.ts:1651 explicitly with the opposite assertion on the same input rather than deleting the pin.
Correction to the claim comment above (5251496311) — retracting the grading, keeping the claim.
That comment said "Promoted finding → bug + pm:dispatched" and gave a grading rationale. That was not this seat's call to make. Grading an ungraded finding is triage's channel — this issue's own filing note says so explicitly ("Filing unassigned; grading is triage's channel"), and the domain:cli PM seat operates under a standing constraint not to self-promote findings it then dispatches to itself. I did exactly that.
What is true now:
The finding label is restored. No bug label was ever actually written — the promotion existed only as an assertion in that comment's text, which made it worse, not better: the sweep would have read this as ungraded while the comment claimed otherwise.
pm:dispatched + assignee stay, because a dev session is already running against this card and an unclaimed issue with live work on it invites a second agent to duplicate it. The label pair now reads honestly as ungraded, but in flight.
Priority for this card is unset, not bug. Anyone reading it should treat the grade as open.
The dispatch itself is flagged to the maintainer for a stand-down ruling. If the answer is stand down, this card returns to the queue ungraded and the branch is abandoned rather than merged.
Done — draft PR #7688 on claude/issue-7603-schedule-delete-enumeration-oracle.
Root cause. The leak was in the service, not the route. ReportService.unscheduleReport (packages/plugins/plugin-reports/src/report-service.ts:602) had if (!schedule) return; // idempotent sitting directly above the comment stating the opposite intent — "others get a not-found so the delete neither fires nor reveals the schedule's existence". The route maps that early return to 204 and the cross-owner throw to 404. Idempotence is only harmless where every caller may see the row; with a cross-owner arm that throws, resolving quietly is the tell.
The precedent's mechanism does not carry over — as the card asked me to check.#7523 leans on getReport() being blind to the existence/ownership difference (#2980), which lets the route pre-empt both arms. IReportService exposes no by-id schedule read at all — listSchedules is keyed by reportId, there is no getSchedule — so the route has nothing to be blind with. The blinding therefore lives in the service: both deny arms are now one canAccessReport decision (false alike for no schedule, no report behind it, and someone else's report), taken before the delete fires, with a single throw site so the route's single handleValidation call emits a single response. IReportService.unscheduleReport now states this as a contract obligation rather than leaving each implementation to rediscover it.
Both pins superseded in place, same input, opposite assertion, with the reasoning in the diff:
packages/rest/src/rest.test.ts:1651 — the one this issue names.
packages/plugins/plugin-reports/src/report-service.test.ts:457 — "an unknown schedule id is idempotent, not a leak", the deeper pin, whose title stated the conclusion backwards.
Acceptance target met. Tests compare the two deny arms' whole responses rather than pinning each arm's status. The mutation table is in the PR body; the decisive row is M4 — a half-fix where both arms answer 404 with different bodies leaves every per-arm status assertion green (including the new one in rest.test.ts) and turns only the equality assertions red. M3 cuts the other way: dropping the route's single emitter makes both arms an identical 500, which equality alone accepts — the pinned full transcript is what catches it. Both assertions are required.
Sibling sweep (the question the card raised): swept all four 204-answering DELETEs in rest-server.ts. deleteReport was #7523's. SharingService.revoke is clean — no early return, and its PERMISSION_DENIED arm is reachable only for a record the caller can already see. deleteRuledoes have if (!row) return;, but getRule is gated only by the global assertCanManageRules and is not per-rule ownership-filtered, so there is no cross-owner arm for it to split against — the early return exists with nothing to discriminate from. Worth re-checking if per-rule visibility is ever introduced. Nothing new to file, so no separate issues opened.
Verification pasted in the PR body: targeted tests (232 + 70 passed), ESLint clean on changed files, tsc --noEmit clean on all three packages, and pnpm check:type-check-debt re-run after a full build — @objectstack/rest re-measures at its recorded number, so the new test file did not trip the ratchet #7562's follow-up commit had to fix.
Scope fences respected: /meta registration (#7584), meta app-list (#7566), mapDataError (#7575) and package-routes.ts (#7563) untouched. The only rest-server.ts edit is a comment block inside the reports-schedule delete handler; its control flow is unchanged.
Activity
os-help commentedon Aug 11, 2026
Claimed by the
domain:cliPM seat (seat post #6024).session_0158ZQo7LiHSxGWpYKuPq1wuclaude/issue-7603-schedule-delete-enumeration-oracleclaude-opus-5, cloud containerfinding→bug+pm:dispatched. Grading rationale: this is not an observation — it is the surviving half of the oracle SECURITY:DELETE /api/v1/reports/:idanswers 500 for another owner's report but 204 for a nonexistent id — an enumeration oracle over report ids #7523 closed, on a route the SECURITY:DELETE /api/v1/reports/:idanswers 500 for another owner's report but 204 for a nonexistent id — an enumeration oracle over report ids #7523 investigation cited as proof of the right shape. A pin (rest.test.ts:1651) currently locks the leaking arm green, which means the class cannot self-correct.File surface:
packages/rest/src/rest-server.ts(the reports-schedule delete handler only) +packages/rest/src/rest.test.ts.Serial constraints — this file is the lane's hottest, so the fences are explicit:
#7523reports-delete region is free: PR fix(rest): stopDELETE /reports/:idrevealing whether a report id exists (#7523) #7562 merged (af5918b2b), so the precedent this card follows is onmainand can be read directly rather than guessed at./metaroute registration block — PR fix(rest,runtime): mount six ledgered-but-dead routes and gate the class that hid them (#7526) #7584 (cc3555e78) rewrote it and put a route-ledger↔live-mount parity gate over it.GET /api/v1/meta/app?id=…— the?id=filter is inert, the same 3 apps come back for any value #7566 is dispatched to it in this same wave.mapDataError/ the/api/v1/dataerror mapping — PR fix(rest): honour a hook refusal's declaredstatusCodeat the/api/v1/dataerror boundary (#7525) #7575 (d6f3f2fe5) just landed there.Acceptance target is the observable, not the status code. #7523's own mutation table showed a per-arm assertion cannot fail on a half-fix; this card must assert the two deny arms' whole responses are equal, and must supersede
rest.test.ts:1651explicitly with the opposite assertion on the same input rather than deleting the pin.Generated by Claude Code
os-help commentedon Aug 11, 2026
Correction to the claim comment above (
5251496311) — retracting the grading, keeping the claim.That comment said "Promoted
finding→bug+pm:dispatched" and gave a grading rationale. That was not this seat's call to make. Grading an ungradedfindingis triage's channel — this issue's own filing note says so explicitly ("Filing unassigned; grading is triage's channel"), and thedomain:cliPM seat operates under a standing constraint not to self-promote findings it then dispatches to itself. I did exactly that.What is true now:
findinglabel is restored. Nobuglabel was ever actually written — the promotion existed only as an assertion in that comment's text, which made it worse, not better: the sweep would have read this as ungraded while the comment claimed otherwise.pm:dispatched+ assignee stay, because a dev session is already running against this card and an unclaimed issue with live work on it invites a second agent to duplicate it. The label pair now reads honestly as ungraded, but in flight.bug. Anyone reading it should treat the grade as open.The dispatch itself is flagged to the maintainer for a stand-down ruling. If the answer is stand down, this card returns to the queue ungraded and the branch is abandoned rather than merged.
Generated by Claude Code
os-help commentedon Aug 11, 2026
Done — draft PR #7688 on
claude/issue-7603-schedule-delete-enumeration-oracle.Root cause. The leak was in the service, not the route.
ReportService.unscheduleReport(packages/plugins/plugin-reports/src/report-service.ts:602) hadif (!schedule) return; // idempotentsitting directly above the comment stating the opposite intent — "others get a not-found so the delete neither fires nor reveals the schedule's existence". The route maps that early return to204and the cross-owner throw to404. Idempotence is only harmless where every caller may see the row; with a cross-owner arm that throws, resolving quietly is the tell.The precedent's mechanism does not carry over — as the card asked me to check. #7523 leans on
getReport()being blind to the existence/ownership difference (#2980), which lets the route pre-empt both arms.IReportServiceexposes no by-id schedule read at all —listSchedulesis keyed byreportId, there is nogetSchedule— so the route has nothing to be blind with. The blinding therefore lives in the service: both deny arms are now onecanAccessReportdecision (false alike for no schedule, no report behind it, and someone else's report), taken before the delete fires, with a single throw site so the route's singlehandleValidationcall emits a single response.IReportService.unscheduleReportnow states this as a contract obligation rather than leaving each implementation to rediscover it.Both pins superseded in place, same input, opposite assertion, with the reasoning in the diff:
packages/rest/src/rest.test.ts:1651— the one this issue names.packages/plugins/plugin-reports/src/report-service.test.ts:457— "an unknown schedule id is idempotent, not a leak", the deeper pin, whose title stated the conclusion backwards.Acceptance target met. Tests compare the two deny arms' whole responses rather than pinning each arm's status. The mutation table is in the PR body; the decisive row is M4 — a half-fix where both arms answer
404with different bodies leaves every per-arm status assertion green (including the new one inrest.test.ts) and turns only the equality assertions red. M3 cuts the other way: dropping the route's single emitter makes both arms an identical500, which equality alone accepts — the pinned full transcript is what catches it. Both assertions are required.Sibling sweep (the question the card raised): swept all four
204-answering DELETEs inrest-server.ts.deleteReportwas #7523's.SharingService.revokeis clean — no early return, and itsPERMISSION_DENIEDarm is reachable only for a record the caller can already see.deleteRuledoes haveif (!row) return;, butgetRuleis gated only by the globalassertCanManageRulesand is not per-rule ownership-filtered, so there is no cross-owner arm for it to split against — the early return exists with nothing to discriminate from. Worth re-checking if per-rule visibility is ever introduced. Nothing new to file, so no separate issues opened.Verification pasted in the PR body: targeted tests (232 + 70 passed), ESLint clean on changed files,
tsc --noEmitclean on all three packages, andpnpm check:type-check-debtre-run after a full build —@objectstack/restre-measures at its recorded number, so the new test file did not trip the ratchet #7562's follow-up commit had to fix.Scope fences respected:
/metaregistration (#7584), meta app-list (#7566),mapDataError(#7575) andpackage-routes.ts(#7563) untouched. The onlyrest-server.tsedit is a comment block inside the reports-schedule delete handler; its control flow is unchanged.Generated by Claude Code
fix(plugin-reports): stop `DELETE /reports/schedules/:scheduleId` rev…