feat: operationalise project bus for Sandbox
This commit is contained in:
@@ -161,7 +161,10 @@ must not be mounted on NFS or used for active-active replicas.
|
|||||||
- `tests/` — service, concurrency, governance, and HTTP contract tests;
|
- `tests/` — service, concurrency, governance, and HTTP contract tests;
|
||||||
- `docs/` — architecture, tool contract, threat model, production notes;
|
- `docs/` — architecture, tool contract, threat model, production notes;
|
||||||
- `deploy/` — container and Compose example;
|
- `deploy/` — container and Compose example;
|
||||||
|
- `work-packages/` — governed implementation and activation scopes;
|
||||||
- `evidence/` — WP-PA-001 verification and independent-review handoff.
|
- `evidence/` — WP-PA-001 verification and independent-review handoff.
|
||||||
|
|
||||||
See [docs/TOOL_CONTRACT.md](docs/TOOL_CONTRACT.md) before integrating a new
|
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.
|
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).
|
||||||
|
|||||||
+12
-1
@@ -8,6 +8,7 @@ services:
|
|||||||
PROJECT_BUS_BOOTSTRAP_TOKEN: "${PROJECT_BUS_BOOTSTRAP_TOKEN:?set a strong bootstrap token}"
|
PROJECT_BUS_BOOTSTRAP_TOKEN: "${PROJECT_BUS_BOOTSTRAP_TOKEN:?set a strong bootstrap token}"
|
||||||
PROJECT_BUS_TOKENS_JSON: "${PROJECT_BUS_TOKENS_JSON:-}"
|
PROJECT_BUS_TOKENS_JSON: "${PROJECT_BUS_TOKENS_JSON:-}"
|
||||||
PROJECT_BUS_ALLOWED_ORIGINS: "${PROJECT_BUS_ALLOWED_ORIGINS:-}"
|
PROJECT_BUS_ALLOWED_ORIGINS: "${PROJECT_BUS_ALLOWED_ORIGINS:-}"
|
||||||
|
PROJECT_BUS_LOG_LEVEL: "${PROJECT_BUS_LOG_LEVEL:-INFO}"
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -19,7 +20,17 @@ services:
|
|||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
cap_drop:
|
cap_drop:
|
||||||
- ALL
|
- 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:
|
volumes:
|
||||||
project-bus-data:
|
project-bus-data:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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 <clean-venv>/bin/pip install --no-deps .
|
||||||
|
<clean-venv>/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.
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -14,6 +14,7 @@ dependencies = []
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
project-bus = "project_bus.__main__:main"
|
project-bus = "project_bus.__main__:main"
|
||||||
|
project-bus-ops = "project_bus.operations:main"
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
package-dir = {"" = "src"}
|
package-dir = {"" = "src"}
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user