mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +00:00
aed1ebb101
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