From fbc4830aa0be0a1483f3abb0799a77c988f25ff2 Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 2 Nov 2026 23:41:20 +0100 Subject: [PATCH] Mint a separate MAS workload key in the attended gate instead of copying the supplier key. The lane design forbids copying the founder-held supplier key onto the workload path. The gate now mints a second key on the MAS application, proves it, writes it CAS=0 over HTTP, and deletes it if a later step fails. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 4541@bnt-lap001 Assistant-Session: 2099b3aa-d7d9-49a9-ab4e-fa8b7d0e39e8 --- .../mas-evidence-backup-grant-2026-11-02.md | 4 + scripts/mas_evidence_iam_session.mjs | 59 ++++- scripts/mas_evidence_publisher_gate.py | 207 ++++++++++++++---- tests/test_mas_evidence_publisher_gate.py | 110 ++++++++++ .../RPF-WP-0070-mas-evidence-recovery.md | 18 +- 5 files changed, 356 insertions(+), 42 deletions(-) diff --git a/docs/credential-lane-designs/mas-evidence-backup-grant-2026-11-02.md b/docs/credential-lane-designs/mas-evidence-backup-grant-2026-11-02.md index 63def04..ceb5258 100644 --- a/docs/credential-lane-designs/mas-evidence-backup-grant-2026-11-02.md +++ b/docs/credential-lane-designs/mas-evidence-backup-grant-2026-11-02.md @@ -54,6 +54,10 @@ and is not created here. denials still require this application's own key. 5. Provision the pair atomically with KV CAS=0 under the pair-writer policy extended to this one path. The single-field paste desk is unsuitable. + The workload pair is a second API key minted on the same IAM application + inside the attended gate (`mas_evidence_publisher_gate.py + --mint-workload`). It is never a copy of the supplier key. The gate + deletes the minted key if the proof or the write fails. 6. Record positive reader proof and negative denials, then set delivery admission. Export the admitted CCR and pin its SHA256 before creating `/etc/mas-backup/primary.env`. diff --git a/scripts/mas_evidence_iam_session.mjs b/scripts/mas_evidence_iam_session.mjs index a75b047..51df9bf 100644 --- a/scripts/mas_evidence_iam_session.mjs +++ b/scripts/mas_evidence_iam_session.mjs @@ -1,15 +1,26 @@ #!/usr/bin/env node -/** One attended Console login. The helper compares and corrects the MAS IAM rule. */ +/** + * One attended Console login. + * + * Default: the helper compares and corrects the MAS IAM rule. + * --mint-workload: start the attended OpenBao platform-admin login with the + * publisher gate as its child. The console session is read and sent only + * after the gate reports ready, so the OIDC login cannot age it out. + */ import {spawn} from 'node:child_process'; -import {readFileSync} from 'node:fs'; +import {existsSync, readFileSync, rmSync} from 'node:fs'; +import {homedir} from 'node:os'; import {dirname, join, resolve} from 'node:path'; import {fileURLToPath, pathToFileURL} from 'node:url'; import {parseArgs} from 'node:util'; import {createInterface} from 'node:readline/promises'; -const {values} = parseArgs({options: {'playwright-module': {type: 'string'}}}); +const {values} = parseArgs({options: {'playwright-module': {type: 'string'}, 'mint-workload': {type: 'boolean'}}}); const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const grant = join(root, 'scripts', 'mas_evidence_iam_grant.py'); +const gate = join(root, 'scripts', 'mas_evidence_publisher_gate.py'); +const ready = join(homedir(), '.local/state/railiance/receipts/mas-evidence-mint-ready.json'); +const READY_TIMEOUT_MS = 10 * 60 * 1000; let browser; let child; @@ -45,6 +56,46 @@ function runGrant(auth) { }); } +function runMint(page) { + rmSync(ready, {force: true}); + return new Promise((resolveResult, reject) => { + const env = {...process.env}; + env.BAO_ADDR = env.BAO_ADDR || 'http://127.0.0.1:18200'; + env.VAULT_ADDR = env.VAULT_ADDR || env.BAO_ADDR; + child = spawn('warden', [ + 'access', 'openbao-platform-admin-login', '--exec', '--', 'python3', gate, '--mint-workload', + ], {stdio: ['pipe', 'inherit', 'inherit'], env}); + let sent = false; + const started = Date.now(); + const timer = setInterval(async () => { + if (sent) return; + if (Date.now() - started > READY_TIMEOUT_MS) { + sent = true; + clearInterval(timer); + child?.stdin.end(''); + return; + } + if (!existsSync(ready)) return; + sent = true; + clearInterval(timer); + try { + child?.stdin.end(JSON.stringify(await sessionAuth(page))); + } catch { + child?.stdin.end(''); + } + }, 1000); + child.on('error', () => { + clearInterval(timer); + reject(new Error('warden_child_failed')); + }); + child.on('close', (code) => { + clearInterval(timer); + child = null; + resolveResult({exit_code: code, output: `warden exit ${code}; receipts under ~/.local/state/railiance/receipts\n`}); + }); + }); +} + try { const modulePath = resolve(values['playwright-module'] || ''); const pkg = JSON.parse(readFileSync(join(dirname(modulePath), 'package.json'), 'utf8')); @@ -61,7 +112,7 @@ try { console.log('Console opened. Sign in, then press Enter. The session token stays in this process.'); await reader.question(''); reader.close(); - const outcome = await runGrant(await sessionAuth(page)); + const outcome = values['mint-workload'] ? await runMint(page) : await runGrant(await sessionAuth(page)); process.stdout.write(outcome.output.endsWith('\n') ? outcome.output : `${outcome.output}\n`); process.exitCode = outcome.exit_code === 0 ? 0 : 1; try { diff --git a/scripts/mas_evidence_publisher_gate.py b/scripts/mas_evidence_publisher_gate.py index 7c47d26..97c9945 100755 --- a/scripts/mas_evidence_publisher_gate.py +++ b/scripts/mas_evidence_publisher_gate.py @@ -1,8 +1,15 @@ #!/usr/bin/env python3 -"""Apply the MAS publisher boundary and write its one CAS=0 pair. +"""Mint the MAS workload key, prove its boundary, and write its one CAS=0 pair. + +Run only as `--mint-workload` inside the attended OpenBao platform-admin login, +started by `mas_evidence_iam_session.mjs --mint-workload`. The founder-held +supplier key at SUPPLIER is not read or copied: the workload pair is a second +API key on the same IAM application, so it can be revoked on its own. A +temporary Scaleway Console session arrives on stdin after the gate signals +readiness. If anything fails after the mint, the minted key is deleted. Stdout stays empty. Receipts record statement ids, HTTP statuses, versions, -and field names. Values are never written to a receipt or to stderr. +and field names. Values are never written to a receipt, a file, or stderr. """ from __future__ import annotations @@ -13,7 +20,7 @@ import pwd import re import subprocess import sys -import tempfile +import time import urllib.request from pathlib import Path @@ -45,6 +52,13 @@ STATEMENT_LIMIT = 10 SUPPLIER = "platform/suppliers/scaleway/mas-evidence-backup-primary" WORKLOAD = "platform/workloads/mas/backup-primary" PREFERRED_PROJECT = "reef-storage" +PROJECT_ID = "e1a0dd0e-04b8-4ea9-8b30-d53f8c35c688" +POLICY_NAME = "mas-evidence-backup-primary" +WORKLOAD_KEY_DESCRIPTION = "MAS evidence workload pair; RPF-WP-0070; node delivery" +READY_NAME = "mas-evidence-mint-ready.json" +MINT_NAME = "mas-evidence-workload-mint.json" +PROOF_ATTEMPTS = 7 +PROOF_DELAY_SECONDS = 10 BAO_ADDR = "http://127.0.0.1:18200" _PROJECT_ID = re.compile( r"\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\Z" @@ -540,24 +554,27 @@ class _Transport: return None def put_cas0(self, path, body): - handle = tempfile.NamedTemporaryFile( - "w", prefix="mas-pair-", dir=RECEIPT_DIR, delete=False - ) - name = Path(handle.name) - try: - json.dump(body["data"], handle) - handle.flush() - os.fchmod(handle.fileno(), 0o600) - handle.close() - proc = _bao( - ["kv", "put", "-format=json", "-cas=0", logical_path(path), f"@{name}"], - token=self.token, - ) - finally: - name.unlink(missing_ok=True) - if proc.returncode: + """Write over the HTTP API. The value never touches a file.""" + if logical_path(path) != WORKLOAD or body.get("options") != {"cas": 0}: raise GateError("pair_write_failed") - return version_from_put(json.loads(proc.stdout)) + request = urllib.request.Request( + BAO_ADDR + "/v1/" + path, + data=json.dumps(body).encode(), + method="POST", + headers={"X-Vault-Token": self.token, "Content-Type": "application/json"}, + ) + opener = urllib.request.build_opener(_NoRedirect) + try: + with opener.open(request, timeout=20) as response: + raw = response.read(65537) + except GateError: + raise + except Exception: + raise GateError("pair_write_failed") from None + try: + return version_from_put(json.loads(raw)) + except json.JSONDecodeError: + raise GateError("pair_write_failed") from None def revoke(self): _bao(["token", "revoke", "-self"], token=self.token) @@ -627,9 +644,6 @@ def write_pair(pair): existing = _bao(["kv", "metadata", "get", "-format=json", WORKLOAD]) if existing.returncode == 0: raise GateError("existing_custody_requires_rotation_review") - written = _bao(["policy", "write", "backup-pair-writer", str(POLICY_FILE)]) - if written.returncode: - raise GateError("writer_policy_install_failed") auth = _request_writer_auth() token = auth["client_token"] try: @@ -657,31 +671,148 @@ def write_pair(pair): return summary -def main(): +def _app_keys(api): + value = api( + "GET", + "/iam/v1alpha1/api-keys?application_id=" + APPLICATION_ID + "&page_size=100", + ) + keys = value.get("api_keys") if isinstance(value, dict) else None + if not isinstance(keys, list) or value.get("total_count") != len(keys): + raise GateError("key_inventory_rejected") + return [item for item in keys if item.get("application_id") == APPLICATION_ID] + + +def _key_view(keys): + return { + "count": len(keys), + "descriptions": sorted(str(item.get("description") or "") for item in keys), + "default_project_ids": sorted({str(item.get("default_project_id")) for item in keys}), + } + + +def mint_workload_pair(api, prove, write, sleep=time.sleep): + """Mint one more key on the MAS application, prove it, then store it CAS=0. + + `api` is the console transport. `prove(pair, project)` and `write(pair)` + are the S3 proof and the OpenBao write. The minted key is deleted when a + later step fails, and the receipt says whether that deletion happened. + """ + receipt = {"application_id": APPLICATION_ID, "values_recorded": False} + app = api("GET", "/iam/v1alpha1/applications/" + APPLICATION_ID) + if app.get("name") != POLICY_NAME: + raise GateError("application_binding_refused") + before = _app_keys(api) + receipt["keys_before"] = _key_view(before) + if len(before) != 1 or before[0].get("default_project_id") != PROJECT_ID: + raise GateError("supplier_key_state_requires_review", receipt["keys_before"]) + issued = api("POST", "/iam/v1alpha1/api-keys", { + "application_id": APPLICATION_ID, + "default_project_id": PROJECT_ID, + "description": WORKLOAD_KEY_DESCRIPTION, + }) + access = issued.get("access_key") if isinstance(issued, dict) else None + try: + if issued.get("application_id") != APPLICATION_ID: + raise GateError("issued_key_binding_refused") + pair = { + "AWS_ACCESS_KEY_ID": access, + "AWS_SECRET_ACCESS_KEY": issued.get("secret_key"), + } + if any(not isinstance(value, str) or not value for value in pair.values()): + raise GateError("issued_key_incomplete") + receipt["minted_at"] = issued.get("created_at") + attempts = 0 + while True: + attempts += 1 + try: + prove(pair, PROJECT_ID) + break + except GateError as exc: + if exc.code != "publisher_denials_failed" or attempts >= PROOF_ATTEMPTS: + raise + sleep(PROOF_DELAY_SECONDS) + receipt["proof_attempts"] = attempts + receipt["pair"] = write(pair) + except Exception as exc: + deleted = False + if isinstance(access, str) and access: + try: + api("DELETE", "/iam/v1alpha1/api-keys/" + access) + deleted = True + except Exception: + deleted = False + code = exc.code if isinstance(exc, GateError) else "contained_gate_failure" + detail = dict(exc.detail or {}) if isinstance(exc, GateError) and isinstance(exc.detail, dict) else {} + detail["minted_key_deleted"] = deleted + raise GateError(code, detail) from None + receipt["keys_after"] = _key_view(_app_keys(api)) + receipt["status"] = "passed" + return receipt + + +def _console_api(raw): + import importlib.util + + if len(raw) > 65536: + raise GateError("console_auth_rejected") + try: + auth = json.loads(raw) + except json.JSONDecodeError: + raise GateError("console_auth_rejected") from None + spec = importlib.util.spec_from_file_location( + "application_recovery_identities", ROOT / "scripts/application_recovery_identities.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + try: + transport = module.Transport(auth) + except module.Refused: + raise GateError("console_auth_rejected") from None + + def api(method, path, data=None): + try: + return transport(method, path, data) + except module.ProviderError as exc: + raise GateError("provider_http_error", {"method": method, "http_status": exc.status}) from None + except module.Refused as exc: + raise GateError("provider_refused", {"reason": str(exc)}) from None + + return api + + +def _preflight(): + existing = _bao(["kv", "metadata", "get", "-format=json", WORKLOAD]) + if existing.returncode == 0: + raise GateError("existing_custody_requires_rotation_review") + if _bao(["policy", "write", "backup-pair-writer", str(POLICY_FILE)]).returncode: + raise GateError("writer_policy_install_failed") + + +def main(argv=None): + args = list(sys.argv[1:] if argv is None else argv) sys.stdout = open(os.devnull, "w") sys.stderr = open(os.devnull, "w") + receipt_path = RECEIPT_DIR / "mas-evidence-publisher-gate.json" try: + if args != ["--mint-workload"]: + raise GateError("usage_refused") _ensure_boto() RECEIPT_DIR.mkdir(mode=0o700, exist_ok=True) - supplier = supplier_pair(_kv_data(SUPPLIER), SUPPLIER) - backup_path = "platform/workloads/railiance/backup/object-storage" - bootstrap_path = "platform/workloads/railiance/scaleway/bootstrap" - backup = _kv_data(backup_path) - bootstrap = _kv_data(bootstrap_path) - reader = supplier_pair(backup, backup_path) - writer = supplier_pair(bootstrap, bootstrap_path) - apply_live_policy(reader, writer) - prove_denials(supplier, bootstrap.get("DEFAULT_PROJECT_ID", "")) - write_pair(supplier) + (RECEIPT_DIR / MINT_NAME).unlink(missing_ok=True) + _preflight() + _write_json(RECEIPT_DIR / READY_NAME, {"status": "waiting_for_console", "values_recorded": False}) + api = _console_api(sys.stdin.read(65537)) + receipt = mint_workload_pair(api, prove_denials, write_pair) + _write_json(RECEIPT_DIR / MINT_NAME, receipt) except GateError as exc: receipt = {"status": "failed", "code": exc.code, "values_recorded": False} if exc.detail: receipt["detail"] = exc.detail - _write_json(RECEIPT_DIR / "mas-evidence-publisher-gate.json", receipt) + _write_json(receipt_path, receipt) return 71 except Exception as exc: _write_json( - RECEIPT_DIR / "mas-evidence-publisher-gate.json", + receipt_path, { "status": "failed", "code": "contained_gate_failure", @@ -690,7 +821,9 @@ def main(): }, ) return 72 - _write_json(RECEIPT_DIR / "mas-evidence-publisher-gate.json", {"status": "passed", "values_recorded": False}) + finally: + (RECEIPT_DIR / READY_NAME).unlink(missing_ok=True) + _write_json(receipt_path, {"status": "passed", "values_recorded": False}) return 0 diff --git a/tests/test_mas_evidence_publisher_gate.py b/tests/test_mas_evidence_publisher_gate.py index b53b41e..6ee23d1 100644 --- a/tests/test_mas_evidence_publisher_gate.py +++ b/tests/test_mas_evidence_publisher_gate.py @@ -249,3 +249,113 @@ def test_pair_custody_preview_includes_mas_and_the_writer_policy_names_it(): text = (ROOT / "openbao/policies/backup-pair-writer.hcl").read_text(encoding="utf-8") assert 'path "platform/data/workloads/mas/backup-primary"' in text assert "read" not in text.split('path "platform/data/workloads/mas/backup-primary"')[1].split("path ")[0] + + +class _FakeConsole: + """Scaleway IAM stand-in. Records calls; never returns the supplier secret.""" + + def __init__(self, keys=None, issued=None, name="mas-evidence-backup-primary"): + self.name = name + self.keys = keys if keys is not None else [ + {"application_id": gate.APPLICATION_ID, "default_project_id": gate.PROJECT_ID, + "description": "supplier"} + ] + self.issued = issued if issued is not None else { + "application_id": gate.APPLICATION_ID, "access_key": "SCWMINTED", + "secret_key": "minted-secret", "created_at": "2026-11-02T20:00:00Z", + } + self.calls = [] + + def __call__(self, method, path, data=None): + self.calls.append((method, path)) + if method == "GET" and path.startswith("/iam/v1alpha1/applications/"): + return {"name": self.name} + if method == "GET" and path.startswith("/iam/v1alpha1/api-keys?"): + return {"api_keys": list(self.keys), "total_count": len(self.keys)} + if method == "POST": + assert data["application_id"] == gate.APPLICATION_ID + assert data["default_project_id"] == gate.PROJECT_ID + self.keys.append({"application_id": gate.APPLICATION_ID, + "default_project_id": gate.PROJECT_ID, + "description": data["description"]}) + return dict(self.issued) + if method == "DELETE": + return {} + raise AssertionError((method, path)) + + +def test_mint_proves_then_writes_the_minted_key_not_the_supplier(): + api = _FakeConsole() + seen = {} + + def prove(pair, project): + seen["prove"] = (pair["AWS_ACCESS_KEY_ID"], project) + + def write(pair): + seen["write"] = dict(pair) + return {"current_version": 1} + + receipt = gate.mint_workload_pair(api, prove, write, sleep=lambda _: None) + assert seen["prove"] == ("SCWMINTED", gate.PROJECT_ID) + assert seen["write"] == {"AWS_ACCESS_KEY_ID": "SCWMINTED", "AWS_SECRET_ACCESS_KEY": "minted-secret"} + assert receipt["status"] == "passed" and receipt["keys_after"]["count"] == 2 + assert "minted-secret" not in json.dumps(receipt) and "SCWMINTED" not in json.dumps(receipt) + assert not any(method == "DELETE" for method, _ in api.calls) + + +def test_mint_retries_propagation_then_deletes_the_key_when_proof_never_passes(): + api = _FakeConsole() + sleeps = [] + + def prove(pair, project): + raise gate.GateError("publisher_denials_failed") + + with pytest.raises(gate.GateError) as caught: + gate.mint_workload_pair(api, prove, lambda pair: None, sleep=sleeps.append) + assert caught.value.code == "publisher_denials_failed" + assert caught.value.detail == {"minted_key_deleted": True} + assert len(sleeps) == gate.PROOF_ATTEMPTS - 1 + assert ("DELETE", "/iam/v1alpha1/api-keys/SCWMINTED") in api.calls + + +def test_mint_deletes_the_key_when_the_pair_write_fails(): + api = _FakeConsole() + + def write(pair): + raise gate.GateError("existing_custody_requires_rotation_review") + + with pytest.raises(gate.GateError) as caught: + gate.mint_workload_pair(api, lambda pair, project: None, write) + assert caught.value.code == "existing_custody_requires_rotation_review" + assert caught.value.detail["minted_key_deleted"] is True + + +@pytest.mark.parametrize("console", [ + _FakeConsole(name="email-connect-backup-primary"), + _FakeConsole(keys=[]), + _FakeConsole(keys=[{"application_id": gate.APPLICATION_ID, "default_project_id": gate.PROJECT_ID}] * 2), + _FakeConsole(keys=[{"application_id": gate.APPLICATION_ID, "default_project_id": "other"}]), +]) +def test_mint_refuses_before_issuing_when_application_or_key_state_is_unexpected(console): + with pytest.raises(gate.GateError): + gate.mint_workload_pair(console, lambda *a: None, lambda pair: None) + assert not any(method in ("POST", "DELETE") for method, _ in console.calls) + + +def test_incomplete_issue_is_deleted_and_never_written(): + api = _FakeConsole(issued={"application_id": gate.APPLICATION_ID, "access_key": "SCWMINTED"}) + written = [] + with pytest.raises(gate.GateError) as caught: + gate.mint_workload_pair(api, lambda *a: None, written.append) + assert caught.value.code == "issued_key_incomplete" + assert caught.value.detail["minted_key_deleted"] is True and written == [] + + +def test_main_refuses_without_the_mint_flag(tmp_path, monkeypatch): + import sys + + monkeypatch.setattr(gate, "RECEIPT_DIR", tmp_path) + monkeypatch.setattr(sys, "stdout", sys.stdout) + monkeypatch.setattr(sys, "stderr", sys.stderr) + assert gate.main([]) == 71 + assert json.loads((tmp_path / "mas-evidence-publisher-gate.json").read_text())["code"] == "usage_refused" diff --git a/workplans/RPF-WP-0070-mas-evidence-recovery.md b/workplans/RPF-WP-0070-mas-evidence-recovery.md index 1255f22..25cf380 100644 --- a/workplans/RPF-WP-0070-mas-evidence-recovery.md +++ b/workplans/RPF-WP-0070-mas-evidence-recovery.md @@ -40,7 +40,7 @@ status: progress priority: medium needs_human: true depends_on: [RPF-WP-0053-T05] -intervention_note: "Next: one attended publisher-gate login with the num_uses fix (ca7c46b). Done when the CAS=0 pair is written and the receipt records the MAS prefix HTTP 200 and the three AccessDenied denials. CCR-2026-0062 is applied. IAM application mas-evidence-backup-primary is 25a4cbca-220e-40c0-9af5-6c7713000174. The founder stored its API key at platform/suppliers/scaleway/mas-evidence-backup-primary, preferred project mail-address-semantics. The source bucket policy names that id under platform-pg/application-archives/mas/ with no object delete. The workload pair is still empty. Delivery admission stays false. Age-identity escrow remains separate." +intervention_note: "Next: one attended run of `node scripts/mas_evidence_iam_session.mjs --mint-workload --playwright-module `: sign in to the Scaleway Console, press Enter, then complete the OpenBao platform-admin login. Done when ~/.local/state/railiance/receipts/mas-evidence-workload-mint.json says passed: a second key on the MAS application, its prefix HTTP 200 and three AccessDenied denials, and the CAS=0 pair. CCR-2026-0062 is applied. IAM application mas-evidence-backup-primary is 25a4cbca-220e-40c0-9af5-6c7713000174. The founder stored its API key at platform/suppliers/scaleway/mas-evidence-backup-primary, preferred project mail-address-semantics. The source bucket policy names that id under platform-pg/application-archives/mas/ with no object delete. The workload pair is still empty. Delivery admission stays false. Age-identity escrow remains separate." state_hub_task_id: "4f6484fb-8303-5dd5-94cb-b2622e1322c8" ``` @@ -88,6 +88,22 @@ Design: `docs/credential-lane-designs/mas-evidence-backup-grant-2026-11-02.md`. 2026-11-02: further IAM tuning stays off the console form. `scripts/mas_evidence_iam_grant.py` uses one temporary Scaleway Console session, the same stdin shape as the Coulomb identity bootstrap. The first run created `mas-evidence-backup-primary` on project `mail-address-semantics`. The receipt showed the bucket publisher `coulomb-social-backup-primary` on project `reef-storage`, while `coulomb-recovery-project-publisher` is the separate recovery project. The second run replaced that one MAS rule and moved the single API key's preferred project to `reef-storage` (`e1a0dd0e-04b8-4ea9-8b30-d53f8c35c688`). Outcome `rule_replaced`, status `aligned`. The following publisher login proved the MAS prefix with HTTP 200 and the three denials with `AccessDenied`. The pair was not written: the OpenBao 2.5 CLI omits `num_uses` from `token create` JSON, so the gate rejected a writer whose policies and 300-second ttl were otherwise exact. The gate now reads `num_uses` from the raw create response. +2026-11-02: the gate no longer copies the founder-held supplier key onto +the workload path, which the lane design forbids. `--mint-workload` runs +inside the attended OpenBao platform-admin login, started by +`mas_evidence_iam_session.mjs --mint-workload`. Before it reads the +Console session, it checks that the workload path is empty and installs the +writer policy. It then mints one more API key on application +`25a4cbca-220e-40c0-9af5-6c7713000174` with preferred project +`reef-storage`, and refuses unless that application has exactly one +existing key. It proves the new key against the prefix and the three +denials, retrying up to 7 times for propagation, and writes it CAS=0 over +the OpenBao HTTP API. No value touches a file. If any step after the mint +fails, the gate deletes the minted key and the receipt records that it did. +The supplier key stays founder custody and can be revoked separately. +Tests cover the mint, retry, deletion and refusal paths. The attended +run is still pending. + ## Admit delivery and place the node file ```task