Bind seed identity and preserve owner step-up session
1 file changed,
+12
−8
implementation/team-directory.md+12 −8
CI passed — run details
diff --git a/implementation/team-directory.md b/implementation/team-directory.md| index a0f60a7..914f0f6 100644 |
| --- a/implementation/team-directory.md |
| +++ b/implementation/team-directory.md |
| @@ -1,6 +1,6 @@ |
| # 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. |
| +Status: revised implementation design, based on Home cc61976. First independent review requested claude-fable-5 and returned CHANGES REQUIRED; the response self-identified as Sonnet 4.5, so reviewer identity/capability is unresolved and this is not an approval. Corrections below require a new qualified review. 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 |
| @@ -14,7 +14,7 @@ The platform source `instances_cli.go` provides console-authorized instance crea |
| 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. |
| +- `teams`: AUTOINCREMENT 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. |
| @@ -33,26 +33,26 @@ Generic common-host entry returns only to the app root. Resource return belongs |
| 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. |
| +All authenticated pages, redirects, roster responses and errors send no-store. Enforce this at the directory route boundary and test it on failures too. Neither role logs addresses, account display names, resource paths, provisioning payloads or authentication material, including failures and metrics. Credential URLs carry no identity, account labels or resource paths. Extend the DB/WAL/log privacy gates to the credential seed unseal-and-hash path and the directory receipt/seed path. |
| ## 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. |
| +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 carrying the resolved person ref and fixed owner role, 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 using only the verified receipt’s person ref and role, never a sibling input field. 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. |
| +Owner changes are same-origin directory POSTs. Use a distinct sensitive-operation consumer completion path, not the ordinary callback that replaces the directory session. Bind the pending intent to the initiating subject, actual existing directory session token hash, shared consumer-generated nonce, team and action digest. The sensitive Complete callback checks those bindings against the still-current session and records only the verified fresh proof in the sealed intent; it does not call sessions.SignIn or rotate the session. A changed or expired session requires a new intent. Consume the intent once at confirmation. The shared StartFresh/BindStart consumer extension is a prerequisite on a separate reviewed branch; do not emulate its replay/freshness policy in the directory. Credential max_age=300 support already exists at this base in assertion_fresh_test.go and exchange.go via the fresh-assertion stage merged before cc61976: it forces actual re-verification and preserves second-factor/recovery completion gates. The older team-entry.md transport narrative is stale on that addition, not proof that max_age is unsupported. Require integration tests that prove a stale credential session reauthenticates, the directory token remains unchanged through this sensitive path, and wrong-subject/expired/changed-token intents fail. 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. |
| +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. Deliver confirmation through the audited operator boundary as an update to the consumer’s pinned provisioning record; verify with a separate pinned directory operator verification key. The transport and key lifecycle remain part of the operator integration review, not an unsigned call-8 response field. 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. |
| @@ -62,4 +62,8 @@ Docs owns opaque Home subject to existing local PersonID linking, new Home-only |
| 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. |
| +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 when call-2/directory-row implementation ships (deferred from this membership-only chooser stage); consumer bearer-token rotation with overlap/removal and fixed errors; 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. |
| + |
| +## Audit follow-up |
| + |
| +Platform discussion117 was read on 2026-09-08 and remains open with only the coordinator follow-up; no implementation is recorded. Platform origin/main dcf0cad confirms per-instance environment/channel creation. Its existing first-boot Seed command is app-manifest-wide and runs only for a genuinely fresh database with no replica. A separately reviewed all-new synthetic directory could use that narrower first-boot path, with exact environment and role guards and audited provisioning input. It cannot seed the existing overnight-directory database, and enabling it must account for every future Home route inheriting the manifest. This alternative is not authorized by this design and is not grounds to bypass the existing-DB fence. |