Hoist control-plane auth provisioning out of the per-backend launchers into one shared contract, parameterized per trust domain (#476). Every blocking finding in PR #471 was the same integration-bug class: each launcher re-derived, by hand, how to generate the signing key, scope it to the orchestrator, mint the gateway JWT, and keep the host key canonical. Introduces `trust_domain.py`: * `TrustDomain` — one credential boundary (host-canonical key file + role set + env vars). `mint`/`verify` are scoped to the domain's roles, so a future host-controller domain (#468) uses its own key/verifier/roles rather than a `host` role on the control plane's frozenset (which the orchestrator key could then forge). * `ControlPlaneProvisioning` — the single seam answering the four invariants: host-canonical key, split key-vs-token credential, CLI token valid across co-running backends, and fail-closed (no open mode) for any co-located topology. * `Topology` — the backend declares what it is; the default is co-located + fail-closed, so a backend need not redeclare it. The `Orchestrator` ABC gets `control_plane_key()` (fail-closed) and routes `mint_gateway_token()` through the contract; docker/macOS/firecracker orchestrators, the server (verify), and the host CLI client (mint cli) all go through the domain instead of reading the host key directly. `orchestrator_auth` gains an optional `roles=` arg (default unchanged) so a domain scopes its own role set; `paths.host_signing_key(filename)` generalizes host_orchestrator_token. Adds unit coverage for the domain boundary + provisioning invariants and a PRD capturing the durable rationale. No change to the auth primitive's HMAC, the plane split, or the server's documented open-mode fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Closes #476
Product requirement docs
One PRD per feature: what to build, why, and how it's scoped. The PRD
is the durable spec — it should stand on its own without a Gitea issue
thread (see ../README.md for when a PRD is the right
document vs. a research note or a decision record).
Naming and numbering
New PRDs use a prd-new-<kebab-title>.md placeholder name while the PR
is open. On merge to main a CI workflow assigns the next sequential
number (0024-…, 0025-…), renames the file, and updates the title
header. Numbers are never reused; gaps are fine.
Once numbered, the filename stays fixed for the life of the doc.
Status
The Status: line near the top tracks the PRD's lifecycle:
- Draft — proposed, not yet shipped.
- Active — the design has shipped to
mainand is in effect. - Superseded by PRD NNNN — replaced by a later PRD; kept for history.
- Retargeted by PRD NNNN — folded into a later PRD's scope.
Format
# PRD prd-new: <short title> ← placeholder; CI fills in the number on merge
- **Status:** Draft
- **Author:** <who>
- **Created:** YYYY-MM-DD
- **Issue:** #<n> # optional — convenience pointer only
## Summary
One paragraph: what this builds and the pain it solves.
## Problem
The current state and why it's inadequate.
## Goals / Success Criteria
Bullets a reviewer can check the finished work against.
## Non-goals
What this explicitly does not do — and won't, to head off scope creep.
## Scope
In scope / out of scope, when the boundary needs spelling out.
## Design
How it works: schema, data flow, diagrams, algorithms as needed.
## Implementation chunks
Ordered, mergeable steps (optional; for multi-PR features).
## Open questions
Unresolved decisions — resolve or fold into Design before shipping.
Sections are a guide, not a straitjacket: drop the ones a given PRD doesn't need (a small change rarely needs Scope or Implementation chunks) and add others where they help (e.g. Testing strategy, Alternatives considered, References). Keep the rationale self-contained — inline the reasoning rather than linking out to an issue thread, so the PRD survives a move off Gitea.