diff --git a/README.md b/README.md index fdd97d2..8766222 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,10 @@ must not be mounted on NFS or used for active-active replicas. - `tests/` — service, concurrency, governance, and HTTP contract tests; - `docs/` — architecture, tool contract, threat model, production notes; - `deploy/` — container and Compose example; +- `work-packages/` — governed implementation and activation scopes; - `evidence/` — WP-PA-001 verification and independent-review handoff. See [docs/TOOL_CONTRACT.md](docs/TOOL_CONTRACT.md) before integrating a new actor and [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md) before deployment. +Sandbox activation is governed by +[docs/SANDBOX_ACTIVATION_RUNBOOK.md](docs/SANDBOX_ACTIVATION_RUNBOOK.md). diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 846fd2e..4a389a8 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -8,6 +8,7 @@ services: PROJECT_BUS_BOOTSTRAP_TOKEN: "${PROJECT_BUS_BOOTSTRAP_TOKEN:?set a strong bootstrap token}" PROJECT_BUS_TOKENS_JSON: "${PROJECT_BUS_TOKENS_JSON:-}" PROJECT_BUS_ALLOWED_ORIGINS: "${PROJECT_BUS_ALLOWED_ORIGINS:-}" + PROJECT_BUS_LOG_LEVEL: "${PROJECT_BUS_LOG_LEVEL:-INFO}" ports: - "127.0.0.1:8080:8080" volumes: @@ -19,7 +20,17 @@ services: - no-new-privileges:true cap_drop: - ALL + healthcheck: + test: + - CMD + - python + - -c + - "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=2)" + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + stop_grace_period: 15s volumes: project-bus-data: - diff --git a/deploy/project-bus.env.example b/deploy/project-bus.env.example new file mode 100644 index 0000000..570edce --- /dev/null +++ b/deploy/project-bus.env.example @@ -0,0 +1,6 @@ +# Copy outside Git, restrict to the service administrator, and replace every +# placeholder. Never commit the populated file. +PROJECT_BUS_BOOTSTRAP_TOKEN=replace-with-at-least-24-random-characters +PROJECT_BUS_TOKENS_JSON={"replace-po-token":{"actor_id":"sandbox-po","auth_subject":"sandbox-po","display_name":"Dillard / Product Owner"},"replace-pm-token":{"actor_id":"sandbox-pm","auth_subject":"sandbox-pm","display_name":"Sandbox PM"},"replace-pa-token":{"actor_id":"sandbox-pa","auth_subject":"sandbox-pa","display_name":"Sandbox PA"},"replace-codex-token":{"actor_id":"codex-implementer","auth_subject":"codex-implementer","display_name":"Codex Implementer"},"replace-claude-token":{"actor_id":"claude-reviewer","auth_subject":"claude-reviewer","display_name":"Claude Code Reviewer"}} +PROJECT_BUS_ALLOWED_ORIGINS= +PROJECT_BUS_LOG_LEVEL=INFO diff --git a/deploy/sandbox-project.json b/deploy/sandbox-project.json new file mode 100644 index 0000000..2b46721 --- /dev/null +++ b/deploy/sandbox-project.json @@ -0,0 +1,39 @@ +{ + "project": { + "project_id": "epimonos-sandbox", + "name": "Epimonos Sandbox", + "description": "BASDM-governed Sandbox project coordination" + }, + "actors": [ + { + "actor_id": "sandbox-po", + "auth_subject": "sandbox-po", + "display_name": "Dillard / Product Owner", + "role": "PO" + }, + { + "actor_id": "sandbox-pm", + "auth_subject": "sandbox-pm", + "display_name": "Sandbox PM", + "role": "PM" + }, + { + "actor_id": "sandbox-pa", + "auth_subject": "sandbox-pa", + "display_name": "Sandbox PA", + "role": "PA" + }, + { + "actor_id": "codex-implementer", + "auth_subject": "codex-implementer", + "display_name": "Codex Implementer", + "role": "IMPLEMENTER" + }, + { + "actor_id": "claude-reviewer", + "auth_subject": "claude-reviewer", + "display_name": "Claude Code Reviewer", + "role": "REVIEWER" + } + ] +} diff --git a/docs/SANDBOX_ACTIVATION_RUNBOOK.md b/docs/SANDBOX_ACTIVATION_RUNBOOK.md new file mode 100644 index 0000000..605f7df --- /dev/null +++ b/docs/SANDBOX_ACTIVATION_RUNBOOK.md @@ -0,0 +1,151 @@ +# Sandbox Activation Runbook + +This runbook operationalises the Project Bus for the Epimonos Sandbox project. +It does not itself authorize an internet-facing endpoint or replacement of the +existing formal project channels. + +## Authority and transition rule + +WP-PA-001 is the accepted software baseline. WP-PA-002 covers deployment and +activation. SG, PTO and the existing project threads remain authoritative +until the independent reviewer returns `APPROVE` on the operationalisation +commit and the Sandbox PM closes the WP-PA-002 gate with `ACCEPT`. + +Before that gate, Project Bus records are shadow records. Differences are +resolved in favour of the formal threads and recorded as corrections rather +than silently rewriting history. + +## Deployment preparation + +1. Use a dedicated single-node host or controlled test VM. +2. Keep the endpoint private unless PM-to-PO consultation has approved + internet exposure. +3. Copy `deploy/project-bus.env.example` to a root/service-owner-readable file + outside the repository. +4. Generate independent random bearer credentials of at least 32 bytes for + SYSTEM, PO, PM, PA, Codex and Claude. Never reuse Git or account tokens. +5. Replace the example values and set permissions to owner-read/write only. +6. Configure TLS/authenticated ingress before any non-local exposure. +7. Pin the reviewed Git commit or immutable image digest. + +Start: + +```bash +docker compose --env-file /secure/path/project-bus.env \ + -f deploy/compose.yaml up -d --build +docker compose -f deploy/compose.yaml ps +``` + +The container must report healthy and run as the non-root image user. + +## Project bootstrap + +The bootstrap command reads the SYSTEM credential only from an environment +variable. The manifest contains identities and roles, never credentials. + +```bash +export PROJECT_BUS_BOOTSTRAP_TOKEN='read-from-secret-store' +project-bus-ops bootstrap \ + --endpoint http://127.0.0.1:8080/mcp \ + --manifest deploy/sandbox-project.json +unset PROJECT_BUS_BOOTSTRAP_TOKEN +``` + +Run the same command a second time. It must replay successfully and create no +duplicate project, actor or event records. + +After bootstrap, remove the SYSTEM credential from the steady-state service +configuration and restart. Retain it offline only if the operating policy +requires future project creation; otherwise rotate/revoke it. + +## Actor connection and smoke test + +For each actor credential: + +1. call `initialize` without authentication; +2. call `get_project` for `epimonos-sandbox`; +3. verify returned roster and formal role expectations; +4. call `sync_since` with that client's durable cursor; +5. call `list_pending_actions`; +6. persist the new cursor only after processing all returned events. + +Store each actor token in that client's secret facility. Do not place tokens in +`.mcp.json`, project instructions, prompts, shell history or Git. + +Claude Code endpoint registration: + +```bash +claude mcp add --scope project --transport http \ + epimonos-project-bus https://approved-project-bus.example/mcp +``` + +The endpoint configuration may be project-scoped; the credential must remain +in a user/workload secret store. + +## Initial shadow records + +Once the actors authenticate, the PM registers WP-PA-002 through +`issue_work_package`, Codex claims it, and the PA links the implementation +commit and evidence. These records mirror the formal thread record during the +bootstrap phase. Claude submits the independent review through both the +existing formal channel and the bus. + +The PM then closes the WP-PA-002 gate. Only an `ACCEPT` outcome after an +independent `APPROVE` permits the separately recorded operational decision +that the bus becomes the primary project interface. + +## Backup and restore + +Create a consistent online backup: + +```bash +project-bus-ops backup \ + --source /data/project-bus.db \ + --destination /secure/backup/project-bus-$(date +%Y%m%dT%H%M%S).db +``` + +The command uses SQLite's online backup API and runs `PRAGMA integrity_check`. +Copy the backup off-host under the applicable retention policy. + +Quarterly and before primary-interface activation: + +1. restore a backup into an isolated instance; +2. start the exact reviewed application version against the restored database; +3. authenticate with a temporary test credential; +4. compare the restored maximum event cursor with the backup receipt; +5. run `get_project`, `sync_since` and `list_pending_actions`; +6. destroy the isolated restore instance and revoke the test credential. + +## Rotation and revocation + +Static-token changes require a restart in v0.1. Generate a replacement, +update the external secret configuration, restart, verify the new credential, +then remove the old credential and restart again. Treat exposure as an +incident; rotate first and investigate from the immutable event trail and edge +logs. + +## Cursor recovery + +Each consumer owns a durable cursor per project. When processing fails, retain +the last successfully processed cursor and replay from it. Reads are safe to +repeat; mutations require stable idempotency keys. Never advance a cursor +before all returned events have been durably processed. + +## Rollback + +Before the activation gate, stop the bus and return solely to SG/PTO/project +threads; no authority has transferred. After activation, announce the incident +in the fallback formal channels, stop writes, preserve the database and logs, +restore the latest verified backup if required, and record reconciliation +events when service resumes. Never delete or edit prior events. + +## Gate evidence checklist + +- exact reviewed commit/image digest; +- healthy non-root container; +- two successful bootstrap runs with unchanged event cursor on replay; +- successful authenticated checks for every formal actor; +- backup receipt, restore result and matching event cursor; +- secret scan with no credential material in Git; +- independent Claude review report; +- PM `ACCEPT` gate event and explicit primary-interface operational decision. diff --git a/evidence/WP-PA-002-IMPLEMENTATION-EVIDENCE.md b/evidence/WP-PA-002-IMPLEMENTATION-EVIDENCE.md new file mode 100644 index 0000000..01a383f --- /dev/null +++ b/evidence/WP-PA-002-IMPLEMENTATION-EVIDENCE.md @@ -0,0 +1,77 @@ +# WP-PA-002 Implementation Evidence + +Baseline: `a2229bc26902e79d63a368e8ab002ebefd4f0863` +Implementer: Codex (`IMPLEMENTER`) +Date: 2026-07-30 + +## Scope delivered + +- repeatable Sandbox project and actor bootstrap manifest; +- operational CLI for authenticated bootstrap and consistent SQLite backup; +- hardened loopback-only Compose profile and external secret template; +- activation, rollback, credential rotation, cursor recovery, backup and + restore runbook; +- regression coverage against the real migrated database schema; +- independent-review brief for Claude Code. + +## Local verification + +Python: `3.12.13` + +```text +PYTHONPATH=src python3.12 -m unittest discover -s tests -v +Ran 24 tests in 7.315s +OK + +PIP_NO_CACHE_DIR=1 /bin/pip install --no-deps . +/bin/python -m unittest discover -s tests -v +Ran 24 tests in 6.201s +OK + +python3.12 -m compileall -q src tests +git diff --check +``` + +Both final commands completed without output or error. + +The operational exercise used a fresh migrated database and verified: + +- first bootstrap creates the project and five distinct formal actors; +- replay with the same manifest and stable idempotency keys creates no + additional events; +- PO, PM, PA, Codex and Claude identities authenticate independently and can + read the roster, synchronize events and query role-filtered pending actions; +- online backup completes with `PRAGMA integrity_check = ok`; +- the restored database has the same maximum `event_log.cursor` as its source. + +The backup exercise initially exposed an incorrect `events(sequence)` query. +The implementation now queries the actual migrated schema, +`event_log(cursor)`, and +`test_backup_is_consistent_and_integrity_checked` prevents regression. + +## Container verification handoff + +No Docker or Podman runtime is installed in the implementer's Work +environment. The implementer therefore did not claim container-build or +runtime evidence. The independent reviewer must build the exact review commit +in a Docker-capable environment and verify: + +- successful image and Compose build; +- healthy service; +- runtime UID `65532`; +- read-only root filesystem, dropped capabilities and + `no-new-privileges`; +- loopback-only published port; +- persistent database volume. + +This is an explicit review task in `WP-PA-002-REVIEW-BRIEF.md`, not a waived +acceptance criterion. + +## Secret handling and authority + +No populated environment file or actor credential is part of the worktree. +`deploy/project-bus.env.example` contains placeholders only. The runbook keeps +SG, PTO and existing project threads authoritative until an independent +`APPROVE` and PM `ACCEPT`. No endpoint, VM, purchase or internet-facing +deployment was created by this work package. + diff --git a/evidence/WP-PA-002-REVIEW-BRIEF.md b/evidence/WP-PA-002-REVIEW-BRIEF.md new file mode 100644 index 0000000..7be77d7 --- /dev/null +++ b/evidence/WP-PA-002-REVIEW-BRIEF.md @@ -0,0 +1,32 @@ +# Independent Review Brief — WP-PA-002 + +Reviewer: Claude Code (`REVIEWER`) +Implementer: Codex (`IMPLEMENTER`) + +Review only the exact commit supplied by the PA. Do not change, commit or push +implementation code. Verify that the commit descends from accepted WP-PA-001 +commit `a2229bc26902e79d63a368e8ab002ebefd4f0863`. + +## Required review + +1. Match every acceptance criterion in `work-packages/WP-PA-002.md` to + implementation and independent evidence. +2. Build and run the packaged application under Python 3.12. +3. Build and run the container, confirm its health check and non-root identity. +4. Bootstrap a fresh database twice and prove replay creates no extra events. +5. Authenticate separately as PO, PM, PA, Codex and Claude; verify the roster, + event sync and pending-actions reads. +6. Create a live backup, restore it in isolation, run integrity checks and + compare event cursors. +7. Search Git history and the reviewed tree for committed credentials. +8. Adversarially verify that bootstrap/operations tooling cannot weaken the + governance invariants accepted in WP-PA-001. +9. Verify the runbook preserves SG/PTO/project-thread authority until the + independent `APPROVE` and PM `ACCEPT` activation gate. +10. Check that no deployment is described as production-ready beyond the + constraints in `docs/PRODUCTION_PROFILE.md`. + +Write `REVIEW-REPORT-WP-PA-002.md` with exactly one verdict: +`APPROVE`, `CHANGES_REQUESTED`, or `REJECT`. Include exact commit SHA, +environment, commands, results, findings with severity, residual risks, and a +machine-readable summary. diff --git a/pyproject.toml b/pyproject.toml index d9999c0..f800669 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [] [project.scripts] project-bus = "project_bus.__main__:main" +project-bus-ops = "project_bus.operations:main" [tool.setuptools] package-dir = {"" = "src"} diff --git a/src/project_bus/operations.py b/src/project_bus/operations.py new file mode 100644 index 0000000..1ede9b1 --- /dev/null +++ b/src/project_bus/operations.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import argparse +import json +import os +import sqlite3 +import sys +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any, Protocol + +from .models import Role + + +class OperationsError(RuntimeError): + pass + + +class ToolCaller(Protocol): + def call(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]: ... + + +class HTTPMCPClient: + def __init__(self, endpoint: str, token: str, timeout: float = 10.0): + self.endpoint = endpoint + self.token = token + self.timeout = timeout + self._request_id = 0 + + def call(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]: + self._request_id += 1 + payload = { + "jsonrpc": "2.0", + "id": self._request_id, + "method": "tools/call", + "params": {"name": name, "arguments": arguments}, + } + request = urllib.request.Request( + self.endpoint, + data=json.dumps(payload, separators=(",", ":")).encode(), + headers={ + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json", + }, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=self.timeout) as response: + result = json.load(response) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error: + raise OperationsError(f"MCP request failed: {error}") from error + if "error" in result: + raise OperationsError(f"JSON-RPC error: {result['error']}") + tool_result = result.get("result", {}) + if tool_result.get("isError"): + content = tool_result.get("content", []) + message = content[0].get("text", "unknown tool error") if content else "unknown tool error" + raise OperationsError(message) + content = tool_result.get("content", []) + if not content or content[0].get("type") != "text": + raise OperationsError("MCP tool returned no JSON text content") + try: + return json.loads(content[0]["text"]) + except json.JSONDecodeError as error: + raise OperationsError("MCP tool returned invalid JSON text") from error + + +def load_manifest(path: Path) -> dict[str, Any]: + try: + manifest = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise OperationsError(f"Cannot read manifest: {error}") from error + project = manifest.get("project") + actors = manifest.get("actors") + if not isinstance(project, dict) or not isinstance(actors, list) or not actors: + raise OperationsError("Manifest requires a project object and non-empty actors array") + for field in ("project_id", "name"): + if not isinstance(project.get(field), str) or not project[field]: + raise OperationsError(f"Manifest project.{field} must be a non-empty string") + seen_ids: set[str] = set() + seen_subjects: set[str] = set() + for index, actor in enumerate(actors): + if not isinstance(actor, dict): + raise OperationsError(f"Manifest actor {index} must be an object") + for field in ("actor_id", "auth_subject", "display_name", "role"): + if not isinstance(actor.get(field), str) or not actor[field]: + raise OperationsError(f"Manifest actor {index}.{field} must be a non-empty string") + try: + role = Role(actor["role"]) + except ValueError as error: + raise OperationsError(f"Manifest actor {index} has invalid role") from error + if role is Role.SYSTEM: + raise OperationsError("SYSTEM is a transport bootstrap identity, not a project actor") + if actor["actor_id"] in seen_ids or actor["auth_subject"] in seen_subjects: + raise OperationsError("Actor IDs and auth subjects must be unique") + seen_ids.add(actor["actor_id"]) + seen_subjects.add(actor["auth_subject"]) + return manifest + + +def bootstrap_project(client: ToolCaller, manifest: dict[str, Any]) -> dict[str, Any]: + project = manifest["project"] + project_id = project["project_id"] + created = client.call( + "create_project", + { + "project_id": project_id, + "name": project["name"], + "description": project.get("description", ""), + "idempotency_key": f"bootstrap:{project_id}:project:v1", + }, + ) + registered = [] + for actor in manifest["actors"]: + registered.append( + client.call( + "register_actor", + { + "project_id": project_id, + **actor, + "idempotency_key": f"bootstrap:{project_id}:actor:{actor['actor_id']}:v1", + }, + ) + ) + return {"project": created, "actors": registered} + + +def backup_database(source: Path, destination: Path) -> dict[str, Any]: + if source.resolve() == destination.resolve(): + raise OperationsError("Backup destination must differ from source") + destination.parent.mkdir(parents=True, exist_ok=True) + try: + with sqlite3.connect(source) as source_db, sqlite3.connect(destination) as backup_db: + source_db.backup(backup_db) + integrity = backup_db.execute("PRAGMA integrity_check").fetchone()[0] + cursor_row = backup_db.execute( + "SELECT COALESCE(MAX(cursor), 0) FROM event_log" + ).fetchone() + except sqlite3.Error as error: + raise OperationsError(f"Database backup failed: {error}") from error + if integrity != "ok": + raise OperationsError(f"Backup integrity check failed: {integrity}") + return {"destination": str(destination), "integrity": integrity, "event_cursor": cursor_row[0]} + + +def _bootstrap_command(arguments: argparse.Namespace) -> int: + token = os.environ.get(arguments.token_env) + if not token: + raise OperationsError(f"Required secret environment variable is unset: {arguments.token_env}") + manifest = load_manifest(arguments.manifest) + result = bootstrap_project(HTTPMCPClient(arguments.endpoint, token), manifest) + print(json.dumps(result, indent=2)) + return 0 + + +def _backup_command(arguments: argparse.Namespace) -> int: + print(json.dumps(backup_database(arguments.source, arguments.destination), indent=2)) + return 0 + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser(description="Project Bus operational tooling") + subparsers = result.add_subparsers(dest="command", required=True) + bootstrap = subparsers.add_parser("bootstrap", help="Provision a project and actor roster") + bootstrap.add_argument("--endpoint", required=True) + bootstrap.add_argument("--manifest", type=Path, required=True) + bootstrap.add_argument("--token-env", default="PROJECT_BUS_BOOTSTRAP_TOKEN") + bootstrap.set_defaults(handler=_bootstrap_command) + backup = subparsers.add_parser("backup", help="Create and integrity-check a live SQLite backup") + backup.add_argument("--source", type=Path, required=True) + backup.add_argument("--destination", type=Path, required=True) + backup.set_defaults(handler=_backup_command) + return result + + +def main() -> None: + arguments = parser().parse_args() + try: + raise SystemExit(arguments.handler(arguments)) + except OperationsError as error: + print(f"error: {error}", file=sys.stderr) + raise SystemExit(2) from error + + +if __name__ == "__main__": + main() diff --git a/tests/test_operations.py b/tests/test_operations.py new file mode 100644 index 0000000..06de701 --- /dev/null +++ b/tests/test_operations.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import json +import sqlite3 +import tempfile +import unittest +from pathlib import Path + +from project_bus.db import Database +from project_bus.operations import OperationsError, backup_database, bootstrap_project, load_manifest + + +class RecordingClient: + def __init__(self): + self.calls = [] + + def call(self, name, arguments): + self.calls.append((name, arguments)) + return {"name": name, "idempotency_key": arguments["idempotency_key"]} + + +class OperationsTests(unittest.TestCase): + def manifest(self): + return { + "project": { + "project_id": "sandbox", + "name": "Sandbox", + "description": "test", + }, + "actors": [ + { + "actor_id": "po", + "auth_subject": "po", + "display_name": "PO", + "role": "PO", + }, + { + "actor_id": "reviewer", + "auth_subject": "reviewer", + "display_name": "Reviewer", + "role": "REVIEWER", + }, + ], + } + + def test_bootstrap_uses_stable_idempotency_keys(self): + first = RecordingClient() + second = RecordingClient() + bootstrap_project(first, self.manifest()) + bootstrap_project(second, self.manifest()) + self.assertEqual(first.calls, second.calls) + self.assertEqual(first.calls[0][0], "create_project") + self.assertEqual([call[0] for call in first.calls[1:]], ["register_actor", "register_actor"]) + + def test_manifest_rejects_system_and_duplicate_subjects(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "manifest.json" + invalid = self.manifest() + invalid["actors"][0]["role"] = "SYSTEM" + path.write_text(json.dumps(invalid), encoding="utf-8") + with self.assertRaises(OperationsError): + load_manifest(path) + invalid = self.manifest() + invalid["actors"][1]["auth_subject"] = "po" + path.write_text(json.dumps(invalid), encoding="utf-8") + with self.assertRaises(OperationsError): + load_manifest(path) + + def test_backup_is_consistent_and_integrity_checked(self): + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / "source.db" + destination = Path(directory) / "backup.db" + Database(source).migrate() + with sqlite3.connect(source) as connection: + connection.execute( + """ + INSERT INTO projects(project_id, name, description, created_at) + VALUES ('sandbox', 'Sandbox', '', '2026-07-30T00:00:00Z') + """ + ) + connection.execute( + """ + INSERT INTO actors(actor_id, auth_subject, display_name, created_at) + VALUES ('system', 'system', 'System', '2026-07-30T00:00:00Z') + """ + ) + connection.execute( + """ + INSERT INTO event_log( + event_id, project_id, event_type, actor_id, actor_role, + aggregate_type, aggregate_id, payload_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + "evt_test", + "sandbox", + "test.created", + "system", + "SYSTEM", + "test", + "test-1", + "{}", + "2026-07-30T00:00:00Z", + ), + ) + receipt = backup_database(source, destination) + self.assertEqual(receipt["integrity"], "ok") + self.assertEqual(receipt["event_cursor"], 1) + with sqlite3.connect(destination) as connection: + self.assertEqual( + connection.execute("SELECT COUNT(*) FROM event_log").fetchone()[0], + 1, + ) + + def test_backup_refuses_in_place_destination(self): + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / "source.db" + source.touch() + with self.assertRaises(OperationsError): + backup_database(source, source) + + +if __name__ == "__main__": + unittest.main() diff --git a/work-packages/WP-PA-002.md b/work-packages/WP-PA-002.md new file mode 100644 index 0000000..4dcfef0 --- /dev/null +++ b/work-packages/WP-PA-002.md @@ -0,0 +1,70 @@ +# WP-PA-002 — Project Bus Operationalisation + +Status: `ISSUED` +Project: Epimonos Sandbox +Issued by: Sandbox PM +Coordinated by: PA +Implementer: Codex (`IMPLEMENTER`) +Independent reviewer: Claude Code (`REVIEWER`) +Predecessor: WP-PA-001, accepted at commit +`a2229bc26902e79d63a368e8ab002ebefd4f0863` + +## Objective + +Bring the accepted Project Bus MVP into controlled operational use for the +Sandbox project without yet declaring it the primary project interface. + +## Scope + +- a repeatable, idempotent Sandbox project bootstrap; +- explicit actor/role provisioning without credentials in Git; +- a hardened single-node Compose deployment profile; +- health, authenticated smoke, backup and restore verification; +- client connection guidance and durable cursor ownership; +- an activation, rollback and incident runbook; +- evidence for an independent implementation and security review. + +The Project Bus remains a separate generic component and repository. This work +package does not change the Generic Sandbox API, Sandbox Manager Core, Incus +provider, Open WebUI integration, private Epimonos implementation, or the +canonical Sandbox documentation baseline. + +## Acceptance criteria + +1. A fresh instance can be deployed from the repository with no secret values + committed. +2. The Sandbox project and its formal actors can be provisioned repeatedly + without duplicate state or conflicting idempotency keys. +3. Each configured actor is authenticated by transport-derived identity and + can read the project using its own credential. +4. The operational smoke test checks health, MCP initialization, project + membership, event synchronization, and role-filtered pending actions. +5. A live SQLite backup can be made, integrity checked, restored into an + isolated instance, and compared with the source event cursor. +6. The runbook defines activation, rollback, token revocation/rotation, + cursor recovery, backup, restore and incident handling. +7. SG, PTO and existing project threads remain the formal channels until + Claude has independently approved the exact implementation commit and the + PM has closed the activation gate with `ACCEPT`. +8. No internet-facing deployment, purchase, or new long-lived infrastructure + is created without the existing PM-to-PO consultation rule. +9. The implementation and its evidence receive an independent Claude Code + review; the implementer does not self-review or close the gate. + +## Required evidence + +- exact implementation commit SHA and clean worktree; +- source and packaged-install test results under Python 3.12; +- container build and non-root runtime proof; +- fresh bootstrap plus idempotent replay transcript; +- per-actor authenticated smoke transcript; +- backup/restore/integrity transcript; +- secret scan and repository diff; +- independent review report with `APPROVE`, `CHANGES_REQUESTED`, or `REJECT`. + +## Activation gate + +The PM may declare the Project Bus the primary Sandbox project interface only +after all acceptance criteria pass and an independent review has returned +`APPROVE`. Until then, bus records are shadow records and SG/PTO/project +threads remain authoritative.