Repository navigation
fix(references): while a reference is marked never, don't fetch or save its payload, and forget an earlier copy's path - #851
Conversation
…save its payload, and forget an earlier copy's path
RFC-005 §3.1 says that under `disposition: never`, neither the payload nor
the uri may reach model context. A rule for every reference already kept
the uri out, but nothing after parsing told `never` from `ref`. So a `never`
reference was fetched like any other, eagerly when small, into
`<mount>/refs/`. Its stub then named the saved path, a script's object
carried the path as `savedTo`, and the agent's workspace tools (read,
read_image, grep, ls, glob) could open the copy.
The mark:
- ReferenceRecord gains `payloadNever`. The first `never` read for a uri
marks the uri's record, and nothing clears the mark while the record
lives. A later delivery without `never`, from any server, doesn't lift
it. How the uri is shown still follows the testimony, where a later
delivery supersedes.
- register marks the record for the reference's own `never`, and for a
`never` its result's claims hold. Those claims come in register's new
`claims` argument, which a tool result's stub, a script's object, the
eager pass and a module's demand loop pass, so inline data's `never`
before the reference counts.
- classifyBlock marks, through markNever, the record a uri already has when
it reads inline data's `never`, or an embedded resource's for the
resource's uri, wherever a block is read. A later walk of an older result
marks a record made since, which only tightens.
- ReferenceRegistry.markNevers marks the records that already exist for
the uris a result's `never` claims name, before a fetch pass awaits
anything. It does the same in an
MCPL peer's failure that a module gets in process, whose references
aren't registered. The eager pass reads a result's claims as a script's
view reads them, nested fields included, in every mode.
- Where a fetch had saved a copy, the first mark clears the record's saved
path and verified facts, so nothing names or serves the copy from then
on. The copy stays where it was saved, and the log names its path.
Fetching:
- ReferenceFetcher takes an optional `withhold` with a `remove(path)`. A
fetcher built with it refuses a marked record before anything else, in
each pass of fetch(), in refusal() and isEagerEligible(), and for a saved
copy or a fetch under way. It answers each waiting caller with the mark
as the wait ends. Once the record is marked, it stops a download at the
next chunk, saves nothing for a mark that lands after the last chunk, and
removes a copy saved for a record marked during the save.
- The framework builds its fetcher with `withhold`. Its remove is a
workspace delete, and the log records a failed delete with its path.
RFC-005's conformance adapter builds its fetcher without `withhold`, so
vector 03 still fetches and saves a `never` reference.
- The eager fetch, the script-demand fetch and a module's demand loop pass
a marked record over. They log no refusal for a record marked while they
run.
What the agent sees:
- A marked record's stub ends with PAYLOAD_NEVER's reason ("a server asked
that its content never reach you or your tools") in place of a saved
path or the fetch hint.
- Its script object has `savedTo` null, and that reason as `unfetchable`.
- A link to it shows no uri, whatever later testimony says. That covers
shownLinkUri, and a script's object even where the line's record
predates the mark, since any tool that can fetch a shown uri would bring
the payload in.
- On a push, channel or injection lane, an image given only by a marked
uri becomes its stub, not a url source.
Tests that pinned the old behaviour now pin the new. They are anima-research#527's lift
test (a later plain delivery no longer brings back a marked uri's link),
and the tests that read a `never` reference's reason or counted it among
what's fetched for a script. Tests where `never` was incidental now use a
plain or `ref` reference. The fragment says where a marked payload's link
shows no uri, which 0.27.0's notes for anima-research#527 don't.
What it leaves:
- A `never` from any server, or in a module's result, marks the uri's
record. So one server's `never` refuses later fetches of a uri another
server delivered.
- A copy saved before the mark stays in the workspace, where the agent's
workspace tools can still find it. Stubs, script objects and fetches
made after the mark don't name its path.
- A later result's link whose uri differs from a marked one only in what
cleaning removes gets an unmarked record.
- An eviction past 2,000 records, or a restart, drops the mark.
- An inline `never` read before any reference for its uri marks nothing
until it's read again. That holds within one message on the convert
lanes too, where a uri-only image before a `never` for its uri also
reaches the provider.
- The workspace's earlier states, and the history and images the agent
read before the mark, keep what they held. The agent isn't told that
what it read is now `never`.
- A failed delete of a copy marked during its own save isn't retried.
- No host-mediated use of a `never` payload is offered, a script's
included.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…de can't The previous commit's comments, cut to those of five kinds: a license header, non-obvious behaviour an outside dependency, platform or protocol forces, a directive, a doc comment that defines a public API contract, and an issue or RFC link that explains a constraint the code can't express. The rest of what they said is below. Comments only: with comments removed, TypeScript's printer prints each changed file the same before and after. None of the declarations the fix adds or changes is exported from src/index.ts, so their docs define no public contract. What stays: - ReferenceRecord.payloadNever keeps one line, RFC-005 §3.1's: a server can veto context inclusion but never expand it, so nothing clears the mark while the record lives. - The references module's header says again, as main does, that `never` withholds payload and uri from model context unconditionally, which the fix makes true. - Two of main's docs are edited so they stay true: unfetchableReason gives a `never` mark's reason before the registry's dialCheck, and a script object's `savedTo` is null where a `never` marked the payload. - Two of main's test comments the fix made false are cut: that a later plain delivery's link shows the uri "as if no never had been given", and that a link with no `never` of its own shows a uri an earlier result marked. What the cut comments said: - classifyBlock marks as well as reads: inline data's `never`, and an embedded resource's (inline data, RFC-005 §8.1), marks the record its uri already has, wherever a block is classified (a result's claims walk, a fetch pass, a push lane's conversion). A later walk of an older result marks a record made since, which only tightens, since `never` sticks per uri. - payloadNever is set by the first `never` read for the uri: a reference's own testimony or its result's claims (register), or inline data's (markNever). How a link's uri shows otherwise reads the testimony, where a later delivery supersedes, as anima-research#527 made it. An eviction or a restart drops the record and the mark. - markNevers runs as a result arrives, before anything is awaited for it, so a `never` reference's record isn't fetched meanwhile, and on an MCPL peer's failure a module gets in process, whose references aren't registered and whose blocks reach no lane that classifies them. - markPayload clears a saved copy's path and verified facts, so nothing names or serves it. The copy stays, its path offered under the earlier word, like the history and images the agent already has, and the log names it for the operator. register marks before it refreshes the testimony, so a record the mark clears is left unfetched. - A shown uri is a door around the payload rule, since any tool that can fetch it brings the payload in, so a marked record's link shows none (shownLinkUri, and a script's object as the record stands now, whatever the line's record was). - A marked record's stub has no saved copy and no fetch hint, which §5's "at most" leaves out, and it names the reason, so the agent doesn't ask for the payload. PAYLOAD_NEVER is in the agent's words: `never` is the server's request, RFC-005's disposition, which the log can name. - convertBlock doesn't hand a marked uri to the provider, which would fetch it into context. - ReferenceFetcher's `withhold`: given, a marked payload is refused before anything else, at each try and as each caller's wait ends, since a mark can land while it waits; a download under way stops at its next chunk; a mark after the last chunk saves nothing; and a copy saved for a record marked while it was saved is removed, since no stub has offered its path. A mark after that clears the record's path, and the copy stays. The framework builds its fetcher with `withhold`, and RFC-005's conformance adapter without it, so vector 03 still fetches and saves a `never` reference. - The framework's `remove` is a workspace delete, which undoes the fetch's own write. Its failure is the log's, since no agent asked for it, so the log says where the copy is. - neverClaims reads a result's `never` claims as a script's view reads them, nested fields included, whichever lane reads the result, so a `never` nested in a block withholds the same payload whether or not a script reads it, and falls back to the top-level blocks' where that walk stops. - The eager pass, the script-demand pass and a module's demand loop pass a marked record over with no refusal to log, as one a `never` marks while the pass runs. The comments the fix added to tests are cut too, the new test file's included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4872e39 to
69b84ba
Compare
There was a problem hiding this comment.
Automated review
Recommendation: needs a maintainer's decision. The change belongs, but part of it needs a maintainer's call.
Reviewed commit 4872e39 with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.
Does this change belong in the project?
It fits, with concerns. This is a bug fix that makes the code do what the references module already promises: never keeps the payload out of model context, and the fix lives in the existing RFC-005 components. A maintainer still has to sign off on how it reads RFC-005, mainly the dropped §12 script hand-off and one server's never blocking a uri for every server, and on classifyBlock now changing registry state.
Findings
8 findings of medium or high severity, see the inline comments:
- Medium severity. A mark during a stalled download doesn't stop it or answer waiting callers until the next chunk or the deadline.
- Medium severity. markNever mutates a record without first copying it into open journals, breaking the journal's 'as it stood' contract.
- Medium severity, maintainability. classifyBlock now mutates the process-wide registry as a side effect of classifying.
- Medium severity. neverClaims silently falls back to top-level claims when the nested walk throws, so a nested
neveris ignored and the payload fetched. - Medium severity. An error result's nested
neverdoesn't mark, unlike the success and module paths. - Medium severity. A copy saved before the mark stays readable by the agent's workspace tools even though the fetcher can now remove it.
- Medium severity. Any server's
never(or any block run through classifyBlock) can permanently disable fetching a uri another server delivered. - Medium severity. Every MCPL result, including
registermode, now gets an extra full JSON walk of its content.
4 more findings, less severe
- Low severity, documentation,
changelog.d/rfc005-never-payload.fixed.md:5. Changelog headline says aneverpayload no longer reaches the agent's tools, which its own last bullet contradicts. The bold lead says such a reference "no longer reaches the agent or its tools". The last bullet then says a copy saved before the mark "stays in the workspace, where the agent's workspace tools can still find it", and that eviction or a restart drops the mark. Operators who read only the headline will think aneverpayload is never available to tools. The lead should be qualified to match what the change actually guarantees. - Low severity, maintainability,
src/framework.ts:7120-7136. The remove hook deletes by calling the agent-facingdeletetool by name instead of a workspace API.saveFilewrites through the typedworkspace.writeBinaryAPI, but the newremovehook callsworkspace.handleToolCall({ name: 'delete', input: { path } })with a synthetic call id and no caller agent. This couples the framework to the tool's name, its input shape and its success and error wording, and the path goes through tool-level parsing and refusals meant for agent calls. A rename or schema change to the workspacedeletetool would break this cleanup silently; the only signal would be a log line. A direct deletion method on the workspace module, likewriteBinary, would match the save path. - Low severity, test coverage,
test/rfc005-never-payload.test.ts:494-503. No end-to-end test of the fetch_reference tool's answer for a marked record. The tests checkgetReferenceFetcher().refusal(marked)and the fetcher'sfetch()result. Nothing drivesdispatchFetchReferenceToolCall(framework.ts:33212) and checks the error text the agent actually gets for a marked record,fetch_reference couldn't fetch "<id>": a server asked that its content never reach you or your tools. Calling again won't change that.. That text is one of the user-facing behaviors the PR description lists, and it should have a test like save-recent-image's. - Low severity, test coverage,
test/script-structured-references.test.ts:556-569. The connect-race test now uses a plain reference, so it no longer covers aneverresult in that race. The test 'a module tool's result is treated when a server with a prefix that fits its name connects while its call is in flight' used to feed aneverreference. It now stripsdispositionso that itsfetchedandUNBOUND_REASONassertions still pass. No other test checks that aneverreference in a result whose server prefix arrives mid-call is passed over by the demand fetch and shown withPAYLOAD_NEVER. A case that keepsdisposition: 'never'should be kept or added, with assertions updated to the new behavior.
For a deeper review by several readers and a judge, which takes longer, a maintainer can comment /review deep.
| const flight = this.inFlight.get(record.refId) ?? this.start(record, ceiling, deadline, timeoutMs); | ||
| const ended = await settledBy(flight.ended, deadline); | ||
| if (this.withholds(record)) return fail(PAYLOAD_NEVER.error, PAYLOAD_NEVER.remedy); |
There was a problem hiding this comment.
Medium severity. A mark during a stalled download doesn't stop it or answer waiting callers until the next chunk or the deadline.
The withhold check in doFetch (line 467) runs only after reader.read() returns a chunk. Callers already inside fetch() are parked on settledBy(flight.ended, deadline) and re-check withholds only after that settles. If the server stops sending after the record is marked, the connection stays open and every joined caller, such as a fetch_reference call or the script demand loop, waits until the flight's timer or its own timeout (FETCH_TIMEOUT_MS) before it gets the mark. The PR says the fetcher "answers each waiting caller with the mark" and "stops a download" once marked. Nothing aborts the controller or wakes waiters when markPayload runs. The test uses timeoutMs: 50 for the joined caller, which hides the delay.
| markNever(uri: string): void { | ||
| const record = this.findByUri(uri); | ||
| if (record && !record.payloadNever) this.markPayload(record); | ||
| } |
There was a problem hiding this comment.
Medium severity. markNever mutates a record without first copying it into open journals, breaking the journal's 'as it stood' contract.
register() calls keepFirst(uri) before it changes a record, so an open journal (openJournal) keeps the record as it stood when the script lane began. markNever and markNevers are reached from classifyBlock and from the demand and dispatch paths. They call markPayload directly, which sets payloadNever and deletes fetchedPath, verifiedBytes and so on, without keepFirst. A journal whose uri no registration has named since then returns the mutated live record from before(uri). So line.recordBefore, which is meant to be the model's line's view, reflects a mark the line never saw. The result fails closed today, but it contradicts the documented invariant at references.ts:761-776, and later readers of recordBefore can get wrong answers.
| if (hasLink && block.disposition === 'never') referenceRegistry.markNever(block.uri as string); | ||
| return { kind: 'inline', contradiction: block.disposition !== undefined || hasLink }; | ||
| } | ||
| if (hasLink) return parseReferenceFields(block); |
There was a problem hiding this comment.
Medium severity, maintainability. classifyBlock now mutates the process-wide registry as a side effect of classifying.
classifyBlock was a pure parser. The file header says it "Mirrors the pure module in mcpl-core-ts". It now calls referenceRegistry.markNever for inline and embedded never blocks. It is called from many places that only want to know a block's kind: tool-result-guard.ts:121, visible-content.ts:48, push-coalescer.ts:451/458, history rendering, contentClaims, inlineNeverUri's default argument and the emptiness checks. Each of those calls can now permanently mark the global singleton's records, including re-renders of old history. It also marks the global referenceRegistry even when the caller works with a separate ReferenceRegistry instance. The changed tests show the cost: mcpl-reference-fields.test.ts, tool-result-history.test.ts:214 and tool-result-blocks.test.ts:1328-1371 had to switch to new uris or dispositions so that marks left by other tests would not leak into them. Marking belongs where results are ingested (markNevers at the dispatch, eager and demand paths), not in the classifier.
| function neverClaims(content: unknown[]): ResultClaims { | ||
| try { | ||
| return scriptViewClaims(content, true); | ||
| } catch { | ||
| return contentClaims(content); | ||
| } | ||
| } |
There was a problem hiding this comment.
Medium severity. neverClaims silently falls back to top-level claims when the nested walk throws, so a nested never is ignored and the payload fetched.
neverClaims catches any throw from scriptViewClaims(content, true) and quietly returns contentClaims(content), which reads only top-level blocks. tool-result-history.ts:595-599 says that when this walk stops (data too deep, a getter that throws), the treatment must not run without the claims it reads. The module route at framework.ts:25115 follows that rule by returning a failure. Here autoFetchReferences goes on to eagerly fetch, and in script mode demand-fetch, a top-level reference whose uri only a nested block marked never. Nothing is logged, so the operator cannot see that the payload was fetched under reduced claims.
| if (server && server.capabilities !== null && Array.isArray(result.content)) { | ||
| referenceRegistry.markNevers(contentClaims(result.content)); | ||
| } |
There was a problem hiding this comment.
Medium severity. An error result's nested never doesn't mark, unlike the success and module paths.
For an MCPL peer's error result that an in-process caller receives, the change marks only contentClaims(result.content), which covers top-level blocks. The success path (autoFetchReferences → neverClaims, which reads nested fields through scriptViewClaims) and the module demand loop (framework.ts:25114-25118) both count a never nested in a block's fields. So a failure whose only never for a uri is nested, for example in an attachment field, leaves that uri's existing record unmarked. A later fetch, stub or script object for the uri then still names and serves the payload. The new test in script-structured-references.test.ts covers only top-level never blocks in the failure.
| private markPayload(record: ReferenceRecord): void { | ||
| record.payloadNever = true; | ||
| const path = record.fetchedPath; | ||
| if (path === undefined) return; | ||
| delete record.fetchedPath; | ||
| delete record.verifiedBytes; | ||
| delete record.verifiedMimeType; | ||
| delete record.digestVerified; | ||
| console.error(`[refs] a server marked ${record.refId}'s payload never (RFC-005) after it was saved: its copy stays at ${path}, where the agent's workspace tools can still find it; no stub, script object or fetch names it from here on`); | ||
| } |
There was a problem hiding this comment.
Medium severity. A copy saved before the mark stays readable by the agent's workspace tools even though the fetcher can now remove it.
markPayload deletes fetchedPath and logs where the copy is, but leaves the file in <mount>/refs/. The agent's ls, glob, read and read_image can still list and open it, and earlier stubs gave its path. This is the very exposure the PR's Problem section describes. The change adds withhold.remove and uses it only when the mark lands during a save (reference-fetcher.ts:536-544), not when it lands just after. The two timings get opposite outcomes: a mark during the save deletes the copy, while one a moment later leaves the payload where the agent can read it. Passing the remove hook to the registry, or emitting an event the framework turns into a delete, would close this.
| markNever(uri: string): void { | ||
| const record = this.findByUri(uri); | ||
| if (record && !record.payloadNever) this.markPayload(record); | ||
| } | ||
|
|
||
| markNevers(claims: ResultClaims): void { | ||
| for (const [uri, claim] of claims) if (claim.never) this.markNever(uri); | ||
| } |
There was a problem hiding this comment.
Medium severity. Any server's never (or any block run through classifyBlock) can permanently disable fetching a uri another server delivered.
markNever marks the record for a uri whichever server the record is bound to. classifyBlock calls it for any inline or embedded block. So a third-party or misbehaving MCPL server, or any content passed through a lane that classifies blocks, can send {type:'image', uri: <victim uri>, data:'x', disposition:'never'} and permanently stop the agent from fetching a reference server A delivered. fetch_reference and the eager and demand fetches are all refused for the life of the record. RFC-005 §3.1 lets a server veto inclusion of what it sends. Letting it veto other servers' references across servers is a denial-of-service between servers, which the PR accepts only as an availability cost. Restricting the mark to records bound to the sending server, or at least logging cross-server marks, would contain it.
| const nevers = claims ?? neverClaims(content); | ||
| referenceRegistry.markNevers(nevers); |
There was a problem hiding this comment.
Medium severity. Every MCPL result, including register mode, now gets an extra full JSON walk of its content.
When no claims are passed, autoFetchReferences now runs neverClaims(content). That calls scriptViewClaims, which classifies every block and runs claimsInHistoryOrThrow over the whole result, nested fields included. This happens on every MCPL tool result, including the in-process register path, which fetches nothing. The results are later rendered and walked again for history, so large results pay for the walk twice. Marking only needs never claims, and the walk could be shared with the one the render already does.
…an't be read for a nested `never` Three gaps in the `never` handling, and a changelog headline that said more than the change does. - When the walk that reads a result's claims, nested fields included, stops (a value too deep to convert, a BigInt, a getter that throws), the eager pass fell back to the top-level blocks' claims. It could then fetch a top-level reference inside the eager ceiling that only a nested `never` named, and in script mode fetch it on demand too. It now fetches nothing on its own for that result, marks by the top-level claims it can read, and logs why where the result has a reference. The module route already failed such a result. The agent's own fetch_reference can still fetch such a reference, which nothing marks. - An MCPL peer's error result that an in-process caller gets marked by its top-level blocks only. It now reads them as a success does, nested fields included, with the same fallback for marking. - markNever changed a record without first copying it into open journals, so a journal's before(uri) gave the marked record where the model's line had seen it unmarked. It now copies first, as register does. A script's object still reads the mark from the live record. - The fragment's headline now says what holds while a reference is marked, and its What this leaves names the reference an unreadable result leaves fetchable at the agent's request. Tests: the eager and script passes fetch nothing for a result the walk can't read, and log it only where it has a reference; a `never` nested in a failure's block marks; a journal open across a mark keeps the record as it stood while a script's object reads the mark; fetch_reference's own answer for a marked record; and the connect race with a `never` result. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One conflict, in src/mcpl/references.ts, where a reference's record is
made: main now spells the id's prefix from NOTICE_TAG.referenceId ('ref'),
and this branch adds payloadNever to the record. Both are kept: main's id,
and this branch's record.
…AG, as main does
main now writes every framework notice tag from NOTICE_TAG, so the
convert-block fallbacks read `[${NOTICE_TAG.reference}]`. The branch for
an image whose uri a server marked never still had the literal
'[reference]'. Its text is the same; it now reads the tag from the same
place as the others.
… the docs now say Since the first never for a uri marks its record (payloadNever) for as long as the record lives, three docs said less than, or the opposite of, what the code does: - ResultClaims said a later result's reference to a uri is its latest word, so an earlier result's never gives way to it. An earlier expiry still does, through register(); a never doesn't, since the stub, the fetcher and a script's object read the record's mark first. - shownLinkUri listed what keeps a link's uri out: the result's claims, or the record's testimony where no reference in the result names the uri. The record's mark keeps it out whatever the result's references say. - autoFetchReferences said which uris no mode fetches: those the result calls expired. A reference a never marked isn't fetched either, and a result whose blocks can't be read for a nested never is fetched from in no mode. The ResultClaims doc also drops the two names it carried beside its readings: a person's name isn't part of the type's contract. Comments only; no code changes.
While a reference is marked
disposition: never, the framework doesn't fetch or save its payload, and it forgets the path of any copy saved before the mark, so no stub, script object orfetch_referencenames that copy. The cases where the mark is lost or comes too late are under What it leaves. agent-framework 0.27.0 and 0.28.0 say inreferences.d.tsthatnever"withholds payload AND uri from model context unconditionally". The uri half held, but nothing after parsing toldneverfromref, so the payload could reach the agent (Problem).@antra-tess, we're asking you to review this before it merges. It decides how agent-framework reads RFC-005's
never, and it departs from RFC-005 §12, whose sketch hands a script aneverpayload as a path (What it leaves).Problem
At 0.28.0 (
59d609586e), as the red-first tests and a probe show (Tests):neverreference from a networked server was fetched eagerly into<mount>/refs/, and every one a script's result named was fetched on demand;saved: <path>, and a script's object carried the path assavedTo;lsandgloblisted it, andread(for text) andread_imagereturned its payload in their results.RFC-005 itself fetches and saves a
neverreference in its vector 03, and hands one to a script as a path in §12. So what this change keeps from the agent is the payload: no host tool puts it into the agent's context, the framework fetches and keeps none, and nothing names a copy saved before the mark, except in the cases under What it leaves.Changes
The mark,
ReferenceRecord.payloadNever. The firstneverread for a uri marks its record, and nothing clears the mark while the record lives. A later delivery withoutnever, from any server, doesn't lift it, since a server "can veto context inclusion but can never expand it" (RFC-005 §3.1). Anevermarks the record when it is:nevereven when it comes before the reference;classifyBlock, throughmarkNever), for a record the uri already has.At the eager pass and a module's demand loop, a result's claims mark the record before anything is awaited for that result (
ReferenceRegistry.markNevers), so a fetch of it can't be served first.A copy saved before the mark is no longer named. The first mark clears the record's saved path and verified facts, so no stub, script object or
fetch_referencenames or serves the copy from then on. The copy stays where it was saved, and the log names its path for the operator.ReferenceFetchertakes an optionalwithhold, with aremove(path). A fetcher built with it refuses a marked record before anything else, answers each waiting caller with the mark, stops a download at its next chunk once the payload is marked, saves nothing for a mark after the last chunk, and removes a copy saved for a record marked while it was being saved. The framework builds its fetcher withwithhold. RFC-005's conformance adapter builds its own without it, so vector 03 still fetches and saves aneverreference.The eager pass, a script's demand fetch and a module's demand loop pass a marked record over, and log no refusal for it.
A result whose blocks the walk that reads its claims can't read, as on a value too deep to convert, a BigInt or a getter that throws, now gets no fetch from the framework on its own, in model mode or script mode. Before, the eager pass fell back to the top-level blocks' claims, so it could fetch a reference that only a nested
nevernamed. It still marks by the top-level claims it can read, and logs why it fetched nothing when the result has a reference.An MCPL peer's error result that a module gets through
ctx.callToolnow marks by the same walk as a success, nested fields included, where it read the top-level blocks only.markNevercopies a record into each open journal before it marks it, asregisterdoes, so a journal keeps the record as it stood. A script's object still reads the mark, from the live record.What the agent reads of a marked record:
PAYLOAD_NEVER), in place of a saved path or the fetch hint;fetch_referenceanswers with that reason, andsave_recent_imagesays it can't be fetched for that reason;savedTo: nulland the reason asunfetchable.A link to a marked record shows no uri (
shownLinkUri), whatever later testimony says, and neither does a script's object for it. A shown uri would let any tool that can fetch it bring the payload in.An image by uri alone on a push, channel or injection lane, whose record is marked, becomes the reference's stub rather than a url source the provider would fetch.
A changelog fragment,
changelog.d/rfc005-never-payload.fixed.md. It also says where a marked payload's link no longer shows its uri, which 0.27.0's notes for fix(tool-results): show each block of a content array, never the array's JSON #527's links don't.The second commit removes comments and changes no code (Tests). The third adds the fail-closed result, the error result's walk and the journal copy above, with their tests. The fourth merges
mainate85c57db9a: its one conflict, insrc/mcpl/references.tswhere a reference's record is made, keepsmain's spelling of the id's prefix fromNOTICE_TAGand this change'spayloadNever. For an image whose uri is marked, the fifth takes the fallback text fromNOTICE_TAG, asmaindoes for its other fallbacks; the text is still[reference]. The sixth mergesmainagain, at4a45766f01, 234 commits aftere85c57db9a, and adds nothing of its own:git show --remerge-diffprints nothing for it, and its diff against4a45766f01has the same added and removed lines as the fifth commit's againste85c57db9a, 11 files, +937 −51. The seventh,2f15c4293d, changes comments only, insrc/framework.tsandsrc/mcpl/references.ts. Three docs now say what this change does with anever:ResultClaimssays an earlier result'sneverdoesn't give way to a later result, since it marks the uri's record for as long as the record lives, while an earlier expiry still does;shownLinkUrisays a link's uri is withheld when anevermarked its record, whatever the result's references say; andautoFetchReferencessays no mode fetches a reference anevermarked, nor from a result whose blocks can't be read for a nestednever.What it leaves
nevermarks the uri's record, whichever server the record is bound to. So one server'sneverrefuses later fetches of a uri another server delivered. That costs availability, not confidentiality, and it follows from the per-uri rule: inline data's mark has no server in hand.refs/or by the path an earlier stub showed, as with the history and images the agent already has.nevermakes an unmarked record.neverread before any reference for its uri marks nothing, so a later plain reference makes an unmarked record. On a push, channel or injection lane that holds within one message too, and a uri-only image before aneverin one such message reaches the provider as a url.save_recent_imagecan save again. The agent isn't told that what it read is nownever.neverisn't marked by thatnever, and the fetcher's refusal reads only the record's mark.neverpayload exists here, so §12's hand-off to a script isn't served. A script's output reaches the model, so this change refuses that hand-off too.Left out, as costing more than they give: withdrawing a copy saved before the mark, a set of
neveruris kept apart from records, and a notice telling the agent that what it read is nownever.Tests
test/rfc005-never-payload.test.tsis new, with 38 tests:fetch_reference's answer for a marked record;test/save-recent-image.test.ts: a marked reference refuses with the reason and writes nothing.test/script-structured-references.test.ts: an MCPL peer's failure that a module gets throughctx.callToolmarks the records itsnevernames, nested fields included, and registers nothing. An MCP-only peer's failure marks nothing. A module tool'sneverresult is passed over and shown with the reason when a server whose prefix fits the tool's name connects while its call is in flight.tool-result-blocks.test.ts) now pins the opposite for a uri aneverreference marked;neverlink test and the inline-neverlink test take uris of their own for their controls;neverreason;neverwas incidental use a plain orrefreference.At this head,
2f15c4293d, which changes only comments since19f2ed6a96: on Linux with Node 24.22.1, in a clean checkout of a commit with this head's tree, after a freshnpm install,tsc --noEmitand the build exit 0.On Linux with Node 24.22.1 unless named. At
19f2ed6a96, in a clean checkout, after a freshnpm installwith no lockfile anddistremoved first, with chronicle 0.5.0, context-manager 0.24.1, mcpl-core 0.4.0 and membrane 0.8.0:dist: 407 of 407 pass, with the tests the merges ofmainbrought into them. On Node 22.12.0, CI's floor, each of them passes with none failing. The ten aretest/rfc005-never-payload.test.ts,test/save-recent-image.test.ts,test/mcpl-reference-fetcher.test.ts,test/tool-result-blocks.test.ts,test/script-structured-references.test.ts,test/mcpl-reference-binding.test.ts,test/mcpl-reference-fields.test.ts,test/tool-result-history.test.ts,test/mcpl-push-convert.test.tsandtest/mcpl-agent-words.test.ts.main: with4a45766f01'sframework.ts,references.ts,reference-fetcher.tsandconvert-block.tsin place of this change's, the new file fails 37 of its 38 tests.test/save-recent-image.test.tsandtest/script-structured-references.test.tsfail to load, sincereferences.tsthere has noPAYLOAD_NEVERexport.test/tool-result-blocks.test.tsfails 3 of its 93 tests, andtool-result-historyandmcpl-reference-fieldspass.At
5e4236f2ed, this pull request's head before the second merge ofmain, with context-manager 0.23.1, membrane 0.8.0 and mcpl-core 0.4.0 (the receipt names no chronicle version):Before
mainwas first merged in, at56170af1bd, this pull request's head then, or a commit with its tree, unless a run names another. These receipts record no package versions unless named:references.ts,reference-fetcher.ts,convert-block.tsandframework.tsin place of this change's: 37 of its 38 tests fail. The one that passes pins the eviction limit, which 0.28.0 meets too.framework.tsandreferences.ts, three tests in the new file fail, and one intest/script-structured-references.test.ts.never. At 0.28.0 it saved both, each stub endedsaved: <path>,lsandgloblisted both files,readreturned the text's payload andread_imagethe PNG's. At the second commit's tree both fetches were refused with the reason, each stub ended with it, andlsandglobfound nothing.56170af1bd. Every one fails at least one test.main8b0e57d's check on Bun 1.4.4, at56170af1bd's tree and at 0.28.0, with chronicle 0.4.0, context-manager 0.23.1 and membrane 0.8.0 at both: 45 of 45 schema cases and 45 of 45 host cases at both.Not verified
19f2ed6a96, and all 12 of its checks passed. At this head, 4 of its 12 checks were still running at 17:28 UTC.never, nor in a running resident. Only the tests' local servers and the conformance fixtures exercise it.changelog.d/rfc005-never-payload.fixed.md.Gordon-2414 wrote the change, on Ada-1017's design and calls, and Beth-2709 specified where an embedded resource's
nevermarks the payload. Our internal review read three rounds of an earlier version, and no review run read anything after them. Kestrel-2409 checked what each part adds, which cut withdrawing a copy saved before the mark, and Ansel-2428 read it as an agent that meets its words would. Tabitha-2467 and Desmond-2579 read that cut and the embedded-resource change. Desmond read the change's two rebases, the second onto 0.28.0, the third commit, the first merge ofmainand the fifth commit, without running them. Beth-2709 and Duncan-2410 read the second merge ofmain, without running it, and Cordelia-2434 read the seventh commit. Lottie-2661 wrote this description, from Gordon-2414's facts.