Actor Deletion Semantics
Audience: Applications that do external cleanup when an account is deleted — cancelling a payment subscription, revoking an IdP grant, deleting remote files.
This page states the contract you may rely on: what deletion does, in what
order, what get_by_id() reports while it happens, and how to write a guard
that suppresses late writes for an actor that is gone.
The short version
Question |
Answer |
|---|---|
Does |
No. The actor row is removed last, so it resolves throughout. |
Does |
Yes — indistinguishable from “does not exist”. |
How do I check “was this actor deleted?” |
|
Where do I put external cleanup? |
The |
Do attribute/property writes check the actor exists? |
Do not write a guard of the form if ActorInterface.get_by_id(actor_id,
config) is None: return. The two rows above explain why: it fails open during
the window it exists for, and closed on an unrelated infrastructure fault.
What deletion does, in order
DELETE /<actor_id> (and actingweb.interface.ActorInterface.delete())
runs these steps in this order:
Write a deletion tombstone. On the HTTP path this happens before the
actor_deletedhook runs — so an external call made from that hook, and any provider callback racing it, already seesDELETED.Run the
actor_deletedlifecycle hook (HTTP path only).Delete peer-trustee relationships.
Delete properties and property lists.
Delete subscriptions.
Delete trust relationships, including reciprocal ones on peers.
Delete all attribute buckets.
Delete the actor row — last.
Run the
actor_deleted_completelifecycle hook (HTTP path only).
Two consequences follow from the row going last, and both are contract:
The actor remains resolvable for the whole of steps 1–7.
ActorInterface.get_by_id()returns an actor, and reading its properties returns progressively less data as the wipe proceeds.Deletion is retriable. If a step fails, the actor row is still there and
DELETEcan be issued again. Removing the row first would strand the remaining rows with nothing pointing at them.
The window is not narrow, and it is not hypothetical: it is seconds wide for an
actor with a real property set, and the documented cleanup pattern enters it
deliberately. actor_deleted is where an application cancels an external
subscription; cancelling triggers an asynchronous provider webhook that arrives
back into the window and writes rows that outlive the actor.
Checking whether an actor is gone
from actingweb.interface import ActorInterface, DeletionStatus
def on_provider_webhook(actor_id, payload):
status = ActorInterface.get_deletion_status(actor_id, config)
if status == DeletionStatus.DELETED:
return # account is gone; drop this late callback
# NOT_DELETED and UNKNOWN both proceed
write_entitlement(actor_id, payload)
get_deletion_status() reads a
tombstone written before the wipe begins, and answers with three values:
DeletionStatus.DELETEDA tombstone was found. The actor has been deleted. Suppress the write.
DeletionStatus.NOT_DELETEDThe store was read and holds no tombstone. Proceed.
DeletionStatus.UNKNOWNThe store could not be read (throttle, timeout, missing table, credentials). Proceed.
Cost is one strongly-consistent point read: a single DynamoDB GetItem, or one
primary-key SELECT on PostgreSQL. Cheap enough for a webhook path.
Why UNKNOWN means proceed
This is the part worth internalising, because it is what makes the check correct rather than merely available.
The two error directions are not symmetric. Treating an unreadable store as “deleted” drops real work: for a payment webhook that is a customer who paid, an HTTP 200 back to the provider so it never retries, no entitlement, and nothing logged. Treating it as “proceed” costs at most one orphan row, which an operator sweep can find and remove later.
A tombstone is positive evidence, so it has a safe failure direction. An
absence check has none — that asymmetry is precisely what it cannot express, and
why a guard built on get_by_id() is None cannot be made correct.
Retention
Tombstones live for 30 days
(actingweb.constants.DELETION_TOMBSTONE_TTL), comfortably past every
provider’s webhook retry window — Stripe retries a failed webhook for up to 3
days, and others are comparable. A tombstone that expired in an hour would
reopen the hole it exists to close.
They are stored under a reserved id that is never itself an actor, so no deletion can destroy them. A marker kept inside the deleted actor’s own attribute bucket does not survive the wipe it is meant to describe.
On DynamoDB, expiry is native TTL on ttl_timestamp; on PostgreSQL, rows are
reclaimed by an explicit delete_expired() sweep (see
Database Maintenance Guide), so tombstones accumulate there until one
runs. Either way an expired tombstone is filtered at read time, so TTL means the
same thing on both backends and stale rows never keep suppressing writes.
Note
Re-creating an actor clears its tombstone. Generated actor ids are never
reused, but create(actor_id=...) accepts a caller-supplied id.
What get_by_id() returning None actually means
Warning
ActorInterface.get_by_id() returns None for both “no such actor”
and “the read failed”. A throttled DynamoDB read, an expired credential and a
missing table all present as a non-existent actor.
This is deliberate for backward compatibility: raising instead would turn every transient throttle across authentication, OAuth2 and MCP into an HTTP 500. It is documented here rather than changed.
What did change: the DynamoDB backend now logs the swallowed exception at
ERROR (Failed to read actor <id> ...) where it previously returned None
with no log line at any level. That ERROR is your only signal that an existence
check answered “no” for an infrastructure reason — alert on it. A genuine
absence logs nothing.
If you need the distinction programmatically, use get_deletion_status(),
which reports UNKNOWN rather than guessing.
Lifecycle hooks
Two hooks bracket the wipe, and which one you use matters.
actor_deleted — before, with data
Fires before any data is removed. The actor is fully readable, so this is
where you read what you will need. It receives an ActorInterface.
actor_deleted_complete — after, without
Fires after the wipe completes. Receives actor=None and
actor_id=<id>: there is deliberately no ActorInterface, because the
actor no longer exists. This is where external side effects belong.
@app.lifecycle_hook("actor_deleted")
def read_what_we_need(actor, **kwargs):
# Only place the data is still readable.
_pending[actor.id] = actor.properties.stripe_subscription_id
@app.lifecycle_hook("actor_deleted_complete")
def do_external_cleanup(actor, actor_id=None, **kwargs):
# The actor is definitely gone. A provider callback triggered from
# here cannot race the wipe.
subscription_id = _pending.pop(actor_id, None)
if subscription_id:
stripe.Subscription.delete(subscription_id)
Splitting the work this way removes the race at its source: the callback arrives after the wipe rather than during it, and the tombstone is there to suppress it.
Both hooks are fire-and-forget — an exception is logged and DELETE still
returns 204. Neither fires for
actingweb.interface.ActorInterface.delete(); only the HTTP path runs
lifecycle hooks, so an application deleting an actor programmatically owns its
own cleanup. A tombstone is written on both paths.
Actor-scoped writes have no referential integrity
Warning
Attribute and property writes do not validate that the actor exists. An
unknown or deleted actor_id creates rows that nothing will ever clean up.
actor_id is a key prefix, not a foreign key. This holds on both backends:
PostgreSQL declares ON DELETE CASCADE only on property_lookup, so the
wipe steps in What deletion does, in order are what actually removes an actor’s
data — not the database.
Two ways this bites:
Test fixtures. Generating
test-actor-<n>ids without creating actors, then passing them to code that writes attributes, leaves permanent rows. This accumulates silently — one such fixture produced 228 orphan rows under 114 ids, unnoticed for five months.Late writes. A webhook for an actor deleted an hour ago succeeds and creates rows, unless guarded as above.
If you write actor-scoped data from a path that is not already inside a
request for that actor, guard it. get_deletion_status() covers deleted
actors; ids that never existed need your own check.
Finding orphaned rows
actingweb-verify-orphans (also python scripts/verify_orphans.py,
actingweb.maintenance.verify_orphans) sweeps property, attribute and
trust rows across the WHOLE table — not one actor’s partition — for rows
whose actor_id is absent from the actor table:
poetry run python scripts/verify_orphans.py
poetry run python scripts/verify_orphans.py --rps 200 \
--checkpoint-file .verify_orphans.checkpoint.json
Run it under the SAME environment as the application (DATABASE_BACKEND
and its connection settings), but under an operator credential, not the
application’s runtime role: the full-table scans need Scan/Query
read permission on the actor, property, attribute and trust tables, which a
locked-down serving-path role deliberately lacks. It is a long-running,
checkpointed CLI for a persistent shell — do not invoke it from a
lambda-like runtime. It classifies orphans only; it does not replace
Database Maintenance Guide (TTL cleanup) or
actingweb-verify-property-lists (per-actor list index integrity).
Four edge cases drove its design, and each is a way to delete live data if gotten wrong:
An empty or failed actor-table read must never be read as “zero orphans.” “Every row is orphaned” is the catastrophic misreading of an actor-table read that failed or came back empty — a clean-looking report from a broken read is worse than an error, because nothing about it looks wrong. The tool fails closed instead: it refuses to sweep and exits with status 2, which is deliberately distinct from “orphans found” (status 1) and “clean” (status 0).
Exclude reserved ids unconditionally. Ids prefixed
_actingweb_hold live system data — some are real actors (ACTINGWEB_SYSTEM_ACTOR,OAUTH2_SYSTEM_ACTOR), some (DELETED_ACTORS_STORE, a consumer’s own registries) deliberately are not in the actors table at all. The tool matches on the prefix, not the closed list of known names, and reports them in a separate section — never as orphans.Use consistent reads. An eventually-consistent scan can show a seconds-old actor as absent. Every read this tool issues — the actor-id enumeration and all three row sweeps — asks for a consistent read.
Never automate it.
create()writes the actor row first anddelete()removes it last, so an actor mid-create or mid-delete always still has its row — that ordering is what makes classification safe at all. It is still a point-in-time judgement about rows another process may be writing. There is no--deleteflag and no code path in the tool deletes a row. Run it deliberately, review the output, then delete reviewed rows with your own tooling.
One consequence of checkpointing to know about: when orphans are found,
the checkpoint file is kept (so an interrupted review can resume), and
a re-run against a checkpoint in which every table is already complete
scans nothing — it would just replay the earlier findings. The tool
detects this, warns REPLAYED FROM CHECKPOINT, and exits with status 3
(distinct from 0/1/2) instead of passing the stale report off as current.
After you have removed the reported rows, delete the checkpoint file,
then re-run for a fresh scan.
See also
Hooks Reference — all lifecycle events
Hooks — hook patterns
Database Backends Reference — required tables and schema
Database Maintenance Guide — TTL sweeps on PostgreSQL, and the orphan scan’s operational envelope