identity: make the operator console decidable — cases, trails, three ways in - #2579
Merged
Conversation
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>
|
Important Review available on request
Reviews should be triggered manually for repositories with fewer than 10 stars. Select Trigger review above or comment ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 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. Comment |
mozhaev-dev
marked this pull request as ready for review
August 16, 2026 03:10
hello1101n
approved these changes
Aug 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
provisionalmark. AllAPI 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 testin src/frontend; or by handVITE_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