oficina / docs Public

Pin TeamMode response aborts and activation edge cases

Paul Campbell pushed by paul@keymail.dev 75818ad99288a7411de0257c196828db2da322b4 parent e065b88
1 file changed, +11 −9
  • implementation/team-mode-integration.md +11 −9

CI passed — run details

diff --git a/implementation/team-mode-integration.md b/implementation/team-mode-integration.md
index 4bb3538..6b00e3c 100644
--- a/implementation/team-mode-integration.md
+++ b/implementation/team-mode-integration.md
@@ -1,6 +1,6 @@
# 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. Independent Fable design review is required before implementation.
+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
@@ -18,7 +18,7 @@ Add `Config.Team *TeamConfig` beside existing `Config.Home`. Nil Home/nil Team p
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. It cannot constrain older binaries that do not read it; the release floor below is mandatory.
+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.
@@ -28,13 +28,13 @@ There is no background cron: request/stream demand drives the canonical cache. N
## 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. Calling the lease helper “local-only” does not remove this ordering.
+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.
+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
@@ -60,7 +60,7 @@ NotMember or Unknown means grant-only query scope: disable the OwnerID alternati
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.
+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
@@ -68,17 +68,19 @@ Preserve app-wide exact-origin/CSRF protection and the single shared assertion c
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. `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.
+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, stop immediately; never append an error body or advance a cursor beyond the bytes actually delivered. This is a response-boundary rule, not a promise to retract bytes already sent.
+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. On denied/Unknown updates, return 503 before `updateIsClientFault` classification, do not broadcast or return Docs-Seq, and prove the browser save queue retains the batch. A committed update keeps its existing idempotent retry behavior for unrelated response failures.
+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.
@@ -92,4 +94,4 @@ Acceptance uses two local people with unchanged owned/shared/private documents,
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. 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.
+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.