Skip to content

`cairnet-provision-keys` verb + airlock provisioning-key class ("D'")

cairnet-provision-keys verb + airlock provisioning-key class (“D’”)

Section titled “cairnet-provision-keys verb + airlock provisioning-key class (“D’”)”

Date: 2026-05-24 Status: open Supersedes: docs/decisions/2026-05-24-petrova-cairnet-onboard-wire-keys.md (D) Superseded-by: none — current

Implementation of D (“petrova cairnet-onboard --wire-keys”) surfaced two facts that invalidate D’s shape:

  1. cairnet_onboard.ts:60 declares a load-bearing invariant: the orchestrator “never reads or writes .petrova/cairnet.env or any bridge outbox/token.” This is the kit-vs-keys boundary — kit-installation is one concern, key-provisioning is another. Bolting --wire-keys onto cairnet-onboard would silently break that boundary. The right shape is a separate verb that only provisions keys, leaving onboard untouched.

  2. Airlock /api/admin/* has no API-key auth path today (verified at src/middleware/auth.ts:122 and src/routes/admin.ts:76 — admin endpoints are gated solely by requirePlatformPermission → BetterAuth session cookies). D assumed a “dedicated capability-scoped key” existed; it does not. Building one is in-scope but should be called out as its own substrate change with its own threat-model paragraph, not buried as an aside.

D’ makes both explicit.

Implement D’s intent in two parts, each landing as a separate PR:

Part 1 — Airlock provisioning API-key class

Section titled “Part 1 — Airlock provisioning API-key class”

Introduce a new ApiKeyScope value provisioning (alongside the existing user-scoped scopes and the agent-only cairn:emit). A provisioning-scoped key:

  • Is mintable only by an authenticated platform admin via the existing POST /api/admin/api-keys (no special path; reuse the human-gated creation flow).
  • Is accepted by POST /api/admin/agent-keys/batch and POST /api/admin/agent-keys/:id/rotate only, as an alternative to a session cookie. Every other admin endpoint stays session-only.
  • Can only mint agent keys whose generated scope is cairn:emit (the existing generateAgentKey already hard-codes that scope — no path to elevate).
  • Cannot mint or rotate another provisioning key. Cannot mint user-scoped keys. Cannot mint any scope outside cairn:emit.
  • Emits an emitAirlockDecision audit row on every accepted call (flow tag TBD; reuse F2 or introduce F3 — picker’s choice at implementation time).

Threat model: the key amplifies privilege (can mint other keys) but the amplification is bounded by the cairn:emit ceiling. A leaked provisioning key creates noise (more agent keys exist than the operator intended) but cannot read user data, modify orgs, or escalate to admin. Mitigation: rotate via the existing DELETE /api/admin/api-keys/:id plus re-mint.

Part 2 — Petrova cairnet-provision-keys verb

Section titled “Part 2 — Petrova cairnet-provision-keys verb”

New verb. Not a flag on cairnet-onboard.

petrova cairnet-provision-keys
--project <slug> # required; resolves via enumerateFleet
[--workspace <path>] # default: parent of cwd
[--roles fleet,coordinator,...] # default: all 6 CAIRNET roles
[--rotate] # rotate existing <producer>-<role>-NNN agents instead of skip
[--actor human:<email>] # required for write; refuses without
[--airlock-url <url>] # default: env PETROVA_AIRLOCK_URL or hardcoded prod
[--dry-run] # plan only; no airlock call, no file write
[--json]

Behavior:

  1. Resolves the project via enumerateFleet. Refuses if not found.
  2. Reads ~/.petrova/credentials (a single-line file: airlock_provisioning_key=<key>). Refuses if missing.
  3. POSTs { inputs: [{producer: <slug>, role}, ...] } to <airlock-url>/api/admin/agent-keys/batch with Authorization: Bearer <provisioning-key>.
  4. With --rotate: first calls airlock.agents() to find existing <slug>-<role>-NNN IDs, then rotates each via /api/admin/agent-keys/:id/rotate.
  5. Reads existing <project>/.petrova/cairnet.env (if any). Preserves any line that does NOT match ^CAIRNET_AGENT_KEY_<ROLE>=. Overwrites the per-role key lines. Writes atomically (write-to-tmp + rename in the same directory).
  6. Emits one CAIRNET stone of type petrova.integration.wired, recording {producer, roles, agent_ids}never the keys themselves.
  7. Refuses to run without --actor human:<email> (reuses the PETROVA_ALLOW_HUMAN_OVERRIDE precedent from registry verbs; the verb is operator-driven and must record who pressed the button).

Scope-out: no service-to-service silent invocation (always-human-actor); no provisioning for non-CAIRNET integrations (each future integration gets its own verb or its own role-enum extension); no multi-project batching in one invocation (one slug per call — keeps blast radius small).

  • Keep --wire-keys flag on cairnet-onboard — violates the kit-vs-keys boundary documented in the verb’s docstring. Reject; the invariant predates D’s design.
  • Reuse the operator’s BetterAuth session cookie instead of a provisioning key — avoids the new auth class but couples the verb to a browser-paste step, has 7-day expiry, and offers no scope limiting (cookie steal = full admin). Reject; the substrate must be CI/script-invokable.
  • Embed provisioning in petrova doctor --heal — would make missing keys self-heal alongside other drift. Reject; doctor must remain read-only-by-default (writes only behind explicit --commit-state), and provisioning is operator-witnessed (--actor required), not autonomous.
  • Provisioning keys can also revoke — adds symmetry but expands the threat model. Reject; revocation stays human-only via the admin UI.

For code:

  • airlock: new provisioning value in ApiKeyScope; modified /api/admin/agent-keys/batch and /api/admin/agent-keys/:id/rotate auth gates; new helper validateProvisioningKey(headers) that returns either a provisioning user-equivalent or null. No new endpoints.
  • petrova-codes: new cli/src/verbs/cairnet_provision_keys.ts; CLI registration in cli/src/index.ts; tests under cli/tests/verbs/.
  • cairnet_onboard.ts docstring updated to reference cairnet-provision-keys instead of “PREREQUISITE A (operator, manual)”.

For docs:

  • New petrova-codes/docs/operator/cairnet-key-provisioning.md (one-time ~/.petrova/credentials setup + when-to-rotate guidance).
  • .petrova/cairnet.env.example PREREQUISITE A comment edited to point at the verb.

For in-flight phases:

  • HERMES has its keys already (provisioned via A’s bulk form). HERMES is the first repo eligible to validate cairnet-provision-keys --rotate once Part 2 ships.
  • Every repo onboarded after Part 2 ships skips the manual UI step.

For invariants:

  • Reinforces the kit-vs-keys boundary (cairnet-onboard stays write-free against .petrova/cairnet.env).
  • New invariant candidate: “Admin API endpoints are session-gated by default; any API-key auth path must be scope-bounded and named in its own ADR.” Worth promoting to an MR once a second case emerges.
  • Supersedes: docs/decisions/2026-05-24-petrova-cairnet-onboard-wire-keys.md (D)
  • Companion (already shipped): docs/decisions/2026-05-24-hatch-bulk-agent-key-issuance.md (A) — airlock#33, hatch#9
  • Invariant cited: petrova-codes/cli/src/verbs/cairnet_onboard.ts:60 (“Never reads or writes .petrova/cairnet.env”)
  • Auth surface cited: airlock/src/middleware/auth.ts:122 (getAuthUser), airlock/src/routes/admin.ts:76 (guard)
  • Schema authority: petrova-codes/contracts/vendor/agentic-emission.v1.json (role enum + agent_id regex)