Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f8a01ef
frontend: say what the identities console actually knows
mozhaev-dev Aug 15, 2026
c4e9003
frontend: decide a case in a window, not in a column
mozhaev-dev Aug 15, 2026
a66dd03
frontend: one argument is one case, not one row per account
mozhaev-dev Aug 15, 2026
60cf2e6
frontend: narrow the queue to what you are looking for
mozhaev-dev Aug 15, 2026
4043b42
frontend: work the queue like a queue
mozhaev-dev Aug 15, 2026
d52d588
frontend: give the case window a settled shape
mozhaev-dev Aug 15, 2026
aa9c9f0
identity: describe the accounts automation cannot place
mozhaev-dev Aug 15, 2026
8348d3a
frontend: a second way into the console — a person and their accounts
mozhaev-dev Aug 15, 2026
0a94913
identity: say which candidate holds the account being argued over
mozhaev-dev Aug 15, 2026
458e7d9
identity: let the person search take an id
mozhaev-dev Aug 15, 2026
ab52e09
identity: keep a login-minted person on the queue until a human decid…
mozhaev-dev Aug 15, 2026
8f089ee
identity: let an account's trail say who decided it and why
mozhaev-dev Aug 15, 2026
1f9b957
identity: mark a person who exists only because somebody signed in
mozhaev-dev Aug 15, 2026
78fc4a5
identity: find an account, and say whose it is
mozhaev-dev Aug 15, 2026
1aca9fd
frontend: say what the account mode is for before anything is searched
mozhaev-dev Aug 15, 2026
f668c56
identity: fix what the review caught in the new reads
mozhaev-dev Aug 15, 2026
d4e2821
frontend: hold the open case while the list moves under it
mozhaev-dev Aug 15, 2026
e43ecfb
frontend+stand: the review's smaller catches, and the tests that were…
mozhaev-dev Aug 15, 2026
1d5d0bd
identity: a bound account never reads as unbound in the search
mozhaev-dev Aug 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
327 changes: 325 additions & 2 deletions docs/components/backend/identity-resolution/openapi.json

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions src/backend/services/identity-resolution/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,32 @@ fn build_operations(router: Router, openapi: &dyn OpenApiRegistry) -> Router {
.handler(resolution::attention)
.register(router, openapi);

let router = OperationBuilder::get("/v1/resolution/accounts")
.operation_id("identity_resolution.resolution.search_accounts")
.summary("Find an observed account by a value it carries, and whose it is")
.authenticated()
.no_license_required()
.query_param_typed(
"q",
true,
"Needle matched against the account's current address, handle, id and observed name (at least 3 characters).",
"string",
)
.query_param_typed(
"limit",
false,
"Cap on returned matches (1..=100, default 20); `truncated` says whether it cut the list.",
"integer",
)
.json_response_with_schema::<resolution::AccountSearchResponse>(
openapi,
StatusCode::OK,
"Matching accounts with their holders",
)
.standard_errors(openapi)
.handler(resolution::search_accounts)
.register(router, openapi);

let router = OperationBuilder::get("/v1/resolution/accounts/{source}/{source_id}/{account_id}")
.operation_id("identity_resolution.resolution.account_binding")
.summary("Current binding of an account and every decision behind it")
Expand Down
94 changes: 91 additions & 3 deletions src/backend/services/identity-resolution/src/api/persons.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
//! both — the operator is the disambiguator, and hiding one of them by
//! recency would decide a contested case silently.
//!
//! A term that parses as a UUID names a person id instead: it is the one
//! identifier an operator can copy off a card, and the only way to reach a
//! person the journal holds no values for.
//!
//! Admin-gated and deliberately NOT visibility-filtered: this is the operator
//! surface, and the seeded operator sits outside the org chart on purpose.

Expand All @@ -20,12 +24,14 @@ use serde::{Deserialize, Serialize};
use toolkit_canonical_errors::CanonicalError;
use toolkit_security::SecurityContext;
use utoipa::ToSchema;
use uuid::Uuid;

use super::AppState;
use super::error::PersonSearchError;
use super::gate::require_admin;
use super::resolution::PersonSummaryResponse;
use crate::domain::person_card;
use crate::domain::resolution::EXCLUDED_PERSON;
use crate::infra::db::persons_repo;

const DEFAULT_LIMIT: u64 = 20;
Expand Down Expand Up @@ -68,11 +74,16 @@ pub async fn search_persons(
let terms = search_terms(params.q.as_deref())?;
let limit = super::listing::clamp_limit(params.limit, DEFAULT_LIMIT, MAX_LIMIT);

let (named, values) = partition_terms(&terms);

// Over-fetch by one: the extra row is the truncation probe, never served.
let mut ids =
persons_repo::search_persons_by_current_values(&state.db, tenant, &terms, limit + 1)
let mut ids = if named.is_empty() {
persons_repo::search_persons_by_current_values(&state.db, tenant, &values, &[], limit + 1)
.await
.map_err(|e| read_err(&e))?;
.map_err(|e| read_err(&e))?
} else {
persons_named_by_id(&state, tenant, &named, &values, limit + 1).await?
};
let truncated = ids.len() > usize::try_from(limit).unwrap_or(usize::MAX);
if truncated {
ids.pop();
Expand All @@ -86,6 +97,9 @@ pub async fn search_persons(
.map(PersonSummaryResponse::from)
.collect();
sort_for_display(&mut items);
// A picker is where the wrong person gets chosen, so a person who exists
// only because somebody signed in must say so here of all places.
super::resolution::mark_provisional(&state, tenant, &mut items).await?;

Ok(Json(PersonListResponse {
items,
Expand All @@ -94,6 +108,55 @@ pub async fn search_persons(
}))
}

/// A term that parses as a UUID names a person id; everything else is matched
/// against observed values.
///
/// Without this the one identifier an operator can copy off a card finds
/// nothing, and a person the journal holds no attributes for — minted at first
/// sign-in, before the resolver attaches the roster's name — cannot be found at
/// all, since a value search has no value to match.
fn partition_terms(terms: &[String]) -> (Vec<Uuid>, Vec<String>) {
let mut named = Vec::new();
let mut values = Vec::new();
for term in terms {
match Uuid::parse_str(term) {
// The excluded-person sentinel is not a person; naming it finds
// nobody rather than serving the row every exclusion appends to.
Ok(id) if id != EXCLUDED_PERSON => named.push(id),
Ok(_) => {}
Err(_) => values.push(term.clone()),
}
}
(named, values)
}

/// Persons named by id, narrowed by any value terms alongside them.
///
/// The value filter runs WITHIN the named ids — intersecting with a tenant-wide
/// value search would test membership in an independently truncated prefix and
/// silently drop a genuine match. Sorted and capped so the caller's truncation
/// probe drops a deterministic id, never whichever the database returned last.
async fn persons_named_by_id(
state: &AppState,
tenant: Uuid,
named: &[Uuid],
values: &[String],
limit: u64,
) -> Result<Vec<Uuid>, CanonicalError> {
let mut known = persons_repo::persons_in_tenant(&state.db, tenant, named)
.await
.map_err(|e| read_err(&e))?;
known.sort_unstable();
known.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
if values.is_empty() || known.is_empty() {
return Ok(known);
}

persons_repo::search_persons_by_current_values(&state.db, tenant, values, &known, limit)
.await
.map_err(|e| read_err(&e))
}

/// Split `q` into terms: non-empty, whitespace-separated, capped in count and
/// total length.
fn search_terms(q: Option<&str>) -> Result<Vec<String>, CanonicalError> {
Expand Down Expand Up @@ -169,4 +232,29 @@ mod tests {
);
Ok(())
}

#[test]
fn a_uuid_term_names_a_person_while_the_rest_match_values() {
let terms = vec![
"019e27bc-dec6-7773-b1e7-820ea2624b1b".to_owned(),
"ann".to_owned(),
];

let (named, values) = partition_terms(&terms);

assert_eq!(named.len(), 1, "the id is a name, not a value to match");
assert_eq!(values, vec!["ann".to_owned()]);
}

#[test]
fn the_excluded_sentinel_names_nobody() {
// It accumulates a journal row per exclusion, so it exists in the
// table — and it is still not a person the picker may offer.
let terms = vec![EXCLUDED_PERSON.to_string()];

let (named, values) = partition_terms(&terms);

assert!(named.is_empty());
assert!(values.is_empty(), "not matched as a value either");
}
}
Loading
Loading