Harden backup credentials and add durable image inventory publication
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06ecb-456a-71c2-b41e-0755d336e883
This commit is contained in:
codex 2026-09-05 01:53:24 +02:00
parent 62423fd092
commit 0349a08e1b
13 changed files with 365 additions and 23 deletions

View file

@ -471,6 +471,14 @@ forgejo-backup-status: ## Show last Forgejo backup success and 7-day gate
forgejo-package-prune-dry-run: ## List Forgejo package versions beyond retention depth (no deletes)
tools/cmd/forgejo-package-prune
LIVE_IMAGES_OUTPUT ?= $(HOME)/.local/state/railiance-platform/live-images/all.txt
LIVE_IMAGES_SOURCES ?=
.PHONY: live-images-refresh
live-images-refresh: ## Atomically preserve and merge image exports outside the checkout
@test -n "$(LIVE_IMAGES_SOURCES)" || { echo 'set LIVE_IMAGES_SOURCES to nonempty cluster export files' >&2; exit 2; }
python3 scripts/refresh_live_images.py --output "$(LIVE_IMAGES_OUTPUT)" \
$(foreach source,$(LIVE_IMAGES_SOURCES),--source "$(source)")
forgejo-package-prune: ## Prune Forgejo packages — keep newest 3 versions per package
tools/cmd/forgejo-package-prune --apply

View file

@ -26,13 +26,13 @@
| workplan | RPF-WP-0015 | active | — | workplans/RPF-WP-0015-audit-core-custody-and-recovery-coordination.md |
| workplan | RPF-WP-0016 | finished | — | workplans/RPF-WP-0016-versioned-ephemeral-custody-lifecycle.md |
| workplan | RPF-WP-0017 | finished | — | workplans/RPF-WP-0017-attended-login-output-containment.md |
| workplan | RPF-WP-0018 | blocked | — | workplans/RPF-WP-0018-openbao-operator-only-access.md |
| workplan | RPF-WP-0018 | finished | — | workplans/RPF-WP-0018-policy-surface-alignment.md |
| workplan | RPF-WP-0019 | finished | — | workplans/RPF-WP-0019-apps-pg-recoverability-and-controls.md |
| workplan | RPF-WP-0019 | finished | — | workplans/RPF-WP-0019-flex-auth-credential-grant-integration.md |
| workplan | RPF-WP-0020 | finished | — | workplans/RPF-WP-0020-ccr-schema-drift.md |
| workplan | RPF-WP-0020 | active | — | workplans/RPF-WP-0020-keycape-live-secret-exposure-recovery.md |
| workplan | RPF-WP-0021 | finished | — | workplans/RPF-WP-0021-core-hub-platform-onboarding.md |
| workplan | RPF-WP-0025 | blocked | — | workplans/RPF-WP-0025-openbao-operator-only-access.md |
| workplan | RPF-WP-0026 | finished | — | workplans/RPF-WP-0026-flex-auth-credential-grant-integration.md |
| workplan | RPF-WP-0027 | active | — | workplans/RPF-WP-0027-keycape-live-secret-exposure-recovery.md |
| task | ADHOC-2026-08-23-T01 | done | — | workplans/ADHOC-2026-08-23.md |
| task | ADHOC-2026-08-23-T02 | done | — | workplans/ADHOC-2026-08-23.md |
| task | RPF-WP-0001-T01 | done | — | workplans/RPF-WP-0001-credential-request-and-lease-broker.md |
@ -116,9 +116,6 @@
| task | RPF-WP-0016-T05 | done | — | workplans/RPF-WP-0016-versioned-ephemeral-custody-lifecycle.md |
| task | RPF-WP-0016-T06 | done | — | workplans/RPF-WP-0016-versioned-ephemeral-custody-lifecycle.md |
| task | RPF-WP-0017-T01 | done | — | workplans/RPF-WP-0017-attended-login-output-containment.md |
| task | RPF-WP-0018-T01 | done | — | workplans/RPF-WP-0018-openbao-operator-only-access.md |
| task | RPF-WP-0018-T02 | done | — | workplans/RPF-WP-0018-openbao-operator-only-access.md |
| task | RPF-WP-0018-T03 | wait | — | workplans/RPF-WP-0018-openbao-operator-only-access.md |
| task | RPF-WP-0018-T01 | done | — | workplans/RPF-WP-0018-policy-surface-alignment.md |
| task | RPF-WP-0018-T02 | done | — | workplans/RPF-WP-0018-policy-surface-alignment.md |
| task | RPF-WP-0018-T03 | done | — | workplans/RPF-WP-0018-policy-surface-alignment.md |
@ -130,21 +127,24 @@
| task | RPF-WP-0019-T02 | done | — | workplans/RPF-WP-0019-apps-pg-recoverability-and-controls.md |
| task | RPF-WP-0019-T03 | done | — | workplans/RPF-WP-0019-apps-pg-recoverability-and-controls.md |
| task | RPF-WP-0019-T04 | done | — | workplans/RPF-WP-0019-apps-pg-recoverability-and-controls.md |
| task | RPF-WP-0019-T01 | done | — | workplans/RPF-WP-0019-flex-auth-credential-grant-integration.md |
| task | RPF-WP-0019-T02 | done | — | workplans/RPF-WP-0019-flex-auth-credential-grant-integration.md |
| task | RPF-WP-0019-T03 | done | — | workplans/RPF-WP-0019-flex-auth-credential-grant-integration.md |
| task | RPF-WP-0020-T01 | done | — | workplans/RPF-WP-0020-ccr-schema-drift.md |
| task | RPF-WP-0020-T02 | done | — | workplans/RPF-WP-0020-ccr-schema-drift.md |
| task | RPF-WP-0020-T03 | done | — | workplans/RPF-WP-0020-ccr-schema-drift.md |
| task | RPF-WP-0020-T04 | done | — | workplans/RPF-WP-0020-ccr-schema-drift.md |
| task | RPF-WP-0020-T01 | done | — | workplans/RPF-WP-0020-keycape-live-secret-exposure-recovery.md |
| task | RPF-WP-0020-T02 | done | — | workplans/RPF-WP-0020-keycape-live-secret-exposure-recovery.md |
| task | RPF-WP-0020-T03 | progress | — | workplans/RPF-WP-0020-keycape-live-secret-exposure-recovery.md |
| task | RPF-WP-0020-T04 | wait | — | workplans/RPF-WP-0020-keycape-live-secret-exposure-recovery.md |
| task | RPF-WP-0020-T05 | wait | — | workplans/RPF-WP-0020-keycape-live-secret-exposure-recovery.md |
| task | RPF-WP-0020-T06 | progress | — | workplans/RPF-WP-0020-keycape-live-secret-exposure-recovery.md |
| task | RPF-WP-0021-T01 | done | — | workplans/RPF-WP-0021-core-hub-platform-onboarding.md |
| task | RPF-WP-0021-T02 | done | — | workplans/RPF-WP-0021-core-hub-platform-onboarding.md |
| task | RPF-WP-0021-T03 | done | — | workplans/RPF-WP-0021-core-hub-platform-onboarding.md |
| task | RPF-WP-0021-T04 | done | — | workplans/RPF-WP-0021-core-hub-platform-onboarding.md |
| task | RPF-WP-0021-T05 | done | — | workplans/RPF-WP-0021-core-hub-platform-onboarding.md |
| task | RPF-WP-0025-T01 | done | — | workplans/RPF-WP-0025-openbao-operator-only-access.md |
| task | RPF-WP-0025-T02 | done | — | workplans/RPF-WP-0025-openbao-operator-only-access.md |
| task | RPF-WP-0025-T03 | wait | — | workplans/RPF-WP-0025-openbao-operator-only-access.md |
| task | RPF-WP-0026-T01 | done | — | workplans/RPF-WP-0026-flex-auth-credential-grant-integration.md |
| task | RPF-WP-0026-T02 | done | — | workplans/RPF-WP-0026-flex-auth-credential-grant-integration.md |
| task | RPF-WP-0026-T03 | done | — | workplans/RPF-WP-0026-flex-auth-credential-grant-integration.md |
| task | RPF-WP-0027-T01 | done | — | workplans/RPF-WP-0027-keycape-live-secret-exposure-recovery.md |
| task | RPF-WP-0027-T02 | done | — | workplans/RPF-WP-0027-keycape-live-secret-exposure-recovery.md |
| task | RPF-WP-0027-T03 | progress | — | workplans/RPF-WP-0027-keycape-live-secret-exposure-recovery.md |
| task | RPF-WP-0027-T04 | wait | — | workplans/RPF-WP-0027-keycape-live-secret-exposure-recovery.md |
| task | RPF-WP-0027-T05 | wait | — | workplans/RPF-WP-0027-keycape-live-secret-exposure-recovery.md |
| task | RPF-WP-0027-T06 | progress | — | workplans/RPF-WP-0027-keycape-live-secret-exposure-recovery.md |

View file

@ -0,0 +1,44 @@
# Repository review — 2026-09-05
Reviewed the working tree, fetched origin, scanned all local workplan statuses,
and read/acknowledged the State Hub inbox. Existing WORK-RECORDS.md changes
correct renumbered plans 00250027 and are retained.
Open plans 0015, 0025 and 0027 retain their attended gates: approved exercise
windows/abort operators, operator listener cutover, and exact recovery/custody
receipts. No new qualifying receipts were in the unread inbox.
New actionable source work is tracked by RPF-WP-0028 (durable image inventory)
and RPF-WP-0029 (backup credential fallback). Their live completion gates remain
explicit. No prune, reboot, lease revocation, or provider rotation was run.
Other inbox demands requiring scoped design or owner inputs remain pending:
- ops-mason fluid-telegram: tenant prefix and actual OIDC group claim remain
unconfirmed. Read/write policy requires reviewed CCR schema/validator support;
current read-only request shape must not be bypassed.
- secrets-engine: native AppRole apply still requires serving canonical action
authorization, exact targets, attended authority and consumer health proof.
Its separate service JWT mount/role contract is not yet established here.
- State Hub rename preflight: dedicated signing-secret lane remains to be
designed and approved; the rename is outside this session's scope.
- Policy Nexus CCR-2026-0014 request is superseded by the repository's existing
activation commit 62423fd; no duplicate provisioning performed.
- Activity-core OpenRouter recovery was reported complete and its intentional
reuse decision retained in RPF-WP-0004.
No coordination messages were sent. Incoming requests are recorded here for
follow-up rather than treated as permission to invent identities or credentials.
Validation: unittest discovery passed 158 tests; full pytest discovery passed
164 tests, including the function-based tests omitted by unittest. Bash syntax
and git diff whitespace checks passed. The inventory and credential tests use synthetic
inputs and mocked custody only.
State Hub synchronization remains pending: `statehub fix-consistency` was
attempted in the sandbox and once outside it. Both were interrupted after
prolonged HTTP reads during the check phase, before a repair report was
returned. No registrar skip was reported, so registrar authority was not
assumed. Re-run `statehub fix-consistency` when these reads complete reliably
to index the new workplans and refresh the generated records/brief. Progress
was logged successfully as `7e317a96-eb7e-4b36-9d81-4efe49efa22c`.

View file

@ -26,6 +26,13 @@ Preflight: `forgejo-db` pod must be Ready (`make forgejo-db-status`). A full dum
with 13 org repos is ~670MiB — allow 1020 minutes for stream + age on a typical
workstation link.
Uploads require the governed `CCR-2026-0004` OpenBao offsite lane or explicitly
provided `RAILIANCE_BACKUP_NC_TOKEN` / `RAILIANCE_BACKUP_NC_WEBDAV_URL` inputs.
There is no built-in credential fallback. Missing credentials stop execution
before any cluster dump. Local encryption dry-runs skip upload authentication.
Provider rotation and replacement upload/restore proof are tracked separately
in `RPF-WP-0029`; removing the source default does not prove revocation.
Decrypt: `~/.config/age/railiance-backup.key` (same key as other Railiance backups).
## Nextcloud layout
@ -62,4 +69,4 @@ See `railiance-infra`:
- `docs/forgejo-restore-drill-evidence.md`
Download and decrypt a dump from Nextcloud, set `BACKUP_LOCAL` to the zip path,
then run the drill script.
then run the drill script.

View file

@ -73,7 +73,38 @@ make forgejo-package-prune-dry-run # list would-delete candidates
make forgejo-package-prune # delete beyond retention depth
```
## Rollback
## Durable multi-cluster image inventory
The inventory belongs outside the platform checkout. On the production worker
host, use `/home/tegwick/.local/state/railiance-platform/live-images/all.txt`.
`make live-images-refresh LIVE_IMAGES_SOURCES="/path/railiance01.txt /path/coulombcore.txt"`
merges complete, nonempty exports with the prior inventory under a writer lock,
then atomically publishes sorted image references and a count/hash receipt.
Set `LIVE_IMAGES_OUTPUT` when publishing for a different host user.
Missing, empty, or malformed input fails without replacing the previous file.
Refresh only adds protection: removal of obsolete tags requires a separate
review of every production cluster and rollback requirement.
Migration and rollout sequence:
1. Seed from the restored `docs/evidence/live-images-all.txt` plus fresh exports
from railiance01 and coulombcore; retain the restored multi-cluster entries.
2. Project the durable **directory** read-only into the activity-core worker
and configure its inventory argument to `all.txt` in that directory. Do not
mount the single file: atomic replacement must be visible to the worker.
3. Verify the worker reads a nonempty inventory and retains the existing hard
refusal of apply when that file is missing or empty. Use only its dry-run
verification; do not manually trigger prune.
4. After each cluster image rollout, export pod regular, init, and ephemeral
container images to a temporary file. Require successful kubectl exit and
nonempty output, then invoke this refresh target with that export. Keep
other clusters' prior entries during outages. Run the hook on the durable
host, or transfer the complete export there before invoking it.
The publisher is implemented here; host migration and the activity-core mount
and rollout-hook adoption are tracked in `RPF-WP-0028` until verified live.
## Rollback procedure
If a needed tag was removed, restore from the latest Nextcloud `forgejo-dump-*.zip.age`
(`make forgejo-backup` evidence) using `railiance-infra/tools/forgejo-restore-drill.sh`,

View file

@ -18,12 +18,12 @@ railiance_backup_load_openbao_lane() {
railiance_backup_require_openbao_lane() {
railiance_backup_load_openbao_lane
# Nextcloud *file drop* shares use /public.php/dav/filesdrop/<token>/ — not /public.php/webdav
: "${RAILIANCE_BACKUP_NC_WEBDAV_URL:=https://nx4069.your-storageshare.de/public.php/dav/filesdrop/${RAILIANCE_BACKUP_NC_TOKEN}}"
if [[ -z "${RAILIANCE_BACKUP_NC_TOKEN:-}" ]]; then
echo "ERROR: set RAILIANCE_BACKUP_NC_TOKEN or run: bao login -method=oidc -path=netkingdom role=railiance-backup-workload-kv-read" >&2
exit 1
echo "ERROR: governed backup credential unavailable (CCR-2026-0004)" >&2
return 1
fi
# Build a file-drop URL only after the required token is present.
: "${RAILIANCE_BACKUP_NC_WEBDAV_URL:=https://nx4069.your-storageshare.de/public.php/dav/filesdrop/${RAILIANCE_BACKUP_NC_TOKEN}}"
}
railiance_backup_require_tools() {

View file

@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Atomically accumulate non-secret image exports outside replaceable checkouts."""
from __future__ import annotations
import argparse
import fcntl
import hashlib
import json
import os
from pathlib import Path
import tempfile
def read_images(path: Path) -> set[str]:
images = set()
for line in path.read_text(encoding="utf-8").splitlines():
image = line.strip()
if not image or image.startswith("#"):
continue
if any(c.isspace() for c in image) or "/" not in image:
raise ValueError("invalid image export")
images.add(image)
if not images:
raise ValueError("empty image export")
return images
def refresh(output: Path, sources: list[Path]) -> dict:
# Read every required export before touching the last known good inventory.
images: set[str] = set()
for source in sources:
images.update(read_images(source))
if not images:
raise ValueError("at least one nonempty export is required")
output.parent.mkdir(parents=True, exist_ok=True)
with output.with_name(output.name + ".lock").open("a") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
if output.exists():
images.update(read_images(output))
data = ("\n".join(sorted(images)) + "\n").encode()
temporary = None
try:
with tempfile.NamedTemporaryFile(dir=output.parent, delete=False) as stream:
temporary = Path(stream.name)
stream.write(data)
stream.flush()
os.fchmod(stream.fileno(), 0o644)
os.fsync(stream.fileno())
os.replace(temporary, output)
directory = os.open(output.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
return {"path": str(output), "images": len(images), "sha256": hashlib.sha256(data).hexdigest()}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--source", type=Path, action="append", required=True)
args = parser.parse_args()
try:
receipt = refresh(args.output, args.source)
except (OSError, ValueError):
parser.exit(1, "inventory refresh failed; no successful publication receipt\n")
print(json.dumps(receipt, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,51 @@
import os
from pathlib import Path
import subprocess
import unittest
ROOT = Path(__file__).resolve().parents[1]
class BackupCredentialTests(unittest.TestCase):
def run_shell(self, body):
env = {k: v for k, v in os.environ.items() if not k.startswith("RAILIANCE_BACKUP_")}
return subprocess.run(
["bash", "-c", 'source lib/railiance-backup-common.sh\n' + body],
cwd=ROOT, env=env, text=True, capture_output=True,
)
def test_missing_credentials_fail_with_safe_error(self):
result = self.run_shell(
'bao() { return 1; }\nrailiance_backup_require_openbao_lane'
)
self.assertNotEqual(result.returncode, 0)
self.assertEqual(result.stdout, "")
self.assertIn("governed backup credential unavailable", result.stderr)
self.assertNotIn("unbound variable", result.stderr)
def test_explicit_governed_credentials_need_no_bao(self):
result = self.run_shell('''
bao() { echo unexpected-bao >&2; return 1; }
RAILIANCE_BACKUP_NC_TOKEN=test-only-placeholder
RAILIANCE_BACKUP_NC_WEBDAV_URL=https://example.invalid/upload
railiance_backup_require_openbao_lane
''')
self.assertEqual(result.returncode, 0)
self.assertEqual(result.stdout + result.stderr, "")
def test_openbao_lane_supplies_missing_credentials(self):
result = self.run_shell('''
bao() {
case "$*" in
"kv metadata get "*) return 0 ;;
"kv get -field=NC_WEBDAV_TOKEN "*) echo test-only-placeholder ;;
"kv get -field=NC_WEBDAV_URL "*) echo https://example.invalid/upload ;;
*) return 1 ;;
esac
}
railiance_backup_require_openbao_lane
[[ "$RAILIANCE_BACKUP_NC_TOKEN" == test-only-placeholder ]]
[[ "$RAILIANCE_BACKUP_NC_WEBDAV_URL" == https://example.invalid/upload ]]
''')
self.assertEqual(result.returncode, 0)
self.assertEqual(result.stdout + result.stderr, "")

View file

@ -0,0 +1,42 @@
import importlib.util
from pathlib import Path
import tempfile
import unittest
SPEC = importlib.util.spec_from_file_location(
"refresh_live_images", Path(__file__).resolve().parents[1] / "scripts/refresh_live_images.py"
)
inventory = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(inventory)
class InventoryTests(unittest.TestCase):
def test_refresh_preserves_other_clusters_and_previous_tags(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
output = root / "durable" / "images.txt"
first, second = root / "first", root / "second"
first.write_text("forgejo.example/org/app:old\n")
second.write_text("forgejo.example/org/other:live\n")
inventory.refresh(output, [first, second])
first.write_text("forgejo.example/org/app:new\n")
receipt = inventory.refresh(output, [first])
self.assertEqual(receipt["images"], 3)
self.assertIn("forgejo.example/org/other:live", output.read_text())
self.assertIn("forgejo.example/org/app:old", output.read_text())
self.assertEqual(inventory.refresh(output, [first]), receipt)
def test_failed_export_preserves_inventory(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
output, source = root / "images", root / "export"
output.write_text("forgejo.example/org/app:live\n")
before = output.read_bytes()
for content in ["", "# no images\n", "error fetching pods\n"]:
source.write_text(content)
with self.assertRaises(ValueError):
inventory.refresh(output, [source])
self.assertEqual(output.read_bytes(), before)
with self.assertRaises(OSError):
inventory.refresh(output, [root / "missing"])
self.assertEqual(output.read_bytes(), before)

View file

@ -10,7 +10,6 @@
# RAILIANCE_BACKUP_DRY_RUN=1 skip upload and remote side effects
set -euo pipefail
: "${RAILIANCE_BACKUP_NC_TOKEN:=MfTBEjcJTGS8Ywo}"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# shellcheck source=lib/railiance-print.sh
@ -55,8 +54,10 @@ cleanup_plain() {
}
trap cleanup_plain EXIT
if [[ "${DRY_RUN}" != "1" ]]; then
railiance_backup_require_openbao_lane
fi
railiance_backup_require_tools
railiance_backup_require_openbao_lane
FORGEJO_DB_POD="${FORGEJO_DB_POD:-$(kubectl_get_pod "${FORGEJO_DB_NAMESPACE}" \
"cnpg.io/cluster=${FORGEJO_DB_CLUSTER}" || true)}"

View file

@ -404,3 +404,14 @@ consumers, restart requirements, provider-side revocation for the OpenRouter
key) plus the post-rotate verification contract. Front-door disable comes
first in every action; audit evidence is never deleted; values stay in
OpenBao/operator custody.
## Recovery evidence reported 2026-09-04
Activity-core message `dcc0d7cc-0046-4c47-ba1c-fb1e0046e18e` supersedes
`c32901ff-2072-4b93-81cd-db93dddfa5f3`. Owner decision
`914e0e04-fd17-4fba-b2d5-be38f7aca185` approved intentional reuse from the
reins provider lane into the canonical activity-core lane. Activity-core reports
KV version 2, ESO synchronization, rollout, health and completion HTTP 200.
This is owner-reported evidence, not an independent platform live verification;
no further replacement was requested.

View file

@ -0,0 +1,36 @@
---
id: RPF-WP-0028
type: workplan
title: "Make Forgejo image protection survive checkout replacement"
domain: financials
repo: railiance-platform
status: blocked
owner: codex
created: "2026-09-05"
updated: "2026-09-05"
---
# Make Forgejo image protection survive checkout replacement
Source: State Hub message `868a2326-cdad-4256-be4c-7cd6c8ec4b5b`. Reviewed against current repository state
on 2026-09-05. Repository implementation is complete; live closure remains pending.
## Implement and verify durable publication
```task
id: RPF-WP-0028-T01
status: done
priority: high
```
Implemented scripts/refresh_live_images.py and make live-images-refresh. Publication validates exports before mutation, locks concurrent writers, retains all previous cluster entries, and fsyncs an atomic replacement. Offline tests prove repeatability, multi-cluster retention, and preservation on missing, empty, or malformed input.
## Install production projection and rollout refresh
```task
id: RPF-WP-0028-T02
status: wait
priority: high
```
Requires the production host inventory, activity-core directory mount/configuration update, and cluster rollout hook adoption. Follow docs/forgejo-package-prune.md. Preserve the restored union and worker refusal on missing/empty inventory; do not manually trigger prune. No production migration or live verification occurred in this session.

View file

@ -0,0 +1,36 @@
---
id: RPF-WP-0029
type: workplan
title: "Remove backup credential default and verify governed replacement"
domain: financials
repo: railiance-platform
status: blocked
owner: codex
created: "2026-09-05"
updated: "2026-09-05"
---
# Remove backup credential default and verify governed replacement
Source: State Hub message `ee702ac9-9118-4b9b-963a-01943052b65a`. Reviewed against current repository state
on 2026-09-05. Repository implementation is complete; live closure remains pending.
## Remove source fallback and verify fail-closed behavior
```task
id: RPF-WP-0029-T01
status: done
priority: high
```
Removed the literal upload credential default from tools/cmd/forgejo-backup. Missing governed input now fails before cluster operations with a value-free diagnostic; encryption dry-runs skip upload authentication. Offline tests prove missing-input denial, explicit input, and mocked OpenBao resolution.
## Invalidate predecessor and prove replacement recovery
```task
id: RPF-WP-0029-T02
status: wait
priority: high
```
Provider-side invalidation and replacement custody need the attended provider owner and CCR-2026-0004 lifecycle procedure. Record only non-secret invalidation, encrypted upload and restore receipts. No provider authority or replacement receipt was available; source removal alone does not close the reported exposure. Never record the predecessor value, fingerprint, length, or shape.