Skip to content

fix: propagate plugin payload mutations instead of inferring them from text - #157

Merged
araujof merged 7 commits into
devfrom
fix/payload_mutations
Aug 7, 2026
Merged

fix: propagate plugin payload mutations instead of inferring them from text#157
araujof merged 7 commits into
devfrom
fix/payload_mutations

Conversation

@araujof

@araujof araujof commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Closes #151.

A plugin that mutated anything other than a message's text had its mutation dropped: the APL route handler decided "was this modified?" by comparing get_text_content(), which reads Text parts and ignores the other eleven ContentPart variants. Redactors are the plugins that rewrite tool results, so the failure was fail-open this path, and the plugin reported a successful redaction and the host forwarded the original secret.

What the fix turned out to need

The issue suggested the invoker already knows, since result.modified_payload is Some when a plugin returns a mutation. It isn't a signal: PipelineResult::allowed_with sets modified_payload: Some(payload) on every allowed pipeline. Acceptance is only observable inside Executor::run_serial_phase, so that is where it is now recorded, surfaced as PipelineResult.payload_modified.

That also exposed another bug: because the old code keyed off is_some(), every plugin(...) stage in a field pipeline reported a new field value whether or not the plugin mutated, so a non-mutating stage overwrote its field with the message's concatenated text.

Fixes

Each replaces an inferred change with a signal from the code that performed it.

  1. Direct mutations reach the host regardless of which content part they touched.
  2. Pipeline write-back applies only the paths the pipeline changed, so a pipeline redacting one argument and a plugin scrubbing another both survive. Covers args: and result:.
  3. Pipeline modification is read from RouteDecision.args_modified / result_modified, which the handler previously ignored in favour of projecting and diffing.
  4. Field-stage dispatch reports a value for the field it was pointed at; a plugin that changed something else reports no field change, and its mutation travels with the payload.

Tests

New tests across three layers. Each mechanism was verified by reverting it and confirming the tests fail:

reverted failing tests
handler branch conditions 6 e2e
per-path merge only 4 e2e
field readback 2 invoker
executor signal 2 e2e + 1 core
field-name convention 1 apl-core

Coverage includes the content-part variants a plugin realistically rewrites (text, thinking, tool call arguments, tool result), both phases, several plugins accumulating, a denied route forwarding nothing, an audit-mode plugin whose mutation the executor rejects not being reported, omit, and nested paths. The other eight ContentPart variants are not exercised.

make lint clean; full workspace suite green (73 binaries). Each commit compiles on its own.

Behaviour notes for reviewers

  • PipelineResult gains a field. Construction goes through allowed_with / denied, so callers using those are unaffected.
  • A pipeline writing a field to the value it already held now reports a modified payload where it previously reported none. Fail-safe direction, pinned by a test.
  • PluginInvocation::Field.name is now root-relative from do:-block field ops (city, not args.city).
  • The workaround in the issue (appending a throwaway text part so the old check fired) can be deleted.

One known limitation is documented in code but not fixed: a field pipeline's interim edits are not visible to a plugin(...) stage later in the same chain. Fixing it changes what every downstream plugin sees, so it wants its own issue.

Plan: docs/plans/2026-08-06-001-fix-modify-payload-propagation-plan.md, including a note on where the plan's premise was wrong.

Additional issues found during self-review:

  • A plugin(...) stage undid an earlier mask / redact / hash stage in the same chain. The field readback was compared against the pipeline's interim value, but interim edits never reach the payload — so the payload's untouched original looked like the plugin's new value and was written back. Pre-change this path wrote the message's concatenated text, so the fix had turned field destroyed into plaintext restored. Now compared against the field as the payload held it before dispatch, with a mask | plugin(...) regression test that fails without it. Three reviewers converged on this independently.

Also addressed:

  • Merge tie-breaks are documented and logged: args: / result: pipeline edits take precedence over a plugin's edit to the same path (config-author-wins), and a key the plugin removed is reinstated if the pipeline rewrote it. Both warn with the field name instead of resolving silently.
  • Removed an unreachable fallback in the result write-back whose wholesale write would have silently reintroduced the clobbering bug this PR fixes.
  • payload_modified now crosses the FFI to the Python and Go bindings; the Go Invoke doc example no longer presents ModifiedPayload != nil as a mutation test (it is true on every allowed pipeline). Additive on the wire, ABI version unchanged.
  • PipelineResult sealed #[non_exhaustive], mirroring RouteDecision.
  • Pre-projections phase-gated; Post-phase field readback now covered; CHANGELOG section order fixed and the Field.name semantic change marked BREAKING.

Deferred: #158 (parallel plugin branches lose each other's payload mutations, and validate_parallel_plugin_modes is never wired into registration, mostly pre-existing, and the reject-at-load vs hold-the-lock choice wants a deliberate call).

araujof added 3 commits August 6, 2026 02:10
`modified_payload` is `Some` on every allowed pipeline, carrying the
final payload whether or not a plugin touched it, so callers had no way
to tell a mutation from an echo. Record acceptance where it happens and
report it as `PipelineResult.payload_modified`.

Also note on `get_text_content` that it reads text parts only and is not
a change detector.

Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
A `do:` block field op passed `args.city` while the `args:` / `result:`
sections passed `city`. One convention everywhere lets an invoker look
the field up in its own payload projection; stripping the prefix
defensively could not work, since `args` is a legal argument name.

Exports `get_dotted` so host bridges read fields back with the same path
semantics the evaluator writes them.

Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
…arts

The route handler decided whether a plugin had mutated the payload by
comparing concatenated text content, so a rewritten tool result, tool
call, or thinking block looked identical to no mutation and was dropped.
Redactors are exactly the plugins that rewrite tool results, so this
failed open on the path that matters most. It now reads the signal the
invoker records when it accepts a mutation.

Two more mutation-losing paths in the same block:

- Folding an `args:` / `result:` pipeline's rewrite back into the message
  replaced the whole argument map or result content, clobbering a
  plugin's edits to other fields of it. Only the paths the pipeline
  changed are applied now.
- Pipeline modification is read from the decision's own flags rather than
  re-derived by projecting and diffing.

A plugin invoked as a pipeline stage now reports a value for the field it
was pointed at instead of the message's concatenated text, which for a
structured tool call overwrote an argument with chat text.

Moves the message projections into their own module, shared by the
handler and the invoker so they cannot drift.

Closes #151

Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
@araujof
araujof marked this pull request as ready for review August 6, 2026 06:16
@araujof
araujof requested review from jonpspri and terylt as code owners August 6, 2026 06:16
@araujof araujof added bug Something isn't working framework Rust labels Aug 6, 2026
@araujof araujof added this to CPEX Aug 6, 2026
@github-project-automation github-project-automation Bot moved this to Backlog in CPEX Aug 6, 2026
@araujof araujof moved this from Backlog to In review in CPEX Aug 6, 2026
@araujof araujof added this to the 0.2.3 milestone Aug 6, 2026
araujof added 3 commits August 6, 2026 03:36
A pipeline's interim edits live only in the pipeline, never in the
payload, so comparing a field readback against the stage's current value
reported the payload's untouched original as the plugin's new value. A
`mask | plugin(...)` chain handed the plaintext back and forwarded it.

Compare against the field as the payload held it before dispatch instead.

Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
The per-path merge silently let pipeline edits override a plugin's edit
to the same field, and reinstated keys the plugin removed. Document the
precedence and warn on both, naming the field.

Also removes the write-nothing-is-safer fallback that would have folded
in an unattributable diff, phase-gates the pre-projections so neither is
computed where it cannot be read, seals PipelineResult as
#[non_exhaustive] mirroring RouteDecision, and covers the Post-phase
field readback.

Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Non-Rust hosts could not tell an accepted mutation from a payload the
pipeline merely carried, so they were left inferring it from content --
the failure this fix removes for Rust callers. Additive on the MessagePack
wire, so the ABI version is unchanged.

Also corrects the Go Invoke doc example, which presented
`ModifiedPayload != nil` as a mutation test; it is true on every allowed
pipeline.

Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
@terylt

terylt commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Nice work. I read the design doc and traced the correctness-sensitive paths
(executor signal, invoker flag + field_before baseline, the three-way merge,
and the field-name convention). Built locally: 884 tests pass, clippy clean
on apl-core / apl-cpex / cpex-core. The security fix is exact — signals
replacing text-diff inference — and the deferred items (#158, mid-pipeline
payload visibility) are scoped honestly.

One minor suggestion (non-blocking)

In apply_changed_paths (crates/apl-cpex/src/message_projection.rs:203-207),
the key-removal loop resolves silently, while the sibling conflict cases all
warn (warn_on_conflict, plus the both-added branch):

for key in pre_map.keys() {
    if !post_map.contains_key(key) {
        base_map.remove(key);   // drops a plugin's edit to this key with no log
    }
}

So a pipeline omit that removes a key a plugin had just redacted is the one
tie-break in this function that doesn't announce itself — which reads slightly
against the function's own stated bar that "a silent tie-break in this path is
exactly the class of bug this function exists to prevent." The outcome is
correct and safe (the key is gone either way, no plaintext leak); this is purely
observability. A warn! here when the removed key differs from what the plugin
left, mirroring the reinstate case, would close the symmetry.

One note for operators (not a change request)

Worth calling out in the changelog/behavior notes: the fail-safe direction means
payload_modified now trips Some on more requests than before (a plugin
returning an untouched clone, or a pipeline writing a field to its existing
value). Inside the engine on the pure-Rust path this is cheap — Value clones
and a Message clone, no serialization. But a host that keys its wire
re-encode off "did the payload change?" (and the FFI msgpack step) will now
run on somewhat more routes. Correct trade — a false positive costs one re-encode,
the false negative was the bug — just want it visible so nobody reads a small
throughput shift on mutating routes as a regression.

@terylt terylt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

The removal branch of the per-path merge was the one tie-break that
resolved silently, against the same bar the sibling cases meet. Warn
naming the field, and cover the conflicting removal, which the existing
test left uncontested.

Also records in the changelog that payload_modified errs toward
reporting a change, so hosts keying a wire re-encode off it can size
the extra work.

Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
@araujof
araujof changed the base branch from main to dev August 6, 2026 22:22
@araujof
araujof merged commit 89b261f into dev Aug 7, 2026
@araujof
araujof deleted the fix/payload_mutations branch August 7, 2026 01:20
@github-project-automation github-project-automation Bot moved this from In review to Done in CPEX Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working framework Rust

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

[BUG]: modify_payload mutations to non-Text ContentParts are silently discarded by AplRouteHandler

2 participants