Appearance
Deployment scoping and rotation
This page covers the deployment-profile matrix that scopes which keys a signer installation may serve, plus the three-state rotation lifecycle. For the underlying KeyProvider port see KeyProvider contract and adapters; for the entry point and ubiquitous language see the index.
Deployment-scoping matrix
Key scoping is deployment-fixed at install time. The operator picks a DeploymentProfile at the signer binary's bring-up; the profile plus the caller-supplied --scope decides whether the installation may serve that scope at all. The matrix below encodes the rules pinned by the README §Signing Service section and enforced by ValidateScopeAgainstProfile in internal/signing/services/scope_invariants.go.
| Deployment profile | --scope platform | --scope domain:<uuid> |
|---|---|---|
saas | Refused — signing scope "platform" not permitted in profile "saas": SaaS deployments require per-Domain keys | Permitted |
selfhosted-single | Permitted (operator choice for simplicity) | Permitted |
selfhosted-multi | Refused — signing scope "platform" not permitted in profile "selfhosted-multi": multi-Domain installations require per-Domain keys | Permitted |
The two refusal detail strings are pinned verbatim in the validator: operator runbooks and the CLI's --scope rejection message grep for them byte-for-byte. Paraphrasing them between releases is a documented contract break.
Integration coverage:
tests/integration/signer_scope_saas_denies_platform_test.gotests/integration/signer_scope_selfhosted_multidomain_test.go
Rotation state machine
For the end-to-end fan-out workflow — the atomic CreateKeyAndOpenRotation repository call, the OutboxEventPublisher per-Node fan-out, and the DomainSigningKeyResolver.Subscribe cache-invalidation consumer — see ../signing-rotation.md. This page focuses on the in-process state machine and the field-ownership split between signing_key_transition.(opened_at, closes_at) and signing_key.valid_until.
A SigningKey lives in exactly one of three lifecycle states. Transitions are pure functions in internal/signing/rotation/state.go; no code outside that package may produce a new state from an old one.
mermaid
stateDiagram-v2
[*] --> active: Activate (zero -> active)
active --> rotating: BeginRotation\n(pairs with a new key, opens Window)
rotating --> active: CompleteRotation\n(new key promoted at ClosesAt)
rotating --> retired: Retire\n(old key retired at ClosesAt)
active --> retired: Retire\n(emergency retirement)
retired --> [*]
note right of rotating
Window invariant:
OpenedAt < ClosesAt (strict)
See rotation/overlap.go
end noteTwo signing-eligible halves coexist exclusively inside the Window that NewRotationTransition pins on the aggregate: OpenedAt is the instant the new key becomes eligible to sign; ClosesAt is the instant the old key stops signing. The strict OpenedAt < ClosesAt invariant exists because a zero-duration overlap would collapse the rotation protocol's safety margin — an envelope signed just before the flip would fail verification on a node that has not yet picked up the new public half. ErrInvalidOverlap is the sentinel callers branch on.
Cross-scope transitions are rejected at construction time: a Platform key never hands off to a Domain key, and one Domain's key never hands off to another's. The trust boundary of the overlap window is defined by the scope, and two different audiences cannot verify each other's signatures.
Boot-time recovery — RotationService.Resume
A rotation's overlap window has a finite lifetime: it opens at OpenedAt and is meant to be closed at ClosesAt by RotationService.Close. If the signer process restarts during an overlap window — and the window's ClosesAt has already passed by the time it boots back up — the transition row would still be marked open and the service would advertise two signing keys for the scope indefinitely.
RotationService.Resume (internal/signing/rotation_service.go) reconciles that case at signer startup. For a given Scope it reads the canonical overlap window from the signing_key_transition row via KeyRepo.GetOpenRotation and then:
- If there is no open rotation (
ErrNoOpenRotation), Resume returns cleanly — nothing to recover. - If a rotation is open and
nowis still beforeClosesAt, the window is legitimately live; Resume leaves it untouched. - If a rotation is open and
nowis at or afterClosesAt, the window has expired while the process was down. Resume auto-closes it: it resolves the old key, callsKeyRepo.CloseRotationto stamp the transition closed, best-effortprovider.Retires the old key handle, and writes the rotation audit row.
Resume reads the overlap window from the transition row rather than reconstructing it from signing_key.valid_until — the transition table is the single source of truth for "is this rotation open?" (see the valid_until section below for the field-ownership split).
The error-propagation contract is deliberate: a GetOpenRotation or CloseRotation failure is propagated so a readiness probe surfaces the unrecovered window rather than letting the signer come up still advertising two keys; only the best-effort provider.Retire is allowed to fail silently, because the persisted close has already landed and Retire is idempotent by contract. When debugging a signer that advertises two keys after a restart, check that Resume ran for the affected scope and that GetOpenRotation / CloseRotation did not error.
Configuring the overlap window
The default rotation overlap duration is 24 hours, pinned by defaultOverlapWindow in ../../../internal/signing/rotation_service.go. Operators override it at signer bring-up through the --overlap-window flag on the plexsphere-signer binary, with PLEXSPHERE_SIGNER_OVERLAP_WINDOW as the environment-variable alias. Both surfaces are parsed in ../../../cmd/plexsphere-signer/main.go. The flag seeds the duration RotationService.Open uses when stamping the transition row's (opened_at, closes_at) window — i.e. the ClosesAt − OpenedAt interval during which both halves of the rotation are advertised.
Zero or negative values are rejected at startup (--overlap-window must be positive); the signer never boots advertising a degenerate overlap. When the flag is omitted the package-level signing.defaultOverlapWindow is used directly — the bring-up path threads WithOverlapWindow only when the operator set the flag explicitly, so the in-package default remains the single source of truth.
For the full rotation contract — the two control-plane RPCs, the stable status-detail strings, and the per-Node outbox fan-out — see ../signing-rotation.md.
signing_key.valid_until — retention semantics, not rotation window
SigningKey.valid_until (the valid_until column on internal/platform/db/queries/50_signing.sql) tracks key retention, not the rotation overlap window. Two fields carry the two time-boxed invariants on purpose; overloading one onto the other would conflate key lifecycle with transition coordination:
| Field | Where it lives | Controls |
|---|---|---|
signing_key_transition.(opened_at, closes_at) | Transition table | The rotation overlap window — the interval in which both halves sign. Written by OpenRotation; stamped closed by CloseRotation. This is the source of truth RotationService.Resume reads. |
signing_key.valid_until | Key row | The point after which a retired key should be aged out of the public-key bundle for retention / key-lifecycle reasons. May be NULL for keys without a retention deadline. |
Concrete consequences of the split:
OpenRotationdoes NOT stampvalid_untilon either key row; the rotation overlap is a property of the transition row only. A reader that needs "how long does the old key keep signing?" readssigning_key_transition.closes_at, never the per-keyvalid_until.CloseRotationflips the pair (old → retired,new → active) and closes the transition row. It does NOT stampvalid_untilon the retired old key: leaving it NULL keeps the retired key in the public-key bundle served byListPublicKeysForScopeso in-flight signatures minted by the old key can still be verified. Operators who want to age the retired key out of the bundle setvalid_untilexplicitly (e.g. via a retention job), at which point theWHERE … AND (valid_until IS NULL OR valid_until > now OR state <> 'retired')predicate drops it from subsequent pages.ListPublicKeysForScope's predicate is deliberately a three-way OR (see the DECISION block above the query ininternal/platform/db/queries/50_signing.sql): keep the row ifvalid_untilis unset, OR the retention window has not yet closed, OR the row is not in the retired state. Active and rotating keys therefore stay visible regardless ofvalid_until; retired keys stay visible untilvalid_untilhas been stamped AND has elapsed.
If a future story decides that valid_until should also bound the old key's signing window (rather than its retention window only), that is a behavioural change — it belongs in a dedicated task with its own migration story, not a drive-by tweak to OpenRotation or CloseRotation.