Skip to content

feat(analytics,identity): metric-results keys on person_id — the identity cutover (⚠️ merge together with the frontend) - #2098

Merged
mozhaev-dev merged 34 commits into
mainfrom
feat/metrics-person-id-cutover
Aug 3, 2026
Merged

feat(analytics,identity): metric-results keys on person_id — the identity cutover (⚠️ merge together with the frontend)#2098
mozhaev-dev merged 34 commits into
mainfrom
feat/metrics-person-id-cutover

Conversation

@mozhaev-dev

@mozhaev-dev mozhaev-dev commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

⚠️ DO NOT MERGE ALONE — breaking wire change, needs the frontend in lockstep

This flips POST /v1/metric-results from email entity ids to canonical person UUIDs. An email in entity.ids is now a 400. The current frontend sends emails (session email, /ic/<email>/personal routes), so merging this without the paired cyber-insight-front change takes every dashboard down.

Kept as a draft on purpose — that is the mechanical guarantee against an accidental merge. Reviewable as-is; flip to ready only when the frontend PR is open and both can go in together (this one first, so the FE never speaks UUID to an email-only backend).

What

Iteration 2 of the metrics person_id rework: the runtime now keys on the canonical person the identity pipeline resolves into the gold tables (delivered by #2065 + #2066). Two people's accounts that resolve to one person finally read as one person; rows identity cannot resolve (person_id IS NULL) drop out of every query — the epic's excluded, not guessed, now enforced by the key itself.

Also integrates and re-contracts #2082 (@aleksdotbar's visibility gate). Its commits are merged here, and its whole stack moved from emails to person UUIDs — see the coordination note at the bottom.

How

Validationentity.ids parse as UUIDs into person_ids: Vec<Uuid> (parse-don't-validate). The pre-cutover email shape and the nil UUID are both loud 400s, never silent empty results. The email lowercasing normalizer is gone.

Compiler — every view (period / timeseries incl. capped / breakdown / histogram / ranking / peer) filters person_id IN (…), groups and orders by it, projects toString(assumeNotNull(person_id)).

Peer — the cohort join runs person_id = person_id instead of string email equality, the fragile HR-email↔source-email match this rework exists to kill. Two hardenings, both from review:

  • the cohort view is unique per email, so two emails resolving to one person could double-weight a peer → both cohort CTEs collapse to person grain;
  • contested membership is excluded, not tie-broken: HAVING uniqExact(cohort_id) = 1, evaluated over the person's complete membership set (the target-cohort filter applies outside the guard — filtering first would hide the conflicting row and wave the person through). A person whose emails claim different departments leaves peer comparison until the org assignment is fixed.

Authorization — the gate now forwards validated person UUIDs to identity's POST /v1/visible-persons (which takes person_ids and answers UUIDs; its email-resolution layer, needed only as the pre-cutover bridge, is deleted along with resolve_person_ids_by_emails). It runs before any ClickHouse work; service principals bypass; a single non-visible id refuses the whole request rather than silently dropping an entity.

Schema validatorperson_id joins OBSERVATION_COLUMNS / COHORT_COLUMNS, as their comment promised: a table without it would 500 every query, so the probe now marks the source unavailable upfront.

Naming discipline — everything carrying the person UUID is named person_id/person_ids through validation, compiler rows, batch demux and builder. entity_id survives only as the wire field name, documented as a seam at the single builder/DTO boundary.

Contracts — metrics DESIGN, gold/schema.yml, request DTO docs, and both OpenAPI specs (analytics + identity, the published one described a contract that no longer existed). The person-only entity contract is stated in code where a first non-person type will land: ids parse as person UUIDs for every type and the gate rules on person alone (fail-closed), so a new type must bring both halves.

Testing

  • cargo test --workspace: 514 + 89 + … , 0 failed · clippy pedantic workspace-clean · fmt clean.
  • Coverage of the touched code: gate person_visibility.rs 100%, compiler.rs 97.8%, builder.rs 92.9%, batch.rs 89.3%, validation.rs 87.6%.
  • The gate's ignored live tests were rewritten: they spoke the email contract, so the 403 case would have died on validation and the not-denied case asserted only != 403 — which that 400 satisfies. Both now use named person UUIDs, and the not-denied case asserts the exact 500 from the unreachable ClickHouse, so it fails if the request stops earlier.
  • e2e: metrics 35/36, api 93 passed, identity 133 passed. Endpoint-coverage gates pass: analytics 28/28 operations (metric-results' 403 is now genuinely observed, not BLOCKED boilerplate), identity 19/19.
  • The metric yaml rig stays authored in emails — the readable persona key — and owns the translation: personas get deterministic uuid5 ids, identity.identity_persons is seeded before the gold build so the dbt resolve macro attributes rows, requests translate email→UUID and responses back. Two latent traps closed: the stub's visible set is derived from the yaml under test (a new persona can't fail as a phantom 403), and a UUID collision in the reverse map raises instead of silently dropping a spelling.

Known, not ours: metrics/ai_assistant_activity fails here and on main alike (silver.class_ai_assistant_usage builds empty, before any person_id is involved) — filed separately, not a cutover regression.

Coordination — #2082

@aleksdotbar — your visibility gate is merged into this branch (commits preserved), but its contract had to change: entity.ids is UUIDs now, so /v1/visible-persons takes person_ids and the email→person resolution step is gone. Visibility itself was always person_id-native inside identity, so the gate got simpler, not harder. Please look at how it landed and say which you prefer: (a) #2082 merges first as-is and this PR rebases to re-contract it, or (b) this PR supersedes it and #2082 closes with a pointer here.

Rollout order

The gold person_id column is populated by a dbt build that reads a ClickHouse
mirror of the identity persons log. Deploy in this order:

  1. persons-sync — populates identity.identity_persons. The CronJob runs
    daily at 06:45, and nothing triggers an initial run, so on a fresh stand
    run the Job by hand or every person_id resolves NULL until the next tick.
  2. dbt gold build — resolves person_id into the observation and cohort
    relations. Until it runs, the old gold tables lack the column and the
    analytics schema validator reports schema_status: error for them.
  3. analytics + identity-resolution — the services in this PR.

Between steps the failure mode is honest emptiness, not a 500: a metric whose
person_id is NULL contributes no rows.

Tenant stamp caveat

Compiled queries filter on the warehouse tenant_id, bound from the caller's
gateway JWT. The platform has no defined mapping between the control-plane
tenant id and the tenant_id string stamped at ingestion. On a stand where the
two differ, every metric comes back empty with no log line saying why — a
configuration problem that reads exactly like "no data for this period".

Tracked in #2107 (deploy-time warning when the JWT tenant matches no stamp). The
predicate itself is not from this PR; it arrived with the metric-results
authorization work and is inherited here.

Refs #1873; builds on #2065, #2066; integrates #2082.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added POST /v1/visible-persons to return person IDs visible to the authenticated caller.
    • Added profile and person lookup using canonical person UUIDs.
    • Analytics metric results now support UUID-based person filtering with visibility enforcement.
  • Bug Fixes

    • Invalid, unknown, nil, or legacy email identifiers now return clear errors.
    • Improved tenant isolation and fail-closed authorization behavior.
    • Prevented empty synchronization results from replacing existing data unless explicitly forced.
  • Documentation

    • Updated API specifications and guides with UUID requirements, visibility rules, validation constraints, and response details.

mozhaev-dev and others added 8 commits July 31, 2026 07:58
Iteration 2 of the metrics person_id rework: the runtime switches from the
email entity key to the canonical person UUID the identity pipeline has
been resolving into the gold tables since #2065/#2066. Hard switch by
decision — no compatibility flag; rollout is gated on the dev coverage
numbers, not on code.

- validation: `entity.ids` parse as UUIDs into `person_ids: Vec<Uuid>`
  (parse-don't-validate; the pre-cutover email shape is now a loud 400,
  never a silent empty result). The email lowercasing normalizer is gone.
- compiler: every view (period/timeseries incl. capped, breakdown,
  histogram, ranking, peer) filters `person_id IN (…)`, groups and orders
  by person_id, and projects `toString(assumeNotNull(person_id))`.
  Unresolved rows (person_id NULL) fall out of every query — the epic's
  "excluded, not guessed" stance, now enforced by the key itself.
- peer: the cohort join runs person_id = person_id instead of string
  email equality — the fragile HR-email↔source-email match this rework
  exists to kill. The person grain is hardened with LIMIT 1 BY person_id
  in both cohort CTEs: the view is unique per EMAIL, and two emails
  resolving to one person must not double-weight a peer or blend pools.
- schema validator: person_id joins OBSERVATION_COLUMNS/COHORT_COLUMNS in
  this same change, as the constants' comment promised — a table without
  the column now fails the probe loudly instead of answering empty.
- naming discipline (review requirement): everything carrying the person
  UUID is named person_id/person_ids through validation, compiler rows,
  batch demux and builder; `entity_id` survives ONLY as the wire field
  name, documented as a seam at the single builder/DTO boundary.

501 unit tests green (peer-guard and ranking tests updated to the new
grain), clippy pedantic clean, fmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
…ested cohorts, contract docs

Review follow-ups on the person_id cutover (Codex):

- schema validator: person_id actually joins OBSERVATION_COLUMNS /
  COHORT_COLUMNS now — the previous commit CLAIMED this but the edit had
  silently missed (the comment block drifted after the #2066 merge and the
  patch matched nothing). Without it a person_id-less table passes the
  probe and every query 500s at SQL time; with it the source is marked
  unavailable upfront.
- peer cohorts: contested membership is EXCLUDED, not tie-broken. The
  LIMIT 1 BY person_id survivor pick silently awarded a person with
  conflicting cohort rows (two emails, two departments) to the
  lexicographically first cohort — against the epic's never-silently-
  assign stance. Both cohort CTEs now collapse to person grain with
  GROUP BY + HAVING uniqExact(cohort_id) = 1: agreeing duplicates merge,
  conflicted persons drop out of targets and pools until the org
  assignment is fixed.
- contracts caught up with the runtime: metrics DESIGN (ids are person
  UUIDs; person_id is THE key; entity_id = table email key + wire field
  name), gold/schema.yml person_id notes (no longer "additive, unread"),
  request DTO field docs, regenerated analytics OpenAPI.
- e2e: the well-formed-request helper sends a valid nobody-UUID instead of
  an email (which now fails validation before reaching the scenario under
  test), plus an explicit test pinning the email shape to a loud 400.

501 unit tests, clippy pedantic 0, fmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
…gate' into feat/metrics-person-id-cutover

# Conflicts:
#	src/backend/services/analytics/src/api/metric_results.rs
#	src/backend/services/analytics/src/domain/metric_results/validation.rs
…on_id cutover

Closes the two blocking review findings (Codex, round 2):

- peer pool conflict guard ordering: the target-cohort filter ran BEFORE
  `HAVING uniqExact(cohort_id) = 1`, hiding a conflicted person's
  other-cohort row and waving them into the pool. The guard now aggregates
  the COMPLETE membership set in an inner query and the target-cohort
  filter applies outside it; a unit test pins the order.

- authorization gate integration: merges fix/2035-metric-results-authz-gate
  and rewires its whole stack onto the cutover's person UUIDs —
  * identity `POST /v1/visible-persons` takes `person_ids: Vec<Uuid>` and
    answers visible UUIDs; the email→person resolution layer and its
    collation-mismatch defense are gone (that was the pre-cutover bridge;
    visibility itself was always person_id-native), along with
    `resolve_person_ids_by_emails` and its live tests;
  * the analytics gate forwards the validated `person_ids` verbatim and
    compares UUID sets; the email normalizer helper is deleted;
  * metric-results authorizes BEFORE any ClickHouse work, service
    principals bypass, non-visible ids 403 the whole request.

- e2e: the yaml metric rig stays authored in emails (the readable persona
  key) and owns the cutover translation — personas get deterministic
  uuid5 person ids (lib.identity_stub.person_id_for), identity_persons is
  seeded before the gold build so the dbt resolve macro attributes rows,
  requests translate email→UUID and responses back for the expect rules;
  the identity stub serves the UUID visible-persons contract; the
  visible-persons and metric-results suites assert UUID semantics
  including the email-shape 400.

513 + 89 unit tests, clippy pedantic clean on both crates, fmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
…REGATION)

Caught by the e2e run, not the unit tests (they assert SQL text, not CH
acceptance): aliasing `any(cohort_id) AS cohort_id` shadows the source
column, and ClickHouse substitutes the aggregate into the enclosing WHERE
— code 184, a 500 on every peer view. The alias is now
`resolved_cohort_id`, re-projected as `cohort_id` one level up; verified
against the live e2e ClickHouse (peer stats return for a translated
persona) and pinned in the ordering unit test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
… ids

The rig seeded identity_persons only for the personas a case REQUESTS, so
cohort members that exist purely as seeded HR data resolved to NULL and
dropped out of peer pools — 32 of 36 cases failed on a peer `n` one or two
short. Every email the yaml mentions (bronze seeds included) now gets a
binding; only the requested ids take part in the wire translation.

That is the same failure mode the rework exists to eliminate, in miniature:
an unresolved person silently shrinks their whole team's comparison pool.

35/36 cases green. The one remaining failure (ai_assistant_activity) is a
PRE-EXISTING break on main — verified by running the case on main itself:
silver.class_ai_assistant_usage builds empty there too, before any
person_id is involved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
Review follow-up: the ignored live tests still spoke the pre-cutover email
contract, so neither one tested what it claimed. The 403 case sent an email
and would have died on validation's 400 long before the gate; the
not-denied case asserted only `!= 403`, which that same 400 satisfies — a
request that never reached the gate would have passed it silently.

The loopback identity now serves the `person_ids` contract, both cases use
named person UUIDs (VISIBLE_PERSON / HIDDEN_PERSON), and the not-denied
case asserts the EXACT 500 from the unreachable ClickHouse: the assertion
now fails if the request stops earlier for any reason.

Also drops the email wording from the /v1/visible-persons OpenAPI summary
and request description, and regenerates the analytics spec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
… nil ids

Review follow-ups (round 3):

- The PUBLISHED identity contract still described emails, so a client
  generated from it would have gotten a 400 on every call: openapi.json's
  VisiblePersons request/response are now uuid-typed arrays, and README,
  DESIGN and ADR-0015 describe person ids. The docs also referenced
  `persons_repo::resolve_person_ids_by_emails`, deleted with the email
  bridge — ADR-0015 now records why it is gone instead of pointing at it.
- A nil UUID in entity.ids produced a 500: analytics let it through,
  identity 400s on it, and the gate maps any identity non-success to a
  server error. It is now rejected in parse_person_ids as the client error
  it is.
- The wildcard short-circuit's semantics are documented where they live: it
  echoes the request, so the answer is a subset of the input and never a
  statement that each id exists in the tenant (the SQL branch cannot say
  more either — absent-from-visible-set and absent-from-tenant are one
  answer).
- Fixture-rig traps: the metric rig now derives the identity stub's visible
  set from the yaml it is running (`allow_visible`), so a new persona
  cannot fail as a phantom 403 against a hand-kept list; and the reverse
  UUID→email map raises on a collision instead of silently dropping one
  spelling.
- The person-only entity contract is stated where a future non-person type
  will land: ids parse as person UUIDs for every entity type and the gate
  has a rule for `person` only (fail-closed otherwise) — a first
  non-person type must bring both halves.

514 + 89 unit tests, clippy workspace clean, fmt clean. e2e re-verified:
metrics 35/36 (the one failure reproduces on main), api 93 passed,
identity 133 passed; endpoint-coverage gates pass (analytics 28/28 ops
with metric-results' 403 now genuinely observed, identity 19/19).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Regenerate the connectors-ddl snapshot

This PR changes src/ingestion/**. If your change affects any
bronze / silver / gold schema, regenerate the committed DDL snapshot
and include it in this PR.

Prerequisites (details: src/ingestion/scripts/bootstrap-db/README.md):

  • docker + a fresh throwaway ClickHouse 25.7.5 (README "Local ClickHouse for testing")
  • .env from .env.bootstrap.example pointing at it; use the host LAN IP,
    reachable from both the host and connector containers
    (host.docker.internal does not resolve on the macOS host itself)
  • python3.12 or python3.11 on PATH (pinned dbt venv)
  • HubSpot + Salesforce credentials in .env — their discover calls the
    live APIs; without them, apply ../connectors-ddl/{hubspot,salesforce}.sql
    (relative to bootstrap-db/) to seed their bronze, then run the dbt step
cd src/ingestion/scripts/bootstrap-db
set -a; source pins.env; source .env; set +a
./bootstrap-db.sh connectors-config.yaml   # fresh ClickHouse 25.7.5
./dump-ddl.sh                              # writes scripts/connectors-ddl/*.sql

Commit the resulting scripts/connectors-ddl/*.sql diff. If nothing
changed, no snapshot update is needed. (Regeneration is manual for now.)

…-id-cutover

Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>

# Conflicts:
#	src/backend/services/analytics/src/domain/metric_results/compiler.rs
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The change replaces email-based person identifiers with canonical UUIDs across Identity Resolution, Analytics, metric processing, API contracts, synchronization, and end-to-end fixtures. Analytics checks person visibility before metric execution.

Changes

Identity visibility and resolution

Layer / File(s) Summary
UUID visibility and profile APIs
src/backend/services/identity-resolution/..., docs/components/backend/identity-resolution/...
Adds canonical UUID profile lookup and authenticated batch visibility filtering.
Visibility and profile validation coverage
src/ingestion/tests/e2e/identity/*, src/backend/services/identity-resolution/src/infra/db/visible_set_live_tests.rs
Covers UUID validation, tenant isolation, grants, reporting visibility, wildcard access, and non-admin behavior.

Analytics UUID migration

Layer / File(s) Summary
Analytics routing and authorization
src/backend/services/analytics/src/api/*, src/backend/services/analytics/src/domain/person_visibility.rs, src/backend/services/analytics/src/infra/identity/mod.rs
Validates UUID paths and filters, forwards authorization headers, and rejects hidden person IDs before metric execution.
Metric-results pipeline
src/backend/services/analytics/src/domain/metric_results/*, src/backend/services/analytics/src/domain/metric_definitions/validator.rs, src/ingestion/gold/schema.yml
Uses person_id for validation, SQL filtering, grouping, peer queries, row decoding, and result building. Responses retain entity_id as the UUID-bearing field.
Contracts and end-to-end fixtures
docs/components/backend/analytics/*, docs/domain/metrics/specs/DESIGN.md, src/ingestion/tests/e2e/api/*, src/ingestion/tests/e2e/metrics/*
Documents UUID contracts and updates fixtures and tests for visible, hidden, invalid, nil, unknown, and mixed identifiers.

Synchronization safeguards

Layer / File(s) Summary
Empty-log protection
src/backend/services/identity-resolution/src/domain/sync_service.rs, src/backend/services/identity-resolution/src/sync_runner.rs, src/backend/services/identity-resolution/src/infra/db/persons_log_repo.rs
Moves empty-log protection into run_sync, adds explicit sync errors, supports forced empty snapshots, and removes the reader count query.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MetricsClient
  participant Analytics
  participant IdentityResolution
  participant MariaDB
  participant ClickHouse
  MetricsClient->>Analytics: Submit metric request with person UUIDs
  Analytics->>IdentityResolution: Check caller visibility
  IdentityResolution->>MariaDB: Resolve visible person set
  MariaDB-->>IdentityResolution: Return visible UUIDs
  IdentityResolution-->>Analytics: Return visible UUID subset
  Analytics->>ClickHouse: Execute UUID-filtered metric query
  ClickHouse-->>Analytics: Return metric rows
  Analytics-->>MetricsClient: Return metric results
Loading

Possibly related issues

Possibly related PRs

Suggested labels: stack:metric-drilldown

Suggested reviewers: mitasovr, cyberantonz

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the analytics and identity cutover from email keys to canonical person_id keys.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/metrics-person-id-cutover

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 July 31, 2026 08:11
@mozhaev-dev
mozhaev-dev requested a review from a team as a code owner July 31, 2026 08:11

@coderabbitai coderabbitai 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.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/ingestion/gold/schema.yml (1)

610-617: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update the adjacent model description, which still claims the peer view relies on email-grain uniqueness.

This new person_id text is accurate. The model description above it (lines 588-593) is now stale. It says uniqueness is per (tenant, entity_type, entity_id, cohort_key) and is "relied on by the analytics service peer view (join fan-out would corrupt percentiles)".

The peer view no longer depends on that. compile_peer_batch_query collapses both cohort CTEs to person grain with GROUP BY person_id plus HAVING uniqExact(cohort_id) = 1, precisely because email-grain uniqueness does not imply person-grain uniqueness — two emails for one person yield two rows. Fan-out protection now lives in the query, not in this table's uniqueness.

📝 Proposed change
       Current cohort membership per entity for peer comparison. One row per
       (tenant, entity_type, entity_id, cohort_key) — uniqueness is asserted by
-      tests/gold/assert_metric_entity_cohorts_unique.sql and relied on by the
-      analytics service peer view (join fan-out would corrupt percentiles). The
-      column set is a published contract probed by the analytics service schema
-      validator (`COHORT_COLUMNS`).
+      tests/gold/assert_metric_entity_cohorts_unique.sql. Uniqueness is per
+      EMAIL, not per person: since the identity cutover the peer view joins on
+      person_id and collapses to person grain itself, because two emails can
+      resolve to one person. The column set is a published contract probed by
+      the analytics service schema validator (`COHORT_COLUMNS`).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ingestion/gold/schema.yml` around lines 610 - 617, Update the adjacent
model description for the table’s uniqueness constraint to remove the claim that
email-grain uniqueness is relied on by the analytics service peer view. State
that peer fan-out protection is enforced by compile_peer_batch_query collapsing
cohort CTEs to person grain with GROUP BY person_id and HAVING
uniqExact(cohort_id) = 1, while preserving the remaining schema semantics.
🧹 Nitpick comments (12)
src/ingestion/tests/e2e/api/test_metric_results.py (1)

45-52: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Consider adding a nil-UUID 400 test.

test_metric_results_400_non_uuid_person_ids covers the pre-cutover email shape, but a nil UUID (00000000-0000-0000-0000-000000000000) parses as a syntactically valid UUID, so this test does not exercise nil-UUID rejection. The PR commit history states nil UUIDs must return 400 as a distinct case. Add a sibling test asserting a nil UUID in entity.ids returns 400.

✅ Proposed additional test
def test_metric_results_400_nil_uuid_person_id(api) -> None:
    """A nil UUID is syntactically valid but never a real person; reject it as
    loudly as a non-UUID string."""
    body = _request(
        metrics=[{"metric_key": "git.commits", "views": [{"view": "period"}]}],
        entity_ids=("00000000-0000-0000-0000-000000000000",),
    )
    r = api.post("/v1/metric-results", json=body)
    assert r.status_code == 400, f"status={r.status_code} body={r.text}"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ingestion/tests/e2e/api/test_metric_results.py` around lines 45 - 52, Add
a sibling test next to test_metric_results_400_non_uuid_person_ids named
test_metric_results_400_nil_uuid_person_id, using the nil UUID in entity_ids and
the same metric request and API assertion, to verify it returns HTTP 400.
src/backend/services/identity-resolution/src/api/visible_persons.rs (1)

117-117: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use #[expect] with a reason instead of a bare #[allow].

#[allow(clippy::expect_used)] has no justification and uses #[allow] where the guideline prefers #[expect]. Add a reason and switch to #[expect] so the lint fails loudly if it stops firing.

As per coding guidelines, "Prefer #[expect(clippy::...)] over #[allow]; provide the justification on the same line as either lint attribute."

🔧 Proposed fix
 #[cfg(test)]
-#[allow(clippy::expect_used)]
+#[expect(clippy::expect_used, reason = "test assertions use expect for clear failure messages")]
 mod tests {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/identity-resolution/src/api/visible_persons.rs` at line
117, Replace the bare #[allow(clippy::expect_used)] attribute with
#[expect(clippy::expect_used, reason = "...")] at the affected location,
providing a concise justification on the same line while preserving the existing
lint suppression.

Source: Coding guidelines

src/backend/services/identity-resolution/src/infra/db/subchart_repo.rs (1)

125-165: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace rustdoc-style comments with plain comments in this service crate. Four new sites use /// or //! doc comments inside identity-resolution, which its own README describes as a service, not a shared library crate. The coding guideline bans documentation comments in services regardless of export status or content quality; the fix at each site is the same conversion to //.

  • src/backend/services/identity-resolution/src/infra/db/subchart_repo.rs#L125-L165: convert the /// doc comments (including the "# Errors" sections) on has_wildcard_grant and visible_targets to plain // comments.
  • src/backend/services/identity-resolution/src/api/visible_persons.rs#L22-L24: convert the /// doc comment on VisiblePersonsRequest to a plain // comment; also drop the "the earlier email-based draft of this endpoint never shipped" phase note per the "no phase/scope notes in source comments" rule.
  • src/backend/services/identity-resolution/src/infra/db/visible_set_live_tests.rs#L1-L4: convert the //! module doc comment to plain // lines; the INVARIANT: tags themselves are fine, only the //! syntax needs to change.

As per coding guidelines, "Use /// documentation comments only on exported items in shared library crates ... Do not add documentation comments to binaries or services, and do not enforce missing_docs," and "Do not use module headers, issue numbers, or phase/scope notes in source comments."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/identity-resolution/src/infra/db/subchart_repo.rs`
around lines 125 - 165, Replace Rustdoc comments with plain comments throughout
the identity-resolution service. In
src/backend/services/identity-resolution/src/infra/db/subchart_repo.rs lines
125-165, convert comments for has_wildcard_grant and visible_targets, including
Errors sections, from /// to //. In
src/backend/services/identity-resolution/src/api/visible_persons.rs lines 22-24,
convert the VisiblePersonsRequest comment to // and remove the email-draft phase
note. In
src/backend/services/identity-resolution/src/infra/db/visible_set_live_tests.rs
lines 1-4, convert the //! module header to // while preserving the INVARIANT
tags.

Source: Coding guidelines

src/backend/services/analytics/src/api/metric_results.rs (2)

37-53: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Move metric execution from query_metric_results into the domain layer.

query_metric_results performs query planning, concurrent execution, result assembly, and response mapping. The added visibility gate extends a handler that is already far beyond the API orchestration limit.

Keep this handler to extract → validate → domain call → map → respond. Move the execution workflow into a domain service or free function.

As per coding guidelines, API handlers must use “extract → validate → domain call → map → respond” and stay approximately 30 lines.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/analytics/src/api/metric_results.rs` around lines 37 -
53, Refactor query_metric_results so it only extracts request/context data,
validates the request, invokes a domain-level metric-results operation, maps its
result into MetricResultsResponse, and responds. Move query planning, concurrent
execution, result assembly, and related visibility-aware workflow into a domain
service or free function, preserving the existing authorization behavior and
response contract while keeping the handler near the extract → validate → domain
call → map → respond structure.

Source: Coding guidelines


42-44: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove source documentation that duplicates executable contract rules.

Use named tests for non-obvious behavior. Keep published API documentation in OpenAPI and design documents.

  • src/backend/services/analytics/src/api/metric_results.rs#L42-L44: express visibility-before-query behavior in a named test.
  • src/backend/services/analytics/src/api/metric_results.rs#L122-L124: express canonical UUID response behavior in a named test.
  • src/backend/services/analytics/src/api/http_live_tests.rs#L109-L118: use helper and test names instead of documentation comments.
  • src/backend/services/analytics/src/api/http_live_tests.rs#L135-L136: use the helper name to describe loopback visibility behavior.
  • src/backend/services/analytics/src/api/http_live_tests.rs#L211-L212: keep Authorization forwarding in test setup without explanatory comments.
  • src/backend/services/analytics/src/api/http_live_tests.rs#L284-L288: rename the test to state the downstream-failure rule.
  • src/backend/services/analytics/src/domain/metric_results/dto.rs#L17-L18: document the request contract in OpenAPI.
  • src/backend/services/analytics/src/domain/metric_results/dto.rs#L117-L118: document the response contract in OpenAPI.
  • src/backend/services/analytics/src/domain/metric_results/builder.rs#L56-L58: retain a named UUID wire-contract test.
  • src/backend/services/analytics/src/infra/identity/mod.rs#L43-L55: retain malformed-response coverage in tests.
  • src/backend/services/analytics/src/infra/identity/mod.rs#L150-L153: keep constructor error behavior in tests or API documentation.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/analytics/src/api/metric_results.rs` around lines 42 -
44, Remove source comments that duplicate executable contracts and replace each
behavior description with named tests or helpers: in
src/backend/services/analytics/src/api/metric_results.rs:42-44 and :122-124, add
or retain named tests for visibility-before-query and canonical UUID responses;
in src/backend/services/analytics/src/api/http_live_tests.rs:109-118, :135-136,
and :211-212, rely on descriptive helper/test names and keep Authorization
forwarding without explanatory comments; rename the downstream-failure test at
:284-288. Document request and response contracts in OpenAPI for
src/backend/services/analytics/src/domain/metric_results/dto.rs:17-18 and
:117-118, retain a named UUID wire-contract test in builder.rs:56-58,
malformed-response coverage in infra/identity/mod.rs:43-55, and constructor
error behavior in infra/identity/mod.rs:150-153 through tests or API
documentation.

Source: Coding guidelines

src/backend/services/analytics/src/infra/identity/mod.rs (1)

218-242: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Return a typed identity-client error.

visible_person_ids exposes anyhow::Result from reusable infrastructure code. Define a thiserror error type for transport, HTTP-status, and response-decoding failures, then map it at the API boundary.

As per coding guidelines, “Use typed thiserror errors in domain and library code; restrict anyhow to binary entry points and startup wiring.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/analytics/src/infra/identity/mod.rs` around lines 218 -
242, Define a custom error type using thiserror that represents the possible
failures for the identity client (transport, HTTP status, response decoding),
then update the visible_person_ids method to return this typed error instead of
anyhow::Result. Map the error sources at the function boundaries: the
.send().await? network call, the status check failure, and the .json().await?
JSON parsing, ensuring each maps to the appropriate error variant so callers
receive structured error information instead of opaque anyhow errors.

Source: Coding guidelines

src/backend/services/analytics/src/domain/person_visibility.rs (3)

115-116: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use #[expect] with a justification for the lint suppression.

The repository guidelines require #[expect(clippy::...)] over #[allow], and a justification on the attribute line.

♻️ Proposed change
 #[cfg(test)]
-#[allow(clippy::unwrap_used, clippy::expect_used)]
+#[expect(clippy::unwrap_used, clippy::expect_used, reason = "test setup fails loudly")]
 mod tests {

As per coding guidelines: "Prefer #[expect(clippy::...)] over #[allow]; provide the justification on the same line as either lint attribute."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/analytics/src/domain/person_visibility.rs` around lines
115 - 116, Replace the #[allow] lint suppression associated with the test module
with #[expect], preserving both clippy lints and adding an inline justification
on the same attribute line. Keep the existing #[cfg(test)] behavior unchanged.

Source: Coding guidelines


62-73: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Unwrap authorization at the guard to make the invariant explicit.

The code checks authorization.is_none() and then still forwards an Option<&str> to visible_person_ids. A let ... else binding removes the second, now-impossible None case from the call site.

♻️ Proposed refactor
-    if authorization.is_none() {
+    let Some(authorization) = authorization else {
         tracing::error!(caller = %caller, "no Authorization header to forward to identity");
         return Err(unavailable());
-    }
+    };
 
     let visible = identity
-        .visible_person_ids(person_ids, authorization)
+        .visible_person_ids(person_ids, Some(authorization))
         .await

This assumes visible_person_ids keeps its Option<&str> parameter. Confirm the signature in src/backend/services/analytics/src/infra/identity/mod.rs before applying.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/analytics/src/domain/person_visibility.rs` around lines
62 - 73, In the authorization guard surrounding visible_person_ids, bind the
present authorization value with a let-else pattern that returns unavailable()
when absent, then pass the unwrapped value to visible_person_ids while
preserving its Option<&str> parameter contract.

75-89: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Count the unmatched ids instead of collecting them.

Both consumers only read is_empty() and len(). The Vec<Uuid> allocation is unused. MAX_PERSON_IDS is 1000, so the cost is small, but the count expresses the intent directly.

♻️ Proposed refactor
-    let unmatched = unmatched_ids(&visible, person_ids);
-    if unmatched.is_empty() {
+    let unmatched = unmatched_count(&visible, person_ids);
+    if unmatched == 0 {
         return Ok(());
     }
 
-    Err(denied(caller, unmatched.len()))
+    Err(denied(caller, unmatched))
 }
 
-fn unmatched_ids(visible: &HashSet<Uuid>, person_ids: &[Uuid]) -> Vec<Uuid> {
+fn unmatched_count(visible: &HashSet<Uuid>, person_ids: &[Uuid]) -> usize {
     person_ids
         .iter()
         .filter(|person_id| !visible.contains(*person_id))
-        .copied()
-        .collect()
+        .count()
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/analytics/src/domain/person_visibility.rs` around lines
75 - 89, Update unmatched_ids to return a count rather than allocating and
collecting a Vec<Uuid>, using the existing visibility membership predicate to
count person_ids not present in visible. Adjust the caller to compare the count
directly with zero and pass it to denied, removing the is_empty and len calls
while preserving current behavior.
src/backend/services/analytics/src/domain/metric_results/compiler.rs (1)

102-103: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the person-id binding pair into one helper.

The two-line pattern

params.extend(req.person_ids.iter().map(Uuid::to_string));
let person_id_params = placeholders(req.person_ids.len());

repeats in compile_period_batch_query, compile_timeseries_query, compile_group_ranking_query, compile_capped_timeseries_query, compile_breakdown_query, compile_histogram_query, and compile_peer_batch_query. Correctness depends on the two lines staying adjacent and in order; a helper makes that structural instead of conventional.

♻️ Proposed helper
fn bind_person_ids(params: &mut Vec<String>, person_ids: &[Uuid]) -> String {
    params.extend(person_ids.iter().map(Uuid::to_string));
    placeholders(person_ids.len())
}

Then each call site becomes:

-    params.extend(req.person_ids.iter().map(Uuid::to_string));
-    let person_id_params = placeholders(req.person_ids.len());
+    let person_id_params = bind_person_ids(&mut params, &req.person_ids);

As per coding guidelines: "Extract repetition into named helpers, and centralize error construction in one helper per failure kind."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/analytics/src/domain/metric_results/compiler.rs` around
lines 102 - 103, Extract the repeated person-ID binding logic into a
bind_person_ids helper that extends params and returns the matching
placeholders. Replace the adjacent binding and placeholder statements in
compile_period_batch_query, compile_timeseries_query,
compile_group_ranking_query, compile_capped_timeseries_query,
compile_breakdown_query, compile_histogram_query, and compile_peer_batch_query
with calls to this helper, preserving their existing query behavior.

Source: Coding guidelines

src/backend/services/analytics/src/domain/metric_results/validation.rs (1)

510-521: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Pin the non-person contract with a test instead of prose.

The "DELIBERATE CONTRACT" note claims that ids are parsed as person UUIDs for every entity type, and that a non-person type is a fail-closed 500. The second half is pinned by entity_type_without_an_authorization_rule_is_not_a_denial in src/backend/services/analytics/src/domain/person_visibility.rs. The first half is asserted only in this comment. A test whose name states the rule keeps it true after future edits, and lets the comment shrink.

♻️ Proposed test
#[test]
fn every_entity_type_parses_ids_as_person_uuids() {
    // Shape does not vary by entity type; a first non-person type must add
    // its own id shape here and its own gate rule.
    let req = shape_request(
        vec!["a@x.io"],
        "2026-01-01",
        "2026-01-31",
        vec!["ai.x"],
    );
    assert!(
        validate_request_shape(&req).is_err(),
        "should reject: non-UUID ids for any entity type"
    );
}

As per coding guidelines: "For non-obvious semantics, add a test whose name states the rule rather than adding a comment."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/analytics/src/domain/metric_results/validation.rs`
around lines 510 - 521, Replace the prose-only contract in parse_person_ids with
a test named every_entity_type_parses_ids_as_person_uuids, using the existing
shape_request and validate_request_shape helpers to submit a non-UUID id for an
entity type and assert validation fails. Keep the test focused on proving all
entity types currently use person UUID parsing, and shrink the surrounding
comment accordingly.

Source: Coding guidelines

docs/components/backend/analytics/openapi.json (1)

3896-3903: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Declare the entity.ids constraints in dto.rs.

MetricResultsEntity.ids and MetricResultsEntityDto.ids still use Vec<String> in src/backend/services/analytics/src/domain/metric_results/dto.rs, so openapi.json emits items: {type: string}. The validator parses each id as a non-nil Uuid and rejects non-UUID values with 400; make the generated schema machine-readable by adding format = "uuid" to both annotations and a cap matching MAX_PERSON_IDS.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/components/backend/analytics/openapi.json` around lines 3896 - 3903, In
src/backend/services/analytics/src/domain/metric_results/dto.rs, add schema
format and length constraints to the ids field in both MetricResultsEntity and
MetricResultsEntityDto. Update the Vec<String> annotations for the ids field to
declare format="uuid" for each item and add a max_items constraint matching the
MAX_PERSON_IDS constant, so the generated OpenAPI schema accurately reflects the
validator's requirement that each id is a UUID and the total count is bounded.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/components/backend/analytics/DESIGN.md`:
- Around line 136-140: The endpoints POST /v1/metrics/{id}/query and POST
/v1/metrics/queries allow caller-supplied person_id and org_unit_id filter
values without visibility validation, enabling users to query data outside their
permitted org scope. Before executing queries that include these filter fields
in these endpoints, validate the requested person_id and org_unit_id values
against the caller's AccessScope using the same authorization pattern as POST
/v1/metric-results (resolving the caller from the gateway JWT and batch-checking
visibility), rejecting the entire request with 403 if any requested id falls
outside the permitted set rather than returning a partial response.

In `@docs/components/backend/identity-resolution/openapi.json`:
- Around line 126-140: Update the VisiblePersonsCommandModel.person_ids schema
to declare the server-enforced array bounds: set maxItems to 1000 and minItems
to 1, while preserving its existing UUID string item definition and required
property.

In `@docs/domain/metrics/specs/DESIGN.md`:
- Around line 450-453: Update the entity-id contract in DESIGN.md to state that
Uuid::nil() is rejected as a client error, alongside the existing UUID parsing
and canonicalization rules. Replace the stale Authorization section with the
authorize_entity_ids behavior: validate requested person UUIDs against the
caller’s visible set before any ClickHouse work, allow service principals to
bypass the check, and reject the entire request with 403 when any requested ID
is not visible.

In `@src/backend/services/analytics/src/domain/metric_results/validation.rs`:
- Around line 199-206: Check req.entity.ids.len() against MAX_PERSON_IDS before
calling parse_person_ids, returning the existing invalid("entity.ids", ...)
response when the submitted count exceeds the cap. Then parse the ids without
the later parsed-count check, so blank entries cannot bypass the cap and
unbounded UUID parsing is avoided.

In `@src/backend/services/identity-resolution/src/api/visible_persons.rs`:
- Around line 37-77: Move the wildcard/visibility branching out of
filter_visible_persons into a pure domain function that accepts is_wildcard,
visible: &HashSet<Uuid>, and requested: Vec<Uuid>, returning the appropriate
echoed or intersected IDs. Update the handler to perform only caller/request
extraction, validation, repository calls, the domain invocation, and response
mapping, using the domain function rather than filtering inline.

In `@src/ingestion/tests/e2e/identity/test_visible_persons.py`:
- Around line 48-51: Add the standard status-code assertion to
test_a_person_in_another_tenant_is_never_visible by storing _check(api,
[seed.EVE]) in a response variable, asserting response.status_code == 200 with
the file’s established diagnostic message pattern, then inspecting
response.json()["visible"] as before.

In `@src/ingestion/tests/e2e/metrics/test_fixtures.py`:
- Around line 65-103: The _seed_identity_persons function must seed a
worker-scoped identity database rather than the shared literal identity
database. Use worker_ctx to derive the database name and apply it consistently
to ensure_database, CREATE/TRUNCATE, and INSERT operations, while preserving the
existing empty-email early return and person-resolution data.

---

Outside diff comments:
In `@src/ingestion/gold/schema.yml`:
- Around line 610-617: Update the adjacent model description for the table’s
uniqueness constraint to remove the claim that email-grain uniqueness is relied
on by the analytics service peer view. State that peer fan-out protection is
enforced by compile_peer_batch_query collapsing cohort CTEs to person grain with
GROUP BY person_id and HAVING uniqExact(cohort_id) = 1, while preserving the
remaining schema semantics.

---

Nitpick comments:
In `@docs/components/backend/analytics/openapi.json`:
- Around line 3896-3903: In
src/backend/services/analytics/src/domain/metric_results/dto.rs, add schema
format and length constraints to the ids field in both MetricResultsEntity and
MetricResultsEntityDto. Update the Vec<String> annotations for the ids field to
declare format="uuid" for each item and add a max_items constraint matching the
MAX_PERSON_IDS constant, so the generated OpenAPI schema accurately reflects the
validator's requirement that each id is a UUID and the total count is bounded.

In `@src/backend/services/analytics/src/api/metric_results.rs`:
- Around line 37-53: Refactor query_metric_results so it only extracts
request/context data, validates the request, invokes a domain-level
metric-results operation, maps its result into MetricResultsResponse, and
responds. Move query planning, concurrent execution, result assembly, and
related visibility-aware workflow into a domain service or free function,
preserving the existing authorization behavior and response contract while
keeping the handler near the extract → validate → domain call → map → respond
structure.
- Around line 42-44: Remove source comments that duplicate executable contracts
and replace each behavior description with named tests or helpers: in
src/backend/services/analytics/src/api/metric_results.rs:42-44 and :122-124, add
or retain named tests for visibility-before-query and canonical UUID responses;
in src/backend/services/analytics/src/api/http_live_tests.rs:109-118, :135-136,
and :211-212, rely on descriptive helper/test names and keep Authorization
forwarding without explanatory comments; rename the downstream-failure test at
:284-288. Document request and response contracts in OpenAPI for
src/backend/services/analytics/src/domain/metric_results/dto.rs:17-18 and
:117-118, retain a named UUID wire-contract test in builder.rs:56-58,
malformed-response coverage in infra/identity/mod.rs:43-55, and constructor
error behavior in infra/identity/mod.rs:150-153 through tests or API
documentation.

In `@src/backend/services/analytics/src/domain/metric_results/compiler.rs`:
- Around line 102-103: Extract the repeated person-ID binding logic into a
bind_person_ids helper that extends params and returns the matching
placeholders. Replace the adjacent binding and placeholder statements in
compile_period_batch_query, compile_timeseries_query,
compile_group_ranking_query, compile_capped_timeseries_query,
compile_breakdown_query, compile_histogram_query, and compile_peer_batch_query
with calls to this helper, preserving their existing query behavior.

In `@src/backend/services/analytics/src/domain/metric_results/validation.rs`:
- Around line 510-521: Replace the prose-only contract in parse_person_ids with
a test named every_entity_type_parses_ids_as_person_uuids, using the existing
shape_request and validate_request_shape helpers to submit a non-UUID id for an
entity type and assert validation fails. Keep the test focused on proving all
entity types currently use person UUID parsing, and shrink the surrounding
comment accordingly.

In `@src/backend/services/analytics/src/domain/person_visibility.rs`:
- Around line 115-116: Replace the #[allow] lint suppression associated with the
test module with #[expect], preserving both clippy lints and adding an inline
justification on the same attribute line. Keep the existing #[cfg(test)]
behavior unchanged.
- Around line 62-73: In the authorization guard surrounding visible_person_ids,
bind the present authorization value with a let-else pattern that returns
unavailable() when absent, then pass the unwrapped value to visible_person_ids
while preserving its Option<&str> parameter contract.
- Around line 75-89: Update unmatched_ids to return a count rather than
allocating and collecting a Vec<Uuid>, using the existing visibility membership
predicate to count person_ids not present in visible. Adjust the caller to
compare the count directly with zero and pass it to denied, removing the
is_empty and len calls while preserving current behavior.

In `@src/backend/services/analytics/src/infra/identity/mod.rs`:
- Around line 218-242: Define a custom error type using thiserror that
represents the possible failures for the identity client (transport, HTTP
status, response decoding), then update the visible_person_ids method to return
this typed error instead of anyhow::Result. Map the error sources at the
function boundaries: the .send().await? network call, the status check failure,
and the .json().await? JSON parsing, ensuring each maps to the appropriate error
variant so callers receive structured error information instead of opaque anyhow
errors.

In `@src/backend/services/identity-resolution/src/api/visible_persons.rs`:
- Line 117: Replace the bare #[allow(clippy::expect_used)] attribute with
#[expect(clippy::expect_used, reason = "...")] at the affected location,
providing a concise justification on the same line while preserving the existing
lint suppression.

In `@src/backend/services/identity-resolution/src/infra/db/subchart_repo.rs`:
- Around line 125-165: Replace Rustdoc comments with plain comments throughout
the identity-resolution service. In
src/backend/services/identity-resolution/src/infra/db/subchart_repo.rs lines
125-165, convert comments for has_wildcard_grant and visible_targets, including
Errors sections, from /// to //. In
src/backend/services/identity-resolution/src/api/visible_persons.rs lines 22-24,
convert the VisiblePersonsRequest comment to // and remove the email-draft phase
note. In
src/backend/services/identity-resolution/src/infra/db/visible_set_live_tests.rs
lines 1-4, convert the //! module header to // while preserving the INVARIANT
tags.

In `@src/ingestion/tests/e2e/api/test_metric_results.py`:
- Around line 45-52: Add a sibling test next to
test_metric_results_400_non_uuid_person_ids named
test_metric_results_400_nil_uuid_person_id, using the nil UUID in entity_ids and
the same metric request and API assertion, to verify it returns HTTP 400.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1b5d791c-0640-4b74-9a93-05e461453d47

📥 Commits

Reviewing files that changed from the base of the PR and between b908f1c and 319ded5.

📒 Files selected for processing (32)
  • docs/components/backend/analytics/DESIGN.md
  • docs/components/backend/analytics/openapi.json
  • docs/components/backend/identity-resolution/identity/README.md
  • docs/components/backend/identity-resolution/identity/specs/ADR/0015-self-scoped-visibility-read-without-admin.md
  • docs/components/backend/identity-resolution/identity/specs/DESIGN.md
  • docs/components/backend/identity-resolution/openapi.json
  • docs/domain/metrics/specs/DESIGN.md
  • src/backend/services/analytics/src/api/handlers.rs
  • src/backend/services/analytics/src/api/http_live_tests.rs
  • src/backend/services/analytics/src/api/metric_results.rs
  • src/backend/services/analytics/src/api/mod.rs
  • src/backend/services/analytics/src/domain/metric_definitions/validator.rs
  • src/backend/services/analytics/src/domain/metric_results/batch.rs
  • src/backend/services/analytics/src/domain/metric_results/builder.rs
  • src/backend/services/analytics/src/domain/metric_results/compiler.rs
  • src/backend/services/analytics/src/domain/metric_results/dto.rs
  • src/backend/services/analytics/src/domain/metric_results/validation.rs
  • src/backend/services/analytics/src/domain/mod.rs
  • src/backend/services/analytics/src/domain/person_visibility.rs
  • src/backend/services/analytics/src/gear.rs
  • src/backend/services/analytics/src/infra/identity/mod.rs
  • src/backend/services/identity-resolution/src/api/mod.rs
  • src/backend/services/identity-resolution/src/api/visible_persons.rs
  • src/backend/services/identity-resolution/src/infra/db/mod.rs
  • src/backend/services/identity-resolution/src/infra/db/subchart_repo.rs
  • src/backend/services/identity-resolution/src/infra/db/visible_set_live_tests.rs
  • src/ingestion/gold/schema.yml
  • src/ingestion/tests/e2e/api/test_metric_results.py
  • src/ingestion/tests/e2e/identity/test_visible_persons.py
  • src/ingestion/tests/e2e/lib/api_coverage.py
  • src/ingestion/tests/e2e/lib/identity_stub.py
  • src/ingestion/tests/e2e/metrics/test_fixtures.py

Comment on lines +136 to +140
Every ClickHouse query includes `insight_tenant_id`. User-supplied OData `$filter` values are ANDed with security filters, so users can narrow their view but never widen it.

**IDOR prevention**: When the frontend includes `org_unit_id eq 'uuid'` in `$filter`, the query engine validates that the requested org unit is within the user's AccessScope before executing the query. Accepting a client-supplied UUID without authorization check would allow any user within a tenant to query any team's data by guessing or enumerating UUIDs.
**IDOR prevention on person entities**: `POST /v1/metric-results` resolves the caller from the gateway JWT and asks identity, in one batch call, which of the requested person ids that caller may see (`POST /v1/visible-persons`). Any requested id outside the answer refuses the whole request with 403 — never a partial response, which would be indistinguishable from absent data. Reaching identity is required: an unconfigured or unreachable identity service is a server error, so an authorization backend that is down cannot read as "permitted".

**Planned — IDOR prevention on org units**: validating `org_unit_id` from `$filter` against the caller's AccessScope is *not* implemented. The `POST /v1/metrics/{id}/query` and `POST /v1/metrics/queries` paths interpolate client-supplied `person_id` / `org_unit_id` filter values with no visibility check, so they remain reachable for any id in the tenant.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Enforce visibility on the legacy metric query endpoints.

POST /v1/metrics/{id}/query and POST /v1/metrics/queries still allow caller-supplied person_id and org_unit_id filters without a visibility check. A tenant user can query data outside their permitted org scope through these routes.

Apply the same authorization rule before query execution, or reject these filter fields until the authorization gate exists.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/components/backend/analytics/DESIGN.md` around lines 136 - 140, The
endpoints POST /v1/metrics/{id}/query and POST /v1/metrics/queries allow
caller-supplied person_id and org_unit_id filter values without visibility
validation, enabling users to query data outside their permitted org scope.
Before executing queries that include these filter fields in these endpoints,
validate the requested person_id and org_unit_id values against the caller's
AccessScope using the same authorization pattern as POST /v1/metric-results
(resolving the caller from the gateway JWT and batch-checking visibility),
rejecting the entire request with 403 if any requested id falls outside the
permitted set rather than returning a partial response.

Comment thread docs/components/backend/identity-resolution/openapi.json
Comment thread docs/domain/metrics/specs/DESIGN.md Outdated
Comment on lines +37 to +77
pub async fn filter_visible_persons(
Extension(state): Extension<Arc<AppState>>,
Extension(ctx): Extension<SecurityContext>,
CanonicalJson(req): CanonicalJson<VisiblePersonsRequest>,
) -> Result<impl IntoResponse, CanonicalError> {
let caller = require_caller(&ctx)?;
let tenant = ctx.subject_tenant_id();

let requested = dedup_person_ids(&req.person_ids)?;

// INVARIANT: the wildcard short-circuit echoes the request, so the answer
// is a SUBSET OF THE INPUT — never a statement that each id exists in the
// tenant. The SQL branch cannot say more either (an id absent from the
// visible set and an id absent from the tenant are one answer), so callers
// must not read presence here as existence.
let visible = if subchart_repo::has_wildcard_grant(&state.db, tenant, caller)
.await
.map_err(read_err)?
{
requested
} else {
let visible = subchart_repo::visible_targets(
&state.db,
tenant,
caller,
&requested,
&state.config.org_chart_source_type,
)
.await
.map_err(read_err)?
.into_iter()
.collect::<HashSet<_>>();

requested
.into_iter()
.filter(|person_id| visible.contains(person_id))
.collect()
};

Ok(Json(VisiblePersonsResponse { visible }))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Move the visibility decision into a domain function instead of the API layer.

filter_visible_persons calls subchart_repo::has_wildcard_grant and subchart_repo::visible_targets directly, then branches and filters inline. This skips the domain layer entirely. The service's own design doc states the dependency direction must be api → domain → infra, and the general handler guideline requires an orchestration skeleton of extract → validate → domain call → map → respond, with business logic kept out of the API layer.

Extract the branching/filtering logic (the choice between "echo the request" and "intersect with the fetched visible set") into a pure domain function that takes already-fetched values (is_wildcard: bool, visible: &HashSet<Uuid>, requested: Vec<Uuid>) and returns the filtered Vec<Uuid>. This makes the decision testable without a database and keeps the handler as a thin orchestrator that only wires infra calls to that function.

As per coding guidelines, "Keep API handlers to an orchestration skeleton of extract → validate → domain call → map → respond, with approximately 30 lines maximum; keep business logic and serialization formats out of the API layer," and "Run CPU-heavy or blocking work ... through spawn_blocking" style separation is reinforced by DESIGN.md's own statement that "Dependency direction is strict: api → domain → infra."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/identity-resolution/src/api/visible_persons.rs` around
lines 37 - 77, Move the wildcard/visibility branching out of
filter_visible_persons into a pure domain function that accepts is_wildcard,
visible: &HashSet<Uuid>, and requested: Vec<Uuid>, returning the appropriate
echoed or intersected IDs. Update the handler to perform only caller/request
extraction, validation, repository calls, the domain invocation, and response
mapping, using the domain function rather than filtering inline.

Source: Coding guidelines

Comment thread src/ingestion/tests/e2e/identity/test_visible_persons.py
Comment on lines +65 to +103
def _seed_identity_persons(cfg: SessionConfig, emails: list[str]) -> None:
"""Replace identity.identity_persons with one email binding per persona.

Runs BEFORE the gold dbt build so resolve_person_id() attributes every
observation row; the table exists thanks to the on-run-start hook (and is
normally fed by the identity-resolution persons-sync — the rig plays that
role here).
"""
clickhouse.ensure_database(cfg, "identity")
clickhouse.execute(
cfg,
"""
CREATE TABLE IF NOT EXISTS identity.identity_persons (
id UInt64, value_type String,
insight_source_type String, insight_source_id UUID,
insight_tenant_id UUID,
value_id Nullable(String), value_full_text Nullable(String),
value Nullable(String), value_effective Nullable(String),
person_id UUID, author_person_id UUID, reason Nullable(String),
created_at DateTime64(6, 'UTC'), _synced_at DateTime64(3, 'UTC')
) ENGINE = MergeTree ORDER BY id
""",
)
clickhouse.execute(cfg, "TRUNCATE TABLE identity.identity_persons")
if not emails:
return
rows = ", ".join(
f"({index + 1}, 'email', 'e2e-rig', generateUUIDv4(), generateUUIDv4(), "
f"'{email}', '{email}', toUUID('{person_id_for(email)}'), "
f"toUUID('00000000-0000-0000-0000-000000000000'), now64(6), now64(3))"
for index, email in enumerate(emails)
)
clickhouse.execute(
cfg,
"INSERT INTO identity.identity_persons " # noqa: S608 — values derive from fixture emails
"(id, value_type, insight_source_type, insight_source_id, insight_tenant_id,"
" value_id, value_effective, person_id, author_person_id, created_at, _synced_at) "
"VALUES " + rows,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check whether resolve_person_id filters identity_persons by tenant,
# and whether other e2e ClickHouse databases are worker-scoped.
rg -n -C 5 'resolve_person_id' src/ingestion
rg -n -C 5 'insight_tenant_id' src/ingestion
rg -n -C 3 'ensure_database' src/ingestion/tests/e2e/lib
rg -n -C 3 'worker_ctx' src/ingestion/tests/e2e/lib/clickhouse.py src/ingestion/tests/e2e/lib/ch_seeder.py 2>/dev/null

Repository: constructorfabric/insight

Length of output: 50382


🏁 Script executed:

#!/bin/bash
set -u

echo "== target test context =="
sed -n '1,260p' src/ingestion/tests/e2e/metrics/test_fixtures.py | cat -n

echo
echo "== WorkerContext and ClickHouse database helpers =="
for f in $(fd -a 'clickhouse.py|ch_seeder.py|conftest.py' src/ingestion/tests/e2e/lib src/ingestion/tests/e2e/metrics | sort); do
  echo "--- $f"
  wc -l "$f"
done

echo
echo "== focused symbols =="
rg -n --glob '!src/ingestion/connectors/**' -C 4 'resolve_person_id|identity_persons|insight_tenant_id|insight_source_id|WorkerContext|worker_ctx|ensure_database|create_database|database=' src/ingestion/tests/e2e src/ingestion/*.py src/ingestion/dbt 2>/dev/null | head -n 400

Repository: constructorfabric/insight

Length of output: 49613


🏁 Script executed:

#!/bin/bash
set -u

echo "== src/ingestion/tests/e2e/lib/clickhouse.py =="
cat -n src/ingestion/tests/e2e/lib/clickhouse.py

echo
echo "== src/ingestion/tests/e2e/lib/dbt_runner.py =="
sed -n '1,180p' src/ingestion/tests/e2e/lib/dbt_runner.py | cat -n

echo
echo "== src/ingestion/tests/e2e/lib/config.py (config fields if present) =="
if [ -f src/ingestion/tests/e2e/lib/config.py ]; then sed -n '1,220p' src/ingestion/tests/e2e/lib/config.py | cat -n; fi

echo
echo "== identity_persons macro references =="
fd -e sql -e yml -e yaml -e py . src/ingestion/dbt macros | xargs rg -n -C 3 '{{ resolve_person_id|identity_persons|create_identity_persons|resolve_person_id'

Repository: constructorfabric/insight

Length of output: 18647


🏁 Script executed:

#!/bin/bash
set -u

echo "== candidate files =="
rg -l -n 'resolve_person_id|create_identity_persons|identity_persons' src/ingestion/dbt src/ingestion/tests/e2e || true

echo
echo "== references with correct regex =="
rg -n -C 5 'resolve_person_id|create_identity_persons|identity_persons' src/ingestion/dbt src/ingestion/tests/e2e || true

echo
echo "== all resolve/identity macros by filename =="
fd -e sql . src/ingestion/dbt/macros src/ingestion/dbt 2>/dev/null | xargs rg -n 'CREATE MACRO|resolve_person_id|identity_persons|sipHash128|insight_tenant_id' || true

Repository: constructorfabric/insight

Length of output: 35719


Tenant/source id choice is fine, but shared identity.identity_persons seeding is still not worker-scoped.

resolve_person_id() only filters on value_type / value_effective, so the generated insight_source_id and insight_tenant_id values do not break resolution. The remaining issue is that the seeding writes to the literal identity database without using worker_ctx, so parallel workers can truncate and insert into the same identity.identity_persons table.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ingestion/tests/e2e/metrics/test_fixtures.py` around lines 65 - 103, The
_seed_identity_persons function must seed a worker-scoped identity database
rather than the shared literal identity database. Use worker_ctx to derive the
database name and apply it consistently to ensure_database, CREATE/TRUNCATE, and
INSERT operations, while preserving the existing empty-email early return and
person-resolution data.

Source: Coding guidelines

The SPA routes and the metrics runtime key on the canonical person UUID since
the identity cutover, but `POST /v1/profiles` could only be asked by email or
by a source-native account id. Resolving a name from the viewer's org subtree
instead would have split one permission into two: the visibility gate admits
people reachable by explicit or wildcard GRANT as well, so a person could
have metrics and no name.

`value_type: "person_id"` closes that: no resolution step (the id already IS
the key), only validation — a non-UUID or the nil UUID is a 400, never a
silent empty resolution, and the source fields are rejected because a person
id is tenant-wide. Existence comes from `persons_repo::person_exists` (the
append-only log IS the person registry), so an unobserved id answers 404 like
an unknown email and the endpoint cannot be used to probe which ids exist.
Visibility is untouched, so a person's name and their metrics now answer to
ONE rule.

A person with no current email becomes reachable at all — structurally
impossible through the email key. The fixture tree grows such a persona to
pin it.

e2e: profile-by-person-id equals profile-by-email (two spellings of one
identity, not two views), the emailless persona resolves, visibility gates
both keys identically (404 for alice, 200 for bob via the seeded grant),
cross-tenant 404, unknown id 404, non-UUID / nil / source-fields 400.
Identity suite 141 passed; workspace tests, clippy pedantic and fmt clean.
Published spec (value_type enum) and the DESIGN FR row updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
@mozhaev-dev

Copy link
Copy Markdown
Contributor Author

Paired frontend PR: constructorfabric/insight-front#255 — merge the two together.

Merging either alone breaks the dashboards: this PR alone makes the API reject the emails the current SPA sends (400 on a non-UUID entity_id), and the frontend PR alone makes the SPA send UUIDs the current API resolves against nothing (empty metrics).

The frontend side also exercises the new value_type: "person_id" mode of POST /v1/profiles added here, and keeps legacy /ic/<email> URLs working by redirecting them through the email mode once.

…-id-cutover

Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>

# Conflicts:
#	src/backend/services/analytics/src/api/metric_results.rs
#	src/backend/services/analytics/src/domain/metric_results/validation.rs
#	src/backend/services/identity-resolution/src/api/visible_persons.rs
#	src/backend/services/identity-resolution/src/infra/db/persons_repo.rs
The gate compares against `normalize()` — sorted keys, so the diff of a
regenerated doc stays reviewable. This branch had committed a raw emitter
dump instead (the workflow's own hint suggests that redirect), which is
byte-identical in content but fails the drift check.

Written with `openapi_spec.py update`, which is the only form that passes.

Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>

@coderabbitai coderabbitai 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.

Actionable comments posted: 4

🧹 Nitpick comments (1)
src/backend/services/identity-resolution/src/infra/db/persons_repo.rs (1)

179-190: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use a typed repository error for person_exists.

persons_repo.rs returns anyhow::Result<bool>, but this is library/repository code. Return a thiserror repository error type and map the database error at this boundary.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/identity-resolution/src/infra/db/persons_repo.rs` around
lines 179 - 190, The person_exists method currently exposes anyhow::Result
instead of the repository’s typed error contract. Change its return type to the
existing thiserror repository error type and map the database failure from the
SeaORM one(db) call at this boundary, while preserving the boolean
found.is_some() result.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/backend/services/identity-resolution/src/api/handlers.rs`:
- Around line 283-323: Move the person existence lookup out of
resolve_person_id_mode and expose it through a typed domain operation that owns
the persons_repo::person_exists call and error mapping. Keep the API handler
limited to extracting and validating the request, invoking that domain
operation, and mapping its result to candidate IDs or an empty response;
preserve the existing tenant/person UUID semantics and API error behavior.
- Around line 279-282: Remove the added documentation comments from the service
modules: delete the private helper comment at
src/backend/services/identity-resolution/src/api/handlers.rs:279-282, remove or
relocate the resolution-outcome comment at
src/backend/services/identity-resolution/src/api/handlers.rs:187-189, move the
person-ID wire-contract text from
src/backend/services/identity-resolution/src/domain/profile.rs:15-17 into
OpenAPI, and delete the repository function comment at
src/backend/services/identity-resolution/src/infra/db/persons_repo.rs:169-178.
Preserve non-obvious behavior through behavior-named tests rather than service
documentation.

In `@src/ingestion/tests/e2e/identity/test_profiles.py`:
- Around line 29-32: Add concrete type annotations to _resolve_person_id,
including the project’s HTTP client type, UUID-or-string person_id input, and
its response type. Annotate the api and bob_api parameters in the affected test
functions with their concrete HTTP client type, ensuring every function
signature is typed and no bare Any is exposed.

In `@src/ingestion/tests/e2e/lib/identity_seed.py`:
- Around line 129-142: Define a concrete row type alias for the eight SQL
parameter elements and replace the unparameterized tuple annotations in
_emailless_observation_rows with that alias, including the rows collection and
function return type; preserve the existing row construction and values.

---

Nitpick comments:
In `@src/backend/services/identity-resolution/src/infra/db/persons_repo.rs`:
- Around line 179-190: The person_exists method currently exposes anyhow::Result
instead of the repository’s typed error contract. Change its return type to the
existing thiserror repository error type and map the database failure from the
SeaORM one(db) call at this boundary, while preserving the boolean
found.is_some() result.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2f058468-9bd2-428c-afaf-79e8520f519e

📥 Commits

Reviewing files that changed from the base of the PR and between 319ded5 and 71c5681.

📒 Files selected for processing (8)
  • docs/components/backend/analytics/openapi.json
  • docs/components/backend/identity-resolution/identity/specs/DESIGN.md
  • docs/components/backend/identity-resolution/openapi.json
  • src/backend/services/identity-resolution/src/api/handlers.rs
  • src/backend/services/identity-resolution/src/domain/profile.rs
  • src/backend/services/identity-resolution/src/infra/db/persons_repo.rs
  • src/ingestion/tests/e2e/identity/test_profiles.py
  • src/ingestion/tests/e2e/lib/identity_seed.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • docs/components/backend/analytics/openapi.json

Comment on lines +279 to +282
/// The `value_type='person_id'` mode: the canonical person needs no resolution
/// step, so this only validates the key and confirms the person exists in the
/// tenant. Visibility still applies downstream, so name resolution and metric
/// access answer to one rule.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove documentation comments from service modules.

These changes add or expand /// comments in service code. Keep API contract text in OpenAPI. Keep non-obvious behavior in behavior-named tests.

  • src/backend/services/identity-resolution/src/api/handlers.rs#L279-L282: remove the private helper documentation comment.
  • src/backend/services/identity-resolution/src/api/handlers.rs#L187-L189: remove or relocate the resolution-outcome documentation comment.
  • src/backend/services/identity-resolution/src/domain/profile.rs#L15-L17: move the person-ID wire-contract text to OpenAPI.
  • src/backend/services/identity-resolution/src/infra/db/persons_repo.rs#L169-L178: remove the repository function documentation comment.

As per coding guidelines, “Do not add documentation comments to binaries or services.”

📍 Affects 3 files
  • src/backend/services/identity-resolution/src/api/handlers.rs#L279-L282 (this comment)
  • src/backend/services/identity-resolution/src/api/handlers.rs#L187-L189
  • src/backend/services/identity-resolution/src/domain/profile.rs#L15-L17
  • src/backend/services/identity-resolution/src/infra/db/persons_repo.rs#L169-L178
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/identity-resolution/src/api/handlers.rs` around lines
279 - 282, Remove the added documentation comments from the service modules:
delete the private helper comment at
src/backend/services/identity-resolution/src/api/handlers.rs:279-282, remove or
relocate the resolution-outcome comment at
src/backend/services/identity-resolution/src/api/handlers.rs:187-189, move the
person-ID wire-contract text from
src/backend/services/identity-resolution/src/domain/profile.rs:15-17 into
OpenAPI, and delete the repository function comment at
src/backend/services/identity-resolution/src/infra/db/persons_repo.rs:169-178.
Preserve non-obvious behavior through behavior-named tests rather than service
documentation.

Source: Coding guidelines

Comment on lines +283 to +323
async fn resolve_person_id_mode(
state: &AppState,
tenant: Uuid,
req: &ResolveProfileRequest,
) -> Result<Vec<Uuid>, CanonicalError> {
// Cross-field shape matches email's: a person id is tenant-wide, and
// source scoping is what selects the 'id' mode instead.
if req.insight_source_type.is_some() || req.insight_source_id.is_some() {
return Err(ProfileError::invalid_argument()
.with_field_violation(
"insight_source_type",
"insight_source_type / insight_source_id must be null for value_type='person_id'",
"INVALID",
)
.create());
}

let person_id = Uuid::parse_str(req.value.trim())
.ok()
.filter(|person_id| !person_id.is_nil())
.ok_or_else(|| {
ProfileError::invalid_argument()
.with_field_violation(
"value",
"value must be a person UUID for value_type='person_id'",
"INVALID",
)
.create()
})?;

// A person exists iff the append-only log holds an observation for it; an
// unknown id yields no candidate, so the caller answers 404 — the same
// shape an unknown email takes, and no probe for which ids exist.
let exists = persons_repo::person_exists(&state.db, tenant, person_id)
.await
.map_err(|e| {
tracing::error!(error = %e, "resolve by person id failed");
CanonicalError::internal("profile resolution failed").create()
})?;
Ok(if exists { vec![person_id] } else { Vec::new() })
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Move person resolution behind the domain boundary.

resolve_person_id_mode calls persons_repo::person_exists from api. Move existence resolution into a typed domain operation. Keep the API code to request parsing, validation, domain invocation, and response mapping.

As per coding guidelines, API handlers must follow “extract → validate → domain call → map → respond.” The design also specifies api → domain → infra.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/services/identity-resolution/src/api/handlers.rs` around lines
283 - 323, Move the person existence lookup out of resolve_person_id_mode and
expose it through a typed domain operation that owns the
persons_repo::person_exists call and error mapping. Keep the API handler limited
to extracting and validating the request, invoking that domain operation, and
mapping its result to candidate IDs or an empty response; preserve the existing
tenant/person UUID semantics and API error behavior.

Source: Coding guidelines

Comment thread src/ingestion/tests/e2e/identity/test_profiles.py Outdated
Comment thread src/ingestion/tests/e2e/lib/identity_seed.py Outdated
…rsion view

The connectors-ddl gate re-dumps the snapshot and fails on any drift. The
stamp view arrived with its `;` on the SELECT line, while the dumper puts
every terminator on its own line, so the gate reports drift on an
otherwise-identical file.

This is main's own red check (the same run fails on main); the one-line
reformat is exactly what CI's regeneration produces, and it unblocks this
PR.

Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
@mozhaev-dev

Copy link
Copy Markdown
Contributor Author

Note on the red connectors-ddl snapshot + field parity check

Not caused by this PR, and it needs a decision from whoever owns that gate (it is not a required check, so it does not block the merge).

The gate was merged today and has never been green. On main it fails at the snapshot drift step, because the silver.contract_version view landed with its ; on the SELECT line while the dumper puts every terminator on its own line. This branch fixes that one line, which lets the job proceed to the next step — and that step, Field parity, was being skipped on main all along.

So the 13 failures now visible are pre-existing and were simply masked:

staging.m365__collab_document_activity_onedrive  visited_page_count  Nullable(Int64)  vs Nullable(Decimal(38,9))
staging.salesforce__crm_{accounts,activities,contacts,deals,users}  custom_fields  Nullable(String) vs String
staging.hubspot__crm_deals  close_date  Nullable(Date) vs Nullable(Date32)
staging.{github,gitlab}__pull_requests_commits  commit_order  UInt8 vs Int64
staging.{active_directory,ms_entra}__to_class_people  hire_date/termination_date  Nullable(Date) vs Nullable(DateTime)

None of these relations are touched here — this PR changes the metrics runtime and identity, not any connector staging model or class contract. Reverting the one-line snapshot fix would only re-mask them.

Everything else is green, including the required Run E2E suite and the metrics e2e job.

…te's edges

Review follow-ups on the person_id cutover.

The id cap was measured after parsing, which let a caller pad a request
past the documented bound (blanks are skipped and duplicates collapse, so
the parsed count is the smaller one) and pay for the parse of every entry
first. It now counts submitted ids, before parsing, with tests for both
padding shapes.

Two rules that were prose are now tests: an entity type with no
authorization rule fails closed, and a service principal bypasses the gate
for any type. The comment that stated them shrinks to the invariant the
tests cannot express.

Also: `let Some(authorization) = …` removes an impossible None from the
identity call, the unmatched ids are counted rather than collected (only
the count ever leaves the function), and both new test modules justify
their lint suppression with `#[expect(…, reason = …)]`.

Docs corrected where they no longer described the code: the metrics
Authorization section still called entity-level scoping deferred and
warehouse tenant isolation absent — this PR lands the first and the
compiler leads every read with `tenant_id = ?`; the cohort model
description still claimed the peer view relies on email-grain uniqueness,
which the person-grain collapse replaced. Added the nil-UUID rule, the
`person_ids` array bounds in the identity OpenAPI doc, an e2e case for the
nil UUID, status assertions on two visibility cases, a concrete row type
for the seed helpers, and a note on why the identity database in the metric
rig is deliberately not worker-scoped.

Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
@mozhaev-dev

mozhaev-dev commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai Went through all findings from both reviews. Fixed in 8197f563:

Fixed

  • Cap the ids before parsing (validation.rs) — correct, and the second half of your point was the sharper one: the cap measured the parsed count, so blanks or duplicates could pad a request past the documented bound. It now counts submitted ids before parsing, with a test for each padding shape.
  • Prose → test, non-person entity types — replaced the DELIBERATE CONTRACT comment with an_entity_type_with_no_authorization_rule_fails_closed plus a_service_principal_bypasses_the_gate_for_any_entity_type. Neither rule had a test before; that was the real gap.
  • #[expect] with a reason — both new test modules (person_visibility.rs, visible_persons.rs).
  • Unwrap authorization at the guard and count the unmatched ids instead of collecting them — both applied; the count is all that ever leaves the function anyway, since which ids were refused is not the caller's to learn.
  • Metrics DESIGN, Authorization section — was worse than stale: it said entity-level scoping is deferred and that warehouse tenant isolation is absent. This PR lands the first, and the compiler already leads every observation/cohort read with tenant_id = ?. Rewritten, plus the nil-UUID rule and the submitted-vs-parsed cap semantics.
  • Cohort model description (gold/schema.yml) — right, the email-grain uniqueness claim no longer explains anything; fan-out protection is the person-grain collapse in compile_peer_batch_query.
  • maxItems / minItems on person_ids — added, matching the enforced bound.
  • Nil-UUID e2e case and status assertions on the two visibility cases — added (identity suites: 23 passed; api metric-results: 9 passed).
  • Concrete row typelist[tuple] → a named ObservationRow alias, applied to the pre-existing helper too.

Not doing, with reasons

  • ///// in service crates. The guideline is real, but these two crates already carry ~3,200 doc-comment lines across ~130 files. Converting only my 34 new lines would make this code inconsistent with everything around it. That is a repo-wide sweep, not a PR-scoped fix.
  • Move the visibility decision / person resolution into the domain layer (3 sites). No behaviour change, and it would restructure handlers that follow the existing layout of both services. Not while this PR is the blocking half of a two-repo lockstep merge.
  • Strip the 11 explanatory comments. The behaviours listed are already pinned by named tests (metric_results_forbids_a_person_outside_the_callers_visible_set, get_person_forwards_authorization_then_5xx_on_dead_identity, the builder's wire-contract tests). What remains is why, which the guideline allows — and on a security gate it is the part a reviewer needs. I did remove the one comment that genuinely restated a rule better expressed as a test.
  • Worker-scope the identity database in the metric rig. The proposed fix would break resolution: resolve_person_id names identity.identity_persons literally, so a per-worker suffix would leave gold reading an unseeded table. The suite is serial by design and documented as not xdist-safe for deeper reasons (shared analytics process, shared dbt target/ dir). Added a comment at the seeding site so a future xdist attempt knows the macro has to become schema-aware first.
  • Typed errors for person_exists / the identity client. Both callers map every failure to one canonical error; a typed error would add a layer with no reader.
  • Visibility on POST /v1/metrics/{id}/query and POST /v1/metrics/queries. Correct and worth its own issue — but pre-existing, and already documented as a known gap in the analytics DESIGN ("Planned — IDOR prevention on org units"). Out of scope for a PR that must merge in lockstep with a frontend change.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@mozhaev-dev Thanks for the detailed update.

The fixes in 8197f563 address the reported validation, authorization, contract, and test coverage findings.

I accept the PR-scope decisions for the documentation-comment style, handler layout, explanatory security comments, fixture worker isolation, and error typing.

The visibility gap on POST /v1/metrics/{id}/query and POST /v1/metrics/queries remains a valid follow-up. Do you want me to create a GitHub issue for that work?

🐇 ✅


✏️ Learnings added
Learnt from: mozhaev-dev
URL: https://github.com/constructorfabric/insight/pull/2098

Timestamp: 2026-07-31T09:53:46.087Z
Learning: The metrics e2e fixture rig is intentionally serial and is not xdist-safe. `resolve_person_id` reads the fixed `identity.identity_persons` table name, so worker-scoped identity schemas would leave gold-model resolution unseeded. The suite also shares the analytics process and dbt `target/` directory. Any future xdist work must first make the resolution macro schema-aware.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

You are interacting with an AI system.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@mozhaev-dev Thanks for the detailed update.

The fixes address the reported issues. The validation bound, authorization tests, OpenAPI constraints, and e2e coverage now match the stated contract.

I accept the scope decisions for the deferred refactors and documentation-comment consistency work. The remaining visibility coverage gap is suitable for a separate issue. Would you like me to open that follow-up issue?

🐇 ✅


✏️ Learnings added
Learnt from: mozhaev-dev
URL: https://github.com/constructorfabric/insight/pull/2098

Timestamp: 2026-07-31T09:53:48.447Z
Learning: The metrics e2e fixture suite is intentionally serial and is not xdist-safe. Its identity resolution macro reads the literal `identity.identity_persons` table, so worker-scoped identity schemas would leave gold models unseeded until the macro becomes schema-aware. The suite also shares the analytics process and dbt `target/` directory.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

You are interacting with an AI system.

`GET /v1/persons/{email}` was the last person-keyed route still speaking
email. After the cutover a person id read off a metric result could not be
handed to it: the path value went to identity as `value_type: "email"` and
came back 404. Two contradicting contracts were published side by side on
one service.

The route is now `GET /v1/persons/{person_id}` and the identity client
sends `value_type: "person_id"`, so one key spans the metrics runtime, the
profile facade and the SPA routes.

A pre-cutover email path — or the nil UUID — is a 400 with a field
violation, not a 404 that would read as "no such person", and the parse
happens before the backend check so that client error does not depend on
whether identity is configured.

The e2e identity stub now resolves both key forms, so the facade's
200/404/400 outcomes are all observed; the endpoint-coverage ledger drops
400 from the boilerplate set because it is reachable now.

Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
@mozhaev-dev

Copy link
Copy Markdown
Contributor Author

Codex review — both points addressed

[P1] The analytics persons facade was still email-only — fixed in 9bf194f3.

Confirmed and it was the right call: GET /v1/persons/{email} sent the path value to identity as value_type: "email", so a person id read off a metric result came back 404. Two contradicting contracts on one service.

Took the first option — converted the facade rather than removing it:

  • Route is now GET /v1/persons/{person_id}, summary updated, OpenAPI regenerated.
  • IdentityClient::get_person takes a Uuid and sends {"value_type": "person_id", "value": "<uuid>"}.
  • A pre-cutover email path, or the nil UUID, is a 400 with a field violation — not a 404, which would read as "no such person". The parse runs before the is_configured check, so that client error does not depend on deployment state.
  • The e2e identity stub resolves both key forms, so all three outcomes are observed: test_persons.py now covers 200 / 404 / 400-on-email / 400-on-nil. The endpoint-coverage ledger drops 400 from the boilerplate set for this route, since it is reachable now.

Removal was the alternative — the new SPA already calls POST /v1/profiles directly, so the facade has no in-repo consumer. Kept it because deleting a published route silently breaks anything outside the repo, and converting is reversible. Worth revisiting once we can confirm nothing external calls it.

Evidence: api suite 96 passed, 14 xfailed (was 93 + 3 failing on the stale runner image — rebuild needed after the Rust change), endpoint-coverage gate green with only the pre-existing metric-definitions advisory, cargo clippy -D warnings clean, 526 analytics + 89 identity unit tests, OpenAPI drift check OK.

[P2] identity README — fixed. The POST /v1/profiles row now spells out all three modes: email (tenant-wide), id (source-native, needs both source fields), person_id (canonical UUID).

One related thing that is intentional, so nobody removes it: the identity openapi.json still lists the retired GET /v1/persons/{email}. The endpoint-coverage gate uses the committed spec as its universe and carries a SKIP entry for that op ("dropped in the Rust successor"). Pulling the path out of the spec would strand the skip.

@aleksdotbar aleksdotbar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Keep the canonical ID in entity_id

The metrics contract is explicitly polymorphic: entity_type + entity_id. After this cutover, the canonical person UUID is the entity identity, so it should replace the email value in entity_id.

The current shape instead keeps:

  • entity_id = source-native email
  • person_id = canonical UUID and actual runtime key

This creates two competing identity columns. entity_id no longer identifies the measured entity despite its name and documented role, while the entire runtime is specialized around person_id.

This causes concrete inconsistencies throughout the stack:

  • All five observation tables remain ordered by the obsolete email key:

    • src/ingestion/gold/ai_metric_observations.sql
    • src/ingestion/gold/collab_metric_observations.sql
    • src/ingestion/gold/git_metric_observations.sql
    • src/ingestion/gold/task_metric_observations.sql
    • src/ingestion/gold/wiki_metric_observations.sql

    Their ORDER BY is still (source_key, measure_key, entity_id, metric_date), while runtime queries filter and group by person_id. Because entity_id sits before metric_date, the old email key also prevents effective pruning by the canonical person and subsequent date component.

  • The gold models group by both entity_id and person_id. Therefore, multiple emails belonging to one canonical person remain separate rows in the serving tables and must be recombined by every runtime query. The gold observation grain remains source-email grain rather than canonical-entity grain.

  • metric_entity_cohorts_current also remains email-grained. The peer compiler now needs additional grouping and conflict-handling CTEs to collapse those rows back to person grain. This is compensating in the query layer for a non-canonical serving model.

  • OBSERVATION_COLUMNS and COHORT_COLUMNS now require both entity_id and person_id, making the duplicated identity shape part of the permanent runtime contract.

  • gold/schema.yml describes entity_id as a lowercased email while declaring person_id to be the actual runtime key. This contradicts the metrics design statement that entity_type + entity_id identifies the measured entity.

  • Validation, compiler rows, batching, builders, and query results were renamed from entity_id to person_id, only for the builders to translate person_id back into the wire field entity_id. That boundary translation is a symptom that the internal model no longer matches the public entity abstraction.

  • Comments in the gold models still state that entity_id is the runtime key, which is no longer true after this PR.

Source email may still be required for resolution coverage and provenance, but it should be represented explicitly as source_entity_id or retained in an upstream resolution model. identity_resolution_coverage can count unresolved source identifiers from that layer; it does not require the source email to remain the canonical gold entity_id.

The intended serving shape should be:

  • entity_type = 'person'
  • entity_id = <canonical person UUID>
  • optional source_entity_id = <source-native email> in the resolution/provenance layer

The runtime can then continue using entity_id throughout, the observation tables can be materialized at canonical entity grain, the sort key remains aligned with runtime access patterns, and future entity types fit the existing abstraction without another domain-wide rename.

Please implement the final canonical shape in this cutover rather than shipping person_id as a second permanent identity column and adapting every downstream layer around that duplication.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

🤖 connectors-ddl snapshot drift

The committed snapshot does not match what this branch actually produces. The regenerated snapshot is waiting in #2141 — review the DDL diff there and merge it into this branch; the gate re-runs on your merge.

Refreshed on every drifting gate run (the regen branch is force-pushed), so it reflects this branch as of the last completed run.

…table

bootstrap-db + dump-ddl over the models already on this branch: the five
evidence relations gained source_entity_id when resolution moved to build
time, and metric_entity_cohorts_current became a MergeTree table — the
committed dump still described the pre-resolution shapes.

Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
The same class of red check as last time — cargo fmt --check caught an
unsorted test import, and the pinned ruff (pre-commit's 0.15.21) had four
import-order errors plus formatting drift in the e2e files this branch
touches. main's copies are clean, so all of it was ours.

Signed-off-by: Sergei Mozhaev <mozhaev.dev@gmail.com>
@mozhaev-dev
mozhaev-dev requested a review from aleksdotbar August 3, 2026 09:58
@mozhaev-dev
mozhaev-dev enabled auto-merge August 3, 2026 10:04
@mozhaev-dev
mozhaev-dev added this pull request to the merge queue Aug 3, 2026
Merged via the queue into main with commit 1c53b72 Aug 3, 2026
51 checks passed
@mozhaev-dev
mozhaev-dev deleted the feat/metrics-person-id-cutover branch August 3, 2026 11:08
ktursunov pushed a commit that referenced this pull request Aug 4, 2026
…uses

Rebase brings #2098, which changes what every person-keyed route TAKES: the
canonical person UUID, not an email. That is not an addition this suite could
ignore — it inverts assertions already here.

  GET  /v1/persons/{email}   becomes  /v1/persons/{person_id}
  POST /v1/metric-results    entity.ids are person UUIDs
  POST /v1/metric-drilldown  entity.id is a person UUID
  POST /v1/profiles          gains value_type="person_id"

`test_metric_results_403_by_uuid` is the sharpest case and it is gone. It
asserted that asking by canonical UUID was REFUSED — true when the endpoint
keyed on email, because a UUID matched nobody in the caller's visible set. The
UUID is the right key now, so the same request is a 200 and the test was
asserting the old world. Replaced by the refusal that still exists: a person
genuinely outside the caller's scope, which reaches the same gate for a reason
that will survive the next cutover.

New coverage where the cutover created a contract rather than moved one — an
email and the nil UUID are both loud 400s on all three routes. The email case
is the one that earns its place: it is what these routes took last week, so an
unmigrated caller sends one in earnest, and the old failure mode was a 200 with
`value: null` that no consumer could tell from a person with no activity. That
ambiguity is what the cutover removed; this is what keeps it removed.

`/v1/persons/{id}` also joins the non-UUID matrix, which it could not before —
it binds Path<Uuid> now. And `/v1/profiles` is asserted to answer the SAME
profile through both keys rather than merely 200 through each: a person is one
record reachable two ways, not two records that agree today.

Generated models regenerated; SOME_EMAIL stays for identity's
`/internal/persons/by-email`, a lookup BY email that the cutover left alone.

272 collected becomes 283.

Signed-off-by: Konstantin Tursunov <Konstantin.Tursunov@constructor.tech>
ktursunov pushed a commit that referenced this pull request Aug 4, 2026
… it reads

The stand came up green and empty. dbt reported PASS=18 and built every gold
model; all four observation tables held nothing, and the readiness gate caught
it — "not rebuilt since …", which is what an EMPTY table looks like through
`system.parts`, because a table with no rows has no parts.

The cause is #2098's other half. Gold observation models now LEFT JOIN
`dbt/macros/resolve_person_id.sql` over `identity.identity_persons` and DROP
every row that does not resolve (`resolved_only()`), because `entity_id` in
gold IS the canonical person id. That log is filled by the identity service's
persons-sync in a deployment. The stand runs no sync, so nothing filled it, so
nothing resolved, so every observation was discarded — silently, with a green
dbt run either side of it.

Seeded here, for the same reason this seed stands in for the connectors
upstream: the stand has no orchestrator to run the real thing. Whole roster,
not just the measured people — the admin operator has no activity but still has
to RESOLVE, or a request naming them reads as an unknown person rather than a
person with nothing.

Worth recording what this says about the readiness gate: it was added to stop a
stand with a silent generator gap being reported ready, and this is precisely
that failure arriving from a direction nobody anticipated — an upstream join
condition rather than a broken generator. Without it the suite would have run
against empty gold and failed as ~40 unrelated assertions.

Signed-off-by: Konstantin Tursunov <Konstantin.Tursunov@constructor.tech>
ktursunov pushed a commit that referenced this pull request Aug 4, 2026
… caught up

Two more consequences of #2098, one a test fix and one a finding.

**`POST /v1/visible-persons` moved to person ids.** `VisiblePersonsRequest` is
`person_ids: Vec<Uuid>` now and the response is `Vec<Uuid>`, so the suite was
sending the pre-cutover shape and getting a 400. Updated, along with the schema.

The tenant half of that route also got sharper and the test now says so: a
wildcard grant used to ECHO the request back, so a wildcard holder in tenant A
could have tenant B's ids confirmed as visible. It is intersected with the
tenant's persons log now — which is exactly what the other-tenant case here
asserts, so the assertion gained meaning without changing shape.

**The pinned frontend predates the cutover, and the stand caught it.**
`src/frontend/helm/Chart.yaml` pins build 2026.07.31; #2098 landed 2026-08-03.
That build still calls person-keyed routes with emails, so the post-cutover
backend refuses them: the failed run logged 140 `POST /v1/metric-results` 400s
with a HeadlessChrome user-agent. The browser is doing it, not the suite.

Marked xfail(strict) rather than softened, because nothing here is wrong. This
is the cross-repo integration break a deployed stand exists to find and that
neither repo's own tests can see — insight-front tests against its own
expectations, and the backend tests its own handlers. Strict so the marker
cannot outlive the skew: the first frontend release that speaks person ids
makes these XPASS and fails the run, which is the signal to delete it.

I could not check whether such a release already exists — listing ghcr tags
needs a read:packages scope this token lacks. If insight-front has shipped
post-cutover, the fix is a chart bump instead of this marker.

Signed-off-by: Konstantin Tursunov <Konstantin.Tursunov@constructor.tech>
ktursunov pushed a commit that referenced this pull request Aug 4, 2026
…son_id}

The BLOCKED table still keyed the person lookup by `{email}`, which is the
route the identity cutover (#2098) renamed. The gate reported it as stale on
its first run afterwards and was ignored; this is that advisory acted on.

It is not cosmetic. With the key naming a route the spec no longer has, the
403 and 409 that `.standard_errors` stamps on `GET /v1/persons/{person_id}`
were counted as coverable and then reported as gaps — two entries in the
uncovered-code list that no test could ever have closed, and two codes
inflating the denominator against the suite.

Neither is reachable. `handlers::get_person` answers 200, 400 for an id that
is not a non-nil UUID, and 404 when identity resolves nobody; every other
outcome, including a refusal from identity, goes through an error arm that
maps it to `internal`. So a 403 upstream leaves analytics as a 500, and there
is no conflict path at all.

Signed-off-by: Konstantin Tursunov <Konstantin.Tursunov@constructor.tech>
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