Fitness gate: judge generated objects by evidence they cannot edit - #11
Fitness gate: judge generated objects by evidence they cannot edit#11andreBurnt wants to merge 26 commits into
Conversation
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.
Overall Impressions & RigorFirst 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:
Architectural Assessment: Alignment with the Abject PhilosophyIn 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 (
Blocking Issues1. Mutation Check Inversion Bricking Deployments When Recording is Enabled (
|
|
Went through all six blocking items against the branch head (
Renames: Mutation operators: comparison flips exist today ( Architecture. Your point about the sandbox blocking Cassette integrity (HMAC): where would the key root? If it lives in the same Eviction bucketing: agreed, deferring to the recording PR where 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.
|
All six blocking items are now on the branch ( One deviation from the plan I posted above, and it is worth your attention. Per item:
Renames ( Still open from my last comment: the clone/extract gating ruling, the |
|
Couple more comments here after reviewing the fixes. The blocking bug. There is a fix that removes this entire class of bug and solves something else at the same time: do not make The gate does not verify anything yet. When you wire it, two things have to change. Cassettes are keyed by 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: 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. Smaller things:
What I would merge, in order. One: the recorder, message-based on The worker-terminate reasoning about |
|
All of it checks out, so this is short. The deadlock: confirmed on your exact path. The action guide at The split: agreed, in your order. Recorder first. On the recorder, the thread problem is worse than you stated. 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 Judge as an abject: agreed. That lands in the judge PR along with three of your smaller items: 1) Tests: dropped from the tree. One-shot scripts in the PR description from here on, same as the health-monitor PR.
So the series is 1) the recorder, message-based, 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. |
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
The problem
ObjectCreatorhas 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, avalidate_callspass, and areview_semanticsstep 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.
no-duplicates,sorted-by,idempotent,subset-on-tighter-filter, and anon-empty-for-known-entityprobe. These catch what a single assertion cannot..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_spawnanddeploy_updatethen 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_updateadditionally refuses a verdict earned against a different target object.A failing verdict reads like this:
What a method declares
New optional fields on a manifest method. Manifests written before these existed stay valid.
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
HttpClientgrows 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
runSandboxedwith the documentedHttpClientresponse shape ({status, statusText, headers, ok, body:string}), a handler-proxythis, 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
_httptraffic 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:Honest scope
The gate is wired and enforced, but
setRecorderhas 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
_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.compose_organismandextract_organelledeploy already-existing source without the gate.getBase64/fetchBase64bypasses the recorder (separate code path). Binary fetches are invisible to the gate.HttpClientis stubbed in the sandbox invoker. WebFetch, WebParser and Storage-based objects fail closed withunstubbed I/O.authorization,cookieandset-cookierequest 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.
tsc --noEmitclean. No new dependencies. ajv and acorn were already inpackage.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.