The plan described ownership transfer as shipping alongside deletion. It did
not, so the rejected-options table now records why -- a gap invites someone to
rebuild it, a decision does not.
Section 7 said deletion was something you request, which is now false in two
directions: it is a button, and it does not erase quite everything.
The keyed hash we keep to stop a second welcome credit is pseudonymised, not
anonymous, so it is disclosed with the basis we rely on rather than left for a
reader to discover. Same for the invoices our processor keeps for tax. Being
vague about either reads worse than being precise.
The right-to-erasure bullet now points at the control instead of the contact
form.
Six files have to move together on every copy change, and the one that gets
missed shows the user a dotted key path instead of a sentence. Nothing caught
that until it reached a screen; zh had been short a key.
The consequences line names the workspaces and the colleagues, not just "your
data": a warning that undersells what the button does is worse than none.
zh also gains documents.delete_success, which it has been missing -- an absent
key renders as its own dotted path to the user.
The privacy policy has always promised deletion on request, with emailing a
person as the only route. This is that promise, self-serve.
The dialog spells out what is lost before it asks, because the consequences
reach past the person clicking: every workspace they own goes, and anyone
sharing one loses that work. Confirmation is typing DELETE, the same bar the
destructive actions elsewhere use.
On success it reloads the page rather than routing, so no cached query
outlives the account it belonged to.
The startup bootstrap builds the current model shape with create_all, and the
claims table is part of that shape now. On any database where the backend
booted before this migration ran, the table already exists and the bare DDL
aborts the upgrade on "relation already exists".
With no transfer to offer, refusing the deletion would strand the user: the
409 told them to hand a workspace over using a feature that no longer exists.
So the preflight, the refusal, and the re-check in the task all go, and the
erase now deletes every workspace the account owns, shared or not.
Members of a shared workspace lose it. That is a real consequence for people
who did not click the button, so the dialog states it before the account is
erased rather than leaving them to discover it. A workspace the user merely
belongs to is untouched -- only their membership goes with them.
The tests move with the behaviour: the shared workspace is destroyed and its
colleague's own account survives, and owning one no longer holds the route
back.
It was built to keep a shared workspace alive past its owner, so that
deletion could hand it over instead of destroying it. Nobody has asked for
that. It costs a transfer endpoint, a preflight, and a per-workspace
resolution step in the delete dialog, all to serve a case we have never seen.
Deleting what you own is what every other resource here already does. The
transfer comes back the day a user needs their team's workspace to outlive
them.
The three that would be silent disasters: a shared workspace refuses the
delete, the erase leaves workspaces owned by other people alone, and a
workspace that gained a member stops the erase instead of cascading away.
Deactivating is the whole lockout: get_auth_context already turns away
inactive users on both the session and the token path, so no new column and
no auth changes. Refresh tokens are revoked and the cookie cleared, then the
erase is queued.
The blocker check runs server-side here as well as in the client, because
only the server's answer is a guarantee that nobody else's workspace
disappears. A failed enqueue puts the account back, rather than locking
someone out of a deletion that never runs.
Session-only: a leaked personal access token must not be able to destroy the
account it was issued from.
Owned workspaces go through the existing workspace deletion rather than the
FK cascade: the cascade drops the rows but leaves their blobs and knowledge
stores on disk forever.
The blocker check runs again here. A workspace that gained a member since the
request aborts the erase rather than being skipped -- "user" cascades to
workspaces, so deleting the row anyway would take a colleague's workspace with
it. The account stays locked out with its data intact, which is recoverable.
Stripe keeps its charges and invoices, which GDPR 17(3)(b) exempts as a tax
record; only the customer, with its saved card and contact details, goes.
Owned workspaces with other members in them would take that work down with
the account. Each carries its other members so the client can offer a
handover without a call per workspace.
Every multi-member case needs a second account joined to a workspace, and the
deletion suite is about to need the same two helpers the transfer suite grew.
All three ownership fields move together, a non-owner member cannot give the
workspace away, and it cannot be handed to a stranger -- that would need an
invite, which is its own flow.
Deleting an account must not take a shared workspace's other members down
with it, so the owner needs a way out that is not "delete everything".
Ownership is recorded in three places -- workspaces.user_id, the membership's
is_owner flag, and its role -- and any one left behind leaves the workspace
with two owners or none. The transaction stays open so the deletion flow can
hand over several workspaces and erase the account as one unit.
The former owner steps down to Editor rather than being removed: they may
still have work in there, and demotion is reversible by the new owner.
The behaviour the whole feature exists for: a fresh identity is credited, and
the same Google subject arriving on a brand-new user row is credited nothing.
A column default mints $5 for every row, which is precisely what makes
delete-and-re-register profitable. The grant moves into on_after_register,
inside the workspace transaction, so a rollback cannot leave an identity
marked as having taken a credit it never received.
The registration response reads the in-memory user, so the balance is
refreshed there too.
Claiming is a single INSERT ... ON CONFLICT DO NOTHING over every identity the
user presents, so two concurrent signups on one identity cannot both win. An
account whose identities are all already spent is awarded nothing.
A user no source recognises is ungated and says so in the log: that is
AUTH_TYPE=LOCAL, where the operator funds their own inference and there is
nothing to farm.
An empty ledger would hand every current user a free second grant the moment
the delete button ships. Backfill folds `people/<sub>` onto the bare subject
so a pre-169 row cannot claim twice, and skips deployments with no
oauth_account table at all.
Claim dates are the migration's own timestamp: "user" has no created_at to
copy from, and one shared value marks exactly which claims were inferred
rather than observed.
signup_credit_claims deliberately carries no foreign key to "user": the row
whose whole purpose is to remember a spent credit must survive the erasure of
the account that spent it. Unique on (kind, fingerprint) so the grant is a
single insert that either wins or loses.
`sub` is stable per Google account and survives the user renaming their
mailbox, so it identifies the person without any per-provider email
normalization. Rows predating the `sub` switch stored `people/<sub>`, which
migration 169 deliberately left in place, so the prefix is stripped before
fingerprinting.
Production is Google-only today, but password and other providers each need
their own way of naming the person. A decorator keeps adding one to a single
new file, with no edit to the granting path.
The ledger has to outlive the account that wrote it, so it cannot store a
subject id or an email. HMAC under SECRET_KEY rather than a bare hash: the
space of Google subject ids and email addresses is small enough to exhaust
offline against a plain SHA-256.
Three requirements that pull against each other: users must be able to erase
themselves, erasing must not re-mint the $5 welcome credit, and it must not
take a shared workspace's other members down with it. Records the identity
ledger, the Google `sub`-only v1 scope, and the rejected alternatives.
Added a new hook, useReasoningAutoScroll, to manage scroll behavior in the ReasoningEpisode component. This allows for automatic scrolling when new content is added, enhancing user experience during reasoning sessions. Updated the ReasoningEpisode component to utilize this hook, improving accessibility and interaction with the nested scroll area.
Uncomment the DAYTONA_* env passthrough on the backend service and add
DAYTONA_SNAPSHOT_ID, so a deployment that sets SANDBOX_PROVIDER=daytona
actually forwards the key, URL, target, and snapshot into the container.
Document the snapshot name in the compose .env.example.
The Daytona snapshot boots with `sleep infinity`, so bare `python3` is the
Ubuntu system interpreter, which lacks the office wheels (weasyprint,
reportlab, xlsxwriter, python-pptx, python-docx). OpenSandbox instead keeps
the image's code-interpreter entrypoint, which activates the 3.12 venv, so
its `python3` already imports them.
Result: office generation on Daytona silently failed for every format that
has no system-binary fallback — xlsx and pptx never wrote a file, surfacing
downstream as a FileNotFoundError during verification. Only pdf slipped
through, via LibreOffice conversion rather than the intended Python path.
Source code-interpreter-env.sh before invoking python in both sandbox entry
points (the deliverables `execute` tool and the Daytona `execute` heredoc).
On OpenSandbox this re-activates the same interpreter, so it is a harmless
no-op there.
Daytona's daemon returns a FILE_NOT_FOUND body as a plain DaytonaError
(sometimes typed DaytonaNotFoundError) instead of raising FileNotFoundError.
read_receipt only catches FileNotFoundError, so a missing receipt on the first
verification crashed the stream. Translate not-found failures to
FileNotFoundError to match the SandboxSession contract OpenSandbox already
fulfills; re-raise all other DaytonaErrors unchanged.