Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
243 changes: 243 additions & 0 deletions docs/domain/identity-resolution/specs/RESPEC-DRAFT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
# Identity Resolution — Respec Draft

> Working draft. Theses only, collected from review-session statements (Roman).
> Not yet reconciled with PRD.md / DESIGN.md / ADRs.

## 1. `persons` — the canonical table

- Consolidated table of all data about people — the users of the system.
- Conceptually a wide table with an unbounded set of columns: every field of every profile of each physical person.
- In the database this is expressed as narrow rows: one row = one field value.
- Row shape:
- `person_id` — canonical person.
- `source_type` — `gitlab` / `youtrack` / `bamboohr` / etc.
- `kind` — field kind: `id` / `email` / `first_name` / ... — a string with a fixed vocabulary.
- `value` — the value. Physically several value columns exist to organize indexes per value type (open question: not convinced this is a good idea).
- `date` — when this value was assigned to this field.
- The narrow-row shape allows reading the state of all fields as of any moment in time.
Comment on lines +16 to +17

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 | 🟠 Major | 🏗️ Heavy lift

Make canonical selection explicitly as-of and tombstone-aware.

The read rule selects the latest fixed/follow record but does not apply an as-of cutoff or exclude decisions closed by later tombstones. The attribution note’s “latest record is not a tombstone” shortcut is also insufficient without defining the logical assignment interval and deterministic ordering for equal timestamps.

Also applies to: 57-61, 194-195

🧰 Tools
🪛 LanguageTool

[style] ~17-~17: This phrase is redundant. Consider writing “moment” or “time”.
Context: ...ading the state of all fields as of any moment in time. ### 1.1 Semantics: decision log, not ...

(MOMENT_IN_TIME)

🤖 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/domain/identity-resolution/specs/RESPEC-DRAFT.md` around lines 16 - 17,
Update the canonical-selection rules in the narrow-row shape and the
corresponding sections around attribution and decision resolution to define an
explicit as-of timestamp cutoff, exclude assignments superseded by later
tombstones, and model each assignment’s logical validity interval. Specify
deterministic tie-breaking when records share the same timestamp, and replace
the “latest record is not a tombstone” shortcut with this interval- and
tombstone-aware rule.


### 1.1 Semantics: decision log, not raw history

- `persons` is the history of **decisions** to assign field values to a canonical profile, made on whatever criteria.
- There are no unreviewed records. If a record exists, it is truth.

Comment on lines +21 to +23

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 | 🟠 Major | 🏗️ Heavy lift

Separate decision records from materialized records in the model.

persons is described as a decision log where every row is truth, but §1.4 also inserts status-less source-materialized rows that are explicitly mechanical consequences. Define the two row classes and clarify which one participates in canonical selection, history, and audit; otherwise consumers cannot interpret persons consistently.

Also applies to: 42-49

🤖 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/domain/identity-resolution/specs/RESPEC-DRAFT.md` around lines 21 - 23,
Revise the persons model definition and the related §1.4 materialization
description to distinguish decision records from mechanically materialized
source records. Define each row class and specify which participates in
canonical selection, history, and audit, while preserving the intended semantics
that only reviewed decisions represent truth.

### 1.2 Assignment and reassignment

- Assign a profile: one row, e.g. `{person_id: p1, source_type: gitlab, kind: id, value: gl1, date: D}`.
- Reassign (profile turns out to belong to someone else): two rows —
- tombstone at the old person: `{person_id: p1, ..., value: null, date: D2}`;
- assignment at the new person: `{person_id: p2, ..., value: gl1, date: D2}`.
Comment on lines +27 to +29

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 | 🟠 Major | ⚡ Quick win

Resolve the contradictory tombstone representation.

Reassignment writes a tombstone with value: null, while the status definition requires the tombstone to carry the closed value and never be null. Choose one representation and define how the closed assignment is identified; otherwise writers and historical queries will implement incompatible semantics.

Also applies to: 44-49

🤖 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/domain/identity-resolution/specs/RESPEC-DRAFT.md` around lines 27 - 29,
Resolve the contradiction in the reassignment description and the related status
definition: choose whether tombstones store the closed value or null, then
consistently update both sections. Explicitly define how historical queries
identify the closed assignment, and ensure the reassignment writer and status
semantics use the same representation.


### 1.3 `value` holds literals only — no references

- Decision: a `persons` record's value columns always hold a materialized literal. References, formulas, or selectors inside `value` are rejected.
- Why:
- The ClickHouse replica is queried with plain SQL joins; a reference would require recursive resolution at read time, inexpressible there.
- As-of-time reconstruction (§7) reads values directly from records; a reference resolves against a moving target and breaks history.
- One column with two interpretation modes (literal vs pointer) is a standing source of bugs.
- Freshness is achieved differently: source changes materialize new records (follow mechanism), not by dereferencing at read time.

### 1.4 Field statuses — decisions vs materializations

- Profile field values are copied into `persons` automatically for linked profiles; linking a profile does not by itself make its fields canonical.
- `status` is written **only on decision records**; source-materialized records carry no status. One column separates the two layers: rows with a status = the decision journal, rows without = mechanical consequences.
- Status enum:
- `follow` — decision: the canonical value of this kind follows this source stream.
- `fixed` — decision: value pinned by the user; newer source values still land in `persons` but are not used.
- `tombstone` — decision: closes a prior record; carries the same `value` it closes (never `value: null`), so it is unambiguous which assignment ended. Also the form for cancelling a profile↔person link.
- While a follow decision is active and the profile is linked, every source change of that field materializes a new (status-less) record automatically.
- Tenant settings define default sources per kind, but are consumed by resolvers when generating proposals (e.g. bulk "display_name ← bamboohr for everyone", confidence 1) — never at read time. Changing a tenant default produces new proposals → new records; history and as-of-time reads are unaffected.
- UI: `fixed` fields show drift — the pinned value vs the latest recorded value from the source.
- Kind registry: each kind is declared scalar (`display_name`, `job_title`, ...) or multi-value (`email`, ...). Multiple active follow streams are legitimate only for multi-value kinds.

### 1.5 Canonical value selection

- Designating the source: a `follow` decision record is inserted **into the chosen source's stream** — its own `source_type` / `source_id` name the stream; no references needed.
- Its `value` is the literal the accepting user saw at decision time (audit trail; §1.3 holds).
- Read rule for `(person, kind)`:
- Find the latest record with `status ∈ {fixed, follow}` — the acting decision.
- `fixed` → its own `value` is the canonical value.
- `follow` → the canonical value is the latest record of the same stream (`source_type + source_id`), while the profile link is active.
- No decision → the field is unset. Unset is honest: reads return empty; a sanitary resolver proposes choosing a source.
- Switching source = one new `follow` decision in the other stream; it is newer, so it wins. No tombstone of the old decision needed.
- Example — two sources both supply `display_name`:

| source_type | kind | value | status | author | date |
|---|---|---|---|---|---|
| bamboohr | display_name | Anna Ivanova | — | follower | 04-01 |
| gitlab | display_name | anna.i | — | follower | 04-02 |
| bamboohr | display_name | Anna Ivanova | follow | operator | 04-03 |
| bamboohr | display_name | Anna Ivanova-Petrova | — | follower | 07-15 |

- Canonical today: the 04-03 decision points at the bamboohr stream → its latest record → `Anna Ivanova-Petrova`.
- Protection against duplicate follow on a scalar kind:

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

Fix the list indentation.

markdownlint reports inconsistent indentation here; adjust the surrounding list nesting so this item renders at the intended level.

🧰 Tools
🪛 markdownlint-cli2 (0.23.0)

[warning] 73-73: Inconsistent indentation for list items at the same level
Expected: 2; Actual: 0

(MD005, list-indent)

🤖 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/domain/identity-resolution/specs/RESPEC-DRAFT.md` at line 73, Fix the
list indentation surrounding “Protection against duplicate follow on a scalar
kind” in the RESPEC draft so its nesting matches adjacent list items and
markdownlint passes.

Source: Linters/SAST tools

- The identity service is the only writer; the invariant is checked inside a MariaDB transaction; OCC (§3.3) serializes operators.
- Read-time tie-breaker if the invariant is ever violated: the latest decision record wins; reads never fail.
- A resolver surfaces violations as proposals to fix.

## 2. Resolution principle

- Email is mutable; it must not be relied on as an identity key.
- General mechanism: given an identifier in system A, obtain the identifier of the same person's profile in system B (via `person_id`) **as of the required moment in time**; then search system B's entities by that identifier.
- Special cases:
- System B's entities carry no profile identifier, only an email (e.g. commits) — resolve to the person's emails instead.
- No source-system identifier at all, only an email — closer to search-by-criteria than to resolution.
- Every resolution task reduces to the same algorithm, e.g. "all commits for a BambooHR profile":
- find the `person_id` for the profile;
- find all `kind = email` rows of that `person_id`;
- find all commits with those emails.

## 3. Resolver pool

- The system provides a pool of resolvers.
- Each resolver draws conclusions from `identity_inputs` using its own algorithm.
- A resolver's output is a set of proposals shown to the user.
- Proposals are notifications of various types.
- Resolvers are most likely stateless and function-like: invoked on every proposals request, returning their proposals within that same request.
- Current data volumes allow waiting for a synchronous run.
- Over time resolver invocation may become asynchronous.
- Resolvers never write to `persons` directly. Decisions are made only through the user: a proposal becomes truth (a `persons` row) only after the user accepts it. There is no auto-link.

### 3.2 `GET /proposals`

- The identity-resolution service exposes `GET /proposals`: calls every resolver, collects an array of proposals from each.
- Proposal shape (approximate):
- `id` — uuid.
- `unique_key` — stable unique key, built so that identical proposals from different resolvers merge into one.
- `kind` — proposal type; determines how the proposal is rendered.
- `inputs` — array of `identity_inputs` objects the conclusion was based on.
- `persons_update` — array of objects to be inserted into `persons` on acceptance.
- `confidence` — 0..1.
- `confidence` semantics:
- Determines position in the proposals list (higher — closer to the top).
- `confidence = 1` — can be accepted all at once (bulk accept).
- Below 1 — accepted one by one.
- Any proposal can be edited before acceptance.
Comment on lines +104 to +115

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## File outline\n'
ast-grep outline docs/domain/identity-resolution/specs/RESPEC-DRAFT.md --view expanded || true

printf '\n## Relevant lines 80-140\n'
sed -n '80,140p' docs/domain/identity-resolution/specs/RESPEC-DRAFT.md | cat -n

printf '\n## Search proposal-related terms\n'
rg -n "proposal|persons_update|acceptance|bulk accept|edit(ed)? before acceptance|source-ownership|tenant|authorization|recompute" docs domain . -g '!**/node_modules/**' -g '!**/.git/**' || true

Repository: constructorfabric/insight

Length of output: 50382


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## Files mentioning RESPEC-DRAFT or proposals"
git ls-files | rg 'RESPEC-DRAFT\.md|proposal|identity-resolution' || true

echo
echo "## Search within docs for validation / recompute wording"
rg -n "recompute|server-side|authorization|tenant|source ownership|ownership|validate|validation|accept.*proposal|edit.*proposal|persons_update|bulk accept" docs -g '*.md' || true

Repository: constructorfabric/insight

Length of output: 50382


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## RESPEC-DRAFT around proposal editing"
sed -n '90,130p' docs/domain/identity-resolution/specs/RESPEC-DRAFT.md | cat -n

echo
echo "## Search within the identity-resolution domain for proposal acceptance/editing rules"
rg -n "proposal|persons_update|accept|edit before acceptance|bulk accept|source ownership|tenant|authorization|validate|recompute" docs/domain/identity-resolution -g '*.md' || true

Repository: constructorfabric/insight

Length of output: 29393


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '130,180p' docs/domain/identity-resolution/specs/RESPEC-DRAFT.md | cat -n

echo
rg -n "accept|proposal.*edit|persons_update|status|RLS|authorization|tenant" docs/domain/identity-resolution/specs/RESPEC-DRAFT.md docs/domain/identity-resolution/specs/PRD.md docs/domain/identity-resolution/specs/DESIGN.md || true

Repository: constructorfabric/insight

Length of output: 28166


Validate edited proposals server-side. persons_update should not be accepted verbatim from the client; on acceptance, re-check tenant, authorization, kind/status, and source ownership, or recompute the write from a server-held proposal record.

🤖 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/domain/identity-resolution/specs/RESPEC-DRAFT.md` around lines 104 -
115, Update the proposal acceptance flow to validate edited proposals
server-side instead of trusting client-provided persons_update. Before applying
persons_update, re-check tenant scope, authorization, proposal kind and status,
and source ownership; preferably recompute the write from a server-held proposal
record while preserving the existing acceptance behavior.

- Any proposal can be hidden: its `unique_key` is stored in a separate hidden list; the user can view hidden proposals again when needed.

### 3.3 Concurrency control on mutations

- Rationale: a mutation may be decided on a stale view of `persons` (e.g. two operators link/unlink the same profile concurrently); duplicate-row detection cannot catch this — staleness of knowledge must be detected, not sameness of data.
- State identifier = `max(persons.id)` (auto-increment) of the state the client read; exposed to clients as an opaque string with no guarantees about its content.
- Every `persons` mutation — proposal acceptance, manual link/unlink, edit — carries the state identifier received at read time (`GET /proposals`, profile read).
- On mismatch the server rejects with `409`; the client refetches and reconsiders on the fresh state. First writer wins.
- Network retries are deduplicated via an explicit `Idempotency-Key`, never by content comparison.
- Granularity starts global — any write invalidates every outstanding state identifier. May narrow to per-person later without changing the API contract.
Comment on lines +121 to +125

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 | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the relevant section with line numbers
sed -n '100,150p' docs/domain/identity-resolution/specs/RESPEC-DRAFT.md | cat -n

# Find other mentions of the state identifier / OCC behavior
rg -n "state identifier|max\\(persons\\.id\\)|Idempotency-Key|409|first writer wins|compare-and-swap|CAS|optimistic concurrency|transaction isolation|locking|atomic" docs/domain/identity-resolution/specs/RESPEC-DRAFT.md docs/domain/identity-resolution -S

Repository: constructorfabric/insight

Length of output: 12836


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the sections around idempotency / concurrency in the related design docs
sed -n '430,470p' docs/domain/identity-resolution/specs/PRD.md | cat -n
printf '\n---\n'
sed -n '1000,1045p' docs/domain/identity-resolution/specs/DESIGN.md | cat -n
printf '\n---\n'
sed -n '1388,1415p' docs/domain/identity-resolution/specs/DESIGN.md | cat -n

# Find any direct mention of persons mutations or state tokens outside the draft
rg -n "persons mutation|proposal acceptance|manual link|manual unlink|profile read|state identifier|max\\(persons\\.id\\)|first writer wins|409" docs/domain/identity-resolution/specs -S

Repository: constructorfabric/insight

Length of output: 7978


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for any explicit write protocol for persons or comparable versioning terms
rg -n "SELECT .*FOR UPDATE|FOR UPDATE|SERIALIZABLE|REPEATABLE READ|READ COMMITTED|version column|optimistic|compare-and-swap|CAS|updated_at|row version|etag|person.*version|persons.*version|persons.*FOR UPDATE|state token|conflict" docs/domain/identity-resolution/specs -S

# Inspect nearby sections that might define persons mutation behavior
rg -n "person(s)? mutation|link/unlink|edit|acceptance|profile read|GET /proposals|persons/" docs/domain/identity-resolution/specs/PRD.md docs/domain/identity-resolution/specs/DESIGN.md docs/domain/identity-resolution/specs/RESPEC-DRAFT.md -S

Repository: constructorfabric/insight

Length of output: 18677


Define the atomic OCC check-and-write protocol.

max(persons.id) is only safe if the read, validation, and write happen in one transaction or via a CAS-style update. Otherwise two writers can read the same token, both pass the check, and both commit despite “first writer wins.” Specify the transaction isolation, locking, or versioning mechanism used by persons mutations.

🤖 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/domain/identity-resolution/specs/RESPEC-DRAFT.md` around lines 121 -
125, Define the atomic OCC protocol for every persons mutation, including the
transaction boundary and mechanism that checks the client’s state identifier
while applying the write. Specify the required isolation, row/table locking, or
CAS/version predicate so concurrent writers cannot both succeed, and preserve
the documented 409 rejection and first-writer-wins behavior.


### 3.1 Known resolvers (non-exhaustive; more will follow)

- **New-source initial resolution** — detects that a new source appeared in the system; proposes forming persons based on exact email matches.
- **Similar-profiles search** — its algorithm is called *min-propagation* in the current specs.
- **Merge-author** — many git systems record which account merged the commits; assumptions can be made from that data as well.

## 4. Storage and replication

- `persons` lives in MariaDB; it is continuously replicated to ClickHouse (mechanism secondary — Airbyte or similar), so ClickHouse always holds a current copy.
- The ClickHouse copy is what gold-level queries join against directly.
Comment on lines +135 to +136

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 | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== target file context =="
nl -ba docs/domain/identity-resolution/specs/RESPEC-DRAFT.md | sed -n '115,155p'

echo
echo "== related mentions =="
rg -n "freshness|lag|read-after-write|backfill|fallback|Airbyte|ClickHouse|gold-level queries|continuously replicated|current copy" docs/domain/identity-resolution -S

Repository: constructorfabric/insight

Length of output: 229


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== target file context =="
awk 'NR>=115 && NR<=155 { printf "%d:%s\n", NR, $0 }' docs/domain/identity-resolution/specs/RESPEC-DRAFT.md

echo
echo "== related mentions =="
rg -n "freshness|lag|read-after-write|backfill|fallback|Airbyte|ClickHouse|gold-level queries|continuously replicated|current copy" docs/domain/identity-resolution -S

Repository: constructorfabric/insight

Length of output: 22735


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== repo files for identity-resolution docs =="
git ls-files 'docs/domain/identity-resolution/**' | sed -n '1,200p'

Repository: constructorfabric/insight

Length of output: 596


Replace “always holds a current copy” with a freshness contract. Continuous replication alone does not define max lag, read-after-write behavior, or the fallback/backfill path for attribution queries that need newly accepted identity decisions.

🤖 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/domain/identity-resolution/specs/RESPEC-DRAFT.md` around lines 135 -
136, Update the ClickHouse replication description in the identity-resolution
specification to replace the absolute “always holds a current copy” claim with
an explicit freshness contract. Define the permitted replication lag,
read-after-write behavior, and the fallback or backfill path for attribution
queries requiring newly accepted identity decisions, while retaining the direct
gold-level query join context.

- Tenant scoping is enforced via RLS; queries do not carry explicit tenant conditions.
Comment on lines +135 to +137

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="docs/domain/identity-resolution/specs/RESPEC-DRAFT.md"

echo "== File info =="
wc -l "$file"
echo

echo "== Relevant excerpts (lines 120-220) =="
nl -ba "$file" | sed -n '120,220p'
echo

echo "== Search for tenant/RLS/policy references in the spec =="
rg -n "RLS|tenant|tenant_id|policy|row-level|row level|ClickHouse|MariaDB|silver\.class_git_commits|persons" "$file"

Repository: constructorfabric/insight

Length of output: 319


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="docs/domain/identity-resolution/specs/RESPEC-DRAFT.md"

echo "== Excerpts with line numbers =="
awk 'NR>=120 && NR<=220 { printf "%4d  %s\n", NR, $0 }' "$file"
echo

echo "== Exact tenant/RLS-related mentions =="
grep -nE 'RLS|tenant|tenant_id|policy|row-level|row level|ClickHouse|MariaDB|silver\.class_git_commits|persons' "$file" || true

Repository: constructorfabric/insight

Length of output: 10672


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="docs/domain/identity-resolution/specs/RESPEC-DRAFT.md"

echo "== Excerpts with line numbers (220-243) =="
awk 'NR>=220 && NR<=243 { printf "%4d  %s\n", NR, $0 }' "$file"
echo

echo "== Search for any concrete RLS/policy definitions outside the main sections =="
grep -nE 'tenant_id|RLS|policy|row-level|row level|grants|filtering by tenant|execution path|class_git_commits|commits' "$file" || true

Repository: constructorfabric/insight

Length of output: 4335


Make tenant isolation explicit on every table touched by the query. §4 says RLS is enforced, but §7 still leaves RLS/tenant_id filtering unresolved. The example query reads from both persons and silver.class_git_commits; if only persons is covered, identical emails can still pull commit rows across tenants. Define and test tenant policies for every queried table and execution path, or add explicit tenant predicates where the policy is not guaranteed.

🤖 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/domain/identity-resolution/specs/RESPEC-DRAFT.md` around lines 135 -
137, The identity-resolution specification must make tenant isolation explicit
for every table and execution path used by gold-level queries, including persons
and silver.class_git_commits. Update the §4/§7 design and example query to
define and test RLS policies for each queried table, or add explicit tenant_id
predicates wherever RLS is not guaranteed; preserve isolation when identical
emails exist across tenants.


## 5. API and frontend

- The page must not extract its own email from the JWT and pass it to `/api/identity/v1/persons/`.
- Required: an endpoint with no arguments that returns the same response for the current user.
- Passing a `person_id` is allowed only via POST, only for impersonation, and only if the visibility mechanism permits it.
- The frontend decides whose metrics to request (team view) and calls the metric endpoint with a time interval and an array of `person_id`.
- The frontend sends `person_id` everywhere email is sent today.

## 6. Examples

### 6.1 BambooHR profile → all commits of that user

Follows the algorithm of §2 step by step. Tenant conditions are omitted — enforced via RLS (§4).

```sql
-- Given: a BambooHR profile with id 'E123'.
-- Find: all commits of that person.

WITH

-- Step 1. Which person owns this profile?
person AS (
SELECT person_id
FROM persons
WHERE source_type = 'bamboohr'
AND kind = 'id'
AND value = 'E123'
),

-- Step 2. All emails of that person — from all of their
-- profiles (bamboo, gitlab, jira, ...).
person_emails AS (
SELECT DISTINCT lower(value) AS email
FROM persons
WHERE person_id IN (SELECT person_id FROM person)
AND kind = 'email'
)

-- Step 3. Commits signed by any of those emails.
SELECT c.*
FROM silver.class_git_commits AS c
WHERE lower(c.author_email) IN (SELECT email FROM person_emails);
```

Sample `persons` rows the query walks over:

| person_id | source_type | kind | value | date |
|---|---|---|---|---|
| p1 | bamboohr | id | E123 | 04-01 |
| p1 | bamboohr | email | anna.ivanova@acme.com | 04-01 |
| p1 | gitlab | id | gl42 | 04-02 |
| p1 | gitlab | email | aivanova@gmail.com | 04-02 |

Deliberately simplified:

- Tombstones ignored — step 2 takes all emails ever assigned; the production form keeps only emails whose latest record is not a tombstone.
- Attribution is to the current owner; as-of-commit-time attribution turns step 3's `IN` into an interval join on `date`.
- `source_type` alone does not distinguish two instances of the same system; the real schema also carries `source_id`.

## 7. To be worked out

- RLS — filtering by `tenant_id` and by org chart.
- Cohorts:
- The user can choose different cohorts.
- The org chart supplies data into cohorts, but there are many sources.
- Data availability is still determined by the org chart.
- Top-level medians and percentiles are computed by the system; access restrictions must not affect that computation.
- Org chart and visibility extension for employees.
- Reports must account for changes in `persons` over time: if within the selected interval a person had a gitlab-profile link added and removed, commits from that profile count only when they fall into the intervals while the profile was linked.

## 8. Org chart

- Two tables:
- `org_chart_edges` — source of truth; an edge is a decision record: `(person, supervisor, org_chart_id, valid_from, valid_to)`.
- `org_chart_closure` — derived ancestor→descendant pairs; the pair's interval = intersection of the intervals of the path's edges. Rebuilt from edges; never a source of truth.
- One row per epoch of a relation. Row count grows with the number of *changes*, not with time.
- Visibility read at moment `T`: filter closure by `ancestor = viewer` and interval containing `T`. The same rows serve any date; in ClickHouse — a range dictionary probed per fact row.
Comment on lines +212 to +215

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 | 🟠 Major | ⚡ Quick win

Specify interval boundary semantics.

Define whether intervals are half-open or closed, how valid_to = NULL is represented, and the timestamp precision/time zone. Without this, adjacent org-chart epochs can overlap or produce boundary-time ambiguity in visibility and attribution.

🤖 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/domain/identity-resolution/specs/RESPEC-DRAFT.md` around lines 212 -
215, Update the org_chart_edges and org_chart_closure specification to define
interval boundary semantics: state whether valid intervals are half-open or
closed, how an open-ended valid_to is represented when NULL, and the required
timestamp precision and time zone. Ensure the visibility-at-T rule and adjacent
epoch behavior follow these definitions without boundary ambiguity.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Remove the stale ClickHouse Dictionary requirement.

The PR objective says the ClickHouse Dictionary is being removed, but this line still requires a range dictionary for every fact row. Replace it with the intended interval-join mechanism or explicitly reconcile the objective and draft.

🤖 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/domain/identity-resolution/specs/RESPEC-DRAFT.md` at line 215, Update
the visibility-read description to remove the ClickHouse range-dictionary
requirement and describe the intended interval-join mechanism for filtering rows
by ancestor and interval at T; ensure the draft no longer contradicts the
dictionary-removal objective.

- A tenant may have several org charts; edges carry `org_chart_id`; the admin switches the active chart.
- Integrity stance — breakage is an expected data state, not corruption:
- A forest (several roots, several components) is normal.
- Gap semantics is strict: no active edge at `T` → not a subordinate at `T`. No silent bridging to the last known supervisor.
- The only write-time invariant is acyclicity — checked by the service transactionally, over the edge's whole validity interval. Cycles never enter the data.
- Orphans, temporal gaps, unresolved supervisors are valid states surfaced by an org-chart-integrity resolver as proposals.
- Switching the active chart goes through a dry-run of the same integrity checks.
- Org-chart quality depends on identity resolution: an edge references the supervisor via identity signals; an unresolved supervisor = a missing edge = an orphan subtree.
- Reads never fail on a broken tree; reports honestly show less.

## 9. Rejected alternatives

### 9.1 Updating fields in `persons` (canonical values)

- **References / formulas in `value`** — rejected, see §1.3.
- **Read-time selector** (canonical value computed from a policy at read time): inexpressible in plain SQL on the ClickHouse replica; resolves against a moving target, breaking as-of-time reads; per-field control was wanted.
- **Not copying attributes into `persons`** (join `identity_inputs` at read time): each field must be reassignable independently of the profile link; `identity_inputs` may be ephemeral (TTL).
- **Per-row `meta` filter** (subscription piggybacked on value rows): a standing rule smeared across N rows; which row's meta is authoritative is implicit; unsubscribing is an invisible absence.
- **Subscription as a separate table / separate records**: superseded — `status` on decision records achieves the same with zero extra entities.
- **Tombstoning competing sources** (keep only the chosen stream): tombstone acquires a second meaning (preference vs fact); destroys true facts (alternatives menu, resolver inputs); switching back requires resurrecting data from possibly-expired `identity_inputs`.
- **Tenant-defaults table consulted at read time** (and replicated to ClickHouse): flipping a default would silently rewrite history and as-of-time reads; defaults feed resolvers at proposal time instead (§1.4).

### 9.2 Org chart

- **Periodic snapshots of visibility sets** (a set per employee per period): O(people × periods) growth; quantizes time — a mid-period boundary is lost. Interval-stamped rows are exact and grow only with changes.
- **Materialized per-viewer sets**: not stored; the closure filtered by `ancestor` + interval answers the same question.
- **Auto-bridging gaps** to the last known supervisor: a silent lie in access-control data.
- **Forbidding broken trees at write time**: the data comes from external sources and cannot be forced complete; only acyclicity is enforced on write, everything else is surfaced by the integrity resolver.
Loading