Written 2026-08-10. Read-only analysis, no code changed. Context: the upstream
smartbotic-database daemon on zeus is 2.11.0 (relations, unique constraints);
our client on mulan is still 2.9.0 with no relation API, so nothing here can be
used yet. This is groundwork for when the client upgrade lands.
Sources read in full before writing this: /data/smartbotic-database/proto/database.proto
(from line 919) and /data/smartbotic-database/client/include/smartbotic/database/client.hpp
(from line 800).
| # | Delete site | Cleans up | Mechanism | Complete? |
|---|---|---|---|---|
| 1 | workflow_controller.cpp:673-696 deleteWorkflow |
its own scheduled triggers, then executions (workflowId match) and its private wf_<id> collection and its files |
updateScheduledTriggers(id,false), then retention_.retire(id) (retention_service.cpp:42-50) queues an async job: restamp matching executions/documents/files to a short TTL, drop the wf_ collection after |
No. retire() restamps only executions matching workflowId at the moment of the call, via an async worker. Anything that failed to restamp (worker not running yet, restart mid-sweep, or - the actual history here - workflows deleted before this retention machinery existed) is never revisited. Confirmed by the hand sweep below. |
| 2 | project_controller.cpp:303-349 deleteProject |
nothing directly; instead refuses the delete while workflows, credentials or workflow_groups still carry that projectId (ownedCollections(), project_controller.cpp:20-25) |
manual restrict-style check, page_size=1000 per collection |
Complete for its stated job (never deletes a project holding work), but it is a hand-written restrict relation over exactly the three collections in ownedCollections() - nothing else with a projectId (e.g. a future collection) is covered without remembering to add it here. |
| 3 | user_controller.cpp:183-227 deleteUser |
sessions (auth_store.cpp:247-252 -> invalidateAllSessions, auth_store.cpp:427-438, deletes each sessions row for the user); the user's personal project, but only if it holds no workflows/credentials/workflow_groups (user_controller.cpp:203-221) |
explicit per-row storage_.remove("sessions", ...); explicit query + conditional storage_.remove("projects", ...) |
Leaves orphans. Sessions are the only thing cleaned. Not touched: credentials.sharedWith arrays containing the deleted user's id (verified no code path strips it - see credential_controller.cpp:107-148, only add/remove-by-request exist); ownerId/createdBy on workflows, credentials, workflow_groups, projects (dangling attribution, arguably intentional - see relation table); collection_permissions and api_keys are unused in this codebase today (see Part 3), so nothing references a user there yet. |
| 4 | workflow_group_controller.cpp:320-379 deleteGroup |
with force=true: reassigns every child workflow's groupId to "" and every child subgroup's parentId to the deleted group's own parent (workflow_group_controller.cpp:334-368); without force, refuses if hasChildren(id) |
manual set_null-style reassignment |
Complete for its own children. This is exactly what a set_null relation with child_field=groupId/parentId would do automatically. |
| 5 | credential_controller.cpp:383-408 deleteCredential |
nothing | none | Leaves orphans. No check that a workflow node still references the credential's id (config.credentialId, embedded inside workflow.nodes[]), and no cleanup after delete. A workflow keeps pointing at a credentialId that resolves to nothing; the node then fails at run time with whatever "credential not found" error the node author wrote (if any). |
| 6 | node_controller.cpp:399-416 deleteNode |
nothing | none | Leaves orphans. No check that any workflow still has a node of that type. Note: this reference cannot be expressed as a v2.11.0 relation at all - see the "not relation-able" note under Part 2. |
The orphan sweep. Commit a0cf96e (2026-08-09, "feat: clear orphaned data,
refuse dropping a collection that holds documents") is the hand sweep referenced
in the task. Its own numbers: 8,538 of 10,159 execution records belonged to
workflows that no longer existed ("mostly test workflows deleted over the past
days"), plus 67 entirely-null rows from writes that never completed;
executions went from 481 MB to 74 MB. The commit message states the direct
cause: retention's cascade only finds executions whose workflowId matches a
current delete call, so a workflow deleted before retire() existed (the
whole per-workflow retention feature landed 2026-08-08/09, commits 8a52564
through 802d30c) has no mechanism that will ever revisit it. The same commit
also found four watch_cursors that looked orphaned by field-matching but
were not (watch_cursors is keyed by the collection it watches, not by a
workflow id) - a caution about guessing a "child field" instead of reading the
schema.
Two more things worth flagging that are not deletions but sit right next to this problem:
execution_controller.cpp:113-131 canSeeExecution explicitly handles both
a deleted-workflow execution ("The workflow has been deleted. Its history
outlives it...") and an execution with an empty workflowId ("Some runs
record no workflow id - the webhook path is one"). The empty-workflowId
case was an active bug until 2026-08-08 (see Part 2's hazard section) -
the comment is now a defensive leftover for historical rows, not a live
data path, but those historical rows still exist.webserver_service.cpp:661-706 reconcile (runner restart handling)
proactively closes executions rows stuck in status: running on a runner
that no longer claims them, logging them as orphans it repairs rather than
ignores. This is the one piece of "orphan" handling in the codebase that is
actually complete for its scope.| Child collection | Child field | Parent collection | on_delete |
validate_on_write |
Why |
|---|---|---|---|---|---|
executions |
workflowId |
workflows |
no_action |
off | This is the one place cascade/set_null looks tempting and is wrong twice over: (a) it is exactly the relation whose child (executions) already carries a per-row TTL restamped after the fact by retention_service.cpp - see the TTL hazard below, this time from the child side, since a cascade delete has to walk the child's rows while the TTL sweep may be mutating them concurrently; (b) validate_on_write would hard-reject the empty-string workflowId that historical rows carry and that the webhook path produced until 2026-08-08 (followups-from-tier-2 memory: "FIXED 2026-08-08... An execution recorded an empty workflowId whenever the run used the published version"). Recommend no_action and leave the existing app-level retire() sweep as the cleanup path, or move to restrict-then-manual-retire if the team wants delete-time visibility. Do not use cascade. |
workflows |
projectId |
projects |
restrict |
on | Replaces the hand-rolled check in project_controller.cpp:320-337. validate_on_write is safe: nothing found that inserts a workflow with an empty or dangling projectId (workflow creation always assigns one, workflow_controller.cpp:357 sets ownerId, and projectId comes from the same request). |
workflow_groups |
projectId |
projects |
restrict |
on | Same reasoning as above; currently covered by the same ownedCollections() check. |
credentials |
projectId |
projects |
restrict |
on | Same; currently covered by ownedCollections(). |
workflows |
groupId |
workflow_groups |
set_null |
on | Matches the existing manual behaviour in deleteGroup force-path (workflow_group_controller.cpp:334-345) exactly - a declared relation would let that hand-written loop be deleted. |
workflow_groups |
parentId |
workflow_groups (self) |
set_null |
on | Matches deleteGroup's subgroup reparenting (workflow_group_controller.cpp:347-367). Self-referential relations are not called out as unsupported in the proto/client docs, but neither are they described as tested - verify with upstream before relying on this one, flagged as a soft caution rather than a hard unsafe. |
credentials |
sharedWith |
users |
cascade (array field, so this pulls the id and keeps the credential - equivalent to set_null here per the proto note on array fields) |
on | Closes gap #3/#5 above: today a deleted user is never stripped from credentials.sharedWith, leaving a dangling id in the array forever. validate_on_write is safe as long as sharing continues to check auth_store_.getUser first, which credential_controller.cpp:100-116 already does. |
sessions |
userId |
users |
cascade |
on | Would let AuthStore::deleteUser's explicit per-row loop (auth_store.cpp:247-252, 427-438) be deleted entirely - the relation does the same thing atomically with the parent delete. validate_on_write is safe: a session is only ever created from an authenticated login against an existing user (auth_store.cpp:282-320). |
workflows / credentials / workflow_groups / projects / nodes |
ownerId / createdBy |
users |
no_action |
off | These are attribution fields, not ownership in the referential-integrity sense - the app already treats a dangling owner as fine (e.g. canSeeExecution's deleted-workflow branch). Declaring anything stronger than no_action would be a behavior change nobody asked for: deleting a user would either destroy their workflows (cascade, clearly wrong) or silently blank out who made them (set_null, loses history). Leave alone; no_action only adds the reverse index for future describeDelete() queries, which is a pure upside. |
api_keys |
(n/a) | users |
- | - | Not a real candidate today. api_keys is a reserved system-collection name (database_controller.cpp:300, auth_store.hpp/system_collections.hpp:43) with no controller, no writer, and (checked live) zero documents and not even listed among the instance's existing collections. Nothing to declare a relation over until the feature is built. |
collection_permissions |
(n/a) | users |
- | - | Not a real candidate. It is a single settings document keyed "settings" (collection_permissions.cpp:39 storage_.get("collection_permissions", "settings")), not a per-user row. No child field to point at a user. |
workflow node config.credentialId |
- | credentials |
(not expressible) | - | Cannot be declared as a v2.11.0 relation at all. child_field is a dot-path that resolves to a single id or an array of ids on the child document itself. A workflow's credential references live inside nodes[].config.credentialId, i.e. one string per element of an array of objects, not an array of ids. The relation contract has no path syntax for "the credentialId field of every element of this array." This is exactly the gap behind hand-rolled cleanup gap #5 (deleteCredential cleans up nothing) - relations do not fix it; only app code walking workflow.nodes can. |
workflow node type |
- | nodes |
(not expressible) | - | Same shape of problem as above, same conclusion: not relation-able, still needs app code if deleteNode (gap #6) is ever to check for in-use node types. |
Unsafe / flagged candidates, summarized:
executions.workflowId -> workflows with cascade or set_null is
unsafe and not recommended. executions carries retention TTLs that
retention_service.cpp actively restamps (restamp(), retention_service.cpp:187-283,
called from both apply() and retire()). The upstream warning is about a
race hitting a cascade/set_null relation whose parent carries a
renewed-after-expiry TTL; here the shoe is on the other foot - executions
is the child, and it is the child rows that are being TTL-renewed out
from under a delete-time cascade walk over the parent (workflows). Either
direction is the same underlying hazard: a delete-time relation walk and an
independent TTL-renewal sweep touching the same rows concurrently. Recommend
no_action, keep retire() as-is (or route it through describeDelete
once the client has it, for visibility), and rely on periodic sweeps like
a0cf96e for anything retire() misses.validate_on_write on executions.workflowId is currently safe going
forward (the empty-string bug was fixed 2026-08-08) but would reject
re-inserting or re-processing any of the historical empty-workflowId rows
that predate the fix, and would reject the row shape canSeeExecution still
defends against. Recommend leaving validate_on_write off for this field
until a migration confirms no code path can still produce workflowId: ""
(see Part 4 - the listPending path pulls raw executions and would be
worth auditing at the same time).workflow_groups.parentId self-relation - not proven unsafe, just
unverified against a self-referential case; test against a real 2.11.0
instance before relying on it for the group hierarchy.Checked against the live instance at http://localhost:8090 (admin/admin) on 2026-08-10.
| Field | Candidate collection | Live duplicates found | Declarable today? |
|---|---|---|---|
username |
users |
0 (only 1 user exists: admin) |
Yes, trivially - but with only one row this is a weak signal. Re-check once the instance has more than one account. |
email |
users |
0 (same caveat) | Yes, same caveat. |
refreshToken |
sessions |
19 (given - task states this is already known) | No. Needs a cleanup pass (dedupe or drop stale sessions) before unique=true can be declared; the server will refuse it while duplicates exist. |
id |
nodes |
0 across 86 node definitions | Moot - id is already the document's own primary key (_id), so a unique index is redundant, not a new guarantee. |
(projectId, name) |
credentials |
1 pair: two credentials named noreply@shopcall.ai in project prj_89c532cf-... (cred_53c04169-... and cred_a668d3b5-...) |
No. Needs the duplicate renamed or merged first. Only 7 credentials total in this instance, so the fix is a two-minute manual rename, not a migration. |
(projectId, name) |
workflows |
0 across 12 workflows | Yes, declarable today. |
No slug-style field was found anywhere in the schema (workflows, projects,
credentials, node definitions all key by generated id, not a human slug), so
there is nothing to check there.
What it looks like today. ExecutionResult::toJson() (workflow_engine.cpp:210-273)
embeds every node's output as nodeExecutions[] directly inside the execution
document, plus an optional full workflowSnapshot for the pinning feature.
Two truncation regimes already exist as a stopgap: a finished execution's node
outputs are truncated by truncateLargeValues (workflow_engine.cpp:251), but
a Waiting execution keeps every node output whole and untruncated
(workflow_engine.cpp:234-240) - deliberately, because a later resume seeds
itself from that stored state. That is precisely the shape of document that
gets largest.
Where the current pain actually shows up, confirmed by reading the read paths rather than assuming:
GET /executions, execution_controller.cpp:286) does
not suffer this today - it already queries a server-side view,
executions_summary (webserver_service.cpp:460-493), that projects out
nodeExecutions and workflowSnapshot entirely at the database, listing
only ten summary fields. This view is the reason listing 336 KB documents
doesn't currently cost what it could.ExecutionController::listPending (execution_controller.cpp:545-573,
GET /executions/pending), by contrast, queries the raw executions
collection with a status=waiting filter and no field projection and no
page size cap. Every waiting execution is exactly the untruncated,
full-node-output case described above. This is the most likely candidate for
"a page of large documents can come back empty because the gRPC message
limit is hit silently" - it is the one remaining raw, unprojected,
unpaginated read of full execution documents in the controller layer.GET /executions/:id and the resume path
(workflow_engine.cpp:1029, 1060-1111) legitimately need the full
document - resume literally reconstructs node_results by reading
record["nodeExecutions"] back off the stored row.What a split would cost.
execution_node_results (or similar) collection, one row per
(executionId, nodeId) (or one row per execution holding the array - either
way the relation is execution_node_results.executionId -> executions).
Given the TTL hazard identified in Part 2 item 1, this new child collection
would sit in the exact configuration the upstream implementor warned about:
its parent (executions) is TTL-bearing and is actively restamped after
creation by retention_service.cpp. A cascade or set_null relation
here inherits the same race the executions-to-workflows relation was
flagged for above, except now it is the primary, intended use of the split,
not an edge case - so it would need no_action plus an explicit sweep
(mirroring what already exists for workflow deletion) rather than relying on
the relation to keep the two collections in sync.WorkflowEngine::execute and every node-completion callback
(workflow_engine.cpp:1921, 1955, 1981, 2001, 2023, and the loop-body
variants) would need to write node results as a second collection call
instead of building one in-memory ExecutionResult and serializing it once.
That is a real increase in write volume and a new failure mode (execution
row written, node-results write fails or vice versa - no longer atomic
without a transaction the storage client doesn't offer across collections).GET /executions/:id (join in the
child rows), resume (workflow_engine.cpp:1060-1111, currently reads
record["nodeExecutions"] directly off the stored execution - would need a
second query), the pinning/diff feature that reads workflowSnapshot and
node outputs together, listPending (would become the thing that benefits
most, since it could stop pulling full documents), and the retention
preview/measure code (retention_service.cpp:114-159) which would need to
measure and restamp two collections in lockstep instead of one.executions (2,125 documents live today,
74 MB post-sweep) would need its nodeExecutions array split out and
reinserted as child rows, or the split would only apply going forward and
every read path would need an if (has child rows) else (read inline)
branch indefinitely. Given how young this collection already is (rebuilt by
the 2026-08-09 sweep) a clean cutover is realistic, but it is still a
one-way migration with no simple rollback once child rows are written and
the inline field is dropped.Recommendation: not yet, and only partly for the reason originally deferred. The wait was "for relations" so the split has referential integrity to lean on
cascade/set_null.
Declaring the relation as no_action (the safe choice) removes the main thing
relations were expected to buy here - automatic child cleanup - leaving a
second collection to keep in sync by hand, which is the same kind of sweep
a0cf96e just had to run once already. The listing-side problem the split was
meant to solve is already half-fixed by the executions_summary view; the
concrete remaining offender (listPending) is a much smaller, much cheaper fix
on its own - add a page size cap and a field projection to that one query -
and would remove most of the "gRPC limit hit silently" risk without touching
the schema, the write path, or every read path at once. Revisit the full split
once (a) the client is actually on 2.11.0, (b) describeDelete/checkRelation
give an operator a way to see what a no_action relation is quietly leaving
dangling, and (c) listPending-style raw reads have been audited across the
codebase to confirm this is the only one - if it is not, the case for the split
gets stronger.