Skip to content

C9b: bind the rotation kernel to the GitHub App key, with an exact-version verifier - #11732

Merged
gunbai-bot[bot] merged 16 commits into
mainfrom
session/wise-bee-389
Sep 20, 2026
Merged

gunbai-bot[bot] merged 16 commits into
mainfrom
session/wise-bee-389

Conversation

@gunbai-bot

@gunbai-bot gunbai-bot Bot commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

C9b: the gunbai-ci GitHub App private key (ci-github-app-private-key) becomes the first real subject of the rotation kernel gunbc.auth.secret_rotation, with a verifier that checks one exact key version on demand.

The kernel has NOT actuated. This PR adds nothing that performs add-version or disable. The frontier row in gunbc.doc_graph_roots (secret-version-lifecycle) now says so and names the missing capability: the operator's first live rotation, with a human in the loop.

What lands

  • gunbc.auth.ci_app_key_rotation (new module):
    • ci_app_key_rotation_intent: ReceivedOutOfBand (GitHub only creates App private keys in the App settings page; no REST endpoint does), DisableOnly, and a one-consumer roster pinned to the exact new version.
    • ci_app_key_contract: the protection contract, attached to the real subject. Each field is read by something:
      • rotate_by is read by rotation_deadline_standing on every verify run. A passed or unorderable deadline refuses.
      • loss_remedy (reissue at the App settings page) is rendered into the unreadable-key and 401 refusals.
      • restore_test binds the rostered consumer, and the intent's roster is built from it. The retirement plan refuses unless that consumer is proven on the exact new version.
    • verify_app_key_version returns a typed verdict over what the mint observed. It refuses:
      • the alias latest, an empty selector, or anything that is not a version number;
      • an unreadable version, with the HTTP status;
      • a response naming a different version (the join uses resolved_identity_matches_request, so project-number spellings are accepted);
      • a missing or non-201 installation-token exchange.
    • app_key_verdict_evidence turns the verdict into consumer evidence for the kernel: ResolvedVersionObserved when the key mints, ConsumerUnreachable with the cause when it doesn't. It is never success-only.
  • fleet-converge mode app_key_version_verify, with a new app_key_version dispatch input:
    • Mints one installation token with versions/N through the same prelude the org-admin step uses. The verifier is one more AppInstallationTokenMint spec on main's github_app_installation_token_prelude (Heal publishes through a credential-isolated boundary, as an App that already existed #11547), with the key ref at the exact version and a new optional observation_record field, so there is still one place key material is written, with the same umask 077, chmod 600 and trap cleanup.
    • The regenerated org_actions_observe and heal-publisher steps are byte-identical.
    • A shell guard admits only a positive decimal version number before the URL is built. The .dag verdict re-applies the same rule (extdeps.cloud.gcp.secret_manager sm_version_id_is_number).
    • The verdict step runs on always(), so a refused mint still gets a typed verdict and a receipt. The receipt is uploaded as the app-key-version-verify artifact.
    • Cost: zero on any run that doesn't select this mode. It is a mode, not a job, and there is no skip toggle.
    • It mutates nothing: no add-version, no disable, no destroy, no GitHub write.
  • Kernel:
    • New plan_rotation_retirement puts the read-back first; consumer proofs are joined against the identity that read-back confirmed.
    • Defect fixed: authorize_retirement authorized DisableOnly retirement over an empty roster. A census over zero names is vacuously complete. It now refuses as RetirementCensusReachedNoOne { CensusReachedNoOne { roster_size: 0 } }.

Evidence

test.claim.ci_app_key_rotation_witness, 16 witnesses, each run with the locally built interpreter via gunbc run --function; all returned true.

  • Discriminating reds:
    • A 401 mint refuses the rotation, with the cause carried through and no targets, so the prior version stays enabled.
    • A read-back of another version refuses.
    • A payload mismatch refuses.
    • An empty roster refuses as CensusReachedNoOne.
  • Verifier reds: alias, non-number, 404, a response about another version, a 401, and a missing token observation.
  • Positive control: a mint on v7 plus a confirmed read-back retires exactly v6, with destroy not permitted.
  • Empty-roster red confirmed against the old kernel: in a scratch copy with the roster check removed, the witness returned false. The other reds call symbols that don't exist before this change, so they fail to compile there.

For the operator

  • rotate_by: 2026-12-18T00:00:00Z is a proposed policy value, 90 days out. Please set it. A completed rotation should move it forward.
  • Next act, safe now: dispatch app_key_version_verify with the currently enabled version number. That is the first restore rehearsal, and it answers "does the key in version N actually mint", which nothing could answer before this PR.
  • The first live rotation stays an operator act:
    1. Generate a new key in the App settings.
    2. Add it as a new version, once.
    3. Verify that exact version with this mode.
    4. Disable the prior version and read it back.

Out of scope, not touched: the org-admin credential deletion (org_admin_credential is still imported by the shared prelude for its two message functions; that lane owns their move), #11689, #11678.

🤖 Generated with Claude Code

Brian Searls and others added 14 commits September 19, 2026 17:23
…rsion verifier

The gunbai-ci App private key (ci-github-app-private-key) becomes the first real
subject of gunbc.auth.secret_rotation:

- gunbc.auth.ci_app_key_rotation: the intent (ReceivedOutOfBand, DisableOnly, one
  consumer pinned to the exact new version), the protection contract (rotate_by,
  loss remedy at the App settings page, restore test bound to the rostered
  consumer), and the verifier: typed verdict over what an exact-version mint
  observed, refusing the alias, unreadable versions, a response about another
  version, and a non-201 installation-token exchange. The verdict is the kernel's
  consumer evidence; the contract's deadline is read on every verify run.
- fleet-converge mode app_key_version_verify: mints one installation token with
  versions/N through the SAME prelude the org-admin step uses (now parameterized
  by key ref and an observation record); org_actions_observe emits unchanged.
  Mutates nothing: no add-version, disable or destroy.
- Kernel: plan_rotation_retirement orders read-back before census/proof;
  authorize_retirement now refuses an empty roster as CensusReachedNoOne (it
  previously authorized DisableOnly retirement over zero consumers).
- doc_graph_roots: the frontier row states the kernel has NOT actuated and names
  the missing capability: the operator's first live rotation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… prelude

#11547 generalized the mint into github_app_installation_token_prelude(spec).
The verifier becomes one more AppInstallationTokenMint (exact-version key ref,
observation record) instead of a second parameterization; org-admin and
heal-publisher steps emit byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	.github/workflows/fleet-converge.yml
#	dag/gunbc/fleet/fleet_converge_workflow.dag
…hind

Review 68730: the header cited gunbc_ci_app_key_version_verify_invoke, which the
re-seating commit replaced with a mint script plus a verdict invoke. DESIGN §3 —
a stale symbol name is decidable, so it is a citation defect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	.github/workflows/fleet-converge.yml
#	dag/gunbc/fleet/fleet_converge_workflow.dag
Ledger-Repair-Judged: docs/design-rung-drops.md
Ledger-Rows-Repaired: docs/design-rung-drops.md namespace_wave_admission_wall_removed
Heal-Candidate-Run: 35485965960
Both conflicts were generated artifacts (fleet-converge.yml, design-rung-drops.md);
resolved by taking main's bytes and re-deriving from the authorities, never by
hand-editing a projection. The rung-drops projection now matches main exactly
(#11812 fixed the sentence terminator in the projector).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main added the site_pxe_edge_converge mode in the same two places this cut
touches. Authority resolved by keeping both mode sets; the .yml re-derived from
it rather than hand-edited.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main added the site_pxe_edge_converge and spark_native_serving_apply modes in the
same declarations this cut extends. Mode list resolved as a union with main's
order first; the dispatch description keeps main's text with this cut's clause
inserted; the .yml re-derived from the authority.

NOTE for whoever hits this next: the merge first reported 'refusing to merge
unrelated histories'. The clone is SHALLOW (.git/shallow) and the graft boundary
had moved past the real merge base, so git could not see one while GitHub's own
compare reported diverged/11-ahead/4-behind. git fetch --deepen=100 origin main
restored it. A missing merge base here is a fetch-depth artifact, not a rewritten
main -- do not conclude history was force-pushed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
review 69158: the observation-record path lived in both the mint step's env (which
writes it) and the verdict step's env (which reads it) -- two sources for one fact,
and a split that fails closed while pointing the reader at the wrong step. The
dispatch-input expression was the same lapse. Both are now one data row consumed by
a shared env builder. The regenerated .yml is byte-identical, so this is single
authority and not a behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five conflict regions, all 'both sides added disjoint content at one spot':
main's mtcollins1_fan_observe mode against this cut's app_key_version_verify.
Mode list resolved as a union with main's order first; every other region keeps
both sides. Region 3 needed care -- both sides end MID-STEP and shared the
continue_on_error/timeout tail that followed, so concatenating them left one step
unterminated (the regen refused: 'heads-only parse: unterminated function body').
The first copy gets its own tail. The .yml is re-derived, not hand-edited, and
carries both modes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
gunbai-bot Bot pushed a commit that referenced this pull request Sep 20, 2026
At e0c453f it had one approval, no change requests and four green checks by
name; the eighth review cleared the capability-grained frontier row. It was
stopped by queue position on the contended pair, not by a defect.

The consequence is recorded rather than left implicit: the App private key still
has no rotation deadline, no restore test and no rehearsed compromise answer, and
nothing in the corpus can answer whether the key in a given version works.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brian Searls and others added 2 commits September 20, 2026 19:35
One conflict region: the dispatch mode list. Resolved as a union with main's
order first (main's approval_broker_dark_install plus this cut's
app_key_version_verify). The .yml is re-derived from the authority.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two conflict regions: the dispatch mode list (union, main's order first, picking
up main's microvm_controller_app_key_converge) and the dispatch description
(main's text with this cut's clause inserted). The .yml is re-derived.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gunbai-bot
gunbai-bot Bot added this pull request to the merge queue Sep 20, 2026
Merged via the queue into main with commit df15e75 Sep 20, 2026
4 checks passed
@gunbai-bot
gunbai-bot Bot deleted the session/wise-bee-389 branch September 20, 2026 23:35
@briansrls
briansrls restored the session/wise-bee-389 branch September 20, 2026 23:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants