Three commits: a non-authority writer of a member-list simplespace could not make their first write, and a denied write could execute after its 403 was sent. Per the working proposal, OAuth scope gates writes and self-reads while membership gates only credential minting — this stack fixes the bootstrap bug within that split.
1. propagate HandledResponse from access-gate helpers #
requireSpaceScope wrote the 403 response but returned void, so every try requireSpaceScope(...) call site kept executing the denied request after the error response was sent. The helper now returns error.HandledResponse after writing the response, and call sites swallow it at the handler boundary (catch return), matching the requireAccount/requireReadAccess convention.
2. let OAuth scope gate writes; bootstrap writer repos lazily #
requireSpaceAccess decided visibility via the caller's permissioned_space_actor_state row — but that row is only materialized by a successful write, so a member could never make their first write. Chicken-and-egg: member-list spaces were writable only by the authority.
The gate now verifies the caller's OAuth scope and that the space exists and is not deleted (via the authority's config row). The write itself lazily materializes the writer's repo in applySpaceWrites, so no pre-existing actor-state row is required. Own-repo reads keep requiring the caller's materialized row, so a removed member retains read/write access to their own data. getSpaceCredential already evaluates the space policy and is unchanged: membership gates credential minting only.
Actor-state materialization stays exactly as documented: being named in a member list does not make the space appear in listSpaces; materializing a writer repo does.
3. member lifecycle in smoke-permissioned; direct-exec servers #
The permissioned smoke previously exercised only the authority's own writes/reads, which is why the bootstrap bug shipped. It now pins the proposal access split: a never-listed writer's first write succeeds (lazy bootstrap) but credential minting is 403; a listed member writes/reads/mints; a removed member keeps write and own-repo read access but loses minting; re-added gets 200.
Also: both smoke scripts run ./zig-out/bin/zds directly instead of zig build run. When the script's trap killed the build-wrapper, the server child survived as an orphan holding the port; the next run's health check then passed against the stale process while its sqlite inserts hit a fresh empty db ("no such table: accounts"). And a ZDS_TARGET env override (default native) for hosts whose system glibc crt breaks zig's linker.
Verification #
zig build test: passtools/smoke.sh,tools/smoke-permissioned.sh: pass