diff --git a/.cf-studio/config/artifacts.toml b/.cf-studio/config/artifacts.toml index fa20a49cc..804deac6b 100644 --- a/.cf-studio/config/artifacts.toml +++ b/.cf-studio/config/artifacts.toml @@ -756,6 +756,16 @@ kind = "DECOMPOSITION" path = "docs/domain/identity-resolution/specs/DECOMPOSITION.md" name = "Identity Resolution Decomposition" +[[systems.artifacts]] +kind = "ADR" +path = "docs/domain/identity-resolution/specs/ADR/0002-stable-person-id-via-persons-observations.md" +name = "ADR-0002: Stable person_id via append-only persons observations" + +[[systems.artifacts]] +kind = "ADR" +path = "docs/domain/identity-resolution/specs/ADR/0003-operator-decisions-as-persons-observations.md" +name = "ADR-0003: Operator identity corrections as append-only persons observations" + [[systems]] name = "Person Domain" diff --git a/docs/domain/identity-resolution/specs/ADR/0002-stable-person-id-via-persons-observations.md b/docs/domain/identity-resolution/specs/ADR/0002-stable-person-id-via-persons-observations.md index 3d8ef0fbf..4c69f55f8 100644 --- a/docs/domain/identity-resolution/specs/ADR/0002-stable-person-id-via-persons-observations.md +++ b/docs/domain/identity-resolution/specs/ADR/0002-stable-person-id-via-persons-observations.md @@ -6,6 +6,22 @@ date: 2026-04-24 # ADR-0002 — Stable `person_id` via append-only `persons` observations, with `account_person_map` as SCD2 cache + + + +- [Context](#context) +- [Decision](#decision) +- [Rationale](#rationale) +- [Consequences](#consequences) +- [Alternatives considered](#alternatives-considered) +- [Related](#related) + + + +**ID**: `cpt-ir-adr-stable-person-id` + +> **Post-acceptance note (2026-08-05)**: later service migrations and the implemented seed refined the mechanics this ADR describes — migration 004 moved the natural-key UNIQUE from `value_hash` to `created_at` and made `value_id` case-insensitive; migrations 009/014 moved timestamps to `DATETIME(6)` and relaxed `reason` to NULL-able; the `account_person_map` rebuild is a transactional tenant-scoped DELETE+INSERT rather than the RENAME swap sketched below; and the implemented Rust seed kept .NET-parity auto-linking instead of the `pending-iresolution` quarantine of section 6 (contested-evidence handling ships with the manual-resolution feature, ADR-0003). Where this text conflicts with DESIGN par. 3.7, the DESIGN is current. The decision itself (stable random `person_id`; append-only observations; derived `account_person_map`) is unaffected. + ## Context The `persons` table (MariaDB, see diff --git a/docs/domain/identity-resolution/specs/ADR/0003-operator-decisions-as-persons-observations.md b/docs/domain/identity-resolution/specs/ADR/0003-operator-decisions-as-persons-observations.md new file mode 100644 index 000000000..82c3b14ab --- /dev/null +++ b/docs/domain/identity-resolution/specs/ADR/0003-operator-decisions-as-persons-observations.md @@ -0,0 +1,149 @@ +--- +id: cpt-ir-adr-operator-decisions-as-observations +status: proposed +date: 2026-08-05 +decision-makers: mozhaev-dev +--- + +# ADR-0003 — Operator identity corrections as append-only `persons` observations (no separate decision store in v1) + + + + +- [Context and Problem Statement](#context-and-problem-statement) +- [Decision Drivers](#decision-drivers) +- [Considered Options](#considered-options) +- [Decision Outcome](#decision-outcome) + - [Consequences](#consequences) + - [Confirmation](#confirmation) +- [Pros and Cons of the Options](#pros-and-cons-of-the-options) + - [Operator decisions as ordinary `persons` binding observations](#operator-decisions-as-ordinary-persons-binding-observations) + - [Snapshot-based merge/split over ClickHouse `aliases` + `merge_audits`](#snapshot-based-mergesplit-over-clickhouse-aliases--merge_audits) + - [Dedicated `identity_decisions` journal (must-link / cannot-link decision store)](#dedicated-identity_decisions-journal-must-link--cannot-link-decision-store) +- [More Information](#more-information) +- [Traceability](#traceability) + + + +**ID**: `cpt-ir-adr-operator-decisions-as-observations` +## Context and Problem Statement + +Automatic e-mail-based resolution can err in both directions: under-merge (one human split across two persons when their e-mails differ, e.g. `alice@example.com` vs `alice@personal.example`) and over-merge (two humans grouped through a shared value such as `team@example.com`). The product offers no supported way to change a binding once automation has written it, so neither error is correctable through any product interface; additionally, the seed's divergent-group collapse can silently re-derive an existing binding (see the seed-hardening enabler below). + +We are adding operator correction verbs (bind, merge, detach, exclude — see constructorfabric/insight#2180 for the reviewed design). The question this ADR answers: **where do operator decisions live, and how are they enforced so that they survive every re-run of the automatic pipeline?** + +## Decision Drivers + +* Operator decisions must survive seed re-runs and future automation **by construction**, not by special-casing (epic #1873: "the override store is the source of truth; the auto-resolver only proposes"). +* Never merge silently: contested cases must surface to a human; no automation path may collapse two existing persons (BR-5 of #1602). +* Full audit: who decided what, when, and why — for every correction. +* Corrections must reach analytics through the existing pipeline (`persons` -> ClickHouse mirror -> dbt `resolve_person_id` macro -> gold), with changes confined to that pipeline's designated extension points. +* Smallest implementable slice for the 2026-08-25 release window of issue #2180. +* A future auto-matcher (confidence-scored proposals, #796/#1790) must be layerable on top without rework. + +## Considered Options + +* Operator decisions as ordinary `persons` binding observations +* Snapshot-based merge/split over ClickHouse `aliases` + `merge_audits` +* Dedicated `identity_decisions` journal (must-link / cannot-link decision store) + +## Decision Outcome + +Chosen option: "Operator decisions as ordinary `persons` binding observations", because decisions written in the same currency the seed reads need no parallel override store, require no schema migration for this iteration (given enabler 4 below), and ship within the release window while keeping all richer machinery layerable later. Survival through re-seeding rests on one seed invariant — an account's existing binding is reused, never re-derived — which already holds for consistently-bound groups and is extended to divergent groups by the seed hardening below. + +The decision in one sentence: **an operator correction is a new binding observation appended to `persons`, written in the same currency the automation already respects.** + +Recorded semantics: + +* Each operator verb appends binding observations (`value_type='id'`) with `author_person_id` = the operator's real UUID (never the seed sentinel) and a machine-readable `reason` code: `operator-bind`, `operator-merge`, `operator-detach`, `operator-exclude`. Free-text commentary and request payloads are captured in the existing `operations` journal, which was designed for reuse by new admin operations. +* Current state remains "latest binding per account wins"; history is never edited or deleted. +* A correction writes binding observations only. The journal does not encode which identity value belongs to which account — the account's own `value_type='id'` row carries the account id in `value_id`, but attribute observations (e-mail, username) reference only the source instance; that linkage lives in the `identity_inputs` evidence (`source_account_id` on every row), which the resolver and the value-addressed bulk bind consume. E-mail resolution therefore follows corrections indirectly, through the current bindings of the accounts observing each e-mail (enabler 1). +* "Confirm" (an account pending review is genuinely a separate person) is bind-to-self: the same person, re-asserted under an operator author. +* "Exclude" (bots, service accounts) binds the account to the reserved excluded-person sentinel — a fixed, unmintable UUID defined normatively in DESIGN par. 4.3, treated by every consumer (resolve macro, read API, person domain, review queue) as "no person". +* Undo is a counter-action (a newer binding), never a destructive revert; the journal retains the mistake, the fix, and both authors. +* The review queue is derived from two sources joined on the account key — the `identity_inputs` evidence (every observed account, including e-mail-less ones; folded per account over UPSERT/DELETE events so closed accounts drop out) and the current bindings in `persons` — with no persisted status columns. A divergence explained by an operator-authored binding is a resolved state, not a conflict — this classification-by-author is what keeps settled decisions out of the queue. + +Required enablers shipping with the feature (this decision is incomplete without them): + +1. **dbt resolver upgrade** — the v1 `resolve_person_id` macro resolves by e-mail only, so `value_type='id'` corrections would never reach gold. The macro gains an account-first map (latest `id` binding per source account) and an **account-derived e-mail fallback** for facts that carry no account (e.g. git commits): each e-mail maps to the accounts observing it in `identity_inputs` and resolves through their current bindings — all on one person → that person; several persons or none → NULL (contested evidence is excluded, not tie-broken). Known limit: facts keyed by an e-mail never observed on any account stay NULL. +2. **Seed hardening** — the current seed collapses a divergent e-mail group onto its first binding, which could silently override an operator row. Per-account bindings must win over group collapse; the bindings loader must return the binding author so divergence can be classified (operator-authored = resolved state; all-seed = surfaced); a contested e-mail stops auto-linking new accounts. +3. **Decision-aware API-level idempotency** — the journal's natural key includes `created_at` (service migration 004), so re-applying a correction would append a new history row rather than collide. The API therefore treats an item as a no-op **only when an identical operator decision is already recorded** (same target person and operator-authored effective binding). A bind-to-self over an automation-authored binding is not a no-op — it is the "confirm" act and appends the operator row. +4. **Unique per-row observation timestamps** — the natural key carries no account discriminator, so two `id` observations for two accounts of one source, bound to one person at the same `created_at`, would collide and `INSERT IGNORE` would drop one. The correction path allocates strictly increasing `DATETIME(6)` timestamps per affected row within an operation (bulk included); the seed carries the same obligation for accounts of one source resolving to one person at the same `_synced_at`. Extending the key with an account discriminator is a candidate follow-up migration. + +### Consequences + +* Good, because durability needs no parallel store or replay machinery — one existing seed invariant (bindings are reused, never re-derived), extended by a small hardening to divergent groups, covers it. +* Good, because the blast radius is small and confined to designated extension points: no new tables; new endpoints in the identity-resolution service; the resolver upgrade lands inside the one macro that owns resolution semantics; the seed hardening is a focused change in `resolve_assignments` and its bindings loader. +* Good, because the journal doubles as an audit trail and as future training data for the auto-matcher (every operator decision is a labeled example). +* Good, because the divergent-group collapse that could silently rewrite bindings is eliminated by the same hardening that protects operator rows. +* Bad, because there is no first-class negative assertion ("never merge these two"): acceptable while the only automation never merges existing persons and a single operator is assumed, but it must be revisited (see below). +* Bad, because n-way splits are a sequence of detaches (not atomic), reverts are manual counter-actions, concurrent operators are last-write-wins, and there is no ignore/defer for queue items (a snooze needs persisted queue state; it returns with the proposal store). +* Neutral, because GDPR erasure does not conflict with append-only: the rule governs identity decisions, while lawful erasure of stored identity values is an explicit administrative operation outside the decision journal (future purge flow), itself recorded in the `operations` journal. + +Revisit triggers — any of these reopens the decision in favour of a dedicated decision journal layered on top (operator rows in `persons` remain valid as materialisations, so no rework is lost): + +1. An auto-matcher that produces merge proposals ships. +2. More than one concurrent operator per tenant. +3. Recurring manual re-decisions on the same account pairs (a signal that "these are different people" needs to be stored as a rule). +4. Need for value blocklists (shared mailboxes, bot addresses) as first-class objects. + +### Confirmation + +* Design review of constructorfabric/insight#2180 (scenarios S1-S10 cover each verb, seed re-run survival, conflict classification, and retroactive metric re-attribution). +* Seed re-run test: an operator-authored binding is untouched by a subsequent seed run. +* Conflict-classification test: an e-mail group whose binding divergence includes an operator-authored binding produces no conflict item and no crash. +* End-to-end test: activity keyed by a corrected account re-attributes to the new person on the next gold build. + +## Pros and Cons of the Options + +### Operator decisions as ordinary `persons` binding observations + +Operator verbs append binding rows to the existing append-only `persons` log; audit metadata rides on the existing `author_person_id` / `reason` columns plus the `operations` journal. + +* Good, because survival through re-seeding is structural (the binding-reuse invariant plus its divergent-group hardening), not a bolted-on exception list. +* Good, because no new tables or migrations are needed for this iteration, and the downstream change is confined to the one macro that owns resolution semantics; implementable within the release window. +* Good, because provenance (human vs automation) is a load-bearing field already present on every row. +* Neutral, because negative knowledge is captured only implicitly (a detach's history implies "this account is not that person") — recoverable later as training/backfill material. +* Bad, because rich decision semantics (cannot-link, atomic partition, retract-with-reapply, resolution epochs) have no first-class home yet. + +### Snapshot-based merge/split over ClickHouse `aliases` + `merge_audits` + +The late-phase plan of DESIGN v2.0: reassign `aliases` rows between persons, snapshot before/after into `merge_audits`, split = restore a snapshot. + +* Good, because it was already written down in the spec and covers GDPR archival flows. +* Bad, because none of the required tables (`aliases` as resolution store, `unmapped`, `conflicts`, `merge_audits`) is actually used by the implemented resolution path — the plan targets an architecture that was not built. +* Bad, because the mechanics are unsound on ClickHouse: `ALTER TABLE UPDATE` is an asynchronous mutation (the post-merge snapshot can read pre-mutation state), and the proposed ReplacingMergeTree keys (with unique `id` in ORDER BY, mutable `status` in ORDER BY) prevent the dedup/collapse the design relies on. +* Bad, because split exists only as a rollback of a recorded merge — it cannot separate persons grouped by the seed itself (e.g. through a shared mailbox), a case the split verb must handle without any prior merge record. +* Bad, because a status-column queue table drifts from reality (resolved items linger). + +### Dedicated `identity_decisions` journal (must-link / cannot-link decision store) + +A separate append-only decision table (+ subjects), applied as constraints on every resolution pass; positive decisions materialise as `persons` bindings. + +* Good, because negative assertions, atomic n-way partition, retract-with-reapply, conflict responses citing decisions, and resolution epochs all get a first-class home — this matches industry-converged practice (pairwise judgement journals, steward decision tables). +* Good, because it is the natural v2 once an auto-matcher or multi-operator concurrency arrives. +* Bad, because it requires new tables, a validation/conflict engine, and fold semantics now — oversized for a single-operator MVP whose only automation never merges existing persons. +* Bad, because two journals create a source-of-truth split that must be actively reconciled (decisions win; bindings are materialisations) — complexity with no current payoff. + +This option is **deferred, not rejected**; the revisit triggers above name the conditions. + +## More Information + +Industry prior art converges on the chosen shape: decisions stored as data that the matcher re-consumes on every run (OpenSanctions/nomenklatura pairwise judgements; Senzing trusted identifiers encoded in records; Semarchy steward decisions as table rows; SortingHat locked profiles), and merge implemented as reversible linking rather than physical collapse (documented irreversible-merge pain in Segment/Mixpanel/PostHog). The reviewed design with scenarios and API shapes lives in constructorfabric/insight#2180; the umbrella vision is #1873; related edge-case requirements: #1767, #1776. + +When the matcher iteration lands, proposal `confidence` and `evidence` receive first-class storage (reserved column names); the journal deliberately carries no dead columns for them now — additive MariaDB migrations are cheap at that point, and operator provenance (author, reason, `operations` payload) already accumulates the training signal in the meantime. This is a deliberate, recorded deviation from the review suggestion to reserve physical columns in the MVP schema. + +This ADR implements the operator-flow consequence promised by [ADR-0002](0002-stable-person-id-via-persons-observations.md) ("Operator-driven flows (future PR) will create new persons rows ... with author_person_id = the operator's person_id and a descriptive reason"). + +## Traceability + +- **PRD**: [PRD.md](../PRD.md) +- **DESIGN**: [DESIGN.md](../DESIGN.md) + +This decision directly addresses the following requirements or design elements: + +* `cpt-ir-fr-merge-v2` — merge of two persons is an appended reassignment of account bindings, recorded with actor and reason. +* `cpt-ir-fr-split-v2` — detach/split works on any account regardless of prior merge history; no snapshot restore required. +* `cpt-ir-fr-merge-audit-v2` — the journal itself is the audit trail (author, reason, timestamp on every row; request payloads in the `operations` journal). +* `cpt-ir-fr-idempotent-mutations-v2` — corrections are idempotent through the decision-aware API check (enabler 3): re-applying an already-recorded operator decision is a reported no-op. +* `cpt-ir-actor-operator` — the operator becomes a first-class author in the `persons` journal. diff --git a/docs/domain/identity-resolution/specs/DECOMPOSITION.md b/docs/domain/identity-resolution/specs/DECOMPOSITION.md index d9688591e..6ed41dd03 100644 --- a/docs/domain/identity-resolution/specs/DECOMPOSITION.md +++ b/docs/domain/identity-resolution/specs/DECOMPOSITION.md @@ -4,9 +4,10 @@ - [1. Overview](#1-overview) - [2. Entries](#2-entries) - - [2.1 Initial Seed — HIGH](#21-initial-seed--high) - - [2.2 Bootstrap Pipeline — HIGH](#22-bootstrap-pipeline--high) + - [2.1 Identity Store & Read Paths — HIGH](#21-identity-store--read-paths--high) + - [2.2 Evidence Intake & Seed Fold — HIGH](#22-evidence-intake--seed-fold--high) - [2.3 Matching Engine — MEDIUM](#23-matching-engine--medium) + - [2.4 Manual Resolution — HIGH](#24-manual-resolution--high) - [3. Feature Dependencies](#3-feature-dependencies) @@ -15,23 +16,23 @@ ## 1. Overview -The Identity Resolution DESIGN is decomposed into three features aligned to the implementation phases defined in the PRD. Each feature builds on the previous, delivering incremental value while maintaining a working system at each step. +The Identity Resolution DESIGN is decomposed into four features. Features 1–2 cover the implemented journal architecture (DESIGN v3.0); Feature 3 is the future matcher; Feature 4 is the manual-resolution capability of the current iteration. + +> **History note**: Features 1–2 were originally planned around an alias-table pipeline (`aliases` store, BootstrapJob, `unmapped`/`conflicts` queues, resolve API, ClickHouse Dictionary). That plan was not built; the entries below describe what shipped instead, and the corresponding alias-pipeline requirements are marked superseded in the PRD. The historical entries remain in this file's git history. **Decomposition Strategy**: -- Features grouped by **implementation phase**: seed → bootstrap → matching. Each phase delivers independently testable capabilities. -- Feature 1 (Initial Seed) establishes the `aliases` table and resolution API — the minimum viable system where HR data is directly loaded. -- Feature 2 (Bootstrap Pipeline) introduces the `identity_inputs` ingestion mechanism, BootstrapJob processing, and conflict/unmapped tracking — enabling automated alias creation from connector data. -- Feature 3 (Matching Engine) adds configurable matching rules with three-phase evaluation (B1/B2/B3), confidence scoring, and operator workflows — enabling intelligent alias resolution beyond exact matches. -- Dependencies are linear: Feature 1 → Feature 2 → Feature 3. No circular dependencies. +- Feature 1 (Identity Store & Read Paths) establishes the `persons` journal with stable `person_id` (ADR-0002), its derived caches, and both read paths — the service read API and the analytics mirror + resolve macro. +- Feature 2 (Evidence Intake & Seed Fold) introduces the `identity_inputs` evidence contract and the scheduled persons-seed fold that binds accounts to persons, plus the persons-sync mirror publisher. +- Feature 3 (Matching Engine, future) adds configurable matching rules with confidence-scored proposals — never auto-applied. +- Feature 4 (Manual Resolution) adds operator correction verbs over the journal (ADR-0003) with the resolver upgrade and seed hardening they require. +- Dependencies: Feature 1 → Feature 2 → Feature 3, and Feature 4 → Feature 3 (proposal acceptance flows through the Feature 4 operator API); Feature 1 → Feature 4. No circular dependencies. - 100% coverage of all DESIGN components, tables, and sequences verified. -**Late-Phase Items (Future Scope)**: -The following capabilities are defined in the PRD (p3 priority) and DESIGN but are not decomposed into features in this release: -- **Merge/split operations**: `merge_audits` table, merge/split API endpoints, `cpt-insightspec-ir-seq-merge` sequence. PRD FRs: `cpt-ir-fr-merge`, `cpt-ir-fr-split`, `cpt-ir-fr-merge-audit`, `cpt-ir-fr-idempotent-mutations`. NFR: `cpt-ir-nfr-merge-reversibility`. -- **GDPR alias deletion**: `alias_gdpr_deleted` table, purge API endpoint. PRD FRs: `cpt-ir-fr-gdpr-purge`. NFR: `cpt-ir-nfr-gdpr-erasure`. -- **Operator API endpoints** for merge/split/purge: These will be added when late-phase features are planned. +**Manual Resolution (current iteration)**: operator corrections were re-scoped from "late phase" to `p1` by [ADR-0003](ADR/0003-operator-decisions-as-persons-observations.md) — the journal-based model replaced the snapshot-based merge/split plan, and the v1 merge/split/audit/idempotency requirements were superseded by their `-v2` forms in the PRD. Entry 2.4 below tracks this feature; its FEATURE.md follows with the implementation PR (design reviewed in constructorfabric/insight#2180). -These items have schema defined in DESIGN §3.7 (`cpt-insightspec-ir-dbtable-merge-audits`, `cpt-insightspec-ir-dbtable-alias-gdpr-deleted`) for forward reference. Implementation will be planned in a separate DECOMPOSITION cycle. +**Late-Phase Items (Future Scope)**: +- **GDPR alias deletion**: erasure archive table and purge flow. PRD FRs: `cpt-ir-fr-gdpr-purge`. NFR: `cpt-ir-nfr-gdpr-erasure`. Schema retained as a future-table summary in DESIGN §3.7; implementation will be planned in a separate DECOMPOSITION cycle. +- **Admin identity console**: a required product surface per the umbrella epic (#1873) — scheduled as the next step once the Feature 4 API stabilizes; not decomposed here. --- @@ -41,75 +42,70 @@ These items have schema defined in DESIGN §3.7 (`cpt-insightspec-ir-dbtable-mer - [ ] `p1` - **ID**: `cpt-ir-status-overall` -### 2.1 [Initial Seed](feature-initial-seed/) — HIGH +### 2.1 Identity Store & Read Paths — HIGH - [ ] `p1` - **ID**: `cpt-ir-feature-initial-seed` -- **Purpose**: Establish the `aliases` table and Resolution API to enable cross-platform analytics from day one. HR Bronze data is loaded directly into `persons` (person domain) and `aliases` via dbt seed models, providing the minimum viable identity resolution: every Gold analytics query can resolve `person_id` for HR-sourced aliases. +- **Purpose**: Establish the `persons` observation journal as the source of truth for the account-to-person binding (stable random `person_id`, ADR-0002), its derived `account_person_map` cache, and the two read paths every consumer uses: the identity-resolution service read API (request-time person lookups) and the analytics mirror + `resolve_person_id` macro (build-time resolution for gold). - **Depends On**: None - **Scope**: - - Create `aliases` table in ClickHouse with PR #55 schema - - dbt seed models to load HR Bronze data (BambooHR employees) into `aliases` - - Resolution API: `POST /resolve`, `POST /batch-resolve` - - Hot-path alias lookup in `aliases` table - - ClickHouse Dictionary for analytical Silver step 2 enrichment - - Tenant isolation on all queries - - Cross-domain integration: `aliases.person_id` references `persons.person_id` (person domain creates person records; identity-resolution seeds the initial `persons` from `identity_inputs`, see ADR-0002) + - `persons` journal and `account_person_map` in MariaDB, schema owned by the service (SeaORM migrations, ADR-0006) + - Service read API: person profile and visibility lookups over the journal (component spec) + - Analytics read path: `identity.identity_persons` mirror + build-time resolution through the resolve macro + - Tenant isolation on all queries (see the known gap on the evidence read path in the PRD) + - Legacy: dbt seed models still populate the ClickHouse `aliases` table; it is not consumed by resolution and is retained until retirement (DESIGN §3.7) - **Out of scope**: - - `identity_inputs` table (Feature 2) - - BootstrapJob incremental processing (Feature 2) + - `identity_inputs` evidence contract and the seed fold (Feature 2) - Match rules and MatchingEngine (Feature 3) - - Unmapped queue (Feature 2) - - Conflict detection (Feature 2) - - Merge/split operations (late phase) + - Operator corrections (Feature 4) - GDPR deletion (late phase) - **Requirements Covered**: - - [ ] `p1` - `cpt-ir-fr-seed-aliases` - - [ ] `p1` - `cpt-ir-fr-resolve-alias` - - [ ] `p1` - `cpt-ir-fr-batch-resolve` + - [ ] `p1` - `cpt-ir-fr-seed-aliases` (superseded — legacy dbt seeds) + - [ ] `p1` - `cpt-ir-fr-resolve-alias` (superseded) + - [ ] `p1` - `cpt-ir-fr-batch-resolve` (superseded) - [ ] `p1` - `cpt-ir-fr-tenant-isolation` - [ ] `p1` - `cpt-ir-nfr-alias-lookup-latency` - [ ] `p1` - `cpt-ir-nfr-tenant-isolation` - **Design Principles Covered**: - - [ ] `p2` - `cpt-insightspec-ir-principle-alias-centric` - - [ ] `p2` - `cpt-insightspec-ir-principle-ch-native` + - [ ] `p2` - `cpt-insightspec-ir-principle-alias-centric` (v1 framing — retained; see the storage-split principle) + - [ ] `p2` - `cpt-insightspec-ir-principle-ch-native-v2` - [ ] `p2` - `cpt-insightspec-ir-principle-domain-isolation` - **Design Constraints Covered**: - - [ ] `p2` - `cpt-insightspec-ir-constraint-ch-only` + - [ ] `p2` - `cpt-insightspec-ir-constraint-storage-split-v2` - [ ] `p2` - `cpt-insightspec-ir-constraint-naming` - [ ] `p2` - `cpt-insightspec-ir-constraint-domain-boundary` - [ ] `p2` - `cpt-insightspec-ir-constraint-half-open-intervals` - **Domain Model Entities**: - - `aliases` (create — primary table for this feature) - - `persons` (cross-domain reference — created by person domain dbt seed) + - `aliases` (create — legacy seed target, see DESIGN §3.7) + - `persons` (journal — seeded by this domain per ADR-0002) - **Design Components**: - - [ ] `p2` - `cpt-insightspec-ir-component-resolution-service` + - [x] `p1` - `cpt-insightspec-ir-component-identity-read-api` - **API**: - - POST /api/identity/resolve - - POST /api/identity/batch-resolve + - Person lookup endpoints of the identity-resolution service (see component spec) - **Sequences**: - - [ ] `p1` - `cpt-insightspec-ir-seq-resolve-hot` + - `cpt-insightspec-ir-seq-build-resolution` - **Data**: - [ ] `p3` - `cpt-insightspec-ir-db-schemas` - - [ ] `p1` - `cpt-insightspec-ir-dbtable-aliases` - - [x] `p1` - `cpt-insightspec-ir-dbtable-persons-mariadb` + - `cpt-insightspec-ir-dbtable-aliases` (legacy — not consumed by resolution) + - `cpt-insightspec-ir-dbtable-persons-mariadb` + - `cpt-insightspec-ir-dbtable-account-person-map` - **Interfaces**: @@ -118,46 +114,38 @@ These items have schema defined in DESIGN §3.7 (`cpt-insightspec-ir-dbtable-mer --- -### 2.2 [Bootstrap Pipeline](feature-bootstrap-pipeline/) — HIGH +### 2.2 Evidence Intake & Seed Fold — HIGH - [ ] `p1` - **ID**: `cpt-ir-feature-bootstrap-pipeline` -- **Purpose**: Enable automated, incremental alias creation from connector data. Connectors write alias observations to `identity_inputs`; the BootstrapJob processes them into the `aliases` table, routing unresolvable aliases to the `unmapped` queue and detecting alias-level conflicts. This replaces the one-time dbt seed with a continuous pipeline that handles new connectors and ongoing syncs. +- **Purpose**: Give connectors one uniform write target for identity observations and fold that evidence into the journal automatically. Connectors populate `identity_inputs` through dbt (`identity_inputs_from_history` macro, incremental append models); the scheduled persons-seed groups accounts by e-mail and binds them (reuse / link-by-e-mail / mint / skip, never merging existing persons); persons-sync republishes the journal to ClickHouse for analytics. -- **Depends On**: `cpt-ir-feature-initial-seed` (aliases table and Resolution API must exist) +- **Depends On**: `cpt-ir-feature-initial-seed` (the `persons` journal and read paths must exist) - **Scope**: - - Create `identity_inputs` table in ClickHouse - - Create `unmapped` table for unresolved aliases - - Create `conflicts` table for alias-level disagreements - - BootstrapJob: reads identity_inputs incrementally (`_synced_at > last_watermark`). See DESIGN §5 REC-IR-02 for recommended watermark mechanism (dbt incremental + `bootstrap_watermarks` table) - - Alias normalization: email/username → `lower(trim())`; others → `trim()` - - Auto-create alias on exact match (confidence >= 1.0 from direct lookup) - - Route unresolved aliases to `unmapped` table - - Detect alias conflicts when same alias claimed by different persons - - Track `last_observed_at` for existing aliases - - Auto-resolve unmapped entries when matching aliases are created - - Idempotent bootstrap runs (dedup on natural key) - - Argo Workflow integration for scheduling + - `identity_inputs` table and write contract; per-connector dbt models via the shared macro (incremental `append` on `_synced_at`) + - persons-seed as a scheduled service subcommand: run-lock, input guards with explicit `--force`, run journal in `operations` with per-branch counters + - Seed fold semantics per DESIGN §3.2 PersonsSeed (including the documented divergent-group known gap, addressed in Feature 4) + - persons-sync: atomic republish of the journal into `identity.identity_persons` + - Idempotent re-runs (journal natural key; deterministic `created_at` from `_synced_at`) - **Out of scope**: - - Configurable match rules (Feature 3 — bootstrap uses direct lookup only in this feature) - - Fuzzy matching (Feature 3) - - Operator unmapped queue management UI (Feature 3) - - Merge/split (late phase) + - Incremental watermark processing — each run currently folds the full evidence set (`cpt-ir-fr-bootstrap-incremental` open; DESIGN §5 REC-IR-02) + - Configurable match rules and fuzzy matching (Feature 3) + - Operator corrections and seed hardening (Feature 4) - GDPR deletion (late phase) - **Requirements Covered**: - [x] `p1` - `cpt-ir-fr-accept-bootstrap-inputs` - - [x] `p1` - `cpt-ir-fr-bootstrap-incremental` - - [ ] `p1` - `cpt-ir-fr-normalize-aliases` - - [ ] `p1` - `cpt-ir-fr-create-alias-exact` - - [ ] `p1` - `cpt-ir-fr-route-unmapped` - - [ ] `p1` - `cpt-ir-fr-track-observations` - - [ ] `p1` - `cpt-ir-fr-bootstrap-idempotent` - - [ ] `p2` - `cpt-ir-fr-alias-conflict-detection` - - [ ] `p2` - `cpt-ir-fr-auto-resolve-unmapped` + - [ ] `p1` - `cpt-ir-fr-bootstrap-incremental` (open: full-set fold today; watermark per REC-IR-02) + - [ ] `p1` - `cpt-ir-fr-normalize-aliases` (superseded) + - [ ] `p1` - `cpt-ir-fr-create-alias-exact` (superseded) + - [ ] `p3` - `cpt-ir-fr-route-unmapped` (future — with the matcher) + - [ ] `p1` - `cpt-ir-fr-track-observations` (superseded) + - [ ] `p1` - `cpt-ir-fr-bootstrap-idempotent` (superseded) + - [ ] `p2` - `cpt-ir-fr-alias-conflict-detection` (superseded) + - [ ] `p2` - `cpt-ir-fr-auto-resolve-unmapped` (future — with the matcher) - [ ] `p1` - `cpt-ir-nfr-bootstrap-throughput` - [ ] `p1` - `cpt-ir-nfr-bootstrap-idempotency` @@ -171,28 +159,25 @@ These items have schema defined in DESIGN §3.7 (`cpt-insightspec-ir-dbtable-mer - **Domain Model Entities**: - `identity_inputs` (create) - - `aliases` (update — add new aliases from bootstrap) - - `unmapped` (create) - - `conflicts` (create) + - `persons` (append observations per ADR-0002 — never updated) + - `unmapped` / `conflicts` (future tables — the v1 review queue is derived; see DESIGN §3.7) - **Design Components**: - - [ ] `p2` - `cpt-insightspec-ir-component-bootstrap-job` - - [ ] `p2` - `cpt-insightspec-ir-component-conflict-detector` + - [x] `p1` - `cpt-insightspec-ir-component-persons-seed` + - [x] `p1` - `cpt-insightspec-ir-component-persons-sync` - **API**: - - (No new API endpoints — BootstrapJob is a batch job, not an API service) + - (No new API endpoints — the seed is a scheduled job, not an API service) - Connector write contract: dbt `identity_inputs_from_history` macro applied to `fields_history` models (implemented for BambooHR and Zoom) - **Sequences**: - - [ ] `p1` - `cpt-insightspec-ir-seq-bootstrap-processing` + - `cpt-insightspec-ir-seq-seed-run` - **Data**: - - [x] `p1` - `cpt-insightspec-ir-dbtable-identity-inputs` - - [ ] `p2` - `cpt-insightspec-ir-dbtable-unmapped` - - [ ] `p2` - `cpt-insightspec-ir-dbtable-conflicts` + - `cpt-insightspec-ir-dbtable-identity-inputs` - **Interfaces**: @@ -204,25 +189,20 @@ These items have schema defined in DESIGN §3.7 (`cpt-insightspec-ir-dbtable-mer - [ ] `p2` - **ID**: `cpt-ir-feature-matching-engine` -- **Purpose**: Enable intelligent alias resolution beyond exact matches. Configurable match rules evaluate candidates using three-phase scoring (B1 deterministic, B2 normalization/cross-system, B3 fuzzy). Integrates with BootstrapJob for cold-path evaluation and provides operator workflows for unmapped queue management and manual alias CRUD. +- **Purpose** (future): Enable matching beyond the seed's exact-e-mail link. Configurable match rules evaluate candidates using three-phase scoring (B1 deterministic, B2 normalization/cross-system, B3 fuzzy) and produce confidence-scored **proposals** for operator review. Two ADR-0003 invariants bind this feature: the matcher never writes bindings (acceptance is an operator act through the Feature 4 API), and operator decisions in the journal override any rule. -- **Depends On**: `cpt-ir-feature-bootstrap-pipeline` (BootstrapJob must exist to invoke MatchingEngine on cold path; unmapped table must exist for suggestions) +- **Depends On**: `cpt-ir-feature-bootstrap-pipeline` (evidence stream and journal), `cpt-ir-feature-manual-resolution` (the accept/reject surface for proposals) - **Scope**: - - Create `match_rules` table with seed data for B1/B2/B3 rules - - MatchingEngine component: loads rules, evaluates against candidates, computes composite confidence - - Three-phase pipeline: B1 (exact email, exact HR ID), B2 (case-insensitive email, domain alias, cross-system username), B3 (Jaro-Winkler, Soundex) - - Confidence thresholds: >= 1.0 auto-link, 0.50-0.99 suggestion, < 0.50 unmapped - - Fuzzy rules disabled by default; NEVER auto-link - - Integration with BootstrapJob cold path: when direct lookup fails, invoke MatchingEngine - - Integration with ResolutionService cold path: `POST /resolve` falls through to MatchingEngine - - Operator API: `GET /unmapped`, `POST /unmapped/:id/resolve`, `POST /unmapped/:id/ignore` - - Operator API: `GET /rules`, `PUT /rules/:id` - - Operator API: `GET /persons/:id/aliases`, `POST /persons/:id/aliases`, `DELETE /persons/:id/aliases/:alias_id` - - ClickHouse Dictionary for analytical alias lookup + - `match_rules` storage with seed data for B1/B2/B3 rules (future table — DESIGN §3.7) + - MatchingEngine component: loads rules, evaluates candidates over evidence and journal, computes composite confidence + - Three-phase pipeline: B1 (exact e-mail, exact HR ID), B2 (case-insensitive e-mail, domain alias, cross-system username), B3 (Jaro-Winkler, Soundex) + - Confidence thresholds ordering proposals; fuzzy rules disabled by default and NEVER auto-link + - Proposal review integration with the operator resolution API (accept / reject / defer) + - Rule configuration surface for operators - **Out of scope**: - - Merge/split operations (late phase) + - Any automatic application of proposals (structurally excluded — ADR-0003) - GDPR deletion (late phase) - **Requirements Covered**: @@ -231,7 +211,7 @@ These items have schema defined in DESIGN §3.7 (`cpt-insightspec-ir-dbtable-mer - [ ] `p2` - `cpt-ir-fr-three-phase-matching` - [ ] `p2` - `cpt-ir-fr-no-fuzzy-autolink` - [ ] `p2` - `cpt-ir-fr-unmapped-management` - - [ ] `p2` - `cpt-ir-fr-manual-alias-crud` + - [ ] `p2` - `cpt-ir-fr-manual-alias-crud` (superseded) - [ ] `p2` - `cpt-ir-nfr-no-fuzzy-autolink` - **Design Principles Covered**: @@ -243,35 +223,96 @@ These items have schema defined in DESIGN §3.7 (`cpt-insightspec-ir-dbtable-mer - [ ] `p2` - `cpt-insightspec-ir-constraint-no-fuzzy-autolink` - **Domain Model Entities**: - - `match_rules` (create + seed default rules) - - `unmapped` (update — add suggestions from MatchingEngine) - - `aliases` (update — auto-link from MatchingEngine results) + - `match_rules` (create + seed default rules — future table, DESIGN §3.7) + - Proposal storage or derivation (design decision of this feature) + - `persons` journal (read-only for the matcher; bindings are written only by operator acceptance through the Feature 4 API) - **Design Components**: - [ ] `p2` - `cpt-insightspec-ir-component-matching-engine` - **API**: - - GET /api/identity/unmapped - - POST /api/identity/unmapped/:id/resolve - - POST /api/identity/unmapped/:id/ignore - - GET /api/identity/rules - - PUT /api/identity/rules/:id - - GET /api/identity/persons/:id/aliases - - POST /api/identity/persons/:id/aliases - - DELETE /api/identity/persons/:id/aliases/:alias_id + - Match-rule configuration and proposal review endpoints (to be designed with this feature; proposals are accepted through the Feature 4 operator surface) - **Sequences**: - (MatchingEngine is invoked within `cpt-insightspec-ir-seq-bootstrap-processing` and `cpt-insightspec-ir-seq-resolve-hot` — both already assigned to Features 1 and 2. No new sequences unique to this feature.) + (The MatchingEngine is invoked within the seed-run and build-resolution sequences already assigned to Features 1 and 2. No new sequences unique to this feature.) - **Data**: + - `match_rules` (future table — summary in DESIGN §3.7) + +- **Interfaces**: + - Match-rule configuration API (future; will be specified with this feature) + +--- + +### 2.4 Manual Resolution — HIGH + +- [ ] `p1` - **ID**: `cpt-ir-feature-manual-resolution` + +- **Purpose**: Give the operator correction verbs over the account-to-person binding — bind (single/bulk), merge, detach, exclude — plus the derived review queue and binding history, per [ADR-0003](ADR/0003-operator-decisions-as-persons-observations.md). Corrections are appended to the `persons` journal and survive every seed re-run. Reviewed design with scenarios: constructorfabric/insight#2180. FEATURE.md to be authored with the implementation PR. - - [ ] `p2` - `cpt-insightspec-ir-dbtable-match-rules` +- **Depends On**: `cpt-ir-feature-initial-seed` (the `persons` journal, seed, and read API must exist) + +- **Scope**: + - Operator write verbs appending binding observations authored by the operator + - Derived review queue (accounts pending a decision, contested-binding groups not explained by an operator decision, and no-evidence accounts surfaced from `identity_inputs`) with candidates and counts + - Resolution-rate reporting (bound / pending / no-evidence / excluded shares — the operator-visible match rate of the umbrella epic) + - Per-account binding history (explain) and per-person account listing (matching table) + - Seed hardening: per-account bindings win over group collapse (removing the path that can silently re-derive a binding); author-aware conflict classification (bindings loader returns author); contested e-mails stop auto-linking + - Evidence reader fix: honor empty-value DELETE (closure) rows — the current non-empty filter drops them, leaving tombstones inert; required for correct seed closure handling and for the queue's UPSERT/DELETE fold + - Reserved excluded-person sentinel treated as "no person" by every consumer — resolve macro (NULL), service read API, person domain, review queue (normative definition in DESIGN par. 4.3) + - dbt resolver upgrade: account-first person resolution (latest `value_type='id'` binding per source account) with e-mail fallback; contested e-mail resolves to NULL — required for corrections to reach gold (DESIGN par. 4.4) + - Decision-aware API idempotency + unique per-row observation timestamps (natural key has no account discriminator — DESIGN par. 3.7 index note) + - Account-derived e-mail fallback in the resolver: `identity_inputs` provides the account-to-value linkage (`source_account_id` on every row); an e-mail resolves through the current bindings of its observing accounts + +- **Out of scope**: + - Stored negative rules, value blocklists, proposals with confidence, automatic revert, multi-operator concurrency — deferred with explicit triggers (ADR-0003) + - Ignore/defer (snooze) for queue items — deliberate narrowing; returns with the proposal store + - GDPR deletion (late phase) + +- **Requirements Covered**: + + - [ ] `p1` - `cpt-ir-fr-merge-v2` + - [ ] `p1` - `cpt-ir-fr-split-v2` + - [ ] `p1` - `cpt-ir-fr-operator-bind` + - [ ] `p1` - `cpt-ir-fr-operator-exclude` + - [ ] `p1` - `cpt-ir-fr-review-queue` + - [ ] `p1` - `cpt-ir-fr-correction-durability` + - [ ] `p1` - `cpt-ir-fr-merge-audit-v2` + - [ ] `p1` - `cpt-ir-fr-idempotent-mutations-v2` + - [ ] `p2` - `cpt-ir-fr-binding-history` + - [ ] `p1` - `cpt-ir-nfr-merge-reversibility` + +- **Design Principles Covered**: + + - [ ] `p1` - `cpt-insightspec-ir-principle-append-only-journal` + - [ ] `p2` - `cpt-insightspec-ir-principle-fail-safe` + +- **Design Constraints Covered**: + + (Inherits all constraints from Feature 1) + +- **Domain Model Entities**: + - `persons` (append operator corrections) + - `operations` (journal operator calls) + - `account_person_map` (rebuild after corrections) + +- **Design Components**: + + - [ ] `p1` - `cpt-insightspec-ir-component-operator-resolution-api` + +- **API**: + - Operator resolution endpoints (working shapes in DESIGN §3.3; contracts fixed at FEATURE level) + +- **Sequences**: + + - `cpt-insightspec-ir-seq-operator-correction` - **Interfaces**: - - [ ] `p2` - `cpt-ir-interface-ch-dictionary` + - [ ] `p1` - `cpt-ir-interface-analytics-resolution` + - [ ] `p1` - `cpt-insightspec-ir-interface-api-v2` --- @@ -281,68 +322,77 @@ These items have schema defined in DESIGN §3.7 (`cpt-insightspec-ir-dbtable-mer cpt-ir-feature-initial-seed (HIGH, p1) | +---> cpt-ir-feature-bootstrap-pipeline (HIGH, p1) - | - +---> cpt-ir-feature-matching-engine (MEDIUM, p2) + | | + | +---> cpt-ir-feature-matching-engine (MEDIUM, p2) + | ^ + +---> cpt-ir-feature-manual-resolution (HIGH, p1) + (proposal acceptance surface) ``` **Late-phase items (not yet decomposed):** ```text -cpt-ir-feature-matching-engine +cpt-ir-feature-manual-resolution | - +---> [future] merge/split operations (p3) + +---> [future] stored negative rules / value blocklists (with the matcher) +---> [future] GDPR alias deletion (p3) - +---> [future] operator merge/split/purge API (p3) ``` **Dependency Rationale**: -- `cpt-ir-feature-bootstrap-pipeline` requires `cpt-ir-feature-initial-seed`: The `aliases` table and Resolution API must exist before the BootstrapJob can create/update alias records and invoke resolution lookups. The dbt seed provides the initial person+alias foundation that bootstrap extends. +- `cpt-ir-feature-bootstrap-pipeline` requires `cpt-ir-feature-initial-seed`: the `persons` journal, its schema ownership, and the read paths must exist before the seed can fold evidence into them and persons-sync can publish the result. + +- `cpt-ir-feature-matching-engine` requires `cpt-ir-feature-bootstrap-pipeline`: the matcher consumes the evidence stream and the journal produced by the intake/fold pipeline, and its proposals are only meaningful once automatic binding runs continuously. -- `cpt-ir-feature-matching-engine` requires `cpt-ir-feature-bootstrap-pipeline`: The MatchingEngine is invoked by the BootstrapJob on the cold path (when direct alias lookup fails). The `unmapped` table must exist for the MatchingEngine to write suggestions. Without the bootstrap pipeline, there is no invocation path for the MatchingEngine. +- `cpt-ir-feature-manual-resolution` requires `cpt-ir-feature-initial-seed`: correction verbs append to the journal and are served by the same service; its resolver upgrade extends the analytics read path established there. **Coverage Verification**: | DESIGN Element | Feature | |---|---| -| `cpt-insightspec-ir-component-resolution-service` | Feature 1 (initial-seed) | -| `cpt-insightspec-ir-component-bootstrap-job` | Feature 2 (bootstrap-pipeline) | -| `cpt-insightspec-ir-component-conflict-detector` | Feature 2 (bootstrap-pipeline) | +| `cpt-insightspec-ir-component-identity-read-api` | Feature 1 (initial-seed) | +| `cpt-insightspec-ir-component-persons-seed` | Feature 2 (bootstrap-pipeline) | +| `cpt-insightspec-ir-component-persons-sync` | Feature 2 (bootstrap-pipeline) | | `cpt-insightspec-ir-component-matching-engine` | Feature 3 (matching-engine) | -| `cpt-insightspec-ir-dbtable-aliases` | Feature 1 (initial-seed) | +| `cpt-insightspec-ir-component-operator-resolution-api` | Feature 4 (manual-resolution) | +| `cpt-insightspec-ir-dbtable-aliases` | Feature 1 (initial-seed, legacy) | | `cpt-insightspec-ir-dbtable-identity-inputs` | Feature 2 (bootstrap-pipeline) | | `cpt-insightspec-ir-dbtable-persons-mariadb` | Feature 1 (initial-seed) | -| `cpt-insightspec-ir-dbtable-unmapped` | Feature 2 (bootstrap-pipeline) | -| `cpt-insightspec-ir-dbtable-conflicts` | Feature 2 (bootstrap-pipeline) | -| `cpt-insightspec-ir-dbtable-match-rules` | Feature 3 (matching-engine) | -| `cpt-insightspec-ir-dbtable-merge-audits` | Late phase (future) | -| `cpt-insightspec-ir-dbtable-alias-gdpr-deleted` | Late phase (future) | -| `cpt-insightspec-ir-seq-resolve-hot` | Feature 1 (initial-seed) | -| `cpt-insightspec-ir-seq-bootstrap-processing` | Feature 2 (bootstrap-pipeline) | -| `cpt-insightspec-ir-seq-merge` | Late phase (future) | -| `cpt-insightspec-ir-interface-api` | Feature 1 (initial-seed) | +| `cpt-insightspec-ir-dbtable-account-person-map` | Feature 1 (initial-seed) | +| `cpt-insightspec-ir-seq-build-resolution` | Feature 1 (initial-seed) | +| `cpt-insightspec-ir-seq-seed-run` | Feature 2 (bootstrap-pipeline) | +| `cpt-insightspec-ir-seq-operator-correction` | Feature 4 (manual-resolution) | +| `cpt-insightspec-ir-interface-api-v2` | Feature 4 (manual-resolution) | +| future tables (`match_rules`, `unmapped`, `conflicts`, `merge_audits`, `alias_gdpr_deleted`) | Future (DESIGN §3.7 summaries) | | PRD Requirement | Feature | |---|---| -| `cpt-ir-fr-seed-aliases` (p1) | Feature 1 | -| `cpt-ir-fr-resolve-alias` (p1) | Feature 1 | -| `cpt-ir-fr-batch-resolve` (p1) | Feature 1 | +| `cpt-ir-fr-seed-aliases` (p1, superseded — legacy dbt seeds) | Feature 1 | +| `cpt-ir-fr-resolve-alias` (p1, superseded) | Feature 1 | +| `cpt-ir-fr-batch-resolve` (p1, superseded) | Feature 1 | | `cpt-ir-fr-tenant-isolation` (p1) | Feature 1 | +| `cpt-ir-fr-persons-history` (p1) | Feature 1 | +| `cpt-ir-fr-persons-initial-seed` (p1) | Feature 1 | | `cpt-ir-fr-accept-bootstrap-inputs` (p1) | Feature 2 | | `cpt-ir-fr-bootstrap-incremental` (p1) | Feature 2 | -| `cpt-ir-fr-normalize-aliases` (p1) | Feature 2 | -| `cpt-ir-fr-create-alias-exact` (p1) | Feature 2 | -| `cpt-ir-fr-route-unmapped` (p1) | Feature 2 | -| `cpt-ir-fr-track-observations` (p1) | Feature 2 | -| `cpt-ir-fr-bootstrap-idempotent` (p1) | Feature 2 | -| `cpt-ir-fr-alias-conflict-detection` (p2) | Feature 2 | -| `cpt-ir-fr-auto-resolve-unmapped` (p2) | Feature 2 | +| `cpt-ir-fr-normalize-aliases` (p1, superseded) | Feature 2 | +| `cpt-ir-fr-create-alias-exact` (p1, superseded) | Feature 2 | +| `cpt-ir-fr-route-unmapped` (p3, future) | Feature 3 | +| `cpt-ir-fr-track-observations` (p1, superseded) | Feature 2 | +| `cpt-ir-fr-bootstrap-idempotent` (p1, superseded) | Feature 2 | +| `cpt-ir-fr-alias-conflict-detection` (p2, superseded) | Feature 4 (derived queue) | +| `cpt-ir-fr-auto-resolve-unmapped` (p3, future) | Feature 3 | | `cpt-ir-fr-configurable-rules` (p2) | Feature 3 | | `cpt-ir-fr-three-phase-matching` (p2) | Feature 3 | | `cpt-ir-fr-no-fuzzy-autolink` (p2) | Feature 3 | -| `cpt-ir-fr-unmapped-management` (p2) | Feature 3 | -| `cpt-ir-fr-manual-alias-crud` (p2) | Feature 3 | -| `cpt-ir-fr-merge` (p3) | Late phase | -| `cpt-ir-fr-split` (p3) | Late phase | -| `cpt-ir-fr-merge-audit` (p3) | Late phase | +| `cpt-ir-fr-unmapped-management` (p2, superseded) | Feature 3 → Feature 4 | +| `cpt-ir-fr-manual-alias-crud` (p2, superseded) | Feature 4 | +| `cpt-ir-fr-merge-v2` (p1) | Feature 4 | +| `cpt-ir-fr-split-v2` (p1) | Feature 4 | +| `cpt-ir-fr-operator-bind` (p1) | Feature 4 | +| `cpt-ir-fr-operator-exclude` (p1) | Feature 4 | +| `cpt-ir-fr-review-queue` (p1) | Feature 4 | +| `cpt-ir-fr-correction-durability` (p1) | Feature 4 | +| `cpt-ir-fr-merge-audit-v2` (p1) | Feature 4 | +| `cpt-ir-fr-idempotent-mutations-v2` (p1) | Feature 4 | +| `cpt-ir-fr-binding-history` (p2) | Feature 4 | | `cpt-ir-fr-gdpr-purge` (p3) | Late phase | -| `cpt-ir-fr-idempotent-mutations` (p3) | Late phase | diff --git a/docs/domain/identity-resolution/specs/DESIGN.md b/docs/domain/identity-resolution/specs/DESIGN.md index 9691132cb..4f251b9b6 100644 --- a/docs/domain/identity-resolution/specs/DESIGN.md +++ b/docs/domain/identity-resolution/specs/DESIGN.md @@ -21,15 +21,15 @@ - [4. Additional Context](#4-additional-context) - [4.1 Min-Propagation Algorithm (ClickHouse-Native)](#41-min-propagation-algorithm-clickhouse-native) - [4.2 Matching Engine Phases](#42-matching-engine-phases) - - [4.3 Merge and Split Operations](#43-merge-and-split-operations) - - [4.4 ClickHouse Integration Patterns](#44-clickhouse-integration-patterns) + - [4.3 Operator Corrections (Merge / Split / Bind / Exclude)](#43-operator-corrections-merge--split--bind--exclude) + - [4.4 Analytics Integration (mirror + resolve macro)](#44-analytics-integration-mirror--resolve-macro) - [4.5 End-to-End Walkthrough: Anna Ivanova](#45-end-to-end-walkthrough-anna-ivanova) - [4.6 End-to-End Walkthrough: Andrei Sokolov (Min-Propagation)](#46-end-to-end-walkthrough-andrei-sokolov-min-propagation) - [4.7 Deployment](#47-deployment) - [4.8 Operational Considerations](#48-operational-considerations) - [5. Implementation Recommendations](#5-implementation-recommendations) - - [REC-IR-01: ClickHouse atomicity for merge/split (Phase 3+)](#rec-ir-01-clickhouse-atomicity-for-mergesplit-phase-3) - - [REC-IR-02: Incremental watermark for identity inputs (Phase 2)](#rec-ir-02-incremental-watermark-for-identity-inputs-phase-2) + - [REC-IR-01: ClickHouse atomicity for merge/split — SUPERSEDED](#rec-ir-01-clickhouse-atomicity-for-mergesplit--superseded) + - [REC-IR-02: Incremental watermark for identity inputs (open)](#rec-ir-02-incremental-watermark-for-identity-inputs-open) - [REC-IR-03: Shared unmapped table for all domains — RESOLVED](#rec-ir-03-shared-unmapped-table-for-all-domains--resolved) - [REC-IR-04: Temporary tenant and source ID derivation via sipHash128 (Phase 1)](#rec-ir-04-temporary-tenant-and-source-id-derivation-via-siphash128-phase-1) - [REC-IR-05: Explicit canonical id emission per connector (Phase 2)](#rec-ir-05-explicit-canonical-id-emission-per-connector-phase-2) @@ -39,21 +39,27 @@ - [ ] `p3` - **ID**: `cpt-insightspec-ir-design-identity-resolution` -> Version 2.0 — April 2026 -> Rewrite: domain-split (identity-resolution only), ClickHouse-native, PR #55 naming conventions +> Version 3.0 — August 2026 +> Sync with the implemented journal architecture (append-only `persons` observation log per ADR-0002; operator corrections per ADR-0003; reviewed design in constructorfabric/insight#2180). The v2.0 ClickHouse-native alias/matching architecture is retained as explicitly-marked future material. --- ## 1. Architecture Overview ### 1.1 Architectural Vision -Identity Resolution maps disparate identity signals — emails, usernames, employee IDs, platform-specific handles — from all connected source systems into canonical person records stored in the Person domain. It operates as the bridge between raw connector data and a unified person model: connectors emit alias observations into the `identity_inputs` table; the BootstrapJob processes those observations into the `aliases` table; and the ResolutionService exposes a query API for downstream consumers to resolve any alias to a `person_id`. +Identity Resolution maps disparate identity signals — emails, usernames, employee IDs, platform-specific handles — from all connected source systems to canonical persons. It answers one question for every downstream consumer: "which person does this source account belong to?" -The analytical tables of this domain are ClickHouse-native — `identity_inputs`, `aliases`, `match_rules`, `unmapped`, `conflicts`, `merge_audits`, `alias_gdpr_deleted` all reside in ClickHouse. Merge/split atomicity, previously achieved via RDBMS transactions, is handled through idempotent operations and audit-trail patterns in ClickHouse (snapshot-before/after in `merge_audits`). SCD Type 2 history for org units is managed by dbt macros in the org-chart domain and is out of scope for this design. +The implemented architecture is a **journal model** with one source of truth and derived read paths: -In addition to the analytical ClickHouse store, this domain owns one MariaDB table — `persons` (see §3.7) — which records the history of identity-field observations per person in a CRUD-friendly, transactional store. The Person domain reads from it to build its golden record; it is initialised by a one-shot seed from `identity_inputs` (see ADR-0002) and maintained by operator edits and the Bootstrap Pipeline going forward. +- Connectors emit identity observations into ClickHouse `identity_inputs` (evidence; unchanged from v2.0). +- The **persons-seed** (a subcommand of the Rust `identity-resolution` service, run as a scheduled job) folds new observations into the append-only MariaDB **`persons`** observation log: reuse an account's existing binding; else link the group to the person its e-mail already maps to (`LinkedByEmail`); else mint a new person; accounts without e-mail are skipped. It never merges two existing persons. Known gap: when accounts of one e-mail group are bound to *different* persons, the current seed collapses the group onto the first binding and can thereby silently re-derive a binding — the hardening that makes it respect per-account (in particular operator-authored) bindings ships with the manual-resolution feature. +- **Operator corrections** (merge, detach/split, bind, exclude — ADR-0003, reviewed design in constructorfabric/insight#2180) are appended to the same `persons` journal as binding observations authored by a real operator UUID — the same currency the seed reads, so durability requires no parallel store; the seed hardening above closes the one path that could overwrite them. +- The service's **persons-sync** worker republishes the journal into ClickHouse (`identity.identity_persons`) via an atomic table swap; the dbt `resolve_person_id` macro resolves `person_id` at build time from that mirror. The v1 macro resolves **by e-mail only** (latest `value_type='email'` observation per normalized e-mail); the manual-resolution feature upgrades it to **account-first with e-mail fallback** (§4.4) — required for corrections, which are `value_type='id'` bindings, to reach gold. +- `account_person_map` (MariaDB) is a derived SCD2 cache rebuilt from the journal — never a source of truth. -This domain is deliberately narrow: it owns alias-to-person mapping and the `persons` identity-attribute history table. Golden record assembly, person-level conflict detection, availability, and org hierarchy all belong to the person and org-chart domains. Identity Resolution produces `aliases` rows and `persons` observations; the person domain consumes them. +The v2.0 ClickHouse-native plan — a resolution store in `aliases` plus `match_rules`/`unmapped`/`conflicts`/`merge_audits` tables operated by a BootstrapJob/MatchingEngine/ResolutionService pipeline — **was not built**. Its matching-engine material is retained as future direction (§4.1, §4.2, §3.7 future tables); its snapshot-based merge/split mechanism is superseded by the journal-based operator flow of ADR-0003 (§4.3). + +This domain is deliberately narrow: it owns the account-to-person binding (the `persons` journal, its seed and sync, and the correction API) plus the `identity_inputs` evidence contract. Golden record assembly, person-level conflict detection, availability, and org hierarchy belong to the person and org-chart domains, which consume `persons` observations. ### 1.2 Architecture Drivers @@ -62,77 +68,83 @@ This domain is deliberately narrow: it owns alias-to-person mapping and the `per | Requirement | Design Response | |---|---| -| Collect alias observations from all connectors | `identity_inputs` table — each connector writes one row per changed alias value | -| Resolve aliases to `person_id` | `ResolutionService` — hot-path lookup in `aliases`; cold-path evaluation via `match_rules` | -| Seed aliases from HR/directory sources | `BootstrapJob` reads `identity_inputs` since last run, creates/updates `aliases` rows | -| Configure matching rules per tenant | `match_rules` table — rule_type, weight, phase, is_enabled; operator-editable via API | -| Quarantine unresolvable aliases | `unmapped` table — pending queue with operator resolution workflow | -| Detect alias-level conflicts | `ConflictDetector` — writes to `conflicts` when same alias maps to multiple persons | -| Merge two person alias sets | `ResolutionService.merge()` — reassigns aliases + snapshot in `merge_audits` | -| Split a wrongly merged alias set | `ResolutionService.split()` — restores from `merge_audits.snapshot_before` | -| GDPR hard erasure of alias data | `ResolutionService.purge()` — moves alias rows to `alias_gdpr_deleted`, removes from `aliases` | +| Collect identity observations from all connectors | `identity_inputs` table — each connector writes one row per changed identity value (implemented) | +| Bind source accounts to persons | persons-seed fold into the append-only `persons` journal: reuse binding / link by e-mail / mint / skip (implemented; divergent-group hardening ships with manual resolution) | +| Resolve `person_id` for analytics | persons-sync mirror + dbt `resolve_person_id` macro at build time (implemented — v1 resolves by e-mail only; account-first upgrade with e-mail fallback ships with manual resolution, §4.4) | +| Person lookups for backend consumers | `identity-resolution` service read API over `persons` (implemented; see component spec) | +| Correct wrong groupings (merge / split / bind / exclude) | Operator correction verbs appending to the `persons` journal — ADR-0003, design in #2180 (planned, this iteration; includes the resolver upgrade and seed hardening) | +| Surface what needs operator attention | Review queue derived from `identity_inputs` evidence joined with current `persons` bindings (pending, contested, no-evidence accounts) + resolution-rate shares; no status tables (planned, this iteration) | +| Never link ambiguously | Planned with manual resolution: an e-mail claimed by more than one person stops being linking evidence — no auto-link, surfaced for review (today the divergent-group collapse can pick a winner silently) | +| Configure matching rules per tenant | `match_rules` + MatchingEngine (future — not built; §4.2) | +| GDPR hard erasure of identity data | Purge flow with `alias_gdpr_deleted` archive (future — not built) | #### NFR Allocation | NFR ID | NFR Summary | Allocated To | Design Response | Verification Approach | |---|---|---|---|---| -| `cpt-insightspec-ir-nfr-alias-lookup-latency` | Alias lookup < 50 ms p99 | `aliases` table + ResolutionService | Direct ClickHouse lookup on ordered key `(insight_tenant_id, value_type, value)` | Benchmark with 10K/s lookup rate | -| `cpt-insightspec-ir-nfr-bootstrap-throughput` | Bootstrap processes 100K inputs/run | BootstrapJob | Batch processing with configurable chunk size; ClickHouse bulk inserts | Load test with 100K identity_inputs rows | -| `cpt-insightspec-ir-nfr-idempotency` | Bootstrap re-runs produce no duplicates | BootstrapJob | Natural key dedup on `(insight_tenant_id, value_type, value, insight_source_id)` in `aliases` | Run bootstrap 3x on same data; verify row counts | -| `cpt-insightspec-ir-nfr-no-fuzzy-autolink` | Zero false-positive merges from fuzzy rules | MatchingEngine | Fuzzy rules disabled by default; never trigger auto-link | Audit test: enable fuzzy; assert no auto-link | -| `cpt-insightspec-ir-nfr-tenant-isolation` | No cross-tenant data leaks | All tables | `insight_tenant_id` as first column in all ORDER BY keys | Cross-tenant resolution query returns empty | -| `cpt-insightspec-ir-nfr-gdpr-erasure` | Hard purge within SLA | ResolutionService.purge() | Move to `alias_gdpr_deleted`; remove from `aliases`; propagate `is_deleted` | Purge test; verify alias no longer resolvable | -| `cpt-insightspec-ir-nfr-merge-safety` | Merge/split operations are auditable and reversible | ResolutionService + merge_audits | Full snapshot_before/snapshot_after; idempotent operations | Merge + split round-trip test | +| `cpt-ir-nfr-alias-lookup-latency` | Person lookup < 50 ms p99 | `persons` indexes + identity-resolution service | Hot-path index `idx_value_id (insight_tenant_id, value_type, value_id)`; bulk analytical resolution happens in dbt at build time, off the request path | Benchmark service lookups under sustained load | +| `cpt-ir-nfr-bootstrap-throughput` | Seed processes 100K inputs/run | persons-seed | Batched ClickHouse reads + batched `INSERT IGNORE` writes | Load test with 100K `identity_inputs` rows | +| `cpt-ir-nfr-bootstrap-idempotency` | Seed re-runs produce no duplicates | persons-seed | `uq_person_observation` natural-key UNIQUE (keyed by `created_at`, migration 004) + `INSERT IGNORE`; known-account rule keeps bindings stable | Run seed 3x on same data; verify row counts | +| `cpt-ir-nfr-no-fuzzy-autolink` | Zero false-positive auto-merges | Structural (no auto-merge path exists); future MatchingEngine | The seed has no branch that merges existing persons; fuzzy rules (future) never auto-link | Seed re-run tests; audit test when matcher ships | +| `cpt-ir-nfr-tenant-isolation` | No cross-tenant data leaks | All tables + service auth | `insight_tenant_id` scoping on keys and journal queries. **Known gap**: the seed's evidence read applies no tenant predicate (hashed producer-side tenant ids — see the note in the reader); single-tenant deployments only until the filter is restored | Cross-tenant resolution query returns empty | +| `cpt-ir-nfr-gdpr-erasure` | Hard purge within SLA | Future purge flow | Not implemented; see §3.7 future tables | Deferred with the purge flow | +| `cpt-ir-nfr-merge-reversibility` | Corrections are auditable and reversible | `persons` journal (ADR-0003) | Append-only history with author + reason on every row; undo = counter-action; full pre-correction state always reconstructible | Correction + counter-action round-trip test | + +**Key decision records** (drivers of this architecture): + +- `cpt-ir-adr-stable-person-id` — [ADR-0002](ADR/0002-stable-person-id-via-persons-observations.md): stable `person_id` via the append-only `persons` observation journal; three-mode seed binding; known-account rule. +- `cpt-ir-adr-operator-decisions-as-observations` — [ADR-0003](ADR/0003-operator-decisions-as-persons-observations.md): operator corrections are ordinary journal observations; no separate decision store in v1; snapshot-based merge/split superseded. +- `cpt-ir-adr-shared-unmapped` — [person-domain ADR-0001](../../person/specs/ADR/0001-shared-unmapped-table.md): one shared operator queue across identity and person domains (realised in v1 as the derived review queue; a shared persistent store may return with the matcher). + +**Implemented PRD requirements anchored by this design**: `cpt-ir-fr-accept-bootstrap-inputs` (the `identity_inputs` evidence intake, §3.7) and `cpt-ir-interface-analytics-resolution` (the mirror + macro analytics read path, §4.4). Incremental seed processing (`cpt-ir-fr-bootstrap-incremental`) is open: each run folds the full evidence set (REC-IR-02). ### 1.3 Architecture Layers - [ ] `p3` - **ID**: `cpt-insightspec-ir-tech-layers` -``` +```text ┌──────────────────────────────────────────────────────────────────────────────┐ │ IDENTITY RESOLUTION DOMAIN │ ├──────────────────────────────────────────────────────────────────────────────┤ │ │ -│ CONNECTORS BOOTSTRAP INPUTS ALIASES │ -│ ────────── ──────────────── ─────── │ -│ │ -│ ┌──────────┐ ┌────────────────────┐ ┌──────────────────┐ │ -│ │ GitLab │────▶│ │ │ │ │ -│ │ GitHub │ │ │ │ aliases │ │ -│ │ Jira │ │ identity_inputs │────▶│ (resolved) │───────┐ │ -│ │ BambooHR │ │ (alias signals) │ │ │ │ │ -│ │ Zoom │ │ │ └──────────────────┘ │ │ -│ │ M365 │────▶│ │ │ │ │ -│ └──────────┘ └────────────────────┘ │ │ │ -│ │ ┌────▼─────┐ │ │ -│ │ │unmapped │ │ │ -│ ┌────────▼────────┐ │(pending) │ │ │ -│ │ BootstrapJob │ └──────────┘ │ │ -│ │ MatchingEngine │ │ │ -│ │ ConflictDetect │ ┌──────────────┐ │ │ -│ └─────────────────┘ │ conflicts │ │ │ -│ │ merge_audits│ │ │ -│ API Layer └──────────────┘ │ │ -│ ┌─────────────────┐ │ │ -│ │ResolutionService│◀─── POST /api/identity/resolve │ │ -│ │ /api/identity/ │ │ │ -│ └─────────────────┘ │ │ -│ │ │ -│ ──── Cross-Domain ────────────────────────────────────────────────── │ │ -│ │ │ -│ aliases.person_id ──FK──▶ persons.person_id (Person Domain) ◀───┘ │ +│ CONNECTORS EVIDENCE JOURNAL (truth) │ +│ ────────── ──────── ─────────────── │ │ │ +│ ┌──────────┐ ┌────────────────────┐ ┌────────────────────────┐ │ +│ │ GitLab │─────▶│ │ │ persons (MariaDB) │ │ +│ │ GitHub │ │ identity_inputs │ │ append-only │ │ +│ │ Jira │ │ (ClickHouse) │ │ observation log │ │ +│ │ BambooHR │─────▶│ │ │ author + reason │ │ +│ │ ... │ └─────────┬──────────┘ └───▲──────────┬─────────┘ │ +│ └──────────┘ │ reads │ appends │ │ +│ ┌─────────▼──────────┐ │ │ │ +│ │ persons-seed │──────────┘ │ │ +│ │ (3-mode fold, │ bindings │ │ +│ │ never rebinds) │ │ │ +│ └────────────────────┘ │ │ +│ │ │ +│ ┌───────────────────┐ appends decisions │ │ +│ │ Operator API │──────────────────────────────────────▶ │ │ +│ │ bind/merge/detach │ (ADR-0003, planned) │ │ +│ │ /exclude + queue │ │ │ +│ └───────────────────┘ │ │ +│ ▼ │ +│ DERIVED READ PATHS: account_person_map (SCD2 cache, MariaDB) │ +│ identity.identity_persons (CH mirror, persons-sync) │ +│ dbt resolve_person_id macro → gold metrics │ +│ identity-resolution service read API │ └──────────────────────────────────────────────────────────────────────────────┘ ``` | Layer | Responsibility | Technology | |---|---|---| -| Ingestion | Connectors write alias observations to `identity_inputs` | ClickHouse (MergeTree) | -| Processing | BootstrapJob resolves inputs into aliases; MatchingEngine evaluates rules | Argo Workflows (Phase 2+, not yet built) | -| Storage (analytical) | aliases, match_rules, unmapped, conflicts, merge_audits | ClickHouse (ReplacingMergeTree, MergeTree) | -| Storage (identity history) | `persons` (observation history), `account_person_map` (stable account→person binding) | MariaDB (InnoDB) | -| API | Person lookup + migration runner | Rust (`identity-resolution` service, axum) | -| Cross-domain | `aliases.person_id` references `persons.person_id` in person domain | Logical FK (no physical constraint) | +| Ingestion (evidence) | Connectors write identity observations to `identity_inputs` | ClickHouse (MergeTree) | +| Binding (automation) | persons-seed folds new observations into `persons` (reuse / link-by-e-mail / mint / skip); scheduled runs, guarded and journaled in `operations` | Rust (`seed` subcommand of the identity-resolution service) | +| Corrections (human) | Operator verbs append binding observations to `persons` (ADR-0003; design #2180) | Rust (identity-resolution service, planned endpoints) | +| Storage (journal) | `persons` (append-only observation log — source of truth), `account_person_map` (derived SCD2 cache), `operations` (admin-operation journal) | MariaDB (InnoDB) | +| Mirror + analytics | persons-sync republishes the journal to `identity.identity_persons`; dbt `resolve_person_id` macro resolves at build time | ClickHouse + dbt | +| API | Person lookup (`/v1/profiles`, `/v1/visible-persons`) + planned operator resolution endpoints | Rust (`identity-resolution` service, axum) | +| Future | MatchingEngine over `match_rules` with confidence-scored proposals | Not built (§4.2) | --- @@ -147,13 +159,22 @@ This domain is deliberately narrow: it owns alias-to-person mapping and the `per Identity resolution is fundamentally an alias mapping problem. Every identity signal from every source system is an alias — an `(value_type, value)` pair that maps to a person. The architecture treats all signals uniformly: an email, a username, an employee ID, and a platform-specific handle are all aliases with different types. This uniform treatment simplifies the resolution pipeline and makes adding new alias types a configuration change, not an architecture change. -#### Storage Split — ClickHouse Analytical + MariaDB Identity History +#### Storage Split — ClickHouse Evidence + MariaDB Journal + +- [ ] `p2` - **ID**: `cpt-insightspec-ir-principle-ch-native-v2` + +Evidence is analytical, decisions are transactional. Connector observations (`identity_inputs`) and the read-side mirror of the journal (`identity.identity_persons`) reside in ClickHouse — event-stream-scale, append-heavy, consumed by dbt at build time. The `persons` observation journal, its derived `account_person_map` SCD2 cache, and the `operations` admin journal live in MariaDB and are owned by the Rust `identity-resolution` service — the binding decisions need transactional writes, row-level operator access, and audit-friendly history, and the dataset is tenant-metadata-scale. See §3.7 and ADR-0002 / ADR-0003; migrations are service-owned per ADR-0006. + +(v1 of this principle placed the whole resolution store — `aliases`, `match_rules`, `unmapped`, `conflicts`, `merge_audits` — in ClickHouse; that architecture was not built and its tables remain future material, see §3.7.) + -- [ ] `p2` - **ID**: `cpt-insightspec-ir-principle-ch-native` +#### Append-Only Journal — Corrections Are New Facts -Analytical identity-resolution tables (`identity_inputs`, `aliases`, `match_rules`, `unmapped`, `conflicts`, `merge_audits`, `alias_gdpr_deleted`) reside in ClickHouse. ClickHouse's ReplacingMergeTree provides last-writer-wins semantics for alias updates; merge/split safety is achieved through idempotent snapshot-based operations with full audit trails rather than ACID transactions. +- [ ] `p1` - **ID**: `cpt-insightspec-ir-principle-append-only-journal` -Identity-attribute history (`persons`) and the stable source-account-to-`person_id` binding (`account_person_map`) live in MariaDB and are owned by the Rust `identity-resolution` service — transactional semantics and CRUD-friendly access are required there, and the dataset is tenant-metadata-scale rather than event-stream-scale. See §3.7 and ADR-0002 (stable `person_id` via account-to-person mapping) / ADR-0006 (service-owned migrations). +The `persons` journal is never updated or deleted; every change — automated binding or operator correction — is a new appended observation carrying its author and reason. Current state is derived (latest binding per account wins); history is always intact. Operator decisions are written in the same currency the automation reads, so no parallel override store is needed; their durability rests on one seed invariant — *an account's existing binding is reused, never re-derived* — which holds today for consistently-bound groups and is extended to divergent groups by the manual-resolution seed hardening. Undo is a counter-action, never a destructive revert. + +One deliberate exception lives outside the decision journal: GDPR right-to-erasure. Append-only governs **identity decisions**; lawful erasure of stored identity values is an explicit administrative operation (future purge flow — see the purge use case in the PRD) whose execution is recorded in the `operations` journal. Erasure is not a decision about who someone is and never flows through binding semantics. #### Domain Isolation @@ -167,25 +188,25 @@ Identity resolution owns alias-to-person mapping and the `persons` / `account_pe - [ ] `p2` - **ID**: `cpt-insightspec-ir-principle-fail-safe` -Unknown identities are quarantined in the `unmapped` table, never auto-linked below the confidence threshold. Pipeline execution continues; records with unresolved `person_id` are visible in analytics as `UNRESOLVED` but do not corrupt resolved data. The system never blocks data flow due to unresolved identities. +Contested identities must never be decided silently. Today the seed auto-links a new account only when its e-mail unambiguously maps to one existing person (`LinkedByEmail`); the manual-resolution feature extends this principle to the contested cases: an e-mail claimed by more than one person stops being linking evidence (no auto-link, surfaced for operator review), and divergent-group collapse is replaced by surfacing. Activity of unresolved accounts appears in analytics with an honest NULL `person_id` — absent rather than misattributed — and never blocks the pipeline. The review queue is **derived from two sources joined on the account key** — the `identity_inputs` evidence (every observed account, including e-mail-less ones) and the current bindings in `persons` — never a status table: an item disappears the moment a decision removes its condition. Accounts with no usable identity evidence are skipped by binding automation but stay visible as no-evidence queue items — unresolved is a first-class, countable state, never hidden. (ADR-0002's `reason='pending-iresolution'` quarantine marker is reserved vocabulary for this flow; the current seed does not yet emit it.) #### Conservative Matching - [ ] `p2` - **ID**: `cpt-insightspec-ir-principle-conservative-matching` -Deterministic matching first (exact email, exact HR ID). Fuzzy matching is opt-in per rule and **never triggers auto-link** — always routes to human review. This decision is based on production experience: fuzzy name matching produced false-positive merges and was removed from the default ruleset. +Deterministic matching first (exact email, exact HR ID). Fuzzy matching is opt-in per rule and **never triggers auto-link** — always routes to human review. Rationale: false-positive merges are the costliest failure mode of identity systems — fuzzy name matching is a known source of them, so it may suggest but never link. ### 2.2 Constraints -#### Storage Split: Analytical in ClickHouse, Identity-History in MariaDB +#### Storage Split: Evidence in ClickHouse, Journal in MariaDB -- [ ] `p2` - **ID**: `cpt-insightspec-ir-constraint-storage-split` +- [ ] `p2` - **ID**: `cpt-insightspec-ir-constraint-storage-split-v2` -Analytical tables (`identity_inputs`, `aliases`, `match_rules`, `unmapped`, `conflicts`, `merge_audits`, `alias_gdpr_deleted`) are stored in ClickHouse — optimised for read-heavy analytical queries; merge/split atomicity is achieved through idempotent snapshot-based operations, not ACID transactions. +ClickHouse holds the evidence and read-side tables: `identity_inputs` (connector observations) and `identity.identity_persons` (the persons-sync mirror of the journal consumed by dbt). The legacy `identity.aliases` table also physically exists in ClickHouse but is not part of the resolution path (see §3.7). -The identity-attribute history table (`persons`) is stored in MariaDB (see §3.7) — a CRUD-friendly transactional store is the right fit for row-level operator edits, audit trails, and the backend APIs that read it. Schema is owned and applied by the `identity-resolution` Rust service itself via its embedded SeaORM `Migrator` (see [ADR-0006](../../ingestion/specs/ADR/0006-service-owned-migrations.md)). +The journal tables — `persons` (append-only observation log), `account_person_map` (derived SCD2 cache), `operations` (admin-operation journal) — are stored in MariaDB (see §3.7): a transactional store is the right fit for binding decisions, row-level operator corrections, and audit history. Schema is owned and applied by the `identity-resolution` Rust service itself via its embedded SeaORM `Migrator` (see [ADR-0006](../../ingestion/specs/ADR/0006-service-owned-migrations.md)). #### PR #55 Naming Conventions @@ -214,8 +235,9 @@ All tables and columns follow the PR #55 glossary naming conventions: - [ ] `p2` - **ID**: `cpt-insightspec-ir-constraint-domain-boundary` Identity resolution owns: -- All `identity_inputs` / `aliases` / matching / conflict / merge tables in ClickHouse (§2.1, §3.x) -- The MariaDB `persons` identity-attribute history table (§3.7) and its one-shot seed (ADR-0002) +- The `identity_inputs` evidence contract and table in ClickHouse (§3.7), and the `identity.identity_persons` mirror published by persons-sync +- The MariaDB `persons` journal, `account_person_map` cache, and `operations` journal (§3.7), the persons-seed that populates them (ADR-0002), and the operator correction flow that appends to them (ADR-0003) +- The legacy ClickHouse `aliases` table and the future matching/conflict/merge tables, if and when built (§3.7 future tables) Identity resolution does NOT own or write to: - The Person-domain **golden record** (derived from `persons` observations by the person domain) @@ -231,7 +253,7 @@ The person domain reads `persons` observations to build its golden record; ident - [ ] `p2` - **ID**: `cpt-insightspec-ir-constraint-no-fuzzy-autolink` -Fuzzy matching rules (Jaro-Winkler, Soundex) MUST NEVER trigger automatic alias creation. They may only generate suggestions for human review. This constraint is non-negotiable after production incidents with false-positive merges. +Fuzzy matching rules (Jaro-Winkler, Soundex) MUST NEVER trigger automatic alias creation. They may only generate suggestions for human review. This constraint is non-negotiable: false-positive merges corrupt attribution and are costly to unwind. #### Half-Open Temporal Intervals @@ -247,222 +269,224 @@ All temporal ranges use `[effective_from, effective_to)` half-open intervals. `e ### 3.1 Domain Model -**Technology**: ClickHouse +**Technology**: ClickHouse (evidence + mirror) and MariaDB (journal + caches) -**Core Entities**: +**Core Entities** (implemented): -| Entity | Description | Key | -|---|---|---| -| `identity_inputs` | Alias observations from connectors — one row per changed alias value per source | `(insight_tenant_id, insight_source_id, value_type, value)` | -| `aliases` | Resolved alias-to-person mapping — many-to-one from source identifiers to `person_id` | `(insight_tenant_id, value_type, value, insight_source_id)` | -| `match_rules` | Configurable matching rules for the MatchingEngine | `id` | -| `unmapped` | Unresolvable alias queue — pending for operator resolution | `id` | -| `conflicts` | Alias-level attribute disagreements between sources | `id` | -| `merge_audits` | Full snapshot audit trail for merge/split operations | `id` | -| `alias_gdpr_deleted` | GDPR erasure archive — moved from `aliases` on purge | `id` | +| Entity | Store | Description | Key | +|---|---|---|---| +| `identity_inputs` | ClickHouse | Identity observations from connectors — one row per changed value per source account | `(insight_tenant_id, insight_source_id, value_type, value)` | +| `persons` | MariaDB | Append-only observation journal — the source of truth for the account→person binding; every row carries `author_person_id` and `reason` | natural key ending in `created_at` (see §3.7) | +| `account_person_map` | MariaDB | Derived SCD2 cache of the binding, rebuilt from `persons` | `(tenant, source_type, source_id, account, valid_from)` | +| `operations` | MariaDB | Journal of admin operations (seed runs, future operator corrections) — request, summary, lifecycle | `id` | +| `identity.identity_persons` | ClickHouse | Read-side mirror of `persons`, republished atomically by persons-sync; consumed by the dbt `resolve_person_id` macro | mirror of `persons` | + +**Future entities** (designed, not built — see §3.7 future tables): `aliases` as a resolution store, `match_rules`, `unmapped`, `conflicts`, `merge_audits`, `alias_gdpr_deleted`. **Relationships**: -- `identity_inputs` → (processed by BootstrapJob) → `aliases` -- `aliases.person_id` → `persons.person_id` (logical FK — stable identity, not the auto-increment row PK) -- `unmapped.resolved_person_id` → `persons.person_id` (logical FK) -- `conflicts.person_id` → `persons.person_id` (logical FK) -- `merge_audits.target_person_id` → `persons.person_id` (logical FK) +- `identity_inputs` → (folded by persons-seed, ADR-0002) → `persons` +- `persons` → (deterministic rebuild) → `account_person_map` +- `persons` → (persons-sync atomic swap) → `identity.identity_persons` → (dbt `resolve_person_id`) → gold `person_id` columns +- Operator corrections (ADR-0003) → append to `persons`; payloads journaled in `operations` +- `persons.person_id` is the stable cross-domain join key (random UUIDv7, never re-derived; ADR-0002) ### 3.2 Component Model +```text +┌────────────────────────────────────────────────────────────────┐ +│ Identity Resolution │ +│ │ +│ ┌────────────────┐ ┌──────────────────────────────┐ │ +│ │ persons-seed │───────▶│ │ │ +│ │ (scheduled │appends │ persons journal (MariaDB) │ │ +│ │ 3-mode fold) │ │ │ │ +│ └────────────────┘ └──▲──────────┬────────────────┘ │ +│ │ │ │ +│ ┌─────────────────────────┐ │ │ ┌──────────────┐ │ +│ │ Operator Resolution API │──┘ └─▶│ persons-sync │ │ +│ │ bind/merge/detach/ │appends reads │ (CH mirror) │ │ +│ │ exclude + review queue │ └──────────────┘ │ +│ └─────────────────────────┘ │ +│ │ +│ ┌─────────────────────────┐ ┌───────────────────────────┐ │ +│ │ Identity Read API │ │ MatchingEngine (future) │ │ +│ │ /v1/profiles etc. │ │ match_rules + proposals │ │ +│ └─────────────────────────┘ └───────────────────────────┘ │ +└────────────────────────────────────────────────────────────────┘ ``` -┌───────────────────────────────────────────────────────────┐ -│ Identity Resolution │ -│ │ -│ ┌──────────────┐ ┌────────────────┐ │ -│ │BootstrapJob │───▶│MatchingEngine │ │ -│ │(scheduled) │ │(rule evaluator)│ │ -│ └──────┬───────┘ └───────┬────────┘ │ -│ │ │ │ -│ ▼ ▼ │ -│ ┌──────────────────────────────────────┐ │ -│ │ ResolutionService (API) │ │ -│ │ POST /resolve POST /merge etc. │ │ -│ └──────────────────┬───────────────────┘ │ -│ │ │ -│ ┌───────────▼───────────┐ │ -│ │ ConflictDetector │ │ -│ │ (alias conflicts) │ │ -│ └───────────────────────┘ │ -└───────────────────────────────────────────────────────────┘ -``` -#### BootstrapJob +#### PersonsSeed + +- [x] `p1` - **ID**: `cpt-insightspec-ir-component-persons-seed` + +##### Why this component exists + +Folds new connector observations from `identity_inputs` into the `persons` journal. Without it, no account is ever bound to a person. Implemented as the `seed` subcommand of the Rust `identity-resolution` service (issue #1690), run as a scheduled job by the umbrella chart; it supersedes the original one-shot Python seed (kept under `seed/` for history). -- [ ] `p2` - **ID**: `cpt-insightspec-ir-component-bootstrap-job` +##### Responsibility scope + +Implemented behaviour (groups accounts by normalized current e-mail, then resolves each group in priority order): + +- Reads `identity_inputs` — the **full evidence set each run** (no incremental watermark yet; REC-IR-02) and currently **without a tenant predicate** (producer-side tenant ids are hashed; single-tenant deployments only — restoring the filter is a multi-tenant prerequisite). UPSERT rows carry values; DELETE rows are closure signals only. **Known gap**: by the write contract DELETE rows arrive with an empty `value`, and the current reader's non-empty filter drops them — closure/tombstone handling is inert until the reader fix ships with the manual-resolution feature. Groups observations per source account and accounts by e-mail. +- Branch 1 — reuse: a group containing an already-bound account reuses that binding. **Known gap**: if the group's accounts are bound to *different* persons, the whole group currently collapses onto the first binding (with `known_binding_conflicts` counted and logged) — which can silently re-bind the other accounts. +- Branch 2 — `LinkedByEmail`: an unbound group whose e-mail already maps to an existing person is linked to that person automatically. +- Branch 3 — mint: a new person for a group with a new e-mail (at least one active profile); groups with no e-mail, or wholly closed, are skipped. +- Writes observations via `INSERT IGNORE` (idempotent for re-emitted observations under the natural key, which includes `created_at` taken from `_synced_at`); rebuilds `account_person_map` atomically. +- Guards destructive/suspicious runs (empty input, foreign tenant) with an explicit `--force` override; journals every run in `operations` (queued → running → completed/failed with summary counters). + +Hardening shipped with the manual-resolution feature (required by ADR-0003; not yet implemented): + +- Divergent groups: respect each account's own binding instead of collapsing — an operator-authored binding is authoritative and must never be re-bound by the group. +- Author-aware classification: divergence explained by an operator-authored binding is a resolved state (silent); all-seed divergence is surfaced for review. Requires the bindings loader to return the binding author alongside `person_id` (today it returns only `person_id`). +- Ambiguity handling: an e-mail mapped to more than one person stops auto-linking new accounts (surfaced instead). + +##### Responsibility boundaries + +- NEVER re-derives the binding of a consistently-bound account; after the hardening above, never re-binds any bound account. +- NEVER merges two existing persons. +- Does NOT serve API requests — that is the read API and the operator resolution API. + +##### Related components (by ID) + +- `cpt-insightspec-ir-component-persons-sync` — republishes what the seed wrote +- `cpt-insightspec-ir-component-operator-resolution-api` — resolves what the seed quarantined + +--- + +#### PersonsSync + +- [x] `p1` - **ID**: `cpt-insightspec-ir-component-persons-sync` ##### Why this component exists -Processes alias observations from `identity_inputs` into resolved `aliases` rows. Without it, the alias table remains empty and no resolution can happen. Runs on schedule (Argo Workflow) after connector syncs complete. +The analytical pipeline (dbt) cannot read MariaDB directly at build time; it needs the journal in ClickHouse. persons-sync republishes `persons` into `identity.identity_persons` so the `resolve_person_id` macro can resolve `person_id` for every gold build. ##### Responsibility scope -- Reads `identity_inputs` rows where `_synced_at > last_run` for the tenant. -- For each input: normalizes the alias value (email/username → `lower(trim())`; others → `trim()`). -- Looks up existing aliases matching `(insight_tenant_id, value_type, normalized_value)`. -- If no match found: evaluates MatchingEngine rules; if confidence ≥ threshold → creates alias linked to matched person; otherwise → inserts into `unmapped`. -- If match found: updates `last_observed_at`, `source_account_id` if changed. -- Auto-resolves `unmapped` entries that match newly created aliases. -- Records processing watermark for incremental runs. +- Mirrors the full `persons` journal into ClickHouse as an atomic snapshot swap (`EXCHANGE TABLES`), so readers never observe a partial mirror. +- Runs inside the identity-resolution service as a background worker. ##### Responsibility boundaries -- Does NOT create person records here. The MariaDB `persons` history is owned and written by the identity-resolution service itself via the `seed-persons-from-identity-input.py` one-shot script (ADR-0002) and, in future, operator merge/split flows. BootstrapJob assumes those records exist and only resolves aliases against them. -- Does NOT build golden records or detect person-level conflicts — those belong to the person domain (which reads `persons` and projects a golden view; see person-domain DESIGN). -- Does NOT expose API endpoints — that is ResolutionService. +- Read-only with respect to `persons`; owns only the mirror table. +- Does NOT transform or filter — resolution semantics live solely in the dbt macro. ##### Related components (by ID) -- `cpt-insightspec-ir-component-matching-engine` — called for cold-path alias evaluation -- `cpt-insightspec-ir-component-conflict-detector` — called when same alias maps to multiple persons +- `cpt-insightspec-ir-component-persons-seed` — produces the journal being mirrored --- -#### ResolutionService +#### IdentityReadApi -- [ ] `p2` - **ID**: `cpt-insightspec-ir-component-resolution-service` +- [x] `p1` - **ID**: `cpt-insightspec-ir-component-identity-read-api` ##### Why this component exists -Entry point for all alias resolution requests (hot path and cold path), merge/split operations, unmapped queue management, match rule configuration, and GDPR purge. Exposes the `/api/identity/` REST API. +Backend consumers (gateway, analytics API) need person lookups at request time: profile by e-mail or account, visible persons, org subchart. Specified in detail in the component spec (`docs/components/backend/identity-resolution/identity/`). ##### Responsibility scope -- `resolve(value_type, value, insight_source_id, insight_tenant_id)` → `person_id` or null. -- Hot path: direct `aliases` table lookup (covers ~90% after bootstrap). -- Cold path: evaluates enabled `match_rules` via MatchingEngine; applies confidence thresholds. -- `batch_resolve([...])` — bulk resolution for pipeline enrichment. -- `merge(source_person_id, target_person_id, reason, actor_person_id)` — reassigns all aliases from source to target; snapshots before/after in `merge_audits`. -- `split(audit_id, actor_person_id)` — restores alias mappings from `merge_audits.snapshot_before`. -- `purge(person_id, actor_person_id)` — GDPR hard purge: moves aliases to `alias_gdpr_deleted`; removes from `aliases`. -- Unmapped queue: list, resolve (link to person or create new), ignore. -- Match rules: list, update (weight, config, is_enabled). -- Idempotency keys on mutating endpoints (`Idempotency-Key` header, 24h TTL). +- `POST /v1/profiles`, `POST /v1/visible-persons` and related read endpoints over the `persons` journal (latest-per-source semantics; single-result invariant with `422 ambiguous_profile` on violation). +- Treats `persons` as an append-only event log; performs no writes. ##### Responsibility boundaries -- Does NOT run the BootstrapJob (scheduled separately via Argo). -- Does NOT manage person records, golden records, or person-level attributes. +- Does NOT write to the journal — writes come only from the seed and the operator resolution API. +- Does NOT expose analytical/bulk resolution — that is the dbt build path. ##### Related components (by ID) -- `cpt-insightspec-ir-component-matching-engine` — called on cold-path resolution -- `cpt-insightspec-ir-component-conflict-detector` — called after merge to detect alias conflicts +- `cpt-insightspec-ir-component-operator-resolution-api` — shares the service process; owns the write path --- -#### MatchingEngine +#### OperatorResolutionApi -- [ ] `p2` - **ID**: `cpt-insightspec-ir-component-matching-engine` +- [ ] `p1` - **ID**: `cpt-insightspec-ir-component-operator-resolution-api` ##### Why this component exists -Evaluates configurable match rules against candidate persons for cold-path resolution and suggestion generation. Separates matching logic from the resolution workflow. +Automatic binding can err in both directions (under-merge, over-merge), and the product offers no supported way to change a binding once automation has written it. This component adds that capability: operator correction verbs whose effects are appended to the journal and survive every re-run (ADR-0003). Reviewed design with scenarios: constructorfabric/insight#2180. Planned for the current iteration; endpoint-level behaviour will be specified at FEATURE level. ##### Responsibility scope -- Loads enabled `match_rules` rows ordered by `sort_order`. -- Evaluates each rule (exact, normalization, cross_system, fuzzy) against the input alias. -- Computes composite confidence: `SUM(rule.weight * match_score) / SUM(all_rule.weight)`. -- Applies thresholds: `>= 1.0` → auto-link; `0.50–0.99` → suggestion; `< 0.50` → unmapped. -- Email normalization pipeline: lowercase → trim → remove plus-tags → apply domain aliases. -- Fuzzy rules (Jaro-Winkler, Soundex) are disabled by default; when enabled, NEVER trigger auto-link. +- Write verbs, each appending binding observations authored by the operator: `bind` (single and bulk, addressable by account or by unambiguous observed value; pre-registration of not-yet-observed accounts allowed), `merge` (explicit surviving person), `detach` (mints a new person), `exclude` (binds to the reserved excluded-person sentinel). +- Read surface: the derived review queue — `identity_inputs` evidence joined with current `persons` bindings: accounts pending a decision, contested-binding groups, and no-evidence accounts, each with candidates; resolution-rate shares (bound / pending / no-evidence / excluded — the operator-visible match rate); per-account binding history (explain); per-person account listing (matching table). +- Journals every call in `operations` (actor, request, comment); idempotency is decision-aware (§3.3) — a correction is a reported no-op only when an identical operator decision is already recorded; a bind-to-self over an automation-authored binding is the confirm act and appends the operator row. +- Ships together with two enablers outside the service: the dbt resolver upgrade (§4.4 — corrections are `value_type='id'` bindings and must reach gold) and the persons-seed hardening (see PersonsSeed) that protects operator bindings in divergent groups. ##### Responsibility boundaries -- Does NOT write to `aliases` directly — returns confidence + candidate `person_id` to caller (BootstrapJob or ResolutionService). +- Never updates or deletes journal rows — corrections are new observations; undo is a counter-action. +- Does NOT create or edit person-domain golden-record attributes — only the account→person binding. +- Does NOT auto-apply any suggestion — every write is an explicit operator act (no-silent-merge invariant). ##### Related components (by ID) -- `cpt-insightspec-ir-component-resolution-service` — calls this on cold path -- `cpt-insightspec-ir-component-bootstrap-job` — calls this during input processing +- `cpt-insightspec-ir-component-persons-seed` — its known-account rule makes these decisions durable +- `cpt-insightspec-ir-component-identity-read-api` — same service; read counterpart --- -#### ConflictDetector +#### MatchingEngine (future) -- [ ] `p2` - **ID**: `cpt-insightspec-ir-component-conflict-detector` +- [ ] `p3` - **ID**: `cpt-insightspec-ir-component-matching-engine` ##### Why this component exists -Detects alias-level conflicts — when the same alias value appears linked to different persons across sources, or when a merge operation would create contradictory alias mappings. +Future rule-driven matcher producing confidence-scored merge **proposals** for operator review (never auto-applied — no-silent-merge invariant; see §4.2 for the phased rule catalogue). Not built; requirements #1765/#796. ##### Responsibility scope -- `detect_alias_conflicts(value_type, value, insight_tenant_id)` — checks if the same `(value_type, value)` is claimed by multiple persons; writes to `conflicts` table. -- Called by BootstrapJob when a new alias observation matches an alias already owned by a different person. -- Called by ResolutionService after merge to verify no contradictory alias mappings were created. +- Evaluates configurable `match_rules` (exact, normalization, cross-system, fuzzy) against journal evidence; emits proposals ordered by confidence. +- Consumes the operator journal as labeled training data; must respect operator decisions as overriding constraints. ##### Responsibility boundaries -- Does NOT detect person-level attribute conflicts (display_name disagreements, role disagreements) — that belongs to the person domain. -- Does NOT auto-resolve conflicts — creates a record for operator review. +- MUST NOT write bindings — proposals only; acceptance is an operator act through the operator resolution API. +- Fuzzy rules never auto-link regardless of score. ##### Related components (by ID) -- `cpt-insightspec-ir-component-bootstrap-job` — calls this during input processing -- `cpt-insightspec-ir-component-resolution-service` — calls this after merge operations +- `cpt-insightspec-ir-component-operator-resolution-api` — the only path by which a proposal becomes a binding + +> Retired v2.0 components (IDs no longer defined): the BootstrapJob is superseded by PersonsSeed; the ResolutionService is split into IdentityReadApi and OperatorResolutionApi; the ConflictDetector is absorbed into the PersonsSeed author-classification and the derived review queue. --- ### 3.3 API Contracts -- [ ] `p2` - **ID**: `cpt-insightspec-ir-interface-api` +- [ ] `p2` - **ID**: `cpt-insightspec-ir-interface-api-v2` + +- **Technology**: REST / HTTP JSON, served by the Rust `identity-resolution` service -- **Technology**: REST / HTTP JSON -- **Base path**: `/api/identity/` +**Evidence write contract**: connectors write observations to `identity_inputs` per `cpt-ir-contract-bootstrap-inputs` (PRD §7.2); schema in §3.7. -**Endpoints Overview**: +**Implemented read surface** (contract details in the component spec, `docs/components/backend/identity-resolution/identity/specs/`): -| Method | Path | Description | Stability | +| Method | Path | Description | Status | |---|---|---|---| -| `POST` | `/resolve` | Resolve single alias → `person_id` | stable | -| `POST` | `/batch-resolve` | Bulk resolution for pipeline enrichment | stable | -| `POST` | `/merge` | Merge two person alias sets | stable | -| `POST` | `/split` | Split (rollback) a previous merge by `audit_id` | stable | -| `GET` | `/unmapped` | List unmapped aliases (filterable by status) | stable | -| `POST` | `/unmapped/:id/resolve` | Link unmapped to existing person or create new | stable | -| `POST` | `/unmapped/:id/ignore` | Mark unmapped as ignored | stable | -| `GET` | `/persons/:id/aliases` | List all aliases for a person (cross-domain ref) | stable | -| `POST` | `/persons/:id/aliases` | Add alias manually | stable | -| `DELETE` | `/persons/:id/aliases/:alias_id` | Deactivate alias | stable | -| `GET` | `/rules` | List match rules | stable | -| `PUT` | `/rules/:id` | Update match rule (weight, config, is_enabled) | stable | -| `POST` | `/purge` | GDPR hard purge for a person's aliases | stable | - -**`POST /resolve` request / response**: - -```json -// Request -{ - "value_type": "email", - "value": "john.smith@corp.com", - "insight_source_id": "550e8400-e29b-41d4-a716-446655440000", - "insight_source_type": "gitlab", - "insight_tenant_id": "660e8400-e29b-41d4-a716-446655440001" -} - -// Response (resolved) -{ "person_id": "uuid-1234", "confidence": 1.0, "status": "resolved" } - -// Response (unmapped) -{ "person_id": null, "status": "unmapped" } -``` +| `POST` | `/v1/profiles` | Person profile lookup by e-mail / account; single-result invariant, `422 ambiguous_profile` on violation | implemented | +| `POST` | `/v1/visible-persons` | Visibility-scoped person listing | implemented | +| `GET` | `/v1/persons-seed`, `/v1/persons-seed/{id}` | Inspect seed runs recorded in the `operations` journal (list / by id); there is no HTTP seed trigger — runs are scheduled | implemented | +| `GET` | `/health`, `/healthz` | Liveness/readiness | implemented | -**Error codes**: +**Planned operator resolution surface** (ADR-0003; reviewed design with request/response shapes and scenarios in constructorfabric/insight#2180; exact contracts to be fixed at FEATURE level): -| HTTP | Error | Condition | -|---|---|---| -| 400 | `invalid_value_type` | Unknown alias type | -| 404 | `audit_not_found` | Split attempted on missing audit record | -| 409 | `merge_conflict` | Circular merge detected | -| 409 | `already_rolled_back` | Split attempted on already-rolled-back audit record | -| 409 | `alias_already_exists` | Duplicate alias creation attempt | +| Method | Path (working) | Description | Status | +|---|---|---|---| +| `POST` | `/v1/resolution/bind` | Bind account(s) to a person — single or bulk; addressable by account or unambiguous observed value; pre-registration allowed; also serves as "confirm" | planned | +| `POST` | `/v1/resolution/merge` | Merge two persons; operator names the surviving person explicitly | planned | +| `POST` | `/v1/resolution/detach` | Detach an account into a freshly minted person | planned | +| `POST` | `/v1/resolution/exclude` | Mark an account as not-a-person (bot/service); binds to the excluded sentinel | planned | +| `GET` | `/v1/resolution/attention` | Review queue derived from `identity_inputs` evidence joined with current `persons` bindings; evidence is folded per account over UPSERT/DELETE events, so accounts whose latest event is a closure drop out. Items: accounts pending a decision, contested-binding groups, no-evidence accounts — with candidates, counts, and resolution-rate shares (match rate) | planned | +| `GET` | `/v1/resolution/accounts/{source}/{id}` | Binding history / explain for one account | planned | +| `GET` | `/v1/resolution/persons/{person_id}/accounts` | Matching table: every account/value bound to a person, with author of each link | planned | + +All write verbs append to the `persons` journal (never update/delete), record the call in `operations`, and require an operator grant enforced through the service's existing `roles` / `person_roles` tables (per-tenant grants with author and reason; whether a dedicated identity-operator role is introduced is a rollout decision). Bulk `bind` reports per-item outcomes with machine-readable skip reasons. Idempotency is decision-aware and enforced at the API level: an item is a reported no-op only when an **identical operator decision is already recorded** (same target person AND operator-authored effective binding). A bind whose target equals the current person but whose effective binding is automation-authored is NOT a no-op — it is a confirmation and appends the operator row. Key-level dedup alone cannot provide this (the journal's natural key includes `created_at`). + +**Future surface** (with the MatchingEngine): match-rule configuration and proposal review endpoints — not designed yet beyond §4.2. --- @@ -470,140 +494,130 @@ Detects alias-level conflicts — when the same alias value appears linked to di | Dependency Module | Interface Used | Purpose | |---|---|---| -| Identity-resolution `persons` (MariaDB) | Logical FK (`aliases.person_id → persons.person_id`) | Alias-to-person mapping target. Owned and written by this domain via the seed (ADR-0002) and future operator flows; the person domain reads from it to build its golden record | -| Identity-resolution seed | One-shot Python script | Initial population of `persons` from `identity_inputs`; runs at bootstrap (and again on operator demand for new connectors). See ADR-0002 | -| Connector sync events | Argo Workflow trigger | BootstrapJob runs after connector sync completes | -| dbt models (Bronze → Silver) | ClickHouse tables | Connectors populate `identity_inputs` via `identity_inputs_from_history` macro applied to `fields_history` models | +| dbt models (Bronze → Silver) | ClickHouse tables | Connectors populate `identity_inputs` via the `identity_inputs_from_history` macro applied to `fields_history` models | +| persons-seed (`seed` subcommand) | ClickHouse read + MariaDB write | Folds `identity_inputs` into `persons` per ADR-0002; scheduled by the umbrella chart; journaled in `operations` | +| persons-sync (service worker) | MariaDB read + ClickHouse write | Republishes `persons` into `identity.identity_persons` via atomic swap | +| dbt `resolve_person_id` macro | ClickHouse mirror | The single place resolution semantics live for analytics; every gold model resolves `person_id` through it at build time | +| Person domain | `persons` observations (read) | Person domain builds its golden record from this domain's journal | **Dependency Rules**: - No circular dependencies between identity-resolution and person domains -- Identity resolution writes only to its own tables; references person domain via logical FK -- Person domain does not depend on identity resolution internals; only consumes `aliases` as read +- Identity resolution writes only to its own tables (`persons`, `account_person_map`, `operations`, the mirror) +- Person domain does not depend on identity-resolution internals; it consumes `persons` observations read-only +- Analytics never reads MariaDB directly — only the ClickHouse mirror through the macro --- ### 3.5 External Dependencies -#### ClickHouse (Storage Engine) +#### ClickHouse (Evidence + Mirror) | Aspect | Value | |---|---| -| Engine | All tables use MergeTree family (ReplacingMergeTree for aliases, MergeTree for others) | +| Tables | `identity_inputs` (MergeTree, connector-written), `identity.identity_persons` (mirror, swapped atomically), legacy `identity.aliases` | | Version | 24.x+ (for `generateUUIDv7()` support) | -| Access | Direct read/write from ResolutionService and BootstrapJob | -| Connection | Native ClickHouse protocol (clickhouse-driver) or HTTP interface | +| Access | Read by persons-seed; written by dbt (inputs) and persons-sync (mirror) | +| Connection | HTTP interface / native protocol | + +#### MariaDB (Journal) + +| Aspect | Value | +|---|---| +| Database | `identity` — `persons`, `account_person_map`, `operations` (+ service-owned auxiliary tables) | +| Schema ownership | `identity-resolution` service via embedded SeaORM `Migrator` (ADR-0006) | +| Access | Written by persons-seed and (planned) the operator resolution API; read by the service APIs and persons-sync | -#### Argo Workflows (Orchestration) +#### Kubernetes (Orchestration) | Aspect | Value | |---|---| -| Purpose | Schedules BootstrapJob runs after connector syncs | -| Trigger | Post-sync workflow completion event | -| Environment | Kind K8s cluster (per PR #45 migration) | +| Purpose | Schedules persons-seed runs (umbrella chart); hosts the identity-resolution Deployment | +| Note | Argo-orchestrated BootstrapJob was the v2.0 plan and is not used; a future matcher may reintroduce workflow orchestration | --- ### 3.6 Interactions & Sequences -#### Bootstrap Input Processing +#### Seed Run (three-mode fold) -**ID**: `cpt-insightspec-ir-seq-bootstrap-processing` +**ID**: `cpt-insightspec-ir-seq-seed-run` ```mermaid sequenceDiagram - participant Argo as Argo Workflow - participant BJ as BootstrapJob + participant Sched as Scheduler (umbrella chart) + participant Seed as persons-seed participant BI as identity_inputs (CH) - participant AL as aliases (CH) - participant ME as MatchingEngine - participant UM as unmapped (CH) - participant CD as ConflictDetector - participant CF as conflicts (CH) - - Argo ->> BJ: trigger(insight_tenant_id) - BJ ->> BI: SELECT WHERE _synced_at > last_run - BI -->> BJ: new alias observations - - loop For each observation - BJ ->> BJ: normalize(value) - BJ ->> AL: lookup(tenant, value_type, normalized_value) - alt Alias exists for same person - BJ ->> AL: UPDATE last_observed_at - else Alias exists for different person - BJ ->> CD: detect_alias_conflicts(...) - CD ->> CF: INSERT conflict record - else No alias exists - BJ ->> ME: evaluate(value_type, value, tenant) - ME -->> BJ: {person_id, confidence} - alt confidence >= 1.0 - BJ ->> AL: INSERT alias (auto-link) - BJ ->> UM: auto-resolve matching unmapped - else confidence 0.50-0.99 - BJ ->> UM: INSERT unmapped (suggestion) - else confidence < 0.50 - BJ ->> UM: INSERT unmapped (pending) - end + participant P as persons (MariaDB) + participant M as account_person_map (MariaDB) + participant OPS as operations (MariaDB) + + Sched ->> Seed: run(tenant) + Seed ->> OPS: INSERT run (queued -> running) + Seed ->> BI: SELECT UPSERT rows, non-empty values + BI -->> Seed: observations grouped per source account + Seed ->> P: load known bindings + known e-mails + + loop For each e-mail group of accounts + alt Group has a bound account + Seed ->> P: reuse binding; INSERT IGNORE observations + Note over Seed: divergent bindings in one group currently collapse
onto the first (known_binding_conflicts++) - hardening
with manual resolution makes per-account bindings win + else Unbound, e-mail maps to an existing person + Seed ->> P: LinkedByEmail - link group to that person + else Unbound, new e-mail (active profile) + Seed ->> P: mint person_id, INSERT observations (group shares person) + else No e-mail / all closed + Seed ->> Seed: skip end end - BJ ->> BJ: update processing watermark + + Seed ->> M: transactional rebuild (tenant-scoped DELETE + INSERT, same txn) + Seed ->> OPS: UPDATE run (completed + summary counters) ``` --- -#### Alias Resolution (Hot Path) +#### Operator Correction (planned — ADR-0003) -**ID**: `cpt-insightspec-ir-seq-resolve-hot` +**ID**: `cpt-insightspec-ir-seq-operator-correction` ```mermaid sequenceDiagram - participant Caller - participant API as ResolutionService - participant AL as aliases (CH) - participant ME as MatchingEngine - participant UM as unmapped (CH) - - Caller ->> API: POST /resolve {value_type, value, insight_source_id, insight_tenant_id} - API ->> AL: SELECT person_id WHERE value_type=? AND value=? AND insight_tenant_id=? AND is_deleted=0 - alt Found (hot path ~90%) - AL -->> API: person_id - API -->> Caller: {person_id, confidence: 1.0, status: "resolved"} - else Not found (cold path) - API ->> ME: evaluate(value_type, value, tenant) - ME -->> API: {person_id, confidence} - alt confidence >= 1.0 - API ->> AL: INSERT alias (auto-link) - API -->> Caller: {person_id, confidence, status: "resolved"} - else confidence < 1.0 - API ->> UM: INSERT unmapped - API -->> Caller: {person_id: null, status: "unmapped"} - end - end + participant Op as Operator + participant API as OperatorResolutionApi + participant P as persons (MariaDB) + participant OPS as operations (MariaDB) + participant M as account_person_map (MariaDB) + + Op ->> API: POST /v1/resolution/{bind|merge|detach|exclude} + API ->> P: validate targets (accounts, persons, tenant) + API ->> OPS: INSERT operation (actor, request, comment) + API ->> P: APPEND binding observation(s), author = operator UUID + Note over P: never UPDATE/DELETE - corrections are new facts + API ->> M: rebuild affected bindings (atomic) + API -->> Op: result (e.g. new_person_id for detach; per-item report for bulk bind) + Note over P: next persons-sync publish + next gold build
re-attribute all history to the corrected persons ``` --- -#### Merge Operation +#### Build-Time Resolution (analytics read path) -**ID**: `cpt-insightspec-ir-seq-merge` +**ID**: `cpt-insightspec-ir-seq-build-resolution` ```mermaid sequenceDiagram - participant Op as Operator - participant API as ResolutionService - participant AL as aliases (CH) - participant MA as merge_audits (CH) - participant CD as ConflictDetector - - Op ->> API: POST /merge {source_person_id, target_person_id, reason, actor_person_id} - API ->> AL: SELECT * WHERE person_id = source_person_id - AL -->> API: source aliases (snapshot_before) - API ->> MA: INSERT merge_audit (snapshot_before) - API ->> AL: UPDATE person_id = target WHERE person_id = source - API ->> AL: SELECT * WHERE person_id = target_person_id - AL -->> API: merged aliases (snapshot_after) - API ->> MA: UPDATE merge_audit (snapshot_after) - API ->> CD: detect_alias_conflicts(target_person_id) - API -->> Op: {status: "merged", audit_id} + participant Sync as persons-sync + participant P as persons (MariaDB) + participant MIR as identity.identity_persons (CH) + participant DBT as dbt build (resolve_person_id) + participant G as gold tables + + Sync ->> P: read full journal + Sync ->> MIR: EXCHANGE TABLES (atomic snapshot swap) + DBT ->> MIR: v1: latest e-mail observation per e-mail -> person_id + Note over DBT: upgrade with manual resolution: account-first
(latest value_type='id' binding per source account),
e-mail fallback for facts without an account,
contested e-mail -> NULL + DBT ->> G: person_id column (honest NULL when unresolved;
excluded sentinel maps to NULL) + Note over G: person_id is recomputed on every build - corrections
re-attribute history once the account-first upgrade lands ``` --- @@ -612,7 +626,7 @@ sequenceDiagram - [ ] `p3` - **ID**: `cpt-insightspec-ir-db-schemas` -Analytical tables (`identity_inputs`, `aliases`, `match_rules`, `unmapped`, `conflicts`, `merge_audits`, `alias_gdpr_deleted`) are in ClickHouse; identity-history tables (`persons`, `account_person_map`) are in MariaDB and owned by the Rust `identity-resolution` service (see §3.7, ADR-0002, ADR-0006). Naming follows PR #55 conventions. For ClickHouse tables: no Nullable unless semantically required; use empty string (`''`) or zero sentinel (`'1970-01-01'`) instead. +Implemented tables: `identity_inputs` (ClickHouse, evidence), `identity.identity_persons` (ClickHouse, persons-sync mirror of the journal), `persons` / `account_person_map` / `operations` (MariaDB, owned by the Rust `identity-resolution` service — see ADR-0002, ADR-0003, ADR-0006). The ClickHouse `aliases` table exists but is legacy (below). The remaining v2.0 tables (`match_rules`, `unmapped`, `conflicts`, `merge_audits`, `alias_gdpr_deleted`) were never built — see "Future tables" at the end of this section. Naming follows PR #55 conventions. For ClickHouse tables: no Nullable unless semantically required; use empty string (`''`) or zero sentinel (`'1970-01-01'`) instead. #### Table: `identity_inputs` @@ -628,7 +642,7 @@ The macro reads from the connector's `fields_history` model (field-level change - **UPSERT** rows when an identity-relevant field changes - **DELETE** rows (with empty `value`) when the deactivation condition is met -Per-connector staging tables (e.g., `staging.bamboohr__identity_inputs`) are unified into a single `identity.identity_inputs` view via `union_by_tag('silver:identity_inputs')`. +Per-connector staging tables (e.g., `staging.bamboohr__identity_inputs`) are unified into a single `identity.identity_inputs` view via `union_by_tag('silver:identity_inputs')`. The view stores nothing: any erasure obligation (GDPR purge) must target the physical staging tables and their upstream history, and guard dbt rebuilds — see the purge use case in the PRD. The models are incremental (`append` strategy): each run processes only `fields_history` rows with `updated_at` newer than the last `_synced_at` in the target table. @@ -654,7 +668,7 @@ The models are incremental (`append` strategy): each run processes only `fields_ **Engine**: `MergeTree` -**Normalization rules**: email/username → `lower(trim())`; others → `trim()`. Applied by BootstrapJob at read time, not at write time (raw values preserved in this table). +**Normalization rules**: applied by consumers at read time, never at write time (raw values are preserved in this table). The seed groups by lowercased e-mail; the analytics macro applies `lower(trim(...))` to both join sides; the future matcher defines its own per-rule normalization (§4.2). **Example**: @@ -667,10 +681,12 @@ The models are incremental (`append` strategy): each run processes only `fields_ --- -#### Table: `aliases` +#### Table: `aliases` (LEGACY) **ID**: `cpt-insightspec-ir-dbtable-aliases` +> **Status: legacy.** The table physically exists in ClickHouse and is populated by dbt seed models only (always with `confidence = 1.0`), but it is **not consumed by the resolution path** — analytics resolves through the `identity.identity_persons` mirror and the `resolve_person_id` macro, and the service resolves through `persons`. Retirement candidate; retained here for reference until the future MatchingEngine decides whether to reuse or replace it. + Resolved alias-to-person mapping. Each row links one `(value_type, value)` from one source to one person. | Column | Type | Description | @@ -722,7 +738,7 @@ Identity-attribute observation history for persons, stored in MariaDB. Each row **Database**: MariaDB, database `identity` — dedicated to identity-resolution-domain tables, reached via the service's `database_url` configuration. The service does not assume co-location with any other MariaDB database; any other service owning MariaDB tables configures its own connection independently. Each backend service owns and applies its own schema — see [ADR-0006](../../ingestion/specs/ADR/0006-service-owned-migrations.md). -**DDL**: SeaORM migration in `src/backend/services/identity-resolution/src/migration/` (applied by the service's `migrate` subcommand) +**DDL**: SeaORM migrations in `src/backend/services/identity-resolution/src/migration/` (applied by the service's `migrate` subcommand). The shape below reflects the migration chain as of `m20260724_000014` — notably migration 004, which (a) moved the natural-key UNIQUE from `value_hash` to `created_at` so that legitimate value re-transitions (Active → Inactive → Active) are recordable, and (b) switched `value_id` to case-insensitive collation. The migrations are authoritative; on divergence, trust them over this table. ##### Columns @@ -733,15 +749,15 @@ Identity-attribute observation history for persons, stored in MariaDB. Each row | `insight_source_type` | `VARCHAR(30) NOT NULL` | Source system: `bamboohr`, `zoom`, `cursor`, `claude_admin`, `gitlab`, etc. Tiny tier — connector keys are short, owned vocabulary (longest today is `claude_enterprise` = 17 chars) | | `insight_source_id` | `BINARY(16) NOT NULL` | Connector instance UUID (temporary: sipHash128 from Bronze string `source_id` until `sources` table exists — see REC-IR-04) | | `insight_tenant_id` | `BINARY(16) NOT NULL` | Tenant UUID (temporary: sipHash128 from Bronze string `tenant_id` until `tenants` table exists — see REC-IR-04) | -| `value_id` | `VARCHAR(320) COLLATE utf8mb4_bin NULL` | Value for `value_type IN ('id', 'email', 'username')`. Strict byte comparison; hot-path lookup target. Size 320 covers RFC 5321/5322 email maximum (64 local + `@` + 255 domain). `username` is id-like (case-sensitive in most platforms) and routes here for strict-equality lookup | +| `value_id` | `VARCHAR(320) COLLATE utf8mb4_unicode_ci NULL` | Value for `value_type IN ('id', 'email', 'username')`. Case-insensitive comparison (migration 004; see component ADR-0011); hot-path lookup target. Size 320 covers RFC 5321/5322 email maximum (64 local + `@` + 255 domain) | | `value_full_text` | `VARCHAR(512) COLLATE utf8mb4_unicode_ci NULL` | Value for `value_type='display_name'`. Case- and accent-insensitive collation for operator search; leaves room for future FULLTEXT index | | `value` | `TEXT NULL` | Catch-all value for any other `value_type` (e.g., `employee_id`, `platform_id`, `functional_team`, custom attributes). Not directly indexed | | `value_effective` | `TEXT GENERATED ALWAYS AS (COALESCE(value_id, value_full_text, value)) STORED` | Human-readable coalesce of the three value columns; **not indexed** (display only). Use it from SELECTs when you want the actual value without knowing the routing rules | -| `value_hash` | `CHAR(64) COLLATE ascii_bin GENERATED ALWAYS AS (SHA2(COALESCE(value_id, value_full_text, value), 256)) STORED` | SHA-256 hex of the routed value. Fixed-width, fully indexable, collision-free regardless of value length. Used in the natural-key UNIQUE so `INSERT IGNORE` re-runs are idempotent even for catch-all `TEXT` values longer than any prefix limit | +| `value_hash` | `CHAR(64) COLLATE ascii_bin GENERATED ALWAYS AS (SHA2(COALESCE(value_id, value_full_text, value), 256)) STORED` | SHA-256 hex of the routed value. Fixed-width, byte-compared. **No longer part of the natural-key UNIQUE** (migration 004 replaced it with `created_at` — the original hash key wrongly collapsed value re-transitions); retained as a stable value digest | | `person_id` | `BINARY(16) NOT NULL` | Person UUID (random UUIDv7). Stable; never re-derived from attribute values. See ADR-0002 | -| `author_person_id` | `BINARY(16) NOT NULL` | Person UUID of who/what made this change. Sentinel `00000000-0000-0000-0000-000000000000` = auto-minted by seed; real operator UUIDs for future merge/split flows | -| `reason` | `TEXT NOT NULL DEFAULT ''` | Optional change-reason comment. Empty for normal seed observations; `pending-iresolution` flags rows produced when an unknown account's email already exists in `persons` (see ADR-0002 §6) | -| `created_at` | `TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)` | When this record was inserted (microsecond precision; MariaDB stores `TIMESTAMP` internally as UTC). The seed sets it from each observation's `identity_inputs._synced_at`, not from the seed wall-clock, so chronology in `persons` reflects when the source actually saw each value | +| `author_person_id` | `BINARY(16) NOT NULL` | Person UUID of who/what made this change. Sentinel `00000000-0000-0000-0000-000000000000` = auto-minted by seed; real operator UUIDs for operator corrections (ADR-0003). This field is load-bearing: the seed and the review queue classify binding divergence by author (operator-authored = resolved state) | +| `reason` | `TEXT NULL` (migration 009) | Machine-readable change-reason code. NULL/empty for normal seed observations; `pending-iresolution` is reserved vocabulary from ADR-0002 for the deferred quarantine flow (not emitted by the current seed); operator corrections will use `operator-bind` / `operator-merge` / `operator-detach` / `operator-exclude` (ADR-0003; free-text commentary lives in the `operations` journal) | +| `created_at` | `DATETIME(6) NOT NULL DEFAULT (UTC_TIMESTAMP(6))` (migration 009) | When this record was inserted (microsecond precision, UTC by convention). The seed sets it from each observation's `identity_inputs._synced_at`, not from the seed wall-clock, so chronology in `persons` reflects when the source actually saw each value. Part of the natural-key UNIQUE (migration 004) — see the index note below for the uniqueness obligations this puts on writers | **Hardcoded routing by `value_type`** (applied in seed + dbt macro): @@ -753,6 +769,8 @@ Identity-attribute observation history for persons, stored in MariaDB. Each row Exactly one of `(value_id, value_full_text, value)` is populated per normal row; the other two are NULL. All-three-NULL is reserved for future "attribute unset at source" events (not emitted by the initial seed). +**Reserved for the matcher iteration**: proposal `confidence` and `evidence` receive first-class storage when the MatchingEngine lands (reserved column names). The journal deliberately carries no dead columns for them now — an additive MariaDB migration is cheap at that point, and operator provenance (`author_person_id`, `reason`, the `operations` payload) already accumulates the training signal. + **UUID representation**: all UUID columns are stored as `BINARY(16)` (SeaORM `.uuid()` default on MariaDB, matches `analytics` convention). Python clients must pass **`uuid.UUID.bytes`** (the 16-byte raw form) — passing the `uuid.UUID` object directly makes the driver fall back to `str(UUID)` (36 chars) which `BINARY(16)` silently truncates to ASCII, corrupting the column. For human-readable reads in SQL use `CAST(col AS UUID)` (MariaDB 10.7+) or build the textual form from `HEX(col)`. Note: MySQL 8's `BIN_TO_UUID()` is **not** available in MariaDB. **Primary key**: `id` (auto-increment integer — MariaDB convention for append-only observation history). @@ -763,7 +781,7 @@ Exactly one of `(value_id, value_full_text, value)` is populated per normal row; - `idx_person_id (person_id)` — list all attributes for a person - `idx_tenant_person (insight_tenant_id, person_id)` — tenant-scoped person lookup - `idx_source (insight_source_type, insight_source_id)` — filter by source system + instance -- `uq_person_observation (insight_tenant_id, person_id, insight_source_type, insight_source_id, value_type, value_hash)` UNIQUE — enforces the natural observation key. The generated `value_hash` column (SHA-256 hex of the coalesced value) gives a fixed-width, collision-free discriminator regardless of value length, which is required because (a) MariaDB treats `NULL` as distinct in UNIQUE keys and (b) catch-all `TEXT` values cannot be fully indexed by prefix without truncation collisions. Combined with `INSERT IGNORE` in the seed, this guarantees idempotent re-runs +- `uq_person_observation (insight_tenant_id, person_id, insight_source_type, insight_source_id, value_type, created_at)` UNIQUE — the natural observation key as of migration 004. `created_at` (taken from the observation's `identity_inputs._synced_at` for seed writes) disambiguates repeated observations: re-emission of the same observation at the same `created_at` collapses via `INSERT IGNORE` (seed re-run idempotency), while a genuine later re-observation of the same value is a new history row. Two obligations follow for writers: (a) the key does **not** deduplicate a re-applied operator correction (its `created_at` is new) — correction idempotency is decision-aware at the API level (§3.3); (b) the key contains **no account discriminator** — two `value_type='id'` observations for two different accounts of the same source, bound to the same person at the same `created_at`, collide and `INSERT IGNORE` silently drops one. Every write path MUST therefore guarantee per-account timestamp uniqueness within the key: the correction path allocates strictly increasing `DATETIME(6)` timestamps per affected row within an operation (bulk merge/bind included), and the seed must disambiguate accounts of one source resolving to one person at the same `_synced_at` (see the seed write step). Extending the key with an account discriminator is a candidate follow-up migration ##### Semantics — append-only observation history (SCD-style) @@ -802,8 +820,8 @@ Row 120 supersedes row 5 as the current `display_name` for person `p-1001` (late | `person_id` | `BINARY(16) NOT NULL` | Person UUID (random UUIDv7); derived from `persons.person_id` of the opening observation | | `author_person_id` | `BINARY(16) NOT NULL` | Forwarded from the `persons` observation. Sentinel `00000000-0000-0000-0000-000000000000` = auto-minted by seed | | `reason` | `VARCHAR(50) NOT NULL` | `initial-bootstrap` \| `new-account` \| `operator-merge` \| ... — forwarded from the `persons` observation | -| `valid_from` | `TIMESTAMP(6) NOT NULL` | When this binding became current (microsecond precision; = `created_at` of the opening `persons` observation). Sub-second precision is required because `valid_from` is part of the PRIMARY KEY — second-level resolution would risk PK collisions for closely-spaced events | -| `valid_to` | `TIMESTAMP(6) NULL` | When this binding ended (= next observation's `created_at`). `NULL` = currently active binding | +| `valid_from` | `DATETIME(6) NOT NULL` (migration 014) | When this binding became current (microsecond precision; = `created_at` of the opening `persons` observation). Sub-second precision is required because `valid_from` is part of the PRIMARY KEY — second-level resolution would risk PK collisions for closely-spaced events | +| `valid_to` | `DATETIME(6) NULL` (migration 014) | When this binding ended (= next observation's `created_at`). `NULL` = currently active binding | **Primary key**: `(insight_tenant_id, insight_source_type, insight_source_id, source_account_id, valid_from)` — one row per historical binding period. An account with N historical bindings has N rows; the latest has `valid_to = NULL`. @@ -819,48 +837,23 @@ Row 120 supersedes row 5 as the current `display_name` for person `p-1001` (late - **"Current binding"**: `WHERE valid_to IS NULL`. - **"Binding as of date T"**: `WHERE valid_from <= T AND (valid_to > T OR valid_to IS NULL)` — one row per source-account, O(log N) range lookup. - **"Full history of an account"**: all rows with the account's PK tuple, ordered by `valid_from`. -- **Rebuild** (at the end of every seed run + every future operator flow) — **atomic two-table swap**. MariaDB `TRUNCATE` is DDL and implicitly commits, so it cannot participate in a transaction; the seed builds the new state into a sibling `account_person_map_next` and atomically swaps with `RENAME TABLE`: - ```sql - CREATE TABLE account_person_map_next LIKE account_person_map; - - INSERT INTO account_person_map_next - (insight_tenant_id, insight_source_type, insight_source_id, source_account_id, - person_id, author_person_id, reason, valid_from, valid_to) - SELECT - insight_tenant_id, insight_source_type, insight_source_id, - value_id AS source_account_id, - person_id, author_person_id, reason, - created_at AS valid_from, - LEAD(created_at) OVER ( - PARTITION BY insight_tenant_id, insight_source_type, insight_source_id, value_id - ORDER BY created_at - ) AS valid_to - FROM persons WHERE value_type = 'id' AND value_id IS NOT NULL; - - RENAME TABLE - account_person_map TO account_person_map_old, - account_person_map_next TO account_person_map; - DROP TABLE account_person_map_old; - ``` - The `RENAME TABLE` pair is atomic in MariaDB; concurrent readers see either the old or the new map, never an empty intermediate. +- **Rebuild** (at the end of every seed run + every future operator flow) — **transactional, tenant-scoped**: within the same transaction that applies the observations, the seed issues `DELETE FROM account_person_map WHERE insight_tenant_id = ?` followed by `INSERT ... SELECT ... LEAD() OVER (PARTITION BY tenant, source_type, source_id, account ORDER BY created_at)` from `persons.value_type='id'` rows. The journal write and the cache rebuild commit atomically — readers see either the pre-run or the post-run state, and the log and cache are never observably inconsistent. (An earlier design described a `RENAME TABLE` two-table swap; the implemented mechanism is the transactional delete-and-insert above.) See ADR-0002 for the full decision record (why a derived cache instead of a second authoritative table, alternatives considered). --- -##### Initial seed (idempotent upsert from `identity_inputs`) +##### Seed (idempotent fold from `identity_inputs`) -**Scripts** — two-file split by separation of concerns, colocated with the identity-resolution service at `src/backend/services/identity-resolution/seed/`: +**Implementation** — the seed is the `seed` subcommand of the Rust `identity-resolution` service (issue #1690), sharing the service's SeaORM models and configuration: -| File | Role | Responsibilities | -|---|---|---| -| `seed-persons.sh` | Environment orchestrator (bash) | Resolve ClickHouse password from the `clickhouse-credentials` K8s secret (fallback to env); compute `MARIADB_URL` from local-cluster defaults (URL-encoded credentials); start `kubectl port-forward svc/insight-mariadb 3306:3306` if port 3306 is not open; `pip install pymysql` if missing; invoke the Python script. Does **not** apply DDL. | -| `seed-persons-from-identity-input.py` | Pure data transform (Python) | HTTP-read `identity.identity_inputs` from ClickHouse (`FORMAT JSONEachRow`) with a bounded timeout; load known bindings (`value_type='id'` rows) + existing emails (`value_type='email'` rows) from `persons`; assign `person_id` per account via three-mode logic (known / initial-bootstrap / steady-state-new); `INSERT IGNORE` observations into `persons`; rebuild `account_person_map` from `persons` as SCD2. Reports counts for each mode and skip reason. | - -**Rationale for the split**: -- Bash is the right tool for kubectl, port-forwards, and secret lookup. Python is the right tool for typed grouping, mapping lookup, UUID minting, and parameterised DB writes. -- The Python script is **independently runnable** — set `CLICKHOUSE_*` and `MARIADB_URL` env vars, ensure the DDL is already applied (by the identity-resolution service startup / `migrate` subcommand), and run `python3 seed-persons-from-identity-input.py`. Used by CI, by non-Kind environments, and for dry runs. -- The Python script is **testable in isolation** — the ClickHouse HTTP call and the pymysql connection are the only external dependencies and are trivially mockable; bash is not. +| Aspect | Value | +|---|---| +| Invocation | `identity-resolution seed` — one run, then exit. Scheduled by the umbrella chart; manually runnable for ad-hoc reseeds | +| Concurrency | A run-lock guarantees a single active run; a concurrent invocation exits with a warning | +| Guards | Suspicious inputs (empty `identity_inputs`, foreign-tenant universe) are refused unless the operator passes an explicit `--force`; the scheduled job itself never forces | +| Audit | Every run is journaled in `operations` (queued → running → completed/failed) with summary counters per mode, including `known_binding_conflicts` | +| History | The original one-shot Python seed (`seed/seed-persons-from-identity-input.py` + `seed-persons.sh`) performed the initial bootstrap and remains in the tree for reference; the Rust subcommand is the operative mechanism | **Schema ownership**: the `persons` and `account_person_map` table DDL lives inside the identity-resolution service at @@ -868,37 +861,45 @@ lives inside the identity-resolution service at and is applied by the service's own SeaORM migrator via the `migrate` subcommand. See [ADR-0006](../../ingestion/specs/ADR/0006-service-owned-migrations.md) -for the service-owned-migrations policy. The seed scripts here operate -on the already-created tables; they never issue `CREATE`, `ALTER`, -`TRUNCATE`, or `DELETE`. +for the service-owned-migrations policy. The seed operates on the +already-created tables and never issues DDL; `persons` is never +deleted from or updated, and the only row deletion in the +transactional apply is the tenant-scoped rebuild of the derived +`account_person_map` cache. -**Process** (data flow executed by the Python script): +**Process** (data flow executed by each seed run): -1. Read all rows from `identity.identity_inputs` (ClickHouse) where `operation_type = 'UPSERT'` and `value` is non-empty. Order by `_synced_at DESC` within each source-account so that the latest email observation is picked deterministically in step 5. +1. Read `identity.identity_inputs` (ClickHouse): UPSERT observation rows plus DELETE closure signals (never persisted). **Known gap**: DELETE rows carry an empty `value` by contract while the reader filters non-empty values only, so closure signals are currently dropped before the fold — the reader fix ships with the manual-resolution feature. **The full set each run** — no incremental watermark yet (REC-IR-02) — and currently without a tenant predicate (single-tenant deployments; multi-tenant prerequisite). Order by `_synced_at DESC` within each source-account so that the latest email observation is picked deterministically in step 5. 2. Group observations by `(insight_tenant_id, insight_source_type, insight_source_id, source_account_id)` — a "source account" = one user in one connector instance. 3. Connect to MariaDB. Load known bindings: for each source-account key, find the latest `value_type='id'` observation in `persons` and capture its `person_id`. This becomes the **known-account** set. -4. Load existing emails: run `SELECT insight_tenant_id, LOWER(TRIM(value_id)) FROM persons WHERE value_type='email' AND value_id IS NOT NULL AND value_id != ''` and collect into a `(tenant, normalized_email)` set. The set is empty on the very first run (initial bootstrap) and non-empty afterwards; the same code path handles both — there is no mode flag. -5. For each source-account in `identity_inputs`: - - **Known account** (present in step 3 set): reuse the mapped `person_id`. Observations go to `persons` via `INSERT IGNORE` (dedupe on UNIQUE key); no new binding. - - **Unknown account, no email observed**: skip. Email remains the sole identity anchor for this seed. - - **Unknown account, email absent from step 4 set**: mint a new `person_id` (random UUIDv7). `reason=''`. Within the same run, two new accounts sharing this new email still share one `person_id` (email-automerge within the run); on a fresh-tenant run this is the initial-bootstrap behaviour as a special case. - - **Unknown account, email already present in step 4 set**: **mint a fresh isolated `person_id`** (visibly NOT merged with the existing email-bearer) and write all observations with `reason='pending-iresolution'`. Each pending account gets its own `person_id` (no intra-run automerge among pending accounts), so the future Identity-Resolution operator flow has per-account granularity. The IRes flow scans for `reason='pending-iresolution'` rows and prompts a per-account decision (link to existing email-bearer / keep separate / merge). -6. Write observations to `persons` via `INSERT IGNORE`. Routing rules (hardcoded in the seed, mirrored by the dbt macro): - - `value_type IN ('id', 'email')` → `value_id = value`, others NULL - - `value_type = 'display_name'` → `value_full_text = value`, others NULL +4. Load the current e-mail map: for each normalized e-mail (lowercased, not trimmed — ADR-0011 parity), take the **latest** `value_type='email'` observation (`created_at DESC, id DESC`) and its `person_id`, producing `normalized_email → person_id`. Latest-wins over the append-only journal; the map is empty on the very first run (initial bootstrap) and non-empty afterwards — the same code path handles both, there is no mode flag. (Contested-claim detection on this map ships with the manual-resolution hardening; see §3.2.) +5. Group accounts by normalized current e-mail; resolve each group in priority order (mirrors the .NET resolver; `domain/seed.rs::resolve_assignments`): + - **Group with a bound account** (step 3 set): reuse that `person_id` for the whole group; no new binding decision. **Known gap**: if the group's accounts are bound to *different* persons, the group currently collapses onto the first binding (counted in `known_binding_conflicts`, logged) and can thereby silently re-derive a binding — the manual-resolution hardening replaces this with per-account binding respect and surfacing. + - **Unbound group, e-mail present in step 4 set**: link the group to that person (`LinkedByEmail`) — the account joins an existing person automatically when the e-mail is unambiguous. + - **Unbound group, new e-mail, at least one active profile**: mint a new `person_id` (random UUIDv7); accounts sharing the new e-mail within the run share the person. + - **No e-mail, or all profiles closed**: skip — no binding is created. Skipped active accounts are not hidden: the review queue surfaces them from `identity_inputs` as no-evidence items awaiting an operator bind. E-mail remains the sole automatic identity anchor for this seed. + + > ADR-0002 additionally specified a quarantine mode (`reason='pending-iresolution'`) for contested e-mails; the implemented port kept .NET-parity auto-linking instead. Contested-evidence handling (no auto-link when an e-mail maps to more than one person) ships with the manual-resolution feature. +6. Write observations to `persons` via `INSERT IGNORE`. Routing rules (hardcoded in the seed's `route_value`, mirrored by the dbt macro): + - id-like types (`id`, `email`, `username`, `employee_id`, `parent_email`, `parent_id`, `parent_person_id`) → `value_id = value`, others NULL + - name-like types (`display_name`, `first_name`, `last_name`, `department`, `division`, `job_title`, `status`) → `value_full_text = value`, others NULL - otherwise → `value = value`, others NULL - `author_person_id` is the all-zero sentinel `00000000-0000-0000-0000-000000000000` for auto-minted bindings; the `uq_person_observation` UNIQUE key (on `value_hash`) dedupes re-runs. `created_at` is taken from each observation's `identity_inputs._synced_at`, not from the seed wall-clock, so chronology in `persons` reflects the source's view of when each value was seen. -7. **Rebuild `account_person_map`** from `persons.value_type='id'` observations via `TRUNCATE` + `INSERT ... SELECT ... LEAD()` (see the account_person_map Semantics block for the SQL). Atomic single transaction. Drift relative to `persons` is impossible by construction. + **Timestamp-uniqueness obligation (seed side)**: `created_at` comes from `_synced_at`, and the natural key has no account discriminator — two accounts of the same source resolving to one person at the same `_synced_at` would collide on their `value_type='id'` rows and `INSERT IGNORE` would silently drop one binding. The seed write path must disambiguate per account (same obligation as the operator path, §3.7 index note); until that hardening ships this is a known gap of the same family as the divergent-group collapse. + + `author_person_id` is the all-zero sentinel `00000000-0000-0000-0000-000000000000` for auto-minted bindings; the `uq_person_observation` UNIQUE key (on `created_at`, migration 004) dedupes re-runs. `created_at` is taken from each observation's `identity_inputs._synced_at`, not from the seed wall-clock, so chronology in `persons` reflects the source's view of when each value was seen — and re-runs over the same input reproduce the same keys. +7. **Rebuild `account_person_map`** from `persons.value_type='id'` observations — tenant-scoped `DELETE` + `INSERT ... SELECT ... LEAD()` inside the same transaction as the observation writes (see the account_person_map Semantics block). Drift relative to `persons` is impossible by construction. -**Re-run semantics**: idempotent on `persons` (UNIQUE key dedupe), bit-identical on `account_person_map` (deterministic rebuild from same `persons` state). Adding a new source between runs creates new accounts; steady-state mode decides each one (skip-existing-email or new-person). +**Re-run semantics**: idempotent on `persons` (UNIQUE key dedupe — same input reproduces the same `created_at` keys), bit-identical on `account_person_map` (deterministic rebuild from same `persons` state). Adding a new source between runs creates new accounts; each is linked by e-mail when unambiguous or minted fresh. A consistently-bound account is never re-derived; the divergent-group collapse above is the one remaining path that can override a binding, and closing it is a precondition of ADR-0003 correction durability. + +**Conflict classification (ships with manual resolution)**: when an e-mail group's accounts are bound to more than one person, the seed will inspect the authors of the divergent bindings: any operator-authored binding marks the divergence as an intentional resolved state (not counted, not surfaced); all-seed divergence is counted in `known_binding_conflicts` and surfaced for review — and never reconciled by rewriting history. Prerequisite: the bindings loader must return `author_person_id` alongside `person_id` (today it returns only the person). See [ADR-0002](ADR/0002-stable-person-id-via-persons-observations.md) for the full decision record. **Safety / idempotency**: - Re-running the script is **safe**: the `account_person_map` lookup keeps `person_id` stable across runs, and `INSERT IGNORE` on `persons` skips duplicates. -- Steady-state re-runs with new sources **do not auto-merge** the new sources' accounts into existing persons — each gets a fresh `person_id`. Merging is an operator-driven workflow (future work). -- The script never issues `TRUNCATE`, `DELETE`, or `UPDATE` against `persons`. `account_person_map` is rebuilt via an atomic rename-swap (`CREATE account_person_map_next` → `INSERT ... LEAD()` → `RENAME TABLE account_person_map TO account_person_map_old, account_person_map_next TO account_person_map` → `DROP TABLE IF EXISTS account_person_map_old`); the rename is the only window where readers see the cache change, and it is atomic. Wipe-and-reseed of `persons` is an explicit operator action outside this script. +- Steady-state re-runs never merge two **existing** persons — no such code path exists. New accounts join an existing person only via the unambiguous e-mail link (`LinkedByEmail`); merging existing persons is an operator-only action (ADR-0003). +- The seed never issues `TRUNCATE`, `DELETE`, or `UPDATE` against `persons`. The only `DELETE` it performs is the tenant-scoped rebuild of the derived `account_person_map` cache, inside the same transaction as the observation writes. Wipe-and-reseed of `persons` is an explicit operator action outside the seed. **Prerequisites and ordering** (end-to-end bootstrap): @@ -907,168 +908,21 @@ See [ADR-0002](ADR/0002-stable-person-id-via-persons-observations.md) for the fu 3. `./src/ingestion/reconcile-connectors.sh` — registers connectors, creates Airbyte connections + per-connector CronWorkflows. (ClickHouse migrations run via the `clickhouse-migrate` Helm Hook Job on helm install/upgrade in step 2, not from a host script.) 4. Airbyte sync produces Bronze data (`./sync-all.sh` + wait). 5. dbt models run to populate `identity.identity_inputs` (`dbt run --select +identity_inputs`). -6. Seed run (`./src/backend/services/identity-resolution/seed/seed-persons.sh`) — invokes the Python seed. +6. Seed run — the `identity-resolution seed` subcommand, scheduled by the umbrella chart (or invoked manually for ad-hoc reseeds). --- -#### Table: `match_rules` - -**ID**: `cpt-insightspec-ir-dbtable-match-rules` +#### Future tables (design proposals — not implemented) -Configurable matching rules evaluated by the MatchingEngine. +The v2.0 design specified five further ClickHouse tables. **None of them exists in migrations or DDL today.** Their summaries are retained below for the future MatchingEngine iteration; their full column-level schemas live in the git history of this file (v2.0). -| Column | Type | Description | +| Table | Purpose (v2.0 proposal) | Status | |---|---|---| -| `id` | `UUID DEFAULT generateUUIDv7()` | PK | -| `insight_tenant_id` | `UUID` | Tenant isolation | -| `name` | `String` | Human-readable rule name (unique per tenant) | -| `rule_type` | `LowCardinality(String)` | `exact`, `normalization`, `cross_system`, `fuzzy` | -| `weight` | `Float32` | Rule weight for composite confidence | -| `is_enabled` | `UInt8` | 1 = enabled, 0 = disabled | -| `phase` | `LowCardinality(String)` | `B1`, `B2`, `B3` | -| `condition_type` | `LowCardinality(String)` | `email_exact`, `email_normalize`, `username_cross`, `name_fuzzy`, etc. | -| `config` | `String` | JSON — rule-specific parameters | -| `sort_order` | `UInt32` | Evaluation order | -| `actor_person_id` | `UUID` | Who last modified this rule | -| `created_at` | `DateTime64(3, 'UTC')` | Row creation time | -| `updated_at` | `DateTime64(3, 'UTC')` | Last modification time | - -**PK**: `id` - -**ORDER BY**: `(insight_tenant_id, sort_order, id)` - -**Engine**: `ReplacingMergeTree(updated_at)` - ---- - -#### Table: `unmapped` - -**ID**: `cpt-insightspec-ir-dbtable-unmapped` - -Observations that could not be resolved above the confidence threshold. Shared by identity-resolution domain (alias-level) and person domain (person-attribute-level) — differentiated by `value_type` values. Pending operator review. - -| Column | Type | Description | -|---|---|---| -| `id` | `UUID DEFAULT generateUUIDv7()` | PK | -| `insight_tenant_id` | `UUID` | Tenant isolation | -| `insight_source_id` | `UUID` | Source system ID | -| `insight_source_type` | `LowCardinality(String)` | Source type | -| `source_account_id` | `String` | Raw account ID from the source system | -| `value_type` | `LowCardinality(String)` | Value type (identity: `id`, `email`, `username`, `employee_id`, `platform_id`; person-attribute: `display_name`, `role`, `location`, etc.) | -| `value` | `String` | Alias value | -| `status` | `LowCardinality(String)` | `pending`, `in_review`, `resolved`, `ignored`, `auto_created` | -| `suggested_person_id` | `UUID` | Best-match person (zero UUID if none) | -| `suggestion_confidence` | `Float32` | Confidence of the suggestion (0.0 if none) | -| `resolved_person_id` | `UUID` | Person linked after resolution (zero UUID if unresolved) | -| `resolved_at` | `DateTime64(3, 'UTC')` | When resolved (`'1970-01-01'` if unresolved) | -| `resolved_by_person_id` | `UUID` | Who resolved (zero UUID if unresolved) | -| `resolution_type` | `LowCardinality(String)` | `linked`, `new_person`, `ignored`, empty string if unresolved | -| `first_observed_at` | `DateTime64(3, 'UTC')` | First time this unmapped alias was seen | -| `last_observed_at` | `DateTime64(3, 'UTC')` | Last time this unmapped alias was seen | -| `occurrence_count` | `UInt32` | Number of times this alias appeared | -| `created_at` | `DateTime64(3, 'UTC')` | Row creation time | -| `updated_at` | `DateTime64(3, 'UTC')` | Last modification time | - -**PK**: `id` - -**ORDER BY**: `(insight_tenant_id, status, value_type, value, id)` - -**Engine**: `ReplacingMergeTree(updated_at)` - ---- - -#### Table: `conflicts` - -**ID**: `cpt-insightspec-ir-dbtable-conflicts` - -Alias-level conflicts — when the same alias value is claimed by multiple persons. - -| Column | Type | Description | -|---|---|---| -| `id` | `UUID DEFAULT generateUUIDv7()` | PK | -| `insight_tenant_id` | `UUID` | Tenant isolation | -| `person_id_a` | `UUID` | First person claiming the alias | -| `person_id_b` | `UUID` | Second person claiming the alias | -| `value_type` | `LowCardinality(String)` | Conflicting alias type | -| `value` | `String` | Conflicting alias value | -| `insight_source_id_a` | `UUID` | Source instance providing person A's claim | -| `insight_source_type_a` | `LowCardinality(String)` | Source type for person A's claim | -| `insight_source_id_b` | `UUID` | Source instance providing person B's claim | -| `insight_source_type_b` | `LowCardinality(String)` | Source type for person B's claim | -| `status` | `LowCardinality(String)` | `open`, `resolved`, `ignored` | -| `resolved_by_person_id` | `UUID` | Who resolved (zero UUID if unresolved) | -| `resolved_at` | `DateTime64(3, 'UTC')` | When resolved (`'1970-01-01'` if unresolved) | -| `created_at` | `DateTime64(3, 'UTC')` | Row creation time | -| `updated_at` | `DateTime64(3, 'UTC')` | Last modification time | - -**PK**: `id` - -**ORDER BY**: `(insight_tenant_id, status, value_type, value, id)` - -**Engine**: `ReplacingMergeTree(updated_at)` - ---- - -#### Table: `merge_audits` - -**ID**: `cpt-insightspec-ir-dbtable-merge-audits` - -Full snapshot audit trail for merge/split operations. Late phase — schema only. - -> **REC-IR-01**: ClickHouse lacks row-level transactions. When implementing merge/split, use application-level advisory locking with idempotent two-step operations, or a lightweight coordination service (e.g., Redis lock) to serialize per person_id. If atomicity proves insufficient, consider MariaDB for this table only. See §5. - -| Column | Type | Description | -|---|---|---| -| `id` | `UUID DEFAULT generateUUIDv7()` | PK | -| `insight_tenant_id` | `UUID` | Tenant isolation | -| `action` | `LowCardinality(String)` | `merge`, `split`, `alias_add`, `alias_remove`, `status_change` | -| `target_person_id` | `UUID` | Person receiving aliases (merge target) | -| `source_person_id` | `UUID` | Person losing aliases (merge source; zero UUID for non-merge actions) | -| `snapshot_before` | `String` | JSON — full alias state before operation | -| `snapshot_after` | `String` | JSON — full alias state after operation | -| `reason` | `String` | Human-readable reason for the operation | -| `actor_person_id` | `UUID` | Who performed the operation | -| `performed_at` | `DateTime64(3, 'UTC')` | When the operation was performed | -| `is_rolled_back` | `UInt8` | 1 = rolled back, 0 = active | -| `rolled_back_at` | `DateTime64(3, 'UTC')` | When rolled back (`'1970-01-01'` if not) | -| `rolled_back_by_person_id` | `UUID` | Who rolled back (zero UUID if not) | -| `created_at` | `DateTime64(3, 'UTC')` | Row creation time | - -**PK**: `id` - -**ORDER BY**: `(insight_tenant_id, target_person_id, performed_at, id)` - -**Engine**: `MergeTree` - ---- - -#### Table: `alias_gdpr_deleted` - -**ID**: `cpt-insightspec-ir-dbtable-alias-gdpr-deleted` - -Archive table for GDPR-purged aliases. Structure mirrors `aliases` with additional purge metadata. Late phase — schema only. - -| Column | Type | Description | -|---|---|---| -| `id` | `UUID` | Original alias ID | -| `insight_tenant_id` | `UUID` | Tenant isolation | -| `person_id` | `UUID` | Person whose aliases were purged | -| `value_type` | `LowCardinality(String)` | Original alias type | -| `value` | `String` | Original alias value | -| `value_field_name` | `String` | Original source field path | -| `insight_source_id` | `UUID` | Original source system ID | -| `insight_source_type` | `LowCardinality(String)` | Original source type | -| `purged_at` | `DateTime64(3, 'UTC')` | When the purge was executed | -| `purged_by_person_id` | `UUID` | Who executed the purge | -| `created_at` | `DateTime64(3, 'UTC')` | Original alias creation time | - -**PK**: `id` - -**ORDER BY**: `(insight_tenant_id, person_id, purged_at, id)` - -**Engine**: `MergeTree` - -**TTL**: Consider adding TTL for automatic expiry per retention policy (organization-specific). +| `match_rules` | Configurable matching rules (type, weight, phase, enablement) for the MatchingEngine | Future — with the matcher (§4.2, #1765) | +| `unmapped` | Persistent operator queue of unresolved observations with statuses and suggestions | Superseded for v1: the review queue is **derived** from the `identity_inputs` active-evidence fold (per-account UPSERT/DELETE) joined with current `persons` bindings — no status columns to drift. A persistent proposal store may return with the matcher | +| `conflicts` | Alias-level conflict records (same value claimed by two persons) | Superseded for v1: conflicts are derived from the journal and classified by binding author (ADR-0003); a persistent record may return with the matcher | +| `merge_audits` | Snapshot-before/after audit trail for merge/split | **Superseded by ADR-0003**: the append-only `persons` journal plus the `operations` journal are the audit trail; merge/split are appended reassignments, not snapshot-restores | +| `alias_gdpr_deleted` | GDPR erasure archive (v2.0 proposal) | **Rejected as designed**: archiving plaintext values contradicts hard erasure. The future purge flow erases values in place across the physical stores (including the staging tables behind the `identity_inputs` union view and their upstream history) and keeps at most value-free tombstones (keyed HMAC digests) as a re-link deny-list consulted by transformations — see the PRD purge use case and #719 | --- @@ -1077,8 +931,10 @@ Archive table for GDPR-purged aliases. Structure mirrors `aliases` with addition ### 4.1 Min-Propagation Algorithm (ClickHouse-Native) > Source: `inbox/IDENTITY_RESOLUTION.md` +> +> **Status: future — not implemented.** Kept as candidate material for the matcher iteration (bulk verification / candidate generation). Note two ADR-0003 constraints on any future use: transitive auto-grouping must never write bindings (proposals only), and operator decisions in the journal override its output. -This is an **alternative implementation** of identity grouping — runs entirely in ClickHouse on `(token, rid)` pairs. The primary architecture uses BootstrapJob + MatchingEngine for incremental resolution; the min-propagation algorithm may be used for bulk initial grouping or as a verification tool to detect grouping inconsistencies. +This is an **alternative implementation** of identity grouping — runs entirely in ClickHouse on `(token, rid)` pairs. It may be used for bulk initial grouping or as a verification tool to detect grouping inconsistencies alongside the future MatchingEngine. **Input**: table of `(token, rid)` pairs where: - `token` — a value identifying a person (username, email, work_email, etc.), mapped from `value` in `identity_inputs` @@ -1115,12 +971,14 @@ Matching is always on **full token values** — no substring matching. | `hubspot` | `display_name`, `email` | From users + owners tables | | `youtrack` | `username`, `email` | | -Min-propagation and the BootstrapJob are **complementary**: min-propagation is a bulk seed / verification tool; BootstrapJob is the primary incremental path. +Min-propagation and the future MatchingEngine are **complementary**: min-propagation is a bulk verification / candidate-generation tool; the matcher is the incremental proposal path. Neither writes bindings (ADR-0003). --- ### 4.2 Matching Engine Phases +> **Status: future — not implemented.** Neither the MatchingEngine nor the `match_rules` table exists; today's only automatic rule is the seed's exact-e-mail grouping (ADR-0002). This section is the design direction for the future matcher (#1765, #796). Whatever the final shape, two invariants from ADR-0003 bind it: the matcher produces **proposals, never writes**, and operator decisions in the journal override any rule. + The MatchingEngine (`cpt-insightspec-ir-component-matching-engine`) evaluates rules in three phases, stored in the `match_rules` table. Rules are ordered by `sort_order` within each phase. **Value type vocabulary** (stored in `aliases.value_type` and `identity_inputs.value_type`). The `value_type` field is a free-form string, not an enum — the canonical list below is extensible, and connectors may emit any custom value-type on top. @@ -1141,7 +999,7 @@ The MatchingEngine (`cpt-insightspec-ir-component-matching-engine`) evaluates ru | `employee_id` | HR-system business identifier (e.g., BambooHR `CKSGP0002`) distinct from the connector's internal account id | `trim()` | | `platform_id` | Platform-specific opaque identifier where distinct from `source_account_id`. If equal to `source_account_id`, use `id` instead. | `trim()` | -**Phase B1 — Deterministic (MVP, auto-link threshold = 1.0)**: +**Phase B1 — Deterministic (highest-confidence proposals)**: | Rule (`condition_type`) | Confidence | Description | |---|---|---| @@ -1149,7 +1007,7 @@ The MatchingEngine (`cpt-insightspec-ir-component-matching-engine`) evaluates ru | `hr_id_match` | 1.0 | Identical `employee_id` from same `insight_source_type` | | `username_same_sys` | 0.95 | Same username within same `insight_source_type` | -**Phase B2 — Normalization & Cross-System (auto-link threshold >= 0.95)**: +**Phase B2 — Normalization & Cross-System (medium-confidence proposals)**: | Rule (`condition_type`) | Confidence | Description | |---|---|---| @@ -1159,17 +1017,19 @@ The MatchingEngine (`cpt-insightspec-ir-component-matching-engine`) evaluates ru | `username_cross_sys` | 0.85 | Same username across related systems (GitLab <-> GitHub <-> Jira) | | `email_to_username` | 0.72 | Email local part matches username in another system | -**Phase B3 — Fuzzy (disabled by default, NEVER auto-link)**: +**Phase B3 — Fuzzy (disabled by default; suggestions only, never eligible for bulk acceptance)**: | Rule (`condition_type`) | Confidence | Description | |---|---|---| | `name_jaro_winkler` | 0.75 | Jaro-Winkler similarity >= 0.95 on `display_name` | | `name_soundex` | 0.60 | Phonetic matching (Soundex) on `display_name` | -**Confidence thresholds**: -- `>= 1.0` — auto-link: create alias in `aliases` table -- `0.50–0.99` — suggestion: insert into `unmapped` with `suggested_person_id` -- `< 0.50` — unmapped: insert into `unmapped` as pending (no suggestion) +**Confidence bands** (order and gate proposals — per ADR-0003 no band ever writes a binding; the v2.0 auto-link semantics of this subsection are superseded): +- high (`>= 1.0` in the v2.0 rule catalogue) — high-confidence proposal; eligible for one-click / bulk acceptance by the operator +- medium (`0.50–0.99`) — suggestion proposal with the candidate person attached +- low (`< 0.50`) — surfaced for review without a candidate + +Acceptance of any proposal is an explicit operator act through the operator resolution API; rejection and deferral are first-class outcomes. **Email normalization pipeline**: ``` @@ -1182,89 +1042,49 @@ Input: "John.Doe+test@Acme.COM" --- -### 4.3 Merge and Split Operations +### 4.3 Operator Corrections (Merge / Split / Bind / Exclude) -> Late-phase implementation. Schema defined in §3.7 (`merge_audits` table); operational flow described here. +> Journal-based per [ADR-0003](ADR/0003-operator-decisions-as-persons-observations.md); supersedes the v2.0 snapshot-based merge/split over ClickHouse `aliases`/`merge_audits`. Reviewed design with worked scenarios (S1–S10): constructorfabric/insight#2180. Planned for the current iteration. -**Merge** — combines two person alias sets under a single `person_id`: +Every correction is an **appended binding observation** in `persons`, authored by the operator's UUID with a machine-readable reason; the request and free-text comment are journaled in `operations`. Current state is the latest binding per account; nothing is ever updated or deleted. A correction writes **binding observations only**: which identity values (e-mails, usernames) belong to which account is not encoded in the journal — an account's own `value_type='id'` row does carry the account id (in `value_id`), but attribute observations reference only the source instance — so the account↔value linkage comes from the `identity_inputs` evidence, where every row carries `source_account_id`. The resolver (§4.4) and the value-addressed bulk `bind` both consume that linkage; the service reads `identity_inputs` through the same infrastructure the seed already uses. -1. Snapshot current aliases for both `source_person_id` and `target_person_id` → `merge_audits.snapshot_before` (JSON) -2. Update all `aliases` rows: `SET person_id = target_person_id WHERE person_id = source_person_id` -3. Notify person domain that `source_person_id` aliases have been reassigned (domain event) -4. Snapshot merged alias state → `merge_audits.snapshot_after` -5. Run `ConflictDetector` on `target_person_id` to detect any new alias conflicts -6. Record `merge_audits` row with `action = 'merge'`, `actor_person_id`, `performed_at` +**Merge** — "these two persons are one human": for each account of the absorbed person, append a binding to the surviving person (`reason='operator-merge'`). The operator names the survivor explicitly. The absorbed person keeps its history and simply ends up with no current accounts. -**Split (rollback)** — restores alias mappings from `merge_audits.snapshot_before`: +**Detach / split** — "this account belongs to a different human": append a binding to a freshly minted person (`reason='operator-detach'`). Works on any account **regardless of how the current grouping arose** — no prior merge record is required (e.g. separating accounts grouped through a shared mailbox such as `team@example.com`). -1. Load `snapshot_before` from `merge_audits` WHERE `id = :audit_id` -2. Assert `is_rolled_back = 0` (prevent double rollback) -3. Restore alias → person_id mappings from snapshot (insert/update aliases) -4. Notify person domain of alias reassignment -5. Mark audit record: `is_rolled_back = 1`, `rolled_back_at`, `rolled_back_by_person_id` -6. Create new `merge_audits` record with `action = 'split'` +**Bind** — attach an account to a known person (`reason='operator-bind'`); single or bulk; also expresses "confirm" (bind-to-self on an account pending review — same person, now operator-authored, so the review item's condition dissolves) and pre-registration of accounts not yet observed. -**Idempotency**: All operations use idempotency keys. If a merge/split request is replayed with the same `Idempotency-Key`, the existing `merge_audits` record is returned without re-executing. +**Exclude** — "not a human" (bot/CI/service): bind to the reserved excluded-person sentinel (`reason='operator-exclude'`). -**ClickHouse considerations**: Unlike RDBMS ACID transactions, ClickHouse merge/split operations are implemented as a sequence of idempotent writes. The `snapshot_before` / `snapshot_after` JSON payloads enable full state reconstruction if any step fails. The `is_rolled_back` flag prevents double-execution. +**The excluded-person sentinel** (normative definition): the fixed UUID `ffffffff-ffff-ffff-ffff-ffffffffffff`. One global constant across tenants (rows carrying it remain tenant-scoped); collision-free by construction (UUIDv7 version/variant bits make this value unmintable). Consumers treat it uniformly as "no person": the resolve macro maps it to NULL (§4.4); the read API never serves it as a person — a lookup landing on it reports the account as not resolvable to a person; the person domain ignores observations bound to it when building golden records; the review queue hides accounts whose effective binding is the sentinel. + +**Undo** — a counter-action: a newer operator binding that re-points the account. History retains the mistake, the fix, and both authors; the pre-correction state is always reconstructible from the journal. + +**Durability**: corrections live in the same journal the seed reads, so no parallel override store exists to drift. Enablers shipping with the feature: the seed hardening that makes per-account bindings win over group collapse — closing the one path that could override an operator row; decision-aware API-level idempotency (§3.3 — a correction is a no-op only when an identical operator decision is already recorded, so confirmations still write); and unique per-row observation timestamps on the write path (§3.7 index note — the natural key has no account discriminator). + +**Downstream**: `person_id` is recomputed by the resolve macro on every gold build. For corrections to re-attribute history, the macro's account-first upgrade (§4.4) is required: corrections are `value_type='id'` bindings keyed by source account, while the v1 macro resolves by e-mail only. Account-carrying facts then follow the binding directly; e-mail-keyed facts (e.g. git commits) follow through the account-derived e-mail fallback (§4.4), with contested e-mails resolving to NULL. Facts keyed by an e-mail that was never observed on any account remain NULL — re-attribution is complete for observed identities, not a promise about unknown e-mails. --- -### 4.4 ClickHouse Integration Patterns - -Since all identity resolution tables are native ClickHouse tables, there is no External Database Engine or RDBMS sync layer. Integration patterns are simplified: - -**Dictionary for hot-path alias lookup** (optional optimization): - -A ClickHouse Dictionary can be created from the `aliases` table for sub-millisecond lookups in analytical queries (e.g., enriching Silver step 2 tables with `person_id`): - -```xml - - identity_alias - - - aliases
- is_active = 1 AND is_deleted = 0 -
- - 3060 - - - - insight_tenant_idUUID - value_typeString - valueString - - person_idUUID00000000-0000-0000-0000-000000000000 - -
-``` +### 4.4 Analytics Integration (mirror + resolve macro) -**Silver step 2 enrichment** (dict lookup in dbt/SQL): +The implemented analytics integration has two moving parts: -```sql -SELECT - c.*, - dictGetOrDefault('identity_alias', 'person_id', - tuple(c.insight_tenant_id, 'email', c.author_email), - toUUID('00000000-0000-0000-0000-000000000000')) AS person_id -FROM silver.class_commits c -``` +**Mirror**: persons-sync republishes the MariaDB `persons` journal into ClickHouse `identity.identity_persons` as an atomic snapshot swap (`EXCHANGE TABLES`) — readers never observe a partial mirror. The mirror is a plain copy; no resolution semantics are applied at sync time. -**Direct table join** (alternative to Dictionary, no cache lag): - -```sql -SELECT c.*, a.person_id -FROM silver.class_commits c -LEFT JOIN aliases a - ON a.insight_tenant_id = c.insight_tenant_id - AND a.value_type = 'email' - AND a.value = c.author_email - AND a.is_active = 1 - AND a.is_deleted = 0 -``` +**Resolve macro**: the dbt macro `resolve_person_id` is **deliberately the only place resolution semantics live** for analytics. Every gold model that needs a `person_id` calls it against the mirror at build time. + +**v1 rule (implemented)**: the macro emits a current `(email → person_id)` map — the latest `value_type='email'` observation per normalized e-mail claims it (`LIMIT 1 BY email`, `created_at DESC, id DESC` tiebreak) — and gold models LEFT JOIN it on the fact's normalized e-mail (`resolved_person_id_join`). Unresolved facts yield an honest NULL (absent rather than guessed). Consequence: **only e-mail-keyed changes reach gold**; a correction expressed as a `value_type='id'` binding is invisible to this map. + +**Account-first upgrade (ships with manual resolution — required by ADR-0003)**: the macro additionally emits an account map keyed by the **source-instance-scoped account identity** — `(insight_source_type, insight_source_id, source_account_id)` — from the latest `value_type='id'` binding per account. Source type and instance are mandatory key parts because account ids are unique only within one source instance; a truncated key would cross-link identical ids from different connectors. **Tenant is deliberately not a join column between evidence and journal**: `identity_inputs` carries a producer-side *hashed* tenant id while the journal carries the caller's tenant (see the reader note in §3.2 PersonsSeed), so an `insight_tenant_id` equijoin across the two stores would match nothing. The key above is consistent across both stores because the journal's source ids originate from the evidence rows. Tenant scoping is applied within the journal/mirror side; cross-store tenant joins become possible only after tenant-id normalization (REC-IR-04 `tenants` table) — the already-noted multi-tenant prerequisite. Facts that carry a source account resolve **account-first** on this key — this explicitly covers the **reviewer namespace**: platforms report code-review identities as a login/account id rather than an e-mail, so review facts resolve through the id-binding map without any e-mail bridge. Facts without an account (e.g. git commits, which are e-mail-keyed by nature) fall back to the e-mail map. The e-mail fallback is **account-derived**, so it follows corrections without any extra writes: `identity_inputs` carries `source_account_id` on every row, so the macro maps each e-mail to the accounts currently observing it (latest evidence per account, joined on the same source-instance-scoped key) and resolves those accounts through the id-binding map. All observing accounts on one person → the e-mail resolves to that person; more than one person (a genuinely shared value) or none → **NULL** (contested evidence is excluded, not tie-broken); the excluded-person sentinel maps to NULL. Known limit: a fact keyed by an e-mail never observed in `identity_inputs` has nothing to follow and stays NULL. Consistency: a build reads one snapshot of the evidence and one atomically-swapped mirror snapshot. Future smarts — per-source maps, as-of resolution, matcher output — keep changing only the macro body. + +Consequences of this shape: -The Dictionary approach trades a 30-60s cache lag for faster lookup in high-throughput analytical queries. The direct join approach is always consistent. Choice depends on query pattern and volume. +- **Retroactive re-attribution**: the macro recomputes `person_id` on every build, so once the account-first upgrade lands, operator corrections (§4.3) move complete activity histories without re-keying jobs; under v1 this held only for e-mail-keyed changes. +- **Build-scoped consistency**: a single build resolves every model against one mirror snapshot, so all gold tables of one build agree on who is who. +- **No request-path coupling**: analytics never queries MariaDB or the service; the mirror decouples build load from the journal store. + +(The v2.0 pattern — a ClickHouse Dictionary or direct JOIN over the `aliases` table — is retired with that table; see §3.7 legacy note. Its normalization caveat survives as a rule for the macro: comparisons must be applied to normalised values on both sides.) --- @@ -1286,31 +1106,27 @@ The Dictionary approach trades a 30-60s cache lag for faster lookup in high-thro | `gitlab` | `17` | `username` | `ivanova.anna` | `bronze_gitlab.users.username` | | `jira` | `aivanova` | `username` | `aivanova` | `bronze_jira.users.name` | -**Step 2 — BootstrapJob processes inputs**: - -1. BambooHR `employee_id:E123` — Phase 1 MVP: dbt seed creates person `id = p-1001` in `persons` table (person domain). BootstrapJob creates alias `(employee_id, E123) → p-1001`. -2. BambooHR `email:anna.ivanova@acme.com` — MatchingEngine B1 `email_exact` → confidence 1.0 → auto-link to `p-1001`. -3. AD `username:aivanova` — no exact match → MatchingEngine B2 → no match → `unmapped` (pending). -4. AD `email:anna.smirnova@acme.com` — no exact match → `unmapped` (pending). (Later: operator links to `p-1001`.) -5. GitHub `username:annai` — no match → `unmapped` (pending). -6. GitLab `username:ivanova.anna` — no match → `unmapped` (pending). -7. Jira `username:aivanova` — matches AD username cross-system (B2 `username_cross_sys`, confidence 0.85) → `unmapped` with suggestion `p-1001`. +**Step 2 — persons-seed folds the observations** (three-mode logic, ADR-0002): -**Step 3 — Operator resolves unmapped**: +1. BambooHR account `E123` — unknown, e-mail `anna.ivanova@acme.com` is new → mint person `p-1001`; observations appended under it. +2. AD account `aivanova` — unknown; its e-mail `anna.smirnova@acme.com` is new (the name changed) → mint separate person `p-1002`. The seed does not guess that `aivanova` is Anna — usernames are not linking evidence in v1. +3. GitHub `42`, GitLab `17` — unknown, no e-mail observed → not auto-bound; they surface in the review queue as no-evidence accounts (#1776) awaiting an operator bind. +4. Jira `aivanova` — unknown, no e-mail → skipped likewise. -Operator reviews unmapped queue, links `aivanova` (AD), `anna.smirnova@acme.com` (AD), `annai` (GitHub), `ivanova.anna` (GitLab), `aivanova` (Jira) to `p-1001`. All become active aliases. +**Step 3 — Operator corrects** (ADR-0003 verbs): -**Step 4 — Alias conflict detected**: +- `merge p-1002 → p-1001` ("AD is the same Anna after the name change") — one appended binding for `aivanova`, authored by the operator. +- `bind github:42 → p-1001`, `bind gitlab:17 → p-1001`, `bind jira:aivanova → p-1001` — or one bulk `bind` call with all three rows. -Both BambooHR and AD claim `email` alias for `p-1001` but with different values (`anna.ivanova@acme.com` vs `anna.smirnova@acme.com`). This is NOT an alias conflict (different values = different aliases, both valid). If both sources claimed the **same** email value for **different** persons, ConflictDetector would flag it. +**Step 4 — What re-runs cannot break**: -> **Note**: Person-level attribute conflicts (BambooHR says `department=Engineering`, AD says `department=Platform Engineering`) are detected by the **person domain**, not identity resolution. Identity resolution only handles alias-level conflicts. +The next seed run sees every one of these accounts as *known* (a binding exists) and reuses their bindings. A later account arriving with `anna.ivanova@acme.com` auto-links to `p-1001` (`LinkedByEmail` — the e-mail is unambiguous). With the contested-evidence hardening, an e-mail claimed by *two* persons would surface for review instead of linking; and a divergent-group collapse can no longer override the operator's merge of `p-1002`. -**Step 5 — Silver step 2 enrichment**: +**Step 5 — Analytics**: -`class_commits` enriched with `person_id = p-1001` via Dictionary or direct join on `aliases`. All commits from GitHub (`annai`) and GitLab (`ivanova.anna`) now linked to `p-1001`. +Under the v1 e-mail map, Anna's activity resolves per fact e-mail — the operator's account bindings for GitHub/GitLab/Jira do not reach gold yet. With the account-first macro upgrade (§4.4), all facts carrying those accounts — including all history from before the corrections — attribute to `p-1001`; git commits keep resolving via their commit e-mails. -**Result**: Person `p-1001` has 7 aliases across 5 sources, all correctly resolved. Gold analytics attribute all activity to one person. +**Result**: one person, six accounts across five sources; two appended operator decisions did the work the automation could not — durable in the journal, and visible end-to-end once the resolver upgrade lands. --- @@ -1340,7 +1156,7 @@ This walkthrough demonstrates the min-propagation algorithm (§4.1) as a verific **Min-propagation result**: `rid(b1)`, `rid(c1)`, `rid(c2)`, `rid(c3)`, `rid(y1)` → all converge to same minimum group ID → `profile_group_id = 1`. -**Verification**: Compare min-propagation grouping with BootstrapJob + MatchingEngine results. If they disagree, investigate which aliases are missing or incorrectly linked. +**Verification**: Compare min-propagation grouping with the journal's current bindings (and, later, the MatchingEngine's proposals). If they disagree, investigate which links are missing or incorrect — as review input, never as an automatic write. --- @@ -1354,36 +1170,38 @@ This walkthrough demonstrates the min-propagation algorithm (§4.1) as a verific |---|---|---| | ClickHouse | StatefulSet (shared cluster) | Per cluster sizing | | MariaDB | StatefulSet (Bitnami chart, shared cluster) | Per cluster sizing | -| identity-resolution | Deployment (horizontal scaling) — Rust `axum` service | 0.5 CPU, 256 MB RAM per replica | +| identity-resolution | Deployment (horizontal scaling) — Rust `axum` service; runs the persons-sync worker | 0.5 CPU, 256 MB RAM per replica | | identity-resolution migrate | InitContainer / one-shot Job — applies MariaDB schema via embedded SeaORM `Migrator` | 0.1 CPU, 64 MB RAM | -| BootstrapJob (Phase 2+, not yet built) | Argo WorkflowTemplate (scheduled) | 0.5 CPU, 512 MB RAM per run | +| persons-seed | Scheduled job (umbrella chart) running the service image with the `seed` subcommand; run-locked, guarded, journaled in `operations` | 0.25 CPU, 256 MB RAM per run | +| MatchingEngine (future, not built) | TBD with the matcher design | — | -**identity-resolution** is a stateless Rust (axum) service. It owns the MariaDB `identity` database — `persons` (observation history) and `account_person_map` (SCD2 cache rebuilt from `persons.value_type='id'`) — with migrations applied at startup via SeaORM `Migrator` (see ADR-0006). The service does not read Bronze tables directly; observation history flows in through `identity.identity_inputs` (populated by the per-connector dbt models that use the `identity_inputs_from_history` macro), is projected into `persons` by the seed, and is consumed at runtime via the `account_person_map` cache. Horizontal scaling via Kubernetes replicas. +**identity-resolution** is a stateless Rust (axum) service. It owns the MariaDB `identity` database — `persons` (observation journal), `account_person_map` (SCD2 cache rebuilt from `persons.value_type='id'`), `operations` (admin-operation journal) — with migrations applied at startup via SeaORM `Migrator` (see ADR-0006). The service does not read Bronze tables directly; observations flow in through `identity.identity_inputs` (populated by the per-connector dbt models that use the `identity_inputs_from_history` macro) and are folded into `persons` by the seed. The persons-sync worker republishes the journal to ClickHouse for analytics. Horizontal scaling via Kubernetes replicas. -**Initial `persons` seed** is a one-shot script (`src/backend/services/identity-resolution/seed/seed-persons-from-identity-input.py`) that reads ClickHouse `identity.identity_inputs` and writes MariaDB `persons` + `account_person_map`. Idempotent via `INSERT IGNORE`. See ADR-0002. +**persons-seed** runs as a scheduled job (the service image with the `seed` subcommand — issue #1690): run-locked, input-guarded (`--force` for deliberate overrides), journaled in `operations`, idempotent via `INSERT IGNORE`. See ADR-0002. The original one-shot Python seed remains under `seed/` for reference. -**BootstrapJob** (Phase 2+) will run as an Argo Workflow, triggered post-connector-sync. Each run is idempotent — safe to retry on failure. +**Operator resolution API** (planned, ADR-0003) ships inside the same service — no new deployable. -**Environment**: Kind K8s cluster with Argo Workflows (per PR #45 migration). +**Environment**: Kubernetes (Kind locally); Argo Workflows is not used by this domain today (it was the v2.0 plan for the BootstrapJob and may return with the matcher). --- ### 4.8 Operational Considerations -**Monitoring metrics**: +**Monitoring observables** (implemented and planned): -| Metric | Description | Alert Threshold | -|---|---|---| -| `unmapped_rate` | Fraction of aliases with no resolved `person_id` | > 20% | -| `bootstrap_processing_lag` | Time from `identity_inputs._synced_at` to alias creation in `aliases` | > 30 min | -| `conflict_rate` | Rate of new `conflicts` table entries per hour | > 10/hour | -| `resolution_latency_p99` | p99 latency of `POST /resolve` endpoint | > 50 ms | -| `merge_audit_count` | Number of merge/split operations per day | Informational | +| Observable | Source | Description | Attention Threshold | +|---|---|---|---| +| Unresolved activity share | `identity_resolution_coverage` view (gold) | Share of activity (not of aliases) without a resolved `person_id`, per source — counts the operator decisions that would close the gap | Trend-based; investigate growth | +| `known_binding_conflicts` | Seed run summary in `operations` | Seed-authored binding divergence inside e-mail groups (operator-authored divergence excluded by classification) | > 0 new per run | +| Pending-decision count + match rate | Review queue (planned, `/v1/resolution/attention`; derived from evidence + bindings) | Accounts pending a decision, contested-binding groups, no-evidence accounts; resolution-rate shares | Backlog growth week-over-week | +| Seed run outcome | `operations` journal | Failed/refused runs (guard rejections) | Any failed run | +| Mirror freshness | persons-sync | Time since last successful `identity.identity_persons` swap | > 1 sync interval | +| Person lookup latency | identity-resolution service | p99 of `/v1/profiles` | > 50 ms | **SLA targets**: -- Alias lookup latency (hot path): < 50 ms p99 -- Bootstrap processing: < 30 min after connector sync completes -- Dashboard visibility (Silver step 2 enrichment): < 60 min after alias creation +- Person lookup latency (service read path): < 50 ms p99 +- Seed processing: < 30 min after connector sync completes +- Correction visibility in dashboards: next gold build after the correction (build-scoped snapshot) **Capacity planning**: - `identity_inputs`: grows linearly with connector syncs. Each sync produces O(changed_accounts * aliases_per_account) rows. TTL-based expiry recommended after processing. @@ -1400,20 +1218,17 @@ This walkthrough demonstrates the min-propagation algorithm (§4.1) as a verific Recommendations for later implementation phases. Not blocking for current scope. -### REC-IR-01: ClickHouse atomicity for merge/split (Phase 3+) +### REC-IR-01: ClickHouse atomicity for merge/split — SUPERSEDED -Merge/split operations require moving aliases between persons atomically with a snapshot in `merge_audits`. ClickHouse does not support row-level transactions. When implementing merge/split (late phase), use one of: -- Application-level advisory locking + idempotent two-step operations (mark source aliases → verify → move) -- Lightweight coordination service (e.g., Redis lock) to serialize merge/split per person_id -- If atomicity proves insufficient, consider introducing MariaDB for `merge_audits` only, with ClickHouse for read-path +**Superseded by [ADR-0003](ADR/0003-operator-decisions-as-persons-observations.md)**: merge/split are journal appends in transactional MariaDB (`persons` + `operations`), so the ClickHouse atomicity problem this recommendation worked around no longer exists on the correction path. Retained for history; revisit only if a future matcher needs bulk mutations on ClickHouse-side tables. -### REC-IR-02: Incremental watermark for identity inputs (Phase 2) +### REC-IR-02: Incremental watermark for identity inputs (open) -BootstrapJob tracks "last run" position to process only new `identity_inputs` rows. Recommended mechanism: dbt incremental model with `_synced_at` as the cursor column, using ClickHouse `ReplacingMergeTree` to ensure idempotent re-processing. Store the high-watermark in a dedicated ClickHouse table (`bootstrap_watermarks`) keyed by `(insight_tenant_id, job_name)`, updated atomically at end of each successful run. +The persons-seed currently folds the **full** `identity_inputs` set on every run (correct but O(history) — see `cpt-ir-fr-bootstrap-incremental`). Recommended mechanism when it becomes a bottleneck: track a "last processed" position with `_synced_at` as the cursor column and store the high-watermark in a dedicated table keyed by `(insight_tenant_id, job_name)`, updated atomically at the end of each successful run. Note the interaction with the known-account rule: a watermarked run sees only new observations, so binding reuse must keep working from the journal, not from the evidence window. ### REC-IR-03: Shared unmapped table for all domains — RESOLVED -**Decision**: Use a single shared `unmapped` table (owned by IR domain) for both alias-level and person-attribute-level unmapped observations. See [ADR-0001: Shared unmapped table](../../person/specs/ADR/0001-shared-unmapped-table.md) (`cpt-ir-adr-shared-unmapped`) for full rationale. +**Decision**: Use a single shared operator queue (owned by the IR domain) for both alias-level and person-attribute-level unresolved observations. See [ADR-0001: Shared unmapped table](../../person/specs/ADR/0001-shared-unmapped-table.md) for full rationale (also listed in §1.2 key decision records). In v1 the shared queue is realised as the derived review queue; the shared persistent table remains the plan if/when the matcher lands. Reason: identical structure (both carry `insight_tenant_id`, `insight_source_id`, `insight_source_type`, `source_account_id`, `value_type`, `value`) and common data origin (`identity_inputs`). Differentiation by `value_type` values is sufficient — identity value types (`id`, `email`, `username`, `employee_id`, `platform_id`) vs person-attribute types (`display_name`, `role`, `location`, etc.). No separate `person_unmapped` table needed. @@ -1429,7 +1244,8 @@ Phase 1 seed and connector models derive `insight_tenant_id` (UUID) and `insight **Why temporary**: The PR #55 convention requires `insight_tenant_id` / `insight_source_id` to be real UUIDv7 foreign keys referencing future `tenants` / `sources` tables. Until those exist, the deterministic hash ensures: - The same Bronze identifier always produces the same UUID across all models. - No collision risk within realistic tenant counts (sipHash128 is 128-bit). -- The value is query-joinable across `persons`, `account_person_map`, `aliases`, and `identity_inputs`. +- `insight_source_id` is query-joinable across `persons`, `account_person_map`, `aliases`, and `identity_inputs` — the journal inherits it from the evidence rows. +- `insight_tenant_id` is **not** cross-store joinable: the evidence carries the producer-side hash while the journal carries the caller's tenant (see §3.2 PersonsSeed and §4.4). Tenant joins across stores become possible only once the `tenants` table replaces the hash. **Migration path**: When `tenants` / `sources` tables are created, replace all `toUUID(UUIDNumToString(sipHash128(...)))` calls with a lookup join (e.g., `JOIN tenants t ON t.external_id = cm.tenant_id`). All affected files are marked with `-- TEMPORARY: sipHash128` comments. Search: `grep -r "TEMPORARY.*sipHash128" src/ingestion/`. @@ -1462,6 +1278,10 @@ Phase 1 seed and connector models derive `insight_tenant_id` (UUID) and `insight - **PRD**: [PRD.md](./PRD.md) - **DECOMPOSITION**: [DECOMPOSITION.md](./DECOMPOSITION.md) +- **ADR-0002**: [Stable `person_id` via append-only `persons` observations](ADR/0002-stable-person-id-via-persons-observations.md) — the implemented binding model +- **ADR-0003**: [Operator identity corrections as append-only `persons` observations](ADR/0003-operator-decisions-as-persons-observations.md) — the correction model; supersedes snapshot-based merge/split +- **Reviewed correction design**: constructorfabric/insight#2180 (scenarios S1–S10, API shapes); umbrella vision: #1873 +- **Component spec**: `docs/components/backend/identity-resolution/identity/specs/` — the live service (read API, schema rules, service ADRs) - **Features**: features/ (to be created from DECOMPOSITION entries) - **Source V2**: `inbox/architecture/IDENTITY_RESOLUTION_V2.md` — MariaDB reference; matching engine, merge/split, API, phases B1–B3 - **Source V3**: `inbox/architecture/IDENTITY_RESOLUTION_V3.md` — Silver layer contract, PostgreSQL added, Bronze → Silver position diff --git a/docs/domain/identity-resolution/specs/PRD.md b/docs/domain/identity-resolution/specs/PRD.md index 2f9d408ab..616f1b913 100644 --- a/docs/domain/identity-resolution/specs/PRD.md +++ b/docs/domain/identity-resolution/specs/PRD.md @@ -16,10 +16,11 @@ - [4.1 In Scope](#41-in-scope) - [4.2 Out of Scope](#42-out-of-scope) - [5. Functional Requirements](#5-functional-requirements) - - [5.1 Phase 1 — MVP: dbt Seed (p1)](#51-phase-1--mvp-dbt-seed-p1) - - [5.2 Phase 2 — Bootstrap Pipeline (p1)](#52-phase-2--bootstrap-pipeline-p1) - - [5.3 Phase 3 — Matching & Workflows (p2)](#53-phase-3--matching--workflows-p2) - - [5.4 Late Phase — Merge/Split & GDPR (p3)](#54-late-phase--mergesplit--gdpr-p3) + - [5.1 Identity Store and Resolution (p1)](#51-identity-store-and-resolution-p1) + - [5.2 Evidence Intake and Seed Fold (p1)](#52-evidence-intake-and-seed-fold-p1) + - [5.3 Phase 3 — Matching & Workflows (p2, future)](#53-phase-3--matching--workflows-p2-future) + - [5.4 Manual Resolution — Operator Corrections (p1)](#54-manual-resolution--operator-corrections-p1) + - [5.5 Late Phase — GDPR (p3)](#55-late-phase--gdpr-p3) - [6. Non-Functional Requirements](#6-non-functional-requirements) - [6.1 NFR Inclusions](#61-nfr-inclusions) - [6.2 NFR Exclusions](#62-nfr-exclusions) @@ -28,10 +29,10 @@ - [7.2 External Integration Contracts](#72-external-integration-contracts) - [8. Use Cases](#8-use-cases) - [Bootstrap New Connector Data](#bootstrap-new-connector-data) - - [Resolve Alias (Hot Path)](#resolve-alias-hot-path) - - [Review Unmapped Aliases](#review-unmapped-aliases) - - [Merge Two Person Alias Sets](#merge-two-person-alias-sets) - - [GDPR Alias Purge](#gdpr-alias-purge) + - [Resolve Person for Analytics and Backend](#resolve-person-for-analytics-and-backend) + - [Review Pending Identity Decisions](#review-pending-identity-decisions) + - [Merge Two Persons](#merge-two-persons) + - [GDPR Identity Purge](#gdpr-identity-purge) - [9. Acceptance Criteria](#9-acceptance-criteria) - [10. Dependencies](#10-dependencies) - [11. Assumptions](#11-assumptions) @@ -65,11 +66,11 @@ Insight connects to 10+ external platforms (GitLab, GitHub, Jira, YouTrack, Bamb | Goal | Success Criteria | |---|---| -| Automated alias resolution | **Baseline**: 0% auto-resolved. **Target**: >= 80% of aliases auto-resolved within 30 min of connector sync. **Timeframe**: Within 2 sprints of Phase 2 deployment. | -| Zero false-positive auto-links | **Baseline**: N/A (new system). **Target**: 0 false-positive auto-links in production over 90-day window. **Timeframe**: Ongoing from Phase 2 launch. | -| Operator efficiency | **Baseline**: 100% manual mapping. **Target**: Operator reviews < 20% of aliases (unmapped queue only). **Timeframe**: Within 30 days of Phase 3 deployment. | -| Cross-platform analytics enablement | **Baseline**: Per-platform siloed dashboards. **Target**: 100% of Gold analytics queries use resolved `person_id`. **Timeframe**: Within 1 sprint of Phase 1 deployment. | -| Audit trail completeness | **Baseline**: No merge/split tracking. **Target**: 100% of merge/split operations have reversible audit records. **Timeframe**: From late-phase deployment. | +| Automated account binding | **Baseline**: 0% auto-bound. **Target**: >= 80% of observed accounts bound automatically within 30 min of connector sync. **Timeframe**: Within 2 sprints of the evidence-intake phase. | +| Zero silent false merges | **Baseline**: N/A (new system). **Target**: 0 automatic merges of two existing persons — structurally excluded; 0 false-positive automatic links over any 90-day window. **Timeframe**: Ongoing. | +| Operator efficiency | **Baseline**: 100% manual mapping. **Target**: Operator reviews only the pending/contested queue — < 20% of observed accounts. **Timeframe**: Within 30 days of the manual-resolution capability shipping. | +| Cross-platform analytics enablement | **Baseline**: Per-platform siloed dashboards. **Target**: 100% of Gold analytics queries use resolved `person_id`. **Timeframe**: Within 1 sprint of the identity-store phase. | +| Audit trail completeness | **Baseline**: No correction tracking. **Target**: 100% of operator corrections are attributable (who/when/why) and reversible. **Timeframe**: From the manual-resolution capability. | ### 1.4 Glossary @@ -79,15 +80,16 @@ Insight connects to 10+ external platforms (GitLab, GitHub, Jira, YouTrack, Bamb | Alias type | Category of identity signal: canonical `id`, `email`, `username`, `display_name`; known custom `employee_id`, `platform_id` (deprecated for Cursor/Claude Admin in favor of `id`, see ADR-0002) | | Bootstrap input | A row in `identity_inputs` representing one changed alias observation from one connector | | Confidence score | Numeric value (0.0–1.0) representing the MatchingEngine's certainty that an alias belongs to a person | -| Auto-link | Automatic creation of an alias mapping when confidence >= 1.0 | -| Unmapped alias | An alias that could not be resolved above the confidence threshold; queued for operator review | +| Auto-link | (v1 plan vocabulary — superseded by ADR-0003) automatic creation of a link at full confidence. In the implemented model the only automatic link is the seed's unambiguous e-mail bind; the future matcher emits operator-reviewed proposals only | +| Unmapped alias | (v1 plan vocabulary) an alias below the auto-link threshold, queued for review; in the implemented model the equivalent is an account or value pending an operator decision in the derived review queue | | Alias conflict | When the same `(value_type, value)` is claimed by two different persons | -| Merge | Combining two person alias sets under a single `person_id` | -| Split | Reversing a merge by restoring alias mappings from an audit snapshot | -| Hot path | Direct alias lookup in `aliases` table (~90% of resolutions) | -| Cold path | MatchingEngine rule evaluation when hot path misses | +| Merge | Operator decision that two persons are one human: all account bindings of the absorbed person are reassigned to the surviving person (append-only; see ADR-0003) | +| Split / Detach | Operator decision that an account belongs to a different human: the account is rebound to another (usually freshly minted) person — regardless of how the current grouping arose (see ADR-0003) | +| Hot path | Direct binding lookup against the journal (service read API) or the mirror (analytics) | +| Cold path | Future MatchingEngine rule evaluation when no binding exists | | Person domain | Separate domain owning the **golden record projection** of persons (and person-level attributes such as availability, conflicts). The MariaDB `persons` identity-attribute history table itself is owned by the identity-resolution domain (see DESIGN §3.7 and ADR-0006); the Person domain reads from it | | Org-chart domain | Separate domain owning `org_units` and `person_assignments` | +| Reviewer namespace | Code-review identities as reported by platforms — a login/account id with no e-mail; resolved account-first through the id-binding map, with no e-mail bridge | --- @@ -101,9 +103,9 @@ Insight connects to 10+ external platforms (GitLab, GitHub, Jira, YouTrack, Bamb **ID**: `cpt-ir-actor-operator` -**Role**: Reviews unmapped aliases, resolves alias conflicts, manages match rule configuration, performs merge/split operations, and handles GDPR purge requests. Typically a platform administrator with knowledge of the organization's systems and personnel. +**Role**: Reviews accounts pending a decision and contested-binding groups, performs corrections (bind, merge, detach, exclude), and — in later phases — manages match-rule configuration and GDPR purge requests. Typically a platform administrator with knowledge of the organization's systems and personnel. -**Needs**: A clear queue of unresolved aliases with suggested matches; ability to link, ignore, or create new persons; visibility into merge/split history for audit; configurable match rules. +**Needs**: A clear review queue with candidate persons per item; correction verbs covering both directions (same person / different people), individually and in bulk; per-account binding history to understand and audit past decisions; assurance that decisions survive automatic re-runs. ### 2.2 System Actors @@ -113,23 +115,23 @@ Insight connects to 10+ external platforms (GitLab, GitHub, Jira, YouTrack, Bamb **Role**: External system connector (e.g., BambooHR, GitLab, Jira) that syncs data from source platforms. Writes alias observations to `identity_inputs` during its sync pipeline, providing raw identity signals for resolution. -#### BootstrapJob +#### Automatic Binding Job (persons-seed) **ID**: `cpt-ir-actor-bootstrap-job` -**Role**: Scheduled job (Argo Workflow) that reads unprocessed rows from `identity_inputs`, normalizes alias values, evaluates matching rules, and creates or updates entries in the `aliases` table. Runs after each connector sync cycle. +**Role**: Scheduled job (the `seed` subcommand of the identity-resolution service) that reads observations from `identity_inputs` and folds them into the `persons` journal: reuses existing bindings, links unbound accounts to the person their e-mail unambiguously maps to, mints persons for new e-mails, skips e-mail-less accounts. Never merges two existing persons. Contested-evidence handling (no auto-link when evidence is ambiguous; respect for per-account bindings in divergent groups) ships with the manual-resolution capability. Runs after connector sync cycles. -#### MatchingEngine +#### MatchingEngine (future) **ID**: `cpt-ir-actor-matching-engine` -**Role**: Rule evaluation engine invoked by BootstrapJob and ResolutionService on the cold path. Loads enabled `match_rules`, computes composite confidence scores, and returns candidate `person_id` with confidence level. Does not write to tables directly. +**Role**: Future rule-evaluation engine. Loads enabled `match_rules`, computes composite confidence scores, and produces merge **proposals** for operator review. Never writes bindings; operator decisions override its output. Not built. #### Analytics Pipeline **ID**: `cpt-ir-actor-analytics-pipeline` -**Role**: Downstream consumer (dbt models, dashboards) that resolves `person_id` by joining Silver tables against the `aliases` table or ClickHouse Dictionary. Depends on alias data being accurate and up-to-date for cross-platform attribution. +**Role**: Downstream consumer (dbt models, dashboards) that resolves `person_id` at build time via the `identity.identity_persons` mirror and the `resolve_person_id` macro. Depends on the journal being accurate; renders unresolved activity with NULL `person_id` rather than guessing. --- @@ -137,8 +139,8 @@ Insight connects to 10+ external platforms (GitLab, GitHub, Jira, YouTrack, Bamb ### 3.1 Module-Specific Environment Constraints -- **Storage**: Analytical identity-resolution tables reside in ClickHouse (`identity_inputs`, `aliases`, `match_rules`, `unmapped`, `conflicts`, `merge_audits`, `alias_gdpr_deleted`). The identity-attribute observation history `persons` and its derived SCD2 cache `account_person_map` reside in MariaDB and are owned by this domain (see DESIGN §3.7 and [ADR-0006](../../ingestion/specs/ADR/0006-service-owned-migrations.md)). The Person and Org-Chart domains remain ClickHouse-only. -- **Orchestration**: BootstrapJob runs as an Argo WorkflowTemplate on a Kind K8s cluster (per PR #45). +- **Storage**: Evidence and the analytics mirror reside in ClickHouse (`identity_inputs`; `identity.identity_persons` published by persons-sync; legacy `aliases`). The identity observation journal `persons`, its derived SCD2 cache `account_person_map`, and the `operations` admin journal reside in MariaDB and are owned by this domain (see DESIGN §3.7, [ADR-0002](ADR/0002-stable-person-id-via-persons-observations.md), [ADR-0003](ADR/0003-operator-decisions-as-persons-observations.md), and [ADR-0006](../../ingestion/specs/ADR/0006-service-owned-migrations.md)). Future matcher tables (`match_rules`, `unmapped`, `conflicts`, `merge_audits`, `alias_gdpr_deleted`) are design proposals only — not built. +- **Orchestration**: the persons-seed runs as a scheduled Kubernetes job (umbrella chart); Argo-orchestrated processing was the earlier plan and is not used by this domain today. - **Naming**: All tables and columns follow PR #55 glossary conventions (see Glossary and DESIGN §2.2). - **Temporal model**: Half-open intervals `[effective_from, effective_to)`. `BETWEEN` prohibited on temporal columns. Zero sentinel (`'1970-01-01'`) replaces NULL for ClickHouse compatibility. @@ -148,16 +150,14 @@ Insight connects to 10+ external platforms (GitLab, GitHub, Jira, YouTrack, Bamb ### 4.1 In Scope -- Bootstrap mechanism: `identity_inputs` ingestion, BootstrapJob processing pipeline -- Alias store: `aliases` table, alias CRUD, temporal ownership tracking -- Resolution API: hot-path and cold-path alias-to-person resolution -- Matching engine: configurable `match_rules`, confidence scoring, normalization pipeline -- Unmapped alias queue: operator review workflow (list, resolve, ignore) -- Alias conflict detection: when same alias value maps to multiple persons -- Merge/split operations with full audit trail via `merge_audits` (late phase) -- GDPR alias deletion: move to `alias_gdpr_deleted`, remove from `aliases` (late phase) -- ClickHouse Dictionary for hot-path analytical lookups (optional optimization) -- **Persons identity-attribute history**: MariaDB `persons` table and its one-shot seed from `identity_inputs` (see DESIGN §3.7 and ADR-0002). Schema / CRUD / seed are owned by this domain; the Person domain reads the resulting rows to build its golden record. +- Evidence ingestion: the `identity_inputs` write contract for connectors +- **Persons observation journal**: the MariaDB `persons` table, its scheduled seed from `identity_inputs` (ADR-0002), the `account_person_map` cache, and the `operations` journal. Schema / seed / corrections are owned by this domain; the Person domain reads the resulting rows to build its golden record. +- **Operator corrections** (ADR-0003): merge, split/detach, bind (single and bulk), exclude — appended to the journal, surviving re-runs; review queue of accounts pending a decision and contested-binding groups; per-account binding history; per-person account listing +- Analytics resolution path: persons-sync mirror + build-time `person_id` resolution (the dbt resolve macro) +- Person lookups for backend consumers (component spec: `docs/components/backend/identity-resolution/identity/`) +- Matching engine (future): configurable `match_rules`, confidence-scored proposals, normalization pipeline +- GDPR alias deletion (future): hard-erasure flow with archive +- Legacy `aliases` table stewardship until retirement or reuse by the matcher ### 4.2 Out of Scope @@ -174,43 +174,35 @@ Insight connects to 10+ external platforms (GitLab, GitHub, Jira, YouTrack, Bamb > **Testing strategy**: All requirements verified via automated tests (unit, integration, e2e) targeting 90%+ code coverage unless otherwise specified. Document verification method only for non-test approaches (analysis, inspection, demonstration). -### 5.1 Phase 1 — MVP: dbt Seed (p1) +### 5.1 Identity Store and Resolution (p1) -#### Seed Aliases from HR Bronze Data +> The v1 plan for this phase was alias-table resolution (dbt-seeded `aliases` + a resolve API). The implemented architecture resolves through the `persons` journal instead (DESIGN §1.1); the alias-specific requirements below are marked superseded and retained for traceability. -- [ ] `p1` - **ID**: `cpt-ir-fr-seed-aliases` - -The system **MUST** create initial alias records in the `aliases` table from HR Bronze data via dbt seed models, mapping `employee_id` and `email` alias types to person records created by the person domain. +#### Seed Aliases from HR Bronze Data (superseded) -**Rationale**: Without initial aliases, no resolution can happen. HR data (BambooHR, Workday) provides the most reliable identity anchors — employee IDs and work emails. +- [ ] `p1` - **ID**: `cpt-ir-fr-seed-aliases` -**Actors**: `cpt-ir-actor-analytics-pipeline` +> **Superseded.** dbt seed models still populate the legacy `aliases` table, but the resolution path does not consume it (DESIGN §3.7 legacy note). The journal seed requirements (`cpt-ir-fr-persons-history`, `cpt-ir-fr-persons-initial-seed`) replace this capability. Retained for traceability until the table is retired. -#### Resolve Alias to Person +#### Resolve Alias to Person (superseded) - [ ] `p1` - **ID**: `cpt-ir-fr-resolve-alias` -The system **MUST** resolve an `(insight_tenant_id, insight_source_type, insight_source_id, value_type, value)` tuple to a `person_id` by looking up active, non-deleted rows in the `aliases` table. The source identifier is required because identifiers like `value_type='id'` (and platform usernames) are unique only within a single source — the same `source_account_id` value can refer to different persons across BambooHR vs Cursor, and the same email can appear with different `source_account_id`s in different platforms. If found, the system **MUST** return the `person_id` and confidence. If not found, it **MUST** return a null `person_id` with status `unmapped`. +> **Superseded.** Alias-table lookup was never built. Resolution is served by the journal read paths: the identity-resolution service read API for request-time lookups (component spec) and the analytics interface for build-time resolution (`cpt-ir-interface-analytics-resolution`). The insight that account-scoped identifiers are unique only within one source instance survives in the account-first resolver key (DESIGN §4.4). -**Rationale**: This is the core capability — every downstream analytics query depends on resolving aliases to persons. - -**Actors**: `cpt-ir-actor-analytics-pipeline` - -#### Batch Alias Resolution +#### Batch Alias Resolution (superseded) - [ ] `p1` - **ID**: `cpt-ir-fr-batch-resolve` -The system **MUST** support batch resolution of multiple aliases in a single request, returning a `person_id` (or null) for each input alias. - -**Rationale**: Silver step 2 enrichment processes millions of rows; one-by-one resolution is prohibitively slow. - -**Actors**: `cpt-ir-actor-analytics-pipeline` +> **Superseded.** Bulk resolution happens at build time through the analytics interface (`cpt-ir-interface-analytics-resolution`) — the mirror plus the resolve macro — not through a REST batch endpoint. A request-time batch endpoint may return with the future matcher if a consumer needs it. #### Tenant Isolation - [ ] `p1` - **ID**: `cpt-ir-fr-tenant-isolation` -The system **MUST** isolate alias data by `insight_tenant_id`. A resolution request for tenant A **MUST NOT** return aliases belonging to tenant B. +The system **MUST** isolate identity data by `insight_tenant_id`. A resolution request for tenant A **MUST NOT** return identity data belonging to tenant B. + +**Known gap**: the seed's evidence read path currently applies no tenant predicate and serves single-tenant deployments only; restoring the tenant filter on `identity_inputs` is a prerequisite for multi-tenant use (see DESIGN §3.2 PersonsSeed). **Rationale**: Multi-tenant deployments require strict data isolation to prevent cross-tenant data leaks. @@ -222,7 +214,7 @@ The system **MUST** isolate alias data by `insight_tenant_id`. A resolution requ The system **MUST** maintain a `persons` table in MariaDB that stores the history of identity field changes per person in an append-only SCD-style log. Each row represents one observed field value (`value_type`, `value`) from one source (`insight_source_type`, `insight_source_id`) assigned to a person (`person_id`) at a specific time (`created_at`). Updating a field **MUST** insert a new row rather than mutate an existing one; the current value is the row with the latest `created_at` for the `(insight_tenant_id, person_id, value_type)` triple. -Schema (the column split by `value_type` into `value_id` / `value_full_text` / `value`, the generated `value_effective` for display and `value_hash` for the natural-key UNIQUE, the SCD2 shape of `account_person_map`, and `TIMESTAMP(6)` precision) is specified in the identity-resolution DESIGN §3.7 and in [ADR-0002](ADR/0002-stable-person-id-via-persons-observations.md). The PRD intentionally states only the behavioural contract. +Schema (the column split by `value_type` into `value_id` / `value_full_text` / `value`, the generated display/digest columns, the natural-key UNIQUE ending in `created_at`, the SCD2 shape of `account_person_map`, and microsecond timestamp precision) is specified in the identity-resolution DESIGN §3.7 and in [ADR-0002](ADR/0002-stable-person-id-via-persons-observations.md) with its post-acceptance note. The PRD intentionally states only the behavioural contract. **Rationale**: Downstream backend services (Analytics API, Identity Resolution service) need a CRUD-accessible, temporal view of person attributes from heterogeneous sources — ClickHouse is optimised for analytical reads, not operator-driven edits. MariaDB with row-level history supports conflict-resolution UX, audit trails, and operator-driven corrections. @@ -245,7 +237,9 @@ Schema of `persons`, UNIQUE-key structure, `person_id` minting (random UUIDv7), **Actors**: `cpt-ir-actor-analytics-pipeline` -### 5.2 Phase 2 — Bootstrap Pipeline (p1) +### 5.2 Evidence Intake and Seed Fold (p1) + +> The v1 plan for this phase was a BootstrapJob maintaining the `aliases` store with `unmapped`/`conflicts` queues. What shipped is the persons-seed fold into the journal (DESIGN §3.2); superseded alias-pipeline requirements are marked below and retained for traceability. #### Accept Alias Observations from Connectors @@ -259,65 +253,53 @@ The system **MUST** accept alias observation records into the `identity_inputs` #### Process Bootstrap Inputs Incrementally -- [x] `p1` - **ID**: `cpt-ir-fr-bootstrap-incremental` +- [ ] `p1` - **ID**: `cpt-ir-fr-bootstrap-incremental` -The BootstrapJob **MUST** process `identity_inputs` rows incrementally, reading only rows with `_synced_at` greater than the last processing watermark. It **MUST** update the watermark after each successful run. +The seed **MUST** process `identity_inputs` such that re-runs neither duplicate nor lose observations, and **SHOULD** process incrementally — reading only rows newer than a persisted watermark and advancing it after each successful run. -**Rationale**: Connectors sync continuously; the bootstrap pipeline must process only new observations to avoid re-processing the entire history on each run. +**Current state**: not implemented as incremental — each run folds the **full** evidence set (idempotent via the journal's natural key); the watermark mechanism is an open scalability requirement (DESIGN §5 REC-IR-02). + +**Rationale**: Connectors sync continuously; full-set folding is correct but its cost grows with history. **Actors**: `cpt-ir-actor-bootstrap-job` -#### Normalize Alias Values +#### Normalize Alias Values (superseded) - [ ] `p1` - **ID**: `cpt-ir-fr-normalize-aliases` -The BootstrapJob **MUST** normalize alias values before matching: `email` and `username` types **MUST** be lowercased and trimmed; all other types **MUST** be trimmed. Raw values in `identity_inputs` **MUST** be preserved unchanged. - -**Rationale**: Case differences and whitespace in emails/usernames cause false negatives in matching. Normalization ensures consistent lookups. - -**Actors**: `cpt-ir-actor-bootstrap-job` +> **Superseded.** Normalization now lives at the consumption points: the seed groups accounts by lowercased e-mail; the analytics macro applies `lower(trim(...))` to **both** join sides; `value_id` comparisons in the journal are case-insensitive by collation (service migration 004). Raw values in `identity_inputs` remain preserved unchanged — that part of the contract stands via `cpt-ir-contract-bootstrap-inputs`. -#### Create Alias on Exact Match +#### Create Alias on Exact Match (superseded) - [ ] `p1` - **ID**: `cpt-ir-fr-create-alias-exact` -When the BootstrapJob finds no existing alias for a normalized `(value_type, value, insight_tenant_id)` and the MatchingEngine returns confidence >= 1.0, the system **MUST** auto-create an alias record in the `aliases` table linking to the matched `person_id`. +> **Superseded.** Automatic exact-match linking is implemented by the seed's unambiguous e-mail link (an unbound account whose e-mail maps to exactly one person joins that person — DESIGN §3.2 PersonsSeed). Anything beyond that arrives with the future MatchingEngine strictly as **operator-reviewed proposals** — the matcher never writes bindings (ADR-0003). -**Rationale**: High-confidence matches (exact email, exact employee ID) should be linked automatically without operator intervention to achieve the >= 80% auto-resolution goal. +#### Route Low-Confidence Aliases to Unmapped Queue (future — with the matcher) -**Actors**: `cpt-ir-actor-bootstrap-job`, `cpt-ir-actor-matching-engine` - -#### Route Low-Confidence Aliases to Unmapped Queue +- [ ] `p3` - **ID**: `cpt-ir-fr-route-unmapped` -- [ ] `p1` - **ID**: `cpt-ir-fr-route-unmapped` +When the future MatchingEngine produces a candidate at any confidence, the system **MUST** surface it for operator review as a proposal (with the candidate person and its confidence) rather than silently dropping or silently applying it; confidence bands only order proposals and gate bulk acceptance. Whether suggestions persist in a store or derive on read is a design decision for that iteration; in v1 the review need is covered by the derived queue (`cpt-ir-fr-review-queue`). -When the MatchingEngine returns confidence < 1.0 for an alias, the system **MUST** insert the alias into the `unmapped` table. If confidence is 0.50–0.99, the system **MUST** include the `suggested_person_id` and `suggestion_confidence`. If confidence < 0.50, the system **MUST** insert with status `pending` and no suggestion. - -**Rationale**: Aliases below the auto-link threshold must not be silently dropped; they need operator review to prevent identity gaps. +**Rationale**: Matcher candidates must not be silently dropped — and per ADR-0003 must not be silently applied either. **Actors**: `cpt-ir-actor-bootstrap-job`, `cpt-ir-actor-matching-engine` -#### Track Alias Observations Over Time +#### Track Alias Observations Over Time (superseded) - [ ] `p1` - **ID**: `cpt-ir-fr-track-observations` -When an alias already exists in the `aliases` table for the same `(value_type, value, insight_source_id, insight_tenant_id)`, the BootstrapJob **MUST** update `last_observed_at` to the current timestamp. It **SHOULD** update `source_account_id` if changed. - -**Rationale**: Tracking when aliases were last confirmed helps identify stale mappings and provides audit context. +> **Superseded.** The append-only journal records every observation with its own `created_at` (sourced from `_synced_at`), so observation history is first-class rather than a pair of `first/last_observed_at` columns on a mutable alias row. -**Actors**: `cpt-ir-actor-bootstrap-job` - -#### Idempotent Bootstrap Runs +#### Idempotent Bootstrap Runs (superseded) - [ ] `p1` - **ID**: `cpt-ir-fr-bootstrap-idempotent` -Re-running the BootstrapJob on the same `identity_inputs` data **MUST NOT** create duplicate alias records. The system **MUST** deduplicate on the natural key `(insight_tenant_id, value_type, value, insight_source_id)`. +> **Superseded.** Idempotent re-runs are guaranteed at the journal level: `INSERT IGNORE` under the natural observation key ending in `created_at`, with `created_at` sourced deterministically from `_synced_at` (see `cpt-ir-fr-persons-initial-seed` and `cpt-ir-nfr-bootstrap-idempotency`). -**Rationale**: Connector retries and Argo Workflow restarts must be safe. Duplicate aliases would corrupt resolution results and inflate metrics. - -**Actors**: `cpt-ir-actor-bootstrap-job` +### 5.3 Phase 3 — Matching & Workflows (p2, future) -### 5.3 Phase 3 — Matching & Workflows (p2) +> Nothing in this phase is implemented; these requirements govern the future MatchingEngine (DESIGN §4.2). Operator-workflow requirements that ADR-0003 re-scoped are marked superseded in favour of §5.4. #### Configurable Match Rules @@ -343,101 +325,141 @@ The MatchingEngine **MUST** evaluate rules in three ordered phases: B1 (determin - [ ] `p2` - **ID**: `cpt-ir-fr-no-fuzzy-autolink` -Fuzzy matching rules (Phase B3) **MUST NEVER** trigger automatic alias creation regardless of confidence score. They **MUST** only generate suggestions routed to the unmapped queue for operator review. +Fuzzy matching rules (Phase B3) **MUST NEVER** trigger automatic binding creation regardless of confidence score. Their output **MUST** be operator-reviewed proposals only and **MUST NOT** be eligible for bulk acceptance. (Under ADR-0003 no matcher rule of any phase writes bindings; this requirement additionally hardens the fuzzy tier.) -**Rationale**: Production experience showed fuzzy name matching produced false-positive merges. This constraint is non-negotiable. +**Rationale**: Fuzzy name matching is a known source of false-positive merges, which corrupt attribution and are costly to unwind. This constraint is non-negotiable. **Actors**: `cpt-ir-actor-matching-engine` -#### Operator Unmapped Queue Management +#### Operator Unmapped Queue Management (superseded) - [ ] `p2` - **ID**: `cpt-ir-fr-unmapped-management` -The system **MUST** allow operators to: (a) list unmapped aliases filtered by status, (b) link an unmapped alias to an existing person, (c) create a new person from an unmapped alias (via person domain), (d) mark an unmapped alias as ignored. Each resolution action **MUST** update the `unmapped` record with `resolved_person_id`, `resolved_at`, `resolved_by_person_id`, and `resolution_type`. +> **Superseded** by the §5.4 operator-correction requirements (`cpt-ir-fr-review-queue`, `cpt-ir-fr-operator-bind`, `cpt-ir-fr-operator-exclude`): the queue is derived rather than a stateful `unmapped` record workflow. Retained for traceability; a persistent proposal store may return with the matcher (see `cpt-ir-fr-route-unmapped`). -**Rationale**: Not all aliases can be auto-resolved. Operators need an efficient workflow to clear the unmapped queue and maintain data quality. +#### Alias Conflict Detection (superseded) -**Actors**: `cpt-ir-actor-operator` +- [ ] `p2` - **ID**: `cpt-ir-fr-alias-conflict-detection` -#### Alias Conflict Detection +> **Superseded.** Conflict surfacing is covered by the derived review queue (`cpt-ir-fr-review-queue`): contested identity values and unexplained binding divergence are surfaced with candidates, without a persistent `conflicts` table. A persistent conflict record may return with the matcher. -- [ ] `p2` - **ID**: `cpt-ir-fr-alias-conflict-detection` +#### Manual Alias Management (superseded) + +- [ ] `p2` - **ID**: `cpt-ir-fr-manual-alias-crud` + +> **Superseded** by the §5.4 operator corrections: attaching identity to a person is `cpt-ir-fr-operator-bind`; taking an account away is `cpt-ir-fr-split-v2` / `cpt-ir-fr-operator-exclude`; listing a person's identity values is `cpt-ir-fr-binding-history`; auditability is `cpt-ir-fr-merge-audit-v2` (journal, not `merge_audits`). + +#### Auto-Resolve Unmapped on New Alias (future — with the matcher) -When the BootstrapJob encounters a new alias observation that matches an alias already owned by a different person, the system **MUST** create a conflict record in the `conflicts` table with both `person_id_a`, `person_id_b`, the conflicting `value_type`/`value`, and source IDs. +- [ ] `p3` - **ID**: `cpt-ir-fr-auto-resolve-unmapped` -**Rationale**: The same email or username claimed by two different persons indicates a data quality issue that requires operator attention. +If the matcher iteration introduces a persistent suggestion store, newly established bindings **SHOULD** auto-resolve pending suggestions for the same identity value. In v1 this is inherent: the review queue is derived, so an item disappears as soon as new bindings dissolve its condition. **Actors**: `cpt-ir-actor-bootstrap-job` -#### Manual Alias Management +### 5.4 Manual Resolution — Operator Corrections (p1) -- [ ] `p2` - **ID**: `cpt-ir-fr-manual-alias-crud` +> Requirements for the operator correction capability decided in [ADR-0003](ADR/0003-operator-decisions-as-persons-observations.md) (reviewed design: constructorfabric/insight#2180). The v1 late-phase merge/split/audit/idempotency requirements prescribed a snapshot-and-rollback mechanism that was never built; they are superseded by the implementation-neutral `-v2` requirements below (see this file's git history for the v1 texts). + +#### Merge Persons -The system **MUST** allow operators to: (a) add an alias to a person manually, (b) deactivate an alias (set `is_active = 0`), (c) list all aliases for a given person. Each action **MUST** be logged in `merge_audits` with `action = 'alias_add'` or `'alias_remove'`. +- [ ] `p1` - **ID**: `cpt-ir-fr-merge-v2` -**Rationale**: Automated resolution covers most cases, but operators need escape hatches for edge cases — manually added email addresses, correcting mislinked aliases. +The system **MUST** allow an operator to declare that two persons are the same human. All account bindings of the absorbed person **MUST** be reassigned to the surviving person, which the operator names explicitly. The operation **MUST** be recorded as new history (no existing records modified or deleted), attributed to the operator with a reason. + +**Rationale**: Automatic resolution under-merges (e.g., work vs personal e-mail). Operators need to combine persons without losing any history. (Supersedes the v1 merge requirement.) **Actors**: `cpt-ir-actor-operator` -#### Auto-Resolve Unmapped on New Alias +#### Split / Detach Accounts -- [ ] `p2` - **ID**: `cpt-ir-fr-auto-resolve-unmapped` +- [ ] `p1` - **ID**: `cpt-ir-fr-split-v2` -When the BootstrapJob creates a new alias, the system **SHOULD** check the `unmapped` table for pending entries matching the same `(value_type, value, insight_tenant_id)` and auto-resolve them to the newly linked person. +The system **MUST** allow an operator to declare that an account belongs to a different human: rebind the account to another person — usually a newly created one — **regardless of how the current grouping arose**. No prior merge record may be required. The operation **MUST** be recorded as new history, attributed to the operator with a reason. -**Rationale**: As more connectors sync, previously unmapped aliases may become resolvable. Auto-resolution reduces operator queue size. +**Rationale**: Automatic resolution can over-merge (e.g., through a shared mailbox such as `team@example.com`). Splitting must therefore work on accounts the automation grouped, where no earlier "merge" exists to roll back. (Supersedes the v1 split requirement.) -**Actors**: `cpt-ir-actor-bootstrap-job` +**Actors**: `cpt-ir-actor-operator` + +#### Bind Account to Person (single, bulk, pre-registration) -### 5.4 Late Phase — Merge/Split & GDPR (p3) +- [ ] `p1` - **ID**: `cpt-ir-fr-operator-bind` -#### Merge Person Alias Sets +The system **MUST** allow an operator to attach an account to a chosen person: individually, or in bulk from a prepared matching table. Bulk rows **MAY** address an account directly or via an observed value (e-mail, username); a value that does not resolve to exactly one account **MUST** be reported per-row and left undecided, never guessed. Binding an account that has not been observed yet **MUST** be accepted and take effect when the account first appears. Binding an account pending review to its own current person **MUST** count as an operator confirmation and clear it from review. -- [ ] `p3` - **ID**: `cpt-ir-fr-merge` +**Rationale**: Covers linking accounts pending review, importing a prepared e-mail/username matching table (for example, one exported from an HR system or maintained as a spreadsheet), and pre-registering known accounts of new hires. + +**Actors**: `cpt-ir-actor-operator` -The system **MUST** allow an operator to merge all aliases from `source_person_id` to `target_person_id`. The merge operation **MUST** snapshot the alias state before and after in `merge_audits`, reassign all source aliases, and run alias conflict detection on the target. +#### Exclude Non-Person Accounts -**Rationale**: Operators occasionally discover that two person records represent the same individual. Merge combines their alias sets with full audit trail. +- [ ] `p1` - **ID**: `cpt-ir-fr-operator-exclude` + +The system **MUST** allow an operator to mark an account as not belonging to any human (bot, CI, service account). Activity of excluded accounts **MUST NOT** be attributed to any person, and excluded accounts **MUST NOT** appear in the review queue. + +**Rationale**: Bot and service accounts (e.g., `ci-bot@example.com`) are not humans; they must be excludable, not merged into persons or endlessly re-reviewed. **Actors**: `cpt-ir-actor-operator` -#### Split (Rollback Merge) +#### Review Queue of Pending Decisions -- [ ] `p3` - **ID**: `cpt-ir-fr-split` +- [ ] `p1` - **ID**: `cpt-ir-fr-review-queue` -The system **MUST** allow an operator to reverse a previous merge by restoring alias mappings from `merge_audits.snapshot_before`. The system **MUST NOT** allow a split on an already-rolled-back audit record. +The system **MUST** surface the accounts that require an operator decision: (a) accounts whose identity evidence is contested (e.g. an e-mail claimed by more than one person), each with candidate persons and the observed values that make it contested; (b) binding divergence within an identity-value group that is **not** explained by any operator-authored decision; (c) observed accounts with no usable identity evidence (e.g. e-mail-less) — visible and countable, never hidden. Divergence explained by an operator decision **MUST NOT** be surfaced. A surfaced item **MUST** disappear once a decision removes its condition, without any separate item lifecycle to maintain. Alongside the queue, the system **MUST** report resolution-rate shares — how many observed accounts are bound, pending, without evidence, and excluded — so the match rate is operator-visible. Deliberate MVP narrowing: there is no ignore/defer (snooze) action — an item leaves the queue only through a decision (bind, detach, exclude, or confirm); deferral state returns with the proposal store of the matcher iteration. -**Rationale**: Merges can be wrong. Reversibility is essential for data integrity and operator confidence. +**Rationale**: Operators need one place showing what needs attention, and it must not show what a human already settled; unresolved is a first-class surface, and reported match rate is a success measure of the umbrella epic (#1873). Surfacing e-mail-less accounts closes #1776. (Together with `cpt-ir-fr-operator-bind` and `cpt-ir-fr-operator-exclude`, supersedes the queue workflow of `cpt-ir-fr-unmapped-management`.) **Actors**: `cpt-ir-actor-operator` -#### Merge/Split Audit Trail +#### Corrections Survive Automatic Re-Resolution -- [ ] `p3` - **ID**: `cpt-ir-fr-merge-audit` +- [ ] `p1` - **ID**: `cpt-ir-fr-correction-durability` -Every merge, split, alias_add, and alias_remove operation **MUST** be recorded in `merge_audits` with: `action`, `target_person_id`, `source_person_id`, `snapshot_before` (JSON), `snapshot_after` (JSON), `reason`, `actor_person_id`, `performed_at`. +An operator decision **MUST** survive every subsequent run of automatic resolution and the connection of any new source: automation **MUST NOT** override, re-derive, or silently supersede an operator-authored binding under any circumstances. (Unconditional once the manual-resolution seed hardening is deployed; until then the divergent-group gap documented in DESIGN §3.2 remains the one known violation path.) -**Rationale**: Auditability is required for compliance, debugging, and building operator trust. Full snapshots enable rollback. +**Rationale**: The single most repeated stakeholder requirement (#1873): the override store is the source of truth; automation only proposes. **Actors**: `cpt-ir-actor-operator` -#### GDPR Alias Purge +#### Correction Audit and Reconstructibility -- [ ] `p3` - **ID**: `cpt-ir-fr-gdpr-purge` +- [ ] `p1` - **ID**: `cpt-ir-fr-merge-audit-v2` + +Every correction **MUST** be recorded with the operator identity, a machine-readable reason, an optional free-text comment, and a timestamp — and **MUST NOT** modify or delete any prior record. The retained records **MUST** be sufficient to reconstruct the state before any correction and to answer "who decided this, when, and why" for any current binding. + +**Rationale**: Auditability and reversibility without snapshot machinery: history that is never destroyed is its own audit trail. (Supersedes the v1 audit-trail requirement.) + +**Actors**: `cpt-ir-actor-operator` + +#### Idempotent Corrections -The system **MUST** support GDPR hard erasure for a person's aliases: move all alias records to `alias_gdpr_deleted`, remove from `aliases` (set `is_deleted = 1`), and ensure the alias is no longer resolvable via any path (hot or cold). +- [ ] `p1` - **ID**: `cpt-ir-fr-idempotent-mutations-v2` -**Rationale**: Legal compliance with right-to-erasure requests. Alias data contains PII (emails, names, employee IDs). +Re-applying a correction identical to an **already-recorded operator decision** (operator retry, duplicate bulk row, re-uploaded matching table) **MUST NOT** change resolution state or duplicate records. Confirming an automation-made binding is not a repeat: it **MUST** record the operator's decision (see the bind requirement). + +**Rationale**: Retries and re-uploads must be safe by construction, without client-side idempotency plumbing. (Supersedes the v1 idempotency requirement.) **Actors**: `cpt-ir-actor-operator` -#### Idempotent Merge/Split Operations +#### Binding History per Account and per Person -- [ ] `p3` - **ID**: `cpt-ir-fr-idempotent-mutations` +- [ ] `p2` - **ID**: `cpt-ir-fr-binding-history` -All mutating API endpoints (merge, split, purge, alias add/remove) **MUST** support idempotency keys (`Idempotency-Key` header, 24h TTL). Replaying a request with the same key **MUST** return the original result without re-executing the operation. +The system **MUST** let an operator view, for any account, its current binding and the full decision history (author, reason, time of each change); and, for any person, every account and identity value ever bound to them with the author of each link. -**Rationale**: Network retries and operator double-clicks must be safe. ClickHouse lacks ACID transactions, so idempotency is the primary safety mechanism. +**Rationale**: Operators cannot make safe merge/split decisions without seeing why the current state is what it is; the same view supports troubleshooting attribution questions. + +**Actors**: `cpt-ir-actor-operator` + +### 5.5 Late Phase — GDPR (p3) + +#### GDPR Alias Purge + +- [ ] `p3` - **ID**: `cpt-ir-fr-gdpr-purge` + +The system **MUST** support GDPR hard erasure of a person's identity data, after which the erased values **MUST NOT** be resolvable via any path and no plaintext copy of them may be retained anywhere, including archives. Erasure **MUST** be an explicit administrative operation, recorded in the operations journal; the append-only rule of the decision journal governs identity decisions and does not preclude lawful erasure of stored values. + +**Rationale**: Legal compliance with right-to-erasure requests. Identity data contains PII (emails, names, employee IDs). **Actors**: `cpt-ir-actor-operator` @@ -451,9 +473,9 @@ All mutating API endpoints (merge, split, purge, alias add/remove) **MUST** supp - [ ] `p1` - **ID**: `cpt-ir-nfr-alias-lookup-latency` -The system **MUST** resolve a single alias to `person_id` via the hot path in < 50 ms at p99 under normal load. +The system **MUST** resolve a single person lookup via the service read API in < 50 ms at p99 under normal load. -**Threshold**: p99 latency < 50 ms for `POST /resolve` when alias exists in `aliases` table, measured at 1000 req/s sustained. +**Threshold**: p99 latency < 50 ms for a profile lookup that hits an existing binding, measured at 1000 req/s sustained. **Rationale**: Resolution is on the critical path for Silver step 2 enrichment. High latency blocks analytical pipeline throughput. @@ -461,7 +483,7 @@ The system **MUST** resolve a single alias to `person_id` via the hot path in < - [ ] `p1` - **ID**: `cpt-ir-nfr-bootstrap-throughput` -The BootstrapJob **MUST** process at least 100,000 `identity_inputs` rows per run within 30 minutes. +The seed **MUST** process at least 100,000 `identity_inputs` rows per run within 30 minutes. **Threshold**: >= 100K rows processed in <= 30 min on standard cluster resources (0.5 CPU, 512 MB RAM). @@ -471,9 +493,9 @@ The BootstrapJob **MUST** process at least 100,000 `identity_inputs` rows per ru - [ ] `p1` - **ID**: `cpt-ir-nfr-bootstrap-idempotency` -Re-running the BootstrapJob on identical input **MUST** produce identical output — zero net new alias rows, zero net deleted rows. +Re-running the seed on identical input **MUST** produce identical output — zero net new journal rows, zero net deleted rows. -**Threshold**: After 3 consecutive runs on unchanged data, `SELECT count() FROM aliases` returns the same value. +**Threshold**: After 3 consecutive runs on unchanged data, `SELECT count() FROM persons` returns the same value and `account_person_map` rebuilds bit-identically. **Rationale**: System restarts, Argo retries, and operational re-runs must be safe. @@ -491,7 +513,7 @@ The system **MUST** produce zero false-positive auto-links from fuzzy matching r - [ ] `p1` - **ID**: `cpt-ir-nfr-tenant-isolation` -A resolution request for tenant A **MUST NOT** return data from tenant B under any circumstances, including cache hits, Dictionary lookups, and error responses. +A resolution request for tenant A **MUST NOT** return data from tenant B under any circumstances, including cache hits, mirror reads, and error responses. **Threshold**: 0 cross-tenant data leaks in penetration testing. @@ -501,26 +523,26 @@ A resolution request for tenant A **MUST NOT** return data from tenant B under a - [ ] `p3` - **ID**: `cpt-ir-nfr-gdpr-erasure` -After a GDPR purge, the purged aliases **MUST NOT** be resolvable via any path (API, Dictionary, direct table query on `aliases`) within 60 minutes. +After a GDPR purge, the purged identity values **MUST NOT** be resolvable via any path (service API, analytics mirror, direct table query) within 60 minutes. -**Threshold**: Purged alias returns null from `POST /resolve` within 60 min of purge. Dictionary reload completes within TTL. +**Threshold**: A purged value resolves to no person via every read path within 60 min of purge, including the next mirror publish. **Rationale**: Legal compliance with right-to-erasure. Delayed purge visibility is a regulatory risk. -#### Merge/Split Reversibility +#### Correction Reversibility -- [ ] `p3` - **ID**: `cpt-ir-nfr-merge-reversibility` +- [ ] `p1` - **ID**: `cpt-ir-nfr-merge-reversibility` -Every merge operation **MUST** be fully reversible via split. After a merge-then-split round-trip, the alias state **MUST** be identical to the pre-merge state. +Every operator correction **MUST** be reversible: applying a correction and then the counter-correction **MUST** restore the effective account-to-person state, and the state before any correction **MUST** remain reconstructible from retained history at all times. -**Threshold**: 100% round-trip fidelity verified by comparing `snapshot_before` with post-split alias state. +**Threshold**: 100% round-trip fidelity — effective bindings after correction + counter-correction are identical to the pre-correction bindings; reconstruction of the pre-correction state succeeds for every corrected account. -**Rationale**: Operator confidence and data integrity. Irreversible merges would make operators reluctant to act. +**Rationale**: Operator confidence and data integrity. Irreversible merges are the single most documented failure mode of comparable systems; reversibility must never depend on remembering to snapshot. ### 6.2 NFR Exclusions - **High availability / clustering**: Identity resolution is not on the real-time serving path for end users. ClickHouse cluster availability is managed at infrastructure level, not by this domain. -- **Sub-second consistency**: The analytical pipeline tolerates 30-60s staleness (Dictionary TTL). Real-time consistency is not required. +- **Sub-second consistency**: The analytical pipeline resolves at build cadence (a correction becomes visible in dashboards on the next gold build). Real-time consistency is not required. - **Encryption at rest**: Handled by ClickHouse infrastructure configuration, not by application-level encryption in this domain. --- @@ -533,25 +555,25 @@ Every merge operation **MUST** be fully reversible via split. After a merge-then - [ ] `p1` - **ID**: `cpt-ir-interface-resolution-api` -**Type**: REST API (HTTP/JSON) +**Type**: REST API (HTTP/JSON), served by the `identity-resolution` service -**Stability**: stable +**Stability**: read surface stable; operator correction surface planned (contracts fixed at FEATURE level; design reviewed in constructorfabric/insight#2180) -**Description**: Primary interface for alias resolution, merge/split operations, unmapped queue management, match rule configuration, and GDPR purge. Base path: `/api/identity/`. +**Description**: Person lookups for backend consumers (see the component spec) plus the operator correction surface: bind (single/bulk), merge, detach, exclude, review queue, binding history. Match-rule configuration and GDPR purge join this interface with their future phases. -**Breaking Change Policy**: Endpoint paths and response shapes are versioned; breaking changes require major version bump (`/api/v2/identity/`). +**Breaking Change Policy**: Endpoint paths and response shapes are versioned; breaking changes require a major version bump. -#### ClickHouse Dictionary (Analytical Lookup) +#### Analytics Resolution Interface (mirror + macro) -- [ ] `p2` - **ID**: `cpt-ir-interface-ch-dictionary` +- [ ] `p1` - **ID**: `cpt-ir-interface-analytics-resolution` -**Type**: ClickHouse Dictionary +**Type**: ClickHouse mirror table (`identity.identity_persons`) + dbt macro (`resolve_person_id`) **Stability**: stable -**Description**: Optional read-only interface for analytical queries. Keyed by `(insight_tenant_id, value_type, value)`, returns `person_id`. Reload TTL 30-60s. Used by dbt models and dashboards for Silver step 2 enrichment. +**Description**: The analytical read path. persons-sync republishes the journal into ClickHouse atomically; every gold model resolves `person_id` at build time through the macro — the single place resolution semantics live for analytics. Unresolved activity carries NULL `person_id` (absent, never guessed). -**Breaking Change Policy**: Dictionary key structure changes require downstream dbt model updates. +**Breaking Change Policy**: Macro semantics changes propagate to all consuming models on the next build; they require a coordinated dbt release note. (Replaces the v1 Dictionary-over-`aliases` interface, retired with the legacy table.) ### 7.2 External Integration Contracts @@ -571,13 +593,13 @@ Every merge operation **MUST** be fully reversible via split. After a merge-then - [ ] `p1` - **ID**: `cpt-ir-contract-person-domain` -**Direction**: provided by library (identity resolution provides `aliases.person_id`) +**Direction**: provided by this domain (identity resolution provides `persons` observations and mints `person_id`) -**Protocol/Format**: Logical FK — `aliases.person_id` references `persons.person_id` (the stable UUIDv7, not the auto-increment observation row PK) +**Protocol/Format**: read access to the `persons` observation journal; `person_id` is the stable random UUIDv7 minted at first binding (ADR-0002), never the auto-increment observation row PK -**Description**: The `aliases` table provides the authoritative mapping from identity signals to person records. The person domain owns person creation; identity resolution links aliases to existing persons. The `person_id` column in `aliases` is the primary integration point. +**Description**: The `persons` journal is the authoritative source for the account-to-person binding and for identity-attribute observations. This domain mints `person_id`; the person domain reads the observations to derive its golden record and never writes here. (The v1 wording of this contract — `aliases.person_id` as the authoritative mapping, persons created by the person domain — is superseded; the legacy `aliases` table is not consumed by resolution.) -**Compatibility**: The `person_id` UUID format is stable. Column name changes are breaking. +**Compatibility**: The `person_id` UUID format is stable. Journal column semantics changes are breaking for the person domain. --- @@ -590,148 +612,146 @@ Every merge operation **MUST** be fully reversible via split. After a merge-then **Actor**: `cpt-ir-actor-bootstrap-job`, `cpt-ir-actor-connector` **Preconditions**: -- Connector has completed a sync and written rows to `identity_inputs` -- BootstrapJob is triggered (Argo Workflow post-sync) -- Person records exist in person domain (seeded by dbt or previous bootstrap) +- Connector has completed a sync; dbt models have written its observations to `identity_inputs` +- The seed run is triggered (scheduled) or invoked manually **Main Flow**: -1. BootstrapJob reads `identity_inputs` rows where `_synced_at > last_watermark` -2. For each row, normalize `value` (lowercase/trim for email/username) -3. Look up existing alias in `aliases` for `(tenant, value_type, normalized_value)` -4. If alias exists for same person: update `last_observed_at` -5. If alias does not exist: invoke MatchingEngine with the alias -6. If confidence >= 1.0: create alias in `aliases` table, auto-resolve matching unmapped entries -7. If confidence < 1.0: insert into `unmapped` (with suggestion if confidence >= 0.50) -8. Update processing watermark +1. The seed reads `identity_inputs` observations and groups them per source account +2. Accounts with an existing binding are left bound; their new observations are appended under the bound person +3. Unknown accounts with a new e-mail get a freshly minted person (accounts sharing the same new e-mail are grouped) +4. Unbound accounts whose e-mail unambiguously maps to one person are linked to it; contested evidence (an e-mail claimed by more than one person) **MUST NOT** be auto-linked — it is surfaced for review (target behaviour of the manual-resolution capability) +5. Binding divergence within an e-mail group is classified by author; only seed-authored divergence is surfaced +6. The derived cache is rebuilt; the run and its counters are journaled **Postconditions**: -- New aliases created in `aliases` table for high-confidence matches -- Low-confidence aliases queued in `unmapped` for operator review -- Processing watermark advanced +- Every processed account is bound, surfaced for review, or (e-mail-less) skipped +- Operator decisions from before the run are untouched +- Run summary (including conflict counters) is available in the operations journal **Alternative Flows**: -- **Alias exists for different person**: ConflictDetector creates a `conflicts` record; alias is NOT auto-created -- **BootstrapJob fails mid-run**: Watermark not updated; safe to retry (idempotent) -- **No matching person in person domain**: Alias routed to `unmapped` as pending +- **Seed fails or is refused by an input guard**: no partial bindings are visible; safe to retry (idempotent); guard overrides require an explicit operator flag +- **Concurrent run**: the run-lock makes the second invocation exit without effect --- -### Resolve Alias (Hot Path) +### Resolve Person for Analytics and Backend - [ ] `p1` - **ID**: `cpt-ir-usecase-resolve-hot` **Actor**: `cpt-ir-actor-analytics-pipeline` **Preconditions**: -- Alias exists in `aliases` table with `is_active = 1` and `is_deleted = 0` +- The journal mirror (`identity.identity_persons`) has been published -**Main Flow**: -1. Caller sends `POST /resolve` with `value_type`, `value`, `insight_source_id`, `insight_tenant_id` -2. System queries `aliases` table for active, non-deleted match -3. System returns `{person_id, confidence: 1.0, status: "resolved"}` +**Main Flow** (analytics, bulk): +1. A gold build calls the `resolve_person_id` macro over the mirror +2. Each fact keyed by a source account receives the `person_id` of the account's latest binding +3. Unresolved accounts yield NULL; excluded accounts resolve to NULL **Postconditions**: -- Caller has `person_id` for downstream processing +- All gold tables of the build agree on person attribution (build-scoped snapshot) **Alternative Flows**: -- **Alias not found (cold path)**: System invokes MatchingEngine; if confidence >= 1.0, auto-creates alias and returns resolved; otherwise returns `{person_id: null, status: "unmapped"}` -- **Multiple matches for same alias**: Should not happen (application-level uniqueness); if it does, return the most recently created active alias +- **Backend request-time lookup**: a service calls the identity read API (`/v1/profiles`); an ambiguous lookup (single-result invariant violated) returns an explicit ambiguity error rather than picking a winner --- -### Review Unmapped Aliases +### Review Pending Identity Decisions -- [ ] `p2` - **ID**: `cpt-ir-usecase-review-unmapped` +- [ ] `p1` - **ID**: `cpt-ir-usecase-review-unmapped` **Actor**: `cpt-ir-actor-operator` **Preconditions**: -- Unmapped aliases exist with status `pending` or `in_review` -- Operator has access to the identity resolution API +- Accounts pending a decision and/or unexplained binding conflicts exist +- Operator has the identity correction role **Main Flow**: -1. Operator calls `GET /unmapped?status=pending` to list unresolved aliases -2. For each unmapped alias, operator reviews the `suggested_person_id` (if any) -3. Operator calls `POST /unmapped/:id/resolve` with `person_id` to link alias to a person -4. System creates alias in `aliases` table and updates `unmapped` record with resolution details +1. Operator requests the review queue and sees each pending account with its observed values and candidate persons +2. Operator investigates using per-account binding history when needed +3. Operator decides: bind to a candidate, detach as a separate person, confirm the current person (bind-to-self), or exclude as a non-person +4. The decision is appended to the journal under the operator's identity; the queue item disappears because its condition no longer holds **Postconditions**: -- Alias created in `aliases` table -- `unmapped` record updated: `status = 'resolved'`, `resolved_person_id`, `resolved_at`, `resolution_type = 'linked'` +- The account's effective binding reflects the operator decision +- The decision survives all future automatic runs **Alternative Flows**: -- **Operator creates new person**: Operator calls person domain API to create person, then links unmapped alias to new `person_id`; `resolution_type = 'new_person'` -- **Operator ignores alias**: Operator calls `POST /unmapped/:id/ignore`; `status = 'ignored'`, `resolution_type = 'ignored'` +- **Bulk import**: the operator uploads a prepared matching table via bulk bind; unambiguous rows apply, ambiguous rows are reported per-row and stay in the queue +- **Legacy conflict**: for a seed-authored binding conflict, the operator either merges the persons or re-asserts the divergence under their own authorship, which silences it --- -### Merge Two Person Alias Sets +### Merge Two Persons -- [ ] `p3` - **ID**: `cpt-ir-usecase-merge` +- [ ] `p1` - **ID**: `cpt-ir-usecase-merge` **Actor**: `cpt-ir-actor-operator` **Preconditions**: -- Two person records exist that the operator has determined represent the same individual -- Both persons have aliases in the `aliases` table +- Two persons exist that the operator has determined represent the same human **Main Flow**: -1. Operator calls `POST /merge` with `source_person_id`, `target_person_id`, `reason`, `actor_person_id` -2. System snapshots current aliases for both persons → `snapshot_before` -3. System reassigns all aliases from `source_person_id` to `target_person_id` -4. System snapshots merged state → `snapshot_after` -5. System records `merge_audits` row with action `merge` -6. System runs ConflictDetector on `target_person_id` -7. System returns `{status: "merged", audit_id}` +1. Operator requests a merge, naming the surviving person explicitly, with a reason +2. The system appends a binding to the survivor for every account of the absorbed person, authored by the operator +3. The operation (actor, request, comment) is journaled +4. On the next build, all historical activity of the absorbed person's accounts re-attributes to the survivor **Postconditions**: -- All aliases previously owned by `source_person_id` now point to `target_person_id` -- Full audit record in `merge_audits` with before/after snapshots +- The absorbed person has no current accounts (its history remains intact) +- The merge is attributable (who, when, why) and reversible via detach **Alternative Flows**: -- **Circular merge detected**: System returns HTTP 409 `merge_conflict` -- **Conflict detected post-merge**: ConflictDetector creates `conflicts` record; merge proceeds but operator is alerted +- **Merge was wrong**: the operator detaches the affected accounts to a new person (counter-action); no rollback machinery is involved +- **Same-person merge**: merging a person into itself is rejected as a no-op validation error --- -### GDPR Alias Purge +### GDPR Identity Purge - [ ] `p3` - **ID**: `cpt-ir-usecase-gdpr-purge` **Actor**: `cpt-ir-actor-operator` **Preconditions**: -- GDPR erasure request received for a specific `person_id` -- Person's aliases exist in the `aliases` table +- A right-to-erasure request has been received for a specific person +- The purge flow (late phase) is implemented **Main Flow**: -1. Operator calls `POST /purge` with `person_id` and `actor_person_id` -2. System copies all aliases for that person to `alias_gdpr_deleted` with `purged_at` and `purged_by_person_id` -3. System sets `is_deleted = 1` on all aliases for that person in `aliases` table -4. System returns confirmation with count of purged aliases +1. Operator invokes the purge for the person; the operation (actor, subject, time, reason) is recorded in the operations journal +2. The system erases the person's stored identity values everywhere they physically rest: the value payloads of the person's `persons` observations, the rows of the per-connector staging tables behind the `identity_inputs` union view (the view itself stores nothing), and the legacy `aliases` rows — structural rows may survive only value-free +3. Upstream copies the evidence derives from (connector history/raw layers) are erased through the owning ingestion domain's purge hook — a stated prerequisite of this flow: without it, a pipeline rebuild could re-materialize the values +4. The next journal mirror publish and gold build no longer contain the erased values; derived caches rebuild without them; transformations consult the deny-list (step 5) so an erased value cannot re-materialize even on rebuild +5. The purge records value-free tombstones in a deny-list consulted by automation and transformations: a keyed digest (HMAC-SHA256 of the normalized value) under a **versioned per-tenant key** held in the platform secret store. Keys are versioned, not rotated-in-place: each tombstone records the key version it was written under; new tombstones use the current version; a candidate value is checked by computing its digest under every retained key version — erased plaintext cannot be re-digested, so key versions with live tombstones **MUST** be retained for the deny-list to function (they are secrets: key plus digest permits verifying guessed values, so versions live in the secret store under the same access control as the current key). Destroying a key version is a separate, explicitly-confirmed administrative act that crypto-shreds the tombstones written under it and **forfeits their re-link protection entirely**: the system can no longer recognise a re-delivered copy of those values, and it will flow through ordinary automatic processing (it may auto-link or mint a person like any new evidence). The destruction record in the operations journal **MUST** state this consequence **Postconditions**: -- Aliases archived in `alias_gdpr_deleted` -- Aliases no longer resolvable via `POST /resolve`, Dictionary lookup, or direct query -- ClickHouse Dictionary refreshes within TTL (30-60s) to exclude purged aliases +- Erased values are unresolvable via any path (service API, analytics mirror, direct query) +- No plaintext copy of an erased value is retained anywhere, including archives **Alternative Flows**: -- **No aliases found for person**: System returns success with `count: 0` -- **Person has active merge audit**: System warns operator but proceeds (merges are alias-level, not person-level) +- **No values found for the person**: purge succeeds with a zero count +- **A connector re-delivers an erased value**: the deny-list digest matches; automatic linking is blocked and the account surfaces for operator review +- **The ingestion-domain purge hook is unavailable**: the purge reports partial completion and the erasure request stays open — domain-local erasure alone does not satisfy the requirement + +> The v2.0 mechanism — copying purged aliases into a plaintext `alias_gdpr_deleted` archive with a soft-delete in `aliases` — is rejected: retaining the values contradicts hard erasure, and it never touched `persons`, where the values actually rest (see DESIGN §3.7). --- ## 9. Acceptance Criteria -- [ ] Aliases seeded from HR Bronze data are resolvable via `POST /resolve` within Phase 1 deployment -- [ ] BootstrapJob processes 100K `identity_inputs` rows and creates correct aliases without duplicates -- [ ] >= 80% of aliases are auto-resolved (confidence >= 1.0) after BootstrapJob runs on typical connector data -- [ ] Unmapped aliases appear in operator queue with correct suggestions -- [ ] No fuzzy rule (Phase B3) produces an auto-linked alias under any input -- [ ] Merge + split round-trip preserves exact alias state (snapshot comparison passes) -- [ ] GDPR purge renders aliases unresolvable within 60 minutes +- [ ] Accounts observed by connectors are bound by the seed or surfaced for review by the evidence-derived queue (e-mail-less accounts included); bound accounts resolve to a `person_id` in gold builds and via the read API +- [ ] The seed processes 100K `identity_inputs` rows without duplicates; three consecutive runs on unchanged data change nothing +- [ ] An operator-authored binding survives a subsequent seed run and the connection of a new source, byte-for-byte +- [ ] Accounts pending a decision appear in the review queue with their candidate persons; a decision removes the item without any explicit "close" step +- [ ] Observed accounts with no identity evidence appear in the review queue (countable), never hidden; the queue reports resolution-rate shares +- [ ] Binding divergence explained by an operator decision is not surfaced as a conflict; unexplained seed-authored divergence is +- [ ] Merge, detach, bind (single and bulk), and exclude each append history only — no row of `persons` is ever updated or deleted +- [ ] A correction followed by its counter-correction restores the effective bindings (reversibility) +- [ ] Re-submitting an identical correction or bulk file is a no-op +- [ ] After a correction, the next gold build re-attributes the affected accounts' complete history, including past periods - [ ] Cross-tenant resolution returns empty for mismatched `insight_tenant_id` -- [ ] All mutating operations are idempotent (replay with same `Idempotency-Key` returns original result) +- [ ] (Future, with the matcher) No fuzzy rule produces an auto-applied merge under any input +- [ ] (Future, with the purge flow) GDPR purge renders identity values unresolvable within 60 minutes --- @@ -739,23 +759,24 @@ Every merge operation **MUST** be fully reversible via split. After a merge-then | Dependency | Description | Criticality | |---|---|---| -| ClickHouse 24.x+ | Storage engine for all identity resolution tables; `generateUUIDv7()` support required | `p1` | -| Person domain (`persons` table) | Provides `person_id` targets for alias mapping; identity resolution does not create persons | `p1` | -| dbt models (Bronze → Silver) | Populate `identity_inputs` during connector sync transformations | `p1` | -| Argo Workflows | Orchestrates BootstrapJob scheduling and execution on Kind K8s | `p1` | -| Connector sync pipeline | Writes alias observations to `identity_inputs`; must conform to write contract | `p1` | -| Person domain (person creation API) | Operator needs to create new persons when linking unmapped aliases to new identities | `p2` | +| ClickHouse 24.x+ | Evidence store (`identity_inputs`) and journal mirror; `generateUUIDv7()` support required | `p1` | +| MariaDB | Journal store (`persons`, `account_person_map`, `operations`); schema applied by the identity-resolution service (ADR-0006) | `p1` | +| dbt models (Bronze → Silver) | Populate `identity_inputs` during connector sync transformations; resolve `person_id` at build time via the macro | `p1` | +| Kubernetes scheduling (umbrella chart) | Runs the persons-seed on schedule and hosts the identity-resolution service | `p1` | +| Connector sync pipeline | Writes identity observations to `identity_inputs`; must conform to the write contract | `p1` | +| Person domain | Reads `persons` observations to build the golden record; consumer, not a prerequisite — this domain mints `person_id` (ADR-0002) | `p2` | --- ## 11. Assumptions -- Person records are created by the person domain (dbt seed in Phase 1, API in later phases) before identity resolution links aliases to them. Identity resolution does not create persons. +- `person_id` is minted by this domain (random UUIDv7 at first binding, ADR-0002); the person domain derives its golden record from this domain's observations, not the other way around. - Connectors conform to the `identity_inputs` write contract and provide accurate `value_field_name` values. - ClickHouse 24.x+ is available in all deployment environments with `generateUUIDv7()` support. -- The five alias types (`email`, `username`, `employee_id`, `display_name`, `platform_id`) cover all current connector identity signals. New types can be added as configuration without schema changes. -- HR source data (BambooHR, Workday) provides the most reliable identity anchors for initial seeding. -- Operator reviews the unmapped queue on a regular basis. Backlog alerts trigger if queue exceeds configured threshold (see NFR `unmapped_rate < 20%`). +- The canonical value types (`id`, `email`, `username`, `display_name`) plus known custom types cover current connector identity signals. New types can be added without schema changes. +- E-mail is the sole automatic identity anchor in the current phase; richer signals arrive with the future matcher. +- A single operator per tenant performs corrections in the current phase; concurrent multi-operator workflows are a revisit trigger of ADR-0003. +- The operator reviews the queue regularly; backlog growth is monitored via queue counts and the coverage view. --- @@ -763,10 +784,10 @@ Every merge operation **MUST** be fully reversible via split. After a merge-then | Risk | Impact | Mitigation | |---|---|---| -| ClickHouse lacks ACID transactions for merge/split (late phase — not yet implemented) | Partial state possible if operation fails mid-execution | See DESIGN §5 REC-IR-01: advisory locking + idempotent operations; retry-safe design | -| Connector writes malformed `identity_inputs` rows | BootstrapJob fails or creates incorrect aliases | Write contract validation at ingestion; malformed rows logged and skipped | -| Person domain unavailable during bootstrap | BootstrapJob cannot resolve new identities to person records | Route to `unmapped` queue; retry on next run when person domain is available | -| False-negative matching (too conservative) | Legitimate aliases stuck in unmapped queue; operator burden increases | Monitor unmapped rate; tune B2 rules for cross-system matching | -| ClickHouse ReplacingMergeTree dedup delay | Duplicate alias rows visible briefly before background merge | Application-level dedup check on read; FINAL keyword for critical queries | -| Scale: large organization with > 100K persons | Bootstrap throughput or alias lookup latency degrades | Benchmark at 100K+ scale; optimize ORDER BY keys and Dictionary layout | +| Connector writes malformed `identity_inputs` rows | Seed fails or creates incorrect bindings | Write contract validation at ingestion; malformed rows logged and skipped | +| Conservative automation (review-first for contested evidence) | Queue grows after each new connector; operator burden increases | Bulk bind for prepared matching tables; queue counts monitored; future matcher proposals reduce per-item work | +| Operator error in corrections | Wrong merge/detach mis-attributes metrics until noticed | History is never destroyed: counter-action restores state; per-account history supports investigation; single-operator assumption limits contention | +| E-mail-less accounts cannot be auto-bound | Their activity stays unattributed until an operator binds them | Never hidden: surfaced in the review queue from evidence; operator `bind` covers them (#1776) | +| Scale: large organization with > 100K persons | Seed throughput or lookup latency degrades | Benchmark at 100K+ scale; indexes sized in DESIGN §3.7 | +| Future matcher reintroduces silent merges | Corrupted attribution, loss of operator trust | ADR-0003 invariants: proposals only, journal decisions override rules; no-fuzzy-autolink NFR | | Domain boundary misunderstanding | Teams accidentally put person-domain logic in identity-resolution | Clear scope documentation (§4); code review enforcement of domain boundaries |