oficina / docs Public

Design additive Home-only principals with legacy allocation isolation

Paul Campbell pushed by paul@keymail.dev a5a791819e65f573a8c329b8cb523771d659c975 parent 3ed7665
1 file changed, +63 −0
  • implementation/home-only-principals.md +63 −0

CI passed — run details

diff --git a/implementation/home-only-principals.md b/implementation/home-only-principals.md
new file mode 100644
index 0000000..52664aa
--- /dev/null
+++ b/implementation/home-only-principals.md
@@ -0,0 +1,63 @@
+# Home-only Docs principals
+
+Status: proposed design, based on integration3ed7665. Discussion46's existing-account link design and discussion48's integration remain binding. This branch contains no implementation, schema change or deployment. Independent review pending.
+
+## What the source permits
+
+`migrations/0002_owners.sql` creates people with INTEGER PRIMARY KEY (not AUTOINCREMENT), email TEXT NOT NULL UNIQUE, and created_at. It adds documents.owner_id as an integer with default0, without a foreign key. `0005_grants.sql` likewise stores created_by as an integer without a person foreign key; grants.subject is a verified-address key. `linkstore/migrations/0001_links.sql` DOES have a real docs_identity_links.person_id REFERENCES people(id). Preserve that constraint and its table.
+
+`Store.EnsurePerson` looks up an address and lets SQLite allocate people.id through a normal INSERT; only its successful first insertion may call seedIfEnabled. Old binaries do not know a second allocator. `identity.go` resolves Home identities through the link table and loads a Person with a nonempty address. `grants.go` readable/writable require positive owner IDs; addressOf loads people and is used when sharing. `handlers.member` and requireAdmission still use address-based membership/admission. Merely returning a new number from a Home callback does not make these paths work.
+
+No production ID census was performed. The existing source has no person deletion or primary-key update operation; explicit unsupported SQL is not a compatibility promise. Content encoding, update/snapshot bytes, document IDs/sqids, ownership IDs and grant authors must remain unchanged.
+
+## Decision and alternatives
+
+Keep people exactly as it is. Add a canonical principal ledger and a separate Home-only identity table. Use deliberately disjoint positive ID ranges so an old people INSERT cannot collide with a Home-only owner. This is a persisted compatibility contract, not a random-ID probability argument.
+
+Reserve IDs 1 through 2^52-1 for legacy people; reserve 2^52 through 2^53-1 for Home-only principals. Both ranges are positive and exactly representable in JavaScript numbers, even though no new client API should expose them. Existing people IDs must all be positive and below2^52; otherwise this migration refuses and this range design must be revised explicitly. Do not renumber exceptional existing rows. Every document.owner_id and grants.created_by >0 must reference an existing people row before bootstrap; unsupported orphaned IDs refuse the migration for operator investigation. Existing documents.owner_id=0 remains the deliberate inaccessible legacy sentinel and is never adopted.
+
+A plain second table with MAX(id)+1 is unsafe: the next old EnsurePerson can independently allocate the same number. Negative IDs conflict with current owner guards. Inserting an empty or invented email is forbidden. Rebuilding people to allow NULL would be a viable different model but is not additive, changes a referenced table, and does not itself teach old identity/grant code what absence means. This proposal therefore does not alter NOT NULL or disable foreign keys.
+
+## Additive schema and allocation
+
+Add `docs_principals(id INTEGER PRIMARY KEY, kind CHECK local|home, created_at)` with a CHECK tying local/home kind to the disjoint ranges. Copy every existing people.id into it unchanged, kind local, in the migration transaction. Add `docs_home_identities(issuer, subject, principal_id REFERENCES docs_principals(id), PRIMARY KEY(issuer,subject), UNIQUE(principal_id))`. Home identity insertion must verify the referenced principal is kind home. The table contains no address field. Its purpose is identity resolution, never standing.
+
+Add a singleton Home allocator high-water row initialized to2^52-1. A transaction advances it by one, checks the upper bound, inserts the home principal and unique issuer/subject mapping, then commits. At exhaustion refuse creation with a fixed error, never wrap or select another range. Read the existing mapping first; concurrent creators serialize on the allocator write and re-read the mapping before allocating. Failed transactions consume no ID; IDs of committed principals are never reused or deleted.
+
+Add database triggers that keep old binaries inside the contract:
+
+- AFTER INSERT on people checks the actual assigned ID is in the local range, then inserts the same ID as kind local into docs_principals. Any collision or range violation aborts the whole people INSERT, including old SQLite allocation. The trigger does not substitute an ID: GORM LastInsertId continues to describe the real people row. Existing EnsurePerson uniqueness retry remains unchanged.
+- Refuse changing people.id or deleting people rows. Refuse changing/deleting principal IDs or kinds. The current application has no such operation; future account erasure must preserve an identity tombstone and be separately designed. This prevents SQLite maximum-row reuse from reassigning an old owner after a person deletion. It is a deliberate new database invariant and requires old/new compatibility tests.
+- Preserve the existing docs_identity_links FK to people. Add INSERT/UPDATE checks on both identity tables to refuse the same (issuer,subject) appearing in both. A Home-only principal already owning documents is not silently merged into a separately authenticated local account. These checks cover old link-writing binaries too; SQLite's single writer makes simultaneous inserts one ordered winner. Principal creation and mapping insertion are one transaction.
+
+Do not add person FKs to old documents/grants by pretending ALTER COLUMN supports it. Existing owner/author integrity has no such FK today. Add INSERT/UPDATE owner_id and created_by validation triggers against docs_principals for future writes; allow their existing0 sentinel only where historical schema permits it. Keep docs_identity_links's existing FK and add real FKs on all new tables. Run PRAGMA foreign_key_check before and after, with enforcement enabled. Triggers plus immutable ledger preserve new owner references without rebuilding content tables. The migration must be installed before any new principal can be admitted.
+
+## Typed request identity and grants
+
+Change the internal resolved actor to hold a positive PersonID plus an explicitly optional verified local address. Absence is not a persisted empty email and is never passed to EnsurePerson, normalizeAddress, an allowlist or a grant equality query. Resolve the exact configured issuer and opaque subject through either the existing local link or Home-only table; both present or missing principal/kind mismatch is refusal. Never infer a local link by email or by a matching numerical value.
+
+A linked legacy account still resolves to its unchanged people.id and current verified people.email. A Home-only actor resolves to its high-range principal ID with absent address. Ownership checks use that ID unchanged. Read/write grant clauses execute only when a verified address exists; do not compare an absent address with a malformed historical empty grant. addressOf becomes an optional-address lookup that distinguishes a known addressless principal from a missing principal. Such an owner may grant access to a real email address without inventing their own; self-grant prevention compares only a present verified address.
+
+`requireAdmission` and `handlers.member` gain a suite-aware standing decision from the reviewed call-8 roster client. A fresh active membership admits a Home-only person and permits member operations such as creating their own document. Membership grants no access to someone else's private document. Existing linked address grants continue to admit guests independently of roster standing, including ex-members whose grant survives. With no address, a Home-only principal cannot redeem an email grant. Future opaque-subject individual grants are a separate typed grant migration; do not smuggle person refs into grants.subject.
+
+No Home-only principal is created just because the issuer recognizes someone. At ordinary handoff completion, verify a complete trusted fresh roster snapshot for this exact team and issuer, and require the subject on that roster before running the idempotent creation transaction. Unavailable/stale/unknown-generation roster refuses creation with retryable failure, without manufacturing standing from the assertion. Every later request rechecks standing age; an existing session is not an exception. The transport consumes the assertion before invoking the principal transaction; a failed session mint leaves a stable principal but requires a new handoff, not replay.
+
+## Linking and first-use behavior
+
+Existing local linking continues unchanged through docs_identity_links and its fresh dual-auth flow. If a subject already has a Home-only principal, linking it to another local account refuses without moving documents, grants or identity rows. An explicit account-merge feature would need to resolve ownership and two authenticated identities separately; it is outside this first-admission stage. State that limit in the eventual linking UI, rather than implying an address can be attached without consequence.
+
+Home-only creation never calls EnsurePerson or seedIfEnabled and creates no sample documents, queue entries or grants. The first document is an explicit admitted POST /documents and owns the new principal ID. Local sign-in keeps its existing opt-in sample behavior. A new Home person with no roster standing and no applicable individual grant sees refusal/empty navigation, not automatic team ownership. Roster owner/member role never rewrites document ownership.
+
+## Rollback, restore and release fence
+
+This migration is additive in schema/content, but it adds compatibility constraints. Its bootstrap census/copy/triggers execute in one SQLite write transaction under the normal migration ledger; failure rolls back everything. A concurrent old reader sees the previous committed schema, and an old writer is serialized before/after the transaction. This does not justify simultaneous independent application processes or replication writers on one live DB.
+
+Certify rollback only to the existing-account-aware3ed7665 integration lineage with the same approved content encoding/key compatibility, not to an arbitrary old Docs release. That lineage refuses Home subjects absent from docs_identity_links before app handlers. The new home table and high-range owner rows remain on disk; old binaries cannot impersonate them because people allocation is constrained and the original links FK requires a people row. New Home-only accounts temporarily cannot sign in during rollback; linked/local accounts retain their IDs and documents. Existing address grantees may still read/write new-owner documents under the original grants queries. Restoring the compatible new binary restores Home-only access without moving data. Rehearse creation of new people by the old binary during rollback and verify its trigger-created ledger IDs.
+
+Before production: exact current ID/reference census, FK checks, migration success/failure/retry tests, old→new→old→new binary drill on a real isolated restored copy, and verified preservation of content keys/encoding/owner IDs/grants are mandatory. New-principal admission stays disabled until the roster client, migration and named rollback binary are all verified. Production host aliases and existing deep links are outside this change.
+
+A full backup restore also rewinds local sessions, handoff replay state and principal allocation. No ordinary startup can prove a snapshot's continuity. Restore therefore requires the platform-supported exclusive maintenance/drain boundary, reset of assertion/session trust according to the existing handoff restore contract, and directory generation confirmation before standing resumes. Platform117's absence remains a production restore-drill dependency. Do not delete/overwrite the live DB, run raw remote SQL, disable FKs, or claim the additive migration makes arbitrary snapshot rollback safe. The schema itself needs no table rebuild or secret rotation; the operational proof is still required.
+
+## Acceptance before implementation approval
+
+Test unchanged IDs for all existing people, owners and grant authors; schema preflight refusal for conflicting/range-violating IDs; exact0 orphan behavior; two Home-only subjects and two local people; concurrent repeated creation/link conflicts with one mapping winner; old EnsurePerson allocation during rollback; maximum-ID/range boundary refusal; attempted delete/update/reuse blocked; all real FKs enabled; new ownership and real-address sharing without email; malformed empty grants not redeemed by address absence; no sample creation; no principal on stale/missing/foreign-team roster; existing-session TTL expiry; local guest survival under valid individual grants; explicit Home/local merge refusal; renderer shows neither opaque ref as email nor fabricated address; DB/WAL/log privacy for handoff material; content-byte/key/encoding invariance; exact binary rollback/restore evidence. Meaningful negative mutations must demonstrate ID collision and absent-address grant tests fail when their guards are removed.