feat(session): Plugin invocation contract + 5-value action vocabulary + abctl row-per-plugin renderer#385
Conversation
… convention Three schema additions to the pipeline.Context + SessionEvent contract, no behavior change yet (plugins don't populate, listener doesn't read — follow-up commits). ## Extensions.Auth (named slot, category-shared) A single AuthExtension shared by all auth-class plugins (jwt-validation inbound, token-exchange outbound, future token-broker, etc). Uses slice- of-struct for Inbound and Outbound so multiple plugins of the same class cooperate by appending — e.g. if token-broker chains after token-exchange on outbound, each contributes one entry. Plugin field on each entry preserves traceability. InboundAuth / OutboundAuth carry the stable machine Reason code (paired with /stats counters) plus diagnostic context operators need to debug a denial without re-running (ExpectedIssuer / ExpectedAudience on deny; TokenSubject/Audience/Scopes on allow). NEVER contains raw tokens or signatures — session API has no auth, only safe-to-log data. ## Plugin-event convention via Extensions.Custom Instead of adding a second plugin-scoped map, reuses the existing Custom map with a key-suffix convention: entries whose keys end in "/event" (exported as PluginEventSuffix) are treated as plugin-public observability and serialized by the listener into SessionEvent.Plugins (key suffix stripped). Entries without the suffix remain plugin-private per the existing contract used by SetState / GetState. This keeps the two intents unambiguous at write time — a plugin author has to deliberately type "/event" to opt into serialization, so private state (often pointer-heavy structs with sync primitives) can never leak by accident. A new plugin can surface events without any authlib modification: just write a JSON-marshalable value to Custom["my-plugin/event"]. ## SessionPhase.SessionDenied Terminal phase for inbound requests a plugin rejected. Lets consumers distinguish denials from ok-request/ok-response pairs without scanning StatusCode. Serializes as "denied" on the wire. SessionEvent.Plugins ships as map[string]json.RawMessage (wire form); the listener materializes it from Extensions.Custom at record time — Phase 5 adds that code. ## Backward compatibility Purely additive. Existing SessionEvent consumers see new optional fields (auth, plugins) and can ignore them. Existing plugins and listeners are unaffected — Extensions itself gains one typed slot (Auth) but no field rename. SetState / GetState behavior is unchanged. ## Test plan - [x] TestSessionPhase_Denied_SerializesAsString (wire shape) - [x] TestSessionEvent_AuthExtension_JSONRoundTrip (round-trip) - [x] TestSessionEvent_PluginsMap_JSONRoundTrip (round-trip) - [x] Existing TestSessionEvent_MarshalJSON_OmitsEmpty updated with new fields - [x] go test ./pipeline/... all green Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
…ults
Plugins populating SessionEvent.Auth.Inbound[].Reason (Phase 3) need a
machine-stable enum they can stringify — not today's free-form
DenyReason text ("missing Authorization header", "token validation
failed", ...). The enum values already exist (DENY_NO_HEADER,
DENY_JWT_FAILED, OUTBOUND_CREDENTIALS_GRANT_FAILURE, ...) and feed
/stats counters, so the code is already available at every deny site;
they just weren't returned to callers.
Add DenyReasonCode to InboundResult and OutboundResult as an additive
field alongside the existing DenyReason string:
- DenyReason (string) stays for logs and response bodies — unchanged
- DenyReasonCode (typed enum) is the filtering/indexing handle
Every ActionDeny site in HandleInbound / HandleOutbound / handleNoToken
now populates the code. Table-driven test exercises all four inbound
deny branches and asserts the code is set.
Also surface CacheHit on OutboundResult — token-exchange already knows
when it served from the cache (tracked via OUTBOUND_ACTION_CACHE_HIT
in /stats), and the token-exchange plugin will want this on the
session event for perf diagnostics without requiring callers to read
/stats.
## Backward compatibility
Additive. Existing consumers of DenyReason (log lines, the default
response body) are unaffected. Callers that don't read
DenyReasonCode / CacheHit still work.
## Test plan
- [x] TestHandleInbound_PopulatesDenyReasonCode (new, table-driven)
- [x] All existing auth/ tests pass
- [x] Full authlib test suite green
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
…y/bypass
Adds the producer side of SessionEvent.Auth.Inbound. In OnRequest,
jwt-validation now writes one InboundAuth entry to
pctx.Extensions.Auth.Inbound before returning, covering all three
terminal branches:
- bypass (matched bypass path, e.g. /healthz): Decision="bypass",
Reason="path_bypass". Lets operators see the request in the session
stream — useful for debugging "why is this URL skipping JWT?"
without grepping slog.
- deny (missing/malformed header, validator missing, JWT failed):
Decision="deny", Reason = DenyReasonCode.String() (machine-stable
code, not the human message), ExpectedIssuer/ExpectedAudience
populated so operators can diagnose a mismatch from the session
event without re-running.
- allow (valid JWT): Decision="allow", Reason="authorized",
TokenSubject/Audience/Scopes populated to surface what the plugin
VERIFIED — diverges from the top-level Identity snapshot if later
plugins re-annotate pctx.Claims.
NEVER surfaces the raw bearer token. Session API has no auth on it,
only safe-to-log fields belong in the extension.
## appendInboundAuth helper
Lazy-initializes pctx.Extensions.Auth on first call and appends one
entry. Mirrors the pattern a2a-parser uses when it writes its
extension slot.
## Test plan
- [x] TestJWTValidation_OnRequest_PopulatesAuth_Bypass
- [x] TestJWTValidation_OnRequest_PopulatesAuth_Deny_NoHeader
- [x] TestJWTValidation_OnRequest_PopulatesAuth_Allow
- [x] Existing tests all pass
- [x] Full authlib suite green
The listener doesn't read this field yet — Phase 5 will widen the
recording gate so Auth-only events appear in /v1/sessions. Until then
the extension is populated but not observable via the session API,
and this commit is behaviorally inert from an observer's perspective.
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
Outbound producer for SessionEvent.Auth.Outbound. On ActionReplaceToken
(exchange succeeded, cached or fresh) and ActionDeny (IdP rejected or
exchange failed), the plugin writes one OutboundAuth entry to
pctx.Extensions.Auth.Outbound before returning.
Explicitly skips population for passthrough hosts (no route match, or
action=passthrough on the route). Recording every outbound HTTP call
without policy would drown the interesting exchange events in the
session stream; /stats counters (OUTBOUND_NO_MATCHING_ROUTE) still
track these.
## Event shape
On ActionReplaceToken:
- Plugin="token-exchange", Action="exchange"
- RouteMatched=true, RouteHost=pctx.Host
- TargetAudience + RequestedScopes from the resolved route
- CacheHit from the auth layer so abctl can flag cache-cold misses
On ActionDeny:
- Plugin="token-exchange", Action="denied"
- Reason = DenyReasonCode.String() (machine code, not the HTTP body)
- RouteMatched + TargetAudience + RequestedScopes surface what the
agent tried to exchange to — critical for diagnosing bad-audience
denials from /v1/sessions without re-running
## auth.OutboundResult gains route context
HandleOutbound now returns RouteMatched + TargetAudience +
RequestedScopes alongside the existing Action/Token/DenyStatus. Gives
the plugin observability over what the router chose without leaking
the routing.ResolvedRoute type across package boundaries.
## Test plan
- [x] TestTokenExchange_ExchangeSuccess — extended to assert
Auth.Outbound[0].Action="exchange", RouteHost set, CacheHit=false
on a fresh exchange
- [x] TestTokenExchange_ExchangeFailure — extended to assert
Auth.Outbound[0].Action="denied", Reason="token_exchange_failed"
- [x] TestTokenExchange_Passthrough — extended to assert
Auth is nil (passthrough hosts stay silent)
- [x] Full authlib suite green
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
…events End-to-end activation of the Auth observability pipeline. After this commit, /v1/sessions carries auth decisions and plugin-public events on every recorded event, plus a new SessionDenied phase for rejected inbound requests. ## Widened recording gate recordInboundSession / recordOutboundSession / recordOutboundResponse previously gated on a protocol extension (A2A, MCP, Inference) being non-nil. Widened to also fire when Auth != nil or plugin-public Custom entries were written — so auth-only and plugin-only events stop vanishing. Passthrough outbound calls (no Auth, no MCP, no Inference) still skip, matching the policy in token-exchange's OnRequest that elides passthrough from Auth.Outbound. /stats counters remain authoritative for that case. ## SessionDenied events on reject recordInboundReject is new: fires from the Reject path of handleInbound / handleInboundBody BEFORE rejectFromAction returns. Gated on Auth being populated so non-auth plugin rejects (e.g., body-too-large) don't pollute the session stream with contextless denials. Wire-mapped fields: - Phase = SessionDenied - StatusCode + Error from the Violation (structured Code + Reason, NOT the rendered JSON wire body) - Auth carries the diagnostic context (ExpectedIssuer, ExpectedAudience, Reason code) - Duration captures the wall-clock from request entry to rejection ## snapshotAuth / snapshotPlugins helpers snapshotAuth deep-copies the Inbound / Outbound slices so a later OnResponse hook appending another entry doesn't retroactively mutate an already-recorded event. Mirrors snapshotA2A's motivation. snapshotPlugins implements the "/event" suffix convention from Phase 1: iterates pctx.Extensions.Custom, filters keys ending in pipeline.PluginEventSuffix, json.Marshals each value into the wire-form map with the suffix stripped. Marshal errors downgrade to slog.Debug and skip the entry — a misbehaving plugin can't take out the whole session stream. ## Session ID fallback for auth-only events inboundSessionID previously dereferenced pctx.Extensions.A2A unconditionally; auth-only events don't have A2A. Now nil-checks A2A first and falls through to session.DefaultSessionID. Operators looking for unauthorized access events in abctl will find them in the default bucket. ## Test plan - [x] TestRecordInboundSession_AuthOnly — auth-only event recorded - [x] TestRecordInboundReject_EmitsDeniedPhase — denial recorded with context - [x] TestRecordInboundReject_SkipsWithoutAuth — non-auth rejects stay out - [x] TestSnapshotPlugins_FiltersByEventSuffix — convention honored - [x] Existing listener tests pass (widened gate is strictly additive) - [x] Full cmd/authbridge + authlib suites green Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
Locks the wire-shape contract for the new SessionEvent fields.
Consumers (abctl, curl scripts, future dashboards) must be able to
decode /v1/sessions/{id} JSON straight into pipeline.SessionView
without side-channel type info — these tests prove that.
## Coverage
- TestHandleGet_SerializesAuthExtension: SessionDenied + Auth.Inbound
entries round-trip through the HTTP API. Asserts the structured
Decision / Reason / ExpectedIssuer fields survive marshal + unmarshal.
- TestHandleGet_SerializesPluginsMap: Plugins map round-trips as keyed
json.RawMessage, and a consumer can decode a single plugin payload
into its own typed struct — the exact pattern abctl will follow to
render known plugins while leaving unknown ones as raw JSON.
No production code change — /v1/sessions JSON serializer is generic
and picked up the new fields automatically from the SessionEvent
wire struct added in Phase 1. These tests are the forever-canary that
catches drift between SessionEvent fields, sessionEventWire, and the
HTTP serializer.
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
Documents the producer-side contract for the new Auth category and the Custom-map /event suffix convention, plus the new SessionDenied phase. Covers both authored paths: named typed slots (first-class in abctl) and the escape-hatch map (plugin-specific observability without core-library changes). CONVENTIONS.md gains an "Emitting session events" section covering: - When to use a named category vs. the Custom map - Rules for plugin-public events (JSON-marshalable, no creds, key prefix = plugin.Name()) - Graduation criteria for promoting map entries to named slots CLAUDE.md Session Events API section gains: - Full event schema (auth, plugins, phases) - Gotcha explaining the denied-phase event and where it lands (default session bucket for unauthorized-access debugging) Zero code change. Documentation-only commit to land alongside the producer / listener / test changes in earlier phases. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
Consumer side of the session-events Auth extension. abctl now
surfaces auth outcomes as a first-class column in the events table
and grows two filter shortcuts for the common diagnostics workflows.
## AUTH column
Inserts between PHASE and PROTO. Values:
- allow / deny / bypass: inbound jwt-validation outcome
- exchange / denied: outbound token-exchange action
- em-dash (—): no auth plugin populated the extension
Last-wins on chained plugins (usually just one entry). Detail pane
renders the full slice via the existing JSON viewport, so operators
still see every decision for chained auth plugins.
## SessionDenied phase rendering
shortPhase now returns "deny" for SessionDenied events, so denied
requests stand out in the PHASE column. Pairs with the new /deny
filter shortcut below.
## Filter shortcuts
/deny — matches SessionDenied events AND inbound "deny" /
outbound "denied" auth decisions. One-word answer to
"show me the failures."
/plugin:<name> — matches events with <name> in the escape-hatch
Plugins map. Lets operators filter to events carrying a
specific plugin payload (e.g. /plugin:rate-limiter).
The general substring search also now scans Auth fields (plugin,
decision, reason, expected issuer, target audience, etc.) so free-
text queries like /jwt_failed or /spiffe://... work naturally.
## Test plan
- [x] TestShortPhase_Denied
- [x] TestAuthCell (7 sub-tests covering every shape)
- [x] TestMatchEvent_DenyShortcut
- [x] TestMatchEvent_PluginShortcut
- [x] Full cmd/abctl suite green
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
…equest ACK Completes the response-side half of PR rossoctl#385's observability work. Previously, Envoy's default config had response_header_mode: SKIP and response_body_mode: NONE, so the processor never received response events — RunResponse never fired and SessionResponse entries never landed in the session store. Recording gates were widened in commit d55524b, but the code paths feeding them were unreachable. Every request-phase ACK now attaches a ModeOverride that asks Envoy to SEND response headers (for status + duration), and additionally BUFFER the response body when the pipeline declares BodyAccess (for response parsers and OnResponse hooks). The override must land on the request ACK, not on the ResponseHeaders ACK — by the latter point the filter has already decided to skip. Also drops the now-redundant ModeOverride inside handleResponseHeaders (it was unreachable before this fix and duplicates the new one). The body phase becomes the single place RunResponse fires when body is needed; header phase just snapshots status and defers. The existing TestExtProc_NoBodyBuffering_WhenNotNeeded asserted that ModeOverride stayed nil when body wasn't needed — updated to the finer assertion: ResponseHeaderMode is SEND, but neither Request nor Response body is BUFFERED. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
… phase The request-phase recording gate was widened in d55524b (PR rossoctl#385 commit 5) to accept A2A, Auth, or plugin-public Custom entries, but the parallel change in recordInboundResponseSession was missed — it still required pctx.Extensions.A2A to be non-nil. Consequence: with the chart's default pipeline (jwt-validation only, no parser), inbound request events were recorded but their paired response events were silently dropped. abctl showed one-sided conversations — request rows without status or duration — even though Option 2 correctly activated the Envoy-side response-phase callback and handleResponseHeaders ran. The event just never made it past the gate. Fix: gate on A2A || Auth || Plugins, matching recordInboundSession (line 187) and the parallel outbound-response check at line 351. The comment block spells out why the gate exists and why A2A alone was wrong so the next pass through this code doesn't reintroduce the bug. TestRecordInboundResponseSession_AuthOnly locks the fix. The existing TestRecordInboundResponseSession_NoA2A is renamed to _EmptyPctx since A2A-null is no longer sufficient for dropping. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
… phase The request-phase recording gate was widened in d55524b (PR rossoctl#385 commit 5) to accept A2A, Auth, or plugin-public Custom entries, but the parallel change in recordInboundResponseSession was missed — it still required pctx.Extensions.A2A to be non-nil. Consequence: with the chart's default pipeline (jwt-validation only, no parser), inbound request events were recorded but their paired response events were silently dropped. abctl showed one-sided conversations — request rows without status or duration — even though Option 2 correctly activated the Envoy-side response-phase callback and handleResponseHeaders ran. The event just never made it past the gate. Fix: gate on A2A || Auth || Plugins, matching recordInboundSession (line 187) and the parallel outbound-response check at line 351. The comment block spells out why the gate exists and why A2A alone was wrong so the next pass through this code doesn't reintroduce the bug. TestRecordInboundResponseSession_AuthOnly locks the fix. The existing TestRecordInboundResponseSession_NoA2A is renamed to _EmptyPctx since A2A-null is no longer sufficient for dropping. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
When the default pipeline (jwt-validation only) runs on a pod serving A2A traffic, /.well-known/agent.json polling from the Kagenti UI and kubelet probe traffic all generate bypass events. Previously they were indistinguishable — every event said `decision: "bypass", reason: "path_bypass"` with no further context. Adds InboundAuth.Path, populated at all three jwt-validation appendInboundAuth sites (allow, deny, bypass). Operators viewing the session stream or filtering in abctl can now tell "/healthz" (kubelet) apart from "/.well-known/agent.json" (discovery) apart from a deny on "/message/stream" (unauthorized data access). abctl's matchEvent substring filter now scans Path too, so `/healthz` or `/.well-known` in the filter box isolates the relevant rows. No change to deny/allow semantics — Path is purely diagnostic. Safe to log (already part of HTTP request line). Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
…d to each row
Previous column said "PROTO" and rendered a 3-letter bucket (a2a / mcp /
inf / —). Useful for telling A2A from MCP at a glance, useless for
answering "which plugin produced this row?" For the chart default
pipeline (jwt-validation only), every row was "—" — technically correct
but not helpful.
Renames the column to PLUGIN (width 5→16). The new responsiblePlugin
helper returns the full plugin name that attached the most distinctive
payload to the event:
- a2a-parser / mcp-parser / inference-parser for protocol matches
(same priority as before — Inference wins over MCP false-positive)
- jwt-validation / token-exchange for auth-only events (last entry
in Inbound/Outbound wins, matching authCell's "last decision" rule)
- escape-hatch Plugins-map keys as final fallback
- "—" when no plugin attached anything
pairRequestsAndResponses and matchEvent's substring hay list switch to
the new helper. Auth-only req/resp still pair because both phases carry
the same Auth snapshot.
TestResponsiblePlugin_Priority locks the precedence; the existing
auth-only pairing test is updated to assert the new attributed name
("jwt-validation") rather than the old "—" token.
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
Matches the recording symmetry already expected of jwt-validation
(allow/deny/bypass all recorded). Previously token-exchange only
populated Auth on exchange and denied; passthrough was deliberately
skipped to control volume, which produced one-sided outbound audit:
operators could see denied egress and exchanged egress but not the set
of hosts the pod talked to without matching a route — the most common
case with the chart default routes.yaml of empty.
Populates Auth.Outbound{Action:"passthrough", RouteMatched, RouteHost}
from the default branch of the action switch (ActionAllow / no route
/ default-policy=passthrough all land here). Reason is left empty —
DenyReasonCode is the zero enum value on the allow branch and would
render as the wrong code.
OutboundAuth doc comment flips: "rarely populated" → "populated so
operators can see every outbound host the pod talks to". Existing
TestTokenExchange_Passthrough inverted: asserts Auth populated with
Action="passthrough" and RouteHost carrying the target.
Consumer side needs no change — recordOutboundSession's gate already
accepts Auth-only events (line 351), so Auth.Outbound entries flow to
the session store automatically. abctl's PLUGIN column attributes the
row to "token-exchange" via the Auth fallback in responsiblePlugin.
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
8c66e8b to
d3e17d7
Compare
Introduces the universal plugin-invocation vocabulary that every plugin
emits per pipeline pass:
type InvocationAction string
const (
ActionAllow = "allow" // gate permitted
ActionDeny = "deny" // gate rejected; terminal
ActionSkip = "skip" // plugin ran but didn't act
ActionModify = "modify" // message mutated
ActionObserve = "observe" // diagnostic data attached
)
Replaces AuthExtension / InboundAuth / OutboundAuth with a unified
Invocation / Invocations pair. The new type merges the auth-gate fields
(ExpectedIssuer, TokenSubject, etc.) and outbound-route fields
(RouteMatched, TargetAudience, CacheHit, etc.) onto one struct; each
plugin populates only the fields that apply. Parsers — previously not
represented on the session wire at all beyond their extension slot —
now participate in the same list with ActionObserve / ActionSkip, so
abctl can attribute each row to exactly one plugin.
pipeline.Extensions.Auth → Extensions.Invocations
SessionEvent.Auth → SessionEvent.Invocations (wire: "invocations")
The type is named InvocationAction (not Action) because pipeline.Action
is already the pipeline-directive struct (Continue/Reject). Constants
stay unprefixed: pipeline.ActionAllow, pipeline.ActionDeny, etc.
Existing round-trip test renamed and expanded to lock the new
"action":"deny" / "action":"modify" wire form alongside the structural
fields.
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
jwt-validation and token-exchange append Invocation records on every
OnRequest instead of AuthExtension entries. Values remap to the
universal 5-word vocabulary:
jwt-validation
allow (was allow)
deny (was deny)
skip (was bypass; reason=path_bypass retained)
token-exchange
modify (was exchange; reason=token_replaced or cache_hit)
deny (was denied)
skip (was passthrough; reason=no_matching_route or route_passthrough)
The helper functions are renamed accordingly — appendInvocationInbound
in jwt-validation, appendInvocationOutbound in token-exchange — so the
plugin names match the Invocations.{Inbound,Outbound} split on pctx.
No behavior change on the gate side; only the vocabulary and container
type change. Unit tests updated to assert the new Action enum values
(pipeline.ActionAllow etc.) rather than the legacy strings.
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
a2a-parser, mcp-parser, and inference-parser now participate in the
pipeline-invocation timeline. Each parser appends one Invocation per
OnRequest/OnResponse:
- ActionObserve when the body matched — extension slot (A2A / MCP /
Inference) is populated; Reason carries the matched method or
model name for filter affinity (matched_<method> /
matched_<model>).
- ActionSkip when the body didn't match — empty, non-JSON, wrong
JSON-RPC shape, wrong path (inference-parser gates on
/v1/chat/completions and /v1/completions). Reason explains which
skip flavour (no_body / invalid_json / not_json_rpc / wrong_path
/ no_response_body_or_request_not_parsed).
abctl can now attribute every parser-touched row to its specific
parser, rather than inferring from the presence of a populated
extension slot. Filtering by `/a2a-parser`, `/mcp-parser`, or
`/inference-parser` isolates that plugin's timeline end-to-end.
No functional change on the parser side — extension population is
unchanged; the Invocation is additive diagnostic.
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
snapshotAuth → snapshotInvocations. The recording helpers now copy
pctx.Extensions.Invocations onto SessionEvent.Invocations rather than
the removed Auth field. Recording gates keep their widened shape: any
of A2A / MCP / Inference / Invocations / Plugins qualifies an event
for the session store, so auth-only and parser-only events both flow.
The sessionapi wire-schema test renames to reflect the new field and
locks the "action":"deny" / "invocations":{...} wire form explicitly so
downstream consumers (abctl, scripts) can rely on it.
Listener behavior unchanged — same SessionDenied emission on reject,
same dual-phase (request + response) recording, same session-rekey
logic when A2A responses assign a server-side contextId.
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
…e vocab
Renders one row per (SessionEvent, Invocation) tuple rather than one
row per SessionEvent. A pipeline pass that ran jwt-validation +
a2a-parser on an inbound A2A request now produces two adjacent rows —
one for each plugin — instead of collapsing both into a single cell.
Plugin developers see every plugin's contribution on every message.
Column changes:
AUTH → ACTION — renamed; renders the 5-value
InvocationAction (allow / deny / skip /
modify / observe). Width 8 still fits the
longest value.
PLUGIN — unchanged header; width 26 → 18 now that
only one plugin name lives per row (no
concatenation). Longest single name
("inference-parser", 16 chars) fits.
Pairing: pairInvocationRows keys on (direction, plugin) so each
plugin's request row connects to ITS response row independent of
other plugins on the same event — the └ visual now walks the
per-plugin timeline, not the per-event one. A request row for
jwt-validation pairs with a response row for jwt-validation; a
request row for a2a-parser pairs with a response row for a2a-parser;
they don't cross.
Filtering: matchInvocationRow replaces matchEvent. The substring hay
includes the invocation's Plugin / Action / Reason / Path plus the
parent event's host / identity / protocol-extension fields.
`/jwt-validation` isolates that plugin's timeline; `/skip` shows
every no-op invocation (path_bypass, no_matching_route, parser
misses); `/deny` continues to surface both SessionDenied events and
per-invocation denies.
m.visibleRows caches the invocationRow slice backing the table, so
selectedEvent can return the row under the cursor without re-walking
the event cache.
Tests rewritten to cover flattenInvocations, pairInvocationRows,
matchInvocationRow, and the end-to-end auth-only req/resp pairing.
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
CONVENTIONS.md "Emitting session events" rewritten to reflect the universal Invocation contract: every plugin — gate, parser, future rate-limiter / guardrail / whatever — MUST emit an Invocation record per OnRequest/OnResponse call. The named protocol extensions (MCP, A2A, Inference) become the secondary channel for carrying structured payload; the Custom map stays as the escape hatch for plugin-specific events that don't warrant a dedicated extension. Includes the 5-value action table (allow/deny/skip/modify/observe) with one-line semantics and worked examples of Reason codes plugin authors should use. authbridge/CLAUDE.md session-event schema updated: "auth" field -> "invocations", with the 5-value vocab documented inline so operators reading the CLAUDE.md don't need to bounce through CONVENTIONS.md to interpret a session dump. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
Adds pipeline.InvocationPhase (request | response) and a Phase field on Invocation. Every plugin sets Phase on emission so the listener can partition the cumulative pctx.Extensions.Invocations list into a request event (only phase=request entries) and a response event (only phase=response entries) at record time. Keeps the full invocation history on pctx across the request→response boundary — future plugins can still reason about what earlier plugins did in the other phase — without duplicating records onto the wire. Listener's snapshotInvocations() takes the phase and filters at snapshot time. Request-side recording sites pass InvocationPhaseRequest; response- side sites pass InvocationPhaseResponse; SessionDenied events pass request (denials terminate the pass before response runs). All five plugins (jwt-validation, token-exchange, a2a-parser, mcp-parser, inference-parser) annotate their invocation records accordingly. Test fixtures updated to include Phase. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
…skips Two related changes to the three protocol parsers (a2a-parser, mcp-parser, inference-parser): 1. Phase tagging — each invocation record now carries InvocationPhaseRequest or InvocationPhaseResponse so the listener's phase-aware snapshot can route entries to the correct event. 2. Drop type-mismatch skip emissions — parsers no longer append a skip invocation when the message doesn't apply to them (empty body, non-JSON body, wrong path). Previously an MCP initialize call would show three plugin rows in abctl including an inference-parser skip with reason=wrong_path, which was pure noise: the parser architecturally can't apply to /mcp, and operators infer pipeline composition from config. Parsers still emit observe invocations on successful matches. mcp-parser's unparseable_response skip stays because it signals "request was MCP but response couldn't be decoded" — diagnostic, not type-mismatch. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
Plugin developers debugging a session need to see, at a glance, which req/resp rows belong to the same logical exchange. The ACTION column's `└resp` glyph pairs by (direction, plugin) at the row level but falls apart visually when an A2A turn wraps many outbound events or when multiple events at the same timestamp interleave. Adds a short numeric column that assigns each event an integer; paired events share theirs. Three bundled improvements: 1. Leftmost # column (width 4). Each SessionEvent gets an integer. Request and matching response share one number. An orphan (fire-and-forget, no response) occupies its own number that never repeats. Computed via computeEventPairIDs using row-level pair data as the source of truth — eliminates a class of bugs where direction+host+method key matching differs from what already drives the `└resp` glyph. 2. Method-aware row-level pairing. pairInvocationRows now keys on (plugin, direction, method) rather than just (plugin, direction). Without method discrimination, an mcp-parser row for `notifications/initialized` (fire-and-forget, no response) would greedily claim the next mcp-parser response row — typically the `tools/list` response — leaving the actual tools/list request orphaned and its pair incorrectly attributed. 3. Event-level pair fallback. Response events with zero invocations (bypass responses where no plugin acted on the response side) have no plugin row for the row-level matcher to hook onto. Fallback: for each unpaired response event, link to the closest preceding unpaired request with matching direction + host so the # column still reflects the pair. Plus continuation-row blanking in rebuildEventsTable: when multiple plugin rows belong to one event, only the first shows TIME/DIR/PHASE/ STATUS/DURATION/TOKENS/HOST/# — subsequent rows blank those cells so the visual block reads as one event with stacked plugin lines. Regression tests cover the three scenarios that motivated this: bypass response with empty invocations, notifications/initialized orphan not stealing tools/list's response, auth-only end-to-end pairing. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
pdettori
left a comment
There was a problem hiding this comment.
Clean, well-structured PR with excellent test coverage across all layers (pipeline types, plugins, listener, session API, TUI). The 5-value action vocabulary is a well-designed abstraction that unifies gate and parser plugins without over-generalizing.
The pipeline executes plugins sequentially so the lazy-init pattern in appendInvocationInbound/Outbound is safe. Recording gates are correctly scoped and well-documented. No security issues found.
Areas reviewed: Go (pipeline types, 5 plugins, listener, session API, TUI), Docs (CONVENTIONS.md, CLAUDE.md)
Commits: 21 commits, all signed-off
CI status: All checks passing
Minor nit: PR title is 93 chars (convention is <72). Consider shortening for git log readability, e.g. feat(session): Plugin invocation contract + 5-value action vocabulary
| RouteHost string `json:"routeHost,omitempty"` | ||
| TargetAudience string `json:"targetAudience,omitempty"` | ||
| RequestedScopes []string `json:"requestedScopes,omitempty"` | ||
| CacheHit bool `json:"cacheHit,omitempty"` |
There was a problem hiding this comment.
nit: CacheHit bool with omitempty means cache-miss (false) is omitted from JSON, making it indistinguishable from "field not applicable." Consumers can't filter cache misses without also knowing the action is modify. Fine for now — just noting the tradeoff. If you ever want an explicit cacheHit: false signal for perf dashboards, this would need *bool or a tristate.
Three-role model for the plugin developer docs: - GUIDE.md (tutorial) — step-by-step; runnable examples - CONVENTIONS.md (reference) — field-level contract; panic rules - pipeline/README.md — framework architecture Each doc now opens with an Audience + See also triplet so readers land on the right file fast. Scope: pipeline/README.md - Refresh Context, Extensions, SessionEvent sections against the PR rossoctl#385 / rossoctl#386 state (Invocations struct, 5-value action vocab, `phase: "denied"`, pctx.Record helpers, /event suffix, open registry. Add three-doc pairing at top. - Replace the long Writing a native plugin worked example with a pointer to GUIDE.md — that walkthrough now lives there. - Rewrite §11 Versioning changelog with the PR rossoctl#385/rossoctl#386 entries (SessionDenied, unified Invocation contract, Record helpers, open registry, Readier interface). - Reorganize §12 Cross-references by audience. plugins/CONVENTIONS.md - Emitting session events → field-level reference with a pointer callout to GUIDE.md Step 2 for the tutorial. Drops the runnable Allow/Skip/Observe/Modify examples (now only in GUIDE.md). - Registering a plugin → keep the factory-shape snippet and rules/guardrails table; drop the out-of-tree walkthrough (now only in GUIDE.md Step 6). Add pointer callout. plugins/GUIDE.md - Add Audience + See also header. authbridge/CLAUDE.md - Add back-link from the Invocation action vocabulary table to plugins/CONVENTIONS.md as the producer-side contract, so future edits to the vocab land in one place. Net diff: +213 / -219 across 4 files. No code changes. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> EOF ) Signed-off-by: Hai Huang <huang195@gmail.com>
Summary
Closes the observability gap where auth decisions and per-plugin actions were invisible in
/v1/sessionsand abctl. Introduces a unified plugin-invocation contract that every plugin emits, a 5-value action vocabulary shared across gate and parser plugins, aSessionDeniedphase for rejected requests, and a rewritten abctl event pane that renders one row per plugin invocation with clear request↔response pairing.New wire-schema
Every event on
/v1/sessions/{id}and/v1/eventscarries aninvocationsfield — a per-plugin record of what each plugin did during the pipeline pass:{ "phase": "request" | "response" | "denied", "invocations": { "inbound": [ { "plugin": "jwt-validation", "action": "allow", "phase": "request", "reason": "authorized", "path": "/message/stream", "tokenSubject": "alice", "tokenAudience": ["..."], "tokenScopes": ["openid", "..."] } ], "outbound": [ { "plugin": "token-exchange", "action": "modify", "phase": "request", "reason": "token_replaced", "routeMatched": true, "routeHost": "weather-tool-mcp", "targetAudience": "spiffe://...", "cacheHit": false } ] } }5-value action vocabulary
Every plugin MUST emit exactly one of these per invocation:
Reason codes discriminate within an action value (
skip/path_bypassvsskip/no_matching_route) for finer-grained filtering.What is new in abctl
/allow,/deny,/skip,/modify,/observe.notifications/initializeddoesn't steal thetools/listresponse.PHASE=deniedcarrying full diagnostic context.Backward compatibility
Breaking for pre-existing consumers of the session wire format.
SessionEvent.Auth->SessionEvent.Invocations, andInboundAuth.Decision/OutboundAuth.Action(free-form strings) unify intoInvocation.Action(5-value typed enum). Session API has no persistent state, so no migration needed. No operator or chart changes.Test plan
go test ./...inauthlib,cmd/authbridge,cmd/abctl— all greengo vet ./...across all three modules — cleanauthbridge-envoyimage, deployed to Kind, drove A2A + MCP + inference traffic; verified one-row-per-plugin rendering, pair IDs, SSE response bodies, filtersAssisted-By: Claude (Anthropic AI) noreply@anthropic.com