RFD 098: Request-Response Event Linking
- Status: Discussion
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2026-06-07
- Extends: RFD 097
Summary
The events that answer a chat request carry no explicit reference to the request they answer; the relationship is inferred from position within a turn. This RFD adds a request link: an optional request_id field on the stream-entry wrapper, holding the event_id (introduced in RFD 097) of the originating ChatRequest. The stream stamps the link at insertion, so it is persisted and survives reordering and deletion as a detectable reference rather than a positional guess. This RFD defines which event kinds are linked, where the link lives on disk, and what every reader does with a missing or dangling link.
Terminology
- Originating request: the
ChatRequestwhose answer is being produced. - Immediate request: the paired
ToolCallRequestorInquiryRequestthat aToolCallResponseorInquiryResponsematches via its existing payloadidfield. - Request link: the
request_idreference from an answering event to its originating request.
The request link is a new, separate concept. ToolCallResponse.id and InquiryResponse.id keep their existing immediate-request pairing semantics unchanged; this RFD does not touch them.
Motivation
A conversation stream interleaves requests — chat requests from the human — with the events that answer them: chat responses, plus the tool and inquiry events produced while answering. The binding between an answering event and its originating request is currently positional: a reader assumes the event belongs to the most recent request in the same turn.
Positional binding is fragile under exactly the hand edits JP encourages on events.json. Deleting a digression, rewinding an in-flight query, or dropping a noisy tool call can silently change which request a response appears to answer. It is also ambiguous even without edits: a turn can contain more than one ChatRequest (interrupt replies inject additional requests mid-turn), so "the request of this turn" is not always a single event. Several proposed capabilities — branching, undo, compaction anchoring, faithful turn reconstruction, and multi-participant conversations — need to know unambiguously which request a given event answers, and cannot rely on position surviving a structural edit.
RFD 097 gives every stream entry a stable event_id but deliberately leaves reference semantics to its consumers, requiring each reference-bearing feature to define its own orphan and ambiguity handling. This RFD is one such consumer: it records the answer-to-request relationship as an explicit event_id reference and defines that handling.
Design
Linked event families
Each event produced while answering a ChatRequest carries a request link to that ChatRequest:
ChatResponse(message, reasoning, and structured variants)ToolCallRequestToolCallResponseInquiryRequestInquiryResponse
Not linked:
TurnStart,ChatRequest— they open scopes, they don't answer oneConfigDelta,Compaction— global entries, not part of any answer- unknown (forward-compatibility) events — opaque, passed through verbatim
All five answering families link to the originating ChatRequest, not to their immediate request. Tool and inquiry events keep their immediate pairing through the existing payload id; the request link groups the whole answer — response, tool round-trips, inquiries — under the request that caused it. Linking all five here, rather than only ChatResponse, keeps the invariant in one place, so a consumer that needs the whole answer — participant attribution, branching, faithful turn reconstruction — takes the grouping as-is instead of redefining it.
Where the link lives
The link is a wrapper-level field, next to event_id on the stream-entry wrapper defined by RFD 097:
struct InternalEvent {
event_id: EventId,
request_id: Option<EventId>,
payload: EventPayload,
}Wrapper-level, not payload-level, for the same reasons RFD 097 put event_id on the wrapper:
- Constructors stay unchanged.
ChatResponse,ToolCallRequest, and the other payload types gain no field and no constructor argument. TheEventBuilderinjp_llmand the turn coordinator injp_cli, which construct payloads with no stream context, need no plumbing to learn the request's ID. - The stream is the one place that knows the scope. IDs are assigned where the stream's existing entries are in scope (RFD 097); the active originating request is known in exactly the same place.
- No payload serde churn.
ChatResponseis an untagged serde enum; injecting a field into every variant is invasive. On the wrapper,request_idserializes besideevent_iduniformly for every linked kind.
How the link is assigned
The stream stamps request_id when it wraps a payload into an InternalEvent, extending RFD 097's insertion-time assignment:
- The active originating request is turn-scoped: a
TurnStartclears it, and inserting aChatRequestsets it. - When a linkable payload (the five families above) is inserted, the wrapper gets
request_id: Some(<the active request's id>). - Non-linkable payloads get
request_id: None. - Linkable events inserted before any
ChatRequestin the current turn (a malformed state;sanitizealready repairs its stream-leading form) are inserted unlinked rather than rejected — never linked to a previous turn's request.
When a turn contains multiple ChatRequests (interrupt replies), answering events link to the newest request at their insertion time. This is the binding today's positional readers assume; the link makes it persistent and explicit.
Stamping at insertion time relies on position — and that is sound: position is trustworthy at creation, when events are appended in causal order. The point of the link is that later readers and editors no longer have to trust position. Note that a link is always created in the same session as its target (an answer is produced after its request, in the same run), so links never point at RFD 097's transient in-memory IDs of never-persisted legacy entries: link and target are persisted together.
Storage
request_id serializes on the stream-entry object, beside event_id:
{
"event_id": "k3m9x2a",
"type": "chat_request",
"timestamp": "2026-06-07 10:00:00.0",
"content": "Review this PR"
}{
"event_id": "p8q2r4b",
"request_id": "k3m9x2a",
"type": "chat_response",
"timestamp": "2026-06-07 10:00:01.0",
"message": "I'll review it."
}{
"event_id": "w5t7y1c",
"request_id": "k3m9x2a",
"type": "tool_call_response",
"timestamp": "2026-06-07 10:00:02.0",
"id": "call_1",
"content": "...",
"is_error": false
}The name request_id collides with nothing: the tool and inquiry families serialize a top-level id (their immediate pairing), and RFD 097 chose event_id for the wrapper identity precisely to leave id alone. The field is omitted when None. The format is forward-compatible (older readers ignore the unknown key) and backward-compatible (files without it load as unlinked, see below).
Unlinked versus unresolved
Two distinct states, with distinct handling:
- Unlinked: the field is absent. Legacy events, and event kinds that never link. Unlinked events keep today's positional reader behavior unchanged.
- Unresolved: the field is present but does not bind. A
request_idis unresolved when:- no stream entry has that
event_id; - the target exists but is not a
ChatRequest; - the target ID was flagged as duplicated by RFD 097's load-time repair (references to a duplicated ID are ambiguous and treated as unresolved, per RFD 097).
- no stream entry has that
JP never repairs an unresolved or missing request_id by position. Falling back to position would reintroduce exactly the silent rebinding this RFD exists to eliminate.
Invariant enforcement
Each stream pass has one job:
- Load preserves raw events verbatim, including unresolved links. Raw history is the user's file; loading does not rewrite it.
sanitizeneither infers, rewrites, nor drops based onrequest_id. This mirrors RFD 097 keeping ID repair out ofsanitize. The existing orphan repair for tool and inquiry pairs continues to operate on the payloadidfields, unchanged.- Provider projection (
Thread::into_parts) excludes events with an unresolved request link from the provider request, closed over immediate tool pairs and reported as a diagnostic. Unlinked events are unaffected. The ordering, closure rule, and diagnostic surface are defined in Provider-projection exclusion below. jp conversation edit --eventsdocuments the field: deleting aChatRequestleaves its answer unresolved (and therefore its raw events provider-invisible) until the user also deletes or relinks those events. An existing compaction summary covering the exchange still projects (see Provider-projection exclusion).
Provider-projection exclusion
Resolution and exclusion run on the raw stream, before compaction projection. This order is forced, not chosen: projection consumes Compaction entries, drops covered events, and synthesizes summary entries that carry no event_id, so links can only be resolved while their targets still exist. It is also safe: TurnStart and ChatRequest events are never linked and never excluded, so exclusion cannot shift turn numbering or suppress summary injection.
Compaction summary overlays are unaffected by exclusion: a summary covering an excluded answer still projects. The raw invariant holds either way — unresolved-linked events never reach the provider — but the summary's derived text is a separate concern (see Risks).
Pair closure. Exclusion is closed over immediate tool pairs: when either half of a ToolCallRequest/ToolCallResponse pair is excluded, both halves are. Matching uses the payload id, scoped to the turn and count-aware, tolerating providers that reuse one tool-call ID within a turn. For streams JP wrote itself this changes nothing — every event of an answer shares one request_id, so the group is already excluded whole. Hand edits, however, can split a pair across linked and unlinked halves; without closure the provider view would contain an orphaned tool call, which providers reject. Inquiry pairs need no closure rule: inquiry events are excluded from provider input by the visibility allowlist regardless of linking. Closure applies to the projected provider copy only; raw history is never rewritten.
Diagnostics. Exclusion is reported as data, not output. Projection returns a diagnostic alongside the projected events, naming the excluded events and the missing or ambiguous target. jp_conversation renders nothing; jp_cli surfaces the diagnostic on the chrome channel (RFD 048) before the provider call, and also emits it to tracing. By default JP writes tracing only to the log file, so a log-only warning would be invisible in exactly the hand-edit workflow this RFD is written for; the chrome channel is visible at default verbosity.
The diagnostic is data internally and prose externally. The projection-level diagnostic names, at minimum: the excluded event IDs (including pair-closure additions), the unresolved request_id, and the reason (missing target, wrong-kind target, or duplicated target). Every rendering must convey this information, but the chrome rendering — text or JSON — is not a stable machine interface: under --format json it appears as the generic {"message": …} chrome envelope, like all chrome. Scripts must not parse it; a stable structured surface, if ever needed, would be a separate proposal built on the internal diagnostic type.
Making the diagnostic precede the provider call changes a boundary: today each provider invokes Thread::into_parts internally, after jp_cli has handed off the query. This design moves projection to the query caller: the turn loop projects the thread via jp_conversation before constructing the provider query, renders the diagnostic, and providers receive already-projected parts — they no longer invoke projection themselves. Non-interactive query paths (title generation, compaction summaries) have no chrome channel and emit the diagnostic to tracing only.
Backward compatibility
Legacy events without request_id remain unlinked on load. JP does not infer links by position during normal reads, and does not backfill on save: a value JP never knew is not invented. New events appended to a legacy conversation are linked normally from their first insertion. Features that consume request links must tolerate mixed streams by treating unlinked events as they are treated today — positionally. A future explicit repair command may offer best-effort relinking of well-formed legacy turns; that is tooling, not read-path behavior (see Future Work).
Drawbacks
- The wrapper gains an optional, kind-dependent field. Unlike
event_id, which every entry has,request_idis meaningful only for the five linked families. The type system no longer guarantees the field's presence rules; tests do. - A bad edit now shrinks provider context. Deleting a request while keeping its answer excludes that answer's raw events from provider input; an existing compaction summary covering the deleted exchange may still describe it until compaction is reset or regenerated. That is the intended, detectable alternative to silently rebinding the answer to the wrong request — but it is a behavior change a hand-editor must learn, mitigated by the chrome-channel diagnostic (visible at default verbosity on the next query) and the
edit --eventsdocumentation. - Hand edits have one more field to maintain. Moving an answer to a different request means updating
request_idby hand. - Another
InternalEventserde change. Small, and it rides on the serde rewrite RFD 097 already performs; sequencing after RFD 097 keeps it one migration. - Providers stop owning projection. Surfacing the diagnostic before the provider call moves the
Thread::into_partscall site from the providers to the query caller — a wider mechanical change than the field itself, and the real cost of the visibility promise.
Alternatives
- Payload-level
request_idonChatResponse,ToolCallRequest, etc. Rejected: it requires every payload constructor and theEventBuilder/ turn-coordinator pipeline to learn the request's ID, touches an untagged serde enum, and contradicts RFD 097's model of assigning identity fields where stream context exists. - Keep positional binding. The status quo; its failure under structural edits is the motivation.
- Link only
ChatResponse. Smaller, but tool and inquiry events would still bind to their request positionally, and any consumer needing the whole answer would have to define the wider grouping itself — same invariant, two homes. - Positional fallback for unresolved links. Undercuts the RFD: an edit would again silently rebind answers.
- A payload-level pairing
id, like tool calls use. Adds a second ID namespace with per-kind matching rules instead of reusing the stream-wideevent_idaddressing RFD 097 was built to provide.
Non-Goals
- Participant attribution. Which assistant produced an event is a concern for a future multi-participant conversations RFD; this RFD provides the request grouping such a design builds on.
- Branching, undo, and compaction anchoring. They are consumers of the link, each with its own RFD; nothing here implements them.
- Immediate-request pairing.
ToolCallResponse.id/InquiryResponse.idsemantics, and thesanitizeorphan repair built on them, are untouched. - Projection-created synthetic events. Compaction projection synthesizes
ChatRequest/ChatResponsesummary pairs that exist only between projection and provider conversion; they are never persisted and carry norequest_id. This scoping is safe, not merely convenient: summary policies cover whole turns, so a summarizedChatRequesttakes its entire answer with it — a surviving event outside the range cannot dangle into it. Summary overlays are likewise out of scope: exclusion never drops or rewrites a summary (see Risks). - Backfill and repair tooling. See Future Work.
Risks and Open Questions
- Provider alternation after exclusion. Excluding an unresolved answer can leave two
ChatRequests adjacent in provider input. Provider conversions already tolerate irregular shapes (andsanitizealready deletes events), but implementation must validate each provider's conversion against a stream with an excluded group. - RFD 097 sequencing. This design cannot land before RFD 097's wrapper and insertion-time assignment exist; the
Extendsrelationship gates promotion accordingly. - Interrupt-reply binding. Linking to the newest
ChatRequestat insertion time matches current positional assumptions, but interrupt-heavy turns should be exercised in tests to confirm the recorded binding matches user intuition. - Stale summaries can still describe deleted exchanges. A summary covering a deleted request projects unchanged; its LLM-generated text may keep describing the deleted exchange even though the raw answer events are excluded. Precise staleness detection is compaction's planned migration to RFD 097 ID anchors — RFD 064's call, per RFD 097 — and the drop-or-regenerate decision is effectful, belonging to the imperative shell rather than this RFD's pure filtering.
Future Work
- An explicit
jp conversation repair(oredit-integrated) command that offers best-effort relinking: backfillrequest_idfor legacy events in well-formed turns containing exactly oneChatRequest, and interactive relinking of unresolved events. Explicit, user-invoked, and never part of the read path.
Implementation Plan
Phase 1 — Wrapper field and insertion-time stamping
- Add
request_id: Option<EventId>to theInternalEventwrapper and its serde (omit whenNone). - Track the active originating request in
ConversationStream; stamp the five linked families in every insertion path (push,extend, and theTurnMutflush). - Regenerate affected snapshots and fixtures.
- Tests: linked kinds get stamped; non-linkable kinds stay unlinked; a linkable event inserted after a new
TurnStartbut before that turn'sChatRequeststays unlinked even when a previous turn contains a request; multi-request (interrupt) turns link to the newest request; round-trip preserves the field byte-for-byte.
Depends on RFD 097 (Phases 1–3). Mergeable on its own: the field is written and preserved, not yet consumed.
Phase 2 — Resolution, exclusion, and diagnostics
- Implement unresolved-link detection on the raw stream (missing target, wrong-kind target, RFD 097-duplicated target), ordered before compaction projection in
Thread::into_parts. - Exclude unresolved-linked events, closed over immediate tool pairs (payload
id, turn-scoped, count-aware). - Move the
Thread::into_partscall site from the providers to the query caller: the turn loop projects before building the provider query, andChatQuerycarries the projected parts plus the schema and config values providers currently extract from the raw thread before conversion. - Return excluded-link diagnostics alongside
ThreadParts;jp_clirenders them on the chrome channel and emits them to tracing. Non-interactive callers (title generation, compaction summaries) emit to tracing only. - Tests: deleting a request excludes its whole answer; a hand-edited pair split across linked and unlinked halves (or across divergent
request_ids) is excluded as a pair; unlinked legacy events are unaffected; a summarized range containing an excluded answer still projects its summary; provider conversions accept a stream with an excluded group; a default-verbosity run shows the diagnostic on the chrome channel.
Depends on Phase 1.
Phase 3 — Documentation
jp conversation edit --eventshelp: therequest_idfield, and the consequence of deleting a linked-to request — its raw answer events become provider-invisible, while an existing compaction summary covering the exchange may still describe it; point atjp conversation compact --resetfor that case.- Glossary entries for "request link", "originating request", and "immediate request" in
docs/architecture/ubiquitous-language.md.
Can land alongside Phase 2.
Cost: roughly 25 bytes per linked event on disk — negligible against event content. Zero provider token cost: the field is dropped during provider conversion.
References
- RFD 097 — Stable Event Identifiers (the
event_idthis RFD references; defines load-time duplicate repair and defers reference semantics to consumers like this RFD) - RFD 047 — Editor and Path Access for Conversations (motivates the manual
events.jsonedits the link must survive) - RFD 048 — Four-Channel Output Model (the chrome channel that carries the exclusion diagnostic)
- RFD 064 — Non-Destructive Conversation Compaction (the projection layer scoped out in Non-Goals; owns summary-staleness handling)