Files
pad/internal
xarmian aed1ebb101 fix(server): a workspace create answers with a usable workspace or with nothing (BUG-2715) (#1376)
Four doors created a workspace, added the creator as owner, and discarded the
result. A failure left a workspace that exists, is seeded, and that NOBODY CAN
ADMINISTER — returned to the caller as a 201. TASK-2658 made the error visible
and left it non-fatal; this makes it fatal.

FOUR DOORS, NOT THE TWO THE BUG NAMES. Its title says "ownerless workspace on
create, consumed invitation on signup", but workspace import
(handlers_workspaces.go) and bundle import (handlers_import_bundle.go) carry the
identical pattern and both cite BUG-2715 in their own comments. A fix written to
the title would have left two live instances of the same defect: an imported
workspace nobody can administer is the same object as a created one.

The three create-family doors share one helper. handlers_members.go's
authenticated accept-invitation door and the cloud auto-create door were already
fatal and are unchanged.

RECONCILE BEFORE DESTROYING, which is why this is not "delete on error".
AddWorkspaceMember returns the raw tx.Commit() error, so a commit whose
acknowledgement was lost LANDS THE ROW and reports failure — deleting there
destroys a workspace whose membership actually succeeded. That is BUG-3026,
already paid for once on the cloud door, and the four arms here are its four
arms: unreadable (keep, refuse), absent (delete, refuse), present-as-owner
(reconcile to success), present-with-another-role (keep, refuse). What differs
is the outcome, not the reasoning — that door reconciles to a successful
auto-create, these must refuse.

WHY A COMPENSATION RATHER THAN ONE TRANSACTION, measured rather than assumed.
The ruling's first choice was create + seed + owner in a single transaction.
SeedCollectionsFromTemplate issues no SQL of its own but orchestrates five
public Store methods; none has an executor-taking variant — createWorkspaceQ is
the ONLY execQueryer helper in the entire store — and two of them
(CreateCollection, and CreateItem via tryCreateItem) open transactions of their
own. That is a store-wide refactor of the two principal write paths, and it is
filed separately with this measurement attached.

The crash window that buys is stated in the code at the compensation: between
the failed owner-add and the delete, this process can die, and the husk it
leaves is exactly the state being prevented. Small, not zero, and only the
transaction closes it.

The compensating delete is soft, which is sufficient: uniqueWorkspaceSlug
filters `deleted_at IS NULL`, so the slug is released and a retry reclaims the
name rather than landing on `name-2`.

THE SIGNUP DOOR is an ordering fix. The membership error was discarded and the
invitation consumed on the next line, so a failure burned the code AND left the
user with no access — unrecoverable, since an invitation cannot be redeemed
twice. Membership first and fatal, accept second: a refusal now leaves the
invitation OPEN and the user retries with the same code. handlers_members.go's
sibling has always done exactly this. Its second write stays non-fatal here, a
deliberate divergence stated in the code: there the caller is an existing
authenticated user and a 500 is cleanly retryable, whereas here the account was
created by this same request and refusing would report failure for a
registration that succeeded, with the retry landing on duplicate-email.

THE MaxOpenConns(1) CLAIM IS SETTLED RATHER THAN DEFERRED. BUG-2778/BUG-2409
were being carried as a hazard attaching to this change; measured, the
compensation's calls are sequential — no read is issued while a transaction on
this path holds a connection — and the door completes under a single
connection. The leg proving it is shown able to go RED: issuing the membership
check on a held connection hangs it to the timeout.

CODEX ROUND 1 closed five findings, three of which were mine being wrong in a
comment rather than in code — the failure mode where a false explanation
forecloses the question for the next reader:

- The deleted-workspaces list is scoped by `workspaces.owner_id`, NOT by
  membership as I had written, so a soft delete alone would surface the husk in
  the creator's restorable list and restoring it would hand back the ownerless
  workspace. The removal is now soft-delete then PurgeWorkspaceData, which also
  takes the workspace ID rather than the mutable slug.
- The signup refusal claimed the user could "retry with the same code". With the
  account persisted that was false — the retry meets duplicate-email. The
  account is now rolled back too, following the verification-token path's
  existing precedent directly above it.
- Absence of THIS caller's owner row is not absence of every member. The ABSENT
  arm now proves the workspace has no other members before removing it, and
  keeps it when the member list cannot be read.

Nine mutants, each killed by its own legs, including the deadlock negative
control; two that would not compile were reported as non-verdicts and re-run in
a form that builds.

Gates: make test exit 0, make test-pg exit 0 (the ack-loss shape is a Postgres
failure mode, so SQLite-green alone would not have been evidence), make lint 0
issues, gofmt clean, go vet clean.

Claude-Session: https://claude.ai/code/session_019EmDd3Wy8b3cPG1TZBSzBu
2026-09-14 19:43:25 -04:00
..
2026-03-26 01:52:36 +00:00