feat: implement project bus MVP
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
.coverage
|
||||||
|
.pytest_cache/
|
||||||
|
.venv/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
.env
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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"]
|
||||||
@@ -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:
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""Epimonos Project Bus core package."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
|
|
||||||
@@ -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},
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
|
||||||
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user