oficina / home Public

Design authoritative directory and team entry stages

Paul Campbell pushed by paul@keymail.dev 24bfeb60fd9e56ec0934c0011449166b884cb2a6 parent cc61976
1 file changed, +65 −0
  • implementation/team-directory.md +65 −0

CI passed — run details

diff --git a/implementation/team-directory.md b/implementation/team-directory.md
new file mode 100644
index 0000000..a0f60a7
--- /dev/null
+++ b/implementation/team-directory.md
@@ -0,0 +1,65 @@
+# Authoritative team directory and app entry
+
+Status: proposed implementation design, based on Home cc61976. Independent review pending. This is an internal implementation plan; no deployed feature is claimed. It preserves Home spec rulings 17, 19, 22, 23 and suite doctrine §5.3. The user accepted team subdomains and waived copy review for this round.
+
+## Source audit and scope
+
+`internal/home/directory.go` currently provides a signed-in placeholder, shared assertion consumer and sign-out. `runtime.go` applies only sessions and handoff schemas to the directory role. `models.go` applies credential people/passkey schemas only to the credential role. There are no teams, memberships, app registry, consumer bearer token, roster or drain implementations. Add a separate directory migration set; do not apply credential migrations or copy the pepper to the directory.
+
+`implementation/team-entry.md` already approves the assertion transport. The credential origin knows identity only; the directory renders account-authored labels. The Home spec §11 requires normal tiles to union membership-derived instances with the viewer's own directory rows, including former members retaining individual grants. The app entry chooser in this bounded stage lists active memberships with a live instance of the requested app. Existing individual-grant links remain usable directly, and later directory-row tiles must not be mistaken for roster authority.
+
+The platform source `instances_cli.go` provides console-authorized instance creation and restricts customer instances to the account's platform subdomain. `domains_cli.go` and `routes_cli.go` concern routing, not application membership writes. The supported session CLI additionally provides environment binding. None is a Home operator transaction or evidence that a running application database can be modified under a maintenance fence. Platform discussion 117 is the known missing maintenance/drain capability; confirm its actual shipped status before implementing any live operator step. Do not substitute remote SQL, a fixture-writing web route, AWS access, or a provisioning credential in Home.
+
+## Directory records
+
+Add forward-only SQL migrations, scoped to the directory role:
+
+- `teams`: integer ID, unique sqid, unique normalized DNS label, sealed display name, creation time. Reserve labels suite-wide; enforce safe single DNS label syntax and the reserved-label list in one package. Encrypt display names before SQL binding, with purpose including immutable team ID. Labels and opaque graph IDs are intentionally readable; display names, return paths and provisioning payloads are not.
+- `team_instances`: unique (team ID, app slug), unique exact HTTPS origin, registered assertion consumer ID, lifecycle state (`pending`, `live`, `disabled`). Only live records appear in app entry. Both origin and consumer registration must match reviewed operator configuration. Production integrated origins are derived from label and compiled app slug. A separate explicit immutable canary manifest may map synthetic team IDs to owned platform preview origins; no request selects arbitrary hosts.
+- `memberships`: primary key (team ID, opaque credential person ref), checked role owner/member. No email and no foreign key into the separate credential database. Membership changes increment the team's integer epoch in the same transaction; refuse overflow.
+- `roster_state`: one installation generation and per-team epoch. Generation is random, persisted, and never changed by ordinary startup. An explicit supported restore procedure changes it before traffic resumes. Ordinary deployment cannot detect an externally restored database reliably; absence of a restore fence is a production gate.
+- `consumers`: opaque ID, app, team ID, exact origin, hashed bearer token, enabled state and token rotation metadata. This is the call-8 authority, independent of the credential issuer's public consumer manifest. A consumer cannot name a different team.
+- `operator_receipts`: unique operation ID, action, team ID, opaque operator reference, outcome and timestamp; no address/name/token/payload. Store success atomically with its mutation. Audit failures without reflecting input.
+
+The registry is compiled Go configuration, as spec §11 requires: slug, display name, integrated and provisionable flags, foreign URL only for unintegrated apps. Start Docs integrated only when its consumer has shipped. Do not advertise an unimplemented app as ready. Use Estilo theme and existing Home layout/classes; a shared choice-list partial supports app entry and the ordinary team switcher. No JavaScript is required.
+
+## Entry and return binding
+
+The common Docs entry redirects to the directory's fixed `/apps/docs`. Anonymous access starts the directory's existing handoff with a validated local return to that exact route. Do not rely on `sessions.Require` implicitly retaining paths: add an explicit wrapper that constructs the shared consumer's local return parameter, and test the resulting flow. The request carries an app slug, never an arbitrary destination origin.
+
+On GET `/apps/{app}`, strictly validate the compiled slug and optional opaque team sqid. Read live instances joined to the current subject's membership. Zero teams renders an empty state. Exactly one redirects to that instance's `/auth/start` with its bounded local return. Several render a choice form. A POST selection uses normal same-origin CSRF protection, validates a single team field, and repeats the membership/live-instance query in the final transaction. Removed or unknown teams receive the same 404. Selection does not modify the identity session or a global current-team cookie. Home team views retain explicit `?team=<sqid>` as ruling 22 requires; two tabs must remain independent.
+
+Generic common-host entry returns only to the app root. Resource return belongs to the originating team: a team Docs origin first stores its full validated local path in its own sealed pending handoff, then authenticates directly against the credential origin. It does not send a document path through the directory. If a directory detour is later needed, use an opaque, expiring consumer-owned continuation bound to the exact registered team and browser; do not concatenate a request URL onto the selected team's origin. This stage deliberately needs no cross-team resource transfer protocol.
+
+The production bare Docs host is currently a live legacy instance alias. Keep it and every old document link unchanged until the original instance has a verified team alias and a separately reviewed compatibility routing rule. Do not turn it into this generic entry merely because the preview chooser works. Preview common entry uses a dedicated owned origin.
+
+All authenticated pages, redirects, roster responses and errors send no-store. Enforce this at the directory route boundary and test it on failures too. No resource paths, account labels or authentication material enter logs, metrics or URLs on the credential origin.
+
+## Bootstrap across the role split
+
+The old single-Home spec describes an address-to-person seed inside Home. The deployed split makes that two authorities: only the credential process can resolve an address with its pepper; only the directory writes a team roster. Implement explicit operator verbs behind the supported drain boundary. This is an adaptation of the settled seed rule, not permission to share a pepper.
+
+1. The human-operated drain selects an approved queue request and reserves its label. For a synthetic pilot use two clearly synthetic names and already-authenticated synthetic identities; no guessed real team label and no outgoing mail. A fixture may create test identities only in disposable local tests. A live pilot still uses actual synthetic authentication and the normal audited operator path.
+2. Credential-side operator resolution receives the sealed request address through private input, resolves/creates the normal person stub, and returns only opaque person ref plus an authenticated, short-lived seed receipt bound to operation ID, proposed team ID and intended directory origin. It must not log or persist the unsealed address. The drain never receives a pepper.
+3. Directory-side operator seed verifies the receipt against pinned credential operator verification material, checks expiry and operation replay, and atomically creates the team and its first owner. The receipt uses a separate protocol/purpose from browser assertions. A browser assertion is never enough to make an owner. A sibling app provision creates no membership.
+4. The external drain uses the supported platform CLI to create two isolated Docs instances, assigns distinct databases/keys and consumer bearer credentials, registers exact origins with the credential issuer, deploys a reviewed Docs consumer, and verifies actual binary body/header versions. Only then does the directory mark each instance live through the audited operator verb. Repeated operation IDs return the original result; a changed payload under the same ID is refused.
+
+For implementation sequencing, first build and test this operator protocol and directory transaction with an injected operator-authority verifier. Do not expose that verifier as a public web bootstrap endpoint. Live deployment waits for the supported fenced operator transport; the protocol is not a claim that this transport exists. Tests may call the same transaction locally without changing production authorization. The exact platform invocation and receipt key distribution require their own source-backed integration review before operator code ships.
+
+## Membership writes and roster contract
+
+Owner changes are same-origin directory POSTs. Require a fresh shared assertion round trip with max_age=300, bind it to the initiating subject, current directory session token hash, team and action digest, and consume that intent once. Recheck the actor's current owner role within the membership mutation transaction. Require the submitted action to match the sealed digest and prevent removing/demoting the last owner. Fresh authentication proves identity, not ownership. Call 2 and consumer tokens cannot write membership.
+
+Call 8 is a bearer-authenticated GET, scoped from the consumer record rather than a caller-selected team. Unknown team and foreign team both return 404. Return versioned strict JSON: generation, per-team epoch, sorted members `{person_ref, role}`, explicit complete flag and optional cursor. Bound each page to 100 entries and 64 KiB, and each team to 10,000 members for this pilot. A cursor is authenticated and bound to consumer ID, team, generation, epoch, last person ref and expiry. A concurrent roster write invalidates the cursor; no mixed epoch can complete. Only an explicit complete response represents an empty roster.
+
+The shared consumer roster package stages pages outside its active snapshot and publishes the complete generation/epoch atomically. Initial generation is pinned by the approved provisioning record, not trusted from an arbitrary first response. A changed generation fences standing immediately until fresh owner confirmation at the directory produces a receipt scoped to this consumer/team/new generation. Replaying confirmation for another generation or consumer fails. Lower epochs fail; same epoch responses may refresh age only after a complete authenticated refetch, never a partial response or a hint.
+
+Proposed pilot policy: refresh after 30 seconds, hard maximum 60 seconds from the start of the successful complete fetch, 5-second total fetch deadline, at most one in-flight refresh per instance and bounded staging. These are engineering starting values, not settled doctrine numbers; measure removal propagation and hibernation wake behavior before approval for production. Every request checks age, including existing sessions and background standing use. Failure never advances fetched-at. Process restart starts stale and refetches; durable roster bytes may survive only for restore evidence, never to invent a fresh timestamp. Clock rollback fences standing rather than extending age. Individual grants remain separate and usable under their own rules; synchronisation callers receive UNKNOWN when standing cannot be determined.
+
+## Boundaries and stages
+
+Docs owns opaque Home subject to existing local PersonID linking, new Home-only principals, existing grant resolution, and all item authorization. The directory never resolves a document owner, invents an email, or supplies item grants. Root's approved Docs linking design remains binding. Publish the roster client as a small shared package after review so Sheets and other apps use the same expiry/generation rules.
+
+Stage A implements directory records, compiled registry, chooser and local operator transaction tests. It may show synthetic local screenshots but cannot call the live flow ready. Stage B implements and verifies the fenced seed/provisioning transport and two isolated Docs instances. Stage C adds roster producer/client, owner step-up and real two-team browser acceptance. Stage D reviews legacy-host compatibility and restore rehearsal before any production entry cutover.
+
+Required evidence: schema/privacy scans over DB/WAL and logs; duplicate/reordered bootstrap operations; no seed by visitor or sibling consumer; malicious labels/origins; zero/one/two-team choice; stale choice removal; guest and ex-member tile union; two tabs; exact resource return on original team; all pages with JS off; Chromium/WebKit narrow/wide; fresh-owner replay/changed-action/last-owner tests; multi-page mutation and explicit empty roster; deadline failures without age extension; existing-session revocation by 60-second ceiling; clock rollback/restart; lower epoch and changed-generation rejection; restored directory requiring explicit owner confirmation; Docs old owner/grant preservation. Each implementation receives actual CI and independent Fable review, then a verified canary before merge. No production readiness claim until these gates pass.