Skip to content

refactor(plugins): Open plugin registry + pctx.Record helpers (DX improvements)#386

Merged
huang195 merged 28 commits into
rossoctl:mainfrom
huang195:feat/open-plugin-registry
May 10, 2026
Merged

refactor(plugins): Open plugin registry + pctx.Record helpers (DX improvements)#386
huang195 merged 28 commits into
rossoctl:mainfrom
huang195:feat/open-plugin-registry

Conversation

@huang195

@huang195 huang195 commented May 8, 2026

Copy link
Copy Markdown
Member

Summary

Two plugin-architecture DX improvements from the architecture review, bundled into one PR:

  1. Proposal E — open plugin registry. Converts the hardcoded plugin map to the stdlib side-effect-import pattern (database/sql drivers, log/slog handlers). Plugins self-register from init(); third-party plugins in separate Go modules can drop into a deployment with a single side-effect import.

  2. Proposal A — pctx.Record helpers. Adds Context-side ergonomic helpers (Record, Allow, Skip, Observe, Modify, DenyAndRecord) so plugin authors emit Invocations in one line instead of 6–8-line struct literals. Framework auto-fills Plugin, Phase, and Path.

Based on PR #385 (feat/session-events-auth-extension). Merge that first.

Proposal E: open plugin registry

Before

// authbridge/authlib/plugins/registry.go — central edit point
var registry = map[string]PluginFactory{
    "jwt-validation":   func() pipeline.Plugin { return NewJWTValidation() },
    "token-exchange":   func() pipeline.Plugin { return NewTokenExchange() },
    ...
}

Every new plugin (in-tree or third-party) required editing this map.

After

// authbridge/authlib/plugins/jwtvalidation.go — colocated with the plugin
func init() {
    RegisterPlugin("jwt-validation", func() pipeline.Plugin { return NewJWTValidation() })
}

Third-party plugins follow the same pattern in their own module; the operator's authbridge build picks them up with one side-effect import:

// authbridge/cmd/authbridge/plugins_extra.go
package main

import _ "github.com/acme/kagenti-rate-limiter/ratelimit"

Safety

Choice Rationale
Double-registration panics Silent last-write-wins would let a version conflict poison the registry; panic at process start surfaces it
Empty name / nil factory panics Both are programmer errors, not recoverable
sync.RWMutex around registry init() order across packages isn't guaranteed serial under every build mode
UnregisterPlugin in registry_testing.go (not _test.go) So tests in other packages can import it while signalling "test-only"

Proposal A: pctx.Record helpers

Before (jwt-validation deny branch)

appendInvocationInbound(pctx, pipeline.Invocation{
    Plugin:           "jwt-validation",
    Phase:            pipeline.InvocationPhaseRequest,
    Action:           pipeline.ActionDeny,
    Reason:           result.DenyReasonCode.String(),
    Path:             path,
    ExpectedIssuer:   p.cfg.Issuer,
    ExpectedAudience: audience,
})

After

pctx.Record(pipeline.Invocation{
    Action:           pipeline.ActionDeny,
    Reason:           result.DenyReasonCode.String(),
    ExpectedIssuer:   p.cfg.Issuer,
    ExpectedAudience: audience,
})

Before (a2a-parser observe)

appendInvocationInbound(pctx, pipeline.Invocation{
    Plugin: "a2a-parser",
    Phase:  pipeline.InvocationPhaseRequest,
    Action: pipeline.ActionObserve,
    Reason: "matched_" + rpc.Method,
    Path:   pctx.Path,
})

After

pctx.Observe("matched_" + rpc.Method)

New Context methods

Method Usage
Record(inv Invocation) Full form with diagnostic fields (ExpectedIssuer, TokenScopes, RouteHost, etc.)
Allow(reason) Gate approved
Skip(reason) Plugin ran but didn't act
Observe(reason) Parser extracted data
Modify(reason) Plugin mutated the message
DenyAndRecord(reason, code, msg) Deny + record in one call

Pipeline.Run / RunResponse now stamp pctx.currentPlugin and pctx.currentPhase around each dispatch so the helpers can fill Plugin + Phase automatically. Path comes from pctx.Path.

Bugs eliminated

Three classes of silent failures the old struct-literal pattern allowed:

  • Typo in the Plugin: string literal → wrong plugin name on the wire
  • Wrong Phase: value (OnRequest path writing InvocationPhaseResponse in a copy-paste)
  • appendInvocationInbound called from an outbound plugin or vice versa → Invocation lands on the wrong direction bucket

All three are framework-controlled now.

Scope

Modified / added:

  • authbridge/authlib/plugins/registry.go — dynamic registry API (rewritten)
  • authbridge/authlib/plugins/registry_testing.go — UnregisterPlugin helper (new)
  • authbridge/authlib/plugins/registry_test.go — coverage for registration lifecycle (new)
  • authbridge/authlib/pipeline/context.go — Record / Allow / Skip / Observe / Modify / DenyAndRecord methods + framework state fields
  • authbridge/authlib/pipeline/pipeline.go — SetCurrentPlugin / ClearCurrentPlugin around each dispatch
  • 5 built-in plugins: init() self-registration + migration to Record helpers
  • plugins_test.go: invokeOnRequest / invokeOnResponse wrappers for tests that call plugins directly
  • CONVENTIONS.md: "Registering a plugin" section + rewritten "Emitting session events" section

Deleted:

  • Central hardcoded registry map literal
  • appendInvocationInbound from jwtvalidation.go
  • appendInvocationOutbound from tokenexchange.go

Net line count: roughly flat (registry API and Record helpers add ~200 lines of framework code; plugin migrations remove ~130 lines of boilerplate).

Test plan

  • go test ./... in authlib — all green (new registry tests plus existing plugin tests still passing against the new API)
  • go test ./... in cmd/authbridge — all green
  • go test ./... in cmd/abctl — all green
  • go vet ./... across all three modules — clean

Commit breakdown

  1. feat(plugins): Open plugin registry via RegisterPlugin + init() — registry API + migrate 5 built-ins + registry tests
  2. docs: Plugin registration pattern in CONVENTIONS.md
  3. feat(pipeline): pctx.Record helpers for ergonomic invocation emission — Context API + pipeline wiring + plugin migration + test helpers
  4. docs: Document pctx.Record family in CONVENTIONS.md

Each commit is independently revertible; if you want to land E without A (or vice versa), the split is clean at commit 2/3.

Assisted-By: Claude (Anthropic AI) noreply@anthropic.com

huang195 added 23 commits May 8, 2026 07:47
… 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>
… 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>
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>
Replaces the closed hardcoded map with RegisterPlugin(name, factory) +
RegisteredPlugins(). Follows the stdlib pattern — plugins register
themselves from an init() function, the same way database/sql drivers
and log/slog handlers do.

Why it matters for DX:

- External plugin authors can now drop a Go module into their deployment
  and pull it in with a single side-effect import
  (`import _ "github.com/acme/custom-plugin"`) without forking
  kagenti-extensions.
- In-tree plugins are located next to their registration, not in a
  central map that everyone has to edit and rebase.
- The "unknown plugin" error from Build now lists every registered
  plugin so a typo in operator YAML produces an actionable diagnostic
  instead of a generic not-found.
- Tests get clean isolation through UnregisterPlugin (paired with
  t.Cleanup) — no more package-level map mutation.

Safety choices:

- Double-registration panics. Silent last-write-wins would let a
  version conflict (two incompatible copies of the same plugin shipped
  in the same binary) poison the registry in ways that only surface as
  mysterious runtime behaviour.
- Empty name / nil factory panic. Both are programmer errors; failing
  at registration is closer to the bug than failing later at Build.
- Registry is guarded by sync.RWMutex. init() order across packages
  isn't serialized under every build mode, and UnregisterPlugin runs
  concurrently with t.Parallel tests.

All five built-in plugins (jwt-validation, token-exchange, a2a-parser,
mcp-parser, inference-parser) are migrated to init()-time registration.
The central `registry` map literal is replaced by the mutex-guarded
dynamic map; Build resolves through factoryFor under RLock.

UnregisterPlugin lives in registry_testing.go (not _test.go so other
packages' tests can import it) and is documented as a test affordance.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
Adds a "Registering a plugin" section covering:

- the in-tree pattern (init() next to the plugin, package-level
  auto-registration)
- the out-of-tree pattern (separate Go module, single side-effect
  import in the authbridge binary to pull it in)
- safety rules (panic on double-register, empty name, nil factory)
- the Build error message listing registered plugins for typo
  diagnostics
- test-isolation pattern with UnregisterPlugin + t.Cleanup

Walks a realistic rate-limiter plugin example end-to-end so a
third-party author can write their plugin by following the doc.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
huang195 added 2 commits May 8, 2026 19:10
Adds Record, Allow, Skip, Observe, Modify, and DenyAndRecord methods
to pipeline.Context so plugins can emit Invocations without repeating
Plugin, Phase, or Path on every call. The framework stamps those
fields onto pctx around each plugin dispatch (SetCurrentPlugin /
ClearCurrentPlugin called from Pipeline.Run / RunResponse); the
helpers read them when building the Invocation.

Before:
    appendInvocationInbound(pctx, pipeline.Invocation{
        Plugin: "jwt-validation",
        Phase:  pipeline.InvocationPhaseRequest,
        Action: pipeline.ActionAllow,
        Reason: "authorized",
        Path:   pctx.Path,
        TokenSubject:  result.Claims.Subject,
        TokenAudience: result.Claims.Audience,
        TokenScopes:   result.Claims.Scopes,
    })

After (allow branch with token context):
    pctx.Record(pipeline.Invocation{
        Action:        pipeline.ActionAllow,
        Reason:        "authorized",
        TokenSubject:  result.Claims.Subject,
        TokenAudience: result.Claims.Audience,
        TokenScopes:   result.Claims.Scopes,
    })

After (bypass without token context):
    pctx.Skip("path_bypass")

Eliminates three classes of silent bugs plugin authors could hit:
  - typo in the Plugin string literal
  - wrong Phase (OnRequest code path accidentally writing
    InvocationPhaseResponse, or vice versa)
  - calling appendInvocationInbound from an outbound plugin (or
    vice versa) — direction is now routed automatically from
    pctx.Direction

Pipeline.Run and RunResponse now wrap each plugin dispatch with
SetCurrentPlugin / ClearCurrentPlugin. Tests that invoke plugins
directly (bypassing Pipeline.Run) use invokeOnRequest /
invokeOnResponse wrappers in plugins_test.go that do the same
stamping.

Migrates all 5 built-in plugins (jwt-validation, token-exchange,
a2a-parser, mcp-parser, inference-parser) to the new API. Deletes
duplicated appendInvocationInbound / appendInvocationOutbound helpers
from jwtvalidation.go and tokenexchange.go.

Net change across the 5 plugins: ~120 lines of invocation boilerplate
removed. Net add in pipeline/context.go: ~100 lines of helpers +
framework state.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
Updates the "Emitting session events" section of CONVENTIONS.md so
plugin authors see the Record / Allow / Skip / Observe / Modify /
DenyAndRecord helpers as the primary API. The earlier
"pctx.Extensions.Invocations = &pipeline.Invocations{...}" pattern is
removed from the docs — plugins shouldn't be touching the Invocations
slot directly now that the helpers exist.

Adds worked examples for:
  - one-liner passive actions (Allow / Skip / Observe / Modify)
  - DenyAndRecord for rejections with matching-Reason Invocations
  - Full Record form for invocations that carry diagnostic fields
    like ExpectedIssuer / TokenScopes / RouteHost

Clarifies that the framework fills Plugin / Phase / Path; plugins may
override explicitly for test scenarios but shouldn't in production.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
@huang195 huang195 changed the title refactor(plugins): Open plugin registry via RegisterPlugin + init() refactor(plugins): Open plugin registry + pctx.Record helpers (DX improvements) May 8, 2026
Adds a step-by-step tutorial for writing a new plugin, sibling to
CONVENTIONS.md. Covers the full lifecycle with runnable examples:

- Minimal plugin (HelloLog)
- Recording invocations (one-liner wrappers + Record + DenyAndRecord)
- Rejecting a request (Deny helpers)
- Adding config (Configurable interface)
- Body access capability
- Out-of-tree plugins (side-effect import pattern)
- Testing (invokeOnRequest helper + UnregisterPlugin for isolation)
- Optional interfaces (Initializer / Shutdowner / Readier)
- Gotchas

CONVENTIONS.md remains the reference doc; GUIDE.md is the tutorial
that walks someone through from scratch. Each file links the other.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
huang195 added 2 commits May 8, 2026 19:31
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>
Moves the three plugin-developer-facing docs into a single
descriptively-named directory so readers find them without learning
the Go package layout:

  authlib/plugins/GUIDE.md          → docs/plugin-tutorial.md
  authlib/plugins/CONVENTIONS.md    → docs/plugin-reference.md
  authlib/pipeline/README.md        → docs/framework-architecture.md

File renames are done via `git mv` so `git log --follow` traces history
to the pre-move filenames.

Leaves thin README.md stubs in authlib/pipeline/ and authlib/plugins/
pointing at the new docs location, so godoc / package-level readers
still have a landmark.

Scope of reference updates:

- Internal cross-refs in the three moved docs rewired to the new flat
  `./filename.md` form.
- authbridge/CLAUDE.md: 5 pointers updated.
- authbridge/cmd/authbridge/README.md: config-pattern link.
- authbridge/authlib/README.md: package table + usage prose.
- authbridge/authproxy/authbridge-combined.yaml: header comment.
- Go source comments: session.go, configurable.go, extensions.go,
  config.go, presets.go, resolve.go, jwtvalidation.go, tokenexchange.go.
- demos/weather-agent/demo-with-abctl.md: "The plugin pipeline spec"
  link.

Verification:
- `grep -rn '(plugins/CONVENTIONS|plugins/GUIDE|authlib/pipeline/README)'`
  across authbridge/ returns zero hits.
- `go build ./authlib/...` clean.

No code changes; only comments, docs, and file moves.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>

@cwiklik cwiklik left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review Summary

Large but well-structured PR (27 commits, +3856/-1035, 38 files) that introduces three interconnected capabilities:

  1. Auth extension with machine-stable DenyReasonCode and CacheHit fields — session events surface auth decisions without leaking raw tokens or body content.
  2. 5-value Invocation type with pctx.Record / Allow / Skip / Observe / Modify / DenyAndRecord helpers — one-line ergonomic emission vs 6-8 line struct literals.
  3. Open plugin registry (database/sql.Register pattern) with sync.RWMutex thread safety, init-time panics on misuse, and UnregisterPlugin for test cleanup.

Security: Session events never include raw request/response bodies — only auth decisions, invocation metadata, and plugin diagnostic summaries. snapshotPlugins gracefully degrades on marshal errors (debug log + skip, doesn't crash recording). All panics are exclusively init-time for registry contract violations.

Test coverage: 34 new test functions covering auth scenarios (bypass, deny, allow), JSON serialization round-trips, registry edge cases (double-register, empty name, nil factory), and abctl TUI rendering (invocation rows, pairing, method discrimination).

Areas reviewed: Go source (auth, pipeline, plugins, registry, listener, sessionapi, abctl TUI), Go tests, Documentation
Commits: 27, all signed-off (DCO pass)
CI: All 17 checks passing
Stacking: Base PR #385 merged; PR #389 (body mutation) depends on this landing first

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

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants