Skip to content

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

Open
araujof wants to merge 6 commits into
mainfrom
fix/payload_mutations
Open

fix: propagate plugin payload mutations instead of inferring them from text#157
araujof wants to merge 6 commits into
mainfrom
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>
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: In review

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