diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2164557 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Relay hashes raw bytes and checks generated files byte-for-byte across platforms. +* text=auto eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f957400..c50f586 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,8 +14,13 @@ concurrency: jobs: verify: - runs-on: ubuntu-latest + name: Verify on ${{ matrix.os }} + runs-on: ${{ matrix.os }} timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] steps: - name: Check out source uses: actions/checkout@v6 diff --git a/README.md b/README.md index 79afd20..1fb6f6d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Project Relay is an evidence-governed coordination layer for multiple AI systems and human reviewers. It uses a Git repository as a shared, inspectable record while MCP provides a provider-neutral integration surface. -> **Status:** pre-alpha public scaffold. The name is a working title, the protocol is unstable, and no production deployment is implied. +> **Status:** pre-alpha M1 candidate. The name is a working title, the protocol is unstable, and no production deployment is implied. ## Why it exists @@ -38,14 +38,16 @@ The canonical record is validated JSON plus referenced artifacts in Git history. ## Current public milestone -This repository starts with: +The local M1 vertical slice now includes: - JSON Schemas for tasks, events, evidence, reviews, and decisions; - deterministic canonicalisation and SHA-256 provenance helpers; -- an append-only event-chain verifier; -- a read-only MCP server for inspection, validation, and event preparation; -- a static console suitable for a future GitHub Pages deployment; -- CI and a public-boundary scanner. +- append-only event-chain verification plus policy-checked task transitions; +- an evidence-bundle CLI that hashes declared artifacts without executing commands; +- a read-only MCP server with tools, a task resource, and an independent-review prompt; +- a deterministic static console projection of state, history, and gate results; +- a synthetic submission, review, remediation, resubmission, and human-decision fixture; +- Windows and Linux CI conformance coverage plus a public-boundary scanner. GitHub write automation, remote MCP transport, authentication, and hosted multi-tenant operation are deliberately deferred until their threat models are reviewed. @@ -65,6 +67,12 @@ Check direct dependency versions without changing the repository: npm run versions:check ``` +Run the offline local doctor for measured runtime, Git, lockfile, workspace, and worktree checks: + +```bash +npm run doctor +``` + To review conservative updates and receive an interactive `[y/N]` installation prompt: ```bash @@ -87,7 +95,15 @@ The MCP process uses stdio and reads the workspace specified by `RELAY_WORKSPACE } ``` -Use `examples/minimal` as the first workspace. +Use `examples/m1` to inspect the complete local remediation loop. `examples/minimal` remains the smallest M0 kernel fixture. + +Create a validated evidence bundle from an explicit manifest: + +```bash +npm run evidence:create -- --manifest path/to/manifest.json --output path/to/bundle.json +``` + +The command reads and hashes declared artifacts. It does not execute the commands recorded in the manifest. ## Safety and publication boundary diff --git a/apps/console/README.md b/apps/console/README.md index 4ff6b75..130669b 100644 --- a/apps/console/README.md +++ b/apps/console/README.md @@ -1,6 +1,6 @@ # Relay console -The console is a credential-free, read-only projection of a generated Relay state snapshot. It is intentionally compatible with static hosting such as GitHub Pages. +The console is a credential-free, read-only projection of a generated Relay state snapshot. It shows derived task state, transition history counts, and default-policy gate results. It is intentionally compatible with static hosting such as GitHub Pages. Run it locally from the repository root: diff --git a/apps/console/app.js b/apps/console/app.js index 0b16da2..d601cc5 100644 --- a/apps/console/app.js +++ b/apps/console/app.js @@ -27,16 +27,31 @@ function renderTask(task) { const strong = document.createElement("strong"); strong.textContent = task.title; const small = document.createElement("small"); - small.textContent = task.id; + small.textContent = `${task.id} · ${task.history.length} transitions · policy ${task.policy_valid ? "valid" : "failed"}`; title.append(strong, small); row.append( title, - cell("badge badge-state", task.state.replaceAll("_", " ")), + cell( + `badge badge-state ${task.policy_valid ? "" : "badge-policy-failed"}`, + task.derived_state.replaceAll("_", " ") + ), cell(`badge badge-risk-${task.risk}`, task.risk), cell("", String(task.evidence_count)), cell("", String(task.review_count)) ); + + const gates = document.createElement("div"); + gates.className = "task-gates"; + for (const [name, satisfied] of Object.entries(task.gates)) { + gates.append( + cell( + `gate ${satisfied ? "gate-pass" : "gate-fail"}`, + `${satisfied ? "✓" : "×"} ${name.replaceAll("_", " ")}` + ) + ); + } + row.append(gates); return row; } diff --git a/apps/console/state/index.json b/apps/console/state/index.json index e51e899..0f7f62f 100644 --- a/apps/console/state/index.json +++ b/apps/console/state/index.json @@ -1,19 +1,112 @@ { "protocol_version": "0.1.0", - "generated_from": "examples/minimal", + "generated_from": "examples/m1", "summary": { "tasks": 1, - "active": 1, - "evidence_bundles": 0, - "reviews": 0, - "decisions": 0 + "active": 0, + "evidence_bundles": 2, + "reviews": 2, + "decisions": 1, + "failed_gates": 0 }, "tasks": [ { - "id": "RELAY-0001", - "title": "Validate the Relay protocol kernel", - "question": "Does the initial Relay protocol reject malformed records and detect a modified event chain?", - "state": "draft", + "id": "RELAY-1001", + "title": "Exercise the default remediation policy", + "question": "Can Relay preserve a failed independent review, require remediation, and record human-authorised acceptance?", + "state": "accepted", + "derived_state": "accepted", + "policy_valid": true, + "gates": { + "evidence_present": true, + "independent_review": true, + "independent_reproduction": true, + "remediation_recorded": true, + "human_decision": true, + "final_state_consistent": true + }, + "history": [ + { + "sequence": 1, + "event_id": "EVT-m1event0001", + "type": "task.created", + "from_state": null, + "to_state": "draft", + "occurred_at": "2026-07-19T01:00:00Z" + }, + { + "sequence": 2, + "event_id": "EVT-m1event0002", + "type": "task.ready", + "from_state": "draft", + "to_state": "ready", + "occurred_at": "2026-07-19T01:01:00Z" + }, + { + "sequence": 3, + "event_id": "EVT-m1event0003", + "type": "task.claimed", + "from_state": "ready", + "to_state": "claimed", + "occurred_at": "2026-07-19T01:02:00Z" + }, + { + "sequence": 4, + "event_id": "EVT-m1event0004", + "type": "task.started", + "from_state": "claimed", + "to_state": "in_progress", + "occurred_at": "2026-07-19T01:03:00Z" + }, + { + "sequence": 5, + "event_id": "EVT-m1event0005", + "type": "task.submitted", + "from_state": "in_progress", + "to_state": "submitted", + "occurred_at": "2026-07-19T01:05:00Z" + }, + { + "sequence": 6, + "event_id": "EVT-m1event0006", + "type": "review.requested", + "from_state": "submitted", + "to_state": "under_review", + "occurred_at": "2026-07-19T01:06:00Z" + }, + { + "sequence": 8, + "event_id": "EVT-m1event0008", + "type": "remediation.requested", + "from_state": "under_review", + "to_state": "remediation", + "occurred_at": "2026-07-19T01:08:00Z" + }, + { + "sequence": 9, + "event_id": "EVT-m1event0009", + "type": "task.submitted", + "from_state": "remediation", + "to_state": "submitted", + "occurred_at": "2026-07-19T01:10:00Z" + }, + { + "sequence": 10, + "event_id": "EVT-m1event0010", + "type": "review.requested", + "from_state": "submitted", + "to_state": "under_review", + "occurred_at": "2026-07-19T01:11:00Z" + }, + { + "sequence": 12, + "event_id": "EVT-m1event0012", + "type": "decision.recorded", + "from_state": "under_review", + "to_state": "accepted", + "occurred_at": "2026-07-19T01:13:00Z" + } + ], "risk": "moderate", "owner": { "id": "model:builder", @@ -21,9 +114,9 @@ "role": "implementation" }, "reviewer_count": 1, - "evidence_count": 0, - "review_count": 0, - "decision_count": 0 + "evidence_count": 2, + "review_count": 2, + "decision_count": 1 } ] } diff --git a/apps/console/styles.css b/apps/console/styles.css index 4950eda..9520fcc 100644 --- a/apps/console/styles.css +++ b/apps/console/styles.css @@ -75,7 +75,12 @@ h2 { font-size: clamp(28px, 4vw, 44px); letter-spacing: -.035em; margin: 8px 0 0 .task-title small { color: var(--muted); font-family: ui-monospace, monospace; margin-top: 5px; } .badge { border: 1px solid var(--line); border-radius: 999px; display: inline-flex; font-family: ui-monospace, monospace; font-size: 10px; padding: 6px 9px; text-transform: uppercase; width: fit-content; } .badge-state { border-color: rgba(121, 168, 255, .4); color: var(--blue); } +.badge-policy-failed { border-color: rgba(255, 107, 53, .5); color: var(--orange); } .badge-risk-high, .badge-risk-critical { border-color: rgba(255, 107, 53, .5); color: var(--orange); } +.task-gates { display: flex; flex-wrap: wrap; gap: 7px; grid-column: 1 / -1; padding-bottom: 14px; } +.gate { border: 1px solid var(--line); border-radius: 999px; font-family: ui-monospace, monospace; font-size: 9px; letter-spacing: .04em; padding: 5px 8px; text-transform: uppercase; } +.gate-pass { border-color: rgba(185, 247, 124, .32); color: var(--lime); } +.gate-fail { border-color: rgba(255, 107, 53, .5); color: var(--orange); } .load-error { background: rgba(255, 107, 53, .09); border: 1px solid rgba(255, 107, 53, .35); color: #ffc2ac; padding: 16px; } .flow { counter-reset: relay; display: grid; gap: 1px; grid-template-columns: repeat(4, 1fr); list-style: none; margin: 0; padding: 0; } .flow li { background: var(--panel); min-height: 210px; padding: 22px; } diff --git a/apps/mcp-server/src/index.js b/apps/mcp-server/src/index.js index cd7b133..81654c7 100644 --- a/apps/mcp-server/src/index.js +++ b/apps/mcp-server/src/index.js @@ -2,13 +2,14 @@ import { randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; import path from "node:path"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { DOCUMENT_KINDS, createRegistry, eventDigest, readDocuments, + readTaskPacket, validateWorkspace } from "@project-relay/protocol"; import { z } from "zod"; @@ -36,9 +37,15 @@ const actorSchema = z.object({ id: z.string().min(2).max(128), type: z.enum(["human", "model", "service"]), role: z.string().min(2).max(100), + capabilities: z.array(z.string().regex(/^[a-z][a-z0-9._:-]{1,99}$/)).optional(), provider: z.string().min(1).max(100).optional(), model: z.string().min(1).max(200).optional() }); +const causalLinkSchema = z.object({ + relation: z.enum(["caused-by", "derived-from", "responds-to", "supersedes"]), + targetEventId: z.string().regex(/^EVT-[A-Za-z0-9_-]{8,}$/), + note: z.string().min(1).max(500).optional() +}); const registryPromise = createRegistry(); const server = new McpServer({ name: "project-relay", version: VERSION }); @@ -73,6 +80,70 @@ server.registerTool( } ); +server.registerResource( + "relay-task-packet", + new ResourceTemplate("relay://tasks/{id}", { list: undefined }), + { + title: "Relay task packet", + description: + "Read one task with its events, evidence, reviews, decisions, derived state, and policy gates.", + mimeType: "application/json" + }, + async (uri, { id }) => { + const taskId = String(id); + if (!taskIdPattern.test(taskId)) { + throw new Error("Invalid Relay task identifier"); + } + const packet = await readTaskPacket(workspace, taskId); + if (!packet) throw new Error(`Relay task not found: ${taskId}`); + return { + contents: [ + { + uri: uri.href, + mimeType: "application/json", + text: JSON.stringify(packet, null, 2) + } + ] + }; + } +); + +server.registerPrompt( + "relay_review_task", + { + title: "Review a Relay task", + description: + "Prepare a bounded independent-review prompt from the canonical local task packet.", + argsSchema: { + taskId: z.string().regex(taskIdPattern).describe("Relay task identifier") + } + }, + async ({ taskId }) => { + const packet = await readTaskPacket(workspace, taskId); + if (!packet) throw new Error(`Relay task not found: ${taskId}`); + return { + description: `Independent review packet for ${taskId}`, + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + "Review the Relay task packet below against its single question and acceptance criteria.", + "Check evidence references, commands, environment, failures, limitations, and policy issues.", + "State whether your review is independent and disclose any AI assistance.", + "Preserve disagreement and request remediation when a criterion is not satisfied.", + "Do not make or imply the final human decision.", + "", + JSON.stringify(packet, null, 2) + ].join("\n") + } + } + ] + }; + } +); + server.registerTool( "relay_get_task", { @@ -138,10 +209,11 @@ server.registerTool( type: z.enum(eventTypes), actor: actorSchema, previousEventHash: z.string().regex(/^[a-f0-9]{64}$/).nullable().optional(), + causalLinks: z.array(causalLinkSchema).optional(), payload: z.record(z.string(), z.unknown()) } }, - async ({ taskId, sequence, type, actor, previousEventHash, payload }) => { + async ({ taskId, sequence, type, actor, previousEventHash, causalLinks, payload }) => { const event = { schema_version: VERSION, id: `EVT-${randomUUID()}`, @@ -151,6 +223,15 @@ server.registerTool( actor, occurred_at: new Date().toISOString(), previous_event_hash: previousEventHash ?? null, + ...(causalLinks + ? { + causal_links: causalLinks.map(({ relation, targetEventId, note }) => ({ + relation, + target_event_id: targetEventId, + ...(note ? { note } : {}) + })) + } + : {}), payload, event_hash: "" }; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 653e8c7..74340b1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -29,13 +29,13 @@ Provides a stable tool surface for MCP-capable clients. The first release is loc Will translate approved proposals into branches, commits, pull requests, labels, and review requests. It is intentionally separate from the protocol and will use least-privilege GitHub permissions. -### Policy engine — planned +### Policy engine -Will evaluate declared gates such as independent reproduction, security review, or named human approval. It will not assess scientific truth; it checks whether the required process evidence exists. +The local default policy derives task state from append-only events, enforces allowed transitions, verifies linked evidence and reviews, preserves remediation, and requires a human-authored decision for acceptance. It does not assess scientific truth; it checks whether required process evidence exists. Project-specific profiles remain planned. ### Web console -A static, read-only projection designed for GitHub Pages. It consumes a generated state snapshot and holds no credentials. Any future write action must leave Pages and enter an authenticated service or GitHub flow. +A static, read-only projection designed for GitHub Pages. It consumes a generated state snapshot containing derived state, transition history, and gate results, and holds no credentials. Any future write action must leave Pages and enter an authenticated service or GitHub flow. ## Trust boundaries diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 1a0189a..dd73b26 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -10,6 +10,8 @@ A task asks one bounded question. It declares an owner, reviewers, risk, inputs, An evidence bundle connects a claim to its method, exact commands, environment, artifacts, hashes, and limitations. Its status begins as `provisional`; reproduction and audit are separate events. +A bundle with status `reproduced` must name its source through `reproduction_of`. For a high- or critical-risk acceptance, the reproduction must come from a producer other than the task owner and source producer, be considered by the independent passing review, and be hashed by the human decision. + ### Review A review names its reviewer, independence status, evidence considered, findings, outcome, and AI disclosure. `independent: true` is a declared property that can be audited; it is not inferred from using a different brand of model. @@ -22,6 +24,10 @@ A decision records a gate outcome and rationale. In protocol `0.1`, the decision An event is an append-only envelope describing a state transition or material action. Events contain a sequence number, previous-event hash, actor, timestamp, payload, and their own deterministic hash. +Actors may declare bounded capability identifiers such as `relay.task.submit` or `evidence.bundle.create`. A declaration is auditable metadata, not proof that the actor is authorised or technically able to exercise it. Policy remains responsible for deciding whether a capability is permitted at a gate. + +Events may add typed `causal_links` to earlier events in the same task chain using `caused-by`, `derived-from`, `responds-to`, or `supersedes`. Forward and self-references are invalid. These links explain provenance without changing sequence order or replacing the hash chain. + ## Task lifecycle ```text @@ -36,7 +42,9 @@ draft -> ready -> claimed -> in_progress -> submitted -> under_review Any active state may become blocked. Rejected and cancelled are terminal. ``` -The validator checks document shape and chain integrity. A future policy engine will enforce allowed state transitions and project-specific gates. +The validator checks document shape, chain integrity, allowed default transitions, linked evidence and reviews, remediation records, and human decision authority. Future policy profiles will add project-specific gates without changing the meaning of schema validity. + +Transition events after `task.created` declare `payload.from_state` and `payload.to_state`. Submission and review-request events also identify the evidence bundles available at that point. The current task document must agree with the state derived from its event chain. ## Default evidence gate @@ -58,6 +66,8 @@ This is the **Relay Canonical JSON 0.1** profile. It is intentionally labelled r - Every material claim points to evidence or is labelled unsupported. - Evidence preserves failures and exclusions as well as successful outputs. - Prior penalties and data-fit metrics remain separable when applicable. +- Declared capabilities never imply authority by themselves. +- Causal links point backward to existing events in the same task chain. - AI involvement is disclosed at the review and decision boundary. - A failed gate cannot be relabelled as acceptance without a new decision record. - Rejected and superseded records remain discoverable. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e32dc36..801f9a8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -8,16 +8,21 @@ - Public-boundary scanner and CI - Read-only local MCP server -## M1 — local vertical slice +## M1 — local vertical slice — implementation candidate -- Policy-checked task transition engine -- Evidence bundle CLI -- MCP resources and prompts -- Deterministic state projection for the console -- Cross-platform installation and conformance tests +- Policy-checked task transition engine implemented for the default lifecycle +- Evidence bundle CLI implemented without arbitrary command execution +- MCP task resource and bounded independent-review prompt implemented +- Deterministic gate and transition projection implemented for the console +- Windows and Linux conformance matrix configured +- Synthetic task covers submission, failed review, remediation, resubmission, pass review, and human acceptance + +M1 becomes complete when the candidate passes both CI operating systems and receives review. ## M2 — GitHub collaboration adapter +- Enforce declared actor capabilities at authenticated write boundaries +- Preserve typed causal provenance links in branch and pull-request projections - Least-privilege GitHub authentication - Branch and pull-request proposal flow - Issue and label projections @@ -28,6 +33,7 @@ - Blind reproduction packets - Reviewer independence declarations +- Rebuildable research indexes that never replace canonical evidence - Adversarial review and disagreement ledger - Remediation loops and supersession graph - Project-specific policy profiles, beginning with ITSM diff --git a/examples/m1/artifacts/initial-result.txt b/examples/m1/artifacts/initial-result.txt new file mode 100644 index 0000000..2dc6a15 --- /dev/null +++ b/examples/m1/artifacts/initial-result.txt @@ -0,0 +1,2 @@ +result: incomplete +constraint-check: missing diff --git a/examples/m1/artifacts/remediated-result.txt b/examples/m1/artifacts/remediated-result.txt new file mode 100644 index 0000000..d3fb7f7 --- /dev/null +++ b/examples/m1/artifacts/remediated-result.txt @@ -0,0 +1,2 @@ +result: complete +constraint-check: passed diff --git a/examples/m1/manifests/initial-evidence.json b/examples/m1/manifests/initial-evidence.json new file mode 100644 index 0000000..7366342 --- /dev/null +++ b/examples/m1/manifests/initial-evidence.json @@ -0,0 +1,35 @@ +{ + "schema_version": "0.1.0", + "id": "EVD-m1initial01", + "task_id": "RELAY-1001", + "producer": { + "id": "model:builder", + "type": "model", + "role": "implementation" + }, + "created_at": "2026-07-19T01:04:00Z", + "claim": "The synthetic result satisfies the task acceptance criteria.", + "method": "Inspect the declared synthetic artifact and compare each line with the acceptance criteria.", + "commands": [ + "node scripts/validate-repository.mjs examples/m1" + ], + "environment": { + "runtime": "Node.js 24", + "platform": "synthetic cross-platform fixture", + "dependencies": { + "project-relay": "0.1.0" + } + }, + "artifacts": [ + { + "path": "../artifacts/initial-result.txt", + "uri": "artifacts/initial-result.txt", + "media_type": "text/plain" + } + ], + "failures": [], + "limitations": [ + "This is a synthetic protocol fixture and does not establish scientific validity." + ], + "status": "provisional" +} diff --git a/examples/m1/manifests/remediated-evidence.json b/examples/m1/manifests/remediated-evidence.json new file mode 100644 index 0000000..fb85f4c --- /dev/null +++ b/examples/m1/manifests/remediated-evidence.json @@ -0,0 +1,37 @@ +{ + "schema_version": "0.1.0", + "id": "EVD-m1remedied01", + "task_id": "RELAY-1001", + "producer": { + "id": "model:builder", + "type": "model", + "role": "implementation" + }, + "created_at": "2026-07-19T01:09:00Z", + "claim": "The remediated synthetic result now satisfies both declared acceptance criteria.", + "method": "Inspect the replacement synthetic artifact and verify that result and constraint-check are both complete.", + "commands": [ + "node scripts/validate-repository.mjs examples/m1" + ], + "environment": { + "runtime": "Node.js 24", + "platform": "synthetic cross-platform fixture", + "dependencies": { + "project-relay": "0.1.0" + } + }, + "artifacts": [ + { + "path": "../artifacts/remediated-result.txt", + "uri": "artifacts/remediated-result.txt", + "media_type": "text/plain" + } + ], + "failures": [ + "The initial submission omitted the required constraint check." + ], + "limitations": [ + "This is a synthetic protocol fixture and does not establish scientific validity." + ], + "status": "provisional" +} diff --git a/examples/m1/relay/decisions/DEC-m1decision01.json b/examples/m1/relay/decisions/DEC-m1decision01.json new file mode 100644 index 0000000..c0a42a7 --- /dev/null +++ b/examples/m1/relay/decisions/DEC-m1decision01.json @@ -0,0 +1,18 @@ +{ + "schema_version": "0.1.0", + "id": "DEC-m1decision01", + "task_id": "RELAY-1001", + "gate": "default-evidence-gate", + "authority": { + "id": "human:maintainer", + "type": "human", + "role": "decision authority" + }, + "created_at": "2026-07-19T01:13:00Z", + "outcome": "accept", + "rationale": "The remediated evidence satisfies both criteria and an independent reviewer recorded a passing review.", + "evidence_hashes": [ + "2561993331377bb60c11434095c2d8ea3d9ef5b5107afc4ccbe69803f43f5d23" + ], + "review_ids": ["REV-m1review002"] +} diff --git a/examples/m1/relay/events/000001-task-created.json b/examples/m1/relay/events/000001-task-created.json new file mode 100644 index 0000000..0d0adc3 --- /dev/null +++ b/examples/m1/relay/events/000001-task-created.json @@ -0,0 +1,19 @@ +{ + "schema_version": "0.1.0", + "id": "EVT-m1event0001", + "task_id": "RELAY-1001", + "sequence": 1, + "type": "task.created", + "actor": { + "id": "human:maintainer", + "type": "human", + "role": "decision authority" + }, + "occurred_at": "2026-07-19T01:00:00Z", + "previous_event_hash": null, + "payload": { + "state": "draft", + "title": "Exercise the default remediation policy" + }, + "event_hash": "931e65ac600472da1bdea28ca24988df2f6f8635f0a9fa9e0566b80fb03e8ee2" +} diff --git a/examples/m1/relay/events/000002-task-ready.json b/examples/m1/relay/events/000002-task-ready.json new file mode 100644 index 0000000..87fc571 --- /dev/null +++ b/examples/m1/relay/events/000002-task-ready.json @@ -0,0 +1,19 @@ +{ + "schema_version": "0.1.0", + "id": "EVT-m1event0002", + "task_id": "RELAY-1001", + "sequence": 2, + "type": "task.ready", + "actor": { + "id": "human:maintainer", + "type": "human", + "role": "decision authority" + }, + "occurred_at": "2026-07-19T01:01:00Z", + "previous_event_hash": "931e65ac600472da1bdea28ca24988df2f6f8635f0a9fa9e0566b80fb03e8ee2", + "payload": { + "from_state": "draft", + "to_state": "ready" + }, + "event_hash": "7cbad813d4196ce0a718856e8a950ce9d5725c0b9d9f681e4df3df60fb2d1a53" +} diff --git a/examples/m1/relay/events/000003-task-claimed.json b/examples/m1/relay/events/000003-task-claimed.json new file mode 100644 index 0000000..68df1f5 --- /dev/null +++ b/examples/m1/relay/events/000003-task-claimed.json @@ -0,0 +1,19 @@ +{ + "schema_version": "0.1.0", + "id": "EVT-m1event0003", + "task_id": "RELAY-1001", + "sequence": 3, + "type": "task.claimed", + "actor": { + "id": "model:builder", + "type": "model", + "role": "implementation" + }, + "occurred_at": "2026-07-19T01:02:00Z", + "previous_event_hash": "7cbad813d4196ce0a718856e8a950ce9d5725c0b9d9f681e4df3df60fb2d1a53", + "payload": { + "from_state": "ready", + "to_state": "claimed" + }, + "event_hash": "dd3d93dc0f3e2f55af0e65252a0168f47cfd207c33d742d2b12426f2366b7852" +} diff --git a/examples/m1/relay/events/000004-task-started.json b/examples/m1/relay/events/000004-task-started.json new file mode 100644 index 0000000..d02e01e --- /dev/null +++ b/examples/m1/relay/events/000004-task-started.json @@ -0,0 +1,19 @@ +{ + "schema_version": "0.1.0", + "id": "EVT-m1event0004", + "task_id": "RELAY-1001", + "sequence": 4, + "type": "task.started", + "actor": { + "id": "model:builder", + "type": "model", + "role": "implementation" + }, + "occurred_at": "2026-07-19T01:03:00Z", + "previous_event_hash": "dd3d93dc0f3e2f55af0e65252a0168f47cfd207c33d742d2b12426f2366b7852", + "payload": { + "from_state": "claimed", + "to_state": "in_progress" + }, + "event_hash": "b6eeb0d58e05f521594491e4246af24ddd05676b9874d31a4f3103138de7ebcb" +} diff --git a/examples/m1/relay/events/000005-task-submitted.json b/examples/m1/relay/events/000005-task-submitted.json new file mode 100644 index 0000000..9fa21a2 --- /dev/null +++ b/examples/m1/relay/events/000005-task-submitted.json @@ -0,0 +1,22 @@ +{ + "schema_version": "0.1.0", + "id": "EVT-m1event0005", + "task_id": "RELAY-1001", + "sequence": 5, + "type": "task.submitted", + "actor": { + "id": "model:builder", + "type": "model", + "role": "implementation" + }, + "occurred_at": "2026-07-19T01:05:00Z", + "previous_event_hash": "b6eeb0d58e05f521594491e4246af24ddd05676b9874d31a4f3103138de7ebcb", + "payload": { + "from_state": "in_progress", + "to_state": "submitted", + "evidence_ids": [ + "EVD-m1initial01" + ] + }, + "event_hash": "303388180eb393b774603d578b940d95895bdd6b26873fd3e4ffd408d41cd049" +} diff --git a/examples/m1/relay/events/000006-review-requested.json b/examples/m1/relay/events/000006-review-requested.json new file mode 100644 index 0000000..195f9bb --- /dev/null +++ b/examples/m1/relay/events/000006-review-requested.json @@ -0,0 +1,22 @@ +{ + "schema_version": "0.1.0", + "id": "EVT-m1event0006", + "task_id": "RELAY-1001", + "sequence": 6, + "type": "review.requested", + "actor": { + "id": "human:maintainer", + "type": "human", + "role": "decision authority" + }, + "occurred_at": "2026-07-19T01:06:00Z", + "previous_event_hash": "303388180eb393b774603d578b940d95895bdd6b26873fd3e4ffd408d41cd049", + "payload": { + "from_state": "submitted", + "to_state": "under_review", + "evidence_ids": [ + "EVD-m1initial01" + ] + }, + "event_hash": "b6f2182d052c0500153ca95014509d82c5e11b1f8c0aba856136d33aa6abfac6" +} diff --git a/examples/m1/relay/events/000007-review-recorded.json b/examples/m1/relay/events/000007-review-recorded.json new file mode 100644 index 0000000..02a42ad --- /dev/null +++ b/examples/m1/relay/events/000007-review-recorded.json @@ -0,0 +1,19 @@ +{ + "schema_version": "0.1.0", + "id": "EVT-m1event0007", + "task_id": "RELAY-1001", + "sequence": 7, + "type": "review.recorded", + "actor": { + "id": "human:auditor", + "type": "human", + "role": "independent reviewer" + }, + "occurred_at": "2026-07-19T01:07:00Z", + "previous_event_hash": "b6f2182d052c0500153ca95014509d82c5e11b1f8c0aba856136d33aa6abfac6", + "payload": { + "review_id": "REV-m1review001", + "outcome": "remediation" + }, + "event_hash": "24e9748dd2de0e76ed6bab87093fbbb47f48f5f914023c8bdf3bf22c6dbba729" +} diff --git a/examples/m1/relay/events/000008-remediation-requested.json b/examples/m1/relay/events/000008-remediation-requested.json new file mode 100644 index 0000000..15a07d0 --- /dev/null +++ b/examples/m1/relay/events/000008-remediation-requested.json @@ -0,0 +1,20 @@ +{ + "schema_version": "0.1.0", + "id": "EVT-m1event0008", + "task_id": "RELAY-1001", + "sequence": 8, + "type": "remediation.requested", + "actor": { + "id": "human:auditor", + "type": "human", + "role": "independent reviewer" + }, + "occurred_at": "2026-07-19T01:08:00Z", + "previous_event_hash": "24e9748dd2de0e76ed6bab87093fbbb47f48f5f914023c8bdf3bf22c6dbba729", + "payload": { + "from_state": "under_review", + "to_state": "remediation", + "review_id": "REV-m1review001" + }, + "event_hash": "99a44ed73262ff4737ac4ede892380d2e2d9864c5961730548c575d425a678c7" +} diff --git a/examples/m1/relay/events/000009-task-resubmitted.json b/examples/m1/relay/events/000009-task-resubmitted.json new file mode 100644 index 0000000..d32217e --- /dev/null +++ b/examples/m1/relay/events/000009-task-resubmitted.json @@ -0,0 +1,22 @@ +{ + "schema_version": "0.1.0", + "id": "EVT-m1event0009", + "task_id": "RELAY-1001", + "sequence": 9, + "type": "task.submitted", + "actor": { + "id": "model:builder", + "type": "model", + "role": "implementation" + }, + "occurred_at": "2026-07-19T01:10:00Z", + "previous_event_hash": "99a44ed73262ff4737ac4ede892380d2e2d9864c5961730548c575d425a678c7", + "payload": { + "from_state": "remediation", + "to_state": "submitted", + "evidence_ids": [ + "EVD-m1remedied01" + ] + }, + "event_hash": "b5d79b44575d523cea4c50aa713a5fb0e8fb23cb5b15cec153a5cf45736b6fc7" +} diff --git a/examples/m1/relay/events/000010-review-requested.json b/examples/m1/relay/events/000010-review-requested.json new file mode 100644 index 0000000..b4218de --- /dev/null +++ b/examples/m1/relay/events/000010-review-requested.json @@ -0,0 +1,22 @@ +{ + "schema_version": "0.1.0", + "id": "EVT-m1event0010", + "task_id": "RELAY-1001", + "sequence": 10, + "type": "review.requested", + "actor": { + "id": "human:maintainer", + "type": "human", + "role": "decision authority" + }, + "occurred_at": "2026-07-19T01:11:00Z", + "previous_event_hash": "b5d79b44575d523cea4c50aa713a5fb0e8fb23cb5b15cec153a5cf45736b6fc7", + "payload": { + "from_state": "submitted", + "to_state": "under_review", + "evidence_ids": [ + "EVD-m1remedied01" + ] + }, + "event_hash": "3249e902233c48c2608a856b7b1de57bc2a35fdb675d9c925ddfb415d3802cad" +} diff --git a/examples/m1/relay/events/000011-review-recorded.json b/examples/m1/relay/events/000011-review-recorded.json new file mode 100644 index 0000000..94053a0 --- /dev/null +++ b/examples/m1/relay/events/000011-review-recorded.json @@ -0,0 +1,19 @@ +{ + "schema_version": "0.1.0", + "id": "EVT-m1event0011", + "task_id": "RELAY-1001", + "sequence": 11, + "type": "review.recorded", + "actor": { + "id": "human:auditor", + "type": "human", + "role": "independent reviewer" + }, + "occurred_at": "2026-07-19T01:12:00Z", + "previous_event_hash": "3249e902233c48c2608a856b7b1de57bc2a35fdb675d9c925ddfb415d3802cad", + "payload": { + "review_id": "REV-m1review002", + "outcome": "pass" + }, + "event_hash": "c10cf045d650e8717fa2f207bca3ac3594799554075548a5a4aa337a25bcbb42" +} diff --git a/examples/m1/relay/events/000012-decision-recorded.json b/examples/m1/relay/events/000012-decision-recorded.json new file mode 100644 index 0000000..5446ac9 --- /dev/null +++ b/examples/m1/relay/events/000012-decision-recorded.json @@ -0,0 +1,20 @@ +{ + "schema_version": "0.1.0", + "id": "EVT-m1event0012", + "task_id": "RELAY-1001", + "sequence": 12, + "type": "decision.recorded", + "actor": { + "id": "human:maintainer", + "type": "human", + "role": "decision authority" + }, + "occurred_at": "2026-07-19T01:13:00Z", + "previous_event_hash": "c10cf045d650e8717fa2f207bca3ac3594799554075548a5a4aa337a25bcbb42", + "payload": { + "from_state": "under_review", + "to_state": "accepted", + "decision_id": "DEC-m1decision01" + }, + "event_hash": "ce9a39177647066411272d567659fca6fbc86ece804bf4b8c985954a51586011" +} diff --git a/examples/m1/relay/evidence/EVD-m1initial01.json b/examples/m1/relay/evidence/EVD-m1initial01.json new file mode 100644 index 0000000..dfd9a90 --- /dev/null +++ b/examples/m1/relay/evidence/EVD-m1initial01.json @@ -0,0 +1,36 @@ +{ + "schema_version": "0.1.0", + "id": "EVD-m1initial01", + "task_id": "RELAY-1001", + "producer": { + "id": "model:builder", + "type": "model", + "role": "implementation" + }, + "created_at": "2026-07-19T01:04:00Z", + "claim": "The synthetic result satisfies the task acceptance criteria.", + "method": "Inspect the declared synthetic artifact and compare each line with the acceptance criteria.", + "commands": [ + "node scripts/validate-repository.mjs examples/m1" + ], + "environment": { + "runtime": "Node.js 24", + "platform": "synthetic cross-platform fixture", + "dependencies": { + "project-relay": "0.1.0" + } + }, + "artifacts": [ + { + "uri": "artifacts/initial-result.txt", + "sha256": "e9ee5ac9a56d1e8db8d701e74d31e9976f45a8bcab806a639aad210bc68cb7a6", + "media_type": "text/plain", + "size_bytes": 45 + } + ], + "failures": [], + "limitations": [ + "This is a synthetic protocol fixture and does not establish scientific validity." + ], + "status": "provisional" +} diff --git a/examples/m1/relay/evidence/EVD-m1remedied01.json b/examples/m1/relay/evidence/EVD-m1remedied01.json new file mode 100644 index 0000000..bd78382 --- /dev/null +++ b/examples/m1/relay/evidence/EVD-m1remedied01.json @@ -0,0 +1,38 @@ +{ + "schema_version": "0.1.0", + "id": "EVD-m1remedied01", + "task_id": "RELAY-1001", + "producer": { + "id": "model:builder", + "type": "model", + "role": "implementation" + }, + "created_at": "2026-07-19T01:09:00Z", + "claim": "The remediated synthetic result now satisfies both declared acceptance criteria.", + "method": "Inspect the replacement synthetic artifact and verify that result and constraint-check are both complete.", + "commands": [ + "node scripts/validate-repository.mjs examples/m1" + ], + "environment": { + "runtime": "Node.js 24", + "platform": "synthetic cross-platform fixture", + "dependencies": { + "project-relay": "0.1.0" + } + }, + "artifacts": [ + { + "uri": "artifacts/remediated-result.txt", + "sha256": "06a671f90d9687c3d7eac777f41b3d2e6fbfd05a92c481b42b967869609c3f5a", + "media_type": "text/plain", + "size_bytes": 42 + } + ], + "failures": [ + "The initial submission omitted the required constraint check." + ], + "limitations": [ + "This is a synthetic protocol fixture and does not establish scientific validity." + ], + "status": "provisional" +} diff --git a/examples/m1/relay/reviews/REV-m1review001.json b/examples/m1/relay/reviews/REV-m1review001.json new file mode 100644 index 0000000..422c118 --- /dev/null +++ b/examples/m1/relay/reviews/REV-m1review001.json @@ -0,0 +1,28 @@ +{ + "schema_version": "0.1.0", + "id": "REV-m1review001", + "task_id": "RELAY-1001", + "reviewer": { + "id": "human:auditor", + "type": "human", + "role": "independent reviewer" + }, + "created_at": "2026-07-19T01:07:00Z", + "evidence_ids": ["EVD-m1initial01"], + "independent": true, + "independence_notes": "The reviewer did not produce the submitted evidence.", + "outcome": "remediation", + "findings": [ + { + "severity": "moderate", + "category": "acceptance-criteria", + "summary": "The initial artifact omits the required constraint check.", + "evidence_uri": "artifacts/initial-result.txt" + } + ], + "ai_disclosure": { + "used": false, + "systems": [], + "scope": "No AI system was used for this synthetic review record." + } +} diff --git a/examples/m1/relay/reviews/REV-m1review002.json b/examples/m1/relay/reviews/REV-m1review002.json new file mode 100644 index 0000000..83ecb4f --- /dev/null +++ b/examples/m1/relay/reviews/REV-m1review002.json @@ -0,0 +1,28 @@ +{ + "schema_version": "0.1.0", + "id": "REV-m1review002", + "task_id": "RELAY-1001", + "reviewer": { + "id": "human:auditor", + "type": "human", + "role": "independent reviewer" + }, + "created_at": "2026-07-19T01:12:00Z", + "evidence_ids": ["EVD-m1remedied01"], + "independent": true, + "independence_notes": "The reviewer did not produce the remediated evidence.", + "outcome": "pass", + "findings": [ + { + "severity": "info", + "category": "acceptance-criteria", + "summary": "The remediated artifact satisfies both declared criteria.", + "evidence_uri": "artifacts/remediated-result.txt" + } + ], + "ai_disclosure": { + "used": false, + "systems": [], + "scope": "No AI system was used for this synthetic review record." + } +} diff --git a/examples/m1/relay/tasks/RELAY-1001.json b/examples/m1/relay/tasks/RELAY-1001.json new file mode 100644 index 0000000..2df7ff4 --- /dev/null +++ b/examples/m1/relay/tasks/RELAY-1001.json @@ -0,0 +1,38 @@ +{ + "schema_version": "0.1.0", + "id": "RELAY-1001", + "title": "Exercise the default remediation policy", + "question": "Can Relay preserve a failed independent review, require remediation, and record human-authorised acceptance?", + "state": "accepted", + "risk": "moderate", + "owner": { + "id": "model:builder", + "type": "model", + "role": "implementation" + }, + "reviewers": [ + { + "id": "human:auditor", + "type": "human", + "role": "independent reviewer" + } + ], + "created_at": "2026-07-19T01:00:00Z", + "updated_at": "2026-07-19T01:13:00Z", + "inputs": [ + { + "uri": "artifacts/", + "description": "Synthetic public-safe result artifacts", + "media_type": "text/plain" + } + ], + "acceptance_criteria": [ + "The result artifact reports a complete result.", + "The result artifact reports a passed constraint check." + ], + "constraints": [ + "Do not execute commands from the evidence manifest.", + "Preserve the failed first review in the canonical record." + ], + "labels": ["protocol", "policy", "m1"] +} diff --git a/package.json b/package.json index 7631c0e..9938c66 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,17 @@ "packages/*" ], "scripts": { - "build:state": "node scripts/build-console-state.mjs examples/minimal apps/console/state/index.json", - "check": "npm run public:check && npm run validate && npm test && npm run build:state && git diff --exit-code -- apps/console/state/index.json", + "build:m1-events": "node scripts/build-m1-example-events.mjs", + "build:state": "node scripts/build-console-state.mjs examples/m1 apps/console/state/index.json", + "build:state:check": "node scripts/check-console-state.mjs", + "check": "npm run public:check && npm run validate && npm test && npm run build:state:check", "console": "node scripts/serve-console.mjs", + "doctor": "node scripts/project-doctor.mjs", + "evidence:create": "node scripts/create-evidence-bundle.mjs", "mcp": "npm --workspace @project-relay/mcp-server start", "public:check": "node scripts/check-public-boundary.mjs", "test": "node --test", - "validate": "node scripts/validate-repository.mjs examples/minimal", + "validate": "node scripts/validate-repository.mjs examples/minimal examples/m1", "versions:check": "node scripts/version-doctor.mjs check", "versions:update": "node scripts/version-doctor.mjs update" } diff --git a/packages/protocol/src/index.js b/packages/protocol/src/index.js index 1870c0f..0393b63 100644 --- a/packages/protocol/src/index.js +++ b/packages/protocol/src/index.js @@ -25,6 +25,39 @@ const DOCUMENT_LOCATIONS = Object.freeze({ decision: "relay/decisions" }); +export const TASK_TRANSITIONS = Object.freeze({ + draft: Object.freeze(["ready", "blocked", "cancelled"]), + ready: Object.freeze(["claimed", "blocked", "cancelled"]), + claimed: Object.freeze(["in_progress", "blocked", "cancelled"]), + in_progress: Object.freeze(["submitted", "blocked", "cancelled"]), + submitted: Object.freeze(["under_review", "blocked", "cancelled"]), + under_review: Object.freeze(["remediation", "accepted", "rejected", "blocked", "cancelled"]), + remediation: Object.freeze(["submitted", "blocked", "cancelled"]), + blocked: Object.freeze([]), + accepted: Object.freeze([]), + rejected: Object.freeze([]), + cancelled: Object.freeze([]) +}); + +const EVENT_TARGET_STATES = Object.freeze({ + "task.created": "draft", + "task.ready": "ready", + "task.claimed": "claimed", + "task.started": "in_progress", + "task.submitted": "submitted", + "review.requested": "under_review", + "remediation.requested": "remediation", + "task.blocked": "blocked", + "task.cancelled": "cancelled" +}); + +const DECISION_TARGET_STATES = Object.freeze({ + accept: "accepted", + reject: "rejected", + remediate: "remediation", + defer: "blocked" +}); + const schemaId = (kind) => `https://project-relay.dev/schemas/${kind}.schema.json`; export async function createRegistry(schemaDirectory = DEFAULT_SCHEMA_DIRECTORY) { @@ -112,6 +145,7 @@ export function eventDigest(event) { export function verifyEventChain(events) { const ordered = [...events].sort((left, right) => left.sequence - right.sequence); const errors = []; + const seenEventIds = new Set(); let previous = null; let expectedSequence = 1; @@ -138,6 +172,22 @@ export function verifyEventChain(events) { }); } + for (const link of event.causal_links ?? []) { + if (link.target_event_id === event.id) { + errors.push({ + event_id: event.id, + message: "causal link cannot target its containing event" + }); + } else if (!seenEventIds.has(link.target_event_id)) { + errors.push({ + event_id: event.id, + message: `causal link target ${link.target_event_id} must be an earlier event in the task chain` + }); + } + } + + seenEventIds.add(event.id); + previous = event.event_hash; expectedSequence += 1; } @@ -145,6 +195,365 @@ export function verifyEventChain(events) { return { valid: errors.length === 0, errors }; } +function policyIssue(code, message, details = {}) { + return { code, message, ...details }; +} + +function isAtOrBefore(record, event) { + return Date.parse(record.created_at) <= Date.parse(event.occurred_at); +} + +export function evaluateTaskPolicy({ task, events = [], evidence = [], reviews = [], decisions = [] }) { + const taskEvents = events + .filter((event) => event.task_id === task.id) + .sort((left, right) => left.sequence - right.sequence); + const taskEvidence = evidence.filter((record) => record.task_id === task.id); + const taskReviews = reviews.filter((record) => record.task_id === task.id); + const taskDecisions = decisions.filter((record) => record.task_id === task.id); + const evidenceById = new Map(taskEvidence.map((record) => [record.id, record])); + const reviewsById = new Map(taskReviews.map((record) => [record.id, record])); + const decisionsById = new Map(taskDecisions.map((record) => [record.id, record])); + const issues = []; + const history = []; + let state = null; + + for (const review of taskReviews) { + for (const evidenceId of review.evidence_ids) { + if (!evidenceById.has(evidenceId)) { + issues.push( + policyIssue( + "review.evidence_missing", + `Review ${review.id} references missing evidence ${evidenceId}`, + { review_id: review.id } + ) + ); + } else if ( + review.independent && + evidenceById.get(evidenceId).producer.id === review.reviewer.id + ) { + issues.push( + policyIssue( + "review.own_evidence", + `Reviewer ${review.reviewer.id} cannot independently review evidence they produced`, + { review_id: review.id, evidence_id: evidenceId } + ) + ); + } + } + if (review.independent && review.reviewer.id === task.owner.id) { + issues.push( + policyIssue( + "review.self_review", + `Task owner ${task.owner.id} cannot provide independent review`, + { review_id: review.id } + ) + ); + } + } + + const evidenceHashes = new Map(taskEvidence.map((record) => [sha256Canonical(record), record.id])); + const independentReproductions = []; + for (const reproduction of taskEvidence.filter((record) => record.status === "reproduced")) { + const source = evidenceById.get(reproduction.reproduction_of); + if (!source || source.id === reproduction.id) { + issues.push( + policyIssue( + "evidence.reproduction_source_missing", + `Reproduction ${reproduction.id} must reference another evidence bundle in the task`, + { evidence_id: reproduction.id } + ) + ); + } else if ( + reproduction.producer.id === source.producer.id || + reproduction.producer.id === task.owner.id + ) { + issues.push( + policyIssue( + "evidence.reproduction_not_independent", + `Reproduction ${reproduction.id} must be produced independently`, + { evidence_id: reproduction.id } + ) + ); + } else { + independentReproductions.push(reproduction); + } + } + for (const decision of taskDecisions) { + for (const reviewId of decision.review_ids) { + if (!reviewsById.has(reviewId)) { + issues.push( + policyIssue( + "decision.review_missing", + `Decision ${decision.id} references missing review ${reviewId}`, + { decision_id: decision.id } + ) + ); + } + } + for (const hash of decision.evidence_hashes) { + if (!evidenceHashes.has(hash)) { + issues.push( + policyIssue( + "decision.evidence_missing", + `Decision ${decision.id} references an unknown evidence hash`, + { decision_id: decision.id } + ) + ); + } + } + } + + for (const event of taskEvents) { + let targetState = EVENT_TARGET_STATES[event.type] ?? null; + let linkedDecision; + + if (event.type === "decision.recorded") { + linkedDecision = decisionsById.get(event.payload.decision_id); + if (!linkedDecision) { + issues.push( + policyIssue("event.decision_missing", `Event ${event.id} references a missing decision`, { + event_id: event.id + }) + ); + } else { + targetState = DECISION_TARGET_STATES[linkedDecision.outcome]; + if (!isAtOrBefore(linkedDecision, event)) { + issues.push( + policyIssue( + "event.decision_not_recorded", + `Decision ${linkedDecision.id} was created after event ${event.id}`, + { event_id: event.id } + ) + ); + } + } + } + + if (!targetState) { + if (event.type === "review.recorded") { + const review = reviewsById.get(event.payload.review_id); + if (!review) { + issues.push( + policyIssue("event.review_missing", `Event ${event.id} references a missing review`, { + event_id: event.id + }) + ); + } else if (!isAtOrBefore(review, event)) { + issues.push( + policyIssue( + "event.review_not_recorded", + `Review ${review.id} was created after event ${event.id}`, + { event_id: event.id } + ) + ); + } + } + continue; + } + + const fromState = state; + if (event.type === "task.created") { + if (state !== null) { + issues.push( + policyIssue( + "transition.duplicate_creation", + `Task ${task.id} has more than one creation transition`, + { event_id: event.id } + ) + ); + } + } else if (!state || !TASK_TRANSITIONS[state]?.includes(targetState)) { + issues.push( + policyIssue( + "transition.not_allowed", + `Transition ${state ?? "none"} -> ${targetState} is not allowed`, + { event_id: event.id } + ) + ); + } + + if (event.payload.from_state !== undefined && event.payload.from_state !== fromState) { + issues.push( + policyIssue( + "transition.from_state_mismatch", + `Event ${event.id} declares from_state ${event.payload.from_state}, expected ${fromState}`, + { event_id: event.id } + ) + ); + } + const declaredTarget = event.payload.to_state ?? event.payload.state; + if (declaredTarget !== undefined && declaredTarget !== targetState) { + issues.push( + policyIssue( + "transition.to_state_mismatch", + `Event ${event.id} declares target ${declaredTarget}, expected ${targetState}`, + { event_id: event.id } + ) + ); + } + + if ( + event.type !== "task.created" && + (event.payload.from_state === undefined || event.payload.to_state === undefined) + ) { + issues.push( + policyIssue( + "transition.state_declaration_required", + `Event ${event.id} must declare from_state and to_state`, + { event_id: event.id } + ) + ); + } + + if (event.type === "task.submitted" || event.type === "review.requested") { + const linkedEvidenceIds = event.payload.evidence_ids; + if (!Array.isArray(linkedEvidenceIds) || linkedEvidenceIds.length === 0) { + issues.push( + policyIssue( + "gate.evidence_required", + `${event.type} requires at least one linked evidence bundle`, + { event_id: event.id } + ) + ); + } else { + for (const evidenceId of linkedEvidenceIds) { + const record = evidenceById.get(evidenceId); + if (!record || !isAtOrBefore(record, event)) { + issues.push( + policyIssue( + "gate.evidence_unavailable", + `Evidence ${evidenceId} was not available for event ${event.id}`, + { event_id: event.id } + ) + ); + } + } + } + } + + if (event.type === "remediation.requested") { + const review = reviewsById.get(event.payload.review_id); + if ( + !review || + !isAtOrBefore(review, event) || + !["fail", "remediation"].includes(review.outcome) + ) { + issues.push( + policyIssue( + "gate.remediation_review_required", + "Remediation requires a linked fail or remediation review", + { event_id: event.id } + ) + ); + } + } + + if (linkedDecision?.outcome === "accept") { + const passingReview = linkedDecision.review_ids + .map((id) => reviewsById.get(id)) + .find( + (review) => + review?.outcome === "pass" && + review.independent && + review.reviewer.id !== task.owner.id && + isAtOrBefore(review, event) + ); + if (!passingReview) { + issues.push( + policyIssue( + "gate.independent_review_required", + "Acceptance requires a referenced independent pass review by someone other than the task owner", + { event_id: event.id } + ) + ); + } else { + const decisionEvidenceIds = new Set( + linkedDecision.evidence_hashes.map((hash) => evidenceHashes.get(hash)).filter(Boolean) + ); + if (!passingReview.evidence_ids.some((id) => decisionEvidenceIds.has(id))) { + issues.push( + policyIssue( + "gate.reviewed_evidence_required", + "Acceptance decision must hash evidence considered by its independent pass review", + { event_id: event.id } + ) + ); + } + } + if (["high", "critical"].includes(task.risk)) { + const decisionEvidenceIds = new Set( + linkedDecision.evidence_hashes.map((hash) => evidenceHashes.get(hash)).filter(Boolean) + ); + const acceptedReproduction = independentReproductions.find( + (reproduction) => + decisionEvidenceIds.has(reproduction.id) && + passingReview?.evidence_ids.includes(reproduction.id) && + isAtOrBefore(reproduction, event) + ); + if (!acceptedReproduction) { + issues.push( + policyIssue( + "gate.independent_reproduction_required", + "High-risk acceptance requires independently produced reproduction evidence reviewed and hashed by the decision", + { event_id: event.id } + ) + ); + } + } + } + + state = targetState; + history.push({ + sequence: event.sequence, + event_id: event.id, + type: event.type, + from_state: fromState, + to_state: targetState, + occurred_at: event.occurred_at + }); + } + + if (state !== task.state) { + issues.push( + policyIssue( + "task.state_mismatch", + `Task document state ${task.state} does not match derived state ${state ?? "none"}` + ) + ); + } + + const independentPass = taskReviews.some( + (review) => + review.outcome === "pass" && + review.independent && + review.reviewer.id !== task.owner.id + ); + const remediationReviewExists = taskReviews.some((review) => + ["fail", "remediation"].includes(review.outcome) + ); + const remediationEventExists = taskEvents.some( + (event) => event.type === "remediation.requested" + ); + const humanDecision = taskDecisions.some((decision) => decision.authority.type === "human"); + + return { + valid: issues.length === 0, + task_id: task.id, + derived_state: state, + history, + gates: { + evidence_present: taskEvidence.length > 0, + independent_review: independentPass, + independent_reproduction: + !["high", "critical"].includes(task.risk) || independentReproductions.length > 0, + remediation_recorded: !remediationReviewExists || remediationEventExists, + human_decision: humanDecision, + final_state_consistent: state === task.state + }, + issues + }; +} + async function directoryExists(directory) { try { await access(directory); @@ -180,6 +589,7 @@ export async function validateWorkspace(workspace, options = {}) { const issues = []; const counts = Object.fromEntries(DOCUMENT_KINDS.map((kind) => [kind, 0])); const events = []; + const documentsByKind = Object.fromEntries(DOCUMENT_KINDS.map((kind) => [kind, []])); for (const kind of DOCUMENT_KINDS) { const location = path.join(root, DOCUMENT_LOCATIONS[kind]); @@ -191,8 +601,9 @@ export async function validateWorkspace(workspace, options = {}) { const result = registry.validate(kind, document); if (!result.valid) { issues.push({ file, message: "schema validation failed", errors: result.errors }); - } else if (kind === "event") { - events.push(document); + } else { + documentsByKind[kind].push(document); + if (kind === "event") events.push(document); } } } @@ -205,10 +616,59 @@ export async function validateWorkspace(workspace, options = {}) { } } + const taskIds = new Set(documentsByKind.task.map((task) => task.id)); + for (const kind of ["event", "evidence", "review", "decision"]) { + for (const document of documentsByKind[kind]) { + if (!taskIds.has(document.task_id)) { + issues.push({ + file: `${kind}:${document.id}`, + message: `references missing task ${document.task_id}` + }); + } + } + } + + const policy = {}; + for (const task of documentsByKind.task) { + const evaluation = evaluateTaskPolicy({ + task, + events: documentsByKind.event, + evidence: documentsByKind.evidence, + reviews: documentsByKind.review, + decisions: documentsByKind.decision + }); + policy[task.id] = evaluation; + for (const error of evaluation.issues) { + issues.push({ file: `policy:${task.id}`, message: error.message, ...error }); + } + } + return { valid: issues.length === 0, workspace: root, counts, + policy, issues }; } + +export async function readTaskPacket(workspace, taskId) { + const root = path.resolve(workspace); + const loaded = {}; + for (const kind of DOCUMENT_KINDS) { + loaded[kind] = ( + await readDocuments(path.join(root, DOCUMENT_LOCATIONS[kind])) + ).documents.map(({ document }) => document); + } + const task = loaded.task.find((candidate) => candidate.id === taskId); + if (!task) return null; + const related = (records) => records.filter((record) => record.task_id === taskId); + const packet = { + task, + events: related(loaded.event).sort((left, right) => left.sequence - right.sequence), + evidence: related(loaded.evidence), + reviews: related(loaded.review), + decisions: related(loaded.decision) + }; + return { ...packet, policy: evaluateTaskPolicy(packet) }; +} diff --git a/schemas/decision.schema.json b/schemas/decision.schema.json index 3c5ecae..dbca8c9 100644 --- a/schemas/decision.schema.json +++ b/schemas/decision.schema.json @@ -47,7 +47,12 @@ "properties": { "id": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@/-]{1,127}$" }, "type": { "const": "human" }, - "role": { "type": "string", "minLength": 2, "maxLength": 100 } + "role": { "type": "string", "minLength": 2, "maxLength": 100 }, + "capabilities": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[a-z][a-z0-9._:-]{1,99}$" } + } } } } diff --git a/schemas/event.schema.json b/schemas/event.schema.json index 4c3bed5..afe2c9b 100644 --- a/schemas/event.schema.json +++ b/schemas/event.schema.json @@ -46,6 +46,25 @@ { "type": "string", "pattern": "^[a-f0-9]{64}$" } ] }, + "causal_links": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["relation", "target_event_id"], + "properties": { + "relation": { + "enum": ["caused-by", "derived-from", "responds-to", "supersedes"] + }, + "target_event_id": { + "type": "string", + "pattern": "^EVT-[A-Za-z0-9_-]{8,}$" + }, + "note": { "type": "string", "minLength": 1, "maxLength": 500 } + } + } + }, "payload": { "type": "object" }, "event_hash": { "type": "string", "pattern": "^[a-f0-9]{64}$" } }, @@ -58,6 +77,11 @@ "id": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@/-]{1,127}$" }, "type": { "enum": ["human", "model", "service"] }, "role": { "type": "string", "minLength": 2, "maxLength": 100 }, + "capabilities": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[a-z][a-z0-9._:-]{1,99}$" } + }, "provider": { "type": "string", "minLength": 1, "maxLength": 100 }, "model": { "type": "string", "minLength": 1, "maxLength": 200 } } diff --git a/schemas/evidence.schema.json b/schemas/evidence.schema.json index 7f082de..b219106 100644 --- a/schemas/evidence.schema.json +++ b/schemas/evidence.schema.json @@ -69,8 +69,18 @@ "type": "array", "items": { "type": "string", "minLength": 1, "maxLength": 2000 } }, + "reproduction_of": { "type": "string", "pattern": "^EVD-[A-Za-z0-9_-]{8,}$" }, "status": { "enum": ["provisional", "reproduced", "audited", "rejected", "superseded"] } }, + "allOf": [ + { + "if": { + "properties": { "status": { "const": "reproduced" } }, + "required": ["status"] + }, + "then": { "properties": { "reproduction_of": {} }, "required": ["reproduction_of"] } + } + ], "$defs": { "actor": { "type": "object", @@ -80,6 +90,11 @@ "id": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@/-]{1,127}$" }, "type": { "enum": ["human", "model", "service"] }, "role": { "type": "string", "minLength": 2, "maxLength": 100 }, + "capabilities": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[a-z][a-z0-9._:-]{1,99}$" } + }, "provider": { "type": "string", "minLength": 1, "maxLength": 100 }, "model": { "type": "string", "minLength": 1, "maxLength": 200 } } diff --git a/schemas/review.schema.json b/schemas/review.schema.json index f296a26..1c7b540 100644 --- a/schemas/review.schema.json +++ b/schemas/review.schema.json @@ -69,6 +69,11 @@ "id": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@/-]{1,127}$" }, "type": { "enum": ["human", "model", "service"] }, "role": { "type": "string", "minLength": 2, "maxLength": 100 }, + "capabilities": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[a-z][a-z0-9._:-]{1,99}$" } + }, "provider": { "type": "string", "minLength": 1, "maxLength": 100 }, "model": { "type": "string", "minLength": 1, "maxLength": 200 } } diff --git a/schemas/task.schema.json b/schemas/task.schema.json index 8b22702..6a3ffed 100644 --- a/schemas/task.schema.json +++ b/schemas/task.schema.json @@ -85,6 +85,11 @@ "id": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:@/-]{1,127}$" }, "type": { "enum": ["human", "model", "service"] }, "role": { "type": "string", "minLength": 2, "maxLength": 100 }, + "capabilities": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[a-z][a-z0-9._:-]{1,99}$" } + }, "provider": { "type": "string", "minLength": 1, "maxLength": 100 }, "model": { "type": "string", "minLength": 1, "maxLength": 200 } } diff --git a/scripts/build-console-state.mjs b/scripts/build-console-state.mjs index 2b1e486..82df3ba 100644 --- a/scripts/build-console-state.mjs +++ b/scripts/build-console-state.mjs @@ -1,7 +1,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; -import { readDocuments, validateWorkspace } from "@project-relay/protocol"; +import { readDocuments, readTaskPacket, validateWorkspace } from "@project-relay/protocol"; const workspaceArgument = process.argv[2] ?? "examples/minimal"; const outputArgument = process.argv[3] ?? "apps/console/state/index.json"; @@ -32,6 +32,13 @@ const [tasks, evidence, reviews, decisions] = await Promise.all([ load("review"), load("decision") ]); +const packets = new Map( + ( + await Promise.all( + tasks.map(async (task) => [task.id, await readTaskPacket(workspace, task.id)]) + ) + ) +); const countFor = (records, taskId) => records.filter((record) => record.task_id === taskId).length; const snapshot = { @@ -42,21 +49,33 @@ const snapshot = { active: tasks.filter((task) => !["accepted", "rejected", "cancelled"].includes(task.state)).length, evidence_bundles: evidence.length, reviews: reviews.length, - decisions: decisions.length + decisions: decisions.length, + failed_gates: [...packets.values()].reduce( + (total, packet) => + total + Object.values(packet.policy.gates).filter((satisfied) => !satisfied).length, + 0 + ) }, tasks: tasks - .map((task) => ({ - id: task.id, - title: task.title, - question: task.question, - state: task.state, - risk: task.risk, - owner: task.owner, - reviewer_count: task.reviewers.length, - evidence_count: countFor(evidence, task.id), - review_count: countFor(reviews, task.id), - decision_count: countFor(decisions, task.id) - })) + .map((task) => { + const packet = packets.get(task.id); + return { + id: task.id, + title: task.title, + question: task.question, + state: task.state, + derived_state: packet.policy.derived_state, + policy_valid: packet.policy.valid, + gates: packet.policy.gates, + history: packet.policy.history, + risk: task.risk, + owner: task.owner, + reviewer_count: task.reviewers.length, + evidence_count: countFor(evidence, task.id), + review_count: countFor(reviews, task.id), + decision_count: countFor(decisions, task.id) + }; + }) .sort((left, right) => left.id.localeCompare(right.id)) }; diff --git a/scripts/build-m1-example-events.mjs b/scripts/build-m1-example-events.mjs new file mode 100644 index 0000000..fd38ebe --- /dev/null +++ b/scripts/build-m1-example-events.mjs @@ -0,0 +1,20 @@ +import { readFile, readdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { eventDigest } from "@project-relay/protocol"; + +const directory = path.resolve("examples/m1/relay/events"); +const names = (await readdir(directory)).filter((name) => name.endsWith(".json")).sort(); +let previousEventHash = null; + +for (const name of names) { + const file = path.join(directory, name); + const event = JSON.parse(await readFile(file, "utf8")); + event.previous_event_hash = previousEventHash; + event.event_hash = ""; + event.event_hash = eventDigest(event); + previousEventHash = event.event_hash; + await writeFile(file, `${JSON.stringify(event, null, 2)}\n`, "utf8"); +} + +console.log(`Rebuilt ${names.length} synthetic M1 event hashes.`); diff --git a/scripts/check-console-state.mjs b/scripts/check-console-state.mjs new file mode 100644 index 0000000..d9e9936 --- /dev/null +++ b/scripts/check-console-state.mjs @@ -0,0 +1,28 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const temporaryDirectory = await mkdtemp(path.join(tmpdir(), "project-relay-state-")); +const generated = path.join(temporaryDirectory, "index.json"); +const expected = path.resolve("apps/console/state/index.json"); + +try { + execFileSync( + process.execPath, + ["scripts/build-console-state.mjs", "examples/m1", generated], + { cwd: process.cwd(), stdio: "inherit" } + ); + const [generatedContent, expectedContent] = await Promise.all([ + readFile(generated), + readFile(expected) + ]); + if (!generatedContent.equals(expectedContent)) { + console.error("Console state is stale. Run npm run build:state and commit the result."); + process.exitCode = 1; + } else { + console.log("Console state is deterministic and current."); + } +} finally { + await rm(temporaryDirectory, { recursive: true, force: true }); +} diff --git a/scripts/check-public-boundary.mjs b/scripts/check-public-boundary.mjs index 3d69bae..b0a2c2d 100644 --- a/scripts/check-public-boundary.mjs +++ b/scripts/check-public-boundary.mjs @@ -33,9 +33,13 @@ async function walk(directory) { async function candidateFiles() { try { - const output = execFileSync("git", ["ls-files", "-z"], { cwd: root }); - const tracked = output.toString("utf8").split("\0").filter(Boolean); - return tracked.length > 0 ? tracked : walk(root); + const output = execFileSync( + "git", + ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], + { cwd: root } + ); + const publicFiles = output.toString("utf8").split("\0").filter(Boolean); + return publicFiles.length > 0 ? publicFiles : walk(root); } catch { return walk(root); } diff --git a/scripts/create-evidence-bundle.mjs b/scripts/create-evidence-bundle.mjs new file mode 100644 index 0000000..db3f873 --- /dev/null +++ b/scripts/create-evidence-bundle.mjs @@ -0,0 +1,56 @@ +import { access, mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { buildEvidenceBundle } from "./evidence-bundle-lib.mjs"; + +function usage() { + console.error( + "Usage: npm run evidence:create -- --manifest --output [--force]" + ); +} + +function parseArguments(args) { + const options = { force: false }; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === "--force") options.force = true; + else if (argument === "--manifest" || argument === "--output") { + options[argument.slice(2)] = args[index + 1]; + index += 1; + } else { + throw new Error(`Unknown argument: ${argument}`); + } + } + return options; +} + +try { + const options = parseArguments(process.argv.slice(2)); + if (!options.manifest || !options.output) { + usage(); + process.exitCode = 2; + } else { + const output = path.resolve(options.output); + if (!options.force) { + try { + await access(output); + throw new Error(`Refusing to overwrite existing file: ${output}`); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + } + + const { bundle, validation } = await buildEvidenceBundle(options.manifest); + if (!validation.valid) { + console.error(JSON.stringify(validation.errors, null, 2)); + process.exitCode = 1; + } else { + await mkdir(path.dirname(output), { recursive: true }); + await writeFile(output, `${JSON.stringify(bundle, null, 2)}\n`, "utf8"); + console.log(`Wrote validated evidence bundle: ${output}`); + } + } +} catch (error) { + console.error(error.message); + process.exitCode = 1; +} diff --git a/scripts/evidence-bundle-lib.mjs b/scripts/evidence-bundle-lib.mjs new file mode 100644 index 0000000..a973003 --- /dev/null +++ b/scripts/evidence-bundle-lib.mjs @@ -0,0 +1,37 @@ +import { createHash } from "node:crypto"; +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; + +import { createRegistry } from "@project-relay/protocol"; + +async function artifactRecord(manifestDirectory, artifact) { + const source = path.resolve(manifestDirectory, artifact.path); + const content = await readFile(source); + const metadata = await stat(source); + return { + uri: artifact.uri, + sha256: createHash("sha256").update(content).digest("hex"), + media_type: artifact.media_type, + size_bytes: metadata.size + }; +} + +export async function buildEvidenceBundle(manifestFile) { + const manifestPath = path.resolve(manifestFile); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + if (!Array.isArray(manifest.artifacts) || manifest.artifacts.length === 0) { + throw new Error("Evidence manifest must declare at least one artifact"); + } + for (const artifact of manifest.artifacts) { + if (!artifact.path || !artifact.uri || !artifact.media_type) { + throw new Error("Each artifact requires path, uri, and media_type"); + } + } + + const artifacts = await Promise.all( + manifest.artifacts.map((artifact) => artifactRecord(path.dirname(manifestPath), artifact)) + ); + const bundle = { ...manifest, artifacts }; + const validation = (await createRegistry()).validate("evidence", bundle); + return { bundle, validation }; +} diff --git a/scripts/project-doctor-lib.mjs b/scripts/project-doctor-lib.mjs new file mode 100644 index 0000000..200f531 --- /dev/null +++ b/scripts/project-doctor-lib.mjs @@ -0,0 +1,20 @@ +export const DOCTOR_STATUS = Object.freeze({ + PASS: "PASS", + WARN: "WARN", + FAIL: "FAIL", + UNKNOWN: "UNKNOWN" +}); + +export function summarizeDoctor(checks) { + const counts = Object.fromEntries( + Object.values(DOCTOR_STATUS).map((status) => [ + status, + checks.filter((check) => check.status === status).length + ]) + ); + return { + counts, + healthy: counts.FAIL === 0 && counts.UNKNOWN === 0, + exitCode: counts.FAIL > 0 || counts.UNKNOWN > 0 ? 1 : 0 + }; +} diff --git a/scripts/project-doctor.mjs b/scripts/project-doctor.mjs new file mode 100644 index 0000000..cf6ec3a --- /dev/null +++ b/scripts/project-doctor.mjs @@ -0,0 +1,102 @@ +import { execFileSync } from "node:child_process"; +import { access, readFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +import { validateWorkspace } from "@project-relay/protocol"; +import { DOCTOR_STATUS, summarizeDoctor } from "./project-doctor-lib.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const checks = []; +const record = (name, status, measurement, suggestion) => + checks.push({ name, status, measurement, ...(suggestion ? { suggestion } : {}) }); + +const manifest = JSON.parse(await readFile(path.join(root, "package.json"), "utf8")); +const requiredNodeMajor = Number(String(manifest.engines?.node ?? "").match(/(\d+)/)?.[1]); +const currentNodeMajor = Number(process.versions.node.split(".")[0]); +record( + "node-runtime", + currentNodeMajor >= requiredNodeMajor ? DOCTOR_STATUS.PASS : DOCTOR_STATUS.FAIL, + "current " + process.versions.node + "; required " + (manifest.engines?.node ?? "unknown"), + currentNodeMajor < requiredNodeMajor ? "Install a supported Node.js runtime." : undefined +); + +try { + const version = execFileSync("git", ["--version"], { cwd: root, encoding: "utf8" }).trim(); + record("git-runtime", DOCTOR_STATUS.PASS, version); +} catch (error) { + const inaccessible = error.code === "EPERM"; + record( + "git-runtime", + inaccessible ? DOCTOR_STATUS.UNKNOWN : DOCTOR_STATUS.FAIL, + error.message, + inaccessible + ? "The environment blocked the Git probe; unknown is not a pass." + : "Install Git and make it available on PATH." + ); +} + +try { + await access(path.join(root, "package-lock.json")); + record("dependency-lock", DOCTOR_STATUS.PASS, "package-lock.json present"); +} catch { + record("dependency-lock", DOCTOR_STATUS.FAIL, "package-lock.json missing", "Regenerate and review the npm lockfile."); +} + +for (const workspaceName of ["examples/minimal", "examples/m1"]) { + try { + const result = await validateWorkspace(path.join(root, workspaceName)); + const recordCount = Object.values(result.counts).reduce((total, count) => total + count, 0); + record( + "workspace:" + workspaceName, + result.valid ? DOCTOR_STATUS.PASS : DOCTOR_STATUS.FAIL, + result.valid ? recordCount + " validated records" : result.issues.length + " validation issue(s)", + result.valid ? undefined : "Run npm run validate for issue details." + ); + } catch (error) { + record( + "workspace:" + workspaceName, + DOCTOR_STATUS.UNKNOWN, + error.message, + "Inspect the workspace manually; unknown is not a pass." + ); + } +} + +try { + const status = execFileSync("git", ["status", "--porcelain"], { cwd: root, encoding: "utf8" }).trim(); + const changed = status ? status.split(/\r?\n/).length : 0; + record( + "worktree", + changed === 0 ? DOCTOR_STATUS.PASS : DOCTOR_STATUS.WARN, + changed === 0 ? "clean" : changed + " changed path(s)", + changed === 0 ? undefined : "Review changes before dependency updates, releases, or publication." + ); +} catch (error) { + record( + "worktree", + DOCTOR_STATUS.UNKNOWN, + error.message, + "Inspect Git status manually; unknown is not a pass." + ); +} + +console.log("Project Relay local doctor"); +for (const check of checks) { + console.log("- [" + check.status + "] " + check.name + ": " + check.measurement); + if (check.suggestion) console.log(" " + check.suggestion); +} +const summary = summarizeDoctor(checks); +console.log( + "Summary: " + + summary.counts.PASS + + " pass, " + + summary.counts.WARN + + " warn, " + + summary.counts.FAIL + + " fail, " + + summary.counts.UNKNOWN + + " unknown." +); +process.exitCode = summary.exitCode; diff --git a/scripts/validate-repository.mjs b/scripts/validate-repository.mjs index e529e62..950e896 100644 --- a/scripts/validate-repository.mjs +++ b/scripts/validate-repository.mjs @@ -2,14 +2,19 @@ import path from "node:path"; import { validateWorkspace } from "@project-relay/protocol"; -const workspace = path.resolve(process.argv[2] ?? "."); -const result = await validateWorkspace(workspace); - -if (result.valid) { - console.log(`Relay workspace is valid: ${workspace}`); - console.log(JSON.stringify(result.counts, null, 2)); -} else { - console.error(`Relay workspace is invalid: ${workspace}`); - console.error(JSON.stringify(result.issues, null, 2)); - process.exitCode = 1; +const workspaces = process.argv.slice(2); +if (workspaces.length === 0) workspaces.push("."); + +for (const workspaceArgument of workspaces) { + const workspace = path.resolve(workspaceArgument); + const result = await validateWorkspace(workspace); + + if (result.valid) { + console.log(`Relay workspace is valid: ${workspace}`); + console.log(JSON.stringify(result.counts, null, 2)); + } else { + console.error(`Relay workspace is invalid: ${workspace}`); + console.error(JSON.stringify(result.issues, null, 2)); + process.exitCode = 1; + } } diff --git a/test/mcp.test.js b/test/mcp.test.js new file mode 100644 index 0000000..f9eef43 --- /dev/null +++ b/test/mcp.test.js @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; + +test("MCP exposes the M1 task resource and bounded review prompt", async () => { + const client = new Client({ name: "project-relay-test", version: "0.1.0" }); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [path.resolve("apps/mcp-server/src/index.js")], + cwd: process.cwd(), + env: { ...process.env, RELAY_WORKSPACE: path.resolve("examples/m1") }, + stderr: "pipe" + }); + + try { + await client.connect(transport); + const templates = await client.listResourceTemplates(); + assert.ok( + templates.resourceTemplates.some( + (template) => template.uriTemplate === "relay://tasks/{id}" + ) + ); + + const resource = await client.readResource({ uri: "relay://tasks/RELAY-1001" }); + const packet = JSON.parse(resource.contents[0].text); + assert.equal(packet.policy.derived_state, "accepted"); + assert.equal(packet.policy.valid, true); + + const prompts = await client.listPrompts(); + assert.ok(prompts.prompts.some((prompt) => prompt.name === "relay_review_task")); + const prompt = await client.getPrompt({ + name: "relay_review_task", + arguments: { taskId: "RELAY-1001" } + }); + assert.match(prompt.messages[0].content.text, /Do not make or imply the final human decision/); + + const prepared = await client.callTool({ + name: "relay_prepare_event", + arguments: { + taskId: "RELAY-1001", + sequence: 13, + type: "record.superseded", + actor: { + id: "service:relay-test", + type: "service", + role: "fixture", + capabilities: ["relay.event.prepare"] + }, + previousEventHash: "a".repeat(64), + causalLinks: [ + { + relation: "supersedes", + targetEventId: "EVT-m1event00011", + note: "Synthetic MCP coverage" + } + ], + payload: {} + } + }); + assert.equal(prepared.structuredContent.validation.valid, true); + assert.deepEqual(prepared.structuredContent.event.actor.capabilities, ["relay.event.prepare"]); + assert.equal( + prepared.structuredContent.event.causal_links[0].target_event_id, + "EVT-m1event00011" + ); + } finally { + await client.close(); + } +}); diff --git a/test/protocol.test.js b/test/protocol.test.js index 2236794..d2387b4 100644 --- a/test/protocol.test.js +++ b/test/protocol.test.js @@ -5,12 +5,16 @@ import test from "node:test"; import { canonicalJson, createRegistry, + evaluateTaskPolicy, eventDigest, + readTaskPacket, sha256Canonical, validateWorkspace, verifyEventChain } from "@project-relay/protocol"; import { classifyUpdate } from "../scripts/version-doctor-lib.mjs"; +import { DOCTOR_STATUS, summarizeDoctor } from "../scripts/project-doctor-lib.mjs"; +import { buildEvidenceBundle } from "../scripts/evidence-bundle-lib.mjs"; test("canonical JSON is stable across object-key insertion order", () => { const left = { beta: [3, 2, 1], alpha: { yes: true, no: false } }; @@ -50,11 +54,114 @@ test("event-chain verification detects payload tampering", () => { assert.equal(verifyEventChain([tampered]).valid, false); }); +test("event chain accepts backward causal links and rejects forward links", () => { + const first = { + schema_version: "0.1.0", + id: "EVT-causal0001", + task_id: "RELAY-0001", + sequence: 1, + type: "task.created", + actor: { id: "human:tester", type: "human", role: "reviewer" }, + occurred_at: "2026-07-19T00:00:00Z", + previous_event_hash: null, + payload: { state: "draft" }, + event_hash: "" + }; + first.event_hash = eventDigest(first); + const second = { + ...structuredClone(first), + id: "EVT-causal0002", + sequence: 2, + type: "task.ready", + occurred_at: "2026-07-19T00:01:00Z", + previous_event_hash: first.event_hash, + causal_links: [{ relation: "caused-by", target_event_id: first.id }], + payload: { from_state: "draft", to_state: "ready" }, + event_hash: "" + }; + second.event_hash = eventDigest(second); + assert.equal(verifyEventChain([first, second]).valid, true); + + const forward = structuredClone(first); + forward.causal_links = [{ relation: "caused-by", target_event_id: second.id }]; + forward.event_hash = eventDigest(forward); + assert.equal(verifyEventChain([forward, second]).valid, false); +}); + +test("actor capability declarations are schema validated", async () => { + const registry = await createRegistry(); + const packet = await readTaskPacket(path.resolve("examples/m1"), "RELAY-1001"); + const task = structuredClone(packet.task); + task.owner.capabilities = ["relay.task.submit", "evidence.bundle.create"]; + assert.equal(registry.validate("task", task).valid, true); + task.owner.capabilities = ["Invalid Capability"]; + assert.equal(registry.validate("task", task).valid, false); +}); + test("synthetic example workspace satisfies schemas and chain rules", async () => { const result = await validateWorkspace(path.resolve("examples/minimal")); assert.equal(result.valid, true, JSON.stringify(result.issues, null, 2)); }); +test("M1 remediation workspace satisfies lifecycle and default policy gates", async () => { + const result = await validateWorkspace(path.resolve("examples/m1")); + assert.equal(result.valid, true, JSON.stringify(result.issues, null, 2)); + assert.deepEqual(result.counts, { + task: 1, + event: 12, + evidence: 2, + review: 2, + decision: 1 + }); + assert.equal(result.policy["RELAY-1001"].derived_state, "accepted"); + assert.ok(Object.values(result.policy["RELAY-1001"].gates).every(Boolean)); +}); + +test("policy rejects submission and acceptance when evidence is absent", async () => { + const packet = await readTaskPacket(path.resolve("examples/m1"), "RELAY-1001"); + const result = evaluateTaskPolicy({ ...packet, evidence: [] }); + assert.equal(result.valid, false); + assert.ok(result.issues.some((issue) => issue.code === "gate.evidence_unavailable")); + assert.ok(result.issues.some((issue) => issue.code === "review.evidence_missing")); + assert.ok(result.issues.some((issue) => issue.code === "decision.evidence_missing")); +}); + +test("policy rejects an owner presented as an independent reviewer", async () => { + const packet = await readTaskPacket(path.resolve("examples/m1"), "RELAY-1001"); + const altered = structuredClone(packet); + altered.reviews[1].reviewer = structuredClone(altered.task.owner); + const result = evaluateTaskPolicy(altered); + assert.equal(result.valid, false); + assert.ok(result.issues.some((issue) => issue.code === "review.self_review")); + assert.ok(result.issues.some((issue) => issue.code === "gate.independent_review_required")); +}); + +test("policy rejects a transition that skips required lifecycle states", async () => { + const packet = await readTaskPacket(path.resolve("examples/m1"), "RELAY-1001"); + const altered = structuredClone(packet); + altered.events[1].type = "task.submitted"; + altered.events[1].payload = { + from_state: "draft", + to_state: "submitted", + evidence_ids: ["EVD-m1initial01"] + }; + const result = evaluateTaskPolicy(altered); + assert.equal(result.valid, false); + assert.ok(result.issues.some((issue) => issue.code === "transition.not_allowed")); +}); + +test("evidence builder hashes declared artifacts without executing commands", async () => { + const { bundle, validation } = await buildEvidenceBundle( + path.resolve("examples/m1/manifests/remediated-evidence.json") + ); + assert.equal(validation.valid, true, JSON.stringify(validation.errors, null, 2)); + assert.equal( + bundle.artifacts[0].sha256, + "06a671f90d9687c3d7eac777f41b3d2e6fbfd05a92c481b42b967869609c3f5a" + ); + assert.equal(bundle.commands[0], "node scripts/validate-repository.mjs examples/m1"); +}); + test("version doctor recommends compatible stable updates", () => { assert.deepEqual(classifyUpdate("4.4.3", "4.5.0"), { advisable: true, @@ -71,3 +178,67 @@ test("version doctor quarantines risky update classes", () => { assert.equal(classifyUpdate("0.6.2", "0.7.0").advisable, false); assert.equal(classifyUpdate("4.4.3", "4.5.0-beta.1").advisable, false); }); + +test("local doctor never treats unknown as healthy", () => { + const summary = summarizeDoctor([ + { status: DOCTOR_STATUS.PASS }, + { status: DOCTOR_STATUS.UNKNOWN } + ]); + assert.equal(summary.healthy, false); + assert.equal(summary.exitCode, 1); + assert.equal(summary.counts.UNKNOWN, 1); +}); + +test("reproduced evidence requires an explicit source bundle", async () => { + const registry = await createRegistry(); + const packet = await readTaskPacket(path.resolve("examples/m1"), "RELAY-1001"); + const reproduction = structuredClone(packet.evidence[1]); + reproduction.id = "EVD-reproduce001"; + reproduction.status = "reproduced"; + assert.equal(registry.validate("evidence", reproduction).valid, false); + reproduction.reproduction_of = packet.evidence[1].id; + assert.equal(registry.validate("evidence", reproduction).valid, true); +}); + +test("high-risk acceptance requires reviewed independent reproduction evidence", async () => { + const packet = await readTaskPacket(path.resolve("examples/m1"), "RELAY-1001"); + const missing = structuredClone(packet); + missing.task.risk = "high"; + const rejected = evaluateTaskPolicy(missing); + assert.equal(rejected.valid, false); + assert.ok( + rejected.issues.some((issue) => issue.code === "gate.independent_reproduction_required") + ); + + const complete = structuredClone(missing); + const reproduction = structuredClone(complete.evidence[1]); + reproduction.id = "EVD-reproduce001"; + reproduction.producer = { + id: "human:reproducer", + type: "human", + role: "independent reproducer" + }; + reproduction.created_at = "2026-07-19T01:11:30Z"; + reproduction.status = "reproduced"; + reproduction.reproduction_of = complete.evidence[1].id; + complete.evidence.push(reproduction); + complete.reviews[1].evidence_ids.push(reproduction.id); + complete.decisions[0].evidence_hashes.push(sha256Canonical(reproduction)); + + const accepted = evaluateTaskPolicy(complete); + assert.equal(accepted.valid, true, JSON.stringify(accepted.issues, null, 2)); + assert.equal(accepted.gates.independent_reproduction, true); +}); + +test("independent reviewers cannot review evidence they produced", async () => { + const packet = await readTaskPacket(path.resolve("examples/m1"), "RELAY-1001"); + const altered = structuredClone(packet); + altered.evidence[1].producer = { + id: "human:auditor", + type: "human", + role: "evidence producer" + }; + const result = evaluateTaskPolicy(altered); + assert.equal(result.valid, false); + assert.ok(result.issues.some((issue) => issue.code === "review.own_evidence")); +});