浏览代码

docs: re-validate the relations design against v2.10.0

The design was written on 2026-08-03 and never executed. Its reasoning holds up;
several of its facts and one of its design choices do not.

Checked and still true: no relation code exists, FilterOp still has exactly
eleven values, MemoryStore::update still forces updated.id = id (so the "no ON
UPDATE" non-goal is still well founded), _-prefixed collections are still
excluded from the LMDB mirror so _relations would live in MemoryStore exactly as
_views does, tests still build with -UNDEBUG, and the restrict-race analysis
stands.

Corrected:
- Target v2.5.0 never existed (2.4.5 -> 2.6.0); the project is at 2.10.0.
- Its "Phase C (later)" collides with the abandoned v2.0 storage Phase C.
- A cited line number had rotted (:316 -> :329). Cite text, not line numbers.
- The non-goal "no candidate-key or unique-constraint machinery" is out of date:
  v2.10.0 built it and left it unreachable, so references to non-_id fields
  become reachable with the same write-path fix relations needs.
- "Every LmdbDocumentStore operation opens its own WriteTxn" is half true now -
  internal helpers already take one, so the restructuring is smaller and partly
  precedented.

New constraints it could not have known, added prominently because the first is
the most likely way to get relations wrong now: a sub-db created at runtime must
be registered with cacheCommittedDbi() after commit, or it is invisible to every
later read (v2.8.1 made the read path cache-only) and relations would silently
not enforce. Plus: never cache a dbi before commit, never open a second env on an
open path, index sub-dbs now carry two reserved keys, and _relidx_ should carry a
key-format version from the first commit.

One design choice replaced: the reverse index should use MDB_DUPSORT rather than
composite <parentId>\0<childId> keys. The secondary-index machinery already does
that shape, and mdb_cursor_count gives a parent's child count without reading the
children - exactly what restrict and DescribeDelete need, where the composite
form must walk the range to count.

Added per-collection enable/disable for relation enforcement and uniqueness, in
CollectionCfg beside versioningEnabled and indexedFields, with the reasoning for
the defaults, the non-retroactivity warning, boot re-arming, and optional bool on
ConfigureCollection so a partial update cannot silently disable enforcement.

The 3278-line plan is marked superseded rather than patched.
fszontagh 1 月之前
父节点
当前提交
7bef2844be
共有 3 个文件被更改,包括 200 次插入 和 35 次删除
  1. 27 7
      docs/ROADMAP.md
  2. 24 11
      docs/superpowers/plans/2026-08-04-relations-v2.5.0.md
  3. 149 17
      docs/superpowers/specs/2026-08-03-relations-design.md

+ 27 - 7
docs/ROADMAP.md

@@ -136,13 +136,33 @@ This is the v2.x arc's architectural endpoint. It touches the write path that
 produced the v2.4.3, v2.4.4 and v2.8.0 LMDB handle incidents, so it wants to land
 in small reviewable pieces with the sub-db identity sentinel kept intact.
 
-### 5. Relations
-
-`docs/superpowers/plans/2026-08-04-relations-v2.5.0.md` is a complete 3278-line
-plan that was **never executed**. Its version label is wrong - v2.5.0 was skipped
-entirely (2.4.5 → 2.6.0). It was written before the v2.8.0 dbi-caching fix, so its
-write-path assumptions need re-validating before use. Treat it as a design input,
-not a script.
+### 5. Relations, and unique constraints - one piece of work
+
+Referential integrity does not exist: deleting a parent leaves children pointing
+at nothing, silently, with no way to detect it. `smartbotic-automation` has
+`workflows` referenced by `executions`, `users` by `sessions`, and `credentials`
+from node configuration.
+
+**The design is re-validated and current** as of 2026-08-09:
+`docs/superpowers/specs/2026-08-03-relations-design.md`. Read its status section
+first - it lists what survived re-validation, what was wrong, and the constraints
+that post-date it. The 3278-line plan beside it is **superseded and must not be
+executed**; re-plan from the design.
+
+**Relations and unique constraints share one blocker**, so schedule them
+together. Both need `LmdbDocumentStore` operations to accept a caller's
+`WriteTxn` - relations for atomic cascade across parent, children and index
+sub-dbs; uniqueness so a rejection can propagate instead of being swallowed by
+`applyDualWriteMirror`, which currently catches every exception, bumps mirror
+drift and flips `mirror_healthy_` (the v2.8.1 fault). MemoryStore also mutates
+before the mirror runs, so a clean rejection needs the in-memory write rolled
+back. The uniqueness check itself is already built and tested, sitting unreachable
+behind that.
+
+Also requested and specified: **per-collection enable/disable** for relation
+enforcement and uniqueness, persisted in `CollectionCfg` alongside
+`versioningEnabled` and `indexedFields`, and re-armed at boot the way
+`applyIndexDeclarations()` already does.
 
 ### 6. Smaller known gaps
 

+ 24 - 11
docs/superpowers/plans/2026-08-04-relations-v2.5.0.md

@@ -1,16 +1,29 @@
-# Relations (v2.5.0) Implementation Plan
-
-> **STATUS: NEVER EXECUTED. Do not run this plan as written.**
+> **STATUS: SUPERSEDED. Do not execute this plan.**
+>
+> Re-validated 2026-08-09 against the code at v2.10.0 and found to be built on
+> facts that no longer hold. The DESIGN it implements has been re-validated and
+> corrected in place - read
+> `docs/superpowers/specs/2026-08-03-relations-design.md` instead, starting with
+> its status section.
 >
-> - Nothing in it is implemented - no `_relations`, no `RelationManager`, no RPC.
-> - **The version target is wrong.** v2.5.0 was skipped entirely; the project went
->   2.4.5 → 2.6.0 and is now at 2.8.0. Renumber before use.
-> - It was written before the v2.8.0 discovery that an `MDB_dbi` must not be
->   cached until its transaction commits. Its write-path tasks touch exactly that
->   code and need re-validating against `document_store_lmdb.cpp` as it stands.
-> - Treat it as a design input, not a script.
+> Why this plan specifically cannot be run as written:
+> - Its target, **v2.5.0, never existed** (2.4.5 → 2.6.0). The project is at 2.10.0.
+> - It was authored against the **pre-v2.8 write path**. Three LMDB handle
+>   incidents have since changed the rules it assumes: an `MDB_dbi` must not be
+>   cached before its transaction commits (v2.8.0), the read path must never call
+>   `mdb_dbi_open` at all (v2.8.1), and a sub-db created at runtime is invisible
+>   to reads unless registered with `cacheCommittedDbi()`. Its task list embeds
+>   the old assumptions.
+> - Its reverse-index tasks build a composite-key structure that the
+>   **v2.9.0-v2.10.0 secondary-index machinery now does better** (DUPSORT, with an
+>   O(1)-ish child count via `mdb_cursor_count`, which is exactly what `restrict`
+>   and `DescribeDelete` need).
+> - It predates the requirement for **per-collection enable/disable** of relations
+>   and uniqueness, now specified in the design.
 >
-> See `docs/ROADMAP.md` for where relations sits in the real ordering.
+> Re-plan from the corrected design. Much of the task decomposition here is still
+> useful as raw material - the ordering, the test matrix, the error-message
+> shapes - but every code snippet needs checking against the current tree.
 
 
 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

+ 149 - 17
docs/superpowers/specs/2026-08-03-relations-design.md

@@ -1,12 +1,71 @@
 # Relations: referential integrity for a document store
 
-> **STATUS: not implemented.** No `_relations` collection, `RelationManager` or
-> relation RPC exists in the codebase. This design and its companion plan
-> (`docs/superpowers/plans/2026-08-04-relations-v2.5.0.md`) were never executed.
-> The "v2.5.0" target is wrong - v2.5.0 was skipped entirely (2.4.5 → 2.6.0).
-> Re-validate against the current write path before using either: both predate
-> the v2.8.0 fix for caching an `MDB_dbi` before commit.
-
+> **STATUS: not implemented. RE-VALIDATED 2026-08-09 against the code at v2.10.0.**
+>
+> The design's *reasoning* holds up. Several of its facts and one of its design
+> choices do not, and there are new hard constraints it could not have known
+> about. Read this section before the body; where they disagree, this section is
+> right.
+>
+> **Still true, checked:**
+> - No `_relations` collection, `RelationManager` or relation RPC exists.
+> - `FilterOp` still has exactly eleven values, all single-collection predicates.
+> - `MemoryStore::update` still forces `updated.id = id` (`memory_store.cpp:502`
+>   and `:802`), so the "no `ON UPDATE`" non-goal still rests on solid ground.
+> - MemoryStore is still in the write path, and `_`-prefixed collections are
+>   still excluded from the LMDB mirror (`dual_write_mirror.hpp:43`), so
+>   `_relations` lives in MemoryStore and is WAL'd/snapshotted - exactly parallel
+>   to `_views`, as the body claims.
+> - Test targets still build with `-UNDEBUG`, so assertions stay live in Release.
+> - The restrict-race analysis and `validate_on_write` as its remedy still stand.
+>
+> **Wrong or stale:**
+> 1. **Target version.** v2.5.0 was skipped entirely (2.4.5 → 2.6.0). The project
+>    is at **2.10.0**. Every "v2.5.0" in this document and its plan is wrong.
+> 2. **"Phase C" collides.** The rollout's "Phase C (later)" now clashes with the
+>    abandoned v2.0 storage Phase C. Do not reuse that vocabulary - see
+>    `docs/ROADMAP.md`, "Storage engine history".
+> 3. **A line citation has rotted.** The body cites
+>    `docs/2026-05-15-v2.0-storage-engine-design.md:316` for "SQL surface"; it is
+>    now line **329**. Cite text, not line numbers.
+> 4. **The non-goal "no candidate-key or unique-constraint machinery" is out of
+>    date.** v2.10.0 built it - `find_duplicate_values` plus enforcement inside
+>    the document's own transaction - and then deliberately left it unreachable
+>    because it cannot be enforced through the mirror (see below). So references
+>    to a non-`_id` field become *reachable* once the write path is fixed, which
+>    is the same fix relations needs. They are one piece of work, not two.
+> 5. **"Every `LmdbDocumentStore` operation opens its own `WriteTxn`" is now only
+>    half true.** Internal helpers already take a `WriteTxn&` (`open_for_write`,
+>    `maintainIndexes`, `markIndexMultiValued`); eight public operations still
+>    open their own. The restructuring the body asks for is therefore smaller
+>    than it was, and partly precedented.
+>
+> **New hard constraints (all post-date this spec):**
+> 6. **⚠ A sub-db created at runtime MUST be registered with
+>    `cacheCommittedDbi()` after its transaction commits.** Since v2.8.1 the read
+>    path never calls `mdb_dbi_open` - `try_open_for_read` serves only from the
+>    primed cache, and a miss means "no such sub-db". So a `_relidx_` sub-db
+>    created without that call would be **invisible to every later read**, and
+>    relations would silently not enforce. This is the single most likely way to
+>    get relations wrong now, and nothing in the body warns about it.
+> 7. **⚠ Never cache an `MDB_dbi` before its transaction commits** (v2.8.0), and
+>    **never open a second `MDB_env` on a path this process already has open**
+>    (v2.4.4 - POSIX locks are per-process). Both cost production outages.
+> 8. **Index sub-dbs now carry TWO reserved keys**, not one: the v2.4.4 identity
+>    sentinel and the v2.10.0 multivalued marker. Use `is_index_meta_key()`;
+>    checking only `is_identity_key()` will miscount and mis-walk.
+> 9. **Give `_relidx_` a key-format version in its name from the first commit**
+>    (`_relidx1_`). v2.9.1 had to bump `_idx_` → `_idx2_` when an encoding
+>    changed, precisely so a stale index is never read under new rules. Paying
+>    that forward costs nothing now.
+>
+> **One design choice is now beaten - see "Reverse index" below for the
+> replacement.**
+>
+> The companion plan (`docs/superpowers/plans/2026-08-04-relations-v2.5.0.md`,
+> 3278 lines) is **superseded**: it was written against the pre-v2.8 write path
+> and its task list embeds the wrong version and the stale facts above. Treat it
+> as a design input and re-plan rather than executing it.
 
 Status: approved design, not yet implemented
 Target: v2.5.0 (Phases A + B together)
@@ -116,18 +175,36 @@ leaving operators to discover it.
 
 ### Reverse index
 
-One LMDB sub-db per relation, `_relidx_<name>`, inside the project env. Key is
-the composite `<parentId>\0<childId>`; value empty. LMDB orders keys, so a
-parent's children are a cursor `MDB_SET_RANGE` over the `<parentId>\0` prefix -
-O(children), not O(collection).
-
-A list-valued index (`parentId -> [childIds]`) is rejected: it turns every child
-insert into a read-modify-write on one key shared by all siblings.
+One LMDB sub-db per relation, `_relidx1_<name>`, inside the project env.
+
+> **RE-VALIDATED: use `MDB_DUPSORT`, not composite keys.** This section
+> originally specified key = `<parentId>\0<childId>` with an empty value, walked
+> with `MDB_SET_RANGE` over the `<parentId>\0` prefix. That was right when
+> nothing else existed. Since v2.9.0 the secondary-index machinery does exactly
+> this shape as **key = parent id, data = child id, DUPSORT** - built, tested and
+> measured on production-sized data.
+>
+> Three reasons to switch:
+> 1. **`mdb_cursor_count` gives a parent's child count without reading the
+>    children.** That is precisely what `restrict` and `DescribeDelete` need, and
+>    it is O(1)-ish. The composite-key form has to walk the range to count.
+> 2. Removing one id from a parent's set is `mdb_del(key, data)`, which deletes
+>    just that pair - no read-modify-write, so the rejection of a list-valued
+>    index below is satisfied without a bespoke encoding.
+> 3. The surrounding discipline already exists and is tested: reserved-key
+>    handling (`is_index_meta_key`), handle caching after commit
+>    (`cacheCommittedDbi`), boot priming, and the identity sentinel.
+>
+> A list-valued index (`parentId -> [childIds]` as one value) stays rejected, for
+> the reason the original text gives: it turns every child insert into a
+> read-modify-write on one key shared by all siblings. DUPSORT is not that - it
+> stores the set as separate data items.
 
 Index sub-dbs carry the v2.4.4 identity sentinel like any other sub-db, and
-`count()` and `scan()` skip the sentinel key. Without this a stale `MDB_dbi`
-could write index entries into an unrelated sub-db, which is precisely the
-failure that misfiled 31 production rows.
+`count()` and `scan()` skip **every** reserved key - as of v2.10.0 there are two
+(identity, and the multivalued marker), so use `is_index_meta_key()`. Without
+this a stale `MDB_dbi` could write index entries into an unrelated sub-db, which
+is precisely the failure that misfiled 31 production rows.
 
 ### Write-path change
 
@@ -175,6 +252,61 @@ documented rather than hidden.
 validate existing data against the policy. On a large collection this blocks, so
 it belongs in a migration rather than a live call. The scan is idempotent.
 
+## Per-collection enable/disable
+
+**Requested 2026-08-09.** Both relation enforcement and uniqueness must be
+switchable per collection.
+
+Home: **`CollectionCfg`, in the `_collection_meta` system collection.** That is
+where `timestampPrecision`, `versioningEnabled` and (since v2.9.0)
+`indexedFields` already live, and the reason is durability: `_collection_meta` is
+an ordinary collection, so it is WAL'd and snapshotted for free. Putting these in
+`CollectionOptions` instead would need a new WAL op to survive a restart between
+snapshots - the same reasoning recorded for `versioningEnabled` in v2.4.5.
+
+```
+struct CollectionCfg {
+    std::string timestampPrecision = "ns";
+    bool versioningEnabled = true;
+    std::vector<std::string> indexedFields;
+    std::vector<std::string> uniqueFields;      // per-collection by construction
+    bool relationsEnforced = true;              // new
+};
+```
+
+**`uniqueFields`** is inherently per-collection - it names fields of one
+collection - so it needs no separate switch. It must be a **subset of
+`indexedFields`**: the check reads the index, so uniqueness without an index has
+nothing to read. Declaring uniqueness over data that already contains duplicates
+is **refused**, with examples, rather than accepted: a constraint that is false
+from the moment it is created would fail later writes for reasons the caller
+never caused. `find_duplicate_values()` already does this.
+
+**`relationsEnforced`** defaults to **true**, because declaring a relation names
+its child and parent collections explicitly - the declaration *is* the opt-in,
+and a declared constraint that silently does nothing would be worse than no
+constraint. The switch is an operator escape hatch for the cases that genuinely
+need one: a bulk import, or a collection under write pressure where the
+`restrict` check costs more than the integrity is worth.
+
+Two things this must get right, both learned the hard way here:
+
+- **Disabling is not retroactive and must say so.** Turning enforcement off then
+  deleting parents creates dangling references that turning it back on will not
+  detect - only the `relations check` command will. The RPC response and the CLI
+  must state that at the point of use, not only in documentation.
+- **Re-arm on boot.** `DatabaseService::applyIndexDeclarations()` already re-reads
+  `indexedFields` at startup and exists for exactly this reason: a declaration
+  that is persisted but not applied leaves the write path not maintaining
+  something the read path still trusts. Relation and uniqueness switches need the
+  same treatment in the same place, and it is load-bearing, not bookkeeping.
+
+Both switches belong on `ConfigureCollection`, which is already a **partial
+update**: absent means "leave unchanged". Use `optional bool` for
+`relations_enforced` - a plain proto3 bool defaults to false and would silently
+disable enforcement on any unrelated config call, which is the exact trap v2.4.5
+hit with `versioning_enabled`.
+
 ## Surface
 
 **RPCs**, mirroring the view surface: `CreateRelation`, `DropRelation`,