Skip to content

Log the refusal we didn't send - #88

Merged
cuibonobo merged 1 commit into
mainfrom
claude/issue-80-plan-now1d9
Aug 28, 2026
Merged

Log the refusal we didn't send#88
cuibonobo merged 1 commit into
mainfrom
claude/issue-80-plan-now1d9

Conversation

@cuibonobo

Copy link
Copy Markdown
Member

Closes #80.

The problem

Since #79, a verified requester denied read access to a record gets the same StackNotFoundError/404 as a genuinely missing record — the anti-oracle rule. Correct for the client, but it leaves the operator with no way to tell "a grant is subtly wrong" apart from "bad record ID" or "a write never landed," per docs/spec/wire-format.md § Server implementation checklist ("Log the refusal you didn't send").

What changed

errorMiddleware now takes the unscoped Stack and, on a StackNotFoundError for a verified (non-anonymous) requester, probes existence with one extra unscoped read and logs:

  • principalId / subjectId — the requester DID
  • recordId — from the route's :id param
  • existed — whether the record is actually there
  • check: 'read' — always 'read', because ScopedStack's denialFor() and get() both collapse into StackNotFoundError exactly when canRead() is false, regardless of which verb (update/delete/etc.) the request asked for. There's no second gate to name, so this isn't a guess.

Logged at debug, not warn: this line is "who asked after what, and was refused" — the sharing graph, written down — and the spec asks that it not ship to a general-purpose aggregator by default (default LOG_LEVEL is info). The existing warn-level "Denied a verified requester" line for StackPermissionError (readable-but-write-denied, 403) is untouched.

Design decisions

  • Probe at the error boundary (option 1 in the issue), not plumbed from the route (option 2). The issue notes option 1 "can't tell you which check refused — only that something did." That turned out not to hold here: reading ScopedStack's implementation shows the refusing check is always read access for this error class, so the probe recovers full fidelity for free. Documented in the code comment.
  • src/routes/entity.ts's GET /entity has the same ambiguity for a non-owner probing the owner's card, but no URL :id param to key the probe on (the record id is resolved internally via a cached query). Left uncovered for now, per discussion — low value (an attacker already knows the card exists from discovery) and out of scope for the generic mechanism.
  • src/routes/attachments.ts is unaffected — getAttachment/canAccessFile already throws StackPermissionError uniformly for missing-or-forbidden (pre-existing masking, unrelated to Upgrade to the core 0.13 line and adopt the 404/403 anti-oracle rule #79), so it never reaches this branch.

Testing

Added to tests/middleware/errors.test.ts:

  • verified requester denied read on an existing record → logs existed: true with DID, record id, check: 'read'
  • verified requester on a genuinely missing record → logs existed: false
  • anonymous 404 on a private record → logs nothing (matches existing anonymous-401 test)

Full suite: 346/346 passing. Typecheck and lint clean.


Generated by Claude Code

Since #79, a verified requester denied read access gets the same
StackNotFoundError/404 as a genuinely missing record, per the
anti-oracle rule. That's correct for the client but leaves the
operator with no way to tell a subtly-wrong grant apart from a bad
record ID or a write that never landed.

errorMiddleware now probes existence with the unscoped Stack (one
extra read, only on a path that's already failing) and logs the
requester DID, record ID, and whether the record existed. The
refusing check is always logged as 'read': ScopedStack's denialFor()
and get() both collapse into StackNotFoundError exactly when
canRead() is false, so there's no second gate to name. Logged at
debug rather than warn, since this is the sharing graph written down
and shouldn't ship to a general-purpose aggregator by default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0152A7WX2PwCTVr2zgzkS7LZ
@cuibonobo
cuibonobo merged commit 9b36e51 into main Aug 28, 2026
4 checks passed
@cuibonobo
cuibonobo deleted the claude/issue-80-plan-now1d9 branch August 28, 2026 18:42
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.

Log the refusal we didn't send

2 participants