Skip to content

Fitness gate: judge generated objects by evidence they cannot edit - #11

Open
andreBurnt wants to merge 26 commits into
mempko:mainfrom
andreBurnt:ratchet/c2-fitness-gate
Open

Fitness gate: judge generated objects by evidence they cannot edit#11
andreBurnt wants to merge 26 commits into
mempko:mainfrom
andreBurnt:ratchet/c2-fitness-gate

Conversation

@andreBurnt

@andreBurnt andreBurnt commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

The problem

ObjectCreator has an LLM write an object's source and then hot-swaps it into a running system. Between a generated source and a live deploy there is a compile, a validate_calls pass, and a review_semantics step whose own comment says it is advice and not a gate. A source that is plausible and wrong compiles fine and deploys.

The reviewer in that loop is a model. So is the author. Asking one to catch the other's mistakes puts the same failure mode on both sides of the check.

What this adds

A check the generator cannot argue with, because it is made of things the generator did not write: the object's own recorded HTTP traffic, its declared output shape, and properties its answers have to satisfy no matter what.

Four checks run in order, and the first failure stops the rest. No LLM is involved in any of them.

  1. Replay. Re-run the candidate against traffic the object previously recorded. If it no longer reproduces what it produced before, it fails.
  2. Schema. Validate outputs against a declared JSON Schema (Ajv).
  3. Relations. Metamorphic properties declared on the method: no-duplicates, sorted-by, idempotent, subset-on-tighter-filter, and a non-empty-for-known-entity probe. These catch what a single assertion cannot.
  4. Mutation. Deliberately break the candidate (acorn AST edits: flip a comparison, drop a .filter, empty an array return, swap a property key) and confirm the evidence above kills at least 80% of the mutants. A suite that cannot detect sabotage is not evidence.

deploy_spawn and deploy_update then refuse to run without a passing verdict. The verdict digest covers the exact source and its manifest methods, so editing the draft after a pass invalidates it. deploy_update additionally refuses a verdict earned against a different target object.

A failing verdict reads like this:

fitness: FAIL — mutation: 3/12 mutants killed (threshold 0.8)

What a method declares

New optional fields on a manifest method. Manifests written before these existed stay valid.

effects: 'read' | 'act'      // reads are safe to regenerate, acts are not
outputSchema: {...}          // JSON Schema
relations: [{ kind, field }] // metamorphic properties
knownEntity: 'some-id'       // a value real output must contain

This is the one thing I want a ruling on before merge: the field names. Everything else is mechanical and I will rename to whatever you prefer.

Where the evidence comes from

HttpClient grows a per-object record/replay seam. An object's own live traffic becomes the cassettes the gate replays, so the evidence is what the world actually said rather than fixtures someone wrote to match the code.

Candidates run under runSandboxed with the documented HttpClient response shape ({status, statusText, headers, ok, body:string}), a handler-proxy this, and a 5s wall-clock deadline (hung work is abandoned, not cancelled).

What the gate does when it has no evidence

With an empty cassette store, every check returns an unverified pass, labelled as such, rather than inventing a verdict.

A store holding only unattributed _http traffic is the harder case. Replay reports that it replayed nothing. Schema and relations fall back to an argument-less probe: they fail closed when the method needs arguments, and otherwise judge that probe's output. The summary names every check that verified nothing:

fitness: PASS (replay, schema, relations, mutation) — unverified: replay, relations

Honest scope

The gate is wired and enforced, but setRecorder has no non-test callers yet, so nothing populates a cassette store on this branch. Today's verdicts on a first create rest on compile plus that unverified-pass path. Wiring the recorder into an object's lifecycle and attributing cassettes to method names is the next piece, and it is what makes the gate start verifying real behaviour. I did not fold it in here to keep this reviewable, but say the word if you would rather see them together.

Known seams

  1. Recorder cassettes are method-agnostic (_http) until something drives named tool calls. They serve as HTTP stubs, never replayed as methods. Until then a store can be non-empty yet unattributed, which is the case above.
  2. First-create live-probe enforcement (no birth without one recorded truth) is not here. I would put it at expose time.
  3. compose_organism and extract_organelle deploy already-existing source without the gate.
  4. getBase64/fetchBase64 bypasses the recorder (separate code path). Binary fetches are invisible to the gate.
  5. Only HttpClient is stubbed in the sandbox invoker. WebFetch, WebParser and Storage-based objects fail closed with unstubbed I/O.
  6. Cassette recording is 2xx-gated but not schema-gated at the HTTP layer. Method-level schema checks happen in the gate.
  7. Redaction covers the authorization, cookie and set-cookie request headers. A token in a URL query string or a request body is still recorded verbatim. That needs a ruling from you, and it matters more once cassettes are shared between peers.

Tests

50 new tests across 7 files. Existing suites stay green.

pnpm tsx --test src/core/manifest-contract.test.ts \
  src/protocol/{cassette,fitness,mutants,cassette-recorder}.test.ts \
  src/objects/object-creator-fitness.test.ts \
  src/objects/capabilities/http-client-recorder.test.ts

tsc --noEmit clean. No new dependencies. ajv and acorn were already in package.json, and this branch touches neither it nor the lockfile.

15 files, +1762/-12. If that is more than you want in one review, say so and I will split it. The manifest fields and the cassette store stand alone. The gate and the invoker can follow after.

andreBurnt and others added 16 commits August 24, 2026 07:20
A manifest could describe a method's shape but not its meaning: nothing
said whether calling it changes the world, what its output must look
like, or which properties every answer has to satisfy. The fitness gate
needs all three, so methods can now declare effects (read|act), a JSON
Schema for their output, metamorphic relations, and a known entity that
must appear in a healthy answer. All fields are optional; existing
manifests are untouched.
A cassette records one exchange: the request an object made, the answer
the world gave, and what the object made of it. Capped per method, LRU
evicted, credentials stripped at the door. This is the memory the
fitness gate replays against every candidate.
evaluate() is the gate a generated source must pass before it deploys:
every cassette must be reproduced in meaning, every declared output
schema satisfied, with all I/O served from the recording. No LLM, no
network, nothing the candidate can edit. Relations and the mutation
gate land next.
Track validation success per method; if a method with outputSchema
has zero successful validations and at least one probe threw, fail
schema with a detail noting the method threw on all probes. This
prevents first-create candidates that always throw from spuriously
passing schema validation.
Metamorphic relations judge a method by how its answers relate: same
call twice agrees, no element repeats, order holds, a tighter filter
returns a subset, and the entity every healthy answer contains is
there. Declared in the manifest, checked by the gate, invisible to
the LLM.
Secondary invocations (idempotent re-call, subset-on-tighter-filter
loose/tight) must be wrapped in try/catch to fail the relation
instead of rejecting the promise. Numeric filter arguments now sort
numerically (a - b) instead of lexicographically; string sorts via
localeCompare as before. Covers both cases in two new integration
tests: stateful invoker failure on idempotent, and numeric argument
ordering for tighter/looser comparisons.
A fixed set of deterministic mutations -- flipped comparisons, dropped
filters, emptied returns, renamed keys -- is applied to every candidate.
If the cassettes, schemas, and relations cannot tell most mutants from
the original, the evidence is too weak to certify a deploy, and the
gate says so with a kill ratio.
Verify that the mutation-gate kill-counting logic is exercised with a source that has real mutation points (comparisons, filter calls), confirming mutants are correctly identified and counted as killed when replay fails.
ObjectCreator gains a fitness action: the draft runs in the sandbox with
every call shimmed, HTTP served from the object's own cassettes, and the
verdict recorded against a digest of the judged source. deploy_spawn and
deploy_update now consult that gate -- no verdict, failed verdict, or
edited-since-judged draft all refuse. The semantic reviewer stays
advice; this is the part that is not.
HttpClient consults a per-object recorder before and after each request:
record mode keeps every successful exchange as a cassette, replay mode
serves recorded answers and refuses to improvise. Objects nobody
registered pass through untouched. This is how the gate's memory grows
without anyone authoring fixtures.
HttpClient's ask guide promises every caller { status, statusText, headers,
body, ok } with body ALWAYS a raw string — that is the contract an LLM writes
its handler against. The fitness invoker was returning { status, body:parsed },
so a candidate that did the documented JSON.parse(res.body) failed judgment
while one written against a shape the runtime does not produce passed.

Cassettes now keep rawBody (the response text verbatim) alongside parsedOutput,
so replay can return the same characters the world sent — JSON.stringify of a
parsed JSON string primitive is not the same text. Entries recorded before
rawBody existed derive it on load.

WebFetch leaves HTTP_TARGETS: its live FetchResult is nothing like an
HttpResponse, so shimming it taught candidates a second fictional contract.
A WebFetch-using candidate now fails with a plain unstubbed-I/O message.

matchRequest is exact method+url only. The host+path fallback served ?q=1's
recording to ?q=other, which defeats argument-dependent replay outright; the
loose behaviour survives as matchRequestLoose for callers that want a sample
of an endpoint rather than an answer to a question.
With zero cassettes — the only state this branch actually produces at runtime,
since nothing calls setRecorder yet — the gate failed honest objects for
reasons that were about the evidence, not the candidate: a declared
outputSchema failed on a {} probe, and a real handler map scored 0/N killed.
Only contract-free sources passed, which is exactly backwards. An empty store
now returns an unverified PASS with each check saying so, and probes nothing.

The mutation gate also failed open on the one input it could not read.
generateMutants wrapped every source as a function body, so the canonical
handler map did not parse and a syntax error and a clean source were both
"no mutation points". It now returns null for "parsed under no dialect", and
that fails the mutation check outright; zero sites from a real parse still
passes.

'_http' cassettes are raw traffic captured on the object's behalf, not method
calls. Replaying them asked the invoker for a handler no object can have, so
any object that had ever recorded could never be healed again. They now
participate only as HTTP stubs.
Handlers were invoked unbound, so `this` was undefined inside them. The house
style is a thin handler over a private helper — `return this.shape(rows)` —
and every such object failed judgment on a `this` the gate itself withheld.
Handlers are now bound to a minimal stand-in for ScriptableAbject's handler
proxy: sibling handlers, the call/dep/find shims, an inert data/saveData/emit/
changed/observe, assert-like ensure/invariant, and an id.

Nothing could interrupt a candidate that never returned, either: the vm's
timeout is synchronous-only, so a mutant that flips a loop guard and awaits
inside it hung evaluate forever. Compile and invocation now race a deadline,
default 5s and injectable for tests. A timing-out mutant is killed by replay's
catch; a timing-out candidate fails it.
A verdict was keyed on the source alone, so redrafting the manifest kept it
valid — even though the schema and relation checks are judgments of the source
AGAINST those declarations. sourceDigest becomes verdictDigest(source,
methods).

It was also keyed on nothing at all where the object was concerned. opFitness
read cassettes for state.targetObjectId, while deploy_update can resolve a
different explicit objectId/targetName — one it stamps AFTER the old gate ran.
A verdict built from one object's recorded traffic could therefore wave a
deploy onto another. The judged target is recorded and the gate refuses a
mismatch; deploy_update now resolves its target before consulting the gate.

On a heal the agent edits source without redrafting a manifest, so methods
came back empty and schema/relations were vacuous. opFitness now reads the
live target's manifest via describe, cached so the deploy gate recomputes the
same digest, with a note in the op summary when the read comes back empty or
fails.
…e is

Two verdicts nothing exercised: a source whose recording kills only one of its
four mutants (0.25, below the 0.8 threshold) must fail, and the whole dialect
an LLM actually writes — parenthesized handler map, a helper reached through
`this`, an HttpClient call checked with res.ok and parsed out of res.body,
against a cassette with a raw body — must pass every check with the mutation
gate armed.

The comments still described a world with two mechanical checks and no fitness
gate, which is now the only non-mechanical reason a deploy is refused.
An object whose only recorded traffic is `_http` has a store that is not
empty but is unattributed: `replayable` filters those cassettes out, so
every method-level check probes with a single `{}` call. A method that
needs arguments throws on it, `checkRelations` swallowed the throw, and
the function fell through to "all declared relations hold" having
evaluated nothing — on data whose own recording violated the relations
it declared.

checkSchema already had the guard (`validatedCount === 0` fails closed);
relations did not. It now counts probes that produced an output and,
when none did, reports the methods it could not judge. The pass is kept
rather than inverted, matching `evaluate`'s no-evidence path: an
unverified pass, honestly labelled, and the mutation gate still refuses
to certify a candidate nothing can kill.

The verdict line the loop reads listed check NAMES, so three vacuous
passes rendered identically to three earned ones. `summarizeVerdict`
now names the checks that verified nothing.

Nothing on this branch can reach that state — `setRecorder` has no
non-test callers and no writer for `cassettes:<objectId>` exists yet —
but the recorder wiring in the next PR makes `_http`-only the normal
shape of a populated store.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The contract comments carried a label from my own notes ("C2"), the cassette
header leaned on a metaphor no reader here shares, and a test comment pointed
at a task number from a plan that does not live in this repo. None of them
mean anything to someone reading the file cold, and the test comment was also
stale: the sandboxed invoker it said was coming already exists next door.

No behaviour change.
@mempko

mempko commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Overall Impressions & Rigor

First off, thanks for this ambitious contribution. Replacing subjective LLM self-evaluation with mechanical, non-forgeable evidence (HTTP cassette record/replay, Ajv schema validation, metamorphic relations, and AST mutation testing) is fundamentally the right direction for ensuring that autonomously generated code is reliable and safe to deploy.

However, to get this PR merge-ready, there are two primary areas we need to address:

  1. Architectural Alignment with the Abject Model (how this fits our "everything is an Abject, message passing, ask protocol" philosophy).
  2. Critical Runtime Blocking Issues (including an incentive inversion that bricks HTTP objects when recording is enabled, an unconditional JSON.parse performance penalty on all runtime HTTP traffic, sandbox timeout risks, and digest binding bypasses).

Architectural Assessment: Alignment with the Abject Philosophy

In the Abject platform architecture, everything is an Abject: autonomous objects communicating asynchronously across a message bus using their declared manifests and the dynamic Ask Protocol, rather than direct in-process method calls or static libraries.

While this PR makes great choices in message attribution (msg.routing.from for caller ID) and persistent storage (Storage abject for cassettes), several architectural patterns currently diverge from this philosophy:

  1. In-Process Modules vs. First-Class Abjects:
    The fitness gate, cassette manager, and sandbox are implemented as static TypeScript modules in src/protocol/ rather than first-class Abjects. ObjectCreator imports and calls deployGate() directly in-process (opDeploySpawn, opDeployUpdate) instead of passing a message over the bus (e.g., this.call('FitnessGate', 'evaluate', payload)).
  2. In-Process Callback Hooks vs. Message Taps:
    HttpClient is wired with a direct in-memory setRecorder(recorder) hook and module-level Map state rather than observing traffic through message bus event subscriptions, proxies, or relays.
  3. Sandbox Message-Passing Restrictions (call() / find() Blocked):
    sandbox-invoker.ts specifically stubs only HttpClient and throws on this.call(), this.find(), and this.saveData(). In a pure Abject system, objects collaborate by passing messages to peer Abjects. Right now, any candidate Abject that interacts with other Abjects via message passing is structurally un-testable in this sandbox, making HttpClient an arbitrary, privileged exception.
  4. Static Manifest Schemas vs. The Dynamic Ask Protocol:
    The gate evaluates candidates via static compile-time AST inspection and schema declarations rather than querying runtime capabilities via object.ask().

Blocking Issues

1. Mutation Check Inversion Bricking Deployments When Recording is Enabled (fitness.ts:60, 234, 273-285, cassette-recorder.ts:44)

cassette-recorder.ts:44 hardcodes method: '_http' for every recorded exchange, and fitness.ts:60 explicitly filters out '_http' from replayable(). When recording is eventually switched on:

  • evidence.cassettes.all() is non-empty, so evaluate() skips the no-evidence early return (fitness.ts:234).
  • replayable() is empty, so checkReplay produces an unverified pass.
  • If an HTTP-calling candidate has no declared outputSchema or relations, checkSchema and checkRelations also contribute zero discrimination.
  • The mutation loop (fitness.ts:273-285) runs all 12 mutants through checks that cannot fail, killing 0 mutants. killRatio evaluates to 0 < 0.8, resulting in a hard FAIL verdict.

Effect: Simply turning on traffic recording will cause previously working objects to fail deployment.

Suggested Fix: Gate mutation testing on discriminator capability rather than raw cassette presence. If replayable(evidence).length === 0 && !evidence.methods.some(m => m.outputSchema || m.relations?.length), skip mutation testing and report { check: 'mutation', pass: true, detail: 'no check can kill a mutant — evidence insufficient' } in line with the unverified pass philosophy.

2. Unconditional JSON.parse(body) on Every Runtime HTTP Response (src/objects/capabilities/http-client.ts:363-372)

In http-client.ts:364-371, JSON.parse(body) executes unconditionally on every single HTTP response in the entire system before afterResponse (:372). Because setRecorder is never set in production runtime, afterResponse discards this object 100% of the time. For multi-megabyte payloads, streaming responses, or LLM completions, this adds significant CPU latency and GC pressure.

Suggested Fix: Lazily parse only when a recorder is active:

if (recorder) {
  let parsed: unknown = undefined;
  try { parsed = JSON.parse(rawBody); } catch { /* ignore */ }
  recorder.afterResponse({ ...opts, body: parsed, rawBody });
}

3. Synchronous AST / Infinite Loop Execution in Sandbox (sandbox-invoker.ts:42-70, fitness.ts:275)

sandbox-invoker.ts executes candidate handlers using vm.runInContext / Function evaluation with async Promise resolution. If an AST mutant or generated code introduces a synchronous runaway loop (e.g. while(true) {}), it will block Node's single-threaded event loop indefinitely regardless of the Promise-level timeout wrapper.

Suggested Fix: Enforce timeout: timeoutMs and microtaskMode: 'afterEvaluate' directly at the native vm.runInContext execution boundary.

4. Replay Matching Collision & Credential Leak in Query Strings (src/protocol/cassette.ts:45-58)

Replay cache matching strictly compares method and url. However:

  • Requests with identical URLs but distinct POST/PUT bodies or distinct request headers (Accept, Content-Type) will match collisions.
  • Sensitive credentials passed in query parameters (e.g., ?api_key=..., ?token=...) are captured verbatim in storage.

Suggested Fix:

  • Include a hash of normalized request body and key headers in cassette matching for non-GET methods.
  • Sanitize query parameters matching /^(key|api_key|token|access_token|secret|auth|apikey)$/i before recording.

5. Digest Binding Bypass on targetId (fitness.ts:308-310)

The verdict digest binds sha256(source + JSON.stringify(methods)), while targetId is verified separately as an unhashed property. A verdict generated for candidate object A could theoretically be presented when updating object B if the source and manifest methods are identical.

Suggested Fix: Include targetId directly in the preimage of the verdict digest:
sha256(targetId + ':' + source + ':' + JSON.stringify(methods)).

6. Sibling Deployment Operations Bypass deployGate (object-creator.ts:1468, 2433, 2503)

While opDeploySpawn (:2019) and opDeployUpdate (:2145) call deployGate, sibling operations opCloneObject (:1468), opComposeOrganism (:2433), and opExtractOrganelle (:2503) bypass it entirely.

Suggested Fix: Unify deployment paths through a single internal helper that enforces deployGate across all creation/modification operations.


Non-Blocking Suggestions

  1. Expand Mutation Operators (mutants.ts:12-14, 66-89):
    The current 4 AST operators (boolean flip, drop .filter(), empty array literal, rename object key) work well for basic data transforms. Adding boundary/comparison operators (<<=, +-) will help catch subtle off-by-one and boundary schema bugs.
  2. Cassette Eviction Strategy (cassette.ts:85):
    The current 20-cassette FIFO cap per method can rapidly evict useful edge cases on parameterized endpoints. Consider bucketing by endpoint pattern/route.
  3. Cassette Store Integrity:
    Since cassettes reside as plain JSON in Storage, signing them (HMAC) will protect against out-of-band tampering by other storage-accessing agents.

Answers to Your Questions

1. Manifest Field Naming (effects / outputSchema / relations / knownEntity)

  • outputSchema & relations: Keep as-is. outputSchema directly maps to JSON Schema / Ajv standards, and relations is established terminology in metamorphic testing.
  • effects: Rename to sideEffects or use an explicit enum/type ('idempotent' | 'read-only' | 'mutating') to clarify runtime behavior.
  • knownEntity: Rename to entityRef or place under metadata to clarify whether it references a schema identifier, type handle, or domain entity.

2. Cassette Redaction Scope

  • Recommendation: Redaction needs to be broadened beyond authorization, cookie, and set-cookie headers. Specifically, sanitize secret query parameters (/^(key|api_key|token|access_token|secret|auth|apikey)$/i) and provide an optional redactPaths string array in method declarations for JSON body masking.

3. PR Splitting

  • Recommendation: Do not split. The 15 files and +1765 lines form a cohesive, self-contained architecture. Splitting into separate PRs (e.g. cassette store without gate, or gate without recorder) would introduce dead intermediate states. Keeping it unified while resolving the blocking items is the cleanest path to merge.

@andreBurnt

Copy link
Copy Markdown
Contributor Author

Went through all six blocking items against the branch head (6572add). Every one is real or points at something real. Fix plan below, one correction, and two questions. Fixes land on this branch, not split, per your call.

  1. Mutation inversion: confirmed, your trace holds. It is the seam the PR body flagged as next-PR work, so I'll close it here instead. One adjustment to the suggested condition: gating on outputSchema || relations alone leaves an edge open. A relations-only candidate whose {} probes all throw still reaches a 0-kill FAIL. So the gate will key off what the baseline checks actually verified. CheckResult gains a verified flag. If replay had no cassettes, schema validated zero outputs, and relations evaluated zero probes, mutation reports an unverified pass with your suggested wording. This also lets summarizeVerdict stop regex-matching detail strings.

  2. Unconditional JSON.parse: confirmed. Moving the parse inside afterResponse, after the mode-check early returns. afterResponse has a single caller, so http-client.ts stops parsing entirely and hands over rawBody only.

  3. Sandbox runaway: confirmed, and it is worse than my comment claims. The vm timeout only covers the compile. The handler invocation (sandbox-invoker.ts:125) is a plain host call under no watchdog at all. A synchronous spin blocks the event loop, so the promise-race deadline can never fire. Fix: keep the context alive and run each invocation through runInContext with timeout plus microtaskMode: 'afterEvaluate'. The host-side deadline stays as the outer belt. Known trade-off: a candidate that uses setTimeout internally won't resolve under afterEvaluate and gets killed by the deadline. I think that is acceptable for judged code, and it will be documented. If the semantics leak in practice, worker-thread isolation is the escalation path.

  4. Matching and query credentials: confirmed on both. Non-GET cassettes get a request-body hash, and matchRequest compares it (tolerant of absent hashes so existing fixtures stay valid). Query params matching your regex get redacted at record time. Also adding the optional redactPaths array on method declarations for JSON body masking, per your redaction answer.

  5. Target binding: the specific scenario you describe is refused today. When both ids are present, deployGate rejects a mismatch (fitness.ts:324). The hole is the undefined case: run fitness with no target, then deploy_update with an explicit target, and the guard goes vacuous. Your fix closes that too, so I'm taking it. targetId goes into the digest preimage and the separate check gets deleted.

  6. Sibling spawn paths: confirmed, all three bypass the gate. Fix: one internal gatedSpawn helper that every Factory.spawn call site routes through, with an explicit per-op policy. compose_organism gates the membrane draft. Question on the other two: clone_object and extract_organelle redeploy source that is already live, verbatim. There is no draft to judge, and no cassettes exist under the new object id yet. My plan is to route them through the helper with a documented exemption. Do you want them hard-gated instead? If so, what evidence should the verdict bind to for a not-yet-existing id?

Renames: knownEntity -> entityRef, done. For effects I'm going with your enum option: sideEffects: 'read-only' | 'mutating' (today's 'read' | 'act', mapped). I'm leaving idempotent out of the enum because idempotence already exists as a relation kind, and one axis shouldn't live in two fields. Keeping outputSchema and relations as-is per your answer.

Mutation operators: comparison flips exist today (mutants.ts:12, the FLIP table). What's missing is boundary (< <-> <=) and arithmetic (+ <-> -). Adding both.

Architecture. Your point about the sandbox blocking call() and find() lands hardest: today only HttpClient users are judgeable, and that is arbitrary. The plan for the recording PR is to record inter-object call() traffic as cassettes the same way HTTP is recorded. That makes call() stubbing symmetric and collaborating candidates testable. On making the gate itself a first-class Abject: I see a tension with the PR's premise. A bus-addressable judge is addressable by the population it judges, and the point of the gate is evidence the object cannot edit or intercept. My proposal: keep the evaluation kernel in-process, add a thin FitnessGate Abject facade so bus callers reach it through normal message passing, and route recording through the facade in the next PR. Does that satisfy alignment for this PR, or do you want the facade in this one?

Cassette integrity (HMAC): where would the key root? If it lives in the same Storage a tampering agent can reach, the signature defends nothing. If you have a trust root in mind I'll wire it, otherwise I'd defer until there is one to hang it on.

Eviction bucketing: agreed, deferring to the recording PR where _http volume makes it real.

Each fix lands with a test that reproduces your scenario first. Anything above you'd sequence differently?

A verdict earned with no target could gate deploy_update onto any object:
the target check compared a state field that was simply absent in the
spawn flow. The target now lives in the digest preimage itself, so there
is no unbound case. Components are hashed separately (generated source
can contain any byte) and the methods JSON is canonicalized (key order
must not decide verdict validity).
Every HTTP response in the system was JSON-parsed to feed afterResponse,
which discards the result whenever no recorder is attached — which in the
runtime is always. The parse now happens inside the recorder, after the
mode checks, so the runtime HTTP path hands over the raw text and nothing
else.
With recording on, a store holds only raw _http traffic: replay, schema,
and relations all judge nothing, so no check can kill a mutant, and the
mutation loop failed every such candidate for the evidence's poverty.
Checks now carry a verified flag saying whether they judged any actual
invocation outcome, the mutation gate refuses to run when nothing did,
and the verdict summary names unverified checks from the flag instead of
pattern-matching detail strings.
The vm timeout only ever covered the compile: a handler invocation was a
plain host call under no watchdog, so a synchronous spin after the first
await blocked the event loop with nothing able to interrupt it -- the
promise-race deadline runs on the loop the spin is blocking. In-process
microtask-mode draining turned out worse: it corrupts the async_hooks
stack (native crash) for any AsyncLocalStorage user.

Invocations now run on a worker thread the deadline can terminate --
which preempts sync spins, post-await spins, and Atomics.wait alike --
with resourceLimits bounding the candidate's heap so an allocation bomb
kills the worker, not the judge. The stub crosses the thread boundary as
data over a synchronous Atomics channel; no host closure is reachable.
Timers now work normally in judged code (the worker has its own loop),
so a retry-with-backoff candidate is judged instead of killed. Atomics
and SharedArrayBuffer join the blocked patterns: inside a vm they can
block past V8's interrupt check.
Replay matching compared method and url only, so two POSTs to one
endpoint collided, and a request that simply omitted its body could pick
up a response recorded for a specific payload. Cassettes now carry a
canonical hash of the request body and matching is symmetric on it: a
body-less request matches only body-less recordings. The request body
travels through the stub and recorder seams so the hash has something to
discriminate.

Secrets get two more doors closed: query parameters with credential
names are rewritten before storage, and method declarations can name
redactPaths -- dot-paths masked in recorded response bodies, with
rawBody re-serialized so the mask cannot be read around.
deploy_spawn consulted the fitness gate; clone_object, compose_organism,
and extract_organelle reached Factory.spawn directly. All four now pass
through one helper that names its policy: a staged draft is judged by
deployGate (compose judges the membrane source, not the organism's JSON
spec), while clone and extract carry a documented exemption -- they copy
source already live verbatim, with no draft to judge and no cassettes
under the new id. A structural test holds the door count at one.
effects said nothing about what kind; it is now sideEffects with the
values 'read-only' | 'mutating'. knownEntity is now entityRef.
Idempotence stays out of the enum on purpose: it already exists as a
relation kind, and one axis must not live in two fields. Both fields are
new in this branch, so nothing existing migrates.
Comparison flips catch inverted logic but not boundary slips: evidence
that cannot tell < from <= cannot certify a boundary, and pagination
offsets fail by sign as often as by direction. Each comparison site now
yields a negation flip and a boundary nudge; + and - swap. The weak-
evidence fixture's measured counts move from 1/4 to 1/6 killed --
same lone observable mutant, more honest denominator.
The + swap turned 'HTTP ' + res.status into a subtraction inside an
error branch no recording exercises -- a mutant that measures message
text, never behavior, and one that failed the house-style candidate for
its unobserved error path. A + with a string literal or template operand
is formatting; it yields no arithmetic site.
@andreBurnt

Copy link
Copy Markdown
Contributor Author

All six blocking items are now on the branch (85fc3a1..d238313, 9 commits). The suite went 67 -> 77 tests, tsc --noEmit clean, still zero new dependencies.

One deviation from the plan I posted above, and it is worth your attention. runInContext + microtaskMode: 'afterEvaluate' corrupts Node's async_hooks stack when vm promise jobs drain with an async_hooks consumer active. It is a native crash ("async hook stack has become corrupted") under node:test, and any AsyncLocalStorage user is exposed the same way. So the escalation path fired at implementation time: invocations now run on a worker thread the deadline can terminate (03c304e). That buys more than the vm approach promised. terminate() preempts sync spins, post-await spins, and Atomics.wait alike. resourceLimits bounds the candidate's heap, so an allocation bomb kills the worker instead of the judge. And timers work normally in judged code now (the worker has its own event loop), so a retry-with-backoff candidate is judged rather than killed. The stub crosses the thread boundary as data over a synchronous Atomics channel. No host closure is reachable from the candidate. Cost: one worker spawn per evaluate, reused across its invocations, ~15ms.

Per item:

  1. Mutation gating (5ee6cfa): checks carry a verified flag saying whether they judged any actual invocation outcome. The mutation loop refuses to run when no baseline check verified anything and reports your suggested wording. The relations-only-all-probes-throw edge is covered, and summarizeVerdict reads the flag instead of pattern-matching detail strings.

  2. Lazy parse (0dcee01): the parse lives inside afterResponse, after the mode checks. http-client.ts hands over raw text and parses nothing.

  3. Worker-thread judge (03c304e), described above.

  4. Body-aware matching (8e6eb2f): cassettes carry a canonical hash of the request body and matching is symmetric on it. A body-less request matches only body-less recordings, so omitting the body is a miss, never a wildcard. Query params matching your regex are redacted at record time. Method declarations take redactPaths for response-body masking, with rawBody re-serialized so the mask cannot be read around.

  5. Digest binding (85fc3a1): targetId is in the preimage and the separate check is deleted. Components are hashed individually before the outer hash (generated source can contain any byte, so field boundaries must not be reconstructible), and the methods JSON is canonicalized so key order cannot decide verdict validity.

  6. Unified spawn (5377f3d): one gatedSpawn door to Factory.spawn with an explicit per-op policy. compose_organism judges the membrane source, not the organism's JSON spec. clone_object and extract_organelle carry the documented exemption pending your ruling. A structural test holds the door count at one.

Renames (2b402a5): sideEffects: 'read-only' | 'mutating' and entityRef, as discussed. Mutation operators (7afa345, d238313): boundary and arithmetic are in. One nuance found by the suite itself: swapping + in 'HTTP ' + res.status turned an error message into a subtraction inside a branch no recording exercises, and that unkillable mutant failed the house-style candidate. A + with a string literal or template operand now yields no arithmetic site. Message formatting is not arithmetic.

Still open from my last comment: the clone/extract gating ruling, the FitnessGate facade question, and the HMAC trust root.

@mempko

mempko commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Couple more comments here after reviewing the fixes.

The blocking bug. opFitness digests the verdict against state.targetObjectId, but deployGate is called with the resolved target. My own prompt tells the agent that if it started a create loop and discovered it should be modifying an existing object, it should pass {objectId} to deploy_update. On that path the two digests can never agree, and the refusal message says "run fitness," which reproduces the identical mismatch. That is a permanent deadlock on a documented flow. deploy_spawn has the mirror of it: it passes no target while fitness digested one, reachable any time the agent has called load_target or clone_object.

There is a fix that removes this entire class of bug and solves something else at the same time: do not make fitness() an action at all. Evaluate inside the deploy ops, against the source and target they are actually about. There is then no verdict to carry, no digest to bind, and nothing to invalidate. It also fixes the turn cost. My commit 292c028 was specifically about not spending LLM turns on checks the machine can run for free, and your own comment says this one "costs a step" while deploy refuses without it anyway.

The gate does not verify anything yet. setRecorder has no callers and nothing writes cassettes:<objectId>, so loadCassettes always returns empty and every verdict takes the unverified-pass path. I ran it: fitness: PASS — unverified: replay, schema, relations, mutation. You are upfront about this, and I appreciate that, but it means what would land on main is a mandatory step that verifies nothing plus a new deadlock. This is the split I want: land the recorder wired end to end first, so there is real evidence, then the gate that judges it. The gate is the interesting half, but it is the half that cannot be evaluated until the first half exists.

When you wire it, two things have to change. Cassettes are keyed by AbjectId (msg.routing.from, cassettes:${targetId}), which is an ephemeral UUID; every restart orphans the evidence. AbjectStore keys by typeId for exactly this reason. And HttpClient imports a module-level recorders Map, but HttpClient is in workerEligible, so that map is per-thread and setRecorder can never reach it from another thread. The seam has to be a message on HttpClient, not an imported function. Everything between abjects here is send/request/event, without exception.

On the design. The pitch is "evidence the generator cannot edit," and I want that. But three of the four checks are written by the generator: outputSchema, relations, and entityRef all come out of the same LLM in the same draft. The digest stops it editing them afterwards; nothing stops it declaring a weak schema up front. Only replay is genuinely outside evidence, and replay is the one that is inert. I would rather have the one real check working than four with three of them self-graded.

That connects to something more fundamental. A schema is a frozen judgment that goes stale from the moment it is committed, and that what should accumulate in this system is the knowledge base, not the artifact. Properties an object's answers must satisfy feel like they belong there, learned from execution history and recalled into later work, rather than declared once in a manifest next to code that keeps moving. I am not certain about this, and I am open to being argued out of it, but a manifest field is the version of this idea I am least excited about.

Make the judge an abject. fitness.ts, cassette.ts, mutants.ts and sandbox-invoker.ts are plain modules that ObjectCreator imports. The rule in Abject is everything is an abject here, including the things that judge other objects. A FitnessJudge abject that ObjectCreator messages can be introspected, cloned, replaced, shared between peers, and healed when it is wrong. As a module it is a privileged layer that none of that applies to, and it also keeps ObjectCreator single-purpose.

Smaller things:

  • mutation: 'no mutation points' reports verified: true. That breaks the honesty rule the rest of the file keeps. With this operator set (comparison flip, drop .filter, empty array return, key swap, off-by-one, sign) an ordinary call-and-transform handler has zero mutation points, so mutation quietly self-certifies on the common case. It should be an unverified pass like every other "nothing could be judged" path.
  • WORKER_SOURCE hand-mirrors SANDBOX_BUILTINS and ScriptableAbject.PROXY_BUILTINS as a string literal. The gate then judges candidates under a copy of the runtime that will drift from the real one, which is a bad failure mode for a component whose whole job is fidelity. Derive it from the real thing.
  • target !== 'HttpClient' is a hardcoded object name. Nothing in this system should name a specific object; discovery goes through the registry and the ask protocol.
  • Please drop the 7 .test.ts files. I do not keep tests in this tree. tsc --noEmit plus one-shot scripts outside the project is how I verify, and I would rather see the scripts in the PR description than test files in the repo.
  • Atomics and SharedArrayBuffer in BLOCKED_CODE_PATTERNS changes what every scriptable abject may use, in order to protect the invoker. Your reasoning about Atomics.wait outrunning a vm timeout is correct, and I am inclined to take it, but call it out as its own change rather than folding it in here.

What I would merge, in order. One: the recorder, message-based on HttpClient, keyed by typeId, wired into an object's lifecycle so cassettes actually accumulate. Two: the manifest fields, if we still want them after that conversation. Three: the judge, as an abject, evaluated inside deploy rather than as its own action.

The worker-terminate reasoning about Atomics.wait versus a vm-level timeout is genuinely good and I want to keep it. Split it up and let us take the first piece if you prefer to do that.

@andreBurnt

Copy link
Copy Markdown
Contributor Author

All of it checks out, so this is short.

The deadlock: confirmed on your exact path. The action guide at object-creator.ts:3858 tells the agent to pass {objectId} to deploy_update mid-create. On that path fitness digested targetObjectId = undefined and the gate digests the resolved id. They can never agree, and the refusal advises the move that reproduces it. Your fix is the right one, not a patch. Fitness stops being an action. The deploy ops evaluate the draft against the target they just resolved, in place. The verdict-carrying and digest machinery gets deleted, and the turn comes back.

The split: agreed, in your order. Recorder first.

On the recorder, the thread problem is worse than you stated. HttpClient and ObjectCreator are both in the workerEligible list, so they can sit on different threads. A module import can't cross that under any wiring. The seam will be a message, and cassettes key by typeId (that is already AbjectStore's own contract: "live objectId, durable typeId"). One design question before I build it. HttpClient emits no events today, so the seam is new either way, and there are two shapes: a) HttpClient emits an exchange event per response and recorders observe it, so anything on the bus can watch traffic, or b) a recorder sends a subscribe message and HttpClient forwards exchanges only to registered recorders. Cassettes are evidence, so scoping matters, but (a) is more in the spirit of everything-on-the-bus. Which do you want?

Manifest fields: I'm taking your side. Three of the four checks were authored by the same model that wrote the source, and the digest only froze them after the fact. Here is where your knowledge-base instinct and the recorder-first order converge. Once cassettes accumulate per typeId, the schema does not need to be declared at all. Derive it from recorded traffic and store it in KnowledgeBase (ObjectCreator already discovers it as a dep and never uses it). Then the generator authors none of the evidence: replay checks conformance to observed reality, and the learned schema catches shape drift. Relations I'm less sure about, and you said you're not certain either. Proposal: the recorder PR carries no declarations at all, and we have that conversation with real cassettes in hand.

Judge as an abject: agreed. That lands in the judge PR along with three of your smaller items: 1) no mutation points becomes an unverified pass, you're right that it breaks the honesty rule, 2) WORKER_SOURCE gets derived from the real builtins instead of a mirror string, 3) the hardcoded HttpClient name goes.

Tests: dropped from the tree. One-shot scripts in the PR description from here on, same as the health-monitor PR.

Atomics / SharedArrayBuffer: split into its own small PR with the reasoning in the description.

So the series is 1) the recorder, message-based, typeId-keyed, wired into the object lifecycle so evidence accumulates, 2) the blocklist PR, 3) the judge as an abject, evaluated inside deploy. I checked v0.9.13 against this branch and nothing collides, so rebases are clean. Do you want #11 left open as the design thread until the judge PR replaces it, or closed now?

Still parked from my last comment: the HMAC trust root and the clone/extract ruling. Both belong to the judge PR's scope, so I'll bring them back there.

andreBurnt added a commit to andreBurnt/abject that referenced this pull request Aug 29, 2026
A CassetteRecorder abject subscribes to HttpClient's httpExchange events
(addDependent, the universal dependents protocol) and persists each
exchange under cassettes:<typeId> in Storage. The caller's identity is
resolved against the registry via resolveCallerIdentity - never trusted
from the payload - and exchanges whose caller has no durable typeId are
not recorded. A live AbjectId dies with its object; the typeId survives
restarts, so the evidence does too.

Retention is per endpoint bucket (method + path, query stripped, FIFO
cap 5) with an overall cap of 50 per typeId. Global overflow always
comes out of the LARGEST bucket, so a rare endpoint's only recording
survives no matter how old it is.

Failure independence: HttpClient discovery retries with capped backoff
for as long as the recorder lives - a permanent recorder that silently
gives up has failed its one job - and the subscription never waits on
Storage. While Storage is missing, recording continues in memory;
persistence catches up once it appears, merging what the store already
held so a restart never clobbers accumulated evidence.

Everything crosses the bus as messages. HttpClient and this recorder may
be hosted on different worker threads (both are workerEligible), which
is why no in-process seam could work. See mempko#11.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q23ProuK7YuZ4wtBbBmQGQ
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants