Authoring an adapter
An adapter is the provider-specific counterpart to the substrate. Substrate ships once and is provider-independent; each adapter ships separately and carries all the mechanics of credential issuance and revocation for one target system (Cloudflare, GitHub App PEM, AWS STS, GCP WIF, Vault, a database role, etc.). This page is the 30-minute-sketch guide — reading it plus the schemas reference should be enough to start writing a new adapter feature doc.
What an adapter provides
Section titled “What an adapter provides”Two mechanisms. Everything else is composable from the substrate.
1. Credential issuance
Section titled “1. Credential issuance”On merge of an approved elevation PR, the adapter mints a scoped, time-boxed credential for the declared scope. Three hard requirements:
- Scoped to the request. The credential carries only the write capability the PR’s
scopefield declares — no broader surface, no convenient-maximum fallback. Many providers grant coarse-grained permissions (e.g. Cloudflare’sPages:Editcovers all Pages operations on the account); the adapter ships with the provider’s achievable minimum as the floor, not as a reason to over-scope. - TTL-bounded.
expires_on(or platform-equivalent) set tonow + min(requested_ttl, tier.max_ttl). The minimum is load-bearing — the tier’smax_ttlis the ceiling regardless of what the request asked for. - Correlation-tagged. The credential’s
namefield (or whatever per-provider identifier can be set at mint time) includes the substrate’s correlation ID:canon-elevation-pr-<N>-<short_sha>. This is what lets the provider’s own audit log join back to the substrate’s audit branch.
2. Credential revocation
Section titled “2. Credential revocation”Post-apply, the adapter explicitly destroys the credential. Belt-and-braces with platform-level expiry: the explicit destroy is the primary mechanism; expires_on is the safety net for cases where the explicit destroy fails (workflow killed mid-run, network partition, etc.). Both layers compose — the credential is unusable because it is both destroyed and expired.
3. Adapter-scoped scope validation
Section titled “3. Adapter-scoped scope validation”The substrate treats scope as an opaque map or list. Each adapter validates its own scope shape:
- Shape checks. Required keys, allowed values, nested-structure correctness.
- Permission mapping. Translates the
scopepayload into provider-specific permission identifiers (e.g. Cloudflare permission-group UUIDs, AWS IAM policy ARNs, Vault policy paths). - Over-permission rejection. A scope declaring more than the declared tier permits for this adapter fails — the adapter is the last line of defence before the credential is minted.
What an adapter consumes from the substrate
Section titled “What an adapter consumes from the substrate”Three inputs, all already materialised by substrate infrastructure:
- Parsed PR frontmatter. The validator parses the PR body and (on success) publishes the structured elevation-request fields. The adapter’s apply workflow reads
scope,ttl,risk_tierfrom the merged PR’s frontmatter to mint the credential. - Tier-to-environment mapping. The reusable
elevation-tier-routerworkflow resolves a declaredrisk_tierto the GitHub Actions environment name for that tier. Medium/high tiers return the tier’s environment; low tiers return empty string (no environment gate). - Correlation ID format.
canon-elevation-pr-<N>-<short_sha>. The adapter computes this from the merged PR’s number + short SHA and embeds it in the provider credential’snamefield.
The adapter does not consume or parse the policy config directly — tier bounds are already enforced by the validator; the adapter trusts that a merged PR has passed policy checks.
Events the adapter emits
Section titled “Events the adapter emits”Three lifecycle events per elevation. All land on the audit orphan branch at YYYY/MM/DD/<pr-number>-<short-sha>/<event>-<sequence>.json, GPG-signed by the writer identity.
issued
Section titled “issued”Emitted immediately after the credential is minted, before apply runs.
Required fields (see schemas reference for field semantics): correlation_id, pr_number, pr_sha, event: "issued", at, actor, target_adapter, scope, ttl_seconds, provider_token_id, apply_run_id, result: "success". A failure to mint produces an issued record with result: "failure" rather than an absent record.
applied
Section titled “applied”Emitted after the apply step completes (success or failure).
Required fields: same shape as issued, with event: "applied". The apply_run_id is the same as the issued record’s (both events occur in the same workflow run). result reflects the apply outcome — a failed apply produces applied-<N>.json with result: "failure", not a silent absence.
revoked
Section titled “revoked”Emitted after the credential is destroyed.
Required fields: same shape. event: "revoked". result: "success" if the destroy call succeeded (or if platform-side expires_on is known to have fired); result: "failure" if the destroy attempt errored and the adapter is relying on expires_on as the fallback.
Example — a hypothetical AWS STS adapter
Section titled “Example — a hypothetical AWS STS adapter”{ "correlation_id": "canon-elevation-pr-101-deadbee", "pr_number": 101, "pr_sha": "deadbee", "event": "issued", "at": "2026-05-10T09:14:22Z", "actor": "github-app-id:12345", "target_adapter": "aws-sts", "scope": { "role_arn": "arn:aws:iam::123456789012:role/canon-elevation-s3-write", "session_name": "canon-elevation-pr-101-deadbee", "inline_policy": { "Version": "2012-10-17", "Statement": [/* ... */] } }, "ttl_seconds": 1800, "provider_token_id": "ASIA...", "apply_run_id": "15000000042", "result": "success"}Scope is adapter-shaped (role ARN + session name + optional inline-policy narrowing) rather than the Cloudflare-shaped example on the schemas page. The substrate treats the payload as opaque.
Skeleton feature-doc template
Section titled “Skeleton feature-doc template”Copy the frontmatter + section outline below as the starting point for a new adapter feature. Replace <provider>, <mechanism>, etc. The feature doc lives at internal/features/jit-escalation-<provider>-adapter.md.
---name: "JIT escalation <provider> adapter — <short description>"status: draftowner: <owner>added: <YYYY-MM-DD>rubric_version: "<current>"criteria_advanced: - "PL4-least-privilege" - "PL3-deployment-cicd"related_recipes: - "canon/recipes/gitops-jit-privilege-elevation.md"related_integrations: []depends_on: - "internal/features/jit-escalation-substrate.md" - "<any target-system feature this adapter depends on>"tags: [infrastructure, jit-elevation, adapter, <provider>, draft]---
# JIT escalation <provider> adapter — <short description>
## Why
<What RW-at-rest gap does this adapter close? Which concrete feature orworkflow stops being "operator mints + applies + revokes" once thisadapter ships?>
## Scope
**In scope.**
- **<Provider> bootstrap identity.** <Long-lived minimal-scope credential; where it lives; what it can and cannot do directly.>- **Adapter module.** <Code that consumes a merged elevation PR's metadata and mints / revokes credentials.>- **Apply step.** <How `tofu apply` or equivalent consumes the runtime credential.>- **Post-apply destroy.** <Non-skippable destroy step + belt-and-braces platform expiry.>- **Audit-event emission.** <`issued` / `applied` / `revoked` records written to the orphan `audit` branch.>
**Out of scope.**
- **The substrate.** <Reference the substrate feature — this adapter consumes it, doesn't rebuild it.>- **<Provider>-specific scope granularity beyond what the platform permits.** <Document the achievable minimum — the adapter cannot scope narrower than the provider allows.>
## Prerequisites
### Pre-scaffold discovery (agent-doable)
- [ ] Confirm <provider>'s expiry enforcement: <hard platform invalidation or soft / cacheable?>- [ ] Confirm the correlation-tag format survives concurrent issuance + fast turnaround on this provider's uniqueness rules.- [ ] Inventory the provider's permission / role model — map scope payloads to platform-specific identifiers.
### Human-required phase-0 steps
1. **Issue the bootstrap identity.** <Exact UI / API steps. Where the resulting secret lands (which GHA environment, which secret name). Rotation cadence.>
## Acceptance criteria
1. **Bootstrap identity is minimally scoped.** Verified by attempting a direct mutation that the adapter is meant to mediate and confirming 403 / permission-denied.2. **Merged elevation PR mints a runtime credential with correct attributes.** Scope, TTL, and correlation tag match the PR's declared fields.3. **Scope validation blocks over-permissioned requests.**4. **Apply runs with the runtime credential.**5. **Post-apply destroy fires reliably.** Credential is unusable after apply, verified by attempting a post-apply mutation.6. **Audit events land on the `audit` branch.** `issued`, `applied`, `revoked` records present with correct shape.
## Dependencies
- Feature dependency: [`features/jit-escalation-substrate.md`](...) — substrate must be shipped.- <Target-system feature>.
## Dogfood targets
- **`PL4-least-privilege`.** <Mechanism: routine state is read-only; writes require the substrate-mediated elevation; credential is scoped, TTL-bounded, correlation-tagged.>- **`PL3-deployment-cicd`.** <Mechanism: apply path is fully automated from merge through destroy; no out-of-band operator steps.>Skeleton adapter apply workflow
Section titled “Skeleton adapter apply workflow”Adapter apply workflows consume the reusable elevation-tier-router to resolve the declared tier’s environment, then route the apply job through it. The broad shape:
name: <provider>-apply
on: pull_request: types: [closed] branches: [main]
jobs: route: if: github.event.pull_request.merged == true uses: ./.github/workflows/elevation-tier-router.yml with: pr_body: ${{ github.event.pull_request.body }}
apply: needs: route runs-on: ubuntu-latest environment: ${{ needs.route.outputs.environment }} # empty string = no gate permissions: contents: read pull-requests: read steps: - uses: actions/checkout@<sha> # SHA-pin; respect repo's pinning policy
# Parse the merged PR's elevation frontmatter. - name: Parse elevation request id: elevation run: | pnpm --filter @canon/elevation-validator exec \ tsx src/cli.ts validate \ --body /tmp/pr-body.md \ --policy .github/elevation-policy.yml \ --print-parsed env: PR_BODY: ${{ github.event.pull_request.body }} # ... materialise PR_BODY safely (env + printf '%s' ... > file).
# Adapter-specific: mint the runtime credential. - name: Mint credential id: mint run: | # Call provider API / Terraform resource to mint a scoped, # TTL-bounded, correlation-tagged credential.
# Emit `issued` audit event. - name: Emit issued event run: | # Compose JSON per the audit-event schema; # commit + push to origin/audit.
# Run the apply. - name: Apply run: | # tofu apply / provider-specific apply with the runtime credential.
# Emit `applied` audit event (success or failure). - name: Emit applied event if: always() run: | # Record result: success | failure based on the apply step's outcome.
# Destroy the credential — non-skippable. - name: Destroy credential if: always() run: | # Provider destroy call. On failure, rely on expires_on as fallback.
# Emit `revoked` audit event. - name: Emit revoked event if: always() run: | # Record result based on the destroy step's outcome.Key shape choices:
environment: ${{ needs.route.outputs.environment }}. An empty string on low tiers means no environment-reviewer gate; a non-empty string (e.g.infrastructure) on medium/high fires the environment’srequired_reviewersgate before the job runs.if: always()on emit steps. Audit events land even when the preceding step failed — a failed apply produces anappliedrecord withresult: failure, not an absent record.- Environment-variable + printf for PR body. The PR body is untrusted content that can carry shell metacharacters or heredoc sentinels. Route it through an env var and
printf '%s'rather than heredoc or direct interpolation; this keeps shell tokenisation off the untrusted content.
Walking example — a Vault dynamic-credentials adapter
Section titled “Walking example — a Vault dynamic-credentials adapter”Suppose the canon grows a Vault deployment and you want a jit-escalation-vault-adapter that mints database credentials for a dynamic-secrets-enabled PostgreSQL role.
Human phase-0. Operator configures a Vault policy that grants the GitHub App’s workload identity the ability to call vault write database/creds/<role-name> and vault lease revoke <lease_id> on elevation-eligible roles only. Vault policy ARN / policy name goes into a GHA environment variable (not secret — Vault’s auth is via workload identity, not static token).
Mint mechanism. vault write database/creds/<role> returns a lease ID + credentials. The adapter captures the lease ID (this is the provider_token_id for the audit record) and the credential for the apply step. expires_on is the lease’s TTL — Vault enforces this server-side.
Revocation mechanism. vault lease revoke <lease_id> destroys the credential explicitly. Vault’s lease expiry is the belt-and-braces fallback if the explicit revoke fails.
Scope shape. scope: { role: "canon-db-migrations-write", postgres_db: "canon_production" } — map keys name the Vault role and the target database. Adapter-level validation checks the role is in an allowlist (e.g. only canon-db-migrations-write and canon-db-hotfix-write are elevation-eligible; canon-db-superuser-write is never callable through this adapter).
Audit emission. issued after vault write, applied after the database migration runs, revoked after vault lease revoke. provider_token_id is the lease ID; the provider-side audit corroboration lives in Vault’s own audit log, joined by the lease ID embedded via Vault’s request metadata if the deployment supports it, or via a sidecar lookup if not.
The skeleton-template above, with these substitutions, is roughly what a jit-escalation-vault-adapter feature doc would start as.
Audit-join usage
Section titled “Audit-join usage”To retrieve the joined PR + event record for a past elevation:
pnpm --filter @canon/elevation-validator exec tsx src/cli.ts \ audit-join --pr <N> --repo <path>The --repo argument is the path to a local checkout; the join reads the local audit branch (preferring origin/audit, falling back to local audit) for event records and calls the GitHub API for PR metadata. Run git fetch origin audit first to ensure the local ref is current.
See the schemas reference for the return shape, including the missing_phases field that surfaces incomplete lifecycles.
Reading the first shipped adapter
Section titled “Reading the first shipped adapter”The canon’s first adapter is the Cloudflare adapter, tracked at internal/features/jit-escalation-cf-adapter.md (view on GitHub). Read it alongside this guide for a concrete instance of the shape — bootstrap token + mint step + apply + destroy + audit emission, all provider-specific but structurally matching the skeletons above.
The canon’s wired substrate instance is documented at internal/integrations/canon-jit-escalation-substrate.md — that’s the integration-level record of the particular substrate files shipped, the required-check context name, and the one-time operator runbook items. Distinct from this page, which is the reusable pattern.