Skip to content

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

  1. The operator starts staker-console-v2 onboard with --token, --key-source generate, --beneficiary, and --issuer-public-key; the CLI options are registered in staker-console/src/index.ts:39.
  2. The console verifies the JWT signature locally before using any sm_url; this is mandatory unless an explicit diagnostic HS256 path is configured, enforced in staker-console/src/commands/onboard.ts:517 and staker-console/src/commands/onboard.ts:531.
  3. The console decodes operator_id, seat_id, sm_url, nonce, chain_id, and vault_config_version from the JWT in staker-console/src/lib/token.ts:62, then enforces register:seat scope and a 172_800 second max TTL in staker-console/src/lib/token.ts:85.
  4. The console calls POST /v1/seats/:seatId/wc-template with 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 in seat-manager/src/api/server.ts:1446.
  5. The console recomputes and validates the response fields against the token and local key material in staker-console/src/commands/onboard.ts:345.
  6. 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:603 and artifact save is in staker-console/src/commands/onboard.ts:660.
  7. 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 a deposit operation in seat-manager/src/api/server.ts:1594 and seat-manager/src/api/server.ts:1684.
  8. 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:

  1. Confirm the backend has a seat.registered audit 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 in seat-manager/src/api/server.ts:1766.
  2. Confirm the seat status is ALLOWLISTED immediately after register. The register transaction sets that status and writes registered_at, validator pubkey, beneficiary, vault address, withdrawal credentials, canonical tuple hash, config version, nonce, and API-key expiry in seat-manager/src/onboarding/prismaStore.ts:976.
  3. Confirm a deposit operation exists for the seat. The register route enqueues it inside the same transaction hook in seat-manager/src/api/server.ts:1709, and the operation queue stores its kind, status, payload_json, request hash, and idempotency key under Operation in seat-manager/prisma/schema.prisma:152.
  4. 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.
  5. 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.
  6. Confirm the operator can run staker-console-v2 status --seat-id <seat_id> --json against the saved profile. The status command resolves the API base from profile unless overridden in staker-console/src/commands/status.ts:99.
  7. Confirm default API key scopes are sufficient for day-2 status, claim, and rotation only. The default set is status, claim, and rotate_api_key in seat-manager/src/onboarding/store.ts:201; exit and migrate require explicit scoped issuance.
  8. Confirm no duplicate non-revoked seat exists for the operator. The database backstop is the partial index on operator_id where status is not REVOKED in seat-manager/prisma/migrations/20260514165745_one_active_seat_per_operator/migration.sql:37.