# Database relations (v2.11.0): what we hand-roll today, and what to declare 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). ## 1. Where we hand-roll referential integrity today | # | 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_` 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. ## 2. Proposed relation table | 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:** 1. **`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. 2. **`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). 3. **`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. ## 3. Unique constraint candidates 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. ## 4. The fat execution document **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: - The list endpoint (`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. - Single-execution `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.** - *Schema*: new `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. - *Write path*: `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). - *Read paths that would have to change*: `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. - *Migration*: every existing row in `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 - but Part 2 and this section both land on the same conclusion: the relation this split would actually use has a TTL-bearing parent, which is on the upstream implementor's own list of races to avoid for `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.