`botseon privacy export` / `botseon privacy erase`
Operator·12 minutes to read
F-DSR-1's subject-rights commands, apps/cli/src/privacy.ts. Both require --org <id> and
--user <id>, and both refuse (exit 2) if the user is not a member of that organisation — a
support engineer who can otherwise reach every organisation through withPrivileged still cannot
point either command at a user outside the organisation named on the command line.
privacy export
botseon privacy export --org <id> --user <id> --actor <id> [--attachments <dir>] [--out <file>] [--json]
--attachments <dir>— save the subject's attachment bytes to disk under this directory. Without it (or when no blob store can be constructed), each attachment's bytes are a same-size placeholder rather than the real file — and every attachment in the export always carries aplaceholderfield,truein that case, so a reader can never mistake a stand-in for real content.saveAttachmentByteswarns once per placeholder file it writes.--out <file>— write the export as JSON to a file. Without--out, or with--jsoneven alongside--out, the export also prints to stdout.- Secrets (provider tokens, anything matching the redaction patterns) are redacted before either
output — the same
redact()every ledger write and CLI export uses. - Runs under
withOrgScope, scoped as the subject themselves: every query inside the export is already narrowed to the subject's own rows by its ownWHEREclause, so scoping as the subject is least-privilege and can only be redundantly safe. - Exit
0on success;2on a missing--org/--user, a user outside the organisation, or a user who no longer exists (already erased — "nothing to export", not an error about your command).
What the export does not cover
Printed in the export's own notCovered array, quoted directly from the source:
export const NOT_COVERED: Array<{ store: string; reason: string; tables?: string[] }> = [
// M3b Task 18 (R20, ruling P1) REMOVED the computer-plane entry that stood here. It named six
// tables — computers, computer_snapshots, computer_sync_objects, computer_sites,
// computer_action_log, teach_recordings — and said the box, its volume, its durable copies, its
// sites, its log and the demonstration video of the subject all survived the erase. All six are
// STORES rows now, with a step each: `owner_kind = 'user' and owner_id = $2` is the predicate the
// polymorphic pair denied the pg_constraint survey, `destroy({ keepData: false })` takes the
// volume and everything on it, and `computer_erasures` records that it happened. The one thing
// that is still not destroyed is `computer_action_log`, and it is not a residual: it is a
// deliberate RETAIN to a 30-day floor, itemised as such in STORES where the plan prints it with
// a count. Nothing about the plane belongs in this list any more — an entry here would say the
// command cannot reach something it now reaches, which is the mirror image of the dishonesty
// this list exists to prevent.
{ store: 'connector caches', reason: 'connectors arrive at M1b and the marketplace at M4' },
{
store: 'the web subject console',
reason: 'deferred to M2 under D30; this CLI is the M1 answer',
},
{
store: 'the encryption key (no crypto-shred)',
reason:
'A2 I4. `org_keys` holds one KEK per ORGANISATION (packages/providers/src/keys.local.ts), ' +
'so erasing one member shreds no key — doing so would destroy every other member’s data. ' +
'What the subject’s retained ciphertext (run_events.payload_enc, audit_log.details_enc) ' +
'gets is the disappearance of the user id that named it, not undecryptability. A ' +
'per-subject shred would need per-member DEKs, which the M1 key model does not have; the ' +
'KEK is shredded on organisation deletion. M3b Task 18: the same reasoning is why the ' +
'computer plane’s erasure does not call `destroyComputerKek` — that verb destroys the ' +
'ORGANISATION’s KEK, and reaching it from a member erasure would make every other member’s ' +
'volume, snapshot and synced copy unreadable. The subject’s box is erased by destroying the ' +
'volume itself (`destroy({ keepData: false })`) and deleting the durable blobs, not by ' +
'shredding a key they share with the organisation; `computers.wrapped_volume_key` is nulled ' +
'so nothing can re-derive a boot key for a volume that is being deleted',
},
{
store: 'organisation- and bot-scope memories naming the subject in free text',
reason:
'A2 C1. `memories.subject` (0006:105) is free text, not encrypted, and carries a person’s ' +
"name beside `subject_kind = 'member'`. A memory another member wrote at organisation or " +
'bot scope about the erased person is neither theirs by `owner_user_id` nor scoped to them ' +
'by `member_user_id`, and the row holds no link to users(id) at all — so no delete, no ' +
'cascade and no `on delete set null` can reach it. Closing this needs a ' +
'`subject_user_id uuid references users(id) on delete set null` column, which is an M1b/M2 ' +
'migration (ruling R2: no new migration in M1c)',
},
{
store: 'the connector’s secret material (erased, never exported)',
reason:
'The export gap this entry used to declare is closed: `connections`, `notifications`, ' +
'`plugins` and `queuedMemories` are `SubjectExport` fields, and `EXPORT_COVERAGE` below ' +
'pins every `personal: true` table to the field that carries it or to a declared reason, so ' +
'the export half can no longer widen silently the way it did (it was two tables wider than ' +
'the note that parked it: `plugin_installs`, and `memory_proposals`, whose member-scoped ' +
'rows are memory content about the subject). What stays out is the secret material those ' +
'stores hang off, and it stays out of the export ONLY — M1b’s erase plan itemises all of it ' +
'by name. `connector_tokens` cascades on `connector_accounts` and holds no privilege for the ' +
'application role at all (0020’s `revoke all`); `oauth_flows`’ PKCE verifier is a single-use ' +
'secret. An export leaves the machine, and an export never carries a secret — handing a ' +
'subject their own access token would create the disclosure risk the control-plane split ' +
'exists to prevent. There is no milestone at which that changes',
},
{
store: 'routine state and message links (counted through their parents, not on their own)',
reason:
'Both are personal in the manifest and both are erased — `routine_state` cascades on ' +
'`routines(id)` and `message_links` on `messages(id)`, and this command removes or cascades ' +
'both parents — but neither carries a user column, so there is nothing for a per-store count ' +
'to compare against and no honest number to put in the plan. They are listed here rather ' +
'than counted so the omission is deliberate and visible. Giving them one would mean joining ' +
'to the parent in `countRows`, which is a shape this file does not have and M2’s subject ' +
'console is the place to add',
tables: ['routine_state', 'message_links'],
},
{
store: 'the run ledger (run_events)',
reason:
'retained and pseudonymised by the disappearance of the user id it named (§2.10), never ' +
'rewritten or exported as free text — a per-subject browse of the raw event stream is the ' +
'web subject console’s job, deferred to M2 under D30 same as above',
},
{
// Review finding 1 (Major), fix round 1. This list feeds BOTH commands — `buildSubjectExport`
// sets `notCovered: NOT_COVERED` — so removing the computer-plane entry because *erasure* now
// reaches the plane also removed the only statement that the *export* contains none of it. It
// still contains none of it, and F-DSR-1 names the scope: "search a person across memories,
// transcripts, ledger, attachments, COMPUTER FILES, connector caches". Restored here in the
// `run_events` shape — a store this command reaches and destroys but does not render — rather
// than in the old shape, which said the erase could not reach it. That is no longer true and
// must not be re-asserted; what is true is that the export shows nothing of it.
//
// No `tables` array, deliberately: the six tables ARE covered stores now, and naming them here
// would put them back in the union the coverage test reads and undo Task 18's own claim. The
// gap is the export's rendering, not the erase's reach.
store: 'the computer plane in the export (the box, its sites, its snapshots, its recordings)',
reason:
'reached and DESTROYED by `botseon privacy erase` since M3b Task 18 — see the six ' +
'`computers*`/`teach_recordings` rows in the plan a dry run prints — but never RENDERED as ' +
'export content. F-DSR-1 puts "computer files" in the export’s scope and this export has no ' +
'field for them: `SubjectExport` carries memories, messages, attachments, approvals, the ' +
'bot organisation and conversation memberships, and nothing about a box, the sites it holds ' +
'a login for, its durable snapshots or the demonstration recordings on its volume. Rendering ' +
'them means reading files off an encrypted volume through the daemon, which is a per-subject ' +
'browse of the box — the web subject console’s job, deferred to M2 under D30, same as the ' +
'run ledger above. Until then an Art. 15 answer built from this export must say so',
},
{
// Area review A, A-I7. This entry used to name `voice_calls` and `voice_turns` too, and said
// in its own words that the encrypted transcript of the subject's calls survived the erase.
// That is no longer true and must not be re-asserted: `voice_calls` is a STORES row with a
// count and a step, and the turns leave on its cascade. What is left here is the rest of the
// plane, which is retained and pseudonymised on purpose.
store:
'the voice plane around the call (catalogue, metered segments, consents, numbers, meetings)',
tables: [
'voice_voices',
'voice_usage',
'voice_consents',
'voice_org_settings',
'voice_numbers',
'voice_media_token_burns',
'meeting_attendances',
'meeting_participants',
],
reason:
'added by migrations 0080–0087 (the voice plan’s Task 2 and the plan-end fix wave). Every ' +
'reference to `users(id)` here is `on delete set null` (M1c 0032), so erasing the subject ' +
'pseudonymises each row rather than removing it. What that leaves standing, said plainly: ' +
'the metered segments and their amounts (the credit-ledger draw derived from them is ' +
'unverifiable without them, the same trade `credit_ledger` itself makes), a registered ' +
'phone number’s ciphertext until the number is released, and any meeting the bot attended ' +
'for them — each with the user id nulled. The recorded consents are a third party’s, not ' +
'the subject’s, and are retained as evidence that consent existed. The `personal: true` ' +
'rows and the retention floors are in `data-classes.json`',
},
{
// The `routine_state`/`message_links` shape above: erased, but through a parent rather than
// through a step of its own, so there is no honest per-store number to print beside it.
store: 'the spoken transcript (erased with the call, not counted on its own)',
tables: ['voice_turns'],
reason:
'ADR 0020 §4.7 makes a spoken turn history: 0080 revokes UPDATE and DELETE from BOTH ' +
'application roles and `packages/db/src/lint.ts`’s APPEND_ONLY keeps them revoked, so no ' +
'statement this command could issue at any privilege it holds would be legal against the ' +
'table. The rows still go: `voice_turns.call_id` is `on delete cascade` and the ' +
'`voice_calls` step above deletes the subject’s calls, and a referential action is not ' +
'privilege-checked against the deleting role. It is listed here rather than counted because ' +
'the table carries no user column for `countRows` to compare against — the same reason ' +
'`routine_state` and `message_links` are listed rather than counted. Giving it a number ' +
'would mean joining to the parent in `countRows`, which is a shape this file does not have ' +
'and M2’s subject console is the place to add',
},
// M4 (0060, 0061, 0063). Two groups, appended to NOT_COVERED and, in the export half's own
// shape, to EXPORT_NOT_COVERED. `store` is the human label every entry in this array carries;
// `tables` is the field the coverage test reads.
{
store: 'per-plugin variables and member-configured MCP servers (M4)',
tables: ['plugin_variables', 'mcp_servers'],
reason:
'M4’s plugin substrate (0060, 0061): the subject’s own plugin settings (a helpdesk ' +
'subdomain — F-PLUG-7) and the MCP servers they configured (a label, a URL, encrypted ' +
'static headers — F-PLUG-4). Both reference users(id) with on delete cascade, so step 3’s ' +
'delete from users removes them, and plugin_secrets and mcp_tools cascade behind them; ' +
'nothing in them is content a subject reads back, and the install they hang off is already ' +
'itemised as `plugin_installs`',
},
{
store: 'the organisation knowledge base (M4)',
tables: ['knowledge_documents', 'knowledge_chunks'],
reason:
'organisation content (F-MEM-6), personal only in that `member_user_id` names who uploaded ' +
'it. 0063 nulls that column on erasure (on delete set null, 0032’s pattern for an ownerless ' +
'bot), so a document survives its uploader with no owner and its chunks stay with it; a ' +
'bot-scoped document goes with the bot. Rendering document text into an export is the web ' +
'subject console’s job (M2), the same call the run ledger’s group above makes',
},
{
// M5's teamwork delta, R20, in the shape of the "routine state and message links" row above.
// None of the four carries a user column, so none appears in `surveyUserReferences` and there
// is nothing for a per-store count to compare against; each is reached — or deliberately not —
// through a parent this command already handles.
store:
'trigger deliveries, channel events and memory evidence (counted through their parents, not on their own)',
reason:
'`trigger_deliveries` cascades on `routines(id)`, which cascades from `users`, and ' +
'`memory_evidence` cascades on `memory_proposals(id)`, which this command already removes as ' +
'a cascade step — so both go, without a number of their own. `channel_events` is the other ' +
'kind, and the only one left here: it carries the sender’s identifier in an external ' +
'workspace on a `channels` row that is the ORGANISATION’s and survives them, and — this is ' +
'why no predicate exists rather than why no grant does — ruling M5-D7 keeps the ' +
'binding-member scope with NO identity mapping, so nothing in the schema connects a `users` ' +
'row to an `external_user_id`. The erase arm is booked against M6’s identity mapping. Until ' +
'then the column is transcript-class in `data-classes.json` (30/730/2555) and ages out under ' +
'retention — which is a real mitigation only once M2’s deletion jobs exist, and is stated ' +
'here as owed rather than done (area review A, A-M1). `handoffs` left this entry at ' +
'A-I3: 0090 grants the DELETE and `STORES`/`DELETE_STEPS` now carry its predicate',
tables: ['trigger_deliveries', 'channel_events', 'memory_evidence'],
},
// M2 (0100). The subscription mirror. `spend_limits` was here beside it as T1's stopgap and is
// a STORES row now (M2-R-W1-1): the member-scoped ceiling is deleted outright under
// `botseon_privileged`, which is what ADR 0018 §8.6 and ruling M2-R-PF28 require and what the
// manifest row already said. What stays here is the one of the two with no user column at all.
{
store: 'the subscription mirror (M2)',
tables: ['subscriptions'],
reason:
'it holds no reference to users(id) at all — it is keyed on the organisation and carries ' +
'the provider’s own customer and subscription identifiers, which the statutory bookkeeping ' +
'period keeps (`data-classes.json`: a 1825-day floor). It is personal only in that a ' +
'personal organisation has one member, so a SUBJECT erasure has nothing to reach here and ' +
'no honest per-subject count to print. The row is anonymised when the ORGANISATION is ' +
'deleted, which is M2 Task 16’s deletion sweeper (`@botseon/privacy`’s `runDeletionSweep`) ' +
'rather than a step of this command',
},
// M2 (0103). The Annex III disclosure ledger. This table holds NO foreign key to `users(id)` at
// all, so `surveyUserReferences` never finds it and `unaccountedTables` never refuses on it; it
// is here because its manifest row is `personal: true` and the coverage check reads this
// `tables` field. T3 declared it as a stopgap claiming nothing; the reason below is now the
// behaviour, written by the task that built the engine (M2-R-W1-1).
{
store: 'the Annex III disclosure ledger (M2)',
tables: ['disclosure_log'],
reason:
'F-AIA-1 evidence that a person was told they were talking to a machine: an organisation, a ' +
'run, a message, a bot, a channel, a disclosure version and a timestamp — identifiers and no ' +
'free text. It is append-only for BOTH roles at table level (0103), so no erase step could ' +
'rewrite it even under `botseon_privileged`, and its manifest floor is 180 days against ' +
'`run_events`’ 90 — the whole reason it is its own table. `run_id`, `message_id` and ' +
'`bot_id` are `on delete set null`, so a subject erasure pseudonymises every row pointing at ' +
'their runs and messages and leaves the deployer the count it has to keep. It is ' +
'`personal: true` because those identifiers resolve to a person while the rows they name ' +
'still exist, not because the ledger holds a name. It is therefore NOT counted as a store: ' +
'there is no column to compare a subject against, and the erase reaches it only through the ' +
'runs and messages it names. The row itself goes with the organisation, on 0103’s cascade ' +
'on `org_id`, and M2 Task 16’s `kek` stage makes what is left of it unreadable rather than ' +
'deleting row by row. Rendering the disclosures made to a subject into a subject-access ' +
'document stays out of this export, and EXPORT_NOT_COVERED says so',
},
// M5b Task 8 (0121). F-GRP-1's task board, in the shape of the M5 teamwork entry above: `store`
// is the human label every entry carries, `tables` is the field the coverage test reads.
{
store: 'the group task board (M5b)',
tables: ['group_tasks', 'group_task_events'],
reason:
'a task is the GROUP’s work and survives its creator, the trade `knowledge_documents` ' +
'makes one entry up: erasing a member must not take the team’s board away, and a task ' +
'nobody created is still the task the group agreed on. 0121 gives every user column a ' +
'referential action rather than a cascade on the row — `group_tasks.owner_user_id` and ' +
'`created_by_user_id` and `group_task_events.actor_user_id` are all `on delete set null` — ' +
'so step 3’s `delete from users` pseudonymises the board and leaves it standing. What the ' +
'subject actually wrote inside a group is `messages`, which has a `STORES` row of its own ' +
'above; a task title and detail are encrypted under the organisation KEK and age out under ' +
'the `task_board` retention class in `data-classes.json` (30/730/2555). It is listed here ' +
'rather than counted for the reason `channel_events` is: a count would have to promise a ' +
'deletion this command does not perform. The itemised, per-row rendering is M2’s subject ' +
'console, as for `handoffs`. `group_task_events` needs no argument of its own beyond this: ' +
'it cascades on `group_tasks(id)` and no role holds UPDATE or DELETE on it at all',
},
// P1-C (0138; the P1-C preflight's amendment 8). Personal, and no `users(id)` reference — its
// subject link is `memory_id` — so neither the survey nor `KNOWN_UNTRACKED_REFERENCES` names it.
{
store: 'bot memory snapshots',
tables: ['bot_memory_snapshots'],
reason:
"ciphertext copies of memory rows keyed by memory_id with on delete cascade (0138); the memories step's hard delete takes every copy with it, so there is nothing left to walk (P1-C spec §3.3; F-MEM-7, an M6 catalogue row built by P1 — D245)",
},
// P3-A T5 (0152, D274). Declared rather than counted, and for a reason no other entry has: the
// table has no `org_id`, so `countRows`' `where org_id = $1 …` could not even be written against
// it. The row IS erased — by the custom `mail_suppressions` DELETE step above, which runs at
// step 1 while `users.email` is still there to hash — so this entry says where to look rather
// than admitting a gap.
{
store: 'the mail suppression list',
tables: ['mail_suppressions'],
reason:
'deployment-wide and without org_id, so outside the org-scoped erase plan; erased by the ' +
'custom mail_suppressions DELETE_STEP by the hash of the subject’s address before the ' +
'users row goes. The table is privileged-only and org-less by design, the shape ' +
'`stripe_events` has carried since M2 (0101), which is why `countRows` — hard-wired to ' +
'`where org_id = $1` — cannot reach it and a step of its own is what does',
},
// P3-B T5 (0153, A10). What the manifest's `personal: true` flip on `org_policies` owes the
// erase side — and the one entry in this list that declares a table deliberately NOT erased.
{
store: 'the organisation memory suppression list',
tables: ['org_policies'],
reason:
'an administrator’s exclusion list (0153, memory_suppression) rather than a record about ' +
'the subject: it names people the deployment is told never to remember anything about, and ' +
'deleting an entry on erasure would resume remembering the person it names. It is retained ' +
'for the life of the organisation, cascades with it, and is never logged or audited by ' +
'value. The rest of org_policies is the organisation’s own configuration, and a per-row ' +
'browse of it for one member is M2’s subject console’s business (D30), not this command’s',
},
];
Four different kinds of gap, not one.
The web subject console does not exist yet at all (M2, D30). Neither does the marketplace's connector cache (M4) — note that this is not the same thing as a connected account, which does exist now and is the fourth kind below.
The computer plane is the second kind, and what kind it is changed with M3b Task 18. It used to
be "this command cannot reach the box at all". It is not that any more: R20 is closed, and
privacy erase reaches and destroys the box, its encrypted volume, its durable snapshots, its sync
objects, the sites it holds a login for and the demonstration recordings on it, as six itemised
STORES rows — see "The computer gate" below. computers.owner_kind/owner_id is still a
polymorphic pair with no foreign key to users(id), so no referential action fires and the
pg_constraint survey still cannot see it; the command reaches it by an explicit step instead,
owner_kind = 'user' and owner_id = $subject.
What the export does with the plane did not change, and this heading is about the export.
F-DSR-1 puts "computer files" in the export's scope and SubjectExport still has no field for
them: it carries memories, messages, attachments, approvals, the bot organisation and conversation
memberships, and nothing about a box, its sites, its snapshots or its recordings. Rendering them
means reading files off an encrypted volume through the daemon — a per-subject browse of the box,
which is the web subject console's job (M2, D30). So an Art. 15 answer built from this export must
say the computer files are not in it; an Art. 17 answer built from privacy erase must not, because
they are gone.
The voice plane is now the same shape as the computer plane, and it changed for the same
reason. It used to be "the encrypted transcript of the subject's calls survives the erase with the
user id nulled" — true while every foreign key to users(id) was on delete set null and nothing
deleted a call row. The voice plan's plan-end fix wave closed that (area review A, A-I7): migration
0087 grants botseon_privileged a DELETE on voice_calls, voice_calls is a STORES row with a
count and a step, and the spoken turns leave on voice_turns.call_id's on delete cascade — a
referential action is not privilege-checked against the deleting role, so the transcript is erased
while voice_turns stays append-only for both application roles, as ADR 0020 §4.7 requires. It is
listed above under "the spoken transcript (erased with the call, not counted on its own)" for the
same reason routine_state and message_links are: the table carries no user column, so there is
no honest per-store number to print beside it.
Nothing else follows the call. voice_usage.call_id and meeting_attendances.call_id are on delete set null, so the metered segments and the amounts the credit ledger is derived from survive
with the call id stripped, and a phone or meeting leg that named nobody (member_user_id null) is
the organisation's rather than the subject's and is neither counted nor taken. Those rows, the
consents, the catalogue and the registered numbers are what the first voice entry above still
declares: retained and pseudonymised, on purpose.
What the export does with the voice plane did not change. voice_turns.text_enc is the
transcript of record for a spoken conversation (ADR 0020 §4.2) and SubjectExport has no field for
it: rendering it needs the per-call decryption a subject console would build, which is M2 under
D30. So this is the run_events shape — a store the command reaches and destroys but does not
render — and an Art. 15 answer built from this export must say so, while an Art. 17 answer built
from privacy erase must not, because the transcript is gone.
The run ledger (run_events) is the third: it exists, is retained, and is pseudonymised the
moment the subject is erased — it is left out of the export only, because rendering the raw
hash-chained event stream as free text is the web subject console's job (M2, D30), not this
command's.
The fourth kind arrived with the connectors themselves, and it is now closed. A store this command
erased but did not export was a subject-access shortfall rather than an erasure one: four
tables were itemised by name in the erase plan and re-counted after the cascade — so the command
told an operator they hold the subject's data — while SubjectExport carried no field for any of
them. M1b's plan-end fix wave added the four fields (connections, notifications, plugins,
queuedMemories), and the gap turned out to be two tables wider than the note that parked it:
plugin_installs, and memory_proposals, whose member-scoped rows are the subject's own queued
memory value, held indefinitely because nothing prunes the table.
What keeps it closed is EXPORT_COVERAGE and EXPORT_NOT_COVERED in privacy.ts: every
personal: true table in the manifest either names the SubjectExport field a reader finds it
under, or says why it is not exported, and privacy.test.ts checks both directions. The erase half
has had that check since M1c's fix round 1; the export half had none, which is exactly why it could
widen in silence. connector_tokens and the PKCE verifier in oauth_flows stay out either way —
an export leaves the machine, and an export never carries a secret.
Skill documents (skills, M5's 0070) are not in this list: they are a store of their own,
exported in full with the body decrypted (EXPORT_COVERAGE maps the table to SubjectExport's
skills field), and split at erasure — the subject's unpublished drafts are deleted outright,
while a published or built-in skill is the organisation's (other members' bots follow it) and
survives with owner_user_id nulled, exactly as a bot does. The two plumbing tables around it
(skill_enablements, skill_imports) are in EXPORT_NOT_COVERED: they record which bot follows a
method and the pin an import was taken at, and carry the subject only as provenance that 0070 nulls
on erasure.
M4's two groups (0060–0064) are the third kind's shape rather than a fifth: they are erased and
not exported, and the erase is the schema's own rather than this command's. plugin_variables,
plugin_secrets and mcp_servers reference users(id) with on delete cascade, so step 3's
delete from users takes them and mcp_tools/mcp_tool_diffs cascade behind the server;
plugin_policies.set_by, catalogue_imports.accepted_by and org_modules.enabled_by are
provenance on rows the ORGANISATION owns and are nulled, as egress_policies is;
knowledge_documents.member_user_id is nulled so an organisation document survives its uploader
with no owner, the way a published skill and an ownerless bot do. All seven are named in
KNOWN_UNTRACKED_REFERENCES — the live pg_constraint survey finds them, and a table this file
cannot name is a table it refuses to erase around. Rendering a document's text into an export is
the web subject console's job (M2, D30), the same call the run ledger's entry makes.
privacy erase
botseon privacy erase --org <id> --user <id> [--confirm <user-id>] --actor <id> [--out <dir>] [--allow-retained-blobs] [--allow-retained-computers] [--dry-run]
Without --confirm <user-id> equal to --user (or with --dry-run), nothing is written: the
command prints the plan — every store in STORES, its action (delete / pseudonymise /
retain), a live row count, and why — followed by the same "not covered" list the export shows,
and exits 2.
With a matching --confirm, three gates run before a single row is touched, any of which
refuses with exit 1 and writes nothing:
-
The coverage gate. Every foreign key to
users(id)in the live schema is surveyed fresh frompg_constraint(never a hand-written list) and checked againstSTORESplus a small known-cascade allowlist. A table that referencesusers(id)but is named in neither is an unaccounted-for table this command cannot promise anything about, and the whole run refuses — before the first write — naming the table and asking for aSTORESrow (or the allowlist) to be added first. The same survey runs again after the erase's owndelete from usersand fails the run (even though the deletes already committed) if anything is still unaccounted for. -
The blob gate. If the subject has attachment blobs and no blob store can be constructed (
BOTSEON_BLOB_DIRandBOTSEON_BLOB_SECRETmust both be set, the same pairapps/web/lib/db.tsrequires), the run refuses — §2.10 requires a hard delete to include blobs, not just rows.--allow-retained-blobsis the explicit escape hatch: pass it to proceed anyway, and the retained keys are recorded in the report (blobsRetained,blobsRetainedReason) instead of deleted. -
The computer gate (M3b Task 18, R20). The subject's own boxes are enumerated (
owner_kind = 'user'; abot-owned box is the organisation's asset and is never touched). If there are any and noComputerEraseris wired into the invocation, the run refuses — a hard delete that cannot reach the encrypted volume, its logins and the demonstration videos of the subject is not a hard delete, and F-DSR-1/Art. 17 makes that a refusal rather than a warning. The eraser is injected rather than built fromCOMPUTER_PROVIDER, because that variable defaults tofake: a factory reading it would hand the command a provider that destroys nothing while the report claimed success.--allow-retained-computersis the explicit escape hatch, and what it retains is recorded in the report (computersRetained,computersRetainedReason).
Once the three gates pass, the computer plane goes first, before any other write, so a driver
that refuses leaves the whole erase refused with nothing done. Per box: destroy({ keepData: false }), then a computer_erasures row written at the driver's return — on Fly that return
means accepted, so erasure_requested_at is stamped there and erased_at only after a
confirming poll (the driver's own, if it can answer one, otherwise sweepPendingErasures'). Every
durable snapshot is released and its blob deleted on the release that takes the refcount to zero;
the demonstration recordings are deleted; the site rows are overwritten (host becomes
erased:<row id> — no role holds DELETE on computer_sites); computer_action_log is counted and
retained to its 30-day floor, which is the deliberate decision R20 asked for rather than a
cascade making it by accident; and the computers row is soft-deleted with provider_id and
wrapped_volume_key nulled. Soft, because a hard delete would cascade computer_erasures — the
record that the erasure happened — out of existence. The report's computerErasures array names
every erasure still awaiting its poll, and the command warns on stderr for each, so nobody answers
a data-subject request with today's date for a volume that is still in pending_destroy.
Then: the subject's blobs are deleted first (recorded as blobsDeleted), then
every STORES row is processed — each its own transaction, appended to the report as it commits,
so an interrupted run's report shows exactly how far it got and a re-run resumes rather than
redoing completed stores — the subject's unfinished runs are cancelled, and a single
delete from users fires every referential action migrations 0030 and 0032 added, which is what
actually pseudonymises everything STORES marks pseudonymise (credit_ledger, audit_log,
bots, conversations, runs, policy_rules's author, memory_grants, approvals.decided_by
— see docs/architecture.md).
--actor <id> (required for both commands) names who performed the operation — a support
operator, typically, since the subject being erased is (by definition) no longer able to act after
their own users row is gone. The command refuses unless that user is an owner or admin of the
organisation; a plain member cannot export or erase another member's data.
The erase's last step writes an audit_log row (action: 'privacy.erase', actor_user_id from
--actor, target the erased subject's id, and the whole erase report — subject id,
per-store counts, cancelled runs and blobs — encrypted into details_enc) — proof of who ran it and what it touched, on a table UPDATE is
already revoked from, so that proof cannot later be rewritten either.
--out <dir> (default: the current directory) is where erase-report.json lands — the same
report a resumed or interrupted run is checked against, and the file this command's own exit code
(0 success, 1 a mid-run failure) reflects.
An already-erased subject (a --user with no users row left, org_members gone with it via
0002's cascade) is let through rather than refused: a resumed erase after an earlier interrupted
run is the normal case, and the only real membership violation this command's gate catches is a
user who exists, but in a different organisation than --org names.
Last verified against build c0f77aa.