Skip to content

feat: chains carry their root identity, and can be filtered by it - #339

Open
rsharath wants to merge 4 commits into
mainfrom
feat/chains-root-identity
Open

rsharath wants to merge 4 commits into
mainfrom
feat/chains-root-identity

Conversation

@rsharath

Copy link
Copy Markdown
Contributor

Two defects in Studio's Issued Tokens screen (highflame-studio#1587 audit) trace back to one gap here: ChainSummary names no identity.

1. N+1 against this service. With no identity on the row, Studio walks /delegations/by-jti once per chain to find out who each belongs to. Listing 200 chains costs 201 requests, so Studio has had to cap the list at 100 to stay responsive — a cap operators can see.

2. A filter that cannot answer its own question. With nothing to filter on server-side, "show me this agent's tokens" runs client-side over the capped page. An empty result means "not among these N", but the screen renders it as "no tokens issued to this agent" — a false negative on a forensics screen.

What changed

ChainSummary gains root_identity_id, root_identity_name, root_wimse_uri, and ListChains accepts root_identity_id as a filter.

The root is resolved through jti — joining on COALESCE(mission_id, jti), since mission_id is the root jti — rather than by picking the shallowest credential inside the window. That distinction matters: the root credential of a long-running chain is often issued before since, and picking within the window would silently relabel chains as the range moved. It also matches what the per-row /by-jti walk returns today, so consumers see no behaviour change beyond the new fields.

The identity join is LEFT: identity_id is ON DELETE SET NULL, and a chain whose identity was deleted must still list — those are exactly the tokens an offboarding review is looking for.

The filter runs before LIMIT. That ordering is the entire value: an empty response becomes a finding rather than a statement about one page.

Tests

Two integration tests. The filter test issues the wanted chain first (making it the older one) and asks for limit=1, so a filter applied after the limit fails rather than passing on a small fixture.

Downstream

Once released, Studio drops the enrichment loop in its chains route, passes the agent filter to the server, and can raise its row cap again.

🤖 Generated with Claude Code

Two defects in Studio's Issued Tokens screen trace back to one gap here:
ChainSummary names no identity.

1. N+1. With no identity on the row, Studio walked /delegations/by-jti once
   per chain to find out who each one belonged to. Listing 200 chains cost
   201 requests against this service, so it had to cap the list at 100 to
   stay responsive — which made the cap itself visible to operators.

2. A filter that cannot answer its own question. With nothing to filter on
   server-side, "show me this agent's tokens" ran client-side over the
   capped page. An empty result meant "not among these N", but the screen
   rendered it as "no tokens issued to this agent" — a false negative on a
   forensics screen.

ListChains now resolves the root through jti, joining on
COALESCE(mission_id, jti) — mission_id IS the root jti — rather than
picking the shallowest credential inside the window. That matters: the root
credential of a long-running chain is often issued before `since`, and
picking within the window would silently relabel chains as the range moved.
The identity join is LEFT, so a chain whose identity was deleted still
lists; those are exactly the tokens an offboarding review looks for.

root_identity_id filters before the LIMIT, which is the whole point — an
empty response is now a finding rather than a statement about one page.

Both are pinned by integration tests, including one that issues the wanted
chain FIRST and asks for limit=1, so a filter applied after the limit fails
rather than passing on a small fixture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@highflame-oracle highflame-oracle Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔮 Oracle Review

🎯 Start Here

internal/handler/delegation.go (~20 min) — Security changes in delegation.go


📋 PR Summary

What this PR does: Adds root identity fields (root_identity_id, root_identity_name, root_wimse_uri) to ChainSummary and introduces root_identity_id filtering to ListChains endpoint to resolve N+1 query issues and enable accurate server-side filtering in Studio.

Key changes:

  • Extended ChainSummary struct with root identity metadata fields
  • Added root_identity_id filter parameter to ListChains handler with tenant context propagation
  • Implemented pre-LIMIT filtering via LEFT JOIN on COALESCE(mission_id, jti) to correctly resolve root identity even when root credential precedes the query window
  • Added two integration tests validating filter behavior and filter-before-limit ordering

Areas affected: delegation chains API, ChainSummary data model, Studio Issued Tokens screen integration

Testing notes: Integration tests specifically validate filter-before-limit ordering by issuing the wanted chain first and using limit=1, ensuring filter failures rather than false passes on small fixtures.


🔍 Code Review

This is a well-architected fix that addresses both performance (N+1 queries) and correctness (false negatives in forensics) issues. The implementation thoughtfully handles edge cases like deleted identities via LEFT JOIN and ensures root identity resolution is consistent with existing behavior by joining on COALESCE(mission_id, jti).

What's good:

  • ✨ Excellent attention to edge cases: handling deleted identities with LEFT JOIN ensures chains remain visible for offboarding reviews
  • ✨ Smart root identity resolution using COALESCE(mission_id, jti) prevents silent relabeling as time windows shift
  • ✨ Deliberate test design that validates filter-before-limit ordering by ordering fixture issuance to catch post-limit filtering bugs
  • ✨ Clear downstream impact documentation showing how Studio can remove client-side enrichment and raise row caps

Review Stats: warning:2


Generated by Oracle - Highflame's AI Code Reviewer

Comment thread internal/handler/delegation.go
Comment thread internal/store/postgres/delegation.go
rsharath and others added 2 commits September 10, 2026 16:19
Review on #339.

A malformed root_identity_id was not rejected — it simply matched no rows.
On this endpoint an empty chain list means "this agent holds no tokens in
this window", so a typo answered 200 with [] and read as a clean bill of
health. That is the same false-negative class the filter was added to
remove, reachable through the filter itself. It now carries a UUID pattern
and 400s, with empty kept explicitly valid because that is the "no filter"
sentinel a cleared agent picker sends.

The comparison was `root.identity_id::text = ?`, which had two problems the
review's second note points at from a different angle. It cast the column
away on every row, so the index on identity_id could not be used; and
Postgres renders a uuid canonically lowercase, so an upper-case UUID —
equally valid, UUIDs are case-insensitive — matched nothing and produced
the false negative again. Now `root.identity_id = NULLIF(?, '')::uuid`:
NULLIF turns the sentinel into NULL before the cast, so "no filter" never
reaches ::uuid, and the handler's pattern guarantees anything that does
will parse.

For the record on the nil-UUID case raised in review: all-zeros is not the
empty sentinel, so it is applied as a filter and matches nothing rather
than bypassing the filter.

Tests: four malformed ids must 4xx and never 500, empty must stay 200, and
an upper-case UUID must match the same chain as its lower-case form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ozereray

Copy link
Copy Markdown

The root-identity resolution through the chain's root JTI is a subtle but important choice.

There is another boundary I would keep explicit as this grows: identity discovery should remain separate from authorization. Knowing the root identity of a delegation chain should tell the system who originated the authority, but it should not by itself grant execution permission to downstream tools.

For agentic systems, the useful audit relation is often:

root identity -> delegated principal -> policy decision -> concrete action -> outcome

That distinction becomes valuable when a chain is long-lived and the original credential predates the query window, exactly like the case you're protecting here.

This is an area we've been working on in Aegisora as well: keeping identity, authorization, execution and evidence as separately bound objects rather than collapsing them into one credential.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants