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.