Owner-reported live incident, 2026-07-26. Opening "My meetings" on the
ironmemo stand showed a toast reading `Forbidden: not a member of this
workspace` — to the account that OWNS the workspace, with the membership row
(role=owner, accepted, not suspended) sitting right there in the workspaces
database.
stapel-workspaces 0.4.2 moved its whole API under `v1/` (the §60 v1-canon
sweep). This library's membership client kept requesting the pre-v1 path,
Django's URL resolver answered 404, and get_membership read that as "no such
membership" — then cached the non-answer for 30 seconds. Every caller that
renders None as HTTP 403 duly denied the user. Nothing logged a cause,
because the 404 branch WAS the normal path: the client's own warning only
fires for non-404 codes. The owner saw a confident accusation; operators saw
nothing. The same bug silently broke get_or_create_personal_workspace, so
registration produced accounts with no personal workspace.
Three guards, because any one alone would have let this through:
- The internal API's mount point is DISCOVERED, newest-first, and remembered
per process. A mixed-version fleet — this library ahead of the service, or
behind it — keeps working instead of reading routing 404s as answers.
- A 404 counts as a verdict only when the VIEW rendered it. DRF answers
application/json; Django's resolver and any proxy in front of it answer
HTML. Status code alone cannot tell "that user is not a member" from "that
URL does not exist", which is exactly how this survived. A non-answer is
never cached and never equated with "not a member".
- get_membership(..., strict=True) / require_role(..., strict=True) raise
WorkspaceLookupUnavailable rather than returning None, so a caller that
turns None into 403 can turn an outage into 503 instead. The default stays
None — existing callers are unaffected.
And preflight.E004, so this class of skew is caught before a deploy rather
than by a user: stapel_preflight now asks the workspaces service whether it
actually serves the path this build calls. Every system check in this
codebase validates a service's OWN urlconf; nothing had ever looked outward
at a peer, which is precisely where the contract broke. Read-only — it asks
about a nil UUID, so any answer at all proves the route exists. An
unreachable peer is a warning; only "answers on NO known path" blocks.
Also fixes a test that could only pass in a full run: the system-check case
harvested Errors from whatever OTHER test modules happened to register, so
it was green together and red alone. That is how 0.15.0 shipped on a red
test. It injects its own Error now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>