oficina / home Public
Authenticated roster snapshots and bounded team standing
Status: proposed implementation design by Codex, 2026-09-08; requires independent Fable design review before code. Source bases: approved team-directoryfa64e286990762aecc6913bb9076a3cb75b088c3, directorydata4c0d84811a018c53a47b604a0de5885baec0771e, chooser07043915daebae10512a710915263093ed793b5b, Home spec call8 and restore ruling23, suite doctrine5.3, Docs1e862f80752004d16db98693c25d4c693dc19510. This is an internal implementation contract, not an operator runbook or a deployed feature.
Existing boundaries
Directorydata currently has teams.rosterepoch, memberships and teaminstances. It has no consumer credential table, installation generation, roster HTTP handler or operator transport. The chooser reads membership for navigation; it neither authenticates an instance nor authorizes documents. The directory alone owns team records. Credential Home owns person refs and browser assertions; neither role receives Docs content, and the instance receives no Home pepper.
Docs currently uses the linked-identity adapter but still uses a local email allowlist for membership/admission in its account-link preview. It has no authoritative roster. Its readable/writable scopes include OwnerID automatically, and stream.go checks access only at connection establishment. These are named integration obligations below, not evidence that adding one front-door middleware completes revocation.
Use a separate small nested module amadan.net/oficina/home/roster for wire, verification, producer handler interface and consumer cache. Reuse the already published canonical Rastrillo crypto.Sign/Verify, which sign SHA256(context NUL message) using raw64-byte P256 signatures; do not place rosters into browser assertion claims or copy signing arithmetic. Use the shared handoff/seal interface for cursor/cache envelopes. Home directorydata supplies the authoritative read transaction; Docs supplies item permissions. Keep the package independent of GORM, app sessions and user-visible copy.
Provisioning and generation authority
A consumer starts only with an explicitly verified provisioning record containing version, operation ID, strictly increasing record sequence, credential issuer origin, directory origin, consumer ID, app slug, team sqid, app origin, installation generation, minimum epoch, roster verification key set and its own bearer credential delivered separately. Origins are exact canonical HTTPS origins; registered app/team/origin must match the compiled production policy or separately approved immutable pilot manifest. No request supplies these pins. No trust-on-first-response path exists.
The provisioning record is signed with a separately pinned directory operator verification key using context oficina/roster/provisioning/v1. That trust key is outside call8 and outside the record it verifies. The verifier returns an opaque validated record to the cache constructor; unsigned configuration or a call8 field cannot construct it. Initial records come from the audited seed/provisioning operation. A new-generation record additionally refers to the audited fresh-owner confirmation operation for that exact consumer/team/generation. Record replay, lower sequence, changed payload under an existing operation/sequence, wrong consumer/team/origin and unknown signing key refuse. Any accepted record change fences the previous cache and discards in-flight staging before refetching; a new generation resets the epoch floor only as authorized by the new record.
Directory generation is random32-byte canonical base64url, persisted once by audited installation bootstrap and changed only by the supported restore procedure before traffic resumes. Ordinary startup never guesses that a restore happened and never generates a new value. The schema migration creates an empty generation table, not a valid generation. Serving call8 refuses until audited provisioning fills it. A signed call8 response with an unfamiliar generation immediately fences standing but cannot approve the new generation. Only the reviewed operator record update can do that.
This module may verify supplied records and test producer transactions with injected verified operator authority. It adds no public bootstrap route and no runtime operator mutation transport. Live provisioning/restore remains gated on supported fenced maintenance (platform discussion117) or a separately reviewed genuinely fresh first-boot path. Neither a browser assertion nor the first visitor nor a sibling bearer token can seed an owner, generation or consumer. Existing-db remote SQL and manifest-wide first-boot assumptions are excluded.
Producer authority and token lifecycle
Add directory-only migrations for generation, consumers and successful operator receipts. A consumer row binds its opaque ID to exactly one team/app/origin and stores SHA256 hashes of random32-byte bearer tokens, never plaintext tokens. Current and previous token hash slots permit an explicit rotation overlap of at most five minutes; expired previous tokens and disabled consumers fail. Rotation and disabling are audited operator mutations, not call8 writes. No browser session substitutes for a token.
GET /roster accepts exactly one Authorization: Bearer header with canonical43-character base64url token. First requests contain exactly team and fetch query fields; subsequent requests additionally contain cursor. fetch is a newly generated random32-byte challenge for this refresh, not an identity or secret. Limit URL/query to4096 bytes, team to64 alphanumeric bytes and cursor to2048 encoded bytes. Refuse duplicates/unknown fields, alternate encodings, malformed tokens and methods before database work. Error bodies are fixed and contain none of the supplied values.
Resolve the consumer from the token, then compare the requested team to that row. A foreign team and an unknown team both return identical404. Invalid/disabled/expired token returns the same401. The handler's configured Host must match the exact directory origin. Every response including failures has Cache-Control: no-store; logs/metrics contain only fixed operation/status labels, never tokens, person refs, labels, cursors or request URLs.
Within one SQLite read transaction, resolve the token and its enabled/rotation state, live team-instance binding, current generation, team epoch and the ordered membership page. All membership writes increment that same team's epoch atomically and refuse overflow. Other teams' epochs never enter the response. A producer transaction cannot mix authorization at one state with members at another. Return at most100 members, fetched with LIMIT101 to determine whether more remain, sorted bytewise by canonical personref. Person refs use directorydata's exact pr plus26 lowercase base32 characters; roles are owner/member. Reject invalid stored rows, not merely the bad suffix. A complete empty roster is a successful page with members=[], complete=true and no next cursor.
The cursor is a purpose-bound AEAD envelope containing version, consumer ID, team, generation, epoch, fetch challenge, last person ref, next page index and expiry. Bind its purpose to the directory origin; expiry is no later than ten seconds after issuance. Validate all fields and current generation/epoch on every next-page request. A changed membership epoch invalidates the cursor; no server-side retained roster snapshot is needed. A concurrent change yields authenticated409 stale-cursor with current generation/epoch, never a mixed page. This also means a busy changing roster can refuse refresh; the consumer's maximum age still expires.
Signed page envelope
A roster-only directory P256 key signs responses, separately provisioned from credential/browser assertion signing keys. Verification keys come only from the operator record, maximum8. An exact response envelope has v, kid, payload and signature. payload is canonical base64url of bounded UTF8 JSON; signature is canonical base64url of raw64 bytes. Verify crypto.Verify(publicKey, "oficina/roster/page/v1", payloadBytes, signature) before using any payload field. Parse envelope and payload with strict closed fields, duplicate-name rejection, integer-only numeric fields and bounded strings. Limit complete HTTP body to64KiB before decoding or crypto; no HTTP compression expansion, redirects or content-type alternatives.
Success payload fields are version, type=page, directory, consumer, team, audience, fetch, requestcursorhash, generation, epoch, pageindex, members, complete and cursor. Bind directory/consumer/team/audience to the provisioning record, fetch to this refresh, and requestcursorhash to SHA256 of the exact incoming cursor string (empty on page0). pageindex starts0 and increases once per cursor. Nonfinal pages have exactly100 members and a nonempty cursor; final pages have0..100 and no cursor. Epoch is a positive signed64-bit integer. The client rejects duplicate/unsorted person refs within or across pages, nonprogressing cursors, unknown roles, more than100 pages or10000 members, and any changed generation/epoch/scope inside one refresh.
The signed409 payload has the same scope/challenge/request-cursor binding and only type=stale-cursor, generation and current epoch instead of membership fields. It cannot publish an empty roster. An unfamiliar generation in an otherwise valid signed response fences standing immediately. An invalid signature, transport error, unsigned status or malformed body cannot update any pin or epoch; it is a failed fetch. HTTP status must agree with signed payload type. No304/unchanged shortcut exists: only a full authenticated complete refetch can renew the same epoch's age.
The client dials only the pinned directory origin with the consumer token in Authorization, standard verified TLS and a no-redirect transport. One refresh has a five-second total context deadline across every page, bounded body reads, and at most one in-flight refresh per instance. A fresh per-refresh challenge prevents replayed signed pages from becoming a fresh observation. Cursors do not select a destination; the next request always uses the same pinned /roster URL. No page/redirect can cause a cross-team request or carry the bearer to a different host.
Atomic cache and expiry
A client starts with no usable roster, including after process restart. It stages verified pages privately, then atomically publishes a complete immutable snapshot and its lease under a mutex. Failure leaves the previous bytes intact without changing their lease. No accessor can see staging rows. Persist an authenticated complete snapshot plus accepted record sequence, generation, epoch and digest as bounded restore evidence before publication; do not persist a timestamp that becomes a fresh lease at startup. At most1MiB of canonical member payload and10000 rows are retained. If persistence fails, do not publish or advance age. Lower epoch within the pinned generation refuses. Same epoch with a different member digest refuses. A provisioned minimum epoch and any newer durable floor apply even after restart.
The initial engineering policy, inherited from fa64e28, is refresh due30 seconds after the start of the last successful complete fetch and a hard expiry60 seconds after that start. The five-second fetch time is included in the60 seconds, not added afterwards. Use Go monotonic time for in-process deadlines; never refresh age from page completion, hint, partial response, failed fetch or process startup. Compare wall-clock observations too: a detected backward adjustment fences current standing and requires a fresh complete challenged fetch before restoring it; an old durable timestamp never restores standing. Existing in-flight leases from before that fence become invalid. Monotonic time protects the new lease from the wall-clock adjustment itself.
Check is tri-state for the exact configured issuer/team/subject: Member(role, lease), NotMember, or Unknown. Missing/invalid provisioning, unapproved generation, cold/expired cache and failed cold refresh are Unknown. A complete usable snapshot lacking the subject is NotMember. On requests at or after30 seconds, one refresh starts; other callers may use the old snapshot only before its hard deadline. At or after60 seconds they wait within the shared five-second budget or receive Unknown. Retry/backoff never changes the deadline; cap retries to one start per five seconds and one flight. Refresh is demand-driven on wake/requests and long-lived stream use, not an independent cron keeping the instance alive.
A lease includes snapshot/record version and monotonic deadline. Its validity is rechecked immediately before each protected side effect or emitted stream event; a generation/record/clock fence invalidates old leases immediately. Deadline passed means no team standing, including already-signed-in sessions and background/sibling work. A removal just after a successful snapshot therefore takes effect no later than that snapshot's60-second ceiling even when every refresh fails. The implementation must measure this deadline in tests and canary; it is not a production latency claim yet.
Do not add roster_epoch to current assertion wire version1 in this change: it has no such field. Future call2/call5/assertion hints can request one bounded refresh only; they cannot update membership, advance lease age, select a generation or directly deny a person. Call7 never invalidates from a hint. Signed complete call8 data, not a claim, determines standing.
Docs integration and principal allocation
Introduce explicit provisioned TeamMode alongside the existing standalone and limited account-link preview modes. Standalone remains local and never dials Home. The existing preview remains explicitly a compatibility preview, not authoritative team mode. TeamMode requires the verified operator record, token, exact Home identity issuer and roster client; partial config refuses startup. The Home chooser must not mark an instance live for general team entry until this configuration and its acceptance gates are satisfied.
The principalstore callback agreed with the Home-principal author receives exact issuer/team/subject. Map a complete fresh nonmember to ErrRefused and cold/stale/unavailable/unapproved-generation to ErrUnavailable. Invoke Check before opening its SQL allocation transaction; do not perform network work under a writer lock. Allocation grants no permission and stores no role, timestamp or lasting standing receipt. Existing principal mappings survive removal; every actual document access rechecks the current cache or its separate grant. The callback result is one bounded observation, not permanent authorization.
For Home sessions, use the already verified opaque subject and exact credential issuer. For linked local sessions, resolve the existing person's unique identity link in reverse to obtain the Home subject; an email allowlist cannot substitute for that mapping or for membership. A local person without a link can still use the explicit fresh local account-link settings path under its existing local admission policy, but that path must be mounted separately from document/team admission so linking does not require the standing it establishes. Local delivery allowlists remain delivery controls, never TeamMode member lists.
Per-request authorization carries the typed Actor plus tri-state team standing. Full member powers (create, share/revoke/publish administration) require a currently valid member lease; the roster owner role does not make someone the owner of every document. Existing OwnerIDs and item grants are preserved and remain item-level checks. Fresh membership never makes another person's private document readable.
When standing is NotMember or Unknown, only independently verified individual grants may reach documents. Add a grant-only query mode to readable, writable, GetFor, ListFor, Search, CanWrite and update transactions: the ordinary OwnerID shortcut is disabled in that mode. Otherwise a former member with one unrelated guest grant could pass global admission and reopen all their owned documents despite stale standing. This changes no stored OwnerID or grant. Unknown without an applicable grant is a retryable503/UNKNOWN, not a fabricated membership denial or permanent save failure. Known nonmembership without an applicable grant keeps the existing non-disclosing404/403 behavior. Global admission cannot merely ask whether any grant exists and then restore the full actor scope.
Recheck the individual grant for the requested document on every access. Existing address grants use the linked verified local address, never the Home subject; a Home-only principal with no local address does not inherit email grants. New Home-only guest identity allocation or new grant kinds are outside this change. Public read-only capability links keep their own independent token checks and confer no membership. No team-visible document rung is added merely by shipping roster admission.
Long-lived SSE must use the same authorization decision before every event/heartbeat and terminate on expired/unknown standing unless that exact document remains independently granted. A timer at the current lease deadline closes an otherwise silent stream; it is not enough to check only when the HTTP handler opens. Request-body decoding can cross an expiry: recheck the lease immediately before update/create/grant commits. Keep network refresh outside SQL write transactions; local lease validity is an in-memory check, and failure aborts the transaction before commit.
Bounded implementation and acceptance
Implement in four reviewable pieces: shared protocol/record verification and cache with deterministic clock/transport tests; directory producer and token/epoch/cursor store under unmounted audited-configuration seams; Docs TeamMode admission/query/SSE adapter with the principalstore callback; only then provisioned runtime wiring and two isolated-team canary. Every piece gets full relevant tests, actual CI and independent Fable review; producer/store tests do not authorize a live seed.
Required failures: missing/unsigned/foreign/replayed operator record; changed generation on first/later page; wrong key/context/token/scope/challenge/cursor; consumer rotation/disable; unknown and foreign team identical404; mixed epochs, same-epoch changed digest, lower epoch/restart, reordered/duplicated/truncated pages, explicit empty roster and the10000-member bound. Assert no token/ref/label/path leakage to logs or unexpected outbound hosts. Verify no partial publish, no age extension after any failure/304/hint, hard60-second removal under all-failure refresh, restart and backward-clock fencing, one flight under concurrency, and staging cancellation on provisioning-record update.
Docs tests must keep an existing session open through removal/expiry, distinguish grant-only access from OwnerID bypass, preserve owned/private and explicitly shared item boundaries, enforce create/share/update expiry, close SSE at the lease deadline, retain legacy owner/grant/content bytes, and show unknown membership refuses Home-only allocation without creating a principal. Two teams and two tabs remain isolated. Mutations must expose missing signature/challenge, early staging publish, timestamp renewal on failure, unchecked OwnerID fallback, and stream/commit checks omitted. Authenticated-generation restore and the supported operator delivery path remain production gates; a synthetic test fixture is not an owner or provisioning authority.