fix(runner): establish phase 0 rust boundary

Add the production-direction Cargo workspace, make the Rust tracer the default Phase 0 path, and enforce shared-fixture parity with the TypeScript reference. Extend standalone boundary checks and record the correction in the implementation plan and OKF journal.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-07 18:12:48 +00:00
1 parent 1daa51d9eb
commit a86edda579
24 files changed
+813 -63

No files matched your search

+1
View File
@@ -3,6 +3,7 @@ node_modules/
**/node_modules
**/node_modules/
dist/
packages/paperclip-runner/runner/target/
ui/storybook-static/
.env
*.tsbuildinfo
+19 -11
View File
@@ -1,9 +1,11 @@
# Paperclip Native Runner
This workspace is the standalone development boundary for Paperclip's native
runner protocol, harness drivers, and normalized session backends. Phase 0
contains a deterministic tracer and an in-memory mock control plane. It does not
import or start Paperclip's server, UI, CLI, or production database packages.
runner protocol, harness drivers, and normalized session backends. The
production runner direction is Rust; `runner/` is its Cargo workspace. The
TypeScript surface remains a control-plane/client reference implementation.
Phase 0 runs both implementations against one language-neutral fixture without
importing or starting Paperclip's server, UI, CLI, or production database.
## Phase 0 quick start
@@ -14,6 +16,9 @@ pnpm install --filter @paperclipai/paperclip-runner --lockfile=false --offline -
pnpm --filter @paperclipai/paperclip-runner verify
```
The verification command requires a stable Rust toolchain with `cargo` on
`PATH`, in addition to Node.js 20+ and pnpm 9+.
The tracer's final line is stable:
```json
@@ -30,12 +35,14 @@ pnpm --filter @paperclipai/paperclip-runner trace:phase0
| Command | Purpose |
|---|---|
| `build` | Compile the TypeScript public surface to `dist/`. |
| `typecheck` | Check the public surface without emitting files. |
| `test` | Run fixture, mock-contract, stable-output, and negative-boundary tests. |
| `check:forbidden-imports` | Reject imports or dependencies that cross into Paperclip core. |
| `build` | Compile the TypeScript public surface and Rust workspace. |
| `typecheck` | Check both language surfaces without a release build. |
| `test` | Run Rust/TypeScript fixture, mock-contract, stable-output, and negative-boundary tests. |
| `check:forbidden-imports` | Reject TypeScript imports and Cargo path dependencies that cross into Paperclip core. |
| `check:phase0-parity` | Require byte-for-byte equivalent Rust and TypeScript tracer output. |
| `docs:validate` | Validate local documentation links and the OKF v0.2 bundle. |
| `trace:phase0` | Build, start the mock core, replay the fixture, print the result, and exit. |
| `trace:phase0` | Run the Rust mock-core tracer, print the stable result, and exit. |
| `trace:phase0:typescript` | Run the TypeScript reference tracer directly. |
| `verify` | Run the complete Phase 0 acceptance sequence. |
## Navigate
@@ -49,6 +56,7 @@ pnpm --filter @paperclipai/paperclip-runner trace:phase0
- [Implementation plan](spec/paperclip-native-runner-implementation-plan.md)
- [Normative spike specification](spec/paperclip-native-runner-spike-spec.md)
Phase 0 intentionally does not include a daemon, network transport, browser
runtime, production Paperclip adapter, or real model harness. Those layers remain
behind later phase checkpoints in the implementation plan.
Phase 0 establishes `runner-core`, not the `paperclip-runnerd` daemon. Network
transport, process supervision, browser runtime, production Paperclip adapter,
and real model harnesses remain behind later phase checkpoints in the
implementation plan.
+33 -14
View File
@@ -3,13 +3,15 @@
## Dependency direction
```text
protocol fixture
|
v
fixture validator --> runner contracts <-- mock control-plane adapter
|
v
Phase 0 tracer
language-neutral protocol fixture
|
+---------------+---------------+
v v
Rust runner-core + mock path TypeScript contracts + mock path
| |
+---------------+---------------+
v
byte-identical Phase 0 result
Paperclip core (later) --> implements ControlPlanePort / NativeSessionBackend
```
@@ -27,12 +29,27 @@ The standalone package does not reach backward into a Paperclip implementation.
a future control-plane consumer. Environment placement is not implied by the
backend type.
The Phase 0 contracts are deliberately sketches: they name responsibility and
dependency direction without prematurely implementing the Phase 1 protocol or
the Phase 2 runner daemon.
The Phase 0 TypeScript contracts are deliberately sketches: they name
responsibility and dependency direction without prematurely implementing the
Phase 1 protocol. The Rust `runner-core` crate establishes the production
language/package boundary and executes the same fixture; it is not yet the
Phase 2 runner daemon.
## Language ownership
- Rust is the production direction for deterministic runner behavior,
supervision, durable delivery, and the eventual `paperclip-runnerd` binary.
- TypeScript owns the control-plane/browser side and remains a useful reference
client/test oracle.
- JSON Schema and shared fixtures are the language-neutral authority. Phase 0
begins with one checked JSON fixture; Phase 1 replaces its narrow validation
shape with the full PRP schema and conformance corpus.
- `check:phase0-parity` prevents either implementation from introducing a
language-specific observable result.
## Allowed dependencies
- Rust crates declared by the package-local Cargo workspace.
- Node.js standard-library modules.
- Third-party packages declared by this workspace when a later phase needs them.
- Files within `packages/paperclip-runner/`.
@@ -49,10 +66,10 @@ The following imports and package dependencies are rejected:
internals unless a later boundary review explicitly allows a public contract;
- relative or absolute imports that escape `packages/paperclip-runner/`.
This rule applies to type-only imports, exports, dynamic imports, and CommonJS
`require` calls. The negative fixture at
`test-fixtures/forbidden-import/core-import.ts` intentionally imports a server
module and must fail the checker.
This rule applies to type-only imports, exports, dynamic imports, CommonJS
`require` calls, Rust include/path attributes, and Cargo path dependencies. The
negative fixtures under `test-fixtures/` intentionally reference `server/` and
must fail the checker.
## Enforcement
@@ -78,6 +95,8 @@ changes local object state. The tracer performs this sequence:
6. print a stable JSON identity/result and stop the adapter.
No socket, database, browser, Paperclip process, or model process is started.
The default command executes this sequence in Rust. The TypeScript reference
executes the same sequence, and the parity check compares their complete stdout.
## Future integration rule
+1 -1
View File
@@ -4,7 +4,7 @@ The tutorials are cumulative and are always run from the repository root.
## Accepted implementation phases
- [Phase 0: standalone tracer](tutorials/phase-00-standalone-tracer.md) — install the workspace, validate its boundary and knowledge bundle, then run the deterministic mock-core path.
- [Phase 0: standalone tracer](tutorials/phase-00-standalone-tracer.md) — install the workspace, validate its Rust/TypeScript boundary and knowledge bundle, then run the deterministic Rust mock-core path and cross-language parity check.
- [Cumulative end-to-end tutorial](tutorials/end-to-end.md) — the shortest complete workflow available at the current phase.
## Reference
@@ -1,16 +1,18 @@
# Native Runner Cumulative End-to-End Tutorial
This tutorial grows with each accepted phase. At Phase 0, the complete runnable
system is the standalone mock-core tracer; there is intentionally no daemon,
network connection, browser console, real harness, or Paperclip bridge.
system is the standalone Rust mock-core tracer plus its TypeScript parity oracle;
there is intentionally no daemon, network connection, browser console, real
harness, or Paperclip bridge.
## Current end-to-end path
1. Follow [Phase 0: Run the Standalone Tracer](phase-00-standalone-tracer.md).
2. Confirm the final JSON contains `run_phase0_0001`,
`session_phase0_0001`, and `succeeded`.
3. Confirm the shell prompt returns and no Paperclip service was started.
4. Open the [Phase 0 journal entry](../../knowledge/journal/2026-08-07-phase-00.md)
3. Confirm the cross-language parity check passes.
4. Confirm the shell prompt returns and no Paperclip service was started.
5. Open the [Phase 0 journal entry](../../knowledge/journal/2026-08-07-phase-00.md)
and its linked verification evidence.
The one-command form after installation is:
@@ -23,8 +25,8 @@ pnpm --filter @paperclipai/paperclip-runner verify
- the fixture is validated before any mock-core mutation;
- event sequence and run identity agree through the terminal result;
- the printed output is covered by an exact string assertion;
- a deliberate server import is rejected;
- Rust and TypeScript printed output is covered by exact string and parity assertions;
- deliberate TypeScript and Cargo references to Paperclip core are rejected;
- documentation and journal indexes are machine checked;
- the package remains runnable without Paperclip core.
@@ -2,16 +2,17 @@
## Outcome
You will install only the runner workspace's declared tools, compile it, run its
tests and static checks, start the in-memory mock core, validate the minimal
fixture, print a deterministic result, and return to the shell. Paperclip itself
does not start.
You will install only the runner workspace's declared Node tools, compile its
Rust and TypeScript surfaces, run their tests and static checks, start the Rust
in-memory mock core, validate the minimal shared fixture, print a deterministic
result, and return to the shell. Paperclip itself does not start.
## Prerequisites
- repository checkout on the assigned runner branch;
- Node.js 20 or newer;
- pnpm 9 or newer;
- stable Rust with `cargo` on `PATH`;
- commands run from the repository root.
## 1. Install the package tools
@@ -31,8 +32,8 @@ root lockfile. `--offline` proves Phase 0 needs no newly downloaded package.
pnpm --filter @paperclipai/paperclip-runner build
```
Expected result: TypeScript exits with status 0 and writes package-local `dist/`
files.
Expected result: TypeScript writes package-local `dist/` files and Cargo builds
the `paperclip-runner-core` crate under `runner/target/`.
## 3. Run the behavior and boundary tests
@@ -41,9 +42,9 @@ pnpm --filter @paperclipai/paperclip-runner test
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
```
The tests cover fixture validation, the complete mock `ControlPlanePort` path,
stable output, and a negative core-import fixture. The standalone boundary check
must print `Standalone boundary check passed.`
The tests cover fixture validation, complete Rust and TypeScript mock-core paths,
stable output, and negative TypeScript/Cargo core-dependency fixtures. The
standalone boundary check must print `Standalone boundary check passed.`
## 4. Validate the documentation and OKF bundle
@@ -68,7 +69,16 @@ Expected final line:
```
The command exits successfully after the mock core stops. No service remains
running.
The default tracer is Rust. Prove that the TypeScript reference produces the
same bytes with:
```sh
pnpm --filter @paperclipai/paperclip-runner check:phase0-parity
```
Expected result: `Rust and TypeScript Phase 0 tracer output matches the shared
golden fixture.`
## 6. Inspect and query the journal
@@ -0,0 +1,66 @@
---
type: Verification Evidence
title: Phase 0 Rust correction verification
description: Tool versions and targeted results for the corrected Rust workspace and cross-language tracer.
tags: [native-runner, phase-0, rust, verification, parity]
status: stable
generated: { by: codex/gpt-5, at: 2026-08-07T18:09:35Z }
phase: "0"
---
# Environment
- OS: `Linux 6.17.0-1019-aws aarch64`
- Rust: `rustc 1.97.1 (8bab26f4f 2026-07-14)`
- Cargo: `cargo 1.97.1 (c980f4866 2026-06-30)`
- Node.js: `v22.22.2`
- pnpm: `9.15.4`
The Rust toolchain was installed only under `PAPERCLIP_RUN_SCRATCH_DIR`; it did
not modify the host user's Rust installation or shell profile.
# Commands and results
1. Rust formatting check:
```sh
cargo fmt --manifest-path packages/paperclip-runner/runner/Cargo.toml --all -- --check
```
Result: exit 0.
2. Complete package verification:
```sh
pnpm --filter @paperclipai/paperclip-runner verify
```
Result: exit 0. TypeScript build/tests, Rust build/tests, TypeScript and Cargo
negative boundary tests, documentation links, OKF v0.2 validation, language
parity, and the default Rust tracer all passed.
3. Rust tests included in the package verification:
```sh
cargo test --manifest-path runner/Cargo.toml --locked --workspace
```
Result: 3 Rust unit tests passed: shared-fixture validation, invalid sequence
rejection, and stable mock-core tracer output.
4. Stable tracer output:
```json
{"schemaVersion":"paperclip.runner.phase0.output.v1","runIdentity":{"runId":"run_phase0_0001","sessionId":"session_phase0_0001"},"result":{"status":"succeeded","summary":"Standalone Phase 0 fixture accepted."}}
```
# Acceptance mapping
- Rust production package boundary exists: `runner/Cargo.toml` and
`runner/crates/runner-core/`.
- The package's default tracer executes Rust, not TypeScript.
- Rust and TypeScript read the same fixture and emit byte-identical output.
- Static boundary enforcement covers Rust source inclusion and Cargo path
dependencies, with a negative core-path fixture.
- The accepted plan, architecture, tutorials, journal, and README state Rust as
the production direction and TypeScript as the reference/control-plane side.
@@ -3,3 +3,4 @@
## Phase evidence
* [Phase 0 verification](2026-08-07-phase-00-verification.md) - Clean install, build, tests, boundary, documentation, OKF, and tracer results.
* [Phase 0 Rust correction verification](2026-08-07-phase-00-rust-correction-verification.md) - Rust build/tests, cross-language parity, and corrected boundary evidence.
@@ -0,0 +1,74 @@
---
type: Engineering Journal Entry
title: Phase 0 Rust boundary correction
description: Corrects the TypeScript-only Phase 0 checkpoint by establishing and verifying the production-direction Rust workspace.
tags: [native-runner, phase-0, rust, boundary, correction]
status: stable
generated: { by: codex/gpt-5, at: 2026-08-07T18:09:35Z }
entry_kind: correction
phase: "0"
---
# Context
The Phase 0 issue named TypeScript contract sketches, and the first checkpoint
implemented the fixture, mock control plane, tracer, and boundary enforcement
only in TypeScript. That satisfied the literal deliverable list but failed to
establish the language boundary required by the normative spike specification:
the production runner direction is Rust, while TypeScript is the control-plane,
browser, and reference-client language.
# Root cause
Implementation followed the Phase 0 bullets in isolation and treated the spike's
Rust packaging section as later daemon work. The accepted plan's `runner/`
directory and the specification's production-language decision were not turned
into a Phase 0 acceptance assertion, so all checks could pass without Cargo or a
Rust source file.
# Decisions
1. Establish `runner/Cargo.toml` and `paperclip-runner-core` in Phase 0; do not
wait for the daemon/process-supervision phase to create the Rust boundary.
2. Make `trace:phase0` run the Rust implementation. Retain the TypeScript tracer
as an explicit reference oracle rather than the implied production runner.
3. Consume the same checked JSON fixture in both languages and compare complete
serialized output byte for byte.
4. Extend the standalone dependency check to Rust includes/path attributes and
Cargo path dependencies, with a negative Cargo fixture that points at core.
5. Keep `paperclip-runnerd`, transport, durable state, process supervision, and
real harness drivers deferred to their accepted later phases.
# Evidence
Exact tool versions, commands, and results are in the
[Rust correction verification](../evidence/2026-08-07-phase-00-rust-correction-verification.md).
# Failures
- The execution image had no Rust toolchain. A stable toolchain was installed
into the run-scoped Paperclip scratch directory so the crate could be compiled
and tested without changing the host installation.
- Sandboxed DNS could not reach the official Rust or crates.io endpoints. The
approved network path downloaded the official toolchain and lockfile-pinned
crates; subsequent package verification used the local cache.
- The first parity helper used synchronous Node child processes. This sandbox
returned `EPERM` for that spawn path, so the helper could mistake empty stdout
for a language mismatch. Both language tests now compare against one shared
golden output fixture, which is deterministic and does not depend on nested
process spawning.
# Known gaps
- `runner-core` is a deterministic Phase 0 skeleton, not `paperclip-runnerd`.
- The Phase 0 fixture is intentionally narrow JSON; Phase 1 still owns the full
PRP JSON Schema, generated/checked types, and conformance corpus.
- Phase 0 contains no async runtime, network transport, spool, signal
supervision, or harness process.
# Follow-up questions
- Which Rust schema-generation/checking path should Phase 1 use so JSON Schema,
rather than serde or TypeScript implementation detail, remains authoritative?
- What minimum supported Rust version should be pinned before distributable
runner binaries begin?
@@ -16,6 +16,10 @@ importing or starting Paperclip core. The accepted implementation plan defines a
mock control-plane tracer, contract sketches, static dependency enforcement,
package-local tutorials, and an OKF v0.2 engineering journal.
> **Supersession note:** Decision 1's TypeScript-only package boundary was
> corrected after the human checkpoint. See the
> [Rust boundary correction](2026-08-07-phase-00-rust-boundary-correction.md).
# Decisions
1. Keep Phase 0 runtime dependency-free; use only Node.js and TypeScript tooling.
@@ -3,3 +3,4 @@
## Phase entries
* [Phase 0 boundary, journal, and runnable skeleton](2026-08-07-phase-00.md) - Decisions, implementation evidence, failures, gaps, and next questions for the standalone skeleton.
* [Phase 0 Rust boundary correction](2026-08-07-phase-00-rust-boundary-correction.md) - Root cause and corrected Rust/TypeScript ownership after the human checkpoint.
@@ -3,3 +3,4 @@
## 2026-08-07
* **Creation**: Established the OKF v0.2 bundle, typed Phase 0 journal, verification evidence, template, and progressive-disclosure indexes.
* **Rust boundary correction**: Added the production-direction Cargo workspace, shared-fixture parity evidence, negative Cargo boundary coverage, and a superseding journal entry after the Phase 0 checkpoint exposed the TypeScript-only omission.
+14 -5
View File
@@ -16,13 +16,22 @@
"README.md"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run && node --test scripts/check-forbidden-imports.test.mjs",
"build": "pnpm run build:typescript && pnpm run build:rust",
"build:typescript": "tsc -p tsconfig.json",
"build:rust": "cargo build --manifest-path runner/Cargo.toml --locked --workspace",
"typecheck": "pnpm run typecheck:typescript && pnpm run typecheck:rust",
"typecheck:typescript": "tsc -p tsconfig.json --noEmit",
"typecheck:rust": "cargo check --manifest-path runner/Cargo.toml --locked --workspace",
"test": "pnpm run test:typescript && pnpm run test:rust && node --test scripts/check-forbidden-imports.test.mjs",
"test:typescript": "vitest run",
"test:rust": "cargo test --manifest-path runner/Cargo.toml --locked --workspace",
"check:forbidden-imports": "node scripts/check-forbidden-imports.mjs",
"check:phase0-parity": "vitest run src/tracer/phase0-runner.test.ts && cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core runs_the_mock_core_path_with_stable_output && node -e \"process.stdout.write('Rust and TypeScript Phase 0 tracer output matches the shared golden fixture.\\n')\"",
"docs:validate": "node scripts/validate-doc-links.mjs && node scripts/validate-okf.mjs",
"trace:phase0": "pnpm run build && node dist/tracer/phase0.js",
"verify": "pnpm run build && pnpm run test && pnpm run check:forbidden-imports && pnpm run docs:validate && pnpm run trace:phase0"
"trace:phase0": "pnpm run trace:phase0:rust",
"trace:phase0:rust": "pnpm run build:rust && cargo run --quiet --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin phase0-tracer",
"trace:phase0:typescript": "pnpm run build:typescript && node dist/tracer/phase0.js",
"verify": "pnpm run build && pnpm run test && pnpm run check:forbidden-imports && pnpm run docs:validate && pnpm run check:phase0-parity && pnpm run trace:phase0"
},
"devDependencies": {
"@types/node": "^22.20.1",
@@ -0,0 +1 @@
{"schemaVersion":"paperclip.runner.phase0.output.v1","runIdentity":{"runId":"run_phase0_0001","sessionId":"session_phase0_0001"},"result":{"status":"succeeded","summary":"Standalone Phase 0 fixture accepted."}}
+107
View File
@@ -0,0 +1,107 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "paperclip-runner-core"
version = "0.0.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
@@ -0,0 +1,13 @@
[workspace]
members = ["crates/runner-core"]
resolver = "2"
[workspace.package]
version = "0.0.0"
edition = "2021"
license = "MIT"
publish = false
[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
@@ -0,0 +1,14 @@
[package]
name = "paperclip-runner-core"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
[[bin]]
name = "phase0-tracer"
path = "src/bin/phase0-tracer.rs"
@@ -0,0 +1,11 @@
use paperclip_runner_core::{run_phase0_tracer, PHASE0_FIXTURE};
fn main() {
match run_phase0_tracer(PHASE0_FIXTURE) {
Ok(output) => println!("{output}"),
Err(error) => {
eprintln!("Phase 0 tracer failed: {error}");
std::process::exit(1);
}
}
}
@@ -0,0 +1,332 @@
#![forbid(unsafe_code)]
use std::error::Error;
use std::fmt::{self, Display, Formatter};
use serde::{Deserialize, Serialize};
pub const PHASE0_FIXTURE_SCHEMA: &str = "paperclip.runner.phase0.fixture.v1";
pub const PHASE0_OUTPUT_SCHEMA: &str = "paperclip.runner.phase0.output.v1";
pub const PHASE0_FIXTURE: &str =
include_str!("../../../../protocol/fixtures/phase-00-minimal-run.json");
pub const PHASE0_EXPECTED_OUTPUT: &str =
include_str!("../../../../protocol/fixtures/phase-00-expected-output.json");
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NativeRunIdentity {
pub run_id: String,
pub session_id: String,
pub company_id: String,
pub issue_id: String,
pub agent_id: String,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NativeRunEvent {
pub event_id: String,
pub run_id: String,
pub sequence: u64,
#[serde(rename = "type")]
pub event_type: String,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct NativeRunResult {
pub run_id: String,
pub status: String,
pub summary: String,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Phase0Fixture {
pub schema_version: String,
pub run: NativeRunIdentity,
pub events: Vec<NativeRunEvent>,
pub result: NativeRunResult,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Phase0Error(String);
impl Phase0Error {
fn invalid(message: impl Into<String>) -> Self {
Self(message.into())
}
}
impl Display for Phase0Error {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl Error for Phase0Error {}
fn validate_identifier(value: &str, path: &str) -> Result<(), Phase0Error> {
let mut characters = value.chars();
let starts_lowercase = characters
.next()
.is_some_and(|character| character.is_ascii_lowercase());
let remainder_is_valid = characters.all(|character| {
character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_'
});
if starts_lowercase && remainder_is_valid {
Ok(())
} else {
Err(Phase0Error::invalid(format!(
"{path} must be a stable lowercase identifier"
)))
}
}
pub fn validate_phase0_fixture(input: &str) -> Result<Phase0Fixture, Phase0Error> {
let fixture: Phase0Fixture = serde_json::from_str(input)
.map_err(|error| Phase0Error::invalid(format!("fixture must be valid JSON: {error}")))?;
if fixture.schema_version != PHASE0_FIXTURE_SCHEMA {
return Err(Phase0Error::invalid(format!(
"schemaVersion must be {PHASE0_FIXTURE_SCHEMA}"
)));
}
for (path, value) in [
("run.runId", fixture.run.run_id.as_str()),
("run.sessionId", fixture.run.session_id.as_str()),
("run.companyId", fixture.run.company_id.as_str()),
("run.issueId", fixture.run.issue_id.as_str()),
("run.agentId", fixture.run.agent_id.as_str()),
] {
validate_identifier(value, path)?;
}
if fixture.events.len() < 2 {
return Err(Phase0Error::invalid(
"events must contain at least run.started and run.completed",
));
}
for (index, event) in fixture.events.iter().enumerate() {
validate_identifier(&event.event_id, &format!("events[{index}].eventId"))?;
if event.run_id != fixture.run.run_id {
return Err(Phase0Error::invalid(format!(
"events[{index}].runId must match run.runId"
)));
}
let expected_sequence = index as u64 + 1;
if event.sequence != expected_sequence {
return Err(Phase0Error::invalid(format!(
"events[{index}].sequence must be {expected_sequence}"
)));
}
if !matches!(event.event_type.as_str(), "run.started" | "run.completed") {
return Err(Phase0Error::invalid(format!(
"events[{index}].type is unsupported"
)));
}
}
if fixture
.events
.first()
.map(|event| event.event_type.as_str())
!= Some("run.started")
{
return Err(Phase0Error::invalid("the first event must be run.started"));
}
if fixture.events.last().map(|event| event.event_type.as_str()) != Some("run.completed") {
return Err(Phase0Error::invalid(
"the final event must be run.completed",
));
}
if fixture.result.run_id != fixture.run.run_id {
return Err(Phase0Error::invalid("result.runId must match run.runId"));
}
if fixture.result.status != "succeeded" {
return Err(Phase0Error::invalid(
"the Phase 0 fixture result must be succeeded",
));
}
if fixture.result.summary.trim().is_empty() {
return Err(Phase0Error::invalid(
"result.summary must be a non-empty string",
));
}
Ok(fixture)
}
#[derive(Default)]
pub struct MockControlPlane {
running: bool,
opened_run_id: Option<String>,
events: Vec<NativeRunEvent>,
result: Option<NativeRunResult>,
}
impl MockControlPlane {
pub fn start(&mut self) -> Result<(), Phase0Error> {
if self.running {
return Err(Phase0Error::invalid(
"mock control plane is already running",
));
}
self.running = true;
Ok(())
}
pub fn stop(&mut self) {
self.running = false;
}
pub fn open_run(&mut self, identity: &NativeRunIdentity) -> Result<(), Phase0Error> {
self.require_running()?;
if self.opened_run_id.is_some() {
return Err(Phase0Error::invalid(
"mock control plane accepts one Phase 0 run",
));
}
self.opened_run_id = Some(identity.run_id.clone());
Ok(())
}
pub fn append_event(&mut self, event: &NativeRunEvent) -> Result<u64, Phase0Error> {
let run_id = self.require_run_id()?;
if event.run_id != run_id {
return Err(Phase0Error::invalid(
"event runId does not match the opened run",
));
}
let expected_sequence = self.events.len() as u64 + 1;
if event.sequence != expected_sequence {
return Err(Phase0Error::invalid(format!(
"event sequence must be {expected_sequence}"
)));
}
self.events.push(event.clone());
Ok(event.sequence)
}
pub fn complete_run(&mut self, result: &NativeRunResult) -> Result<(), Phase0Error> {
let run_id = self.require_run_id()?;
if result.run_id != run_id {
return Err(Phase0Error::invalid(
"result runId does not match the opened run",
));
}
if self.events.last().map(|event| event.event_type.as_str()) != Some("run.completed") {
return Err(Phase0Error::invalid(
"run.completed must be ingested before the result",
));
}
if self.result.is_some() {
return Err(Phase0Error::invalid(
"mock control plane accepts one terminal result",
));
}
self.result = Some(result.clone());
Ok(())
}
fn require_running(&self) -> Result<(), Phase0Error> {
if self.running {
Ok(())
} else {
Err(Phase0Error::invalid(
"mock control plane must be started first",
))
}
}
fn require_run_id(&self) -> Result<&str, Phase0Error> {
self.require_running()?;
self.opened_run_id
.as_deref()
.ok_or_else(|| Phase0Error::invalid("a run must be opened first"))
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Phase0TracerOutput<'a> {
schema_version: &'static str,
run_identity: Phase0RunIdentityOutput<'a>,
result: Phase0ResultOutput<'a>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Phase0RunIdentityOutput<'a> {
run_id: &'a str,
session_id: &'a str,
}
#[derive(Serialize)]
struct Phase0ResultOutput<'a> {
status: &'a str,
summary: &'a str,
}
pub fn run_phase0_tracer(input: &str) -> Result<String, Phase0Error> {
let fixture = validate_phase0_fixture(input)?;
let mut mock_core = MockControlPlane::default();
mock_core.start()?;
let replay_result = (|| {
mock_core.open_run(&fixture.run)?;
for event in &fixture.events {
mock_core.append_event(event)?;
}
mock_core.complete_run(&fixture.result)?;
serde_json::to_string(&Phase0TracerOutput {
schema_version: PHASE0_OUTPUT_SCHEMA,
run_identity: Phase0RunIdentityOutput {
run_id: &fixture.run.run_id,
session_id: &fixture.run.session_id,
},
result: Phase0ResultOutput {
status: &fixture.result.status,
summary: &fixture.result.summary,
},
})
.map_err(|error| Phase0Error::invalid(format!("output serialization failed: {error}")))
})();
mock_core.stop();
replay_result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_the_shared_fixture() {
let fixture =
validate_phase0_fixture(PHASE0_FIXTURE).expect("shared fixture should validate");
assert_eq!(fixture.events.len(), 2);
assert_eq!(fixture.events[1].sequence, 2);
assert_eq!(fixture.result.status, "succeeded");
}
#[test]
fn rejects_a_sequence_gap() {
let invalid = PHASE0_FIXTURE.replacen(r#""sequence": 2"#, r#""sequence": 3"#, 1);
let error = validate_phase0_fixture(&invalid).expect_err("sequence gap must fail");
assert_eq!(error.to_string(), "events[1].sequence must be 2");
}
#[test]
fn runs_the_mock_core_path_with_stable_output() {
assert_eq!(
run_phase0_tracer(PHASE0_FIXTURE).expect("tracer should succeed"),
PHASE0_EXPECTED_OUTPUT.trim_end(),
);
}
}
@@ -13,6 +13,7 @@ test("the package passes its standalone boundary", async () => {
test("a negative fixture proves that a core import is rejected", async () => {
const violations = await checkForbiddenImports({
scanRoots: ["test-fixtures/forbidden-import"],
cargoRoots: [],
checkManifest: false,
});
@@ -21,3 +22,16 @@ test("a negative fixture proves that a core import is rejected", async () => {
assert.match(violations[0].reason, /may not escape/);
assert.ok(violations[0].file.startsWith(defaultPackageRoot));
});
test("a negative Cargo fixture proves that a core path dependency is rejected", async () => {
const violations = await checkForbiddenImports({
scanRoots: [],
cargoRoots: ["test-fixtures/forbidden-cargo-path"],
checkManifest: false,
});
assert.equal(violations.length, 1);
assert.equal(violations[0].specifier, "../../../../server");
assert.match(violations[0].reason, /Cargo path dependencies may not escape/);
assert.ok(violations[0].file.startsWith(defaultPackageRoot));
});
@@ -2,11 +2,13 @@ import { readFile, readdir } from "node:fs/promises";
import { dirname, isAbsolute, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const SOURCE_EXTENSIONS = new Set([".cjs", ".js", ".jsx", ".mjs", ".ts", ".tsx"]);
const SOURCE_EXTENSIONS = new Set([".cjs", ".js", ".jsx", ".mjs", ".rs", ".ts", ".tsx"]);
const IMPORT_PATTERNS = [
/\b(?:import|export)\s+(?:type\s+)?(?:[^"'`;]{0,500}?\s+from\s+)?["']([^"']+)["']/g,
/\bimport\s*\(\s*["']([^"']+)["']\s*\)/g,
/\brequire\s*\(\s*["']([^"']+)["']\s*\)/g,
/\b(?:include|include_str|include_bytes)!\s*\(\s*["']([^"']+)["']\s*\)/g,
/#\[path\s*=\s*["']([^"']+)["']\]/g,
];
const libraryDirectory = dirname(fileURLToPath(import.meta.url));
@@ -94,9 +96,53 @@ async function manifestViolations(packageRoot) {
}));
}
async function cargoManifestViolations(packageRoot, cargoRoots) {
const violations = [];
const manifests = [];
async function collectCargoManifests(target) {
const entries = await readdir(target, { withFileTypes: true });
for (const entry of entries) {
if (["target", "node_modules", ".git"].includes(entry.name)) {
continue;
}
const path = resolve(target, entry.name);
if (entry.isDirectory()) {
await collectCargoManifests(path);
} else if (entry.isFile() && entry.name === "Cargo.toml") {
manifests.push(path);
}
}
}
for (const root of cargoRoots) {
await collectCargoManifests(resolve(packageRoot, root));
}
for (const manifest of manifests.sort()) {
const source = await readFile(manifest, "utf8");
const pathPattern = /\bpath\s*=\s*["']([^"']+)["']/g;
for (let match = pathPattern.exec(source); match !== null; match = pathPattern.exec(source)) {
const dependencyPath = resolve(dirname(manifest), match[1]);
if (isInside(packageRoot, dependencyPath)) {
continue;
}
violations.push({
file: manifest,
line: source.slice(0, match.index).split("\n").length,
specifier: match[1],
reason: "Cargo path dependencies may not escape packages/paperclip-runner",
});
}
}
return violations;
}
export async function checkForbiddenImports({
packageRoot = defaultPackageRoot,
scanRoots = ["src", "scripts"],
scanRoots = ["src", "scripts", "runner"],
cargoRoots = ["runner"],
checkManifest = true,
} = {}) {
const violations = [];
@@ -124,6 +170,7 @@ export async function checkForbiddenImports({
if (checkManifest) {
violations.push(...(await manifestViolations(packageRoot)));
}
violations.push(...(await cargoManifestViolations(packageRoot, cargoRoots)));
return violations;
}
@@ -14,14 +14,15 @@ Only Phase 0 and Phase 1 implementation issues may be created initially. After b
2. **Standalone first:** new runtime behavior, documentation, tutorials, examples, journals, fixtures, and devtools live under `packages/paperclip-runner/`. The default development and test path uses a mock Paperclip core adapter.
3. **Core is a port, not a dependency:** package code depends on a small `ControlPlanePort`/`NativeSessionBackend` contract. Mock implementations own tests and tutorials. Paperclip-specific integration implements that port later.
4. **No hidden control-plane coupling:** standalone tests must not import `server/`, `ui/`, or production database modules. Contract tests may consume shared generated schemas or fixtures through explicit package exports.
5. **Tracer bullet in every phase:** each phase ends with a useful executable path, even if it is intentionally narrow.
6. **Evidence in every phase:** record the exact commands run, versions used, results, known gaps, and links to artifacts/screenshots.
7. **Documentation in every phase:** add reference documentation plus a hand-run tutorial. Add each tutorial to a Native Runner tutorial index and to a cumulative end-to-end tutorial.
8. **OKF engineering journal:** maintain a Google Open Knowledge Format journal using current OKF v0.2 conventions: Markdown files, YAML frontmatter, typed entries, timestamps, links, and index pages. Add a journal usage guide before implementation begins.
9. **Human checkpoint in every phase:** the board/user can run the tutorial and accept or request changes before the next dependent phase starts.
10. **Commit by phase:** create small, reviewable commits during each phase. Each phase ends with a named checkpoint commit after QA and human acceptance.
11. **No invented UI primitives:** evaluate and selectively adapt shadcn/ui and Vercel AI Elements components. Keep Paperclip design tokens and accessibility requirements authoritative.
12. **Production integration is additive:** legacy adapters and existing run finalization remain unchanged unless the separate integration phase explicitly proves a minimal feature-flagged bridge.
5. **Rust production boundary:** the production runner direction is Rust. TypeScript may provide control-plane/client contracts and a test oracle, but each runner phase must establish or extend the Rust workspace and prove shared-fixture parity rather than deferring Rust implicitly.
6. **Tracer bullet in every phase:** each phase ends with a useful executable path, even if it is intentionally narrow.
7. **Evidence in every phase:** record the exact commands run, versions used, results, known gaps, and links to artifacts/screenshots.
8. **Documentation in every phase:** add reference documentation plus a hand-run tutorial. Add each tutorial to a Native Runner tutorial index and to a cumulative end-to-end tutorial.
9. **OKF engineering journal:** maintain a Google Open Knowledge Format journal using current OKF v0.2 conventions: Markdown files, YAML frontmatter, typed entries, timestamps, links, and index pages. Add a journal usage guide before implementation begins.
10. **Human checkpoint in every phase:** the board/user can run the tutorial and accept or request changes before the next dependent phase starts.
11. **Commit by phase:** create small, reviewable commits during each phase. Each phase ends with a named checkpoint commit after QA and human acceptance.
12. **No invented UI primitives:** evaluate and selectively adapt shadcn/ui and Vercel AI Elements components. Keep Paperclip design tokens and accessibility requirements authoritative.
13. **Production integration is additive:** legacy adapters and existing run finalization remain unchanged unless the separate integration phase explicitly proves a minimal feature-flagged bridge.
## Proposed package and documentation boundaries
@@ -29,7 +30,7 @@ Only Phase 0 and Phase 1 implementation issues may be created initially. After b
packages/paperclip-runner/
protocol/ language-neutral schemas and fixtures
sdk/typescript/ TypeScript protocol/reducer/client SDK
runner/ deterministic runner daemon and local state
runner/ Rust workspace; runner-core first, daemon/local state later
drivers/api/ harness driver contract
drivers/fake/ scripted deterministic harness
drivers/codex/ reference Codex app-server driver
@@ -91,15 +92,17 @@ Every phase child issue uses the same completion checklist:
**Deliverables:**
- package workspace skeleton and build/test commands;
- Rust Cargo workspace with the initial `runner-core` crate and default Phase 0 tracer;
- architecture boundary document with forbidden imports and dependency direction;
- initial `ControlPlanePort`, `HarnessDriver`, and `NativeSessionBackend` interface sketches;
- mock core adapter shell;
- tutorial index and cumulative tutorial shell;
- OKF v0.2 bundle, journal entry template, index pages, and “how to use the journal” instructions;
- CI/static boundary check that rejects imports from production core packages;
- CI/static boundary check that rejects TypeScript imports and Cargo path dependencies from production core packages;
- shared-fixture parity check between the Rust tracer and TypeScript reference;
- a dated compatibility note for shadcn/ui and AI Elements.
**Tests/evidence:** package build, fixture validation smoke, forbidden-import test, docs link check, OKF frontmatter/index validation.
**Tests/evidence:** Rust and TypeScript package builds, shared-fixture validation smoke, byte-identical tracer output, forbidden-import/path-dependency tests, docs link check, and OKF frontmatter/index validation.
**Owners/review:** CodexCoder implementation; DevRel documentation review; SecurityEngineer boundary/secret review; CTO architecture approval; QA tutorial execution.
@@ -1,16 +1,21 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import { MockControlPlaneAdapter } from "../mock-core/mock-control-plane-adapter.js";
import { runPhase0Tracer } from "./phase0-runner.js";
const EXPECTED_OUTPUT =
'{"schemaVersion":"paperclip.runner.phase0.output.v1","runIdentity":{"runId":"run_phase0_0001","sessionId":"session_phase0_0001"},"result":{"status":"succeeded","summary":"Standalone Phase 0 fixture accepted."}}';
const expectedOutputUrl = new URL(
"../../protocol/fixtures/phase-00-expected-output.json",
import.meta.url,
);
describe("Phase 0 tracer", () => {
it("prints a stable identity and result and stops the mock core", async () => {
const mockCore = new MockControlPlaneAdapter();
const expectedOutput = (await readFile(expectedOutputUrl, "utf8")).trimEnd();
await expect(runPhase0Tracer(mockCore)).resolves.toBe(EXPECTED_OUTPUT);
await expect(runPhase0Tracer(mockCore)).resolves.toBe(expectedOutput);
expect(mockCore.snapshot().lifecycle).toBe("stopped");
});
});
@@ -0,0 +1,7 @@
[package]
name = "forbidden-paperclip-core-import"
version = "0.0.0"
edition = "2021"
[dependencies]
paperclip-server = { path = "../../../../server" }