Operator Enrollment¶
Pre-conditions¶
Foundation Ops has vetted the operator and assigned exactly one seat. The backend has a CREATED seat row at process start from SEAT_MANAGER_CREATED_SEATS_JSON, which is parsed in seat-manager/src/api/runtime.ts:17 and documented in seat-manager/README.md:134. Token issuance is outside this codebase; the backend only verifies the token.
The Three Keys¶
| key | holder | used for | source |
|---|---|---|---|
| JWT issuer Ed25519 keypair | Foundation Ops private key, backend/operator public key | Signs one-shot enrollment JWTs; backend verifies with ENROLLMENT_JWT_PUBLIC_KEY_PEM; operator passes --issuer-public-key |
backend verifier uses EdDSA in seat-manager/src/onboarding/tokenVerifier.ts:50; console requires local verification in staker-console/src/commands/onboard.ts:517 |
| BLS12-381 validator key | Operator machine | Signs the deposit message locally; private key is encrypted into EIP-2335 keystore and never sent to backend | key generation/import is in staker-console/src/commands/onboard.ts:607; private key bytes are wiped in staker-console/src/commands/onboard.ts:742 |
| Operator API key | Operator profile after register | Authenticates day-2 operations | register response stores API key fields in staker-console/src/commands/onboard.ts:273; backend issues default scopes in seat-manager/src/onboarding/store.ts:201 |
The Ceremony¶
- The operator starts
staker-console-v2 onboardwith--token,--key-source generate,--beneficiary, and--issuer-public-key; the CLI options are registered instaker-console/src/index.ts:39. - The console verifies the JWT signature locally before using any
sm_url; this is mandatory unless an explicit diagnostic HS256 path is configured, enforced instaker-console/src/commands/onboard.ts:517andstaker-console/src/commands/onboard.ts:531. - The console decodes
operator_id,seat_id,sm_url,nonce,chain_id, andvault_config_versionfrom the JWT instaker-console/src/lib/token.ts:62, then enforcesregister:seatscope and a172_800second max TTL instaker-console/src/lib/token.ts:85. - The console calls
POST /v1/seats/:seatId/wc-templatewith the enrollment token; the backend verifies the token, checks the seat, enforces nonce replay protection, requires an idempotency key, predicts vault address and withdrawal credentials, and persists the template inseat-manager/src/api/server.ts:1446. - The console recomputes and validates the response fields against the token and local key material in
staker-console/src/commands/onboard.ts:345. - The console encrypts the local BLS private key into the operator workspace before register; keystore recovery checks are in
staker-console/src/commands/onboard.ts:603and artifact save is instaker-console/src/commands/onboard.ts:660. - The console signs the deposit message locally and calls
POST /v1/seats/:seatId/register; the backend verifies the BLS payload, commits the seat, consumes the nonce, issues the operator API key, and enqueues adepositoperation inseat-manager/src/api/server.ts:1594andseat-manager/src/api/server.ts:1684. - The console writes the profile, client config, and keystore path, then clears the transient onboarding session in
staker-console/src/commands/onboard.ts:728.
sequenceDiagram
participant Ops as Foundation Ops
participant Op as Operator
participant SC as Staker Console
participant SM as Seat Manager
participant DB as Postgres
Ops->>SM: seed CREATED seat via SEAT_MANAGER_CREATED_SEATS_JSON
Ops->>Op: deliver one-shot signed enrollment JWT
Op->>SC: staker-console-v2 onboard --issuer-public-key
SC->>SC: verify JWT signature and claims locally
SC->>SC: generate BLS key and deposit signature
SC->>SM: POST /v1/seats/:seatId/wc-template
SM->>DB: persist WcTemplate and idempotency replay
SM-->>SC: vault address + withdrawal credentials
SC->>SM: POST /v1/seats/:seatId/register
SM->>DB: consume nonce, update seat, issue API key, enqueue deposit
SM-->>SC: register response + operator API key
SC->>SC: save encrypted keystore and profile
Failure Modes and Recovery¶
JWT expired before register completed¶
This triggers when the JWT exp time is already behind the local clock or the backend clock. The backend token verifier caps accepted token lifetime at MAX_TOKEN_TTL_SECONDS = 172800 and allows only 60 seconds of clock skew in seat-manager/src/onboarding/tokenVerifier.ts:8. The console performs the same usability check before it contacts sm_url; expired tokens raise TOKEN_EXPIRED in staker-console/src/lib/token.ts:96, and the backend maps an expired verified JWT to TOKEN_EXPIRED in seat-manager/src/onboarding/tokenVerifier.ts:97.
The console prints error [TOKEN_EXPIRED] Enrollment token is expired through the shared command error handler in staker-console/src/index.ts:372. Foundation Ops recovers by minting a new JWT with the same seat_id, the same operator_id, and a fresh nonce. The existing seat stays CREATED because commitRegister has not run and the register transaction is the path that writes registered_at, pubkey, vault_address, nonce_consumed, and the operator API key in seat-manager/src/onboarding/prismaStore.ts:976.
No DB cleanup is needed. The unused JWT nonce is not written to NonceLedger during local token verification or wc-template; NonceLedger is created only inside the register transaction in seat-manager/src/onboarding/prismaStore.ts:967. A stale WcTemplate for the same seat can be reused only when the tuple matches exactly; conflicting tuple reuse fails with CANONICAL_TUPLE_MISMATCH in seat-manager/src/onboarding/prismaStore.ts:499.
Nonce already consumed¶
This triggers when the operator re-runs onboard with a JWT whose nonce was consumed by a prior successful register. The backend rejects it from commitRegisterTx after it finds an existing NonceLedger row and raises TOKEN_REPLAYED in seat-manager/src/onboarding/prismaStore.ts:896. A nonce mismatch between an earlier wc-template and a later register request also raises TOKEN_REPLAYED in seat-manager/src/onboarding/prismaStore.ts:905.
The console-side recovery path is local artifact resume, not new token issuance. The console first checks for an existing profile and a registered onboarding session in staker-console/src/commands/onboard.ts:547; if the session is registered and the keystore still exists, it finalizes profile, config, and keystore paths instead of calling register again in staker-console/src/commands/onboard.ts:587. The operator runs:
staker-console-v2 onboard-resume --seat-id <seat_id> --workspace <workspace>
Do not mint a new JWT for the same seat after a successful register. The already registered seat is ALLOWLISTED, the nonce has been consumed, and the operator API key has been issued in seat-manager/src/onboarding/prismaStore.ts:1005. If the local keystore is gone, this recovery path stops with KEYSTORE_MISSING in staker-console/src/commands/onboard.ts:554; use the next subsection.
Keystore lost before register completed¶
This triggers when the machine was wiped, the keystore password is gone, or encryption parameters no longer match the saved keystore. The BLS key is unrecoverable from the backend because the private key is generated or imported on the operator machine, encrypted locally, and never transmitted to Seat Manager. The console saves an encrypted EIP-2335 keystore during onboarding in staker-console/src/commands/onboard.ts:660, and it wipes local private key bytes before returning in staker-console/src/commands/onboard.ts:742.
Foundation Ops must revoke the unusable seat before provisioning a replacement. The admin route is POST /admin/v1/seats/:seatId/revoke, registered at seat-manager/src/api/server.ts:2983; it requires ADMIN, CSRF, fresh step-up, an idempotency key, and governance approval evidence in seat-manager/src/api/server.ts:2985. The one-seat-per-operator invariant permits re-enrollment after revocation because revoked seats are excluded by isActiveSeatStatus in seat-manager/src/onboarding/store.ts:19 and by the partial unique index in seat-manager/prisma/migrations/20260514165745_one_active_seat_per_operator/migration.sql:37.
The audit trail has two durable records. The lifecycle transition writes LifecycleEvidence with governance approval id, hash, issuer, expiry, verified-at timestamp, nonce, reason code, and actor in seat-manager/src/api/server.ts:3060. The admin route emits admin.seat.revoke with result, reason, governance ticket, and status change detail in seat-manager/src/api/server.ts:3134.
Backend rejected with OPERATOR_ALREADY_HAS_ACTIVE_SEAT¶
This triggers when Foundation Ops tries to provision a second non-revoked seat for an operator. The application checks for an existing non-revoked seat during seedCreatedSeat and raises OPERATOR_ALREADY_HAS_ACTIVE_SEAT with HTTP 409 in seat-manager/src/onboarding/prismaStore.ts:296. The database repeats the rule with uniq_seats_active_operator_id, so a future code path that creates a duplicate non-revoked row still fails in seat-manager/prisma/migrations/20260514165745_one_active_seat_per_operator/migration.sql:37.
The operator usually does not see this directly because it fires at provisioning time before most JWT issuance flows. Foundation Ops sees the seed/provisioning error and checks the existing seat status from the admin seats endpoint, which filters and returns status, pubkey, vault address, and pending/failed operation flags in seat-manager/src/api/server.ts:2715.
There are two recovery options. Cancel the duplicate provisioning when the existing seat is still the intended active seat; no operator action is required. If the prior validator is being retired, revoke the existing seat first with governance approval, then provision a new seat_id for the same operator. That second path uses the revocation evidence and audit flow in seat-manager/src/api/server.ts:3041 and seat-manager/src/api/server.ts:3134.
Operator Handoff Checklist¶
Use this checklist before telling an operator that enrollment is complete:
- Confirm the backend has a
seat.registeredaudit event with the expected operator id, pubkey, vault address, canonical tuple hash, and nonce prefix. The event is emitted after the register transaction and deposit operation enqueue inseat-manager/src/api/server.ts:1766. - Confirm the seat status is
ALLOWLISTEDimmediately after register. The register transaction sets that status and writesregistered_at, validator pubkey, beneficiary, vault address, withdrawal credentials, canonical tuple hash, config version, nonce, and API-key expiry inseat-manager/src/onboarding/prismaStore.ts:976. - Confirm a
depositoperation exists for the seat. The register route enqueues it inside the same transaction hook inseat-manager/src/api/server.ts:1709, and the operation queue stores itskind,status,payload_json, request hash, and idempotency key underOperationinseat-manager/prisma/schema.prisma:152. - Confirm the operator profile contains only the returned API key and public identifiers, not the enrollment JWT. The console writes profile data from the register response in
staker-console/src/commands/onboard.ts:728. - Confirm the encrypted keystore file exists in the operator workspace. The console writes it before register when a new key is generated or imported in
staker-console/src/commands/onboard.ts:660. - Confirm the operator can run
staker-console-v2 status --seat-id <seat_id> --jsonagainst the saved profile. The status command resolves the API base from profile unless overridden instaker-console/src/commands/status.ts:99. - Confirm default API key scopes are sufficient for day-2 status, claim, and rotation only. The default set is
status,claim, androtate_api_keyinseat-manager/src/onboarding/store.ts:201; exit and migrate require explicit scoped issuance. - Confirm no duplicate non-revoked seat exists for the operator. The database backstop is the partial index on
operator_idwhere status is notREVOKEDinseat-manager/prisma/migrations/20260514165745_one_active_seat_per_operator/migration.sql:37.