Skip to content

feat(cmf): prompts and resources are routable but their payloads are not addressable #75

Description

@terylt

Description

The routing layer already carries prompts and resources end to end: prompt: and resource: selectors parse, hook_pair_for_entity maps them to cmf.prompt_pre_invoke / cmf.prompt_post_invoke and cmf.resource_pre_fetch / cmf.resource_post_fetch, HookFamily::for_entity puts them in the CMF family, and the visitor installs a phase-bound AplRouteHandler for each. That half holds.

The data plane does not. The projection between a CMF message and the args / result values APL evaluates against knows three content parts: a tool call and a prompt request on the way in, a tool result on the way out (crates/ppe-apl-runtime/src/message_projection.rs). Everything else falls back to "the concatenated text parts of the message", and a message carrying a resource or a rendered prompt has no text parts, so the fallback is the empty string. A result: pipeline on a resource route reads nothing, rewrites nothing, and reports no error.

So today a resource route can decide whether a fetch is allowed, from the URI in meta.entity_name and whatever the host puts in MCPExtension, and it cannot see, classify, or redact a single byte of what came back. Redacting what comes back is most of why a response-side route exists.

Evidence

A temporary probe in crates/ppe-apl-runtime/tests/visitor_e2e.rs, run against 36a2357, dispatched cmf.resource_post_fetch for a route resource: hr://employees/E1 against a message whose one content part was a ContentPart::Resource with content: Some("topsecret"):

  • post_invocation: ["result == 'topsecret': deny"] allowed. So did result.content == 'topsecret'.
  • post_invocation: ["result == '': deny"] denied.

The second is the control: the handler is installed on the post hook and does run, and what it evaluates result against is the empty string while the resource body sits untouched in the message. The probe was removed; nothing is committed.

What is projected today

entity and phase what APL sees as args / result
tool, pre the tool call's arguments object, addressable by field
tool, post the tool result's content value, addressable by field
prompt, pre the prompt request's arguments object, addressable by field
prompt, post empty string. PromptResult.messages and PromptResult.content are not read
resource, pre empty string, or the message text if the host sent one. ResourceReference.uri, range_start, range_end, selector are not read
resource, post empty string. Resource.content, blob, mime_type, annotations are not read
llm, both the concatenated Text parts. See the separate LLM issue

Two consequences beyond the missing reads.

Write-back is the inverse of the same function, so a pipeline that does produce a value on a resource or prompt route writes a Text part into a message that carried a resource or a rendered prompt (write_result_back_to_message falls through to rewrite_message_text). The original part is left in place and a text part is appended next to it. Nothing today reaches that path because the pipelines never fire, but it is the shape the fix has to avoid.

Nothing rejects the config. reject_field_stages_without_fields refuses an args: or result: block only on an http: route, on the grounds that HttpPayload has no field for a path to address. A resource route's payload has fields; they are just not projected. So an operator writing result: { ssn: "str | mask(4)" } on a resource route gets a load with no diagnostic and a policy that does nothing.

Design questions to settle first

A resource body is not JSON. Resource.content is Option<String> and Resource.blob is Option<Vec<u8>>. Options: project the string as a scalar (result is the body, result.<field> addresses nothing), or parse it when mime_type says JSON and project the parsed object, or project a fixed envelope such as result.content, result.mime_type, result.uri, result.size_bytes with the body under result.content. The envelope reads best to me: it makes result.mime_type == 'application/json' writable, keeps a field path meaningful, and does not make policy behaviour depend on a parse that can fail. A blob probably projects its metadata and not its bytes, with deny or drop as the only operations on it.

A prompt result is a list of messages. PromptResult.messages: Vec<Message> plus an optional flat content. The bag has no list-of-object representation, and scanning a rendered prompt for injected instructions is precisely the use case. This is the same problem as the multi-part LLM message and the A2A artifact, and it is tracked as issue-multi-view-addressing.md. The single-part projections in this issue can land without it and should not wait for it; the messages case belongs there.

Worth knowing before anyone starts: the read side is largely written already. MessageView::content() in crates/ppe-core/src/cmf/view.rs reads Resource.content and PromptResult.content, which are the two values message_projection.rs cannot see, and MessageView::uri() and name() cover the resource reference fields. iter_views() produces the views and has no caller in the workspace outside its own unit tests. Some of this issue may be wiring rather than new code.

The pre-fetch side needs the request fields. A resource fetch carries a URI and optionally a range or a selector. Whether those arrive as a ContentPart::ResourceRef or only in MCPExtension is a host contract question that is not written down anywhere in this repo. Nothing in this tree parses MCP wire format, so the contract with the host is the deliverable, not just the projection.

Acceptance criteria

  • args and result project a documented shape for ContentPart::ResourceRef, ContentPart::Resource, ContentPart::PromptRequest, and ContentPart::PromptResult, and the doc comment on message_projection.rs carries the table.
  • Each projection round-trips: extract, mutate one field, write back, and the message differs in that field and nowhere else. No appended Text part on a message that carried a structured part.
  • An args: or result: block that addresses a shape with no field path is refused at load with a message naming the block and the entity type, the way an http: route's is today. Silence is what made this invisible.
  • e2e tests that redact a field of a fetched resource and of a rendered prompt, and that deny on their contents. Assertions on a deny, not on an allow.
  • A written host contract for what a host must put on the message and in MCPExtension for a prompt route and a resource route to work, since nothing in this tree produces either.

Test coverage today

Hook names as they appear across the workspace, which is roughly where the demos have been: cmf.tool_pre_invoke 310, cmf.tool_post_invoke 44, cmf.llm_input 38, cmf.resource_pre_fetch 14, cmf.prompt_pre_invoke 13, cmf.llm_output 13, cmf.resource_post_fetch 4, cmf.prompt_post_invoke 4. The non-tool entities are mostly covered by wiring tests that assert a request was allowed, which is the assertion that cannot distinguish a route that fired from one that did not.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions