Skip to content

October 2026 remediation plan: R01–R05

Status: proposed implementation, no production authorization or remediation performed. Source: October review, findings R01–R05 (P2). Repository: h8v6/heyaira, source commit ceab5b19a5985281f09e2c2e319f8fcca4b2a868. Parent task: 89803f50-5eb3-4fd1-a7eb-559daa7c9265, frozen version 2.

This is a work order for backend/DevOps implementation and subsequent approved operations, not shell commands to run against production today. Existing paths below identify the implementation seams; explicitly labelled new artifacts do not exist yet. Host paths and new role names are proposed settings, not live attestations. Never copy secret values, fragments, environment dumps or private operator inventories into this document, test output or task receipts.

Delivery and windows

The five task briefs carry separate scope, acceptance and handoff requirements. Task creation is pending coordinator registration: this execution has only assigned-task reads, memory reads and task_result, not task_create. The RESULT explicitly hands over all five briefs; it does not claim five tasks were created.

Recommended sequence: R01 and R05 independently; R03 protects recovery before R02 changes DB authority; R04 uses R03's separate restore credentials. R03 and R04 may share a reviewed release but have separate acceptance records. R02 requires backend work before DevOps can switch logins. Do not combine the DB cutover with SSH or runner changes in one production window.

For every window DevOps records the approved commit, current and rollback image revisions, selected services, approval reference and non-secret preflight result. Use ops/deploy-heyaira.sh and the active production overlays; preserve its deployment lock, revision check, DB-client schema guard and restore-point guard. Never use an unscoped Compose restart or recreate PostgreSQL/OpenBao as part of these changes. A missing approved setting or failing preflight stops that window; it does not authorize wider access. Security reviews the implementation before merge; the owner gives the production go in the DevOps session.

R01 — file-specific API mounts and isolated administrative output

Exact change (files/settings). Modify compose.yaml at services.server: remove ./secrets:/run/heyaira-secrets completely. Replace it with individual read-only bind mounts, using long syntax with read_only: true and bind.create_host_path: false. No whole-directory mount may remain after merging overlays. The API's file consumers are:

Setting in compose.yaml File to expose to the API
HEYAIRA_GITHUB_APP_PRIVATE_KEY_FILE ./secrets/github-app.pem at /run/heyaira-secrets/github-app.pem
HEYAIRA_GITHUB_WEBHOOK_SECRET_FILE ./secrets/github-webhook.secret at /run/heyaira-secrets/github-webhook.secret
HEYAIRA_OPENBAO_TOKEN_FILE ./secrets/openbao.token at /run/heyaira-secrets/openbao.token, when the vault integration is enabled
HEYAIRA_APNS_KEY_FILE Only the configured APNs key file, at its configured container path, when APNs is enabled
HEYAIRA_DECISION_LINK_KEY_FILE Only the configured decision link key file, at its configured container path, when decisions are enabled
HEYAIRA_OPENBAO_CA_BUNDLE Only the configured public CA bundle, read-only, if nonempty

Keep the optional mounts in their activation overlays; do not create empty credential files to satisfy Compose. ops/openbao-production-compose.yaml must add the individual OpenBao mount because it enables that consumer. For APNs/decisions, add a new ops/api-optional-secrets-compose.yaml, selected explicitly by DevOps, containing only enabled feature mounts; take the source file and target from the approved feature configuration. Extend ops/deploy-heyaira.sh preflight to require each enabled source to be a regular file and readable by the container user, and to reject a broad API secret mount. Inspect merged configuration with values suppressed, printing only service, source path, target path and read-only flag. Do not print docker compose config or container environments into receipts.

Remove HEYAIRA_SERVER_BEAT_SES_CONFIG_FILE from the API environment (currently wired from HEYAIRA_DECISION_ENROLLMENT_SES_CONFIG_FILE). SES for enrollment is CLI-only. Keep pulse and SES file mounts on server-beat-runner in ops/server-beat-repair-compose.yaml; keep backup/retention environments and all private age/recovery material absent from the API. Static project-token files are CLI output, not API inputs; hashes in the DB serve authentication.

Add a new ops/admin-compose.yaml defining a one-shot admin service under profile admin, using the same pinned server image, no published ports, no restart policy, and only the command's necessary DB/config/individual input files. Do not inherit the API service's complete environment. Mount a fresh, operator-owned mode-0700 directory at /run/heyaira-admin-output writable only for this invocation. Preserve _write_secret in src/heyaira/admin.py: exclusive creation, mode 0600, no stdout secret. Change the create-project and recover-bridge-token examples in docs/DEPLOYMENT.md from exec server to this one-shot service, with --token-file under the output directory. Enrollment mail alone receives the SES input file; recover-bridge-token gets the necessary Bridge signing settings, not unrelated API keys. R02 later gives this service its separate admin DML login and explicit migration step. Transfer output only through the established owner channel, then remove the temporary container/mount and protected output after confirmed consumption.

Execution and closure checks. Prepare the files without changing values; verify ownership/modes under rootless Docker, render the selected overlays and deploy only server. With synthetic files in an isolated Compose test, prove API reads its own enabled files but cannot overwrite them or read static-token, pulse, SES, backup or admin-output files. Prove disabled optional files are not required. Exercise GitHub integration/webhook verification, vault-backed mobile delivery, APNs and decision links only where enabled. Test admin output creation, duplicate refusal and absence of output in API/container logs using tests/test_admin.py plus mount-level integration coverage. Check deployment guards in tests/test_deployment_guard.py. Production evidence contains only mount names/modes and success/failure, never contents.

Blast radius. A wrong target path, absent mount or UID mapping can stop startup, GitHub operations, notifications or decisions; a missing CLI-only input can prevent emergency recovery/enrollment. A leftover broad overlay mount silently preserves R01 even if the base file looks correct.

Rollback. Keep original host files in place through acceptance. Prefer correcting the single failed mount and redeploying the previous pinned image. If that is insufficient, the owner may time-box restoration of the previous server mount configuration; explicitly record that R01 reopens. Remove any temporary admin service/container and output mount, without deleting output still needed by the owner. No key rotation or revocation is inherent in R01.

Who must agree. Owner: secret placement, administrative recovery procedure and production go. Backend maintainer: complete consumer/CLI mapping. DevOps: rootless permissions and rollback readiness. Security: mount-boundary review.

R02 — migrate once, run with separate DB authority

Exact change (files/settings). Keep the existing PostgreSQL bootstrap settings and volume in compose.yaml unchanged on existing installations. Changing POSTGRES_USER/password does not change roles in an initialized volume. Add a new operator provisioning script ops/provision-db-roles.sql and a new schema migration at the next unused number; never edit applied SQL. Provision the following roles in the existing DB through the authorized local DB admin channel; credentials are supplied through protected files, not argv, SQL committed to Git or logs:

Proposed role Authority and consumer
heyaira_schema_owner NOLOGIN owner of application schema, tables, sequences and application functions; not a runtime membership
heyaira_migrator Operator-only LOGIN, NOINHERIT; may explicitly SET ROLE to schema owner for the finite migration command; no superuser, CREATEDB or CREATEROLE
heyaira_api API LOGIN; SELECT/INSERT/UPDATE/DELETE on application tables except read-only schema_migrations; USAGE/SELECT on application sequences; only required application function execution; no ownership, TRUNCATE, schema CREATE or role membership
heyaira_admin One-shot admin CLI LOGIN with the API DML baseline, no migration membership; not injected into running services
heyaira_beat Scheduler LOGIN; SELECT on schema_migrations; execute only the bounded scheduler operations below
heyaira_runner Check/action runner LOGIN; SELECT on schema_migrations; execute only the bounded runner operations below
heyaira_backup Backup LOGIN; SELECT on every application table/sequence for full pg_dump, including migration metadata; bounded queue/heartbeat operations below; no general application DML

All runtime logins must be NOSUPERUSER, NOCREATEDB, NOCREATEROLE, NOREPLICATION, NOBYPASSRLS and must not own objects or inherit bootstrap/migration roles. Retain the bootstrap administrator solely for local operator recovery; revoke runtime membership in it. Transfer application objects explicitly, not a broad REASSIGN OWNED covering extensions or unrelated databases. Revoke PUBLIC CREATE on the application schema and PUBLIC execution on application functions, then grant the documented invoker functions (including backup_state_signal(text)) to API/admin. Preserve invoker-trigger DML on backup_state_signals and its identity sequence; otherwise ordinary token revocation fails. Revoke PUBLIC DB CONNECT/TEMP after inventorying consumers; explicitly grant CONNECT to these logins and the operator admin. No runtime needs TEMP or a writable search path. Set default privileges for objects created as heyaira_schema_owner to preserve API/admin DML and backup SELECT, with no default worker DML/EXECUTE grants.

Use a new non-login heyaira_control_owner for narrow SQL gateway functions in a new heyaira_control schema, owned separately from schema owner. It gets only the table operations listed below, not credentials, memory bodies or arbitrary application writes. Gateway functions use SECURITY DEFINER with a fixed search_path = pg_catalog, fully qualified object names, no dynamic SQL, no caller-provided relation/role names, and PUBLIC EXECUTE revoked. Worker logins get USAGE on this schema and EXECUTE on an explicit function list only. Keep network calls, parsing and repair actions in Python; move the existing SQL transactions into bounded gateway operations as follows:

Existing implementation to adapt Exact gateway boundary
src/heyaira/control_loop.py, PostgresWorkStore enqueue/claim/reap/renew/complete/fail/owned-seq operations Preserve transactions, leases, advisory locks and SKIP LOCKED. Derive permitted work types from session_user: backup only heyaira-backup; Beat/runner only server-beat-check and server-beat-action. Beat schedules/reaps, runner claims/completes; backup does both for its own type. Restrict control_work_items and control_work_events by the parent work type and configured scope on every read/write; no arbitrary queue access. Reaping must no longer reap another service's jobs.
src/heyaira/server_beat.py, ServerBeatStore Scheduler gets pulse, due-check scheduling, pulse scheduling, task-email job upgrades and ended-action reconciliation. Runner gets check-result/incident transitions, repair reservation, maintenance, action/escalation and email-ledger operations. Functions touch control_check_states, control_incidents, control_incident_events, control_repair_budgets, control_pulses, control_email_events and the bounded queue. Keep maintenance's age/batch limits and repair budgets; do not expose unrestricted DELETE or reset-budget operations.
ServerBeatChecks, task-alert and DatabaseNotificationSink queries in src/heyaira/server_beat.py Return only the exact health/alert projections used today from backup_runtime_heartbeats, bridge_nodes, bridge_mobile_devices, bridge_mobile_preferences, bridge_mobile_deliveries, bridge_notification_events, tasks, task_workflows, task_workflow_events. No table-wide worker SELECT grant. Allow notification event/delivery insertion and superseded/retry transitions only for the configured project and the current incident/task event, retaining preference filters and deduplication. Never return device-token references or OAuth/project-token data.
src/heyaira/backup_runtime.py, heartbeat/status/fence/advance_job_seq; src/heyaira/backup.py, history and state marker Backup gateway writes only its backup_runtime_heartbeats scope and its queue/events. Preserve monotonic lineage reconciliation: fixed control_work_items.seq sequence, existing advisory/table locks, forward-only setval, renumber only queued/running backup rows. Never grant raw sequence UPDATE/setval to the login. Keep marker reads from pg_stat_user_tables, pg_stat_database, pg_stat_wal; no pg_read_all_data or server-file role.

Store allowed project and scope mappings in an operator-managed table in heyaira_control, writable only by schema owner. Seed the existing scheduler scope server, backup scope prod, and the approved notification project; validate any configured nondefault scope before cutover. Resolve authority from session_user, never a caller-supplied mode, project or work type alone. heyaira_control_owner needs sequence USAGE for queue insertion and UPDATE on the queue sequence only for lineage reconciliation; runtime callers do not. These details prevent a nominal role split from leaving the shared queue or notification tables writable by every service.

Split src/heyaira/db.py into the existing migration writer and a read-only schema validator. The latter checks missing/unknown migrations and recorded checksums and never creates the tracking table, adds its checksum column or backfills checksums. Replace startup apply_migrations calls in src/heyaira/server.py, src/heyaira/server_beat.py, src/heyaira/backup_runtime.py, src/heyaira/monitor_runtime.py and ordinary commands in src/heyaira/admin.py with validation. Add an explicit heyaira-admin migrate command using the dedicated migrator, preserving bounded locks and the existing advisory-lock/checksum logic. Do not offer runtime fallback to migration authority when validation fails.

In compose.yaml, map each service's existing HEYAIRA_DB_USER and HEYAIRA_DB_PASSWORD inputs to separate new protected host settings: HEYAIRA_API_DB_*, HEYAIRA_BEAT_DB_*, HEYAIRA_RUNNER_DB_*, HEYAIRA_BACKUP_DB_* (suffixes USER/PASSWORD). One-shot admin/migration services use HEYAIRA_ADMIN_DB_* / HEYAIRA_MIGRATOR_DB_*, never passed to runtime containers. Account for HEYAIRA_DATABASE_URL precedence in src/heyaira/config.py and beat_settings: reject a leftover shared URL in production service definitions. Backup's _write_pg_service must use its own login. Retention remains DB-free.

Update ops/deploy-heyaira.sh: after DB-client compatibility and verified restore-point checks, run the finite migration command once with the candidate image, remove that container, validate grants, then start runtime services. Keep release_sql and restore-point dumping as operator-only local PostgreSQL administration, not a runtime bootstrap credential. Extend docs/DEPLOYMENT.md for fresh bootstrap, existing-volume cutover, explicit migration and credentials. The first authority change also needs an encrypted, verified restore point even if the ordinary pending-migration detector would otherwise skip it.

Execution and closure checks. First implement and review the gateway and startup changes in an isolated PostgreSQL 16 clone with synthetic data. Deploy a compatibility release containing the new migration catalog before downgrading logins. In the approved window stop the four DB clients, create roles/grants and gateways transactionally, change their connection settings, run the finite migration/validator, then start API, backup, Beat and runner together. Do not restart PostgreSQL. Rotate/revoke the former shared login's runtime credential only after proving no client still uses it, retaining protected admin recovery.

Extend tests/test_migration_runner.py, tests/test_concurrent_migrations.py, tests/test_deployment_guard.py, tests/test_server_beat_e2e.py, tests/test_backup_runtime.py and tests/test_postgres_runner.py with real PostgreSQL role sessions. Prove bootstrap and upgrade, schema-ahead/checksum refusal, API/admin writes with triggers, incident lifecycle/notification delivery and queue reaping, backup dump/restore and post-restore sequence reconciliation. Negative tests as each login must deny DDL, SET ROLE to owner, CREATE ROLE, TRUNCATE, worker reads of credential/memory tables, cross-type and cross-scope queue operations, and arbitrary function calls. Backup may read all dumped data by design, but must not change tasks/memories or non-backup queues. Record value-free pg_roles, ownership, memberships and effective grant results from the approved installation; never password hashes or DSNs.

Blast radius. Incomplete grants or trigger dependencies break API writes, alerts or backup; incorrect worker filters strand queues; lost sequence reconciliation makes restored backups rank behind old objects. DDL/ownership mistakes can lock out every DB client. Full-database backup necessarily retains read access to sensitive rows; role separation does not eliminate that need.

Rollback. Keep a tested compatibility image containing the new migration and gateway definitions, protected old connection settings and an operator DB session until acceptance. Stop affected clients, restore their previous connection mapping and redeploy the compatible image. Temporary reuse of broad authority needs the owner's recorded, time-limited exception. Leave additive roles/functions in place; do not drop roles that own objects, remove migration rows, restore old SQL checksums or use down -v. If schema compatibility prevents rollback, recover forward under the deployment guard. Restoring a DB snapshot discards later writes and requires a separate owner decision.

Who must agree. Owner: DB identities, secret rotation, migration/startup change, outage window and any emergency broad-role rollback. Backend/DB maintainer: grants, trigger/gateway contracts and compatibility tests. DevOps: restore point and cutover. Security: independent privilege-boundary review.

R03 — protected recovery copy and independent R2 credentials

Exact change (files/settings). In src/heyaira/backup_runtime.py, make retention_rclone_env refuse both absent and partially supplied HEYAIRA_RETENTION_R2_ACCESS_KEY_ID / HEYAIRA_RETENTION_R2_SECRET_ACCESS_KEY. Remove the fallback to _rclone_env, including in dry-run mode. In compose.yaml, remove backup.env from retention.env_file and require retention.env when that profile is invoked. Supply the existing non-secret HEYAIRA_BACKUP_R2_BUCKET, endpoint, retention interval and APPLY settings in retention's own file; default APPLY remains false. Update retention.env.example, docs/BACKUP_RUNBOOK.md and deployment linking in ops/deploy-heyaira.sh; keep the shared retention-lock volume and flock protocol. Assert backup never receives retention credentials and retention never receives writer credentials. R2 Object Read & Write still includes delete.

On the primary bucket, after the runbook's retention-marks migration and an inventory with zero legacy marks/problems, configure the supported provider locks: rolling/ 1 day, daily/ 6 days, no lock on monthly/, retention-marks/ or status/. Keep the planner at rolling 48 hours, daily 30 days with a 7-day promotion lock, monthly 180 days. No rolling lifecycle rule while retention is review-only. Once separately approved for apply, use rolling lifecycle 7 days, daily 30, monthly 180 and incomplete multipart 1 day, as specified in docs/BACKUP_RUNBOOK.md. Do not enable apply merely to install these access changes.

Those age locks alone are insufficient: an unchanged DB may have only old, unlocked copies. Add a separate private EU R2 Standard recovery bucket, with a daily/ lock of 30 days from upload and a daily/ lifecycle of 35 days. No production writer/retention token may be scoped to it or have provider policy administration. An independent job on the restore Mac copies the newest verified encrypted backup into that bucket every day, even when the DB did not change. Its destination is daily/YYYY/MM/DD/<backup-id>.dump.age plus the corresponding .manifest.json; the date is the copy day, while original created_at, backup ID, lineage, encrypted hash and size are preserved. Rewrite only the manifest's object key to the destination. This archive is not managed by the primary retention planner. The production writer cannot delete it at any age; the copy job cannot overwrite/delete it within the provider lock.

Add new ops/restore-rehearsal/recovery-copy.py and ops/restore-rehearsal/eu.heyaira.recovery-copy.plist; update the existing ops/restore-rehearsal/install-mac.sh for a daily LaunchAgent with catch-up after wake. Reuse latest_manifest in src/heyaira/backup_latest.py and the three-tier validation contract in mac-restore-rehearsal.sh. Read the primary bucket using a bucket-scoped Object Read only token, held on the Mac; write the recovery bucket with a distinct bucket-scoped Object Read & Write token, also only on the Mac. Neither can change bucket locks. No private age key is required for copying. Keep all tokens in mode-0600 local files, loaded into subprocess environments, never command arguments or the production container.

The copy job downloads and verifies encrypted size/SHA-256, uploads the object then manifest to unique daily keys, reads both back, verifies them, and reports success only then. On a repeat run, verify an existing complete pair and skip; do not overwrite locked objects. If only the matching object exists, finish the missing manifest; conflicting content fails closed. A failed/incomplete pair never counts as a recovery copy. Compare successive manifests' job_seq and source freshness; do not silently replace a usable recovery copy with a regressed or malformed source. The Mac must notify the owner if no verified copy completes for 36 hours; on wake, check elapsed time before copying. An offline/lost Mac cannot send that alert. Extend the existing external monitor in ops/cloudflare/server-beat-watchdog/src/index.js and its wrangler.jsonc: use the current one-minute cron but persist an hourly recovery probe deadline in the Durable Object. Add a separate bucket-scoped Object Read only token as Worker secrets (new RECOVERY_R2_ACCESS_KEY_ID and RECOVERY_R2_SECRET_ACCESS_KEY) plus non-secret endpoint/bucket settings. Use SigV4-signed R2 list/get/head requests, reusing the file's crypto primitives; no write API and no R2 binding with implicit write permission. Inspect the latest three UTC copy-day prefixes for a complete object/manifest pair, compare size and object key, and age by the older provider LastModified of the pair, not the backup's original creation time. Persist that upload timestamp, never refresh it merely because a probe re-reads the same pair. Absence, invalid data or read failure must never count as success. At 36 hours without a verified pair, enqueue one incident alert through the existing SES outbox, retry delivery under its existing limits, and send recovery when a new pair verifies. This alert must not invoke OVH reboot or consume repair budgets. Add synthetic monitor cases to ops/cloudflare/server-beat-watchdog/test/watchdog.test.mjs and document secret names/activation in its README.md. Activate this read-only monitor before declaring the independent copy operational; it remains live when the Mac and production server are both offline.

Use R04's direct read-only restore download for both primary and recovery buckets. Its bounded server report writes only status/restore-rehearsal.json; the restore reader gets no write credential. Record approval of the 30-day immutability/35-day storage horizon and daily copy cadence; this is a recovery window, not a promise of indefinite recoverability or valid future backups after a writer compromise.

Execution and closure checks. Before touching primary policies, test locks, lifecycle and credentials in a disposable provider bucket with synthetic encrypted artifacts. Prove writer credentials cannot access the recovery bucket, delete/overwrite of locked object AND manifest is denied even to the copy token, and read-only restore cannot put/delete. Test no-change days, missing manifest, partial copy retry, stale/regressed source and offline-Mac alert handling. Restore a complete recovery pair into isolated PostgreSQL on the Mac before accepting the boundary. Use injected clocks/provider fakes for expiry cases; record those as simulated, and obtain provider policy attestation plus real locked-object negative tests, not a claim to have waited 35 days.

Extend tests/test_backup_runtime.py, tests/test_retention_cli.py, tests/test_backup_retention_runtime.py and tests/test_backup_retention.py: missing/partial retention credentials refuse; dry-run stays read-only; normal retention promotions/deletions work outside locks; the recovery bucket is never a retention target. In production: provision independent copy/read credentials, create and verify the first protected pair, validate recovery, then cut over retention's dedicated environment. Rotate the formerly shared writer key only after the backup container's new process has listed R2 successfully (cached evidence is insufficient). Revoke its predecessor after both consumers have switched. Keep private age custody unchanged.

Blast radius. An overlong primary lock stops retention and increases cost; a short lifecycle can erase unpromoted history. A bad credential split stops backup or retention. A bad recovery manifest defeats restore despite a valid encrypted object. Mac availability, copy bandwidth and extra storage become operational dependencies. The independent bucket cannot protect against an owner/provider-admin compromise that changes its policy.

Rollback. Stop retention apply first and preserve its lock volume. Revert the application/environment change only with APPLY=false; do not silently resume a shared writer fallback. Reissue a dedicated retention credential if needed. Stop a faulty recovery-copy job while leaving verified copies and bucket locks intact; continue primary backups and repair forward. Never delete the archive, shorten its lock or introduce a lifecycle rule as rollback. Provider locks can keep objects undeletable until expiry, so budget storage before activation. Revoked credentials are replaced, not restored from Git.

Who must agree. Owner: provider policies, extra bucket/storage costs, copy and monitoring custody, retention/deletion horizon, credential issuance/rotation and production go. Backup maintainer: planner compatibility and manifest integrity. DevOps: restore and alert evidence. Security: separation of authority.

R04 — direct read-only restore and forced report command

Exact change (files/settings). Replace remote() in ops/restore-rehearsal/mac-restore-rehearsal.sh: no sudo -u heyaira sh -s, no upload of server-helper.sh, no docker exec. Download manifests/objects directly on the Mac using R03's Object Read only credential and src/heyaira/backup_latest.py; retain strict key, size, hash and manifest validation, local-only PostgreSQL, cleanup and private age custody. Add an explicit primary/recovery selector mapped to two fixed configured buckets; no arbitrary bucket/endpoint/credential argument from a manifest. Schedule the weekly rehearsal against the recovery bucket after R03 acceptance and keep a manual primary test available.

Reduce ops/restore-rehearsal/server-helper.sh to a server-installed, root-owned forced command that handles only report PASS or report FAIL. Because object/manifest transfer is direct, no SSH download command is needed. The helper parses SSH_ORIGINAL_COMMAND as a strict whole-string match; reject empty command, extra arguments, newlines, shell punctuation, paths, client timestamps, arbitrary JSON and all other commands before any action. Ignore stdin, use an absolute interpreter and fixed PATH, sanitize inherited environment and never use eval, sh -c with caller data or source a client file. Timestamp is generated by the server; write only the fixed status key using the existing JSON shape (at, result). No client-provided output path.

Create a host OS account heyaira-restore-report, locked password, no sudo, no Docker group/socket access, no runtime-user membership and no DB access. Install helper/code under new host path /usr/local/libexec/heyaira-restore-report (root-owned, not writable by this account); root controls the parent and SSH key file too. Its local protected credential is separate from all writer/retention credentials. Since R2 does not provide a put-only single-key credential, place restore status in a separate status bucket, scope the report token to that bucket, and grant it no data-bucket access. Extend RcloneStatusReportReader in src/heyaira/backup_runtime.py to read retention status from the primary bucket and restore status from the status bucket using a separate read-only token. Use new HEYAIRA_RESTORE_STATUS_R2_* settings for that reader and wire only its read-only credential to backup via backup.env; update backup.env.example and docs/BACKUP_RUNBOOK.md. Preserve existing missing, failed and older-than-eight-days report warning semantics. Status write authority cannot become backup-delete authority.

Host SSH configuration (new Match User heyaira-restore-report block): AuthenticationMethods publickey, PasswordAuthentication no, KbdInteractiveAuthentication no, PermitTTY no, DisableForwarding yes, PermitTunnel no, PermitUserRC no, and ForceCommand /usr/local/libexec/heyaira-restore-report. Use a root-managed authorized-keys location outside the login's writable home; mark the dedicated Mac key restrict. Validate effective sshd configuration for that user before reloading; retain an independent operator SSH session. On the Mac, pin the approved server host key, use a dedicated IdentityFile, IdentitiesOnly yes, BatchMode yes, no agent forwarding; map HEYAIRA_RESTORE_SSH_TARGET to this account. Update ops/restore-rehearsal/README.md, install-mac.sh and the existing weekly plist.

Execution and closure checks. Provision the status reader before switching the report writer; accept a short, explicit dual-read transition, deduplicating by server timestamp, then remove the old primary status fallback after one successful report. Test the new SSH account while old scheduling is paused. Negative tests: interactive shell, sh -s, sudo, Docker, SFTP/SCP, PTY, local/remote/dynamic/agent forwarding, unknown command, newline/extra-argument injection and writes to data buckets all fail. Valid PASS/FAIL reports succeed; simulated reporting failure remains visible on the Mac and eventually in backup.fresh. Run sh ops/restore-rehearsal/test-validation.sh and extend it for direct reads and forced-command cases; extend tests/test_backup_runtime.py for independent status credentials and transition/missing/stale reports. Perform a full isolated recovery restore. Then remove the restore identity's old authorized key and the specific passwordless sudo grant; if it previously shared an operator identity, migrate that consumer first and revoke only the restore-specific route after the owner confirms unrelated users retain access. Do not edit general operator access blindly. Reinstall the Mac LaunchAgent.

Blast radius. Wrong SSH match/key placement can disable reporting or lock out operators if applied too broadly. Bad reader wiring causes stale/false backup warnings. Revoking a shared SSH route too early breaks unrelated operations. A compromised restore Mac can still submit false PASS/FAIL and read/decrypt backups it legitimately holds; this plan removes arbitrary production execution, not that residual trust.

Rollback. Keep independent operator access throughout. Disable the new LaunchAgent and forced account if broken; continue primary backups and an owner-supervised manual local restore. Restore the previous status-reader configuration and report via the operator channel while repairing. Do not automatically reinstate passwordless runtime-user shell; that requires an explicit time-limited owner exception. Keep working bucket read-only access and private age custody. Never roll back by copying the age identity to the server.

Who must agree. Owner: SSH/sudo/key changes, status bucket cost, report trust and production go. DevOps: effective sshd policy and recovery access. Restore/backup maintainers: Mac workflow and status compatibility. Security: command grammar and negative authority tests.

R05 — disposable CI jobs and separate untrusted PR admission

Exact change (files/settings). Select the already-used GitHub-hosted Linux model for the main test job: in .github/workflows/ci.yml change jobs.test.runs-on from [self-hosted, linux, heyaira-ci] to ubuntu-latest, matching hosted-only. Each standard GitHub-hosted job receives its own fresh VM and Docker daemon; preserve both job names and required checks, PostgreSQL service, random port, Python version, timeouts and test selection. Remove the comments claiming shared rootless Docker and persistent host isolation. This choice avoids inventing a self-hosted VM controller absent from the repo; it needs owner-approved hosted-minute cost.

Set persist-credentials: false on both actions/checkout steps. Preserve permissions: contents: read, no id-token write, no production environment, no production secrets, no SSH/provider login or network tunnel. Disable pip cache for untrusted PR runs (prefer no cache in either job for this cutover), so untrusted artifacts cannot seed a later trusted environment. Keep pull_request plus push-to-main; never switch to pull_request_target to execute the PR head with trusted credentials. A separate hosted-only job is already a separate VM, not a second process on the test job's daemon.

Owner-authorized repository settings: Actions general workflow permissions read-only, disallow Actions creating/approving PRs, require approval for all outside collaborators' fork workflows, and disable sending write tokens or secrets to fork PRs. Restrict any remaining self-hosted runner group to explicit trusted workflows; deregister the two heyaira-ci runners after in-flight jobs finish and remove their service/autostart registrations on host runner. Merely removing a label in this workflow is insufficient: otherwise PR code can change runs-on to request the old runner. Do not allow repository PR jobs to reach persistent organization runners through another group. Preserve an operator inventory of runner IDs and affected repositories; never publish registration tokens.

No hosted VM is attached to production VPC/Tailscale/VPN, no production management ingress rule is added for runner addresses, and production SSH, DB, Docker and provider admin access remain private/authenticated. Attest this boundary from approved networking settings; default hosted CI is not a guarantee that public HTTPS endpoints cannot be reached. Public API access is still subject to the normal authorization boundary. If the owner's requirement is to prohibit even public production HTTP, this hosted choice requires an external enforceable egress policy before activation; a job-local firewall that PR code can undo is not an acceptable substitute.

For the third-party-action follow-up mentioned in R05, resolve checkout, setup-python and setup-node versions to reviewed full immutable upstream commit IDs, verify their provenance, and pin all occurrences in this workflow before accepting the runner change. Do not guess SHAs or treat a movable major tag as a pin. This implementation-time version selection is a dependency review, not a request to inspect secrets or production state. Keep version comments so upgrades remain reviewable.

Execution and closure checks. Run two synthetic PR jobs concurrently: each creates a uniquely named container and workspace marker; neither can list/control the other's container or observe its marker. A subsequent run sees neither marker/container. Record different runner instance identities, separate daemons, successful PostgreSQL tests, cancellation cleanup and hosted VM teardown. Check checkout leaves no persisted Git auth extraheader without printing header values. Prove fork workflows get no secrets/write token and cannot schedule a persistent runner. Attest absent tunnels/private production routes and absence of production credentials by names/policy, not by dumping environment values. Review settings without probing production during this documentation task. CI must pass both existing jobs with required-check names unchanged; observe one main run after the first accepted PR.

Blast radius. Hosted cost/quota exhaustion, toolchain differences or network egress assumptions can block all merges. Premature runner deregistration can kill jobs or another repository's workload. Checkout without persisted auth breaks later authenticated Git operations if introduced; the current workflow does not require them. Action pin mistakes prevent jobs from starting.

Rollback. Revert the workflow commit to a tested hosted-image/pinned-action version first. If hosted capacity is unavailable, pause PR CI and fix capacity; do not silently return untrusted PRs to the shared persistent user/daemon. Only an owner-approved temporary trusted-only runner exception may restore a previous runner, with PR admission disabled and an expiry. Retain the known working required-check names; remove obsolete runner registrations only after the new jobs pass. A deliberately paused check is preferable to reporting a skipped isolation test as success.

Who must agree. Owner: hosted-minute budget, GitHub admission/runner settings and removal of old runners. CI maintainer: action provenance and workflow compatibility. DevOps: runner/network inventory and teardown. Security: untrusted/trusted boundary and isolation evidence.

One owner handoff after implementation review

Approve separate windows for file mounts/admin output (R01), database roles and migration startup (R02), independent recovery/status storage and R2 keys (R03/R04), bounded SSH reporting (R04), and hosted CI cost/admission (R05). No consent is presumed by this plan. R06–R11 remain outside this delivery. Track closure with value-free test and approval references in each task; neither source inspection nor this document attests live production settings.