feat: implement project bus MVP

This commit is contained in:
Codex Lead Engineer
2026-07-30 02:13:26 +02:00
commit bc55924198
28 changed files with 3518 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
__pycache__/
*.py[cod]
*.db
*.db-shm
*.db-wal
.coverage
.pytest_cache/
.venv/
dist/
build/
*.egg-info/
.env
+17
View File
@@ -0,0 +1,17 @@
# Changelog
All notable changes to this project are documented here.
## 0.1.0 — 2026-07-30
Initial review candidate:
- generic project, actor-role, work-package, result, review, decision,
escalation, gate, baseline, and event models;
- append-only SQLite event log with cursor synchronization;
- concurrency-safe and idempotent mutation handling;
- stateless MCP Streamable HTTP tool interface;
- transport authentication abstraction and BASDM separation controls;
- tests, container example, contract, threat model, production profile, and
WP-PA-001 review evidence.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Epimonos contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+167
View File
@@ -0,0 +1,167 @@
# Epimonos Project Bus
Epimonos Project Bus is a small, generic coordination service for auditable
human/agent projects. It exposes a stateless MCP Streamable HTTP endpoint and
keeps an ordered, append-only event trail alongside relational projections.
The core has no Sandbox, Epimonos product, billing, identity-provider, or
backoffice domain logic. A project supplies its own actors, role assignments,
work-package contents, decisions, evidence URIs, and repository references.
## MVP capabilities
- isolated projects and actors with one formal role per project;
- issued, atomically claimed, and evidenced work packages;
- independent review requests and verdicts;
- product, architecture, operational, and gate decisions;
- escalations and formal resolution;
- commit and artifact references;
- PM/PO gate closure and baseline-release events;
- ordered cursor-based event synchronization;
- idempotent mutations and concurrency-safe local writes;
- transport-derived identity and role-based authorization;
- database-level protection against event update and deletion.
## Governance invariants
The service enforces these minimum separations:
- a PA cannot record product, architecture, operational, or gate decisions;
- only a PO can record a product decision;
- only a PM or PO can close a gate or release a baseline;
- the actor who claimed/submitted a result cannot review it;
- a gate cannot accept a result without an independent `APPROVE` review;
- an actor who submitted a result cannot accept it;
- a baseline can reference only accepted work packages.
These are technical guardrails, not a replacement for the project role
contracts or the PM's tolerance and escalation policy.
## Quick start
Python 3.12 is the only runtime dependency.
```bash
export PYTHONPATH=src
export PROJECT_BUS_BOOTSTRAP_TOKEN='replace-this-with-long-secret'
python -m project_bus --database ./project-bus.db --host 127.0.0.1 --port 8080
```
Health endpoint:
```bash
curl http://127.0.0.1:8080/healthz
```
Initialize MCP:
```bash
curl http://127.0.0.1:8080/mcp \
-H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
```
Create a project:
```bash
curl http://127.0.0.1:8080/mcp \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer replace-this-with-long-secret' \
--data '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"create_project","arguments":{"project_id":"example","name":"Example","idempotency_key":"bootstrap-example-v1"}}}'
```
Use `register_actor` with the bootstrap identity to appoint a PO. Configure
the actor's bearer credential through `PROJECT_BUS_TOKENS_JSON`, then let the
PO/PM register the remaining members. Actor identity is always taken from the
authenticated bearer credential; callers cannot supply an acting identity in
tool arguments.
Example credential configuration:
```bash
export PROJECT_BUS_TOKENS_JSON='{
"long-random-po-token": {
"actor_id": "po-1",
"auth_subject": "po-1",
"display_name": "Product Owner"
}
}'
```
The identity must match an actor registered in the target project. Environment
changes require a process restart in this MVP.
## MCP client configuration
For Claude Code:
```bash
claude mcp add \
--scope project \
--transport http \
epimonos-project-bus \
https://project-bus.example/mcp
```
For a local-only development server, use the local HTTP URL. Credentials
belong in the client/user secret store, never in `.mcp.json` or Git.
## Configuration
| Variable | Default | Meaning |
|---|---:|---|
| `PROJECT_BUS_DATABASE` | `project-bus.db` | SQLite database path |
| `PROJECT_BUS_MIGRATIONS` | source-tree discovery | Migration directory |
| `PROJECT_BUS_HOST` | `127.0.0.1` | Bind address |
| `PROJECT_BUS_PORT` | `8080` | HTTP port |
| `PROJECT_BUS_BOOTSTRAP_TOKEN` | required, minimum 24 characters | SYSTEM bearer token |
| `PROJECT_BUS_TOKENS_JSON` | empty | Static bearer-token identity map |
| `PROJECT_BUS_ALLOWED_ORIGINS` | empty | Comma-separated exact browser origins |
| `PROJECT_BUS_LOG_LEVEL` | `INFO` | Python log level |
Never expose the bootstrap token. On an internet-facing deployment, terminate
TLS at a trusted reverse proxy and set a strict origin allowlist.
## Test
```bash
PYTHONPATH=src python -m unittest discover -v
```
The suite uses only temporary databases and includes concurrent writer tests.
## Local persistence and production profile
SQLite runs in WAL mode and every mutation uses `BEGIN IMMEDIATE`. This gives
a dependable single-process/single-node MVP and prevents claim and invariant
check races. The append-only event table is protected with `UPDATE` and
`DELETE` rejection triggers.
Production differences are intentionally explicit:
| Concern | MVP | Production target |
|---|---|---|
| Database | SQLite/WAL, one node | PostgreSQL 16+, transactions and row locks |
| Identity | static bearer map | OIDC/JWT or mTLS workload identity |
| Secrets | environment | secret manager, rotation, revocation |
| Transport | plain HTTP capable | TLS-only reverse proxy/service mesh |
| Scaling | one server process | multiple stateless MCP instances |
| Audit durability | local DB/backup | PITR, replicas, external immutable export |
| Availability | process supervisor | orchestrator probes, SLOs, alerting |
| Abuse control | body cap/origin policy | edge rate limits, quotas, WAF where useful |
The persistence boundary is deliberately small (`Database` plus SQL in the
service), but this release does **not** contain a PostgreSQL adapter. SQLite
must not be mounted on NFS or used for active-active replicas.
## Repository guide
- `src/project_bus/` — domain service, auth abstraction, MCP and HTTP adapter;
- `migrations/` — ordered relational schema;
- `tests/` — service, concurrency, governance, and HTTP contract tests;
- `docs/` — architecture, tool contract, threat model, production notes;
- `deploy/` — container and Compose example;
- `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.
+4
View File
@@ -0,0 +1,4 @@
PROJECT_BUS_BOOTSTRAP_TOKEN=replace-with-a-long-random-secret
PROJECT_BUS_TOKENS_JSON={}
PROJECT_BUS_ALLOWED_ORIGINS=https://chatgpt.com
+23
View File
@@ -0,0 +1,23 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PROJECT_BUS_DATABASE=/data/project-bus.db \
PROJECT_BUS_MIGRATIONS=/app/migrations \
PROJECT_BUS_HOST=0.0.0.0 \
PROJECT_BUS_PORT=8080
WORKDIR /app
COPY pyproject.toml README.md LICENSE ./
COPY src ./src
COPY migrations ./migrations
RUN pip install --no-cache-dir --no-deps . \
&& mkdir -p /data \
&& chown -R 65532:65532 /data
USER 65532:65532
EXPOSE 8080
VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=2)"]
ENTRYPOINT ["project-bus"]
+25
View File
@@ -0,0 +1,25 @@
services:
project-bus:
build:
context: ..
dockerfile: deploy/Dockerfile
restart: unless-stopped
environment:
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:-}"
ports:
- "127.0.0.1:8080:8080"
volumes:
- project-bus-data:/data
read_only: true
tmpfs:
- /tmp:size=16m,noexec,nosuid,nodev
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
volumes:
project-bus-data:
+61
View File
@@ -0,0 +1,61 @@
# Architecture
## Boundary
The Project Bus coordinates formal project events. It does not execute source
code, clone repositories, inspect artifacts, send chat messages, implement a
project methodology, or decide what a project should build. Those activities
remain with authenticated actors and external systems.
## Components
1. `ProjectBusRequestHandler` implements a small stateless MCP Streamable HTTP
transport over JSON-RPC 2.0.
2. `AuthProvider` turns transport credentials into an immutable principal.
3. `MCPApplication` publishes tools and maps calls to domain operations.
4. `ProjectBusService` enforces role, lifecycle, independence, idempotency,
and gate invariants.
5. `Database` owns migration and transaction boundaries.
6. SQLite relational projections support current-state queries; `event_log`
is the immutable ordered synchronization source.
## Write path
A mutating call is authenticated before tool dispatch. The service opens
`BEGIN IMMEDIATE`, resolves the principal to an active project membership,
checks the idempotency tuple `(project, actor, key)`, validates current state,
updates the relevant projection, appends exactly one event, stores the
canonical response, and commits.
The event and projection update therefore succeed or fail together.
Idempotency replays return the original response without adding an event.
## Event ordering and cursors
SQLite allocates a monotonic integer `cursor` for each event. `sync_since`
filters by project, returns ascending cursors, and returns the last delivered
cursor. Consumers persist that value only after processing the response. The
cursor is global to the database, so gaps within a project's stream are
normal. Consumers must not infer missing project events from gaps.
This is pull synchronization. The MCP server does not wake a ChatGPT thread or
push into a dormant CLI session.
## State model
Work packages move through:
`OPEN → CLAIMED → RESULT_SUBMITTED → IN_REVIEW → REVIEWED → ACCEPTED|REJECTED`
`HOLD` leaves a reviewed package in `REVIEWED`. A new review can be requested
after a completed review. This MVP treats corrections after
`CHANGES_REQUESTED` as a new work package or a new explicitly governed
iteration; it does not silently overwrite submitted result evidence.
## Trust boundaries
- Authentication proves an external subject; project membership grants a role.
- The database is trusted to enforce integrity and append-only triggers.
- Artifact URIs and commit hashes are references, not trusted content.
- Reverse proxy identity headers are not consumed by the built-in adapter.
+36
View File
@@ -0,0 +1,36 @@
# Production Profile
The MVP is suitable for local integration and controlled proof-of-concept use.
Internet-facing or business-critical deployment requires a separate production
hardening work package.
## Required changes
1. Implement the persistence interface for PostgreSQL 16+.
Use transactions, `SELECT … FOR UPDATE` or conditional updates, unique
idempotency constraints, and database roles that cannot update/delete the
event table.
2. Replace `StaticTokenAuthProvider` with validated OIDC/JWT or mTLS workload
identity. Bind issuer, audience, subject, expiry, and revocation policy.
3. Put the service behind TLS and authenticated ingress. Do not trust arbitrary
forwarded identity headers.
4. Run multiple stateless instances only after PostgreSQL conformance and
concurrency tests pass.
5. Add structured audit export, metrics, tracing correlation, alerts, backup,
point-in-time recovery, and restore exercises.
6. Add membership lifecycle, token revocation, retention/export policy, and
project deletion/archival decisions. Event deletion is intentionally absent.
7. Pin and scan the base image; generate an SBOM and sign release artifacts.
8. Run load, fault, client interoperability, and penetration tests.
## MCP activation
The deployed endpoint must support `POST /mcp` with `application/json`. The
current service returns normal JSON instead of SSE and uses no session IDs.
Validate that each intended client accepts this stateless profile before
production gate acceptance.
Chat threads are not push subscribers. A client, automation, or PA invocation
must call `sync_since(last_cursor)`. The cursor itself belongs in durable client
state and is advanced only after successful processing.
+83
View File
@@ -0,0 +1,83 @@
# Threat Model
## Assets
- project instructions, decisions, results, reviews, and escalations;
- actor-role assignments and identity bindings;
- event ordering and audit integrity;
- bearer credentials;
- commit, artifact, and baseline references;
- service/database availability.
## Adversaries
- an unauthenticated network client;
- an authenticated actor exceeding their formal mandate;
- an implementer attempting self-review or self-acceptance;
- a compromised bearer token;
- a malicious artifact URI or oversized request;
- an operator or database account attempting audit-history mutation;
- concurrent clients exploiting check/write races.
## Implemented controls
| Threat | MVP control |
|---|---|
| Identity spoofing in tool args | no actor argument; identity comes from transport |
| Token timing comparison | SHA-256 digest and constant-time comparison |
| Unauthorized project access | active membership lookup per call |
| PA decision overreach | explicit decision/gate/baseline denial |
| Product decision overreach | PO-only product decisions |
| Self-review | reviewer role plus actor independence checks |
| Self-acceptance | PM/PO role and result-submitter identity check |
| Premature gate acceptance | independent APPROVE review required |
| Double claim / TOCTOU | `BEGIN IMMEDIATE` and conditional state update |
| Duplicate delivery | scoped request hash and idempotency record |
| Audit update/delete | SQLite triggers reject both operations |
| Cross-project reads | project filter plus membership resolution |
| Browser cross-origin request | exact configurable Origin allowlist |
| Oversized request | one MiB body limit |
| MIME confusion/caching | strict JSON input, nosniff, no-store |
| SQL injection | parameterized SQL; one controlled placeholder expansion |
Artifact URIs and review evidence are recorded as untrusted data and are never
fetched or executed by the bus.
## Residual risks and production requirements
1. Static bearer credentials have no built-in expiry, rotation endpoint,
audience, issuer, or proof-of-possession. Replace the adapter with validated
OIDC/JWT or mTLS identities.
2. Plain HTTP is available. Use TLS and prohibit direct public access to the
application port.
3. SQLite append-only triggers do not protect against an operator replacing
the entire database file or disabling triggers. Use restricted DB roles,
PostgreSQL permissions, PITR, backups, and an external immutable audit
export for production assurance.
4. The event payload is not cryptographically chained or signed. Add hash
chaining/signatures only if the agreed threat model requires tamper
evidence against privileged database operators.
5. No application rate limiter exists. Apply per-identity limits and request
quotas at the trusted edge.
6. The server emits access metadata but no security audit sink or metrics.
Integrate structured logs, alerts, and privacy-aware retention.
7. Membership deactivation and credential revocation are not exposed as MVP
tools. Operators must treat compromised tokens as an immediate
configuration/restart incident.
8. Role assignment is one role per actor/project. Shared human/agent accounts
undermine independence even when IDs differ and are prohibited operationally.
9. Evidence and artifact digests are caller assertions; the bus does not
verify repository ownership or retrieve content.
10. Dependency-free MCP handling reduces supply-chain surface but has not yet
been interoperability-tested against every intended client version.
## Security verification before activation
- run all tests and an independent code review;
- test the exact Claude Code and ChatGPT MCP clients;
- replace all example tokens and restrict origin/host exposure;
- confirm backup/restore and audit export;
- review log contents for sensitive project data;
- add abuse limits and operational monitoring;
- decide whether privileged-operator tamper evidence is required.
+79
View File
@@ -0,0 +1,79 @@
# MCP Tool Contract
Protocol version: `2025-03-26`
Endpoint: `POST /mcp`
Transport profile: stateless Streamable HTTP with JSON responses
`initialize`, `ping`, and `tools/list` do not require credentials. Every
`tools/call` requires `Authorization: Bearer …`. The server does not implement
server-to-client SSE notifications in this MVP; `GET /mcp` returns `405`.
## Common rules
- Every project-scoped call resolves the authenticated principal to one active
membership.
- Every mutation requires a stable `idempotency_key`.
- Reusing a key with the same operation and canonical arguments replays the
original response. Reusing it with different input returns `conflict`.
- IDs use letters, numbers, `.`, `_`, `:`, `/`, or `-`, up to 128 characters.
- Tool domain failures are MCP tool results with `isError: true`.
- JSON-RPC syntax/method failures use JSON-RPC error objects.
- Missing or invalid credentials return HTTP `401`.
## Tools
| Tool | Allowed role / invariant | Primary event |
|---|---|---|
| `create_project` | SYSTEM only | `project.created` |
| `register_actor` | SYSTEM, PO, PM; PM cannot appoint PO | `actor.registered` |
| `issue_work_package` | PO, PM | `work_package.issued` |
| `claim_work_package` | Architect, Lead Engineer, Implementer; atomic OPEN claim | `work_package.claimed` |
| `submit_result` | claiming actor only | `work_package.result_submitted` |
| `request_review` | PO, PM, PA, implementation/design roles; reviewer independent | `review.requested` |
| `submit_review` | assigned independent review actor | `review.submitted` |
| `record_decision` | product: PO; other kinds: PO/PM; never PA | `decision.recorded` |
| `raise_escalation` | every active member | `escalation.raised` |
| `resolve_escalation` | PO, PM | `escalation.resolved` |
| `link_commit` | every active member; valid hexadecimal object ID | `commit.linked` |
| `link_artifact` | every active member | `artifact.linked` |
| `close_gate` | PO, PM; ACCEPT needs independent approval | `gate.closed` |
| `baseline_release` | PO, PM; included work packages accepted | `baseline.released` |
| `sync_since` | every active member; max 500 events | none |
| `list_pending_actions` | every active member, role-filtered reviews/escalations | none |
| `get_work_package` | every active member | none |
| `get_project` | every active member; includes actor-role roster | none |
The authoritative argument schema is returned by `tools/list`. Clients should
discover it instead of hard-coding optional defaults.
## Event envelope
`sync_since` returns:
```json
{
"cursor": 42,
"event_id": "evt_...",
"project_id": "example",
"event_type": "review.submitted",
"actor_id": "reviewer-1",
"actor_role": "REVIEWER",
"aggregate_type": "review",
"aggregate_id": "rev_...",
"correlation_id": "WP-001",
"causation_event_id": null,
"created_at": "2026-07-30T00:00:00.000Z",
"payload": {}
}
```
Event payloads are version-zero MVP structures. Consumers should ignore
unknown fields and key behavior off `event_type`, not prose.
## MCP interoperability limits
The transport implements the required request/response subset used by MCP
tools over Streamable HTTP. It is stateless, does not issue `Mcp-Session-Id`,
does not accept JSON-RPC batches, and provides no resumable SSE channel.
Interoperability must be checked against the concrete ChatGPT and Claude Code
clients during activation.
+45
View File
@@ -0,0 +1,45 @@
# Independent Claude Review Brief
## Role
Act as independent Lead Reviewer for `WP-PA-001 — Epimonos Project Bus MVP`.
Do not modify the implementation during the first review pass. The implementer
is Codex; reviewer identity and evidence must remain independent.
## Review objectives
1. Verify every requested capability against code, schema, tests, and docs.
2. Challenge BASDM separations:
- PA cannot make product/architecture/gate decisions;
- implementer cannot review or accept own work;
- only authorized PM/PO actors close gates and release baselines.
3. Inspect transaction boundaries, idempotency replay, concurrent claims,
cursor semantics, and projection/event atomicity.
4. Assess authentication, cross-project isolation, origin handling, input
validation, SQL usage, audit immutability, and operational failure modes.
5. Check MCP `2025-03-26` request/response interoperability assumptions.
6. Confirm the core contains no Sandbox/Epimonos product-specific logic.
7. Confirm documentation accurately states all production gaps.
## Required output
Return:
- verdict: `APPROVE`, `CHANGES_REQUESTED`, or `REJECT`;
- numbered findings with severity `Critical/High/Medium/Low`;
- exact file and line reference;
- exploit/failure scenario;
- required correction and suggested verification;
- scope-completeness matrix;
- residual-risk statement.
Run at minimum:
```bash
PYTHONPATH=src python -m unittest discover -v
PYTHONPATH=src python -m compileall -q src tests
```
Also add adversarial tests where a claimed invariant is not adequately proven.
Do not accept solely because the existing suite is green.
+40
View File
@@ -0,0 +1,40 @@
WP-PA-001 VERIFICATION
Date: 2026-07-30
Candidate version: 0.1.0
RESULTS
- Python source compilation: PASS
- Source-tree test suite: PASS (16 tests)
- Built/installed-package test suite: PASS (16 tests)
- Installed-package migration smoke test: PASS
- Local Git repository root initialized: PASS
- External repository creation/push: NOT PERFORMED (by scope)
- Container build: NOT RUN (Docker unavailable in execution environment)
- Independent Claude review: PENDING
- ChatGPT/Claude Code client interoperability: PENDING ACTIVATION
COMMANDS
PYTHONPATH=src python -m compileall -q src tests
PYTHONPATH=src python -m unittest discover -v
PIP_CACHE_DIR=/tmp/pip-cache python -m pip install --root-user-action=ignore --no-deps --no-build-isolation --target <temporary-directory> .
PYTHONPATH=<temporary-directory> PROJECT_BUS_MIGRATIONS=<repository>/migrations python -m unittest discover -v
SCOPE INTEGRITY
- Canonical Sandbox ZIP SHA-256 after implementation:
9ca8ec98221146730c1ed82d8d5e2011b710d623ea046b7ab87a1d1cfb4e8bad
- Handoff delta SHA-256 after implementation:
81fd1e58319299c4acfaf6c22bddacfe6f1de34ccfffac7943647d834aa912e6
- Neither file exists inside this repository.
PRE-VERIFICATION SOURCE SET
- Files: 27
- Aggregate SHA-256 over sorted per-file SHA-256 records:
e379541644e82fa4fd8096761625a0ec015528e51f20b21b13c9a7fa7bf53ce1
- This aggregate intentionally excludes this VERIFICATION.txt file.
REMAINING GATES
1. Independent Claude security/architecture review using CLAUDE_REVIEW_BRIEF.md.
2. Correction and re-verification of any findings.
3. Exact MCP interoperability checks with intended ChatGPT and Claude Code versions.
4. PM gate acceptance.
5. Production hardening work package before internet-facing deployment.
+56
View File
@@ -0,0 +1,56 @@
# WP-PA-001 Evidence
## Scope delivered
- standalone generic repository root;
- relational schema and migration;
- append-only project event log;
- projects, actors, formal roles, work packages, results, reviews, decisions,
escalations, links, gates, baselines, and cursor synchronization;
- authorization, identity abstraction, idempotency, atomic claims, and
concurrency-safe writes;
- MCP Streamable HTTP request/response interface;
- unit, governance, concurrency, and HTTP integration tests;
- deployment example, architecture, tool contract, threat model, production
delta, and independent review brief.
## Verification commands
```bash
PYTHONPATH=src python -m compileall -q src tests
PYTHONPATH=src python -m unittest discover -v
python -m unittest discover -v
```
The third command is run after editable installation and proves the packaged
import path. Record the final observed counts and hashes in
`VERIFICATION.txt`; generated database files are excluded.
## Acceptance mapping
| Requested evidence | Location |
|---|---|
| Append-only event log | `migrations/001_initial.sql`, trigger tests |
| Project/actor/role model | migration, `models.py`, service tests |
| Work/result/review lifecycle | `service.py`, full workflow test |
| Decision and gate separation | authorization tests |
| Reviewer independence | request/submit checks and test |
| Idempotency | table, canonical hash logic, replay/mismatch test |
| Concurrency safety | `BEGIN IMMEDIATE`, concurrent claim test |
| Cursor synchronization | `sync_since`, pagination test |
| Commit/artifact/baseline links | service and MCP tools |
| Authentication abstraction | `AuthProvider`, static adapter |
| MCP interface | `mcp.py`, HTTP integration tests |
| Deployment and production differences | `deploy/`, production profile |
| Threat model | `docs/THREAT_MODEL.md` |
| Independent review input | `CLAUDE_REVIEW_BRIEF.md` |
## Explicit exclusions
- no external repository creation, commit, or push;
- no production deployment or ChatGPT/Claude activation;
- no PostgreSQL adapter, OIDC provider, SSE push channel, or chat wake-up;
- no Sandbox/Epimonos product domain logic;
- no copy of the Sandbox SG handoff delta;
- no modification of the canonical Sandbox v0.1.0 ZIP.
+144
View File
@@ -0,0 +1,144 @@
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
);
CREATE TABLE projects (
project_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE TABLE actors (
actor_id TEXT PRIMARY KEY,
auth_subject TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE project_memberships (
project_id TEXT NOT NULL REFERENCES projects(project_id),
actor_id TEXT NOT NULL REFERENCES actors(actor_id),
role TEXT NOT NULL CHECK (role IN (
'SYSTEM','PO','PM','PA','ARCHITECT','LEAD_ENGINEER',
'IMPLEMENTER','REVIEWER','SECURITY_REVIEWER','OBSERVER'
)),
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
created_at TEXT NOT NULL,
PRIMARY KEY (project_id, actor_id)
);
CREATE TABLE work_packages (
project_id TEXT NOT NULL REFERENCES projects(project_id),
work_package_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL,
acceptance_criteria_json TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN (
'OPEN','CLAIMED','RESULT_SUBMITTED','IN_REVIEW','REVIEWED',
'ACCEPTED','REJECTED','CANCELLED'
)),
issued_by TEXT NOT NULL REFERENCES actors(actor_id),
claimed_by TEXT REFERENCES actors(actor_id),
result_submitted_by TEXT REFERENCES actors(actor_id),
result_json TEXT,
accepted_by TEXT REFERENCES actors(actor_id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (project_id, work_package_id)
);
CREATE INDEX work_packages_project_status
ON work_packages(project_id, status, updated_at);
CREATE TABLE review_requests (
project_id TEXT NOT NULL REFERENCES projects(project_id),
review_id TEXT NOT NULL,
work_package_id TEXT NOT NULL,
requested_by TEXT NOT NULL REFERENCES actors(actor_id),
reviewer_id TEXT NOT NULL REFERENCES actors(actor_id),
review_type TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('OPEN','SUBMITTED')),
verdict TEXT CHECK (verdict IN ('APPROVE','CHANGES_REQUESTED','REJECT')),
findings_json TEXT,
created_at TEXT NOT NULL,
submitted_at TEXT,
PRIMARY KEY (project_id, review_id),
FOREIGN KEY (project_id, work_package_id)
REFERENCES work_packages(project_id, work_package_id)
);
CREATE INDEX review_requests_project_status
ON review_requests(project_id, status, reviewer_id);
CREATE TABLE decisions (
project_id TEXT NOT NULL REFERENCES projects(project_id),
decision_id TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('PRODUCT','ARCHITECTURE','OPERATIONAL','GATE')),
title TEXT NOT NULL,
decision TEXT NOT NULL,
rationale TEXT NOT NULL,
decided_by TEXT NOT NULL REFERENCES actors(actor_id),
work_package_id TEXT,
created_at TEXT NOT NULL,
PRIMARY KEY (project_id, decision_id),
FOREIGN KEY (project_id, work_package_id)
REFERENCES work_packages(project_id, work_package_id)
);
CREATE TABLE escalations (
project_id TEXT NOT NULL REFERENCES projects(project_id),
escalation_id TEXT NOT NULL,
raised_by TEXT NOT NULL REFERENCES actors(actor_id),
severity TEXT NOT NULL CHECK (severity IN ('LOW','MEDIUM','HIGH','CRITICAL')),
title TEXT NOT NULL,
description TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('OPEN','RESOLVED')),
target_role TEXT,
resolution TEXT,
resolved_by TEXT REFERENCES actors(actor_id),
created_at TEXT NOT NULL,
resolved_at TEXT,
PRIMARY KEY (project_id, escalation_id)
);
CREATE TABLE event_log (
cursor INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT NOT NULL UNIQUE,
project_id TEXT NOT NULL REFERENCES projects(project_id),
event_type TEXT NOT NULL,
actor_id TEXT NOT NULL REFERENCES actors(actor_id),
actor_role TEXT NOT NULL,
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
correlation_id TEXT,
causation_event_id TEXT,
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX event_log_project_cursor ON event_log(project_id, cursor);
CREATE INDEX event_log_aggregate ON event_log(project_id, aggregate_type, aggregate_id, cursor);
CREATE TRIGGER event_log_no_update
BEFORE UPDATE ON event_log
BEGIN
SELECT RAISE(ABORT, 'event_log is append-only');
END;
CREATE TRIGGER event_log_no_delete
BEFORE DELETE ON event_log
BEGIN
SELECT RAISE(ABORT, 'event_log is append-only');
END;
CREATE TABLE idempotency_records (
project_id TEXT NOT NULL,
actor_id TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
operation TEXT NOT NULL,
request_hash TEXT NOT NULL,
response_json TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (project_id, actor_id, idempotency_key)
);
+22
View File
@@ -0,0 +1,22 @@
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "epimonos-project-bus"
version = "0.1.0"
description = "Generic, auditable project coordination bus with an MCP Streamable HTTP interface"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "MIT" }
authors = [{ name = "Epimonos contributors" }]
dependencies = []
[project.scripts]
project-bus = "project_bus.__main__:main"
[tool.setuptools]
package-dir = {"" = "src"}
[tool.setuptools.packages.find]
where = ["src"]
+4
View File
@@ -0,0 +1,4 @@
"""Epimonos Project Bus core package."""
__version__ = "0.1.0"
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
import argparse
import logging
import os
from .auth import StaticTokenAuthProvider
from .db import Database
from .mcp import MCPApplication
from .server import ProjectBusHTTPServer, allowed_origins_from_environment
from .service import ProjectBusService
def parser() -> argparse.ArgumentParser:
result = argparse.ArgumentParser(description="Run the Epimonos Project Bus MCP server")
result.add_argument(
"--database",
default=os.environ.get("PROJECT_BUS_DATABASE", "project-bus.db"),
help="SQLite database path",
)
result.add_argument("--host", default=os.environ.get("PROJECT_BUS_HOST", "127.0.0.1"))
result.add_argument(
"--port",
type=int,
default=int(os.environ.get("PROJECT_BUS_PORT", "8080")),
)
return result
def main() -> None:
arguments = parser().parse_args()
logging.basicConfig(
level=os.environ.get("PROJECT_BUS_LOG_LEVEL", "INFO"),
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
service = ProjectBusService(Database(arguments.database))
service.initialize()
application = MCPApplication(service)
auth_provider = StaticTokenAuthProvider.from_environment()
server = ProjectBusHTTPServer(
(arguments.host, arguments.port),
application,
auth_provider,
allowed_origins_from_environment(),
)
logging.getLogger("project_bus").info(
"Project Bus listening on http://%s:%d/mcp", arguments.host, arguments.port
)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
if __name__ == "__main__":
main()
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
import hashlib
import hmac
import json
import os
from dataclasses import dataclass
from typing import Mapping, Protocol
from .errors import AuthenticationError
from .models import Principal
class AuthProvider(Protocol):
def authenticate(self, authorization_header: str | None) -> Principal:
"""Authenticate a request without trusting actor parameters."""
@dataclass(frozen=True)
class TokenIdentity:
actor_id: str
auth_subject: str
display_name: str
class StaticTokenAuthProvider:
"""Small deployment auth adapter intended for local and controlled use.
Tokens are compared using SHA-256 digests and constant-time comparison.
Production deployments should replace this adapter with an OIDC/JWT or
mTLS-backed implementation and keep the same ``AuthProvider`` contract.
"""
def __init__(self, token_identities: Mapping[str, TokenIdentity]):
if not token_identities:
raise ValueError("At least one authentication token is required")
if any(len(token) < 24 for token in token_identities):
raise ValueError("Every authentication token must contain at least 24 characters")
self._digests = {
hashlib.sha256(token.encode("utf-8")).digest(): identity
for token, identity in token_identities.items()
}
@classmethod
def from_environment(cls) -> "StaticTokenAuthProvider":
raw = os.environ.get("PROJECT_BUS_TOKENS_JSON")
bootstrap = os.environ.get("PROJECT_BUS_BOOTSTRAP_TOKEN")
if not bootstrap:
raise ValueError("PROJECT_BUS_BOOTSTRAP_TOKEN is required")
if len(bootstrap) < 24:
raise ValueError("PROJECT_BUS_BOOTSTRAP_TOKEN must contain at least 24 characters")
entries: dict[str, TokenIdentity] = {
bootstrap: TokenIdentity("system", "system", "Bootstrap system")
}
if raw:
decoded = json.loads(raw)
if not isinstance(decoded, dict):
raise ValueError("PROJECT_BUS_TOKENS_JSON must be a JSON object")
for token, identity in decoded.items():
entries[token] = TokenIdentity(
actor_id=identity["actor_id"],
auth_subject=identity.get("auth_subject", identity["actor_id"]),
display_name=identity.get("display_name", identity["actor_id"]),
)
return cls(entries)
def authenticate(self, authorization_header: str | None) -> Principal:
if not authorization_header or not authorization_header.startswith("Bearer "):
raise AuthenticationError("A Bearer token is required")
token = authorization_header[7:]
candidate = hashlib.sha256(token.encode("utf-8")).digest()
for expected, identity in self._digests.items():
if hmac.compare_digest(candidate, expected):
return Principal(
actor_id=identity.actor_id,
auth_subject=identity.auth_subject,
display_name=identity.display_name,
)
raise AuthenticationError("Invalid Bearer token")
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
import contextlib
import os
import sqlite3
import threading
from pathlib import Path
from typing import Iterator
class Database:
"""SQLite persistence adapter.
Each transaction gets its own connection. ``BEGIN IMMEDIATE`` serializes
writers before domain invariants are read, preventing check-then-write
races while still allowing concurrent readers in WAL mode.
"""
def __init__(self, path: str | Path, migrations_dir: str | Path | None = None):
self.path = str(path)
configured = migrations_dir or os.environ.get("PROJECT_BUS_MIGRATIONS")
if configured:
self.migrations_dir = Path(configured)
else:
source_tree = Path(__file__).parents[2] / "migrations"
working_tree = Path.cwd() / "migrations"
self.migrations_dir = source_tree if source_tree.is_dir() else working_tree
self._migration_lock = threading.Lock()
def connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(
self.path,
timeout=30,
isolation_level=None,
check_same_thread=False,
)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
connection.execute("PRAGMA busy_timeout = 30000")
connection.execute("PRAGMA journal_mode = WAL")
return connection
def migrate(self) -> None:
with self._migration_lock:
connection = self.connect()
try:
connection.execute(
"CREATE TABLE IF NOT EXISTS schema_migrations "
"(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)"
)
current = {
row["version"]
for row in connection.execute("SELECT version FROM schema_migrations")
}
migration_files = sorted(self.migrations_dir.glob("[0-9][0-9][0-9]_*.sql"))
if not migration_files:
raise RuntimeError(f"No migrations found in {self.migrations_dir}")
for migration in migration_files:
version = int(migration.name.split("_", 1)[0])
if version in current:
continue
connection.executescript(migration.read_text(encoding="utf-8"))
connection.execute(
"INSERT INTO schema_migrations(version, applied_at) "
"VALUES (?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
(version,),
)
finally:
connection.close()
@contextlib.contextmanager
def transaction(self, *, write: bool = False) -> Iterator[sqlite3.Connection]:
connection = self.connect()
try:
connection.execute("BEGIN IMMEDIATE" if write else "BEGIN")
yield connection
connection.commit()
except BaseException:
connection.rollback()
raise
finally:
connection.close()
+30
View File
@@ -0,0 +1,30 @@
class ProjectBusError(Exception):
"""Base class for expected domain and interface failures."""
code = "project_bus_error"
def __init__(self, message: str, *, details: dict | None = None):
super().__init__(message)
self.message = message
self.details = details or {}
class ValidationError(ProjectBusError):
code = "validation_error"
class NotFoundError(ProjectBusError):
code = "not_found"
class ConflictError(ProjectBusError):
code = "conflict"
class AuthorizationError(ProjectBusError):
code = "forbidden"
class AuthenticationError(ProjectBusError):
code = "unauthorized"
+430
View File
@@ -0,0 +1,430 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any, Callable
from .errors import ProjectBusError, ValidationError
from .models import Principal
from .service import ProjectBusService
def object_schema(
properties: dict[str, dict[str, Any]], required: list[str]
) -> dict[str, Any]:
return {
"type": "object",
"properties": properties,
"required": required,
"additionalProperties": False,
}
STRING = {"type": "string"}
IDEMPOTENCY = {
"type": "string",
"description": "Caller-generated stable key, unique per actor/project mutation.",
}
@dataclass(frozen=True)
class Tool:
name: str
description: str
schema: dict[str, Any]
handler: Callable[..., dict[str, Any]]
def metadata(self) -> dict[str, Any]:
return {
"name": self.name,
"description": self.description,
"inputSchema": self.schema,
}
class MCPApplication:
protocol_version = "2025-03-26"
def __init__(self, service: ProjectBusService):
self.service = service
common = {"project_id": STRING}
self.tools: dict[str, Tool] = {}
def add(name: str, description: str, properties: dict, required: list[str], handler):
self.tools[name] = Tool(
name,
description,
object_schema(properties, required),
handler,
)
add(
"create_project",
"Bootstrap a new isolated project. Requires the SYSTEM identity.",
{
"project_id": STRING,
"name": STRING,
"description": {"type": "string", "default": ""},
"idempotency_key": IDEMPOTENCY,
},
["project_id", "name", "idempotency_key"],
service.create_project,
)
add(
"register_actor",
"Register an actor and their single formal role in a project.",
{
**common,
"actor_id": STRING,
"auth_subject": STRING,
"display_name": STRING,
"role": {
"type": "string",
"enum": [
"PO",
"PM",
"PA",
"ARCHITECT",
"LEAD_ENGINEER",
"IMPLEMENTER",
"REVIEWER",
"SECURITY_REVIEWER",
"OBSERVER",
],
},
"idempotency_key": IDEMPOTENCY,
},
["project_id", "actor_id", "auth_subject", "display_name", "role", "idempotency_key"],
service.register_actor,
)
add(
"issue_work_package",
"Issue a formally scoped work package. Only PM or PO may issue.",
{
**common,
"work_package_id": STRING,
"title": STRING,
"description": STRING,
"acceptance_criteria": {"type": "array", "items": STRING, "minItems": 1},
"idempotency_key": IDEMPOTENCY,
},
[
"project_id",
"work_package_id",
"title",
"description",
"acceptance_criteria",
"idempotency_key",
],
service.issue_work_package,
)
add(
"claim_work_package",
"Atomically claim an open work package as architect or implementer.",
{**common, "work_package_id": STRING, "idempotency_key": IDEMPOTENCY},
["project_id", "work_package_id", "idempotency_key"],
service.claim_work_package,
)
add(
"submit_result",
"Submit implementation result and evidence for the caller's claimed work package.",
{
**common,
"work_package_id": STRING,
"summary": STRING,
"evidence": {"type": "array", "items": {"type": "object"}},
"idempotency_key": IDEMPOTENCY,
},
["project_id", "work_package_id", "summary", "evidence", "idempotency_key"],
service.submit_result,
)
add(
"request_review",
"Request a review from an actor independent of the implementer.",
{
**common,
"work_package_id": STRING,
"reviewer_id": STRING,
"review_type": STRING,
"idempotency_key": IDEMPOTENCY,
},
["project_id", "work_package_id", "reviewer_id", "review_type", "idempotency_key"],
service.request_review,
)
add(
"submit_review",
"Submit findings and verdict for an assigned independent review.",
{
**common,
"review_id": STRING,
"verdict": {
"type": "string",
"enum": ["APPROVE", "CHANGES_REQUESTED", "REJECT"],
},
"findings": {"type": "array", "items": {"type": "object"}},
"summary": STRING,
"idempotency_key": IDEMPOTENCY,
},
["project_id", "review_id", "verdict", "findings", "summary", "idempotency_key"],
service.submit_review,
)
add(
"record_decision",
"Record an authorized, immutable product, architecture, operational, or gate decision.",
{
**common,
"decision_id": STRING,
"kind": {
"type": "string",
"enum": ["PRODUCT", "ARCHITECTURE", "OPERATIONAL", "GATE"],
},
"title": STRING,
"decision": STRING,
"rationale": STRING,
"work_package_id": {"type": ["string", "null"], "default": None},
"idempotency_key": IDEMPOTENCY,
},
[
"project_id",
"decision_id",
"kind",
"title",
"decision",
"rationale",
"idempotency_key",
],
service.record_decision,
)
add(
"raise_escalation",
"Raise an auditable escalation to a formal project role.",
{
**common,
"severity": {
"type": "string",
"enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"],
},
"title": STRING,
"description": STRING,
"target_role": {"type": ["string", "null"], "default": None},
"idempotency_key": IDEMPOTENCY,
},
["project_id", "severity", "title", "description", "idempotency_key"],
service.raise_escalation,
)
add(
"resolve_escalation",
"Resolve an open escalation. Only PM or PO may resolve.",
{
**common,
"escalation_id": STRING,
"resolution": STRING,
"idempotency_key": IDEMPOTENCY,
},
["project_id", "escalation_id", "resolution", "idempotency_key"],
service.resolve_escalation,
)
add(
"link_commit",
"Append a Git commit reference to a work package event trail.",
{
**common,
"work_package_id": STRING,
"repository": STRING,
"commit_sha": STRING,
"relation": STRING,
"idempotency_key": IDEMPOTENCY,
},
[
"project_id",
"work_package_id",
"repository",
"commit_sha",
"relation",
"idempotency_key",
],
service.link_commit,
)
add(
"link_artifact",
"Append an artifact URI and optional digest to a work package event trail.",
{
**common,
"work_package_id": STRING,
"uri": STRING,
"digest": {"type": ["string", "null"], "default": None},
"media_type": {"type": ["string", "null"], "default": None},
"relation": STRING,
"idempotency_key": IDEMPOTENCY,
},
["project_id", "work_package_id", "uri", "relation", "idempotency_key"],
service.link_artifact,
)
add(
"close_gate",
"Close a work-package gate. Only PM/PO; ACCEPT requires independent approval.",
{
**common,
"work_package_id": STRING,
"outcome": {"type": "string", "enum": ["ACCEPT", "REJECT", "HOLD"]},
"rationale": STRING,
"idempotency_key": IDEMPOTENCY,
},
["project_id", "work_package_id", "outcome", "rationale", "idempotency_key"],
service.close_gate,
)
add(
"baseline_release",
"Record a baseline release containing only accepted work packages.",
{
**common,
"version": STRING,
"artifact_uri": STRING,
"digest": STRING,
"included_work_packages": {"type": "array", "items": STRING},
"idempotency_key": IDEMPOTENCY,
},
[
"project_id",
"version",
"artifact_uri",
"digest",
"included_work_packages",
"idempotency_key",
],
service.baseline_release,
)
add(
"sync_since",
"Read ordered immutable project events after a cursor.",
{
**common,
"cursor": {"type": "integer", "minimum": 0, "default": 0},
"limit": {"type": "integer", "minimum": 1, "maximum": 500, "default": 100},
},
["project_id"],
service.sync_since,
)
add(
"list_pending_actions",
"List active work packages, relevant open reviews, and open escalations.",
common,
["project_id"],
service.list_pending_actions,
)
add(
"get_work_package",
"Read a work package, its result, and review trail.",
{**common, "work_package_id": STRING},
["project_id", "work_package_id"],
service.get_work_package,
)
add(
"get_project",
"Read project metadata and its formal actor-role roster.",
common,
["project_id"],
service.get_project,
)
def handle(self, request: dict[str, Any], principal: Principal | None) -> dict[str, Any] | None:
request_id = request.get("id")
method = request.get("method")
if request.get("jsonrpc") != "2.0" or not isinstance(method, str):
return self._rpc_error(request_id, -32600, "Invalid Request")
if method.startswith("notifications/"):
return None
if method == "initialize":
return self._rpc_result(
request_id,
{
"protocolVersion": self.protocol_version,
"capabilities": {"tools": {"listChanged": False}},
"serverInfo": {"name": "epimonos-project-bus", "version": "0.1.0"},
"instructions": (
"Use stable idempotency keys for every mutation. Actor identity is "
"derived from the authenticated transport and never from tool arguments."
),
},
)
if method == "ping":
return self._rpc_result(request_id, {})
if method == "tools/list":
return self._rpc_result(
request_id,
{"tools": [tool.metadata() for tool in self.tools.values()]},
)
if method == "tools/call":
if principal is None:
raise ValidationError("Authenticated principal is required")
params = request.get("params") or {}
name = params.get("name")
arguments = params.get("arguments") or {}
tool = self.tools.get(name)
if tool is None:
return self._rpc_error(request_id, -32602, f"Unknown tool: {name}")
if not isinstance(arguments, dict):
return self._rpc_error(request_id, -32602, "Tool arguments must be an object")
try:
# Optional defaults are normalized here because not all MCP clients
# materialize JSON Schema defaults.
if name == "create_project":
arguments.setdefault("description", "")
elif name == "record_decision":
arguments.setdefault("work_package_id", None)
elif name == "raise_escalation":
arguments.setdefault("target_role", None)
elif name == "link_artifact":
arguments.setdefault("digest", None)
arguments.setdefault("media_type", None)
elif name == "sync_since":
arguments.setdefault("cursor", 0)
arguments.setdefault("limit", 100)
result = tool.handler(principal, **arguments)
return self._rpc_result(
request_id,
{
"content": [
{
"type": "text",
"text": json.dumps(result, ensure_ascii=False, sort_keys=True),
}
],
"structuredContent": result,
"isError": False,
},
)
except ProjectBusError as error:
payload = {
"code": error.code,
"message": error.message,
"details": error.details,
}
return self._rpc_result(
request_id,
{
"content": [
{
"type": "text",
"text": json.dumps(payload, ensure_ascii=False, sort_keys=True),
}
],
"structuredContent": payload,
"isError": True,
},
)
except TypeError as error:
return self._rpc_error(request_id, -32602, f"Invalid tool arguments: {error}")
return self._rpc_error(request_id, -32601, "Method not found")
@staticmethod
def _rpc_result(request_id: Any, result: dict[str, Any]) -> dict[str, Any]:
return {"jsonrpc": "2.0", "id": request_id, "result": result}
@staticmethod
def _rpc_error(request_id: Any, code: int, message: str) -> dict[str, Any]:
return {
"jsonrpc": "2.0",
"id": request_id,
"error": {"code": code, "message": message},
}
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from typing import Any
class Role(StrEnum):
SYSTEM = "SYSTEM"
PO = "PO"
PM = "PM"
PA = "PA"
ARCHITECT = "ARCHITECT"
LEAD_ENGINEER = "LEAD_ENGINEER"
IMPLEMENTER = "IMPLEMENTER"
REVIEWER = "REVIEWER"
SECURITY_REVIEWER = "SECURITY_REVIEWER"
OBSERVER = "OBSERVER"
class WorkPackageStatus(StrEnum):
OPEN = "OPEN"
CLAIMED = "CLAIMED"
RESULT_SUBMITTED = "RESULT_SUBMITTED"
IN_REVIEW = "IN_REVIEW"
REVIEWED = "REVIEWED"
ACCEPTED = "ACCEPTED"
REJECTED = "REJECTED"
CANCELLED = "CANCELLED"
class ReviewVerdict(StrEnum):
APPROVE = "APPROVE"
CHANGES_REQUESTED = "CHANGES_REQUESTED"
REJECT = "REJECT"
class DecisionKind(StrEnum):
PRODUCT = "PRODUCT"
ARCHITECTURE = "ARCHITECTURE"
OPERATIONAL = "OPERATIONAL"
GATE = "GATE"
class GateOutcome(StrEnum):
ACCEPT = "ACCEPT"
REJECT = "REJECT"
HOLD = "HOLD"
@dataclass(frozen=True)
class Principal:
actor_id: str
auth_subject: str
display_name: str
@dataclass(frozen=True)
class ActorContext:
actor_id: str
project_id: str
display_name: str
role: Role
active: bool
JSON = dict[str, Any]
+136
View File
@@ -0,0 +1,136 @@
from __future__ import annotations
import json
import logging
import os
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from urllib.parse import urlsplit
from .auth import AuthProvider
from .errors import AuthenticationError
from .mcp import MCPApplication
LOGGER = logging.getLogger("project_bus.http")
MAX_BODY_BYTES = 1_048_576
class ProjectBusHTTPServer(ThreadingHTTPServer):
daemon_threads = True
def __init__(
self,
server_address: tuple[str, int],
application: MCPApplication,
auth_provider: AuthProvider,
allowed_origins: set[str],
):
super().__init__(server_address, ProjectBusRequestHandler)
self.application = application
self.auth_provider = auth_provider
self.allowed_origins = allowed_origins
class ProjectBusRequestHandler(BaseHTTPRequestHandler):
server: ProjectBusHTTPServer
protocol_version = "HTTP/1.1"
def log_message(self, format_string: str, *args: Any) -> None:
LOGGER.info("%s - %s", self.address_string(), format_string % args)
def do_GET(self) -> None:
path = urlsplit(self.path).path
if path == "/healthz":
self._json(HTTPStatus.OK, {"status": "ok"})
return
if path == "/mcp":
self._json(
HTTPStatus.METHOD_NOT_ALLOWED,
{"error": "This stateless server does not expose an SSE GET stream"},
extra_headers={"Allow": "POST"},
)
return
self._json(HTTPStatus.NOT_FOUND, {"error": "not_found"})
def do_POST(self) -> None:
if urlsplit(self.path).path != "/mcp":
self._json(HTTPStatus.NOT_FOUND, {"error": "not_found"})
return
if not self._origin_allowed():
self._json(HTTPStatus.FORBIDDEN, {"error": "origin_not_allowed"})
return
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip()
if content_type != "application/json":
self._json(HTTPStatus.UNSUPPORTED_MEDIA_TYPE, {"error": "application/json required"})
return
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
self._json(HTTPStatus.BAD_REQUEST, {"error": "invalid_content_length"})
return
if length <= 0 or length > MAX_BODY_BYTES:
self._json(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"error": "invalid_body_size"})
return
try:
request = json.loads(self.rfile.read(length))
except (json.JSONDecodeError, UnicodeDecodeError):
self._json(
HTTPStatus.OK,
{"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error"}},
)
return
if not isinstance(request, dict):
self._json(
HTTPStatus.OK,
{"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Invalid Request"}},
)
return
principal = None
if request.get("method") == "tools/call":
try:
principal = self.server.auth_provider.authenticate(self.headers.get("Authorization"))
except AuthenticationError as error:
self._json(
HTTPStatus.UNAUTHORIZED,
{"error": error.code, "message": error.message},
extra_headers={"WWW-Authenticate": 'Bearer realm="project-bus"'},
)
return
response = self.server.application.handle(request, principal)
if response is None:
self.send_response(HTTPStatus.ACCEPTED)
self.send_header("Content-Length", "0")
self.end_headers()
return
self._json(HTTPStatus.OK, response)
def _origin_allowed(self) -> bool:
origin = self.headers.get("Origin")
if not origin:
return True
return origin in self.server.allowed_origins
def _json(
self,
status: HTTPStatus,
payload: dict[str, Any],
*,
extra_headers: dict[str, str] | None = None,
) -> None:
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
for name, value in (extra_headers or {}).items():
self.send_header(name, value)
self.end_headers()
self.wfile.write(body)
def allowed_origins_from_environment() -> set[str]:
raw = os.environ.get("PROJECT_BUS_ALLOWED_ORIGINS", "")
return {item.strip() for item in raw.split(",") if item.strip()}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
+120
View File
@@ -0,0 +1,120 @@
from __future__ import annotations
import json
import tempfile
import threading
import unittest
import urllib.error
import urllib.request
from pathlib import Path
from project_bus.auth import StaticTokenAuthProvider, TokenIdentity
from project_bus.db import Database
from project_bus.mcp import MCPApplication
from project_bus.server import ProjectBusHTTPServer
from project_bus.service import ProjectBusService
class MCPHTTPTestCase(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
service = ProjectBusService(Database(Path(self.temporary.name) / "bus.db"))
service.initialize()
auth = StaticTokenAuthProvider(
{"bootstrap-secret-at-least-24": TokenIdentity("system", "system", "System")}
)
self.server = ProjectBusHTTPServer(
("127.0.0.1", 0), MCPApplication(service), auth, {"https://chat.example"}
)
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
self.url = f"http://127.0.0.1:{self.server.server_port}/mcp"
def tearDown(self) -> None:
self.server.shutdown()
self.server.server_close()
self.thread.join()
self.temporary.cleanup()
def request(
self,
payload: dict,
*,
token: str | None = None,
origin: str | None = None,
) -> tuple[int, dict]:
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
if origin:
headers["Origin"] = origin
request = urllib.request.Request(
self.url,
data=json.dumps(payload).encode(),
headers=headers,
method="POST",
)
try:
response = urllib.request.urlopen(request, timeout=2)
return response.status, json.loads(response.read())
except urllib.error.HTTPError as error:
return error.code, json.loads(error.read())
def test_initialize_and_list_tools(self) -> None:
status, initialized = self.request(
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}
)
self.assertEqual(status, 200)
self.assertEqual(initialized["result"]["protocolVersion"], "2025-03-26")
status, listed = self.request(
{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}
)
self.assertEqual(status, 200)
names = {tool["name"] for tool in listed["result"]["tools"]}
self.assertIn("sync_since", names)
self.assertIn("close_gate", names)
def test_authenticated_tool_call(self) -> None:
status, response = self.request(
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "create_project",
"arguments": {
"project_id": "demo",
"name": "Demo",
"idempotency_key": "create-demo",
},
},
},
token="bootstrap-secret-at-least-24",
)
self.assertEqual(status, 200)
self.assertFalse(response["result"]["isError"])
self.assertEqual(response["result"]["structuredContent"]["project_id"], "demo")
def test_tool_call_requires_authentication(self) -> None:
status, response = self.request(
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": "sync_since", "arguments": {"project_id": "demo"}},
}
)
self.assertEqual(status, 401)
self.assertEqual(response["error"], "unauthorized")
def test_origin_allowlist(self) -> None:
status, response = self.request(
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
origin="https://evil.example",
)
self.assertEqual(status, 403)
self.assertEqual(response["error"], "origin_not_allowed")
if __name__ == "__main__":
unittest.main()
+428
View File
@@ -0,0 +1,428 @@
from __future__ import annotations
import sqlite3
import tempfile
import threading
import unittest
from pathlib import Path
from project_bus.db import Database
from project_bus.errors import AuthorizationError, ConflictError, ValidationError
from project_bus.models import Principal
from project_bus.service import ProjectBusService
class ServiceTestCase(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.database_path = Path(self.temporary.name) / "bus.db"
self.database = Database(self.database_path)
self.service = ProjectBusService(self.database)
self.service.initialize()
self.system = Principal("system", "system", "System")
self.service.create_project(
self.system,
project_id="sandbox",
name="Sandbox",
idempotency_key="create-sandbox",
)
self.principals: dict[str, Principal] = {}
for actor_id, role in (
("po", "PO"),
("pm", "PM"),
("pa", "PA"),
("lead", "LEAD_ENGINEER"),
("impl2", "IMPLEMENTER"),
("reviewer", "REVIEWER"),
("security", "SECURITY_REVIEWER"),
):
self.service.register_actor(
self.system,
project_id="sandbox",
actor_id=actor_id,
auth_subject=f"subject-{actor_id}",
display_name=actor_id.title(),
role=role,
idempotency_key=f"register-{actor_id}",
)
self.principals[actor_id] = Principal(
actor_id, f"subject-{actor_id}", actor_id.title()
)
def tearDown(self) -> None:
self.temporary.cleanup()
def issue(self, work_package_id: str = "WP-001") -> None:
self.service.issue_work_package(
self.principals["pm"],
project_id="sandbox",
work_package_id=work_package_id,
title="Build it",
description="Implement the bounded feature.",
acceptance_criteria=["Tests pass", "Evidence linked"],
idempotency_key=f"issue-{work_package_id}",
)
def submit_and_review(self, work_package_id: str = "WP-001") -> str:
self.service.claim_work_package(
self.principals["lead"],
project_id="sandbox",
work_package_id=work_package_id,
idempotency_key=f"claim-{work_package_id}",
)
self.service.submit_result(
self.principals["lead"],
project_id="sandbox",
work_package_id=work_package_id,
summary="Implemented.",
evidence=[{"type": "test", "value": "all green"}],
idempotency_key=f"result-{work_package_id}",
)
requested = self.service.request_review(
self.principals["pa"],
project_id="sandbox",
work_package_id=work_package_id,
reviewer_id="reviewer",
review_type="technical",
idempotency_key=f"request-review-{work_package_id}",
)
self.service.submit_review(
self.principals["reviewer"],
project_id="sandbox",
review_id=requested["review_id"],
verdict="APPROVE",
findings=[],
summary="Approved independently.",
idempotency_key=f"review-{work_package_id}",
)
return requested["review_id"]
def test_full_workflow_gate_and_baseline(self) -> None:
self.issue()
self.submit_and_review()
linked = self.service.link_commit(
self.principals["lead"],
project_id="sandbox",
work_package_id="WP-001",
repository="git.example/project-bus",
commit_sha="0123456789abcdef",
relation="implementation",
idempotency_key="link-commit",
)
self.assertEqual(linked["event"]["event_type"], "commit.linked")
gate = self.service.close_gate(
self.principals["pm"],
project_id="sandbox",
work_package_id="WP-001",
outcome="ACCEPT",
rationale="Criteria and review satisfied.",
idempotency_key="close-gate",
)
self.assertEqual(gate["status"], "ACCEPTED")
baseline = self.service.baseline_release(
self.principals["pm"],
project_id="sandbox",
version="0.1.0",
artifact_uri="https://artifacts.example/project-bus-0.1.0.tar.gz",
digest="sha256:abc",
included_work_packages=["WP-001"],
idempotency_key="release-010",
)
self.assertEqual(baseline["version"], "0.1.0")
with self.assertRaises(ConflictError):
self.service.close_gate(
self.principals["pm"],
project_id="sandbox",
work_package_id="WP-001",
outcome="HOLD",
rationale="Cannot reopen a terminal gate.",
idempotency_key="reopen-gate",
)
with self.assertRaises(ConflictError):
self.service.baseline_release(
self.principals["pm"],
project_id="sandbox",
version="0.1.0",
artifact_uri="https://artifacts.example/other.tar.gz",
digest="sha256:different",
included_work_packages=["WP-001"],
idempotency_key="duplicate-release-version",
)
events = self.service.sync_since(
self.principals["pa"], project_id="sandbox", cursor=0, limit=100
)
self.assertEqual(events["events"][-1]["event_type"], "baseline.released")
self.assertFalse(events["has_more"])
def test_pa_cannot_record_decision_or_close_gate(self) -> None:
with self.assertRaises(AuthorizationError):
self.service.record_decision(
self.principals["pa"],
project_id="sandbox",
decision_id="DEC-001",
kind="ARCHITECTURE",
title="Architecture",
decision="Use option A.",
rationale="Because.",
work_package_id=None,
idempotency_key="pa-decision",
)
self.issue()
with self.assertRaises(AuthorizationError):
self.service.close_gate(
self.principals["pa"],
project_id="sandbox",
work_package_id="WP-001",
outcome="HOLD",
rationale="Waiting.",
idempotency_key="pa-gate",
)
def test_only_po_can_record_product_decision(self) -> None:
with self.assertRaises(AuthorizationError):
self.service.record_decision(
self.principals["pm"],
project_id="sandbox",
decision_id="DEC-PRODUCT-1",
kind="PRODUCT",
title="Product scope",
decision="Add a feature.",
rationale="Customer need.",
work_package_id=None,
idempotency_key="pm-product",
)
result = self.service.record_decision(
self.principals["po"],
project_id="sandbox",
decision_id="DEC-PRODUCT-1",
kind="PRODUCT",
title="Product scope",
decision="Add a feature.",
rationale="Customer need.",
work_package_id=None,
idempotency_key="po-product",
)
self.assertEqual(result["kind"], "PRODUCT")
def test_reviewer_must_be_independent_and_have_review_role(self) -> None:
self.issue()
self.service.claim_work_package(
self.principals["lead"],
project_id="sandbox",
work_package_id="WP-001",
idempotency_key="claim",
)
self.service.submit_result(
self.principals["lead"],
project_id="sandbox",
work_package_id="WP-001",
summary="Done.",
evidence=[],
idempotency_key="result",
)
with self.assertRaises(ValidationError):
self.service.request_review(
self.principals["pa"],
project_id="sandbox",
work_package_id="WP-001",
reviewer_id="lead",
review_type="technical",
idempotency_key="self-review",
)
def test_gate_accept_requires_approved_review(self) -> None:
self.issue()
self.service.claim_work_package(
self.principals["lead"],
project_id="sandbox",
work_package_id="WP-001",
idempotency_key="claim",
)
self.service.submit_result(
self.principals["lead"],
project_id="sandbox",
work_package_id="WP-001",
summary="Done.",
evidence=[],
idempotency_key="result",
)
with self.assertRaises(ConflictError):
self.service.close_gate(
self.principals["pm"],
project_id="sandbox",
work_package_id="WP-001",
outcome="ACCEPT",
rationale="Premature.",
idempotency_key="premature-gate",
)
def test_idempotent_replay_and_key_mismatch(self) -> None:
first = self.service.issue_work_package(
self.principals["pm"],
project_id="sandbox",
work_package_id="WP-001",
title="Build it",
description="First request.",
acceptance_criteria=["Pass"],
idempotency_key="same-key",
)
second = self.service.issue_work_package(
self.principals["pm"],
project_id="sandbox",
work_package_id="WP-001",
title="Build it",
description="First request.",
acceptance_criteria=["Pass"],
idempotency_key="same-key",
)
self.assertFalse(first["idempotent_replay"])
self.assertTrue(second["idempotent_replay"])
self.assertEqual(first["event"]["event_id"], second["event"]["event_id"])
with self.assertRaises(ConflictError):
self.service.issue_work_package(
self.principals["pm"],
project_id="sandbox",
work_package_id="WP-OTHER",
title="Different",
description="Different request.",
acceptance_criteria=["Pass"],
idempotency_key="same-key",
)
def test_concurrent_claim_has_single_winner(self) -> None:
self.issue()
barrier = threading.Barrier(2)
outcomes: list[str] = []
lock = threading.Lock()
def claim(actor_id: str) -> None:
barrier.wait()
try:
self.service.claim_work_package(
self.principals[actor_id],
project_id="sandbox",
work_package_id="WP-001",
idempotency_key=f"claim-{actor_id}",
)
value = "won"
except ConflictError:
value = "lost"
with lock:
outcomes.append(value)
first = threading.Thread(target=claim, args=("lead",))
second = threading.Thread(target=claim, args=("impl2",))
first.start()
second.start()
first.join()
second.join()
self.assertCountEqual(outcomes, ["won", "lost"])
def test_cursor_pagination_has_no_duplicates(self) -> None:
self.issue("WP-001")
self.issue("WP-002")
first = self.service.sync_since(
self.principals["pa"], project_id="sandbox", cursor=0, limit=3
)
second = self.service.sync_since(
self.principals["pa"],
project_id="sandbox",
cursor=first["next_cursor"],
limit=100,
)
first_ids = {event["event_id"] for event in first["events"]}
second_ids = {event["event_id"] for event in second["events"]}
self.assertFalse(first_ids & second_ids)
self.assertTrue(first["has_more"])
def test_work_package_ids_are_project_scoped_and_reads_are_isolated(self) -> None:
self.issue("WP-001")
self.service.create_project(
self.system,
project_id="other",
name="Other",
idempotency_key="create-other",
)
self.service.register_actor(
self.system,
project_id="other",
actor_id="pm",
auth_subject="subject-pm",
display_name="Pm",
role="PM",
idempotency_key="other-register-pm",
)
self.service.issue_work_package(
self.principals["pm"],
project_id="other",
work_package_id="WP-001",
title="Same local identifier",
description="Different project.",
acceptance_criteria=["Isolated"],
idempotency_key="other-issue-wp",
)
with self.assertRaises(AuthorizationError):
self.service.get_work_package(
self.principals["pa"],
project_id="other",
work_package_id="WP-001",
)
def test_event_log_rejects_update_and_delete(self) -> None:
self.issue()
with self.database.transaction(write=True) as connection:
event_id = connection.execute(
"SELECT event_id FROM event_log ORDER BY cursor LIMIT 1"
).fetchone()["event_id"]
with self.assertRaises(sqlite3.IntegrityError):
connection.execute(
"UPDATE event_log SET event_type = 'tampered' WHERE event_id = ?",
(event_id,),
)
with self.database.transaction(write=True) as connection:
with self.assertRaises(sqlite3.IntegrityError):
connection.execute("DELETE FROM event_log")
def test_baseline_rejects_unaccepted_work_package(self) -> None:
self.issue()
with self.assertRaises(ConflictError):
self.service.baseline_release(
self.principals["pm"],
project_id="sandbox",
version="bad",
artifact_uri="artifact://bad",
digest="sha256:bad",
included_work_packages=["WP-001"],
idempotency_key="bad-baseline",
)
def test_escalation_can_be_raised_by_pa_but_only_resolved_by_pm(self) -> None:
escalation = self.service.raise_escalation(
self.principals["pa"],
project_id="sandbox",
severity="HIGH",
title="Decision needed",
description="Tolerance exceeded.",
target_role="PM",
idempotency_key="raise-escalation",
)
with self.assertRaises(AuthorizationError):
self.service.resolve_escalation(
self.principals["pa"],
project_id="sandbox",
escalation_id=escalation["escalation_id"],
resolution="Resolved by PA.",
idempotency_key="pa-resolve",
)
resolved = self.service.resolve_escalation(
self.principals["pm"],
project_id="sandbox",
escalation_id=escalation["escalation_id"],
resolution="PM supplied the required decision.",
idempotency_key="pm-resolve",
)
self.assertEqual(resolved["status"], "RESOLVED")
if __name__ == "__main__":
unittest.main()