# Docs TeamMode integration

Status: proposed implementation design, 2026-09-08. Source base is fetched Docs main `5fe0447ff896d9cc9083b326642bfac549ce524d`. This branch changes no runtime code, schema, configuration or deployment. Initial revision `e065b88` received explicit Claude Fable design APPROVE with no blockers. This clarification revision requires focused Fable review before implementation.

## Dependencies and boundaries

Use the approved principal design `a5a791819e65f573a8c329b8cb523771d659c975` and unmounted implementation `10a8364`, and the approved roster contract `4e7b3ffd915b76b623aec3f70f672ff6baf08164`. The canonical roster module is `amadan.net/oficina/home/roster v0.0.0-20260908115900-667dc8218dd5`; its exact code, full gate, actual CI and Fable approval are in Home discussion 31. Producer storage is separately owned and reviewed in Home discussion 33. Import the canonical module; do not copy its protocol, signing, clocks, generation rules, cache or provisioning verifier into Docs.

Those contracts remain binding. In particular, a principal is identity only, roster standing expires within the measured 60-second lease ceiling, and an individual document grant is independent of team membership. The credential issuer sees no address/content; Docs receives no Home pepper. This design adds no opaque-subject grant kind, account merge, team-wide document visibility, operator bootstrap or production restore mechanism.

Current main has existing-account Home linking, not authoritative TeamMode. Its Home resolver loads `people.email` and rejects absence. Ordinary Home completion resolves only `docs_identity_links`. `Actor.Subject` means a grant address; `handlers.member` still calls the email allowlist. `readable` and `writable` always include OwnerID. `shareGet` checks ownership without membership. `ApplyUpdate` checks access before CRDT replay, writes an optional snapshot before the update, commits the update later and bumps `updated_at` outside that transaction. `updatesGet` reads snapshot/tail rows after a single document check, and SSE checks only at connection establishment. These exact paths are integration work, not permission already supplied by the shared cache.

## Configuration, migrations and durable cache

Add `Config.Team *TeamConfig` beside existing `Config.Home`. Nil Home/nil Team preserves standalone behavior. Non-nil Home/nil Team preserves the explicitly limited linked-account preview only on databases never activated for TeamMode. Non-nil Team requires complete Home configuration and a verified operator record; it cannot silently fall back to either mode.

`TeamConfig` contains `TeamID`, `DirectoryOrigin`, `ProvisioningBase64`, `OperatorKeysBase64` and `RosterToken`. Decode strictly with bounded canonical encodings. Operator keys are separate from browser assertion keys. Build exact expected `roster.Bindings` from Home issuer and consumer ID, app slug `docs`, configured team and directory origin, and Docs Origin. Pass these pins to `roster.VerifyProvisioning`; no browser path/query/header supplies them. Runtime delivery uses reviewed immutable platform configuration, never a public mutation endpoint. Neither a valid assertion nor the first visitor supplies a generation or owner.

Keep `BootSchema` for the existing standalone/preview set. Add `TeamBootSchema`, merging the existing set with `principalstore.Schema` and a small namespaced additive Team state schema in that order. Apply the extended set only when TeamMode is explicitly configured. Once applied, keep it permanently. Principal census/range/FK failure must roll back the principal migration without renumbering anyone. Existing keys, content encoding, update/snapshot bytes, owner IDs, document IDs and grants remain unchanged.

Team state has a singleton mode-required/bindings fingerprint row and a singleton opaque roster-evidence BLOB. The first marker is written only after configuration and the operator record verify; it pins issuer/directory/consumer/team/app/audience. It is not membership or provisioning authority. Before selecting a mode, every new binary checks whether this table/marker already exists: a present marker with missing/mismatched Team configuration refuses startup. A malformed or unreadable existing marker refuses too. This prevents an environment-variable omission from restoring email-allowlist standing. Commit this marker before any listener serves requests or Home-only allocation can occur. Add UPDATE/DELETE refusal triggers; binding changes require a separately reviewed migration, not a runtime rewrite. It cannot constrain older binaries that do not read it; the release floor below is mandatory.

For initial activation, inspect existing `docs_identity_links` and `docs_home_identities`: all stored issuers must match the configured credential issuer or startup refuses for investigation. Existing Home sessions contain method/subject/AuthTime but no issuer field; preview sessions were minted only after resolving such a link. This census, the immutable binding marker and exact per-request lookup prevent reinterpretation under a different issuer. The actor never takes issuer from a cookie or unsigned browser field.

Implement `roster.Durable` over the app's replicated SQLite writer, one bounded atomic BLOB replacement and one bounded load. The module already seals the payload under exact bindings and stores no reusable freshness timestamp; the adapter must neither decode it nor add its own age. Use the dedicated Home storage sealer under the existing Docs namespace, never the content key. Missing evidence returns `roster.ErrNoEvidence`; all other errors are generic. Honor contexts, the module's byte cap and atomic replace-on-success semantics. On restart `roster.New` verifies durable floors but begins Unknown. A failed fetch leaves age untouched. Token/key values and plaintext evidence never enter SQL statements, logs or metrics.

There is no background cron: request/stream demand drives the canonical cache. No route updates operator records. Later reviewed configuration reload can call `UpdateRecord`; until then deploy/restart delivers a new record, with durable sequence/generation checks intact. A generation fence intentionally cannot be cleared by another record for the old generation. The 1024-record receipt bound is an operational lifetime ceiling; there is no silent history reset. Total durable-state loss still requires delivery of the latest audited operator record.

## Required narrow shared-cache amendment

The approved module currently holds `Cache.mu` through `Durable.Save`. `Lease.Valid` and `Remaining` take that same mutex. With SQLite evidence storage, a real deadlock exists: an edit holds the SQL writer and waits for the lease mutex, while a publishing refresh holds the mutex and waits for the SQL writer. A context-honoring Save breaks this cycle at FetchTimeout, but causes a repeated whole-writer stall; an unbounded/non-context-honoring wait can make it permanent. Calling the lease helper “local-only” does not remove this ordering.

Amend the shared module in a separate reviewed revision before Docs integration. `Lease.Valid` uses `mu.TryLock`; contention returns false immediately. `Remaining` uses the same rule and returns zero on contention. After successful acquisition their existing version/clock/deadline checks remain unchanged. The immutable lease carries no new authority. `Check`, `Refresh` and `UpdateRecord` remain outside Docs write transactions. A contended member-only edit returns the existing retryable 503 path with no committed update; the editor retains its queued bytes. An exact independent writer grant may still authorize through the transaction's grant query. A stream closes/reconnects if it cannot obtain a usable new lease. No age extension or fabricated absence is introduced.

Releasing `Cache.mu` around persistence would require a separate serial persistence protocol so an old flight cannot overwrite a newer record/floor, then compare/fence/publish coordination. That is a larger change to the reviewed atomic-cache contract and is not this proposal. The bounded TryLock refusal favors correctness; tests and the canary must measure whether retries become frequent. Do not solve the inversion with an unreplicated sidecar or a second live SQLite writer process.

The deterministic regression test holds Docs' real writer transaction, starts a refresh whose Durable.Save waits for it, then calls both lease methods in the transaction. They must return immediately rather than deadlock. Roll back the edit, let publication complete, and prove a new decision permits the retry. A mutation restoring blocking Lock must fail on the intended bounded wait. Existing module tests, actual CI and explicit Fable code review are required for the amended canonical version before Docs pins it; `667dc82` remains unchanged meanwhile. Include an explicit unknown-kid map lookup/refusal before calling the canonical signature verifier and an unknown-kid test in that same amendment, closing the prior nonblocking review advisory without changing the wire.

## Identity and ordinary Home completion

Change the internal `Actor` to `{PersonID int64; VerifiedAddress *string}`. A local actor has the canonical current verified address; a Home-only actor has nil. Never convert nil to an empty string for normalization, allowlist lookup, grant equality, sample creation or display. Existing standalone and preview actors retain the same IDs and address behavior through adapted constructors. Keep the original authenticated `sessions.Session` and a separate optional Home subject in request identity; the grant address never becomes the Home subject.

Construct `principalstore.Store` with exact configured issuer/team and `RequireMembership` backed by `roster.Check`. Member succeeds only with a currently valid lease. Fresh complete absence maps to `principalstore.ErrRefused`; Unknown or contended/expired lease maps to `ErrUnavailable`. The callback runs before the allocation SQL transaction. Principal allocation stores no standing and creates no documents, samples or grants.

In TeamMode, the Home branch of identity resolution calls `principalstore.Resolve` and uses its optional-address result directly, without loading `people` for Home-only IDs. Resolution is read-only on ordinary requests: a missing identity never allocates from a session. Local session resolution accepts only the actual magiclink/keymail methods, resolves the verified address through the existing people path and disables automatic sample seeding in TeamMode. Standalone/preview retain their current opt-in sample behavior. A linked local principal resolves its Home subject by the exact issuer/person reverse lookup on `docs_identity_links` (already unique on that pair); no link means no roster standing, regardless of the delivery allowlist.

The shared handoff still verifies/consumes the assertion before ordinary completion. Keep the fresh-link branch of `homeBridge.complete` distinct and unchanged in authority. For ordinary TeamMode completion, first resolve an existing principal. If an actual current browser session exists, resolve it read-only and require the same principal; refuse unknown/conflicting arrivals before allocating anything. For an unknown incoming identity with no current session, call `EnsureHome` only after the exact fresh roster callback succeeds. No address matching, EnsurePerson fallback or account merge exists. A concurrent local-link winner causes refusal/retry, not an invented principal.

An existing resolved identity may receive an identity session without deriving document standing from it; its redirected request still undergoes roster/grant authorization. This permits a known linked guest to use an independent grant when team standing is absent. A new unknown Home identity cannot use an email grant because Home has supplied no email. Preserve the original assertion AuthTime, opaque subject, actual session-cookie duplicate checks and existing account-switch conflict checks. Allocation followed by failed session mint leaves one stable principal and requires a fresh handoff; replay is not revived.

`existingPerson` is no longer a general principal resolver: keep a strict local-person helper for dual-auth link binding and add a typed existing-principal helper for ordinary completion/current-session comparison. Home-only identity must not accidentally satisfy fresh local authentication. A Home-only principal cannot later be silently attached to another local account: the approved cross-table triggers refuse it. The local sign-in/link option stays available before first Home-only creation, and rollout pilots must link existing accounts before testing new-person entry. This stage does not promise account merging.

## One permission scope, two independent sources of access

Introduce an unexported request access value binding the resolved actor, the exact Home subject (if known), TeamMode and a canonical roster decision. Construct it after session and identity middleware, before document routes. Call the cache outside SQL transactions. Missing TeamMode access context is Unknown/refused, never standalone fallback. Mutation methods also verify their supplied owner/actor matches this context; possession of a `Document` pointer or arbitrary numeric owner is not authorization.

Use one permission-scope constructor in `grants.go`, parameterized by read/write/owner-administration. All GetFor/ListFor/SearchFor/CanWrite/update queries go through it, including `getDocumentForUpdates`, raw updates, `?since=`, exports and thumbnails reached from lists. Owner access is eligible only for standalone/preview or a currently valid TeamMode member lease. A grant clause is eligible only with a present verified address; writer access additionally requires RoleWriter. Keep `owner_id > 0` and existing nondeleted checks. Nil address emits no grant equality clause at all, so malformed historical empty-address grants cannot be redeemed.

NotMember or Unknown means grant-only query scope: disable the OwnerID alternative even for the actual stored owner. Do not use “has any grant” as a global gate followed by the old full scope. Query the requested document's grant, with the actor's current verified address. Membership never opens another owner's private document, and a roster owner is not every document's owner. Store IDs and grants do not change when standing changes.

Owner administration is distinct from writer access. GET share settings/ListGrants/IsPublished and POST Grant/Revoke/Publish/Unpublish require both a member lease and exact document ownership. Home-only owners can grant to a valid address; replace `addressOf` with the principal store's optional-address lookup and compare for self-grant only when present. A writer grant never grants administration. `CanShare` must include member standing as well as OwnerID, closing the current display/backend mismatch. Create requires a member lease and obtains its owner exclusively from the resolved actor.

For an item that the scope cannot reach, return existing non-disclosing 404 for known nonmembership/ordinary lack of permission; return retryable 503 for Unknown where standing could not be evaluated. Database errors are 503 in TeamMode, never interpreted as absent membership or bad update bytes. Grant-backed item reads/writes succeed independently despite Unknown standing. Member-only collection mutations return 403 for definite nonmembership and 503 for Unknown. A list/search under Unknown may show only individually granted results and a catalog-backed temporary-status indicator; with no applicable grants it answers 503 instead of a misleading empty authoritative list. Known nonmembers may see the grant-only list, including empty. No response reveals whether an inaccessible document exists. Test nonexistent and existing-inaccessible sqids as pairs in every standing state, especially identical 503 bodies/status under Unknown and identical 404 under known nonmembership.

## Mounting and local linking

Preserve app-wide exact-origin/CSRF protection and the single shared assertion callback exception. Public sign-in, callback/finish, signed-out, static and existing public-capability routes stay outside document standing middleware. Public `/p/{token}` keeps its independent hash/token checks and does not gain mutation authority.

Split the current signed-in group. Account-link routes use real session resolution plus existing local delivery/admission policy and strict local freshness; they do not require roster membership. This is necessary for a still-unlinked local account to establish its Home mapping. Home-only sessions route through local reauthentication and cannot create local proof on their own. Keep fixed sealed navigation continuation, mandatory link-init and confirm POSTs, bound pending-cookie/session checks and actual session rotation behavior. This account group exposes no document list, content, share administration or general mutation route. Existing signed-out behavior and deep-link safe return remain intact.

The document group uses real session -> typed principal -> TeamMode access decision. It replaces `requireAdmission(list)` for TeamMode only. The email allowlist and existing HasGrant checks continue to gate local mail delivery and local authentication; they never confer team standing. In explicitly configured TeamMode, an empty AllowedAddresses value constructs an empty nonnil configured-address set (also in development), so nobody is implicitly allowed; actual valid grants can still admit local guests. This is a deliberate exception to standalone/preview production startup refusal for an empty list. A fresh Home-only team must not invent a seed email. Keep existing production mailer and instance-key requirements so local guest delivery remains supported; no logged-mail production fallback is introduced. `handlers.member` becomes presentation-only over the typed decision; backend mutations call the explicit authorization helpers and propagate Unknown separately instead of collapsing it to false. No whole-suite routing or gateway topology is changed here.

## Operation boundaries and streams

A middleware-time check is insufficient. GetFor/ListFor/SearchFor select through the permission scope, and late response emission revalidates access after expensive replay/render/export work. The renderer already buffers templates; attach a bounded response guard to its final write, JSON search output, exports and update-log chunks. Bind the guard to the exact selected document IDs; a member lease that expires cannot silently allow the old list of owned titles to be emitted as a guest response. Before first bytes, failure returns 503/404 as above. After response headers/body have begun, abort that HTTP response using `http.ErrAbortHandler`, preserving its semantics through middleware. Never append an error body or return a clean truncated success. A single Write is not transport-atomic. Bound writes to chunks (64 KiB initially) and check access before each; set exact Content-Length when the complete buffered length is known and bound blocking writes by the remaining member lease. This is a response-boundary rule, not a promise to retract bytes already sent.

This is especially important for updatesGet: it sets Docs-Seq to the full buffered snapshot/tail before writing chunks. `fixtures/editor-entry.js` awaits `arrayBuffer()` before returning bytes/seq, and `fixtures/live-updates.mjs` advances its cursor only after fetch and application succeed; its catch leaves the cursor unchanged for the next event/reconnect. Test actual Chromium and WebKit against a real mid-body HTTP abort, including snapshot, raw and since paths: arrayBuffer must reject, no partial batch/cursor may be persisted or applied as a complete result, and the next authorized notification/reconnect must fetch from the unchanged cursor. Do not count a stub Promise rejection as that browser proof. If framework/proxy behavior turns the abort into clean EOF, activation is blocked until the actual transport is corrected. The server must stop content even when the browser cannot retry because access was permanently revoked.

Before opening a mutation transaction, refresh standing outside SQL if needed. Inside the writer transaction, re-evaluate the exact writable grant or ownership predicate and use only nonblocking lease validation. Check again immediately before Commit. No network or Cache.Check occurs under the writer. The authorization point is the final pre-commit check; bytes already accepted before a concurrent later roster change cannot be retroactively undone. Contention, expired lease or storage uncertainty produces a retryable rollback.

`ApplyUpdate` must recheck after body decoding and CRDT replay, then authorize inside the same writer transaction that appends the update. Move its `documents.updated_at` bump into that transaction under the actor scope; do not retain the current post-commit owner-of-document shortcut. Snapshot creation/pruning triggered by ApplyUpdate is a separate write transaction today: thread the same authorization guard through it and check before its commit too. A lost-authority/Unknown result is not swallowed as an ordinary optional snapshot failure; stop the user mutation and preserve retryable semantics. Pure replay/reseeding of an in-memory CRDT handle does not create durable authority. Preserve snapshot correctness, encryption and gapless sequence guarantees.

Create, Grant, Revoke, Publish and Unpublish likewise put the final owner/member predicate and mutation inside one writer transaction with the final lease check. Existing standalone tests must retain their semantics. For Unknown/expired/contended authority or grant-query storage failure, updatesPost returns 503 before `updateIsClientFault` classification, never broadcasts or returns Docs-Seq, and the browser save queue retains the batch. For definite NotMember with no writer grant, return the existing non-disclosing 404; that is permanent revocation and the current queue drops/reports the refused batch. A retained valid writer grant succeeds regardless of roster absence. Test these cases separately; do not label a definite removal as an indefinitely retryable outage. A committed update keeps its existing idempotent retry behavior for unrelated response failures.

SSE re-resolves the actual session and access for that exact document before the initial event, every update event and every heartbeat. This includes signout/session revocation, grant removal and current roster age. Use a timer at the current member lease deadline; at expiry obtain a new decision outside SQL and either re-arm from that new lease or close. A failed refresh cannot keep the old stream open. If the specific document remains individually granted, the stream can continue as a guest with grant/session rechecks before each emission. No standing timer is manufactured for a grant, and the existing bounded heartbeat/connection TTL remains. Silent streams with member-only access close by lease expiry. Test immediate per-event generation fencing and grant removal as well as timer expiry; do not infer them from connect-time tests.

## Release floor and acceptance

The principal store's old/new/old/new proof establishes schema/ID/content compatibility. It does not establish authorization-safe rollback after TeamMode activation. In particular, `5fe0447` or its `3ed7665` lineage can grant an allowlisted removed local person OwnerID access without a roster. The earlier principal design's rollback paragraph must be read with this narrower limit. Once TeamMode serves users, rollback must use a reviewed TeamMode-aware binary with the same mandatory-mode marker and standing/grant rules. Otherwise supported maintenance must keep document traffic closed. Clearing Team configuration, deleting markers or revoking only Home sessions is not a safe rollback procedure. This design authorizes no raw production DB edits.

Implement in bounded reviewed branches: (1) the shared nonblocking-lease amendment and tests; (2) Docs typed identity/principal/schema/durable Team runtime plus callback/account-group integration; (3) unified permission scopes, late write/read guards and stream behavior; (4) full actual-router/browser/rollback canary with immutable configuration. No TeamMode activation between identity allocation and the complete permission stage. Source can remain unmounted until all pieces are combined. Every code branch requires the repo gate, meaningful mutations, actual exact-SHA CI and explicit Fable review.

Acceptance uses two local people with unchanged owned/shared/private documents, two Home-only subjects, two separately pinned teams and actual captured-mail local authentication plus real signed handoffs. Cover existing-link ID continuity, optional nil address and malformed empty grants, unknown principal refusal on every roster failure, explicit first document creation, Home-only owner sharing to an address, current-session conflict before allocation, issuer/config/mode-marker mismatch and missing Team config, unchanged AuthTime and content bytes/encoding. Keep local linking reachable while unlinked and prove it grants no document standing before a verified roster decision.

Exercise every read/write/export/search/share/update/raw/since route under Member, NotMember and Unknown. A former owner with one unrelated grant must reach only that grant's document. Expire a lease during body read, replay, transaction acquisition, final response rendering and silent SSE; verify no unauthorized update/snapshot/admin commit or late content emission. Make roster refresh wait on the actual SQLite writer to prove the TryLock amendment and retained edit retry. Revoke an individual writer grant while a POST waits for the writer, then prove the transaction's grant predicate refuses it. Mutations remove nil-address checks, OwnerID exclusion, the final write guard, share GET standing, stream timer and Unknown retry classification; each must fail for its intended behavior.

Before activation, run current ID/FK census, exact TeamMode-aware binary rollback and restored-copy drills with preserved keys/content. Record live canonical X-Carlos-Version and bounded revocation observations on two isolated team instances. The initial contention canary is two active editors posting twice per second for 120 seconds against a healthy directory: zero deadlocks/fetch timeouts/lease-expiry outages, fewer than 1% retryable contention responses, and every retained batch committed within 10 seconds after load stops. These are initial engineering acceptance thresholds, not a production SLA; changing them needs a recorded review before activation. The all-refresh-failure canary must still stop member-only operations by the 60-second lease ceiling. Production restore still depends on supported exclusive maintenance, session/handoff replay reset and owner-confirmed directory generation; platform discussion 117 and operator-delivery gates remain external prerequisites. A synthetic signed roster or a passing local migration never substitutes for them.
