diff --git a/docs/requirements/custody-schema.md b/docs/requirements/custody-schema.md index a61634d4..90d009b4 100644 --- a/docs/requirements/custody-schema.md +++ b/docs/requirements/custody-schema.md @@ -52,6 +52,31 @@ terminal outcome (F8). Managed deletion is an ordered git-then-database protocol with fault-injection witnesses at both interruption points, replacing the impossible cross-store transaction (F9). +Revision 4 (sol r3 findings F1/F2/F4/F6/F7/F9 residual, F10/F11 new): +the write fence pins `READ COMMITTED` isolation and lock-first +statement order for the reclassification migration, and the +supported-version declaration becomes a named source symbol checked +under the same lock as each decision (F1). The user foreign keys +adopt the identity store's actual key type — `users.id` is `text` +(BetterAuth identifiers), so every `user_id` column is `text` (F10). +The agent referent binds to the live platform `agents` table by +symbol, since `identity-lifecycle.md` defines no enrollment surface; +an amendment re-binds if one arrives (F2). The system auto-revocation +predicate is withdrawn entirely: grantee cessation is handled +structurally by `ON DELETE CASCADE` from the grantee referent (as +subject cessation already is from `users`), default-deny ends access, +and the audit event table keeps the durable history — which removes +the trigger/actor contradiction rather than patching it (F2, F7). +Sensitive-write routes gain a static write-set closure witness so a +derived representation cannot land in any generic platform table +(F4). Managed deletion step 2 becomes a compare-and-delete on the +hash removed in step 1, witnessed against a concurrent rewrite (F9). +Ordinary sensitive writes get an ordered content-first protocol with +deterministic locators, ack-after-pointer-commit, idempotent retry, +and fault-injection witnesses (F11). §8 discloses every rule above +plus the previously undisclosed §4.3 committed-tree/symlink rule +(F6). + This contract binds the profile-category registry (§2), the custody placement rule (§3), the pointer schema (§4), the consent schema and its evaluation (§5), mode application (§6), witnesses (§7), and disclosed @@ -70,8 +95,8 @@ with contract 6 (`mode-conversion.md`), identity with 3. **Pointer**: a database record referencing sensitive content that lives in a user brain, carrying no content (§4). 4. **Grantee**: a non-subject principal that may be granted access to a - user's sensitive content: an enrolled agent, a connector, or a - platform feature (§5.2). + user's sensitive content: a platform agent (a row of the `agents` + table, §5.2), a connector, or a platform feature (§5.2). 5. **Consent record**: a database record granting one grantee access to one category of one user's data (§5). 6. **Registry version**: the monotonically increasing integer @@ -152,18 +177,37 @@ with contract 6 (`mode-conversion.md`), identity with that performs the write, and routes by the version so read. A reclassification migration takes an **exclusive lock** on that row (`SELECT ... FOR UPDATE`) before its scan and holds it through the - value moves and the version bump. Consequently a concurrent writer - either commits before the migration's scan begins — its value is - then swept by the scan — or blocks until the migration commits and - then routes by the new version. No interleaving exists in which a - value routed under the old version commits after the migration's - scan (witness §7.6g). + value moves and the version bump. Isolation and statement order + are part of the fence, not implementation freedom: both the + profile-write transaction and the migration transaction run at + `READ COMMITTED`, and the migration's `SELECT ... FOR UPDATE` is + the FIRST statement of its transaction — no read precedes it, so + the migration establishes no snapshot before it holds the lock. + Under `READ COMMITTED` each subsequent statement takes a fresh + snapshot, so the migration's scan — which runs only after the lock + is granted — sees every row committed by every `FOR SHARE` holder + that finished before the lock was granted. Consequently a + concurrent writer either commits before the lock is granted — its + value is then visible to and swept by the scan — or blocks until + the migration commits and then routes by the new version. No + interleaving exists in which a value routed under the old version + escapes the scan, including the writer-commits-while-migration- + waits interleaving (witness §7.6g). 7. **Supported version.** Each release declares the exact registry - version its code implements. If the declared version does not equal - `custody_config.registry_version`, every profile routing and - consent decision is refused — a readable but unsupported registry - is a refusal, never best-effort routing under either version - (witness §7.6h). + version its code implements as an exported compile-time constant + in the custody module of the shared schema package; the + implementing PR names the symbol and §7.6(h) pins it by symbol + identity, so the declaration is source, not configuration. Every + profile routing decision and every consent decision reads + `custody_config.registry_version` under a `SELECT ... FOR SHARE` + on the `custody_config` row inside the same database transaction + as the decision (write decisions share the §2.6 fence transaction; + read-only consent evaluations take the shared lock in their own + transaction), so the supported-version comparison and the decision + it gates use one atomically read version. If the declared version + does not equal the version so read, the decision is refused — a + readable but unsupported registry is a refusal, never best-effort + routing under either version (witness §7.6h). ## 3. Custody placement rule @@ -186,7 +230,7 @@ with contract 6 (`mode-conversion.md`), identity with only and creates no new obligation on estate brains. 5. **Non-sensitive profile table.** Non-sensitive profile answers land in exactly one declared table, `profile_answers`, columns exactly: - `id` (uuid, primary key), `user_id` (uuid, NOT NULL, FK → users(id) + `id` (uuid, primary key), `user_id` (text, NOT NULL, FK → users(id) ON DELETE CASCADE), `category_key` (text, NOT NULL, FK → profile_category_registry(category_key)), `value` (text, NOT NULL), `created_at` and `updated_at` (timestamptz, NOT NULL), and @@ -195,6 +239,36 @@ with contract 6 (`mode-conversion.md`), identity with table ONLY while its category's registry classification is `non-sensitive` (§2.4 migrates the rows on reclassification). No other platform table stores profile answer content. +6. **User-key type binding.** The identity store declares `users.id` + as `text` (BetterAuth identifiers, `packages/db` schema); every + `user_id` column in this contract (§3.5, §4.1, §5.1) is therefore + `text`, matching the referenced key's declared type exactly. The + identifiers are opaque strings, not guaranteed UUIDs; no custody + column re-types, parses, or reformats them. If a sibling contract + ever migrates the user key type, an amendment to this contract + follows it (§7.1 asserts the FK types match the live referenced + columns). +7. **Ordered sensitive-write protocol.** An ordinary sensitive write + spans two stores and is ordered, content first: step 1 commits the + content to the owning user's brain at a deterministic locator — + the implementing PR declares a locator scheme that maps (category, + answer slot) to one `brain_ref`, so a retry of the same write + resolves to the same path; step 2, only after step 1 has + committed, upserts the pointer row (insert, or update of the + existing row for that (user, category, brain_ref)) in its own + database transaction inside the §2.6 fence. The write is + acknowledged to the caller only after step 2 commits. Interruption + before step 1 leaves both stores unchanged. Interruption between + the steps leaves committed brain content with no pointer: + unpointed content is inert — it lives in the correct custody + store, no read path serves it (reads resolve through pointers + only), and nothing dangles in the database. The client's retry — + the write was never acknowledged — recommits the same locator and + completes the pointer upsert, converging to the pointed state. + The reverse order (pointer before content) is forbidden: an + ordinary write never creates a pointer whose content has not + committed. Witness §7.16 fault-injects both interruption points + and drives the retry convergence. ## 4. Pointer schema @@ -203,7 +277,7 @@ with contract 6 (`mode-conversion.md`), identity with | Column | Type | Constraints | | ---------------- | ----------- | ------------------------------------------------------ | | `id` | uuid | primary key | - | `user_id` | uuid | NOT NULL, FK → users(id) ON DELETE CASCADE | + | `user_id` | text | NOT NULL, FK → users(id) ON DELETE CASCADE (§3.6) | | `category_key` | text | NOT NULL, FK → profile_category_registry(category_key) | | `brain_ref` | text | NOT NULL, CHECK against the §4.3 grammar | | `content_hash` | text | NOT NULL (§4.4 construction) | @@ -276,32 +350,41 @@ with contract 6 (`mode-conversion.md`), identity with 7. **Managed deletion protocol.** A managed deletion of sensitive content spans two stores and is an ordered protocol, not a single transaction: step 1 commits the content deletion to the user's - brain repository; step 2, only after step 1 has committed, deletes - the pointer row in its own database transaction. Interruption + brain repository, recording the `content_hash` of the row it + intends to delete as read before step 1; step 2, only after step 1 + has committed, deletes the pointer row in its own database + transaction as a **compare-and-delete** — the DELETE is + conditioned on (`user_id`, `category_key`, `brain_ref`) AND + `content_hash` equal to the recorded value. A concurrent §3.6 + write that recreates the same locator with new content commits a + new hash on the pointer row, so step 2's condition fails, deletes + nothing, and the fresh content stays pointed — deletion never + removes a pointer for content it did not delete. Interruption before step 1 commits leaves both stores unchanged. Interruption between the steps leaves a dangling pointer, which §4.5 repairs toward deletion within one reconciliation cycle. At no point does - any compensation write content into the database (witness §7.10). + any compensation write content into the database (witness §7.10, + including the concurrent-rewrite interleaving). ## 5. Consent schema and evaluation 1. **Exact columns.** Table `profile_consents`, columns exactly: - | Column | Type | Constraints | - | ---------------- | ----------- | --------------------------------------------------------------------- | - | `id` | uuid | primary key | - | `user_id` | uuid | NOT NULL, FK → users(id) ON DELETE CASCADE (the data subject) | - | `grantee_type` | text | NOT NULL, CHECK in (`agent`, `connector`, `feature`) | - | `agent_id` | uuid | NULL, FK → the enrolled-agent table (§5.2) | - | `connector_id` | text | NULL, FK → custody_connector_registry(connector_key) (§5.2) | - | `feature_key` | text | NULL, FK → custody_feature_registry(feature_key) (§5.2) | - | `grantee_ref` | text | generated stored: COALESCE(agent_id::text, connector_id, feature_key) | - | `category_key` | text | NOT NULL, FK → profile_category_registry(category_key) | - | `state` | text | NOT NULL, CHECK in (`granted`, `revoked`) | - | `granted_at` | timestamptz | NOT NULL | - | `revoked_at` | timestamptz | CHECK ((state = 'granted') = (revoked_at IS NULL)) | - | `actor` | text | NOT NULL (the authenticated principal that recorded the change) | - | `audit_event_id` | uuid | NOT NULL, FK → the §1.8 audit event table | + | Column | Type | Constraints | + | ---------------- | ----------- | ----------------------------------------------------------------------------- | + | `id` | uuid | primary key | + | `user_id` | text | NOT NULL, FK → users(id) ON DELETE CASCADE (the data subject, §3.6) | + | `grantee_type` | text | NOT NULL, CHECK in (`agent`, `connector`, `feature`) | + | `agent_id` | uuid | NULL, FK → agents(id) ON DELETE CASCADE (§5.2) | + | `connector_id` | text | NULL, FK → custody_connector_registry(connector_key) ON DELETE CASCADE (§5.2) | + | `feature_key` | text | NULL, FK → custody_feature_registry(feature_key) ON DELETE CASCADE (§5.2) | + | `grantee_ref` | text | generated stored: COALESCE(agent_id::text, connector_id, feature_key) | + | `category_key` | text | NOT NULL, FK → profile_category_registry(category_key) | + | `state` | text | NOT NULL, CHECK in (`granted`, `revoked`) | + | `granted_at` | timestamptz | NOT NULL | + | `revoked_at` | timestamptz | CHECK ((state = 'granted') = (revoked_at IS NULL)) | + | `actor` | text | NOT NULL (the authenticated principal that recorded the change) | + | `audit_event_id` | uuid | NOT NULL, FK → the §1.8 audit event table | plus: the CHECK that exactly one referent column is non-NULL and matches the discriminant — `(grantee_type = 'agent') = (agent_id @@ -317,15 +400,18 @@ NULL)` — and the partial unique index UNIQUE (`user_id`, display form is `:`. The discriminant set is closed at the three CHECK values, and every referent is a real foreign key: - - `agent` → `agent_id` references the **enrolled-agent table**: the - single platform table in which agent enrollment under - `identity-lifecycle.md` (the contract 5 §3.1 rank-4 command - family) records enrolled agents. No sibling contract names that - physical table, so the binding is by identity: the custody - migration orders after that table's migration, the implementing - PR binds this FK to it, and the §7.11 witness pins the physical - name by asserting the FK's referenced table is the one the - enrollment surface writes. + - `agent` → `agent_id` references the platform **`agents` table** + (`agents.id`, uuid) as declared in the shared schema package + (`packages/db`). This is a binding to the live schema, named + honestly: the ratified `identity-lifecycle.md` defines no agent + enrollment surface or enrolled-agent table, so there is no + sibling-defined referent to bind to — the `agents` table is + today's only platform representation of agents, and the §7.11 + witness pins the FK's referenced table to that schema symbol. + If a sibling amendment later introduces an agent-enrollment + surface with its own table, an amendment to this contract + re-binds the FK; until then, rows in `agents` are the closed + set of possible agent grantees. - `connector` → `connector_id` references this contract's own table `custody_connector_registry`, columns exactly: `connector_key` (text, primary key), `since_version` (integer, @@ -347,26 +433,31 @@ NULL)` — and the partial unique index UNIQUE (`user_id`, grantee, category) means no access. There are no implicit grants, no platform-admin bypass, and no mode in which default-deny is suspended. -4. **Mutation authority — two disjoint predicates.** Every consent - mutation must satisfy exactly one of: - - **Subject mutation**: the authenticated actor IS the row's data - subject — the server asserts `actor` equals the principal of the - row's `user_id` before the mutation commits. This is the only - predicate under which rows are created or granted. A platform - admin has no consent-mutation capability over another user's - rows — an admin self-grant is refused at write time, closing the - write-side route around §5.3. - - **System auto-revocation** (disclosed, §8): the actor is the - single named platform system principal, the mutation is a - granted → revoked flip and nothing else, and the server has - verified the precondition that the row's grantee has ceased to - exist (e.g. agent retirement under `identity-lifecycle.md`). A - system-actor mutation whose precondition does not hold, or that - is anything other than a revocation, is refused. - - The predicates are disjoint (the system principal is not a data - subject); §7.12 witnesses both and their refusal complements, and - every §7.3-enumerated consent-mutation route asserts one of them. +4. **Mutation authority — subject only.** Every consent mutation + (create, grant, revoke) must satisfy the single predicate: the + authenticated actor IS the row's data subject — the server asserts + `actor` equals the principal of the row's `user_id` before the + mutation commits. A platform admin has no consent-mutation + capability over another user's rows — an admin grant or revocation + on another user's data is refused at write time, closing the + write-side route around §5.3. There is no system-actor mutation + predicate: revision 3's system auto-revocation is withdrawn + (disclosed, §8), because grantee cessation is handled structurally + instead — + **Cessation by cascade**: when a grantee ceases to exist — the + `agents` row is deleted, or a `custody_connector_registry` / + `custody_feature_registry` row is removed by an amendment + migration — the referent FK's `ON DELETE CASCADE` removes that + grantee's consent rows in the same transaction as the referent + deletion. Access ends with the rows (§5.3 default deny: no row, no + access); no UPDATE is performed, so no actor question arises. The + durable record of the grants and their lifecycle is the audit + event table (§1.8): every mutation carried an `audit_event_id`, + and audit events are not deleted by the cascade. The same rule + already governs subject cessation via the `user_id` FK. §7.12 + witnesses the subject predicate, its refusal complement, and the + cascade; every §7.3-enumerated consent-mutation route asserts the + subject predicate. 5. **Revocation and re-grant.** Revocation flips exactly one active row to `revoked` and stamps `revoked_at`; it is effective for every @@ -374,10 +465,17 @@ NULL)` — and the partial unique index UNIQUE (`user_id`, retained as history and never mutated again — enforced by a database trigger on `profile_consents` that permits UPDATE only when it is the granted → revoked transition (the state flip plus - the `revoked_at` stamp, every other column unchanged) and rejects - every other UPDATE and every DELETE, so a revoked row cannot be - flipped back to `granted` by anyone, including through direct SQL - (witness §7.13). A re-grant after + the `revoked_at` stamp, every other column unchanged), and permits + DELETE only when the row's referenced subject or grantee no longer + exists — the trigger checks that the `users` row named by + `OLD.user_id`, or the referent row named by the grantee column, is + absent, which is true exactly during a §5.4 cessation cascade + (referential cascades delete the parent row before the dependent + rows, so the check distinguishes a cascade from a standalone + DELETE). Every other UPDATE and every DELETE with both referents + alive is rejected, so a revoked row cannot be flipped back to + `granted` and history cannot be pruned by anyone, including + through direct SQL (witness §7.13). A re-grant after revocation inserts a NEW row (append-only history) — repeated grant/revoke cycles are represented as successive rows, and the §5.1 partial unique index guarantees the old revoked rows cannot @@ -446,9 +544,13 @@ declared profile table" means `profile_answers` (§3.5). custody tables' live column sets are exactly §2.1/§4.1/§5.1/§5.2/§6.3 and `profile_answers` is exactly §3.5; no platform table outside `profile_answers` carries profile - answer content; and every `audit_event_id` FK (§3.5, §4.1, §5.1, + answer content; every `audit_event_id` FK (§3.5, §4.1, §5.1, §6.3) references the same physical table that the contract-1 - (hierarchy §5.2) audit write path inserts into (§1.8 binding). + (hierarchy §5.2) audit write path inserts into (§1.8 binding); + and every FK column's declared type equals the referenced + column's live declared type — in particular each `user_id` is + `text` matching `users.id` and `agent_id` is `uuid` matching + `agents.id` (§3.6 binding). 2. **Column-type allowlist witness:** the custody tables and `profile_answers` use only the column types named in §2.1/§3.5/§4.1/§5.1/§5.2/§6.3 (uuid, text, integer, timestamptz, @@ -465,9 +567,23 @@ declared profile table" means `profile_answers` (§3.5). and runtime code construction — with the database client reachable only through a closed importer allowlist. Every enumerated write route implements §3.3 registry routing inside the §2.6 fence, and - every enumerated consent-mutation route asserts a §5.4 predicate; - a route outside the enumeration, or a client import outside the - allowlist, fails the assertion. + every enumerated consent-mutation route asserts the §5.4 subject + predicate; a route outside the enumeration, or a client import + outside the allowlist, fails the assertion. **Write-set closure + for sensitive paths:** for every enumerated route that performs a + sensitive write, the same static inventory (same prong set) + enumerates every database table that route's code path can write; + the set MUST equal exactly {`profile_pointers`, the §1.8 audit + event table} — the brain write goes through the declared + brain-write surface, never the database. Any additional + platform-table write statically reachable from a sensitive-write + path — an embedding insert, a cache row, any derived + representation in any generic table — fails the witness. Control: + a mutated route that adds one insert into an arbitrary existing + platform table is detected. This closes the §3.1 escape in which + a conforming-looking route also writes derived sensitive data + elsewhere: §7.5 probes for planted content; this closure bounds + what a sensitive-write route can write at all. 4. **Closed brain-read witness** (same style): the inventory enumerates every production route that reads user-brain content on behalf of a grantee, and every enumerated route calls the §5.6 consent @@ -493,13 +609,25 @@ declared profile table" means `profile_answers` (§3.5). seeded data moves the values to brains, creates pointers, and leaves zero relational values (§2.4); (g) fence race: a profile write transaction opened before a reclassification migration takes - its exclusive lock either commits before the scan (its value is - swept) or blocks and, on commit, routes by the new version — the - witness drives both interleavings and asserts no relational value - for the reclassified category exists after either completes - (§2.6); (h) with `custody_config.registry_version` set to a value - the release does not declare, profile writes and consent decisions - are refused (§2.7); (i) sensitive → non-sensitive: after the + its exclusive lock either commits before the lock is granted (its + value is swept) or blocks and, on commit, routes by the new + version — the witness drives both interleavings, INCLUDING the + interleaving in which the writer holds `FOR SHARE` when the + migration requests `FOR UPDATE`, the migration waits, the writer + commits its relational row, and the migration's scan (running + after lock grant, at `READ COMMITTED`, with the lock as its first + statement per §2.6) is asserted to sweep that row — and asserts no + relational value for the reclassified category exists after any + interleaving completes (§2.6); the witness also asserts the + migration implementation takes its exclusive lock as the first + statement of its transaction (a migration variant that reads + before locking fails review of this witness's static check); + (h) with `custody_config.registry_version` set to a value the + release's declared compile-time constant (§2.7, pinned by symbol) + does not equal, profile writes and consent decisions are refused, + and the witness asserts the version comparison reads the row under + the §2.7 shared lock in the same transaction as the refused + decision (§2.7); (i) sensitive → non-sensitive: after the version bump, existing brain content and pointers remain intact and readable, nothing is materialized into the database, and the next write for that category lands in `profile_answers` (§2.4). @@ -535,7 +663,14 @@ declared profile table" means `profile_answers` (§3.5). brain commit, both stores are unchanged; killed between the brain commit and the pointer delete, the dangling pointer is deleted by the next reconciliation — and at no point is content written to - the database or restored to the brain. + the database or restored to the brain. **Concurrent-rewrite + interleaving:** deletion step 1 commits; before step 2 runs, a + concurrent §3.7 write recreates the same `brain_ref` with new + content and completes its pointer upsert (new `content_hash`); + step 2 then executes its compare-and-delete — the witness asserts + the DELETE matches zero rows, the fresh pointer survives, its + content resolves and verifies, and no unpointed content and no + dangling pointer exist afterward (§4.7). 11. **Default-deny and granularity witnesses:** an agent grantee with no active row is refused; with a `granted` row for category A only, category B is refused; with agent X granted, agent Y of the @@ -543,8 +678,9 @@ declared profile table" means `profile_answers` (§3.5). and `feature` are exercised via their FK refusal paths, since both registries are empty at version 1 (§5.2); a consent insert naming a nonexistent agent id violates the `agent_id` FK, and the - witness pins that FK's referenced table as the one the enrollment - surface writes (§5.2 binding); a platform admin requesting + witness pins that FK's referenced table by symbol identity to the + `agents` table exported by the shared schema package (§5.2 + binding); a platform admin requesting another user's sensitive content through any production read surface is refused — admins are not a grantee type and default-deny applies (the access-time complement of §7.12's @@ -554,12 +690,14 @@ declared profile table" means `profile_answers` (§3.5). 12. **Mutation-authority witnesses:** a platform admin attempting to create a grant on another user's data is refused at write time; a hierarchy owner or manager likewise; the data subject succeeds; - `actor` equals the subject's principal on every subject-mutation - row; retiring an enrolled agent auto-revokes its active rows with - the system principal as `actor` and granted → revoked as the only - change; a system-principal mutation whose grantee still exists, - or that attempts anything other than a revocation, is refused — - the two §5.4 predicates are witnessed as disjoint (§5.4). + `actor` equals the subject's principal on every mutation row — + there is no non-subject mutation path (§5.4). **Cessation + cascade:** deleting an agent's `agents` row removes that agent's + consent rows in the same transaction via the FK cascade, the next + consent evaluation for that grantee refuses (no row), the linked + audit events survive, and no other grantee's rows are touched; a + connector-registry amendment migration removing a key cascades + identically (§5.4). 13. **Revocation/re-grant witnesses:** after revocation commits, the next evaluation refuses and the revoked row persists unmutated; a full grant → revoke → re-grant cycle yields two rows (one @@ -567,8 +705,12 @@ declared profile table" means `profile_answers` (§3.5). second concurrent grant attempt for the same key violates the §5.1 partial unique index; a direct SQL UPDATE flipping a revoked row back to `granted`, an UPDATE altering any other column of a - revoked row, and a DELETE of any row are each rejected by the - §5.5 trigger (§5.5). + revoked row, and a standalone DELETE of a row whose subject and + grantee both still exist are each rejected by the §5.5 trigger; + the complement controls pass: deleting the subject's `users` row + cascades the subject's consent rows, and deleting the grantee's + referent row cascades that grantee's rows — both permitted by the + trigger's absent-referent prong (§5.5). 14. **Self-access witness:** the data subject reads their own content without consent rows (§5.6). 15. **Mode witnesses:** the custody-table schemas are byte-identical @@ -581,6 +723,19 @@ declared profile table" means `profile_answers` (§3.5). users, user A's grantee with a grant on user A cannot reach any of user B's content (§6.6); inserting a second `custody_config` row violates the §6.3 singleton constraint. +16. **Ordered-write protocol witnesses:** the §3.7 protocol is + fault-injected at both interruption points: killed before the + brain commit, both stores are unchanged and the caller receives + no acknowledgment; killed between the brain commit and the + pointer upsert, the brain holds unpointed content, no pointer row + exists, no read surface serves the content, and the caller + received no acknowledgment — then the retried write recommits the + same deterministic locator, completes the pointer upsert, and the + witness asserts the converged state (one pointer, verifying hash, + content served to the subject). A static control asserts the + implementation orders content before pointer: no enumerated + sensitive-write route creates a pointer row before its brain + commit has been confirmed (§3.6). ## 8. Drafting additions (PRD §12.1 disclosure) @@ -591,9 +746,14 @@ ratification; none is claimed as a PRD mandate, and each is severable: that is empty at version 1 (§5.2). 2. Append-only consent history: re-grants insert new rows; revoked rows are retained unmutated, enforced by trigger (§5.5). -3. System-actor auto-revocation when a grantee ceases to exist, as a - precondition-checked predicate disjoint from subject mutation - (§5.4). +3. Cessation by cascade: when a grantee or subject referent row is + deleted, consent rows are removed by `ON DELETE CASCADE` in the + same transaction; access ends through default deny, and durable + history is carried by the §1.8 audit event table. The trigger's + DELETE prong permits a child-row DELETE only when the referenced + subject or grantee row is absent (§5.4, §5.5). This replaces the + revision-2 system-actor auto-revocation predicate, which is + withdrawn. 4. The `custody_config` election record for the Standalone layout, singleton by constraint (§6.3). 5. The domain-separated keyed `content_hash` construction, its key @@ -605,16 +765,42 @@ ratification; none is claimed as a PRD mandate, and each is severable: unknown-key refusal, and both reclassification transition policies (§2.1, §2.3, §2.4). 9. The custody-config write fence and the declared supported-version - refusal (§2.6, §2.7). + refusal, including the revision-4 pinning: both fence transactions + run at `READ COMMITTED` with the migration's `SELECT ... FOR +UPDATE` as the first statement of its transaction, and the + supported version is an exported compile-time constant in the + custody module of the shared schema package, read under the same + lock as the decision it gates (§2.6, §2.7). 10. Subject-only consent mutation authority (§5.4, first predicate). 11. The declared non-sensitive profile table `profile_answers` and its closed schema (§3.5). 12. The typed grantee referent columns, the `custody_connector_registry` and `custody_feature_registry` - tables, and the audit-table binding by identity (§5.1, §5.2, - §1.8). + tables, the audit-table binding by identity (§5.1, §5.2, §1.8), + and the agent-grantee binding by symbol identity to the `agents` + table exported by the shared schema package (§5.2, §7.11). 13. Shipping all §2–§6 schemas in v1 ahead of any sensitive write surface (§6.4). +14. The committed-tree-only resolution rule: content reads resolve + only through the committed tree of the brain repository, and + symlink or non-tree/non-blob resolution is refused (§4.3, §7.8). +15. The text user-key binding: every `user_id` column is `text` + matching the live `users.id` declaration, values are opaque + strings not guaranteed to be UUIDs, and any sibling user-key + migration triggers amendment of this contract (§3.6, §7.1). +16. The ordered sensitive-write protocol for ordinary writes: + content-first at a deterministic locator, pointer upsert second + in its own transaction, acknowledgement only after the pointer + commit, idempotent retry convergence, and the + pointer-before-content prohibition (§3.7, §7.16). +17. The compare-and-delete managed-deletion step: deletion step 2 is + conditioned on the `content_hash` recorded in step 1, so a + concurrent recreate of the same locator is never unpointed by a + stale deletion (§4.7, §7.10). +18. The sensitive-path write-set closure: a static inventory of every + table a sensitive-write route can write, required to equal + exactly the pointer table plus the §1.8 audit event table + (§7.3). The revision-1 "reporting" rationale for relational background/education storage is withdrawn; the traced rationale is