Record objects' HTTP traffic as typeId-keyed cassettes - #12
Conversation
HttpClient's four message entry points now emit a changed('httpExchange')
event after each completed request, carrying the caller's id
(msg.routing.from), the request, and the response as text. Redaction is
stem-based on NAMES at the emission boundary: headers, query params, and
JSON/form body fields whose name contains a secret stem (key, token,
secret, passw, credential, session, signature, cookie, auth) are replaced
with REDACTED before anything crosses the bus. A false positive redacts
something harmless; a false negative persists a live credential - so the
matcher errs toward matching. Bodies are capped at 64K characters with a
truncated flag.
Nothing on this path parses JSON. With no dependents subscribed the
emission is skipped entirely (new hasDependents accessor on Abject), so
an unobserved HttpClient does no serialization work at all. Emission
failures are logged and never break the reply to the caller. Requests
that throw before a response exists (timeout, DNS, the SSRF guard)
produce no event - evidence of failed transport is a different shape
and a deliberate non-goal here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q23ProuK7YuZ4wtBbBmQGQ
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
Same shape as HealthMonitor: registered constructor, workerEligible, supervisedSpawn permanent with a system typeId. Recording is on from boot - evidence accumulates without anyone asking for it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q23ProuK7YuZ4wtBbBmQGQ
mempko
left a comment
There was a problem hiding this comment.
Thanks for landing the recorder first, and for keeping the whole path on the bus. The shape is right: changed() as the seam, registry-verified attribution, typeId keys, merge before write. Two things need to change before this merges, one of them a design point.
Only record user-created objects
The purpose of the cassettes is evidence about objects the generator wrote. As built, the recorder captures every direct caller of HttpClient with a typeId, and that includes the system objects. The biggest one is LLMObject: every provider call goes through HttpClient's request handler (createFetchDelegate in llm-object.ts), so with this PR each completion becomes a cassette under cassettes:{peer}/system/LLMObject. That is full prompts, system prompts, chat history, and KnowledgeBase facts, 64K per body, 50 kept, in cleartext in Storage. The name-based redaction removes x-api-key and authorization but nothing inside messages or content matches a stem. WebFetch, WebSearch, WebAgent, OAuthHelper, and the catalog clients are in the same position.
Two problems come with that. First, it breaks the workspace privacy boundary: one pile under a single system key holds private prompts from every workspace. Second, volume: each flush ships the whole list, up to several megabytes, from the recorder's worker to Storage and rewrites one row every 500ms while the LLM is busy, which is the same shape as the per-frame saveData flood we already had to fix once.
Please have the recorder skip system-scoped callers and record only user-created objects. TypeIds already carry the scope: system objects are {peer}/system/{Name}, user objects are {peer}/{workspace}/user/{Name}. Checking the resolved typeId inside record() after resolveCallerIdentity is enough, and it keeps the emission side in HttpClient unchanged. Since system objects never produce cassettes then, a hot LLMObject also stops paying for the redaction and event build on every call, because the recorder is still a dependent. If you would rather the recorder subscribe with a filter so HttpClient skips the work too, that is fine, but keep the policy in the recorder, not in HttpClient.
Boot fails with workers enabled
CassetteRecorder is in the workerEligible list in server/index.ts but its constructor is never registered in workers/abject-worker-node.ts. Workers are on by default (cores minus one, minimum one), so the Factory sends the spawn to a worker, the worker answers No constructor for 'CassetteRecorder' in worker, supervisedSpawn rejects, and main() exits with a fatal startup error. Your scripts construct the recorder directly and never spawn through the Factory, so they cannot see this. Add the import and a constructors.set('CassetteRecorder', ...) line in the worker entry. CLAUDE.md calls this pitfall out under per-workspace objects, and it applies to anything marked worker-eligible.
Smaller items
- Export
CassetteRecorderand aCASSETTE_RECORDER_IDconstant fromsrc/index.ts, like the other system objects. - In
core/abject.tsthe newhasDependentsgetter landed betweenchanged()and its doc comment, so the note about not registering both notification styles now documents the getter. Move the getter above the comment. - When Storage is absent,
flush()re-arms a 500ms timer per typeId and callsdiscoverDep('Storage')on every tick. Backing that off the waysubscribe()does would be enough. - The
keystem redactskeyword=andidempotency-key. You called out the false-positive tradeoff and I agree with erring toward matching; just noting it so it does not surprise anyone reading a cassette.
Everything else checked out for me: the branch merges cleanly onto current main, tsc --noEmit passes on the merged tree, the Storage and Registry protocols match what the recorder sends, and the eviction and merge logic read correctly. With the user-only filter and the worker registration in place I am happy to merge.
Address review feedback on mempko#12: - Only record user-created objects (4-segment TypeId {peer}/{workspace}/user/{Name}) to preserve workspace privacy boundaries and eliminate storage flushes from system objects like LLMObject. - Register CassetteRecorder constructor in workers/abject-worker-node.ts and workers/abject-worker.ts to resolve fatal boot errors when workers are enabled. - Export CassetteRecorder, CASSETTE_RECORDER_ID, and Cassette from src/index.ts. - Move hasDependents getter above changed() doc comment in src/core/abject.ts. - Back off discoverDep('Storage') in flush() when Storage is absent instead of re-arming every 500ms.
|
Thanks for the thorough read, Maxim. All five points are addressed in a439f00, pushed to this branch. Only record user-created objects. Boot with workers enabled. Exports.
Storage discovery backoff. When Storage is absent, Re-verified: |
mempko
left a comment
There was a problem hiding this comment.
Thanks for the quick turnaround on a439f00. All four items from the last round are done the way I asked: the user-only filter lives in the recorder and matches the typeId shape AbjectStore stamps, the worker entries both register the constructor, the exports and the getter placement are right, and the Storage backoff is real. I merged the branch onto current main again and tsc --noEmit is clean. Reading the emission side more closely turned up two things that have to change before this merges, and one of them reframes the seam.
Exchanges are broadcast to anyone who asks
addDependent is the universal dependents protocol: any object on the bus can send it, and nothing checks who. As built, HttpClient emits every exchange to every dependent, so a generated object in any workspace can subscribe and receive every other object's HTTP traffic, including LLMObject's prompts and other workspaces' user objects' API calls, with only credential names scrubbed. The user-only filter in the recorder does not touch this, because the filter runs after the broadcast. That is the workspace privacy boundary broken one hop earlier than the Storage pile from the last round.
The fix is to stop treating this as a broadcast. HttpClient should discover the CassetteRecorder through the registry and send each exchange as an event straight to that id, and only that id. System objects knowing about each other is fine; that is what the registry is for. Re-resolve on recipientGone so a recorder restart picks back up, and drop the changed() path for exchanges entirely. That also halves the bus traffic per exchange, since changed() sends both the aspect event and a generic changed copy the recorder ignores, each carrying up to 128K characters across the worker boundary.
Skip LLMObject's calls at the source
Every provider call goes through HttpClient's request handler, and today every one of them pays for redaction and event construction before the recorder drops it. HttpClient should resolve LLMObject from the registry at init and skip emission for that caller. One id comparison per request, no policy about user objects in HttpClient, and LLMObject stops paying anything. If other system callers with large bodies turn up, the same shape covers them.
The redaction regex is superlinear
This still matters with the skip in place, because user objects' bodies go through it. capBody runs redactBodyText over the full body before the 64K cut, and JSON_SECRET_FIELD has the shape "[^"\\]*(?:stem)[^"\\]*"\s*:\s*". When a long string value contains a stem word and is not followed by a colon, the engine backtracks across every way of splitting the string around the stem before it gives up. I ran the two regexes exactly as written in the diff over synthetic JSON bodies whose string values contain "key", "token", and "session":
| Body | Time |
|---|---|
| 100 KB | 140 ms |
| 300 KB | 1.2 s |
| 1 MB | 9.7 s |
| 300 KB, no stem words in values | 1 ms |
That runs synchronously inside the request handler on a shared pool worker, so it stalls every object co-located with HttpClient. Same class of problem as the saveData flood.
The fix is small: match any string key / string value pair with a linear pattern and decide in a replacer, instead of putting the stem alternation inside the key's character class. Same for the form pattern. This keeps the "no JSON.parse on this path" property.
const JSON_STRING_FIELD = /"((?:[^"\\]|\\.)*)"(\s*:\s*")(?:[^"\\]|\\.)*"/g;
const FORM_FIELD = /(^|[&?])([^=&]*=)[^&]*/g;
function redactBodyText(body: string): string {
return body
.replace(JSON_STRING_FIELD, (m, key: string, sep: string) =>
SECRET_NAME_STEM.test(key) ? `"${key}"${sep}REDACTED"` : m)
.replace(FORM_FIELD, (m, lead: string, name: string) =>
SECRET_NAME_STEM.test(name) ? `${lead}${name}REDACTED` : m);
}With that, the same 1 MB body scrubs in 2 ms, and the stem cases from your scripts (api_key, access_token, refresh_token, nested auth.token, author left alone, escaped quotes inside a value) all come out the same. Please re-run http-emit-unit.ts against it and add a body in the hundreds of KB with stem words in the values, so this class of regression has a check.
Noted, not blocking
- If the Supervisor restarts
HttpClient, nothing re-establishes the link today. WithHttpClientdoing the discovery instead of the recorder, a freshHttpClientresolves the recorder at init and this goes away on its own. - The recorder keeps every user type's list in memory indefinitely and each flush rewrites the whole list. Fine at current volume.
- Cassettes for workspace objects land in global Storage rather than the workspace's Storage. Keys are scoped so nothing mixes, but the data sits outside the workspace boundary. Worth a conversation before the judge PR reads them.
With the direct-to-recorder seam, the LLMObject skip, and the linear scrub in place, I am ready to merge this.
…ar redaction HttpClient no longer broadcasts httpExchange through the dependents protocol, where any object could addDependent and read every other object's traffic. It discovers the CassetteRecorder through the registry (lazily: the recorder spawns after HttpClient at boot) and sends each exchange as an event to that one id, dropping the link on recipientGone so a restarted recorder is picked back up. The recorder no longer subscribes to anything; its user-only typeId filter stays. LLM's provider calls are skipped before any redaction or serialization. Each caller id is classified once against the global registry's current LLM and the verdict cached per id: ids are never reused, so a restarted LLM (fresh id) is classified afresh and a verdict never goes stale. While LLM cannot be found nothing is recorded at all, rather than recording on a guess. Body redaction is linear now. The old patterns put the stem alternation inside the key's character class and backtracked across every split of a long value containing a stem word (seconds per 100 KB, synchronous on a shared pool worker). The new patterns match every string-key/value pair and form field and test the stem in the replacer; output is unchanged on the existing cases. Drops the hasDependents getter from Abject, unused now. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019DwqpbcPoKB5zXWofg2br4
|
All three are in 97030bc, pushed to this branch. Direct-to-recorder seam. One deviation from "at init": LLM skipped at the source. Per request it is a Map lookup keyed by Linear redaction.
Output is identical on the existing cases (
Re-verified: |
Record objects' HTTP traffic as typeId-keyed cassettes
Piece 1 of the #11 series, per the order we agreed there: the recorder first, so evidence exists before anything judges it. No judging in this PR, no manifest declarations, no new dependencies. 3 commits, 4 files, +385/-5.
What this adds
Every HTTP request an object makes through
HttpClientnow leaves a trace: a cassette undercassettes:<typeId>inStorage, written by aCassetteRecorderabject spawned permanent at boot. Restart the object and the evidence is still there, because it is keyed by the durable typeId, not the ephemeral AbjectId.The seam
I asked in #11 whether you wanted an open exchange event or a scoped subscribe message. Reading the dependents protocol answered it:
changed()already IS both.HttpClientemitschanged('httpExchange', ...), and only objects that sentaddDependentreceive it. Anything on the bus may subscribe (your everything-on-the-bus spirit), nothing sees traffic without subscribing (my scoping concern). If you want a different shape, the emission is one method and moves cheaply.Everything crosses the bus as messages: the subscription (
addDependent), the exchanges (events), the persistence (Storagerequests), the attribution lookups (Registryrequests).HttpClientand the recorder are both workerEligible and may sit on different threads. There is no module state anywhere in the path.One line lands in
core/abject.ts: a protectedhasDependentsaccessor, so an unobservedHttpClientskips building event payloads entirely. Zero work when nobody subscribed.What the redaction does, and does not, promise
Redaction is stem-based on NAMES, applied before emission inside
HttpClient: headers, query params, and JSON/form body fields whose name contains a secret stem (key,token,secret,passw,credential,session,signature,cookie,auth) are replaced withREDACTEDbefore anything crosses the bus. That catchesauthorization,client_secret,refresh_token,x-amz-security-token,X-Amz-Signature, anaccess_tokenin an OAuth response body, apasswordin a form post - and whatever similarly-named header a generated object invents. A false positive redacts something harmless; a false negative persists a live credential, so the matcher errs toward matching.What it does NOT do: find a secret stored under an innocent name, or scan free text. It is a name-pattern scrub, not a secret scanner. If you want value-based redaction against what
SecretsVaultactually holds, that is a conversation I would have before building it - it means the vault's values reachHttpClientfor comparison, which has its own threat model. The request handed to the network is untouched either way; only the emitted copy is scrubbed.Two more honest boundaries: bodies are capped at 64K characters (not bytes) with a
truncatedflag, and requests that throw before a response exists (timeout, DNS failure, your SSRF guard) produce no cassette - only resolved responses are evidence, including 4xx/5xx. If you want transport-failure evidence too, that is an event-shape question and I would rather design it with you than guess.Design choices you should check
Attribution is registry-verified. The event carries
msg.routing.from, and the recorder resolves it throughresolveCallerIdentity- your existing anti-spoofing helper - rather than trusting the payload. A caller with no durable typeId is not recorded: a cassette that cannot be tied to a type is evidence about nothing.Eviction is bucketed by endpoint (method + path, query stripped): FIFO cap 5 per bucket, 50 per typeId, and global overflow always comes out of the LARGEST bucket. Your Fitness gate: judge generated objects by evidence they cannot edit #11 bucketing suggestion, landed here where
_httpvolume makes it real - a hot parameterized endpoint cannot evict the one recording of a rarely-hit one, no matter how old that recording is.The recorder never silently gives up. HttpClient discovery retries with capped backoff for as long as the recorder lives, and the subscription does not wait on Storage. While Storage is missing, recording continues in memory; persistence catches up when it appears, merging what the store already held so a restart never clobbers evidence. Both behaviors exist because scripted verification caught the failure modes, not because I thought of them.
Discovery is by registered name (
discoverDep('HttpClient')/discoverDep('Storage')), the same patternHealthMonitorand theAbjectbase class already use. I know your rule about naming specific objects - the sandbox hardcoding you flagged in Fitness gate: judge generated objects by evidence they cannot edit #11 special-cased behavior per name, which this does not. If you want capability-based discovery here instead, say so and I will follow whateverHealthMonitormigrates to.The cassette shape is contributor-authored infrastructure, not generator-authored evidence. Your Fitness gate: judge generated objects by evidence they cannot edit #11 concern was the generator declaring the standards it is judged by. Nothing here is written by a generator: the recorder records what actually crossed the wire, and a judged object cannot influence what gets recorded about it. The shape itself is yours to review like any other code.
fetchBase64does not emit. Base64 image fetches are not replay evidence and would blow the body cap for nothing. Say the word if you want it covered.Verification
tsc --noEmitclean. Three one-shot scripts (below, not committed - your convention), 16 checks total, run withnpx tsx --test <file>:recorder-unit.ts- recorder behaviors against the real bus and real dependents protocol: typeId keying, no-typeId skip, merge-not-clobber on restart, per-bucket eviction, largest-bucket overflow eviction, late-registering HttpClient, recording through a Storage outage.http-emit-unit.ts- emission behaviors with onlymakeRequeststubbed (your SSRF guard blocks loopback, so no live socket): caller attribution, header/query/body redaction incl. the stem cases above, truncation, all four entry points, zero work when nobody subscribes.recorder-live.ts- end to end with production classes: realRegistry, realStorage, realHttpClient, real recorder. Boot-style spawn and register, discovery, subscription, a caller's request, cassette read back fromStoragewith the secret gone.lab/recorder-unit.ts
lab/http-emit-unit.ts
lab/recorder-live.ts
What comes next in the series
The blocklist PR (
Atomics/SharedArrayBuffer, its own reasoning), then the judge as an abject, evaluated inside the deploy ops. Once cassettes accumulate here, the learned-schema conversation from #11 has real data to stand on.