`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
Context
Section titled “Context”Implementation of D (“petrova cairnet-onboard --wire-keys”) surfaced two facts that invalidate D’s shape:
-
cairnet_onboard.ts:60declares a load-bearing invariant: the orchestrator “never reads or writes.petrova/cairnet.envor any bridge outbox/token.” This is the kit-vs-keys boundary — kit-installation is one concern, key-provisioning is another. Bolting--wire-keysontocairnet-onboardwould silently break that boundary. The right shape is a separate verb that only provisions keys, leaving onboard untouched. -
Airlock
/api/admin/*has no API-key auth path today (verified atsrc/middleware/auth.ts:122andsrc/routes/admin.ts:76— admin endpoints are gated solely byrequirePlatformPermission→ 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.
Decision
Section titled “Decision”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/batchandPOST /api/admin/agent-keys/:id/rotateonly, 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 existinggenerateAgentKeyalready 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
emitAirlockDecisionaudit row on every accepted call (flow tag TBD; reuseF2or introduceF3— 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:
- Resolves the project via
enumerateFleet. Refuses if not found. - Reads
~/.petrova/credentials(a single-line file:airlock_provisioning_key=<key>). Refuses if missing. - POSTs
{ inputs: [{producer: <slug>, role}, ...] }to<airlock-url>/api/admin/agent-keys/batchwithAuthorization: Bearer <provisioning-key>. - With
--rotate: first callsairlock.agents()to find existing<slug>-<role>-NNNIDs, then rotates each via/api/admin/agent-keys/:id/rotate. - 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). - Emits one CAIRNET stone of type
petrova.integration.wired, recording{producer, roles, agent_ids}— never the keys themselves. - Refuses to run without
--actor human:<email>(reuses thePETROVA_ALLOW_HUMAN_OVERRIDEprecedent 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).
Alternatives considered
Section titled “Alternatives considered”- Keep
--wire-keysflag oncairnet-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 (--actorrequired), not autonomous. - Provisioning keys can also revoke — adds symmetry but expands the threat model. Reject; revocation stays human-only via the admin UI.
Consequences
Section titled “Consequences”For code:
airlock: newprovisioningvalue inApiKeyScope; modified/api/admin/agent-keys/batchand/api/admin/agent-keys/:id/rotateauth gates; new helpervalidateProvisioningKey(headers)that returns either aprovisioninguser-equivalent ornull. No new endpoints.petrova-codes: newcli/src/verbs/cairnet_provision_keys.ts; CLI registration incli/src/index.ts; tests undercli/tests/verbs/.cairnet_onboard.tsdocstring updated to referencecairnet-provision-keysinstead of “PREREQUISITE A (operator, manual)”.
For docs:
- New
petrova-codes/docs/operator/cairnet-key-provisioning.md(one-time~/.petrova/credentialssetup + when-to-rotate guidance). .petrova/cairnet.env.examplePREREQUISITE 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 --rotateonce 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.
References
Section titled “References”- 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)
Sign-off
Section titled “Sign-off”- Subagent: planner (this session)
- Human: alex@devarno.com 2026-05-24