* feat: consolidate offline eval and operator workflows
Compose the retained framework, operator skill, roadmap and cleanup ranges on current main. Preserve current release dependencies and keep candidate execution disabled pending OS containment. Repair draft/DOCX behavior, obligation uniqueness, trusted send and audience guidance, runner provenance and eval diagnostics.
Source-PR: 2930 0abe3727d2b500c6e4830bdeb47ed67cae3f4785
Source-PR: 2931 992b49c44ed872def49675b791168b8fcd091df6
Source-PR: 2932 4a193dd13041cb7a6bebf4d2e910a0cd32bcc797
Source-PR: 2933 59cdfe500a91949ba1415f1edd7279620f21e804
Source-Base: ca185ef5f7
* fix: repair foundation CI and update js-yaml
* fix: reconcile pending-delete capsule locks after close
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
20 KiB
Eval Harness Frameworks
Local capsule, inspection, fixture replay, and receipt building blocks.
Candidate execution and promotion are unavailable.
They live in scripts/lib/eval-harness/, ship with a CLI at
scripts/eval-harness.js, and have an end-to-end example under
examples/eval-harness/. The example runs locally, offline, and inside temporary
directories. It does not merge, deploy, publish, or spend.
node scripts/eval-harness.js example
Why these five
The harness engineering plan v2 (August 2026) describes a twelve-layer stack. The part that belongs in the portable ECC package is the contract surface any harness can install and exercise: record what happened, prove it was not altered, gate a proposed change behind an external checker, replay tool calls without re-firing effects, and hand a verifier something it can check without trusting the producer. The execution gate remains disabled pending a verified OS containment backend. The other modules expose local utilities, not a trust decision about code.
| Framework | Module | Plan epic | What it gives you today |
|---|---|---|---|
| Envelope | envelope.js, schemas/capsule-envelope.schema.json |
01 telemetry and capsule contract | capsule-envelope/v1, stable identifiers, effect classes SE0 to SE4, default-deny payload allowlist, secret canaries |
| Capsule | capsule.js |
02 local execution capsule | Append-only NDJSON journal, five lineages, sha256 predecessor links, verify that fails at the exact entry, byte-stable projection, minimal export bundle |
| Gate | gate.js, gate-child.js |
03 verification gate | Static source digests and syntactic warnings; all execution entrypoints refuse |
| Replay | replay.js, effect-fence.js |
04 replay-safe branching | Declared determinism and effect class per tool, content-addressed fixtures, tool.fixture_missing fail-closed replay, retired child preload refuses execution |
| Receipt | receipt.js |
07 verifiable receipts | Offline receipt over capsule root, entry count, artifact digest, and gate receipt; detached signature interface; verification names the failing check |
Epics 05 (offline self-improvement) and 06 (causal triage and compaction invariance) are not implemented. They consume the records these five produce.
Effect classes
Every journal entry, tool declaration, and variant manifest carries one class.
| Class | Meaning | Where it is allowed |
|---|---|---|
| SE0 | Read-only evaluation or schema validation | Everywhere |
| SE1 | Reversible local writes inside the capsule or work root | Journal, gate metadata |
| SE2 | Process or filesystem mutation, no live network writes | Candidate execution unavailable |
| SE3 | Append-only remote evidence publication | Never in replay; trusted record-mode caller controls authorization; refused in replay |
| SE4 | Economic, counterparty, payment, provider, or secret-handling effects | Never in replay; record mode requires the trusted caller to forbid it |
Effect classes are declarations, not OS permissions. Static inspection reports effect-class expansion but cannot enforce a declaration. The replayer refuses SE3 and above in replay mode regardless of fixtures; record mode invokes the caller-supplied implementation up to its configured maximum. Only register trusted implementations. No JavaScript tool wrapper isolates arbitrary code.
Capsule journal
A capsule is a directory with capsule.json, journal.ndjson, and an optional
projection.json. Each line of the journal is one canonical-JSON envelope. The
first entry links to sixty-four zeros; every later entry links to the previous
entry_hash.
const { capsule } = require('./scripts/lib/eval-harness');
const c = capsule.Capsule.create('.ecc/capsules/run-42', { task_family: 'slugify' });
c.append('plan', 'inspection.start', { task_id: 't01' });
c.append('attempt', 'gate.unavailable', { status: 'blocked', reason: 'gate.isolation_required' });
capsule.verify('.ecc/capsules/run-42'); // { ok, code, failed_at, root_hash }
verify returns ok: false with a stable code and the exact failing index for
a changed byte (capsule.invalid_entry), a dropped or swapped entry
(capsule.reordered or capsule.broken_link), and a partial trailing write
(capsule.truncated_tail). The journal digest covers the original bytes;
invalid UTF-8 is rejected as capsule.non_canonical. project derives stable
content from the verified journal snapshot and validated metadata. exportBundle
copies the three capsule files and nothing from the workspace.
Metadata is validated before creation writes and when opening, verifying or
projecting a capsule. IDs use the envelope ID pattern; harness/task family must
be nonempty, and created_at must use the canonical ISO timestamp produced by
Date.toISOString(). Missing, unreadable or malformed metadata returns
capsule.metadata_invalid; invalid UTF-8 is also rejected. Every journal entry must match metadata schema,
run_id, capsule_id, harness_version and task_family, or verification returns
capsule.metadata_mismatch at that entry. Empty journals have no historical
identity binding; their projection and receipt bind the metadata values.
created_at is shape-checked but is not authenticated by journal entries.
Envelope v1 enforces the scalar payload types declared in
schemas/capsule-envelope.schema.json. String fields require strings; number
fields require finite numbers, and integer fields require integers. Only
exit_code accepts null. No extra nonnegative restrictions are imposed on these
payload numbers. Omitted append payloads still default to an empty object.
Explicit null, arrays, primitives, exotic objects, accessors, symbol keys and
non-enumerable properties are rejected. Plain data objects with either the normal
or null prototype are accepted. Validation inspects descriptors before reading
values; it does not isolate proxies or arbitrary caller JavaScript.
Retained fields are validated before canary scanning or hashing. Undefined,
non-finite numbers, functions, symbols, BigInt and nested/cyclic objects are
refused instead of coerced, dropped from serialized bytes or recursively scanned.
redactPayload adds an errors array to its existing result; callers must check
it alongside dropped and findings. Append reports capsule.payload_invalid
without writing a journal entry; the existing finally path releases its owned
lock. Strict unknown payload keys still report capsule.payload_denied.
strict: false permits dropping unknown keys, but never invalid retained values.
Custom allowlists can narrow v1 fields only, and cannot widen the persisted schema.
Envelope validation also requires its own schema-defined fields and rejects
unknown top-level fields even when the supplied hash has been recomputed. Invalid
stored records return capsule.invalid_entry at their journal index. This tightens
acceptance of malformed v1 data: existing nonconforming callers/journals need
explicit correction; no automatic migration or healing is performed. Valid v1
bytes and hashes remain unchanged. Generic key preservation and remaining
non-JSON limitations are described below; neither supplies OS containment.
The generic canonicalizer preserves every selected own enumerable JSON key as an
own data property, including __proto__, constructor and prototype. It does
not invoke an inherited setter while constructing the canonical object. Results
retain their ordinary object prototype. Envelope schema rejection is separate:
an own __proto__ key is valid generic JSON data but remains an unknown envelope
field. Receipt schema acceptance is unchanged; hashing a field is not permission
from a higher-level schema.
Traversal, key sorting, array handling, undefined omission, JSON.stringify and UTF-8 hashing retain their prior policy, including JavaScript's ordering of numeric-looking keys. Schema-valid v1 journal/projection bytes and unaffected receipt/fixture bytes stay identical. Regression vectors were captured from the pre-fix implementation, including unsigned and synthetic string-signed receipts. Verification does not rewrite those stored artifacts.
The earlier canonicalizer omitted own __proto__ keys, creating hash aliases.
Corrected inputs retaining that key intentionally produce different hashes. An
artifact retaining it with a legacy digest fails existing hash checks; a fixture
lookup does not fall back to the old aliased key. Existing key-free stored bytes
remain readable as those bytes, but cannot authenticate richer original inputs
whose keys were lost. Recovery requires explicit re-recording from a trusted
source or receipt rebuilding/re-signing; there is no automatic rekey, migration,
rewrite, dual-hash acceptance or recovery of already discarded information.
This correction does not define a stricter generic policy for undefined, functions/symbols, non-finite numbers, sparse arrays, class/toJSON/getter behavior, cycles, resource limits or hostile proxies. Their prior behavior remains; no claim of unambiguous hashing for every JavaScript value is made. The envelope's stricter scalar validation remains a separate layer.
Append operations serialize cooperating writers using an exclusive local
.append.lock file. Acquisition uses wx and fails immediately with
capsule.busy when the path exists, regardless of age or contents. There is no
waiting, retry, PID/age heuristic, or automatic stale unlocking. Under ownership,
each append reloads and verifies the complete journal and metadata, then derives
its sequence and predecessor hash from that snapshot. Preopened handles never
use cached sequence/hash values as authoritative state. Full validation costs
O(journal size) per append; this implementation is intended for small local
journals.
The writer handles short writes until the complete UTF-8 entry has been written,
then fsyncs the journal. The append lock is released in finally on success,
validation refusal, or ordinary I/O exceptions. A zero-progress write returns
capsule.write_failed. Release checks the open lock descriptor's device/inode
against the path before unlinking; a detected missing/replaced lock returns
capsule.lock_lost and a replacement is preserved. This is cooperative ownership
checking, not atomic protection against an actor replacing paths between syscalls.
The local filesystem must support exclusive file creation and stable identities.
A process crash can leave .append.lock behind. Acquisition/cleanup I/O failures
can also leave a lock that was not safely released. Further appends stay busy;
only an operator who has stopped all writers and inspected the capsule should
perform recovery. The library never guesses ownership, removes an old lock,
truncates a tail, or repairs journal bytes automatically.
A write failure may leave a partial entry; later appends verify the journal and refuse the invalid tail, preserving evidence. A full entry may already exist when fsync, close or lock release throws. Such a failure is an ambiguous acknowledgement, not proof of rollback: inspect disk before retrying, or a logical event could be recorded twice. No transaction, exactly-once retry, parent-directory fsync, or power-loss durability guarantee is added here.
Create, read/verify, projection, receipt production and export are not serialized by the append lock. Use quiescent capsules for consistent receipts/exports; there is no concurrent export guarantee or hostile-filesystem containment. The append repair does not change the disabled candidate execution boundary.
What the chain does not claim: it does not stop an operator from replacing the whole log. That is the job of a witnessed transparency log, which is a later, opt-in layer outside this package.
Verification gate: unavailable
Supported candidate execution backends: none, on any OS. runGate and
runVariant throw gate.isolation_required unconditionally, before reading
configuration, copying files, loading candidate modules, or creating receipts.
gate run exits 1 before reading its config or creating a capsule. Direct
gate-child.js invocation and the retired effect-fence.js preload also refuse
before loading requests or candidate code. Trust flags and caller-supplied
executor objects cannot enable execution. There is no promotion path.
The former directory copy and JavaScript interception did not isolate host
reads, alternate builtin loaders, or filesystem descriptors and promises.
Keeping answers in a parent process did not hide the taskset on disk. The
interception code and staged execution implementation have been removed.
Node's permission model and
vm module are not substitutes for isolation
of malicious code.
A future executor must have a separately reviewed OS containment implementation and adversarial evidence on each supported OS. At minimum it must:
- Expose only immutable, digested variant files and task inputs in an ephemeral filesystem. Host tasksets, answers, credentials, configuration, sockets, and other workspaces must be inaccessible, including via links and inherited FDs.
- Enforce network, process, filesystem, and resource restrictions outside the candidate runtime, with an unprivileged identity and a bounded lifetime.
- Keep the checker, output/protocol validation, audit channel, and receipt creation outside candidate control. Verify the actual runtime policy using independent canaries before any candidate starts; refuse unavailable backends.
- Reject failed, timed-out, signalled, incomplete, or malformed baseline runs
before evaluating candidate improvements. Require a complete unique result
for each task. Container availability or a caller's
verified: trueassertion alone is not policy verification.
Static APIs remain available for trusted, quiescent local source trees:
loadTaskset, loadVariant, digestDir, and scanTripwires. Variant names are
single components of 1–64 ASCII letters, digits, underscores or hyphens, starting
with a letter or digit. Entries must be relative regular files included in the
digest; absolute, parent-traversing, symlinked, and excluded entries are rejected.
.git and node_modules remain excluded. Inspection does not resist concurrent
host filesystem mutation and is not a sandbox or an execution attestation.
Task IDs must be unique. Syntactic warnings are incomplete by design: zero hits
prove neither safety nor correctness.
parseChildResult and baselineFailure(run, tasks) are pure validation helpers
for bounded protocol and baseline integrity regression checks. No executor calls
them in this release. Their tests are not evidence of an operational gate or a
verified OS backend. Existing manifest/config fixtures are preserved as data.
Replay-safe tool calls
const { replay } = require('./scripts/lib/eval-harness');
const store = new replay.FixtureStore('.ecc/fixtures');
const tools = {
read_inventory: { effect_class: 'SE0', determinism: 'deterministic', impl: liveRead },
place_order: { effect_class: 'SE4', determinism: 'nondeterministic', impl: livePlace },
};
const r = replay.createReplayer(tools, { mode: 'replay', store, maxEffectClass: 'SE2' });
r.call('read_inventory', { sku: 'gpu-8x' }); // served from fixture or tool.fixture_missing
r.call('place_order', { sku: 'gpu-8x' }); // tool.effect_forbidden, always
Fixtures are keyed by the canonical hash of (tool, args) and store both an
argument hash and a response hash, so a stale or edited fixture fails with
tool.fixture_mismatch. Record mode executes caller-supplied trusted functions;
replay uses fixtures. These wrappers do not constrain arbitrary effects inside
an implementation. The legacy EFFECT_FENCE_PRELOAD export remains for import
compatibility, but loading that file always throws gate.isolation_required.
It no longer attempts JavaScript interception.
Offline receipts
node scripts/eval-harness.js receipt build .ecc/capsules/run-42 \
--artifact skills/my-skill/SKILL.md --out run-42.receipt.json
node scripts/eval-harness.js receipt verify run-42.receipt.json exported-bundle/ \
--artifact skills/my-skill/SKILL.md
A receipt names the capsule root, entry count, journal digest, projection
hash, artifact digest, and optional gate receipt digest, plus its own hash.
buildReceipt now persists projection.json using the verified journal snapshot
before returning the receipt. This is a producer write and can fail on a read-only
capsule; copy a read-only source to a writable local directory before building.
An explicit invalid artifact_digest throws receipt.schema_invalid before the
projection write. Other construction failures continue to throw.
verifyReceipt is read-only. It never regenerates or heals a missing projection.
The supplied projection must parse and match the complete deterministic projection
from the validated metadata/journal snapshot; its computed hash must match both
its stored projection_hash and the receipt. Missing, unreadable, corrupt or
substituted projections return check: 'projection'; invalid UTF-8 is rejected. Receipt identity mismatches
and invalid capsule metadata return check: 'metadata'.
Schema validation rejects negative, fractional, string or unsafe entry counts,
invalid identity/schema values and malformed required digests before journal
indexing. Optional artifact/gate digest fields must be SHA-256 values or null.
Otherwise valid receipts retain signature, journal integrity, truncation,
capsule-root and stale-checkpoint checks before projection/artifact comparisons.
Missing or unreadable artifact files return check: 'artifact' rather than
throwing. Every verification failure has {ok: false, check, reason} for these
validated file/content cases.
Existing v1 exported bundles retain their format. Older source directories whose
receipts were built without a saved projection must explicitly run capsule project or rebuild the receipt before verification; verification itself never
writes a replacement. The CLI validates --artifact, --gate and --out before file
reads or producer writes: missing values, values that are another flag, and
repeated flags exit with usage code 2. Disabled gate commands still refuse before
configuration/capsule I/O.
Signing remains a detached interface: pass a signer when building and a verifier when verifying. No key generation, transport or rotation happens in this package. A signature proves who vouched for the bytes, not that the run was correct. Optional gate-receipt hashing remains for compatibility with existing artifacts; accepting externally supplied bytes proves neither containment nor promotion.
This slice addresses receipt/projection validation and metadata identity binding. The OS executor is still unavailable. Cooperative append serialization is described above; concurrent export/create and broader envelope/review findings remain separate. Package/count evidence is a separate ignore-scripts test scope and does not validate normal prepack or clear a release.
Where it plugs in
skills/eval-harness/SKILL.mddescribes eval-driven development. These frameworks are the mechanical layer under its report format.- The
harness-optimizeragent and/harness-auditcommand must report the gate unavailable until a reviewed OS backend exists. They cannot emit new gate receipts using this implementation. - The Rust
ecc2/src/harness_eval.rsbounded evaluation loop is a separate, earlier experiment. The Node frameworks are the portable surface.
Tests
node tests/lib/eval-harness/envelope.test.js
node tests/lib/eval-harness/capsule.test.js
node tests/lib/eval-harness/gate.test.js
node tests/lib/eval-harness/security.test.js
node tests/lib/eval-harness/replay.test.js
node tests/lib/eval-harness/receipt.test.js
node tests/lib/eval-harness/cli.test.js
node examples/eval-harness/run-example.js