Skip to content

identity: make the operator console decidable — cases, trails, three ways in - #2579

Merged
mozhaev-dev merged 19 commits into
mainfrom
identity-console-usability
Aug 17, 2026
Merged

identity: make the operator console decidable — cases, trails, three ways in#2579
mozhaev-dev merged 19 commits into
mainfrom
identity-console-usability

Conversation

@mozhaev-dev

@mozhaev-dev mozhaev-dev commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #2515 (#2424). Makes the identities console decidable for an
operator: the queue groups conflict rows into cases and says who holds each
account, accounts without an address are described by what their source sends,
the decision trail shows who decided what and the comment they typed, and two
new modes open settled bindings — by person and by account value. First-login
provisioning (#2536) gets its own queue group and a provisional mark. All
API additions are optional fields / new routes; both deployment orders are safe.

Touches: identity-resolution (reads only), the identities console frontend,
stand contract tests.

Verify: cargo test -p identity-resolution; pnpm typecheck && pnpm lint && pnpm test in src/frontend; or by hand VITE_ENABLE_MOCKS=true pnpm dev
Manage → Identities. Stand: ./dev-compose.sh test-stand up --build && ./dev-compose.sh test-stand test
(the drilldown module can flake in bursts — that is #2361, untouched here).

Follow-ups (in the commits, deliberately out): IN-filtered person-accounts
fold, queue dedup for a rare login-mint state, UTF-8 case folding in account
search, bulk verbs, merge tombstone.

🤖 Generated with Claude Code

mozhaev-dev and others added 19 commits August 15, 2026 03:41
The console showed values without the facts that make them mean something.
Four of those, each with a way for an operator to be wrong:

- an automatic decision rendered an EMPTY badge. The journal stores no reason
  for the resolver's own rows as an empty string, not as null, so the nullish
  fallback never fired — and the mock sent null, the one shape the wire never
  carries, so no test could catch it. Machine versus human is the only fact
  that badge holds.
- a binding minted during a sign-in showed its raw `login-bootstrap` code.
- a person the journal holds no attributes for — exactly what first-login
  provisioning creates until the resolver attaches the roster's name — was
  titled with its own id, stating it twice and as if it were a name.
- the person id was nowhere. A conflict is normally two records of one human,
  so name and address are precisely the fields that fail to tell them apart;
  the id never does, and it is what gets pasted into a search or a ticket. It
  is now on every card, selectable, with a copy control — which is why the
  queue row stopped being a <button>: its text could not be selected and a
  control may not nest inside a control.

The rates strip claimed work that was not the operator's: "Pending review"
counts accounts the resolver binds by itself on its next run. The tiles now
name the binding state they count, each carries what it means, and the strip
leads with the one figure an operator can act on — the queue's own size, a
floor when the server capped the list. "No evidence" said nothing links the
account to anyone; it is the matcher that has no key, and a person can bind
these from the name the evidence already carries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
The panel was where every correction was taken, in the narrowest thing on the
page. Addresses truncated to an ellipsis in the bound person, in both
candidates, and in the person picker — on a surface whose whole job is telling
two records of one human apart, and where the address is the field that does
it. The verbs wrapped onto their own rows underneath.

It is a dialog now, opened by the `?acct=` already in the URL rather than by
click state, so a shared link still lands a colleague on the same case. The
queue takes the freed column: full-width rows, the person id and its copy
control on every card. Inside, the decisions sit beside the trail behind them
and the history scrolls on its own — an account decided a dozen times can no
longer push the verbs out of reach.

The confirmations now say what changes rather than name the verb again.
Re-asserting the binding in force is not a move: "Confirm" said the account
would move to the person it already belongs to, which is the one thing that
does NOT happen. A detach names who the account stops counting towards.

The row also stopped announcing itself as its entire contents — both candidate
cards, their ids and their copy buttons, read out before the account was named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
A binding conflict flags every account sharing the disputed value, and each
of those rows carries the identical candidate list — so a single split person
arrived as five near-identical rows. The rows are ordered by source, which
then interleaved them with every other case holding an account in the same
connector, and the queue read as a scattered list of unrelated problems.

The candidate set is what identifies the case: it is exactly "who is being
argued over". Accounts sharing one now render under a single block that states
those people once, with a row per account beneath — the row a decision is
still taken on. Accounts with nothing to match on stay cases of their own;
collapsing them would claim an argument that does not exist.

A group also says which connectors it spans, folds away, and stops at ten
cases with the rest a press away — the largest group could otherwise bury the
more urgent ones under it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
A group can hold more accounts than anyone scrolls through, and the operator
usually arrives knowing what they are after: a person, a connector, an address
someone pasted into a chat. There was no way to say so.

The filter matches anything visible on a row — the account's own values, its
source, and the candidates, including their person ids, which is what an
operator copies off a card and pastes back. Every term must match, so a second
word narrows.

It rides in the URL like the rest of the portal's state, written on a pause in
typing and replacing rather than pushing, so Back leaves the surface instead of
walking backwards through the typing. Two things it must not do: celebrate an
empty result — no matches is a mistyped filter, not a finished backlog — and
hide a case a shared link points at, so the window is fed from the unfiltered
queue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
Small things, all of them about the operator's second hour rather than their
first minute.

A decided account stayed on screen until the refetch came back — and the
attention read folds every observed account, which can be
seconds of looking at work already done. The rows the SERVER reported as
decided now leave immediately; a refused account keeps its row, because it
kept its binding, and everything else still comes from the refetch.

The list moves like a list (arrows between rows), closing a case returns
focus to the row it was opened from rather than the top of the page, and the
window offers the next account so a backlog can be worked without a trip back
through the list each time. Rows already opened this sitting are dimmed —
session-scoped, since "have I looked at this" is about the sitting.

Group headers stay put while their contents scroll: past thirty rows there was
nothing on screen saying which group was being read.

History entries lead with how long a binding has stood, which is the question
an audit trail is read for — a date makes the reader do that subtraction. The
exact instant stays beside it on hover, and it reads the journal's zone-less
timestamps as UTC, the same trap the instant formatter already guards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
Three things the layout was getting wrong.

The window fitted itself to each case, so walking the queue moved the verbs
under the operator's cursor: a two-entry history and a twenty-entry one put
Confirm in different places. It is a fixed height now; the trail takes the
slack, with a floor under it so a tall decision block cannot squeeze the
history into a sliver.

Two columns halved the width of exactly the fields that tell two records of one
human apart — addresses and ids, which is why they were still truncating after
the move off the narrow panel. One column, the decision above the trail behind
it.

The history said "14 days ago" and nothing else. That answers how long this has
stood, which is what a trail is usually opened for, but not when — and the
instant is what an operator compares between entries and pastes into a ticket.
Both now, the instant leading. Each entry also carries the person id it points
at: a trail names the same handful of people over and over, and the id is what
separates two of them when the names do not.

Opening a case no longer lands focus on the header's copy control, which
announced "Copy dev-42" to a screen reader as the first thing about the case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
The review queue's "no address to match on" group listed a source name and an
account id, and nothing else. Those are precisely the accounts nobody but an
operator will ever bind — and the row gave them nothing to bind it BY. An
account id names no one.

The evidence the fold already scans carries the answer: the connectors send a
display name (or the parts to compose one), a job title, a department, an
employment status and the manager's address alongside the values the matcher
uses. The fold read two of them, because two are all the matcher needs. It now
reads the descriptive ones too — same scan, same grouping — and the queue item
carries them.

So the row leads with the person when the source named one, keeps the account
id beside its source where the other rows carry theirs, and says what the
account is: title, department, who they report to. Status shows when it is
anything but active: an operator asked to resolve a leaver is usually being
asked for nothing, and the queue should not hide which rows those are.

Additive on the wire — the fields are optional and a client that ignores them
sees exactly what it saw before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
The console could only be entered through the queue, which shows what
automation could not decide. A binding that is settled and consistent is
therefore invisible — and the questions that actually arrive are about settled
bindings: this person's work looks split, that account is not theirs. The verbs
already accept those accounts; there was no door to them.

Not a list of every binding: thousands of rows nobody reads by eye, needing
paging and filtering on top of a read that already folds every observed
account — and a list invites binding things because they are on screen. Entered
through a person, a correction stays attached to the question that prompted it.

Built from reads that already exist: the person search behind "Assign to
someone else", the accounts read that feeds the merge preview (it reports who
made each binding, which is the first thing to know before changing one), and
the case window, which already worked for an account outside the queue.

Modes are tabs with the mode in the URL, so a link opens the one it was sent
from, and adding the next one is an entry in a list. Switching drops the open
account: a case picked in one mode means nothing in a list it is not part of.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
A binding conflict lists the same candidates on every row of the case — that
is what makes it one case — so the row asked an operator to choose between two
people without saying which of them they would be taking the account FROM. The
fact was reachable only by opening the account, one at a time.

The queue is built from the bindings joined with the evidence, so the answer
was already in hand and simply not carried: the item now reports who holds it,
and the row names them. Absent means nobody, which is exactly what a contested
account is — unbound, which is why two people can claim it.

Additive on the wire, like the descriptive fields before it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
The console now prints a person id on every card and offers it for copying,
and pasting one back into the search found nothing: the search matches
observed values, and an id is a column, not a value anyone observed.

Worse for the case it matters most in — a person minted during a first sign-in
carries no values at all until the resolver attaches the roster's name, so no
value search can reach them, and the id is the only handle they have.

A term that parses as a UUID now names a person; terms beside it still match
values, so an id and a name together narrow rather than contradict. The
excluded-person sentinel is refused by name as it is by every verb: it
accumulates a journal row per exclusion and is still not a person.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
…es it

First-login provisioning mints a person for a roster account with no address,
so its owner can sign in. The trade is deliberate: "cannot sign in" becomes
"possibly a duplicate person", because the same human may already exist from a
source that does publish an address — and with no address here, nothing can
join the two.

Before this, that account left the operator's view at the moment it acquired a
live owner: unbound with nothing to match on, it sat in the queue; bound by the
sign-in, it counted as resolved and disappeared. Work vanished instead of
arriving.

It now has a group of its own, kept apart from the conflicts — a conflict asks
which of two people, this asks whether one person or two — and it retires
itself the moment an operator's decision lands, since the question it poses is
exactly the one they answered.

Still open (not in this change): marking such a person wherever they are
CHOSEN, so nobody merges into a stub by accident. That needs a person-level
flag, not a per-account one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
Every verb takes a comment, and every call has always stored one — into the
operations journal, next to the operator, the verb and the per-account
outcomes. The account's trail read a different table and showed none of it, so
the one question an audit trail is opened for went unanswered: not what
changed, but why somebody changed it.

The trail now carries both records. A binding row still says what a decision
did; an operator call says who ran it, what they typed, how far it reached and
what it did to this account. They are matched by the account named in the
call's own payload — never by proximity in time — and rendered as two rows, not
folded into one: a merge moves a dozen accounts, and claiming this account's
row IS that call would invent a link the journal does not record.

The people a trail names are resolved too, so an entry no longer degrades to a
bare id whenever it points at somebody who is not a queue candidate — which is
most of them, once an account has been decided.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
A login-minted person is a stub: the journal holds one binding for them and
nothing else — no name, no address, no decision — until the resolver adopts
them or an operator says whose they are. They may well duplicate a person the
roster already knows, which is the trade first-login provisioning makes.

The queue says so for the account. The person themselves said nothing, and
they appear where it matters most: in the picker, which is where the wrong
person gets chosen, and in a candidate list, where a merge INTO the stub is the
wrong direction — the history is on the other side of it.

The mark is a read over their bindings rather than a card attribute, because
that is what it is a fact about: a card is assembled from observed values, and
a stub has none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
Both ways into the console are entered through a person: the queue arrives
from a problem the resolver could not settle, the person mode from a name. An
operator handed an account instead — a git login off a review, an address off
a ticket — could not ask the only question they have.

`GET /v1/resolution/accounts?q=` matches the same folded evidence the queue
reads, filtered to what an account carries: address, handle, id, observed name.
Each match answers with the holder, hydrated, and who decided that binding —
undoing automation is routine, overruling a colleague is not. Bound to nobody
is an answer of its own and says so rather than leaving a gap.

Three characters minimum: a shorter needle scans the whole fold to answer with
everything.

Matches open in the same case window as everywhere else, so a correction is one
press from the search that found it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
It opened to a search box over blank space, which reads as a surface that
failed to load rather than one waiting for a question. It now says what can be
searched — an address, a handle, an id, the name a source gives the account —
and that three characters is the floor, matching the mode beside it.

A search that matches nothing gets the same treatment, plus the fact that
frames the result: only accounts a connector has actually seen can be found
here, so "nothing" is an answer about the evidence, not about the search.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
Three defects, all found by adversarial review of the branch:

The provisional flag was computed over the hydrated cards' keys — and a
login-minted person has no card attributes at all, their journal holds only
the binding row. So the one candidate the flag was built to mark, the stub on
every provisioned-at-first-sign-in queue item, was the one person it was never
true for. The set is now computed over the candidate ids themselves.

The id+value person search intersected the named ids with a tenant-wide value
search that is truncated to a prefix by person id. A person who genuinely
matched both could sort past that prefix and vanish from the answer with
truncated=false — "this person does not match", the exact wrong reading on a
disambiguation surface. The value filter now runs WITHIN the named ids.

The id-named path also ignored the limit and returned persons in database
order, so the truncation probe popped a nondeterministic id. Sorted and capped
before the probe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
Three review findings with one root: the queue prunes a decided row at once
(the point of the optimistic prune), and the case window was fed straight from
that list — so the operator's own success yanked the case out from under them.
The outcome alert unmounted unread, unbound cases flashed "the link may be
stale", and the Next button vanished at the exact moment a conveyor needs it.
Each piece was tested in isolation; the breakage lived between them.

The window now holds what it knew about the open case — the row and its
position. A pruned row keeps its heading, candidates and outcome; the held
index points at whatever shifted into the slot, which is precisely where Next
should go.

The second root: "this account is unknown — the link may be stale" fired on
`queueItem == null`, and the search and person modes can never supply a queue
item — so an unbound account those modes just FOUND opened with no verbs,
against the account-mode's central use case (binding an unplaced account).
Both modes now hand the window their rows as cases: proof the account exists,
a proper heading instead of a raw id, and the holder rendered as a card. The
stale-link guard still fires where it should — on a mistyped link no caller
vouches for.

Also from the review: the filter box desynced from the URL on Back (input kept
"bob" over an unfiltered list); closing the window after deciding the last-
focused row dropped keyboard focus to nowhere — it now falls to the top of the
list; and the dialog's `initialFocus={false}` turned out to move focus NOT AT
ALL, stranding it in the aria-hidden page behind the dialog — the window
itself takes focus now, not its copy button, not the background.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
… missing

Accessibility, from the review's live pass:
- the person picker nested the card's copy <button> inside a row <button> —
  invalid HTML, a React error per render, and the copy control unreachable.
  Rows are role="button" divs now, the queue rows' own pattern.
- the rates-strip Info tooltips carried the tiles' actual meaning and were
  mouse-only; the triggers are focusable and their accessible name is the hint.
- a queue row's accessible name said label and source while the sighted row
  said who holds it, its status and what the source calls it; the label now
  carries all of it.

Copy, where the words said the wrong thing:
- the page subtitle described the review queue; it captions three modes now,
  two of which exist for settled bindings.
- "only a person can bind these" collided with the surface's own noun — it is
  an operator who binds accounts TO persons.
- "held by {{name}}" instead of "now {{name}}", which read as a rename.
- the merge dialog names the absorbed person, not only the survivor: a
  wrong-direction merge should have to say who it erases.
- "Assign to someone else" implied an "else" over an unbound account.
- the "Needs a decision" hint called its figure cases; it counts accounts.
- an operator call row wears a "the call" badge, so two same-verb rows in the
  trail stop being twins; the age sits in parentheses so the instant and the
  age stop concatenating for screen readers and the clipboard.

Tests the audit found missing:
- vitest now runs under a pinned non-UTC timezone. CI runners live in UTC,
  where "parse zone-less as UTC" and "parse as local" are the same function —
  both zone-handling tests passed with the handling deleted.
- the verb hooks' cache pruning is pinned under the REAL query key, session
  scope included: decided rows leave at once, refused rows stay, both cache
  families invalidate. A key drift here shipped green before.
- the trail's newest-first interleave of decisions and calls is pinned.
- the stand suite covers GET /v1/resolution/accounts (200 with the holder
  named, 400 short needle, 403 realm admin) — catalogued but untested, it
  would have failed the coverage gate as swept-only on the first PR run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
Two ways the account search lied about a holder, both from external review:

A holder with no card attributes — a login-minted stub — missed the card
hydration entirely, so the response carried `person: null` and the row said
"bound to nobody" about a bound account. The holder is now back-filled as an
id-only card, the same rule every other surface applies.

An excluded account came out the same way: `person: null`, indistinguishable
from never-bound — presenting an operator's recorded exclusion as an
invitation to bind the bot and undo it. The response now says `excluded`
explicitly and the row renders it as its own state, not as an absence.

Also from the same review: the cache-prune key in `cases.ts` used literal NUL
bytes as separators, which made git treat the TypeScript file as binary — it
now uses the shared `accountKey`, which is collision-safe by URI-encoding; a
comment in the query layer stated latency as a fact about real deployments
rather than a property of dataset size; and two files gained a blank line at
EOF.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown

Important

Review available on request

  • 🔍 Trigger review

Reviews should be triggered manually for repositories with fewer than 10 stars. Select Trigger review above or comment @coderabbitai review to review the latest changes. For a full review, comment @coderabbitai full review.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7d725d0b-c2c7-429c-a282-8e9b6c3ab39d


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mozhaev-dev
mozhaev-dev marked this pull request as ready for review August 16, 2026 03:10
@mozhaev-dev
mozhaev-dev requested a review from a team as a code owner August 16, 2026 03:10
@mozhaev-dev
mozhaev-dev added this pull request to the merge queue Aug 17, 2026
Merged via the queue into main with commit 24c07ce Aug 17, 2026
61 checks passed
@mozhaev-dev
mozhaev-dev deleted the identity-console-usability branch August 17, 2026 03:09
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.

2 participants