Record populated pilot capture and prepare encrypted export
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.1-sol
Assistant-Session: 01a1abf1-7e95-7141-87c9-ee78332bf790
This commit is contained in:
tegwick 2026-11-06 01:02:59 +01:00
parent 8fd2c9772d
commit d2e1377354
11 changed files with 713 additions and 8 deletions

View file

@ -0,0 +1,29 @@
{
"schema": "railiance.vergabe-pilot-user-return.v1",
"reported_on": "2026-11-06",
"workplan": "RAPPS-WP-0014",
"task": "RAPPS-WP-0014-T03",
"run_id": "20261105T233543Z-89bfbe04dece",
"evidence_kind": "user_reported",
"two_accounts_work_after_restart": true,
"user_report": "Both accounts work. The Ausschreibung is no longer listed on the \u00dcbersicht; everything else seems as before.",
"human_restart_check": "confirmed_with_overview_followup",
"overview_omission_reported": true,
"overview_followup": {
"deployed_source_revision": "c067993037f1cdeeb7b22d5600b51c649bc239db",
"source_file": "vergabe_teilnahme/apps/ausschreibungen/views.py",
"function": "dashboard",
"ongoing_list_filter": "status__range=(1, 9); latest ten by geaendert_am",
"final_statuses_omitted": [
10,
11,
12,
13
],
"fixture_current_status": "not_reported; clarification requested",
"explanation": "A final status would explain exclusion; phase 8 alone is included. No live status or data-loss cause is inferred."
},
"provider_publication_verified": false,
"restore_verified": false,
"authentication_policy_changed": false
}

View file

@ -0,0 +1,44 @@
{
"schema": "railiance.vergabe-pilot-three-store-capture.v1",
"run_id": "20261105T233543Z-89bfbe04dece",
"status": "captured_and_resumed",
"database": "vergabe_demo_company",
"helm_revision": 4,
"app_resumed": true,
"off_host_verified": false,
"restore_verified": false,
"media_files": 1,
"media_bytes": 803,
"media_logical_sha256": "24fe90da88973ac03c8a475dff93c5866cebd797aeecdca2286989a1250779ed",
"fixture_document_sha256": "23a6850fa2391a58280c8515361ca29e9e92fa65fdadc36be7b44b552192e381",
"sqlite": {
"tables": 9,
"rows": 7,
"logical_sha256": "579dedb0e6a6b30f6cf3a5cec27ae423dbdaf0de959837cb9d697f6d5a789258",
"linked_issue_found": true,
"integrity_verified": true
},
"stores_sha256": "6affa22da26badb4daceb87acf62d4eba57bfbfc2d60da0823b607c449596d66",
"stores_bytes": 3489,
"database_sha256": "8758ebd2e2b6c9e75742c42f2d32f71596522f5aade3a7212ed5a04c3cf3476c",
"database_bytes": 169232,
"database_tables": 39,
"database_rows": 247,
"database_logical_sha256": "33191c43c437f1b05da410a3f22989c86ce50f4fca8fd32d40fbb327b8b73765",
"writers_quiesced": true,
"anonymous_document_http_status": 302,
"anonymous_document_denied": true,
"helper_cleanup_confirmed": true,
"restart_fixture_verified": true,
"elapsed_seconds": 98.61,
"evidence_kind": "user_reported_terminal_output",
"reported_on": "2026-11-06",
"workplan": "RAPPS-WP-0014",
"task": "RAPPS-WP-0014-T03",
"command": "python3 tools/vergabe_pilot_snapshot_remote.py --execute",
"founder_executed_reviewed_change": true,
"elapsed_seconds_scope": "Whole helper operation after preflight; not separately measured outage duration",
"human_two_account_post_restart_walkthrough": "confirmed_with_overview_followup",
"production_data_restored": false,
"human_return_receipt": "2026-11-06-vergabe-post-restart-user-return.json"
}

View file

@ -0,0 +1,85 @@
# Export the populated Vergabe recovery point
The founder's [native capture receipt](../../evidence/2026-11-06-vergabe-three-store-capture-return.json)
passes for run `20261105T233543Z-89bfbe04dece`. The app has resumed and both
accounts work, with the [overview UI follow-up](../../evidence/2026-11-06-vergabe-post-restart-user-return.json).
This step transfers the frozen point; it needs no further application pause.
[Exact scope, pins and validation](plan.json).
## Founder workstation command
```bash
git pull --ff-only
python3 tools/vergabe_pilot_export_remote.py --fetch
```
Default without `--fetch` reviews locally. `--fetch` uses the same existing
interactive SSH/sudo lane as inventory and capture. The agent does not execute
the founder's credential path. No remote checkout is needed.
The node helper requires the successful capture receipt and the exact database
and stores sizes/hashes from the returned receipt. It packages only
`database.dump`, `stores.tar.gz`, `manifest.private.json`, `receipt.json` and a
generated packet manifest. Raw SQLite copies/work directories are excluded.
Using the existing platform **public** age recipient, it encrypts on railiance01
and retains the encrypted point plus its hash manifest in the root-private run
directory. A temporary plaintext package exists only inside that protected
directory and is removed after sealing. The original capture is preserved.
An interrupted, inconsistent seal stops for review rather than being overwritten.
The helper copies ciphertext and safe transfer metadata to two 0600 files in
the existing founder SSH account's home (`tegwick`), named
`vergabe-pilot-20261105T233543Z-89bfbe04dece.{zip.age,transfer.json}`.
Only those encrypted handoff files are made readable by that account; the
private capture files remain root-only. Existing different handoff bytes refuse.
The wrapper uses scp to fetch the pair into a private temporary directory, then
checks the source run/image/database, captured database/store hashes, encrypted
size and ciphertext hash. Only a verified pair becomes the final directory:
```text
~/.local/state/vergabe-pilot-recovery/20261105T233543Z-89bfbe04dece/
```
It refuses a destination inside a Git tree. Files are 0600 and the directory is
0700. An already verified pair can be reused on retry. Successful final JSON
has `status: encrypted_workstation_copy_verified` and includes the local path.
Return that JSON, without opening or pasting backup contents.
No Kubernetes/database access, application change, OpenBao access, provider call
or recovery-key retrieval occurs. Age must already be available on the node at
one of the standard or existing platform vendor paths. Missing age fails before
sealing; this command does not install tools or alter the platform configuration.
## What this establishes
The encrypted bytes are now copied to the founder's workstation outside the
production node, with transport hashes verified. This is a separate milestone
from publication to the declared backup provider. The receipt keeps
`provider_publication_verified`, `decryption_verified` and `restore_verified`
false. Public-key encryption alone does not prove the escrow identity can recover
the point, and no recurring protection or accepted recovery objective is inferred.
Credential routing confirms the existing `railiance-backup-offsite-lane` belongs
to railiance-platform. Its Nextcloud file-drop credential is create-only;
independent readback uses the Backup account, and age decryption uses recovery
escrow in a contained founder login. Reuse those owner helpers for publication
and readback; do not borrow another application's Scaleway publisher identity.
No new credential lane, role, provider folder, retention change or timer is
created by this export. The upload-only token is not readback authority.
After provider readback, rehearse an isolated restore of this matched point with
outgoing integrations disabled. Verify every PostgreSQL table digest, documents
and local issue linkage, restored account behavior, source/image availability and
elapsed recovery time. Compatible rollback, access denial and named backup/support
ownership, cadence, retention and accepted RPO/RTO remain T03 requirements.
## Validation
`python3 -m unittest discover -s tests -p test_vergabe_pilot_export.py`: seven
synthetic checks pass. They include actual age encryption/decryption, complete
packet comparison, changed capture/ciphertext rejection, unsafe source refusal,
handoff preservation, verified transfer/retry and Git destination refusal.
No native backup or real key is read by those tests. Native export remains
founder-executed and unverified until its receipt returns.

View file

@ -0,0 +1,49 @@
{
"schema": "railiance.vergabe-pilot-recovery-export-plan.v1",
"prepared_on": "2026-11-06",
"workplan": "RAPPS-WP-0014",
"task": "RAPPS-WP-0014-T03",
"run_id": "20261105T233543Z-89bfbe04dece",
"sources": {
"vergabe_pilot_recovery_store": "724a2dd9db8a0f57e1efa709ec989d8a47c681a86b1fca923bfdfea1698ae23c",
"vergabe_pilot_export": "b45733fa10ae59b56563045c43c53b5f824a74e1114f68fbed28404f14b43e3a"
},
"execution": "Founder workstation --fetch: interactive SSH/sudo on railiance01, followed by scp of ciphertext and a safe manifest",
"source": "/var/lib/vergabe-pilot-recovery/20261105T233543Z-89bfbe04dece/",
"scope": [
"Verify accepted capture receipt and pinned database/stores byte sizes and SHA256",
"Package only database.dump, stores.tar.gz, manifest.private.json, receipt.json and generated packet.json",
"Encrypt on the node to the existing platform public age recipient",
"Create mode-0600 encrypted handoff files for the existing founder SSH account",
"Copy and checksum-verify ciphertext and transfer metadata in a private workstation directory outside Git"
],
"recipient": "age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz",
"recipient_source": "railiance-platform/lib/railiance-backup-common.sh",
"node_ciphertext": "recovery-point.zip.age",
"node_public_manifest": "seal.json",
"founder_handoff_basename": "vergabe-pilot-20261105T233543Z-89bfbe04dece",
"workstation_directory": "~/.local/state/vergabe-pilot-recovery/20261105T233543Z-89bfbe04dece/",
"application_mutations": false,
"database_access": false,
"secret_reads": false,
"openbao_access": false,
"provider_access": false,
"provider_publication_verified": false,
"decryption_verified": false,
"restore_verified": false,
"next_provider_route": {
"id": "railiance-backup-offsite-lane",
"owner_repo": "railiance-platform",
"provider": "Existing Nextcloud create-only file-drop lane; independent Backup account for readback; age escrow for contained decrypt verification",
"route_only_not_publication_evidence": true
},
"validation": {
"unit_cases_passed": 7,
"actual_age_synthetic_round_trip": true,
"tampered_capture_or_ciphertext_refused": true,
"existing_handoff_not_overwritten": true,
"transport_retry_uses_verified_pair": true,
"git_destination_refused": true,
"native_export_executed": false
}
}

View file

@ -37,6 +37,14 @@ data. A failed check stops before any capture mutation.
## Prepared capture, after the founder's go
Executed by the founder: run `20261105T233543Z-89bfbe04dece` reports
[`captured_and_resumed`](../../evidence/2026-11-06-vergabe-three-store-capture-return.json).
The matched populated point and restart checks pass; the full helper took
98.61 seconds. Both accounts work after restart, with an overview-list UI
follow-up. Off-host publication and isolated restore remain open. The command
below is retained for reproducibility; **do not recapture this accepted point**.
Next: [encrypt and fetch the frozen point](../2026-11-06-vergabe-recovery-export/README.md).
Live production changes require the founder's go for this change under
`the-custodian/docs/agent-environment-orientation.md` section 0. The following
command is prepared for that later maintenance window:

View file

@ -24,10 +24,21 @@ passes at Helm revision 4 with the expected healthy deployment/database and both
Bound claims. The [November 6 capture check](evidence/2026-11-06-vergabe-capture-preflight-return.json)
also passes: two active members and OIDC identities, fixture 1/1/1, matching file,
valid local-state header, anonymous download denial, no pending migrations and
the reader Pod server dry-run. Next is the founder-approved pause/capture/resume
in the [three-store recovery packet](proposals/2026-11-06-vergabe-three-store-capture/README.md).
the reader Pod server dry-run. The founder-approved capture then executed using
the [three-store recovery packet](proposals/2026-11-06-vergabe-three-store-capture/README.md).
Neither preflight nor synthetic validation satisfies the pilot recovery gate.
The [populated capture and resume](evidence/2026-11-06-vergabe-three-store-capture-return.json)
pass: 39 PostgreSQL tables / 247 rows, one PDF and SQLite integrity/link checks;
the helper was removed. Total operation time was 98.61 seconds, including
resume/postflight, rather than a separately measured outage duration.
The [human restart return](evidence/2026-11-06-vergabe-post-restart-user-return.json)
confirms both accounts work and everything else appears as before, except the
tender is absent from Übersicht. The deployed dashboard lists only status 1–9;
final statuses 10–13 would explain exclusion. The actual current status is not
yet reported, so this remains a UI follow-up. Off-host publication and isolated
restore remain unverified.
The other session does not update automatically, and a fresh login was needed
after a short interval. The user wants a full working day without renewed login
and would prefer no OTP at initial app sign-in, with refinement explicitly
@ -93,7 +104,8 @@ could not read native node, Deployment and PVC status. The founder's subsequent
[capture preflight](evidence/2026-11-06-vergabe-capture-preflight-return.json)
clear that blocker at Helm revision 4. They use the founder's own terminal and
existing SSH/sudo lane; the agent receives only sanitized receipts. Native
capture and restart have not run.
capture and automated restart verification subsequently passed; see the capture
receipt above. No additional pause is needed to transfer that frozen point.
After the populated workflow, prepare the exact attended recovery action from
the existing [pilot recovery contract](vergabe-teilnahme-pilot.md#persistence-and-recovery)
@ -122,8 +134,8 @@ and [deployment binding](vergabe-demo-company-binding.md). Its receipt must cove
Existing database-only backup/restore targets default to historical `vergabe_db`
and omit both file stores. They cannot establish this three-store recovery claim.
The two-account functional walkthrough is user-confirmed with UI follow-up.
The attended capture/restart action is prepared and its live preflight passes;
execution awaits the founder's go. Off-host publication, isolated native restore
and rollback remain outstanding.
The attended capture/restart action has executed successfully, with automated
and human restart checks (overview follow-up above). Off-host publication,
isolated restore and rollback remain outstanding.
Logged-in account deactivation remains a separate owner-run access-control check
from the pilot contract. No account is disabled as part of these walkthrough steps.

View file

@ -3,6 +3,14 @@
Updated 2026-11-06 under RAPPS-WP-0014 and VERGABE-WP-0019.
Release binding is complete (RAPPS-WP-0014-T02). Recovery and invited-recipient acceptance remain open.
November 6 recovery update: the founder's matched PostgreSQL/media/SQLite capture
and app resume pass at revision 4; the full operation took 98.61 seconds. Both
accounts work after restart, with an overview-list UI follow-up. See the
[capture receipt](evidence/2026-11-06-vergabe-three-store-capture-return.json).
Provider publication/readback, isolated restore, rollback/access and operating
ownership remain open. The accepted point can be transferred without another
pause using the [encrypted export packet](proposals/2026-11-06-vergabe-recovery-export/README.md).
| Item | Current binding |
| --- | --- |
| Tenant | tenant:trial:demo-company, active/version 1; three native memberships and one linked directory identity |

View file

@ -0,0 +1,156 @@
"""Synthetic recovery export checks; no node, native backup or credential reads."""
import io
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
import zipfile
from contextlib import redirect_stdout
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
import vergabe_pilot_export as export
import vergabe_pilot_export_remote as remote
from vergabe_pilot_recovery_store import Refused, digest
class ExportTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix="vergabe-export-fixture-")
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.capture = self.root / "capture"
self.capture.mkdir(mode=0o700)
for name, content in {"database.dump": b"PGDMP synthetic data",
"stores.tar.gz": b"synthetic store data"}.items():
(self.capture / name).write_bytes(content)
(self.capture / name).chmod(0o600)
expected = {name: ((self.capture / name).stat().st_size, digest(self.capture / name))
for name in export.EXPECTED}
self.patch = patch.object(export, "EXPECTED", expected)
self.patch.start()
self.addCleanup(self.patch.stop)
receipt = {"run_id": export.RUN_ID, "status": "captured_and_resumed", "helm_revision": 4,
"database": "vergabe_demo_company", "app_resumed": True, "writers_quiesced": True,
"helper_cleanup_confirmed": True, "restart_fixture_verified": True}
for name, prefix in [("database.dump", "database"), ("stores.tar.gz", "stores")]:
receipt[prefix + "_bytes"], receipt[prefix + "_sha256"] = expected[name]
self.write("receipt.json", receipt)
self.write("manifest.private.json", {"image": export.IMAGE, "database": "vergabe_demo_company",
"private_fixture": "synthetic-private-sentinel"})
def write(self, name, value):
path = self.capture / name
path.write_text(json.dumps(value))
path.chmod(0o600)
def test_changed_capture_refuses_before_crypto(self):
(self.capture / "database.dump").write_bytes(b"different capture")
with patch.object(export.subprocess, "run") as crypto, self.assertRaises(Refused):
export.seal(self.capture, "age")
crypto.assert_not_called()
def test_public_or_linked_source_is_refused(self):
path = self.capture / "manifest.private.json"
path.chmod(0o644)
with self.assertRaises(Refused):
export.capture_files(self.capture)
path.unlink()
path.symlink_to(self.capture / "receipt.json")
with self.assertRaises(Refused):
export.capture_files(self.capture)
def test_handoff_never_overwrites_a_different_file(self):
home = self.root / "home"
home.mkdir(mode=0o700)
target = home / "packet.age"
target.write_bytes(b"already there")
target.chmod(0o600)
with self.assertRaises(Refused):
export.handoff(self.capture / "database.dump", home, target.name, os.geteuid(), os.getegid())
self.assertEqual(target.read_bytes(), b"already there")
@unittest.skipUnless(shutil.which("age") and shutil.which("age-keygen"), "requires age fixture tools")
def test_encrypt_decrypt_transfer_and_retry_preserve_the_whole_packet(self):
identity = self.root / "synthetic-identity"
generated = subprocess.run(["age-keygen"], capture_output=True, check=True)
identity.write_bytes(generated.stdout)
identity.chmod(0o600)
public = subprocess.run(["age-keygen", "-y", str(identity)], capture_output=True, check=True).stdout.decode().strip()
with patch.object(export, "RECIPIENT", public):
ciphertext, sealed = export.seal(self.capture, shutil.which("age"))
repeated, again = export.seal(self.capture, shutil.which("age"))
self.assertEqual(digest(repeated), digest(ciphertext))
self.assertEqual(again, sealed)
decrypted = subprocess.run(["age", "-d", "-i", str(identity), str(ciphertext)],
capture_output=True, check=True)
with zipfile.ZipFile(io.BytesIO(decrypted.stdout)) as packet:
self.assertEqual(set(packet.namelist()),
{"database.dump", "stores.tar.gz", "manifest.private.json", "receipt.json", "packet.json"})
metadata = json.loads(packet.read("packet.json"))
for name, row in metadata["files"].items():
self.assertEqual(packet.read(name), (self.capture / name).read_bytes())
self.assertEqual(row["sha256"], digest(self.capture / name))
home = self.root / "handoff"
home.mkdir(mode=0o700)
export.handoff(ciphertext, home, export.NAME + ".zip.age", os.geteuid(), os.getegid())
export.handoff(self.capture / "seal.json", home, export.NAME + ".transfer.json", os.geteuid(), os.getegid())
result = remote.verify(home)
self.assertEqual(result["status"], "encrypted_workstation_copy_verified")
self.assertFalse(result["provider_publication_verified"])
self.assertFalse(result["decryption_verified"])
self.assertFalse(result["restore_verified"])
self.assertNotIn("synthetic-private-sentinel", json.dumps(sealed))
(home / (export.NAME + ".zip.age")).write_bytes(b"age-encryption.org/v1\ntampered")
with self.assertRaises(Refused):
remote.verify(home)
def test_changed_source_pin_prevents_remote_execution(self):
with patch.dict(remote.PINS, {"vergabe_pilot_export": "0" * 64}), self.assertRaises(Refused):
remote.remote_command()
def test_fetch_publishes_only_a_verified_pair_and_reuses_it_on_retry(self):
handoff = self.root / "remote-home"
handoff.mkdir(mode=0o700)
cipher = handoff / (export.NAME + ".zip.age")
cipher.write_bytes(b"age-encryption.org/v1\nsynthetic-ciphertext")
cipher.chmod(0o600)
metadata = {"schema": "railiance.vergabe-pilot-sealed-export.v1", "run_id": export.RUN_ID,
"recipient": export.RECIPIENT, "ciphertext_sha256": digest(cipher),
"ciphertext_bytes": cipher.stat().st_size,
"packet": {"run_id": export.RUN_ID, "image": export.IMAGE,
"database": "vergabe_demo_company", "files": export.capture_files(self.capture)}}
transfer = handoff / (export.NAME + ".transfer.json")
transfer.write_text(json.dumps(metadata))
transfer.chmod(0o600)
def execute(args, **kwargs):
if args[0] == "scp":
source = args[2].split(":", 1)[1]
shutil.copy2(handoff / source, Path(args[3]))
return subprocess.CompletedProcess(args, 0)
with patch.object(remote, "remote_command", return_value=["fixture-ssh"]), \
patch.object(remote.Path, "home", return_value=self.root / "workstation"), \
patch.object(remote, "outside_git", return_value=True), \
patch.object(remote.sys.stdin, "isatty", return_value=True), \
patch.object(remote.sys, "argv", ["export", "--fetch"]), \
patch.object(remote.subprocess, "run", side_effect=execute) as command, \
redirect_stdout(io.StringIO()):
self.assertEqual(remote.main(), 0)
self.assertEqual(remote.main(), 0)
self.assertEqual(command.call_count, 3)
path = self.root / "workstation/.local/state/vergabe-pilot-recovery" / export.RUN_ID
self.assertTrue((path / "workstation-copy.json").exists())
self.assertEqual({p.suffix for p in path.iterdir()}, {".age", ".json"})
def test_nested_backup_destination_inside_a_repository_is_refused(self):
(self.root / ".git").mkdir()
self.assertFalse(remote.outside_git(self.root / "private/backup"))
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Seal the accepted recovery point for founder-only workstation transfer.
Run by the founder through the remote wrapper. Reads only this completed
capture; writes an age-encrypted packet and private founder handoff files.
No Kubernetes, database, OpenBao or provider access.
"""
import json
import os
import pwd
import shutil
import stat
import subprocess
import tempfile
import zipfile
from pathlib import Path
from vergabe_pilot_recovery_store import Refused, digest, private_directory, require
RUN_ID = "20261105T233543Z-89bfbe04dece"
BASE = Path("/var/lib/vergabe-pilot-recovery")
NAME = "vergabe-pilot-" + RUN_ID
# Public recipient declared by railiance-platform/lib/railiance-backup-common.sh.
RECIPIENT = "age1zvryunvjhvpkmasskauga2heeg0ztnte9ymgppvjge36ekumk50syr3tsz"
EXPECTED = {
"database.dump": (169232, "8758ebd2e2b6c9e75742c42f2d32f71596522f5aade3a7212ed5a04c3cf3476c"),
"stores.tar.gz": (3489, "6affa22da26badb4daceb87acf62d4eba57bfbfc2d60da0823b607c449596d66"),
}
IMAGE = ("forgejo.coulomb.social/coulomb/vergabe-teilnahme@sha256:"
"a26444f59c259698159c69ccb96f73dc648a261ece4c86bb2037a9d977870d91")
def regular(path, owner):
info = path.lstat()
require(stat.S_ISREG(info.st_mode) and info.st_uid == owner
and not info.st_mode & 0o077, "private_regular_file_required")
return info
def capture_files(directory):
private_directory(directory)
files = {}
for name in [*EXPECTED, "manifest.private.json", "receipt.json"]:
path = directory / name
info = regular(path, os.geteuid())
require(0 < info.st_size <= 1024 * 1024, "capture_file_bound")
files[name] = {"bytes": info.st_size, "sha256": digest(path)}
if name in EXPECTED:
require((info.st_size, files[name]["sha256"]) == EXPECTED[name], "capture_hash_mismatch")
receipt = json.loads((directory / "receipt.json").read_text())
require(receipt["run_id"] == RUN_ID and receipt["status"] == "captured_and_resumed"
and receipt["helm_revision"] == 4 and receipt["database"] == "vergabe_demo_company"
and all(receipt.get(key) is True for key in ["app_resumed", "writers_quiesced",
"helper_cleanup_confirmed", "restart_fixture_verified"]), "capture_not_accepted")
for name, prefix in [("database.dump", "database"), ("stores.tar.gz", "stores")]:
require(receipt[prefix + "_sha256"] == files[name]["sha256"]
and receipt[prefix + "_bytes"] == files[name]["bytes"], "receipt_hash_mismatch")
manifest = json.loads((directory / "manifest.private.json").read_text())
require(manifest["image"] == IMAGE and manifest["database"] == "vergabe_demo_company",
"capture_binding_mismatch")
return files
def seal(directory, age):
files = capture_files(directory)
metadata = {"schema": "railiance.vergabe-pilot-recovery-packet.v1", "run_id": RUN_ID,
"database": "vergabe_demo_company", "image": IMAGE, "files": files}
ciphertext = directory / "recovery-point.zip.age"
seal_receipt = directory / "seal.json"
if ciphertext.exists() or seal_receipt.exists():
regular(ciphertext, os.geteuid())
regular(seal_receipt, os.geteuid())
result = json.loads(seal_receipt.read_text())
require(result["packet"] == metadata and result["recipient"] == RECIPIENT
and result["ciphertext_sha256"] == digest(ciphertext)
and result["ciphertext_bytes"] == ciphertext.stat().st_size,
"existing_seal_mismatch")
return ciphertext, result
with tempfile.TemporaryDirectory(prefix="seal-", dir=directory) as temporary:
packet = Path(temporary) / "recovery-point.zip"
with zipfile.ZipFile(packet, "x", compression=zipfile.ZIP_STORED) as archive:
for name in files:
archive.write(directory / name, name)
archive.writestr("packet.json", json.dumps(metadata, sort_keys=True))
packet.chmod(0o600)
# Recheck frozen source hashes after packing, before encryption/publication.
require(capture_files(directory) == files, "capture_changed_during_export")
encrypted = Path(temporary) / "packet.age"
fd = os.open(encrypted, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
with os.fdopen(fd, "wb") as output:
result = subprocess.run([age, "-r", RECIPIENT, str(packet)], stdout=output,
stderr=subprocess.DEVNULL, timeout=60)
require(result.returncode == 0, "encryption_failed")
output.flush()
os.fsync(output.fileno())
require(encrypted.stat().st_size <= 4 * 1024 * 1024, "encrypted_packet_bound")
with encrypted.open("rb") as source:
require(source.read(22) == b"age-encryption.org/v1\n", "age_header_required")
result = {"schema": "railiance.vergabe-pilot-sealed-export.v1", "run_id": RUN_ID,
"recipient": RECIPIENT, "ciphertext_sha256": digest(encrypted),
"ciphertext_bytes": encrypted.stat().st_size, "packet": metadata,
"provider_publication_verified": False, "restore_verified": False}
fd = os.open(seal_receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
with os.fdopen(fd, "w") as output:
json.dump(result, output, indent=2)
output.flush()
os.fsync(output.fileno())
# No overwrite, including after an interrupted export. A partial pair
# requires review rather than silently replacing an earlier ciphertext.
os.link(encrypted, ciphertext)
return ciphertext, result
def handoff(source, directory, name, uid, gid):
target = directory / name
if target.exists() or target.is_symlink():
regular(target, uid)
require(digest(target) == digest(source), "existing_handoff_mismatch")
return
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
with os.fdopen(fd, "wb") as output, source.open("rb") as stream:
shutil.copyfileobj(stream, output)
output.flush()
os.fsync(output.fileno())
os.fchown(output.fileno(), uid, gid)
def main():
require(os.geteuid() == 0, "founder_node_sudo_required")
account = pwd.getpwnam("tegwick")
require(os.environ.get("SUDO_USER") == account.pw_name
and os.environ.get("SUDO_UID") == str(account.pw_uid) and account.pw_uid > 0,
"founder_ssh_account_required")
home = Path(account.pw_dir)
info = home.lstat()
require(str(home) == "/home/tegwick" and stat.S_ISDIR(info.st_mode)
and info.st_uid == account.pw_uid and not info.st_mode & 0o022,
"founder_home_required")
age = next((str(p) for p in [Path("/usr/bin/age"), Path("/usr/local/bin/age"),
Path("/opt/railiance-platform/tools/vendor/bin/age"),
home / "railiance-platform/tools/vendor/bin/age"] if p.is_file() and os.access(p, os.X_OK)), None)
require(age, "age_unavailable")
private_directory(BASE)
encrypted, result = seal(BASE / RUN_ID, age)
handoff(encrypted, home, NAME + ".zip.age", account.pw_uid, account.pw_gid)
handoff(encrypted.parent / "seal.json", home, NAME + ".transfer.json", account.pw_uid, account.pw_gid)
print(json.dumps({"status": "encrypted_handoff_ready", "run_id": RUN_ID,
"ciphertext_sha256": result["ciphertext_sha256"],
"ciphertext_bytes": result["ciphertext_bytes"], "application_changed": False}))
if __name__ == "__main__":
os.umask(0o077)
try:
main()
except Exception as error:
print(json.dumps({"status": "export_failed", "failure": str(error) if isinstance(error, Refused)
else "export_command_failed", "details_suppressed": True}))
raise SystemExit(1)

View file

@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Founder workstation: seal and fetch this completed pilot recovery point.
Default reviews only. --fetch uses the existing interactive SSH/sudo lane to
encrypt on railiance01, then scp copies only ciphertext and a safe manifest.
No plaintext backup, credential or private recovery key leaves the node.
"""
import argparse
import hashlib
import json
import os
import shlex
import subprocess
import sys
import tempfile
from pathlib import Path
import vergabe_pilot_export as export
from vergabe_pilot_recovery_store import Refused, digest, private_directory, require
PINS = {
"vergabe_pilot_recovery_store": "724a2dd9db8a0f57e1efa709ec989d8a47c681a86b1fca923bfdfea1698ae23c",
"vergabe_pilot_export": "b45733fa10ae59b56563045c43c53b5f824a74e1114f68fbed28404f14b43e3a",
}
def remote_command():
sources = {}
for name, pin in PINS.items():
raw = Path(__file__).with_name(name + ".py").read_bytes()
require(hashlib.sha256(raw).hexdigest() == pin, "export_source_pin_mismatch")
sources[name] = raw.decode()
bootstrap = ("import sys,types;m=types.ModuleType('vergabe_pilot_recovery_store');"
"sys.modules[m.__name__]=m;exec(compile(" + repr(sources["vergabe_pilot_recovery_store"]) +
",'vergabe_pilot_recovery_store.py','exec'),m.__dict__);exec(compile(" +
repr(sources["vergabe_pilot_export"]) + ",'vergabe_pilot_export.py','exec'),"
"{'__name__':'__main__'})")
return ["ssh", "-t", "railiance01", shlex.join(["sudo", "/usr/bin/python3", "-c", bootstrap])]
def verify(directory):
private_directory(directory)
ciphertext = directory / (export.NAME + ".zip.age")
transfer = directory / (export.NAME + ".transfer.json")
export.regular(ciphertext, os.geteuid())
export.regular(transfer, os.geteuid())
require(0 < transfer.stat().st_size <= 16384, "transfer_metadata_bound")
receipt = json.loads(transfer.read_text())
require(receipt["schema"] == "railiance.vergabe-pilot-sealed-export.v1"
and receipt["run_id"] == export.RUN_ID and receipt["recipient"] == export.RECIPIENT
and receipt["packet"]["run_id"] == export.RUN_ID
and receipt["packet"]["image"] == export.IMAGE
and receipt["packet"]["database"] == "vergabe_demo_company", "wrong_recovery_point")
for name, (size, checksum) in export.EXPECTED.items():
require(receipt["packet"]["files"][name] == {"bytes": size, "sha256": checksum},
"source_hash_mismatch")
require(0 < ciphertext.stat().st_size <= 4 * 1024 * 1024
and ciphertext.stat().st_size == receipt["ciphertext_bytes"]
and digest(ciphertext) == receipt["ciphertext_sha256"], "ciphertext_transfer_mismatch")
with ciphertext.open("rb") as stream:
require(stream.read(22) == b"age-encryption.org/v1\n", "age_header_required")
return {"schema": "railiance.vergabe-pilot-workstation-copy.v1", "run_id": export.RUN_ID,
"status": "encrypted_workstation_copy_verified", "ciphertext_sha256": receipt["ciphertext_sha256"],
"ciphertext_bytes": receipt["ciphertext_bytes"], "encrypted_on_source_node": True,
"off_host_copy_verified": True, "provider_publication_verified": False,
"decryption_verified": False, "restore_verified": False, "application_changed": False}
def outside_git(path):
return not any((p / ".git").exists() for p in [path, *path.parents])
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--fetch", action="store_true")
args = parser.parse_args()
command = remote_command()
if not args.fetch:
print(json.dumps({"status": "prepared", "run_id": export.RUN_ID, "ssh_target": "railiance01",
"application_changed": False, "next": "--fetch", "sources": PINS}))
return 0
require(sys.stdin.isatty(), "founder_interactive_terminal_required")
destination = Path.home() / ".local/state/vergabe-pilot-recovery" / export.RUN_ID
require(outside_git(destination), "private_destination_outside_git_required")
destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
private_directory(destination.parent)
if destination.exists() or destination.is_symlink():
result = verify(destination)
else:
result = subprocess.run(command, check=False)
require(result.returncode == 0, "remote_seal_failed")
with tempfile.TemporaryDirectory(prefix="fetch-", dir=destination.parent) as temporary:
temporary = Path(temporary)
for suffix in (".zip.age", ".transfer.json"):
name = export.NAME + suffix
copied = subprocess.run(["scp", "-p", "railiance01:" + name, str(temporary / name)],
capture_output=True, timeout=120)
require(copied.returncode == 0, "encrypted_copy_failed")
result = verify(temporary)
# Keep a verified pair only; temporary failures never become success.
temporary.rename(destination)
result["local_directory"] = str(destination)
receipt = destination / "workstation-copy.json"
if receipt.exists() or receipt.is_symlink():
export.regular(receipt, os.geteuid())
require(json.loads(receipt.read_text()) == result, "existing_copy_receipt_mismatch")
else:
fd = os.open(receipt, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o600)
with os.fdopen(fd, "w") as output:
json.dump(result, output, indent=2)
output.flush()
os.fsync(output.fileno())
print(json.dumps(result, indent=2))
return 0
if __name__ == "__main__":
os.umask(0o077)
try:
raise SystemExit(main())
except Exception as error:
print(json.dumps({"status": "export_failed", "failure": str(error) if isinstance(error, Refused)
else "export_transport_failed", "details_suppressed": True}))
raise SystemExit(1)

View file

@ -196,10 +196,10 @@ id: RAPPS-WP-0014-T03
status: wait
needs_human: true
decision_id: d06307e5-2737-461a-a2f4-a12c3b492a9a
intervention_note: "Functional walkthrough is confirmed with UI follow-up. Latest founder inventory returned cluster_read unavailable_or_invalid_response; invoking host is unconfirmed, so no cluster fault is inferred. Next: run tools/vergabe_pilot_inventory_remote.py --run from the founder workstation terminal to target railiance01 explicitly. Agent privileges stay unchanged. Native inventory, populated recovery/access/rollback and ownership remain open."
intervention_note: "Native matched PostgreSQL/media/SQLite capture and resume pass at Helm revision 4 (98.61 seconds whole operation). Both accounts work after restart, with an Übersicht omission under UI review. Next: founder-run encrypted export --fetch, then provider publication/readback and isolated restore. Access/rollback and operating ownership remain open."
priority: high
assignee: the-custodian
blocking_reason: "Two-account functional walkthrough is confirmed with UI follow-up. Await attended native inventory, populated restart, coherent PostgreSQL/media/issue-state off-host recovery, isolated restored workflow, rollback/access evidence and operating ownership. September 25 proves database-only restore and restart."
blocking_reason: "Matched populated capture, automated restart checks and human two-account return pass with overview UI follow-up. Await encrypted off-host publication/readback, isolated restored workflow, access checks, compatible rollback and operating ownership."
state_hub_task_id: "dd069c6d-fcc1-5bac-b233-976f2f0d5cd1"
```
@ -402,6 +402,35 @@ and guarded resume. No capture or populated restart has executed. T03 remains
waiting for that action and subsequent off-host/restore/rollback, access and
operating-owner acceptance.
2026-11-06 founder execution return: run `20261105T233543Z-89bfbe04dece`
reports `captured_and_resumed` at Helm revision 4. PostgreSQL: 39 tables / 247
rows, 169232-byte custom dump. Media: one 803-byte PDF matching the original
SHA256. SQLite: 9 tables / 7 rows, integrity and linked issue verified. Writers
were quiesced, helper cleanup confirmed and the app resumed with automated
document/issue checks passing. Whole operation: 98.61 seconds; this is not a
separately measured outage duration. Native receipt:
`docs/evidence/2026-11-06-vergabe-three-store-capture-return.json`.
The founder's execution is the go for the reviewed capture change. The app is
running; further capture pauses are unnecessary for this point. The human return
confirms both accounts work and everything else seems as before, with an
Übersicht omission reported. The deployed dashboard filters ongoing tenders to
status 1–9; final statuses would explain exclusion, but the current fixture
status is not yet confirmed. Receipt:
`docs/evidence/2026-11-06-vergabe-post-restart-user-return.json`. Keep that UI
follow-up separate from the confirmed restart checks.
Off-host publication/readback and isolated restore remain explicitly unverified;
rollback/access and operating ownership also remain open. T03 is not complete.
Prepared `tools/vergabe_pilot_export_remote.py --fetch` and the review packet
`docs/proposals/2026-11-06-vergabe-recovery-export/README.md`: seal this exact
completed point on the node to the existing platform public age recipient, then
fetch only ciphertext/metadata to the founder's private workstation directory
and verify hashes. Seven synthetic export cases pass, including real age round
trip and transport/retry. This action does not pause the app or access credentials
or the provider. Native execution is pending. Warden routing reconfirms the
existing platform Nextcloud lane; an encrypted workstation copy is not provider
publication, independent escrow readback or an isolated restore receipt.
## Validate synthetic sizing against the exact release and shared contention