# Directory roster HTTP and signing integration

Status: design-only; review record in Home discussion 34. Explicit Fable design
approval is required before implementation.
Scope: Home discussion 34, stacked on directory runtime 171f49a, shared roster
667dc82 and producer 32fb49c. The approved roster contract (discussion 28) and
producer storage contract (discussion 33) remain authoritative. This design adds
runtime wiring and an HTTP adapter; it adds no operator mutation or provisioning
transport and makes no deployment/readiness claim.

## Source audit and boundaries

`runtime.go` currently merges sessions, handoff and directorydata.Schema only for
the directory role. The credential role alone owns `Runtime.signer` and
HOME_ASSERTION_KEY. Directory configuration holds credential public verification
keys and its own sealer; `newDirectory` rejects a credential private signer.
`directoryRouter` puts sessions middleware around the browser routes.
`directoryFeatures` already supplies the compiled app registry and validated
team-origin policy. Reuse that registry and policy when constructing the producer;
do not duplicate per-team rows in environment configuration.

The published client builds GET directory+/roster with `url.Values.Encode`, a
Bearer token, Accept application/json and Accept-Encoding identity. It refuses
redirects, compression, bodies over 64 KiB and Content-Type values other than
exact application/json. It checks signatures before using page fields. Page-zero
CursorHash is SHA256(empty), not an empty string. There is no epoch query or 304
shortcut. Wire members use person_ref. The pending Docs cache lease amendment
changes no Page/SignPage contract; this adapter remains compatible with 667dc82.

## Runtime and schema

Merge directorydata.RosterSchema immediately after directorydata.Schema in the
directory-only schema. Credential and legacy schemas must not gain these tables
or membership triggers. Applying this migration leaves generation, consumers,
tokens and receipts empty. Repeated startup never fills them.

Introduce HOME_ROSTER_MODE with absent/disabled and serve values. In directory
mode, absent/disabled plus an absent HOME_ROSTER_KEY means a disabled endpoint.
The directory browser routes and boot remain available; every request that passes
the roster host/method guards receives the same fixed 503 before inspecting any
token, query or database. There is no standalone roster fallback.

Serve requires HOME_ROSTER_KEY. Unknown mode, key supplied while disabled, missing
serve key or invalid key refuses startup with one fixed configuration error.
Credential and legacy roles reject either roster environment variable when set,
including an explicit disabled value. Direct Runtime constructors enforce the
same mode/key invariants; LoadRuntime is not the only guard.

Represent roster configuration separately, for example a private rosterRuntime
field with mode, signer and derived key ID. Keep Runtime.signer credential-only
and preserve newDirectory's existing rejection of it. Directory now deliberately
owns one new roster-only private key; update role comments, validation and tests
that currently equate all private signing keys with credential authority.

HOME_ROSTER_KEY is canonical standard-base64 of the existing family.MarshalKeypair
format, bounded to 4096 encoded bytes, with no alternate file or second encoding
input. Decode strictly and verify the encoding round trip. Validate the closed
keypair JSON before calling family.UnmarshalKeypair: exactly sign_priv_d and
box_priv_d, canonical 64-character lowercase hexadecimal strings, no duplicate or
unknown fields. Require P256 signing scalar 0<D<N and valid box scalar; derived
public keys come from the existing library. The box half is unused by roster.
Derive roster key ID as lowercase hex SHA256(SignPub()), matching the shared
64-byte atom bound. Do not take the key ID from request input.

Reject a roster signing public key equal to any configured credential assertion
verification key. The independently audited operator provisioning key must also
be distinct; it is not loaded by this producer and its separation is a writer/
provisioning gate. Never derive a roster private key from HOME_DEK, pepper, token,
credential key or startup randomness. Startup does not publish a verification key
or manufacture a signed operator record. Key rotation requires the separately
approved operator record/key rollout before changing the active signing key.

## Handler seam and dispatch

Use an unexported rosterHandler with the concrete validated roster runtime,
configured directory origin, clock seam and a small read-source interface:
Read(context.Context, directorydata.RosterRequest, time.Time)
(roster.Page, int, error). Production injects NewRosterSource using the existing
store's DB/sealer/app policy. Tests may inject a counting source or real store.
Disabled mode does not construct or invoke a source or signer.

Dispatch decoded URL.Path == /roster to this adapter before directory browser
session/chooser middleware. The adapter owns all methods for that exact route,
so chi's default method response cannot bypass its headers. Cookies neither
authorize the request nor cause Set-Cookie, sign-in redirects or session writes.
All other routes retain the existing directory router and guards. The roster
adapter does not reuse browser CSRF or assertion-consumer middleware.

At adapter entry set Cache-Control: no-store, no-transform, Content-Type: application/json and
X-Content-Type-Options: nosniff. No CORS permission, compression, ETag, Last-Modified,
Location or browser cookie is emitted. Refusal precedence is:

1. r.Host must equal the configured directory host, including any configured
   port, exactly. No trailing-dot, alternate-host or forwarded-header alias.
   Wrong host returns fixed 404, before source access.
2. Method must be GET; HEAD, POST, OPTIONS and all others return fixed 405 with
   Allow: GET. Nothing follows a redirect to turn a POST into a roster read.
3. Disabled runtime returns fixed 503 for every token/query variation.
4. Validate request syntax below; malformed query/path/body returns fixed 400.
5. Validate one exact bearer header; malformed/missing/duplicate returns fixed401.
6. Call the source once with a current server time. Nonpositive server time
   returns fixed503 here, before the source's own input check.

Require URL.EscapedPath() exactly /roster, no absolute request-target, no fragment,
and no nonempty GET body: ContentLength must be zero and TransferEncoding empty.
Do not read an attacker-sized body. The configured directory origin plus the
request path/query must fit 4096 bytes; bound RequestURI too. A valid first query
contains exactly team and fetch. A continuation additionally contains one
nonempty cursor. Query field order is unrestricted. Reject duplicate/unknown or
empty fields, extra separators, semicolons, percent encodings, plus encodings and
padding. Parse literal bounded name=value pairs; do not use a helper that silently
discards malformed parameters. Team is 1..64 ASCII alphanumerics, fetch passes
roster.ValidChallenge, and cursor is strict canonical raw-base64url at most
roster.CursorLimit (2048 bytes), checked by decoding and round trip. The producer
validates the sealed cursor contents again. Neither team nor cursor selects a
network destination.

Count Authorization values across case-insensitive header names; require exactly
one value equal to Bearer + one ASCII space + a canonical43-character token.
Reject comma lists, repeated lines, tabs, extra spaces, alternate schemes and
padded/noncanonical values. Use roster.ValidChallenge before calling the source.
No browser session, Origin, Referer or query token substitutes for this header.
Accept and Accept-Encoding need no special value from the caller: the adapter
always returns the one JSON representation without compression. The no-transform
directive is required by the CARLOS edge compressor as well as the app adapter:
without it, application/json responses can be gzipped for Accept-Encoding: gzip,
including small error responses when the upstream length is unknown.

The application adapter never redirects. The existing outer Go ServeMux can
canonicalize unrelated malformed paths before the application sees them; the
shared client never follows these redirects. Tests through the real outer mux
must show no alternate path produces a trusted page, and canonical /roster never
redirects for any method/query/error. No change to global router normalization is
needed to serve the fixed canonical endpoint.

## Response and error boundary

Give source.Read a request-derived context capped at five seconds, and obtain
now immediately before it. No network call or operator write occurs under that
read transaction. On nil error, accept only status200 with type page or status409
with type stale-cursor. Sign with roster.SignPage using the validated roster key
and derived ID, and buffer the complete returned bytes before writing headers.
Check len <= roster.BodyLimit as a final boundary. Sign/marshal/status failure
returns the fixed unsigned503; never emit an unsigned Page or a partial signed
response. Do not duplicate the shared envelope/signature or JSON-pointer logic.

A source failure maps only its fixed401/404/400/503 error/status pairs. Unexpected
pairs become fixed503. Keep the approved source's exact mapping, including401
for a missing token-to-registry join; do not infer a more revealing cause.
Authenticated epoch/generation change is the signed409 shape, with current scope,
fetch, request-cursor hash, generation and epoch and all page-only fields absent.
It is never treated as an empty successful roster or a new generation approval.

Unsigned errors contain only a fixed machine code: bad_request, unauthorized,
not_found, method_not_allowed or unavailable in an error JSON member. No supplied
value or underlying error is serialized. No response includes a team name, email,
origin selected from request input or token diagnostic. All errors keep no-store
and exact application/json, although the client still refuses unsigned errors.
No raw URLs, bodies, cursor, token, label, subject or key enters logs, traces,
metrics or status messages. The handler itself needs no logging. Transport and
platform access-log behavior must be checked before any live enablement; the
application's no-log claim does not attest to an unaudited proxy configuration.

## Authority and deployment gates

In serve mode an otherwise authenticated, scoped request with absent generation
returns503 through the source;
it must not mint a generation, owner, membership, token or receipt. Invalid or
unknown tokens remain401 and foreign/unknown teams remain identical404. Merely
installing the key does not initialize or approve authority. Runtime health may
report process health as before; it is not a claim that roster provisioning is
complete or that Docs TeamMode is ready.

The future writer must authenticate independent operator authority before its
transaction, verify the exact scope/expected generation/record sequence, assert
foreign_keys ON, and atomically write state and a successful receipt. It must
follow the producer storage contract's token-slot and epoch-trigger rules.
This slice adds no such writer, route, command, migration seed or environment
import. No first visitor or roster bearer can supply it. Existing-db bootstrap
and restore remain behind the supported fenced maintenance path (platform117);
a genuinely fresh first-boot path needs separate audit. Without either approved
path there is no live generation/consumer initialization and no general team
roster launch. Test SQL and test operator signatures prove behavior only.

## Implementation acceptance

Run the complete Home gate, focused producer/handler race tests, actual CI and
Fable whole-code review on a frozen candidate before parent integration. Tests
must use actual runtime/schema/router paths as well as the injected handler:

- Directory schema contains roster tables/triggers; credential/legacy schemas do
  not. Empty and repeated startup leave authority tables empty. Disabled runtime
  returns identical503 across token/query inputs without DB/signing/session work.
- Mode/key matrix covers both environment loader and direct constructors, role
  separation, malformed/private-key scalar/JSON cases, credential key reuse and
  no startup generation. Existing Home handoff/chooser browser gates still pass.
- Exact Host, all wrong methods, duplicate/mixed-case Authorization maps, comma/
  whitespace forms, absent or noncanonical challenges, hostile team/cursor,
  duplicate/unknown/escaped query keys, empty cursor, oversized target and GET
  body all refuse before any source call. Errors leak none of the test corpus.
- Use real directory fixture transactions and the actual shared Cache client
  against the handler through an injected same-origin TLS test transport. Signed
  200 with zero, one and more than100 members verifies; exact empty array stays
  explicit. Rotation, disable and foreign/unknown team failures remain bounded.
- Between pages mutate membership or generation; verify signed409 and no cache
  publication/lease extension. Wrong key/context/scope/signature cannot become
  standing. A missing generation never signs a successful page. No epoch/304
  response path exists. Two consumers and two teams stay isolated.
- Buffer/sign failures return unsigned503 with no partial Page. Every adapter
  result has no-store, no-transform/exact JSON and no Location, encoding, validator or cookie.
  The outer mux/real server retains these for canonical /roster, and no redirect
  is followed by the shared client.
- Seed names, tokens and cursor plaintext in fixtures and scan DB/WAL plus
  captured application logs. Negative mutations removing bearer/mode/Host
  checks, bypassing SignPage, or dropping signed409 expose failing tests.

No browser UI is introduced by this endpoint. A deployed two-team transport
canary is a later root-owned gate once an audited provisioning path exists; this
design and local fixtures do not authorize live seeding or mark the suite ready.
