๐ค docs(spec): vaults โ a sharing boundary inside a team
memoria-owned content is team-wide by construction (ยง4.2), which is why a personal document cannot live in a team instance. Vaults are the answer to a cost the parent already records as real. The design is a child spec in the notion-ingest style, and ยง3 changes the parent rather than quietly disagreeing with it: ยง4.2's "team-wide by construction" and ยง4.3's "memoria does not have an authorization model" are both narrowed to sibling content. ยง3.1 argues why the ban does not reach content with no upstream โ a copied ACL drifts, and there is nothing here to drift from. ยง3.2 argues why decision 11 survives: vault membership is applied after admission and can only narrow, never widen, so a person removed from every sibling is still refused at the door. A vault is a key boundary, not only a permission boundary โ one HKDF purpose salted per vault, so a gate bug yields ciphertext rather than a passage. One in-memory index per vault, with IDF summed across the asker's vaults because BM25 is not comparable across indexes. Two things it refuses to claim. No vault is private from the running instance, because the server must derive every vault key to build the index; ยง10 fixes what the product may say and gate 8 holds it. And ยง7.3 asserts no number for per-shard overhead โ the parent's ยง7.2 was wrong by half when it was asserted rather than measured, so stage 1 measures it into BENCHMARKS.md before stage 2 is designed. Not yet reviewed. Per AGENTS.md ยงReview this needs a model that did not write it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+595
−0
docs/superpowers/specs/2026-09-05-vaults-design.md+595 −0
diff --git a/docs/superpowers/specs/2026-09-05-vaults-design.md b/docs/superpowers/specs/2026-09-05-vaults-design.md| new file mode 100644 |
| index 0000000..7761c3d |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-09-05-vaults-design.md |
| @@ -0,0 +1,595 @@ |
| +# ๐ค memoria โ vaults |
| + |
| +**Status:** draft 1. **Not yet reviewed** โ per AGENTS.md ยงReview this |
| +must go to a model that did not write it before it is approved. |
| +**Parent:** `2026-09-03-memoria-design.md`. Where this disagrees with the |
| +parent, the parent wins and this document is wrong โ fix it in the same |
| +commit. **ยง3 is the exception: it changes the parent, and says so.** |
| + |
| +A vault is a sharing boundary *inside* a team instance. It exists so that |
| +a document can be shared with two people rather than with everyone the |
| +siblings call a member. |
| + |
| +--- |
| + |
| +## 0. The one rule, as it applies here |
| + |
| +The parent's rule is *memoria never speaks a passage without asking |
| +whoever owns it whether this person may see it, right now.* |
| + |
| +Vaults touch only the half of that rule where **there is nobody to ask** |
| +โ parent ยง4.2, content memoria owns outright. Sibling content is not in a |
| +vault and never will be: parent ยง4.1 still asks the owning app, per |
| +person, per query, and memoria still evaluates nothing about it. That |
| +separation is the whole reason this design is buildable without |
| +relitigating the parent's decision 3, and ยง2.1 is where it would break |
| +if someone |
| +widened it. |
| + |
| +The second rule โ the bucket never holds a readable passage โ binds |
| +harder here, not less. A vault's content is sealed under a key derived |
| +for that vault (ยง5), so the bucket does not merely hold ciphertext, it |
| +holds ciphertext under a key the rest of the corpus does not share. |
| + |
| +**And one claim this document is not permitted to make.** The server must |
| +decrypt to build the in-memory index, so it must be able to derive every |
| +vault key. **No vault is private from the running instance, or from |
| +whoever holds the DEK.** Per-vault sealing is a boundary against a stolen |
| +bucket and a stolen database. It is not a boundary against memoria. ยง10 |
| +is what the product is allowed to say about this, and ยง13's gate 8 is |
| +what stops the copy drifting back into the stronger claim. |
| + |
| +--- |
| + |
| +## 1. Settled decisions |
| + |
| +| # | Decision | Because | |
| +|---|---|---| |
| +| 1 | A vault governs **memoria-owned content only** (parent ยง4.2). Sibling content has no vault | The parent's decision 3's ban exists because a *copied* ACL drifts from its source. Content with no upstream has nothing to drift from, so the reason does not reach it. ยง2 | |
| +| 2 | **This changes the parent.** ยง4.2's "team-wide by construction" and ยง4.3's "memoria does not have an authorization model" are both narrowed | Recorded rather than slipped in, per AGENTS.md. ยง3 | |
| +| 3 | A vault is a **key boundary**, not only a permission boundary โ one HKDF purpose, salted per vault | A filter that is never exercised is a filter written to pass (AGENTS.md). A wrong key yields ciphertext, which cannot be written to pass. Paul, 2026-09-05. ยง5 | |
| +| 4 | A document lives in **exactly one vault** | Forced by decision 3: a second membership would be a second sealed copy, not a second row. Sharing wider is adding a member or making an explicit copy. ยง4 | |
| +| 5 | **Vault membership can only narrow, never widen** โ it is applied *after* admission and can never admit someone admission refused | This is what keeps the parent's decision 11 intact; see ยง3.2. Gate 3 | |
| +| 6 | **One in-memory index per vault.** A query opens only the asker's vaults | Same reasoning as decision 3, applied where keys cannot reach. ยง7 | |
| +| 7 | Ranking uses **IDF summed across the asker's vaults**, not per-shard scores | BM25 across separate indexes is not comparable, and naive merging favours the small shard. ยง7.2 | |
| +| 8 | **The team owner can open any vault, and every use is logged** | The server can derive every vault key, so the capability exists whether or not it has a button. Surfacing it is honest; hiding it is the over-claim ยง1.6 keeps catching. Paul, 2026-09-05. ยง10 | |
| +| 9 | **Unresolvable vault membership is an error**, never an empty result and never a silent narrowing to the team vault | AGENTS.md, both opening rules, pointed at one code path. ยง9 | |
| +| 10 | Migration is **additive**: a `team` vault holding every admitted person, and every existing document in it | The parent's migration rule. New code over an old DB stays safe. ยง11 | |
| + |
| +**Numbering.** An unqualified "decision N" in this document means this |
| +table. The parent's are always written "the parent's decision N" โ the |
| +two tables both have a decision 3 and they say different things, which |
| +is exactly the kind of collision a reader resolves wrongly and silently. |
| + |
| +--- |
| + |
| +## 2. What a vault is |
| + |
| +A named row with a membership list. A document points at exactly one |
| +vault. To read a memoria-owned document you must be admitted to the |
| +instance (parent ยง4.3, unchanged) **and** be a member of that document's |
| +vault. |
| + |
| +**A vault of one is the ordinary case, not a special case.** It is what |
| +makes a personal document โ the motivating example was a passport scan โ |
| +storable in a team instance at all. Nothing in the model distinguishes it |
| +from a vault of twelve. |
| + |
| +### 2.1 What a vault is not |
| + |
| +- **Not a boundary on sibling content.** A Sheets document indexed from a |
| + sibling has no vault. ยง4.1 governs it, per person, per query, and this |
| + document does not touch that path. If a future change gives sibling |
| + content a vault, it has reintroduced the per-viewer ACL copy that ยง1.1 |
| + killed before draft 1 existed, and it needs its own decision saying so. |
| +- **Not a boundary against the instance.** ยง0, and ยง10. |
| +- **Not admission.** ยง3.2. |
| +- **Not nested.** A vault has no parent vault. Nesting turns membership |
| + into a graph traversal, and the failure mode of a traversal that half |
| + completes is a plausible short list โ the shape this codebase keeps |
| + producing. ยง15 carries it if it is ever wanted. |
| +- **Not cross-team.** Parent ยง6.4 is untouched, and decision 1 of the |
| + parent โ one instance per team โ is what a vault subdivides, not what |
| + it replaces. |
| + |
| +--- |
| + |
| +## 3. This changes the parent |
| + |
| +Parent ยง4.2 currently says, of memoria-owned content: |
| + |
| +> There is no members table (decision 11, ยง2) and there are no per-person |
| +> grants: holding grants would be an authorization model, which ยง4.3 says |
| +> memoria does not have. [โฆ] So memoria-owned content is team-wide by |
| +> construction. Anything a connector or an upload puts in is offered to |
| +> everyone the siblings call a member of this team, and the only |
| +> revocation is deletion. |
| + |
| +**Vaults contradict that paragraph directly.** After this design, |
| +memoria-owned content is not team-wide by construction, memoria does hold |
| +per-person rows, and revocation is not only deletion. This section is the |
| +argument for the change, in the parent's ยง1.2โยง1.4 style, so a reader of |
| +ยง4.2 and a reader of this document build the same thing. |
| + |
| +### 3.1 Why the ban does not reach this content |
| + |
| +The parent bans an authorization model for one stated reason, given in |
| +ยง1.1 and carried into the parent's decision 3: **a per-viewer ACL copied |
| +from another |
| +app drifts from the app that owns it.** Sheets' access is a hand-written |
| +grants join; memoria cannot evaluate it, and a copy is stale from the |
| +moment it is taken. That argument is exact, and it is the reason memoria |
| +asks rather than decides. |
| + |
| +**It has no purchase on content with no upstream.** An uploaded JPEG, a |
| +forwarded mail, a migrated Notion page โ there is no other app holding |
| +the authoritative answer, so there is nothing for memoria's answer to |
| +drift *from*. memoria is not copying an ACL here; it is the only place |
| +the ACL could live. The parent's decision 3's reasoning is preserved |
| +exactly where it |
| +applies and is silent where it does not. |
| + |
| +### 3.2 Why the parent's decision 11 survives |
| + |
| +The parent's decision 11 rejected *a members table memoria maintains*, |
| +because a |
| +person removed from every sibling would still read memoria. `VaultMember` |
| +is a per-person table memoria maintains, and it will read as reversing |
| +that decision unless the distinction is made precisely. |
| + |
| +**The distinction is that vault membership is never consulted to admit |
| +anyone.** Admission is parent ยง4.3's relayed sibling answer, unchanged |
| +and checked first. Vault membership is applied to an already-admitted |
| +person, and it can only ever remove documents from what they would |
| +otherwise see. |
| + |
| +> **Invariant.** For every asker and every document, the set of documents |
| +> visible with vaults is a subset of the set visible without them. |
| + |
| +A person removed from every sibling fails at the door regardless of every |
| +row in `VaultMember`. The failure the parent's decision 11 was |
| +protecting against is |
| +therefore unreachable through this table: it cannot widen. Gate 3 is the |
| +gate, and gate 3's mutation is the one that matters โ delete the |
| +admission check and confirm the vault check alone does *not* let a |
| +non-admitted person through. |
| + |
| +### 3.3 What it costs, said plainly |
| + |
| +Three things, none of them hypothetical. |
| + |
| +- **A second sharing surface to keep in step.** Parent ยง3 rejected "a |
| + second list to keep in step with a list" for Notion custody, and it was |
| + right to. Vaults are that second list. The defence is that this one is |
| + not in step with anything โ it is the only list, for content that has |
| + no other. But if a connector ever grows per-item upstream sharing that |
| + maps onto vaults, this cost becomes real and ยง15 should be read again. |
| +- **`last_seen_at` and re-ingest interact with vault placement.** A |
| + re-ingested document keeps its vault. A connector cannot move a |
| + document between vaults, because the far end has no idea vaults exist. |
| + Placement is a memoria act. |
| +- **The parent's ยง3.6 honest-claim paragraph and its README need |
| + rewriting**, because both currently say every admitted person sees |
| + every memoria-owned document. They must not go on saying it. ยง12. |
| + |
| +--- |
| + |
| +## 4. Data |
| + |
| +Two new models and one new column. Additive; the parent's migration rule |
| +holds. |
| + |
| +``` |
| +Vault |
| + ID int64 |
| + Name []byte sealed โ a vault name is content ("Passport", "Doc + Vicky") |
| + NamePrint string keyed fingerprint, for lookup without opening |
| + CreatedBy string person ref |
| + CreatedAt, UpdatedAt |
| + |
| +VaultMember |
| + VaultID int64 index |
| + PersonRef string index |
| + Role string "owner" | "member" |
| + AddedBy string person ref |
| + AddedAt time.Time |
| + (unique on VaultID+PersonRef) |
| + |
| +Document |
| + VaultID int64 index, not null โ new |
| +``` |
| + |
| +**A vault name is content and is sealed.** "Redundancies Q4" is a |
| +passage. `NamePrint` is `seal.Fingerprint`, keyed โ the parent's rule |
| +that a content hash is not a coordinate applies here exactly, and a bare |
| +SHA-256 of the name would let anyone with the bucket confirm the team |
| +holds a vault they can guess the name of. |
| + |
| +**The team vault holds no `VaultMember` rows.** Its membership *is* |
| +admission, evaluated live (ยง11), so `VaultMember` carries rows only for |
| +vaults narrower than the team. This is not an optimisation: a stored |
| +roster for the team vault would be the members table the parent's |
| +decision 11 rejected, arriving under a new name, and it would drift from |
| +admission the moment someone left. |
| + |
| +**`Document.VaultID` is not null.** There is no unvaulted memoria-owned |
| +document; ยง11's migration is what makes that true on an existing |
| +database. Sibling documents are *also* not null and point at the team |
| +vault, because their access is decided by ยง4.1 and the column must not |
| +become a place where two rules disagree โ see ยง6.2. |
| + |
| +**Roles.** Any admitted person may create a vault; the creator is its |
| +first owner. Owners add and remove members. There is no vault-level |
| +delete of the vault itself while it holds documents; empty it first, so |
| +that "delete this vault" can never be a way to lose content silently. |
| + |
| +--- |
| + |
| +## 5. Keys |
| + |
| +One new HKDF purpose, `memoria/vault-content/v1`, salted with the vault |
| +id, derived from the instance DEK. Everything that is content on a |
| +memoria-owned document seals under its vault's key rather than the |
| +instance key: `SealedTitle`, `SealedBody`, `Chunk.SealedText`, |
| +`Chunk.SealedHeading`, and `Vault.Name` itself (under the vault it |
| +names). |
| + |
| +`internal/memoria/seal` stays the only encryption surface โ the parent's |
| +rule โ and gains one derivation function, not a second surface. |
| + |
| +**Moving a document between vaults is a re-seal**, inside a transaction, |
| +scoped to `tx`: open every chunk under the old vault key, re-seal under |
| +the new, update `VaultID`, commit. The index refresh follows the commit |
| +under `context.WithoutCancel`, per the parent's derived-state rule, so a |
| +closed tab cannot leave a document indexed in a vault it has left. A |
| +partial move must not be possible: the transaction covers the document |
| +row and every chunk row together. |
| + |
| +**What this buys, precisely.** An attacker with the bucket and the |
| +database, but not the DEK, learns nothing new โ they could already read |
| +nothing. An attacker who compromises the *application's document-reading |
| +path* but not its key derivation reads ciphertext. And a bug in the ยง6 |
| +gate that returns a row from the wrong vault yields a blob that does not |
| +open, which is a loud failure rather than a leaked passage. That third |
| +one is the case decision 3 was chosen for, and it is the common one. |
| + |
| +**What it does not buy** is anything against the running instance. ยง0. |
| + |
| +--- |
| + |
| +## 6. The gate |
| + |
| +### 6.1 One resolution, both paths |
| + |
| +Parent gap 2 was `GET /query` returning passages on a path that never |
| +called the authorization gate, while the answer path did. The fix was one |
| +gate, both paths. Vaults must not undo it. |
| + |
| +**Vault membership resolves once per request**, in the same place ยง8.1's |
| +gate runs, into a value both the passage path and the answer path read. |
| +Not two lookups that happen to agree โ one resolution, passed down. A |
| +reviewer should be able to point at the single line that computes it. |
| + |
| +### 6.2 Ordering, and the two rules that must not collide |
| + |
| +For any candidate chunk: |
| + |
| +1. **Admission** โ parent ยง4.3, unchanged, checked first for everyone. |
| +2. **Then, by owner:** |
| + - **sibling content** โ parent ยง4.1's S2 call, per person, per |
| + revision. **The vault column is not consulted.** Every sibling |
| + document sits in the team vault as a placeholder and its access is |
| + decided entirely by the sibling. |
| + - **memoria-owned content** โ vault membership. |
| + |
| +The hazard is a future reader seeing `VaultID` on a sibling row and |
| +filtering on it, which would make memoria evaluate sibling access โ the |
| +thing the parent's decision 3 forbids. ยง13 gate 6 exists for exactly |
| +that mutation. |
| + |
| +--- |
| + |
| +## 7. The index |
| + |
| +### 7.1 One index per vault |
| + |
| +The parent keeps the FTS5 index in memory because its term postings are |
| +plaintext and an on-disk index would put readable passages in the |
| +replicated file. Vaults change the shape: **one in-memory index per |
| +vault**, and a query opens only the shards for the vaults the asker |
| +belongs to. |
| + |
| +This is decision 3's reasoning applied where keys cannot reach. A hit |
| +from a vault you are not in is not filtered out after the fact; the shard |
| +holding it was never opened. There is no predicate to get wrong, which |
| +matters because a predicate is precisely what AGENTS.md says gets written |
| +to pass. |
| + |
| +### 7.2 Ranking across shards |
| + |
| +BM25 depends on corpus statistics โ inverse document frequency and mean |
| +document length โ and those are per-index. The same term in a |
| +three-document personal vault and a five-thousand-document team vault |
| +gets wildly different IDF, so **merging raw scores systematically favours |
| +the small shard**, which would put your passport above the team's |
| +handbook for almost any query. |
| + |
| +The fix: each shard keeps a term โ document-frequency map. At query time, |
| +sum those maps across the asker's vaults and score with the summed |
| +statistics. This leaks nothing โ the statistics only ever cover vaults |
| +the asker is already in โ and costs one bounded map merge per query, |
| +bounded by vault count, which is small. Cache the merged statistics keyed |
| +by the vault set, not by the person: two people in the same vaults share |
| +one entry. |
| + |
| +**This is a quality regression against today's single index and the spec |
| +should not pretend otherwise.** Summed IDF is correct; mean document |
| +length across shards is an approximation, and cross-shard ranking will be |
| +measurably worse than single-index ranking. ยง14 requires it measured, not |
| +asserted. |
| + |
| +### 7.3 The arithmetic has to be re-derived |
| + |
| +The parent's ยง7.2 and ยง7.3 give the index's memory profile and the wake |
| +budget, and derive a ~150k chunk ceiling. **Per-shard overhead breaks |
| +those numbers**, and the direction is bad: many small shards cost more |
| +than one large index holding the same chunks. A team with forty vaults of |
| +three documents each is a plausible shape and a bad one. |
| + |
| +This is not a number this document may assert. Parent ยง7.2 was wrong by |
| +half when it was asserted rather than measured, and `docs/BENCHMARKS.md` |
| +exists because of it. **Stage 1 of ยง14 measures per-shard overhead on the |
| +real index implementation and writes the machine and command into |
| +`BENCHMARKS.md` before stage 2 is designed.** If the ceiling falls far |
| +enough, a shard-pooling strategy โ the parent's ยง12 on-disk sealed index, |
| +arriving earlier than planned โ becomes the next question rather than |
| +this design's problem. |
| + |
| +--- |
| + |
| +## 8. Derived artefacts |
| + |
| +Parent ยง5.2 partitions a derived artefact's provenance by owner, ANDs the |
| +authorities, and withholds the whole artefact if any source fails. Vaults |
| +add a dimension to step 3. |
| + |
| +A memoria-owned source now carries a vault, so the memoria-owned half of |
| +the partition is no longer "exists and is not deleted" โ it is "exists, |
| +is not deleted, and the asker is in its vault." **Every memoria-owned |
| +source must pass, ANDed with the sibling half as before, with no |
| +precedence and no fallback.** |
| + |
| +Without this, a summary drawn from a team document and a private-vault |
| +document is a channel between the two: the artefact would be served on |
| +the strength of the team document while speaking the private one. That is |
| +ยง5.2's laundering case arriving through a new door, and ยง5.2's own |
| +sentence covers it once vault is in the partition. |
| + |
| +**The transitivity hole the parent already records is made worse and is |
| +not fixed here.** Parent ยง13 notes that an artefact built from another |
| +artefact partitions as memoria-owned and never reaches S2 for the first |
| +artefact's sibling sources. With vaults, two hops can also cross a vault |
| +boundary. This design does not fix it โ it is the parent's open item and |
| +belongs there โ but it does note that vaults raise its severity from |
| +"laundering across authorities" to "laundering across authorities and |
| +across vaults." ยง15. |
| + |
| +--- |
| + |
| +## 9. Failure is loud |
| + |
| +**If vault membership cannot be resolved, the query errors.** |
| + |
| +It does not return an empty result set. It does not narrow to the team |
| +vault. It does not return the results it managed to gather from the |
| +shards that did open. |
| + |
| +This is AGENTS.md's two opening rules landing on one code path. A search |
| +returning nothing because membership failed to load is byte-identical, to |
| +the person reading it, to a search returning nothing because there is |
| +nothing there โ and the second reading is the one they will make. It is |
| +`chunkCount` returning 0 on error, with a worse blast radius. |
| + |
| +Three specific cases, each of which must error rather than degrade: |
| + |
| +| Case | Wrong behaviour | Required | |
| +|---|---|---| |
| +| Membership lookup fails | empty list | error, named | |
| +| One shard of several fails to open | partial results, silently | error, naming that the result is incomplete | |
| +| A vault key fails to derive | skip the document | error | |
| + |
| +**A partial result is the dangerous one**, because it looks like a |
| +successful narrow search. If the asker is in four vaults and one shard |
| +will not open, the honest answer is that memoria cannot answer right now, |
| +not three vaults' worth of results presented as four. |
| + |
| +--- |
| + |
| +## 10. The override, and what the product may not claim |
| + |
| +The team owner can open any vault. Every use writes an audit row: who |
| +opened which vault, when, and what they read at document granularity. |
| + |
| +**The audit log is a courtesy, not a guarantee, and the spec says so.** |
| +Whoever holds the DEK can read the in-memory index without ever touching |
| +the override path, so the log measures compliance and not security. A |
| +product that presents it as a security control has made the over-claim |
| +ยง1.6 has caught three times. |
| + |
| +Which fixes what the product is allowed to say. It may say: this document |
| +is visible to the members of this vault, and to the team owner. It may |
| +not say, or imply, private, only you, or encrypted so that nobody else |
| +can read it. The line is that **a vault is a boundary between members, |
| +hardened at rest against a stolen bucket โ and not a boundary against |
| +whoever runs the instance.** |
| + |
| +The person uploading is told this **at the point of upload**, not in a |
| +settings page they will never open. The motivating case is someone |
| +putting their own passport into a vault of one, and they are entitled to |
| +know who can open it before they do it, not after. |
| + |
| +Final wording of every string in this section goes through the |
| +`copy-review` gate before it is written into a template. This document |
| +specifies what the copy must convey and does not fix its words. |
| + |
| +--- |
| + |
| +## 11. Migration |
| + |
| +Additive, in the parent's style, and resumable. |
| + |
| +1. Create the `team` vault. Its membership is not a stored roster โ it is |
| + *everyone admission admits*, evaluated live, so the parent's |
| + decision 11 is not reintroduced through the migration. `VaultMember` holds rows only for |
| + vaults narrower than the team. |
| +2. Add `Document.VaultID`, defaulting to the team vault, so old rows are |
| + valid the moment the column exists. |
| +3. **Backfill the re-seal**: every existing document's content moves from |
| + the instance key to the team vault key, in batches, resumable, with |
| + progress recorded so an interrupted run does not restart from zero and |
| + does not leave a half-sealed corpus. A document is re-sealed |
| + atomically with its chunks or not at all. |
| +4. Rebuild the index as shards. |
| + |
| +**Old code over a new DB is not a case that has to work** โ the parent's |
| +rule is new code over an old DB โ but note that during the backfill both |
| +key derivations are live, so the reader must try the vault key and fall |
| +back to the instance key for a document not yet migrated. That fallback |
| +is deleted in stage 3 (ยง14), and a gate asserts it is gone, because a |
| +permanent fallback to the instance key would quietly undo decision 3 for |
| +every document that ever failed to migrate. |
| + |
| +--- |
| + |
| +## 12. What has to change in the parent |
| + |
| +Recorded so it happens in the same commit, per AGENTS.md. |
| + |
| +| Parent location | Change | |
| +|---|---| |
| +| ยง4.2 | "team-wide by construction" narrowed; the two-fact test becomes three for memoria-owned content; cross-reference this document | |
| +| ยง4.3 | "memoria does not have an authorization model" narrowed to sibling content | |
| +| Decision 11 | note that vault membership is not admission and cannot widen (ยง3.2) | |
| +| ยง3.2 leak list | new entry: vault names are sealed, vault *membership* and vault *count* are plaintext coordinates, as document counts already are | |
| +| ยง3.6 / README | both currently say every admitted person sees every memoria-owned document. No longer true | |
| +| ยง7.2, ยง7.3 | re-derived after ยง7.3's measurement, not before | |
| +| ยง5.2 | vault added to the step-3 partition | |
| +| ยง12 | on-disk sealed index may arrive earlier; see ยง7.3 | |
| + |
| +--- |
| + |
| +## 13. Gates |
| + |
| +Per AGENTS.md: each gate is written, then the thing it guards is broken, |
| +watched go red, and reverted โ per path, not per file โ and the test |
| +comment records the mutation and the date. Each gate below states its |
| +mutation. A gate asserting an absence first asserts the presence of the |
| +thing whose absence it checks. |
| + |
| +1. **A non-member's search cannot return a vaulted chunk.** Presence |
| + first: assert the *member* gets the hit, so the absence is a real |
| + absence. *Mutation: open all shards regardless of membership.* |
| +2. **A non-member's answer cannot cite a vaulted chunk.** The gap-2 pair |
| + of gate 1 โ separate test, separate path. *Mutation: fix gate 1's path |
| + only and confirm this one still goes red.* |
| +3. **Vault membership cannot widen.** A person admission refuses is |
| + refused with a `VaultMember` row present for the vault they are |
| + asking about. This is ยง3.2's invariant, and the reason the parent's |
| + decision 11 survives. *Mutation: replace the admission check with the |
| + vault membership check.* Reordering alone is not enough โ it leaves |
| + both checks running and the gate stays green, which would make this a |
| + gate written to pass. |
| +4. **A document sealed under vault A does not open with vault B's key.** |
| + Presence first: assert it opens with A's. *Mutation: derive the key |
| + from the DEK without the vault salt.* |
| +5. **Unresolvable membership errors.** Three cases from ยง9's table, three |
| + assertions, each asserting an error and not an empty list. *Mutation: |
| + return an empty slice on the error path.* |
| +6. **A sibling document's access does not consult its vault column.** Put |
| + a sibling document in a narrow vault, have a non-member of that vault |
| + ask, and assert S2 decides โ `VISIBLE` renders it. *Mutation: add |
| + `VaultID` to the sibling filter.* This is ยง6.2's hazard. |
| +7. **A two-vault derived artefact is withheld from a one-vault asker.** |
| + Presence first: assert the two-vault asker gets it. *Mutation: OR the |
| + partition instead of ANDing it.* |
| +8. **The product does not claim privacy from the instance.** Grep the |
| + templates for the forbidden claims of ยง10 in the vault surfaces. |
| + Presence first: assert the permitted string is present, so the gate is |
| + not green on a page that renders nothing โ this is gate 4's lesson. |
| + *Mutation: change the copy to "only you can read this".* |
| +9. **The override writes an audit row.** Presence first: assert the row |
| + is absent on an ordinary read. *Mutation: return the document before |
| + the audit write.* |
| +10. **Moving a document between vaults leaves nothing under the old |
| + key.** *Mutation: update `VaultID` without re-sealing.* |
| +11. **The migration's instance-key fallback is gone after stage 3.** |
| + *Mutation: restore the fallback.* |
| + |
| +--- |
| + |
| +## 14. Sequencing |
| + |
| +Three stages, each reviewable, in the notion-ingest style. |
| + |
| +**Stage 1 โ measure.** Build the sharded index behind the existing |
| +single-index interface with no authorization change at all, and measure |
| +per-shard memory and wake cost against the parent ยง7.3 model. Write the |
| +machine and the command into `docs/BENCHMARKS.md`. Also measure ranking |
| +quality: ยง7.2's summed-IDF merge against today's single index on the same |
| +corpus and the same queries. **If the ceiling or the ranking falls far |
| +enough, stage 2 is redesigned rather than built.** Nothing in this stage |
| +is user-visible. |
| + |
| +**Stage 2 โ the boundary.** Vault, VaultMember, `Document.VaultID`, the |
| +key derivation, the ยง6 gate, ยง9's loud failures, ยง8's partition, and |
| +gates 1โ7 and 9โ10. The migration runs with everything landing in the |
| +team vault, so behaviour is unchanged and the mechanism is exercised. |
| + |
| +**Stage 3 โ the product.** Creating vaults, managing membership, moving a |
| +document, the override and its audit surface, ยง10's copy through |
| +`copy-review`, gates 8 and 11, and removal of the migration fallback. |
| + |
| +Binary documents and metadata extraction are **not** in this spec. They |
| +are the sibling project, and they sequence after stage 3 โ the ordering |
| +argument being that the first thing anyone will upload is a passport, and |
| +a boundary retrofitted into a gate after the fact is the shape ยง1.6 |
| +records this codebase getting wrong three times. |
| + |
| +--- |
| + |
| +## 15. Open |
| + |
| +- **Unreviewed.** AGENTS.md ยงReview requires a model that did not write |
| + this. It has had none. |
| +- **Per-shard overhead is unmeasured**, and ยง7.3's ceiling is therefore |
| + unknown rather than known-to-be-fine. Stage 1 exists to settle it. This |
| + document asserts no number. |
| +- **TensorX vision support is unverified** โ relevant to the sibling |
| + project, not this one, but recorded here because it was checked during |
| + this design and the check was inconclusive. The models page reports six |
| + vision-capable ids including the two ยง8.2 already names; the API's |
| + `supports_vision` field on `GET /v1/model/info` settles it and was not |
| + called for want of a key. QUESTIONS-FOR-PAUL ยง1 records two wrong |
| + claims previously made from this same page. |
| +- **The parent's S7 admission cache still defaults to 30 days** and was |
| + never chosen. Vaults sharpen it rather than cause it: a person removed |
| + from every sibling now reads private vaults, not just team-wide |
| + content, for up to a month. This is the point to choose the number. |
| +- **Derived-artefact provenance is still not transitive** (parent ยง13). |
| + Vaults raise the severity; ยง8 says how, and does not fix it. |
| +- **Nesting is deliberately absent** (ยง2.1). If a team asks for |
| + "everything in Engineering, plus this one folder", the answer today is |
| + a second vault, and that answer gets worse as vault count grows. |
| +- **Vault count is a plaintext coordinate.** How many vaults a team has, |
| + and who is in each, is visible to anyone with the database. Names are |
| + sealed; the shape of the org chart they imply is not. New ยง3.2 entry, |
| + and the honest position is that this is the same class as document |
| + counts, which the parent already accepts. |
| +- **No decision on vault-level export or deletion.** "Delete this vault |
| + and everything in it" is the obvious next request and is not specified; |
| + ยง4 deliberately forbids deleting a non-empty vault so that the gap |
| + fails safe rather than quietly. |
| + |
| +--- |
| + |
| +๐ค Written by Claude Opus 5. Reviewed by nobody yet. |