Skip to content

fix(hr): make class_people a true one-row-per-person snapshot - #2125

Merged
mitasovr merged 2 commits into
mainfrom
fix/class-people-snapshot-grain
Aug 3, 2026
Merged

fix(hr): make class_people a true one-row-per-person snapshot#2125
mitasovr merged 2 commits into
mainfrom
fix/class-people-snapshot-grain

Conversation

@mitasovr

@mitasovr mitasovr commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Problem

A data-quality audit on virtuozzo reported two findings against silver.class_people:

  • "19 duplicate active emails — the SCD2 dedup is broken"
  • "active-count drift: silver has 20 more active people than bronze"

They are one root cause, and the dedup is not broken — ReplacingMergeTree is behaving correctly. Live numbers: 1440 rows for 1420 employees, 414 active rows for 394 active people (~5% headcount inflation), countIf(valid_to IS NOT NULL) = 0.

Root cause: two defects compounding

Neither is sufficient alone.

  1. The __to_class_people staging views read append-only bronze without FINAL. Airbyte appends a full snapshot per sync, and bronze is ReplacingMergeTree(_airbyte_extracted_at), which only collapses on background merge. A bare read therefore emits every unmerged snapshot row. (Caught live: bronze went from 1420 rows / 1 part to 2840 rows / 2 parts mid-investigation.)
  2. Those views appended a version axis to unique_key (lastChanged / createdDateTime / whenCreated / Last_Functionally_Updated), as ADR-0004 required. That turns each transient bronze duplicate into a permanently distinct silver key — so the versionless RMT can never collapse it, and FINAL cannot either, because the keys genuinely differ. SELECT count() FROM silver.class_people FINAL returns the same 1440.

valid_to was hardcoded NULL in every producer, so nothing marked which row was current.

Because the table is materialized='table', the surplus is regenerated on every run and drifts with bronze merge timing — which is exactly why it surfaced as two separate symptoms. The nightly rebuilt it mid-investigation and the surplus moved 19 → 20.

The arithmetic, end to end

1440 rows = 1400 entities x 1 row + 20 entities x 2 rows
1420 distinct (source, source_person_id)      <- exactly bronze's 1420 employees
 414 active rows = 374 singles + 20 doubles   -> 394 distinct active entities

The residual vs bronze's 393 status='Active' is +1, and it is not a status disagreement between versions. It is the ELSE 'active' catch-all: exactly one bronze row has status='' with employmentHistoryStatus='Third party' and falls through to active. 393 + 1 = 394. ✔

Why the contract allowed this

Two normative documents disagreed:

Source Claim
ADR-0004:58 version axis in unique_key is mandatory for class_people
DESIGN.md:178 class_people is a snapshot "with no event history kept at this layer"
hr-directory/README.md:168 full SCD2, "exactly one row per person may have valid_to IS NULL", enforced by an "SCD2 Merge component"
ADR-0001:109 exempted class_people from the read-dedup cleanup because it "already collapse[s] to one row per key"

ADR-0001:109 is the load-bearing error: "one row per key" only equals "one row per person" while unique_key is the entity key — which ADR-0004 had already stopped being true.

History is genuinely covered elsewhere, which is what makes the snapshot reading safe: each connector already has *_employees_snapshot / *_fields_history (incremental+append, 17 tracked columns) that accumulate across syncs. class_people is materialized='table' and cannot retain history by construction. Note class_hr_events is not that history — it reads leave_requests only.

Changes

  • All four __to_class_people views (active-directory, bamboohr, ms-entra, workday): entity-level unique_key, valid_to dropped, bronze read with FINAL. All four projections verified aligned 1:1 (25 columns) for the positional UNION ALL.
  • ELSE 'active''unknown' in all four, with accepted_values updated. Defaulting an unrecognised source status to active silently inflates headcount.
  • Same missing FINAL in bamboohr__working_hours / workday__working_hours. No row inflation there (unique_key was already entity-level), but WHERE status='Active' ran before dedup, so a leaver could still qualify via a stale snapshot row.
  • New assert_class_people_one_row_per_person data-quality check (data_quality, tier: error), plus unique/not_null on class_people.unique_key. There was previously no uniqueness test on this table and zero data_quality tests touching it.
  • Contract reconciled: ADR-0004, ADR-0001, union_by_tag docstring, HR README, four connector schema.yml, glossary.

Verification (live virtuozzo, read-only)

Simulating the fixed transform against live bronze:

before after
rows / distinct keys 1440 / 1420 1420 / 1420
active 414 rows (394 entities) 393
unknown 1

The new DQ check returns exactly the 20 current violations against the deployed table, and goes green after a rebuild.

Also demonstrated why FINAL is mandatory rather than defensive: the versionless LIMIT 1 BY unique_key in union_by_tag has no ORDER BY, so the surviving row is undefined. At max_threads=1 it picks the stale row for 20 of 20 changed entities; at max_threads=4/16, the fresh one.

Consumers

  • gold/metric_entity_cohorts_current.sql — already correct (LIMIT 1 BY ordered by valid_from DESC); keeps working, now over a unique input.
  • crm-gold-views.sql (4x silver.class_people FINAL) — FINAL was a false safety signal; the people CTE is LEFT JOINed on lower(email), so duplicates fan out and double count()/sum(). Currently latent (verified 0 overlap between CRM owner emails and the 20 duplicated emails); fixed at the source by this PR.
  • insight.people — reads bronze directly with argMax; unaffected.
  • No reference to class_people in services/analytics. valid_to had zero readers anywhere in src/.

Migration

None needed. class_people is materialized='table', so one dbt run replaces it wholesale and the surplus rows disappear — unlike the column-order heal precedent in 20260716000000_class_contract_heal.sql, which exists for incremental tables with positional inserts.

argo submit --from workflowtemplate/dbt-run -n insight-v2 -p dbt_select="tag:bamboohr+" -l workflows.argoproj.io/controller-instanceid=argo-v2-insight-v2

Expect count() = uniqExact(source, source_person_id) = 1420 and countIf(status='active') = 393.

Deliberately not in this PR

  • audit_rmt_read_dedup.py has a false negative that hid this bug. It matches source('<name>','<table>') against the physical <schema>.<table> recorded by promote_bronze_to_rmt, so connectors declaring an unprefixed source name (bamboohr, workday — both class_people producers) are silently classified non-RMT and skipped; ms-entra is caught because its source is named bronze_ms_entra. The one-line fix surfaces 5 further genuine gaps. Left out because the file predates the repo's ruff config and touching it trips 31 pre-existing lint errors plus a whole-file reformat, which would bury this change. Follow-up needed.
  • silver/hr/schema.yml:7 references dbt_utils.unique_combination_of_columns, but dbt_utils is not installed anywhere (no packages.yml, no dbt deps, not vendored) — a pre-existing latent breakage. Follow-up needed.
  • hr_events models also read bronze without FINAL, but union_by_tag's versioned dedup path (QUALIFY ROW_NUMBER ... ORDER BY _version DESC) protects them, unlike the versionless path used here.

Rebase note

Rebased onto upstream/main (178 commits) and moved from the mitasovr fork into this repository; supersedes #2045, which is closed.

One conflict, resolved in favour of upstream: active_directory__to_class_people.sql had manager_person_id retyped Nullable(UUID)Nullable(String) and hire_date/termination_date DateDateTime on main (the NO_COMMON_TYPE alignment fix). Those types are kept as-is; this PR only removes valid_to, drops the version axis from unique_key, adds FINAL, and changes the status catch-all. Re-verified after the rebase: all four producers have valid_to gone, FINAL present, no ELSE 'active', no version concat, and their column projections are byte-identical.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Data Model Updates

    • HR people records now represent one current row per person, with version history handled separately.
    • Removed the valid_to field from people records.
    • Added support for the unknown employment status across HR directory sources.
    • Added support for Microsoft Entra directory data.
  • Data Quality

    • Added validation to detect duplicate current people records.
    • Improved latest-record selection for HR data and working-hours reporting.
  • Documentation

    • Updated HR directory, schema, glossary, and architecture guidance to reflect current snapshot behavior and data-quality rules.

`silver.class_people` overstated headcount by ~5% on virtuozzo (1440 rows for
1420 employees; 414 "active" rows for 394 active people). Two defects
compounded — neither is sufficient alone:

1. The `__to_class_people` staging views read append-only bronze WITHOUT
   `FINAL`. Airbyte appends a full snapshot per sync and bronze is
   RMT(_airbyte_extracted_at), which only collapses on background merge, so a
   bare read emits every unmerged snapshot row.
2. Those views appended a version axis (`lastChanged` / `createdDateTime` /
   `whenCreated` / `Last_Functionally_Updated`) to `unique_key`, per ADR-0004.
   That turned each transient bronze duplicate into a permanently distinct
   silver key, so the versionless RMT could never collapse it — and `FINAL`
   could not either, because the keys genuinely differ. `valid_to` was
   hardcoded NULL, so nothing marked which row was current.

The surplus was regenerated on every run and drifted with bronze merge timing,
which is why it surfaced as two separate audit findings ("duplicate active
emails" and "active-count drift").

class_people is a current-state snapshot, not an SCD2 history table: it is
`materialized='table'` and rebuilt in full each run, so it cannot accumulate
history by construction. HR attribute history already lives in the per-source
`*_snapshot` / `*_fields_history` chain, which is incremental+append and tracks
strictly more fields.

Changes:
* All four `__to_class_people` views (active-directory, bamboohr, ms-entra,
  workday): entity-level `unique_key`, `valid_to` dropped, bronze read with
  `FINAL`. Projections stay aligned 1:1 for the positional UNION.
* `ELSE 'active'` catch-alls replaced with `'unknown'`. A BambooHR record with
  `status=''` / `employmentHistoryStatus='Third party'` was being counted as
  active — this is the residual +1 between bronze (393) and silver (394).
  `accepted_values` updated accordingly.
* Same missing `FINAL` fixed in `bamboohr__working_hours` /
  `workday__working_hours`: there the `status='Active'` filter ran before
  dedup, so a leaver could still qualify via a stale snapshot row.
* New `assert_class_people_one_row_per_person` data-quality check, plus
  `unique`/`not_null` on `class_people.unique_key`.
* Reconcile the contract, which contradicted itself: ADR-0004 mandated the
  version axis while DESIGN.md and ADR-0001 called class_people a pure
  snapshot, and ADR-0001 exempted it from the read-dedup cleanup on the
  now-false premise that it "already collapses to one row per key".

Verified against the live virtuozzo cluster: the fixed transform yields
1420 rows / 1420 distinct keys, active 394 -> 393, unknown 1; the new check
reports exactly the 20 current violations and goes green after a rebuild.
No migration needed — `materialized='table'` fully replaces the table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Roman Mitasov <Roman.Mitasov@constructor.tech>
@mitasovr
mitasovr requested a review from a team as a code owner August 3, 2026 03:42
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR changes HR class_people models from versioned SCD2-style rows to current-state snapshots. It removes valid_to, uses entity-level keys, adds Bronze FINAL reads, normalizes unknown statuses, updates documentation and schemas, and adds duplicate-grain validation.

Changes

HR current-state ingestion

Layer / File(s) Summary
Current-state contracts and storage
docs/components/connectors/hr-directory/README.md, docs/domain/ingestion-data-flow/specs/ADR/*, docs/shared/glossary/README.md, src/ingestion/dbt/macros/union_by_tag.sql, src/ingestion/silver/_shared/schema.yml, src/ingestion/scripts/connectors-ddl/silver.sql
Documentation, shared metadata, and the Silver DDL now define versionless entity keys, current-state semantics, FINAL reads, history boundaries, and removal of valid_to.
Connector current-state models
src/ingestion/connectors/hr-directory/{active-directory,bamboohr,ms-entra,workday}/dbt/*
People models now emit one current row per source person, preserve entity-level unique_key, map unrecognized statuses to unknown, and read Bronze sources with FINAL.
Working-hours reads and grain validation
src/ingestion/connectors/hr-directory/{bamboohr,workday}/dbt/*_working_hours.sql, src/ingestion/dbt/tests/hr/assert_class_people_one_row_per_person.sql
Working-hours queries use merged Bronze snapshots. A dbt warning test detects multiple current rows for one workspace, source, and person.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Bronze
  participant HRConnectorModels
  participant union_by_tag
  participant SilverClassPeople
  Bronze->>HRConnectorModels: Read source rows with FINAL
  HRConnectorModels->>union_by_tag: Provide entity-level unique_key rows
  union_by_tag->>SilverClassPeople: Select versionless current-state rows
Loading

Possibly related issues

Possibly related PRs

Suggested reviewers: ktursunov, cyberantonz

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: enforcing one current-state row per person in class_people.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/class-people-snapshot-grain

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

⚠️ Regenerate the connectors-ddl snapshot

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

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

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

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

@mitasovr
mitasovr enabled auto-merge August 3, 2026 03:47
The committed DDL snapshot still declared `valid_to Nullable(DateTime)` on
`silver.class_people`. The `connectors-ddl snapshot + field parity` job
rebuilds the database from the dbt models and re-dumps the DDL, so removing
the column from the four `__to_class_people` producers made the snapshot
drift and fail the gate.

This is exactly the one-line delta the CI drift check computed; no other
snapshot references `valid_to`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Roman Mitasov <Roman.Mitasov@constructor.tech>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

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

⚠️ Outside diff range comments (1)
docs/components/connectors/hr-directory/README.md (1)

431-461: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Align the schema table with the shipped DDL.

Line 430 still calls workspace_id a partition-key component, but Line 454 states that the table has no partition key. The DDL also defines valid_from, source_person_id, and several person attributes as nullable, and defines org_unit_id as Nullable(UUID), not String. Update the table so it remains an accurate ClickHouse schema reference.

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

In `@docs/components/connectors/hr-directory/README.md` around lines 431 - 461,
The schema table in the class people documentation is inconsistent with the
shipped DDL. Remove the claim that workspace_id is part of the partition key,
update valid_from, source_person_id, and the nullable person attributes to
reflect their Nullable definitions, and change org_unit_id from String to
Nullable(UUID). Keep the Engine/ORDER BY and no-partition-key description
aligned with the DDL.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/components/connectors/hr-directory/README.md`:
- Around line 67-79: Remove the shipped-flow claim that HR Silver produces
class_org_units in the paragraph describing canonical tables, keeping
class_people as the only currently produced table. Preserve the existing
statement that class_org_units does not exist, and describe it as planned only
if that terminology is already supported by the surrounding documentation.

In `@docs/shared/glossary/README.md`:
- Around line 521-522: Remove the `valid_from` to `effective_from` entry for
`bamboohr__to_class_people.sql` from the glossary convention-violations table;
treat `class_people` as a current-state snapshot whose `valid_from` is the
source change timestamp, without adding an exception unless required by the
surrounding documentation.

In
`@src/ingestion/connectors/hr-directory/active-directory/dbt/active_directory__to_class_people.sql`:
- Line 24: Preserve missing unique_key values so not_null validation can detect
them, or quarantine rows lacking an entity key, instead of coalescing them to an
empty string. Apply this change to the unique_key projection in
active_directory__to_class_people.sql (line 24), bamboohr__to_class_people.sql
(line 24), and ms_entra__to_class_people.sql (line 24), while retaining the
existing valid-key behavior.

In `@src/ingestion/silver/_shared/schema.yml`:
- Around line 5-13: Update the shared class_people contract description to
include Active Directory among the HR sources and accurately document
provider-specific valid_from semantics, including that Active Directory and MS
Entra use creation timestamps (whenCreated and createdDateTime) rather than
last-change timestamps; keep the contract aligned with the existing connector
projections.

---

Outside diff comments:
In `@docs/components/connectors/hr-directory/README.md`:
- Around line 431-461: The schema table in the class people documentation is
inconsistent with the shipped DDL. Remove the claim that workspace_id is part of
the partition key, update valid_from, source_person_id, and the nullable person
attributes to reflect their Nullable definitions, and change org_unit_id from
String to Nullable(UUID). Keep the Engine/ORDER BY and no-partition-key
description aligned with the DDL.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 05201ca6-628c-476f-adb8-55fd4d10c88d

📥 Commits

Reviewing files that changed from the base of the PR and between 59e87f0 and 83db3ab.

📒 Files selected for processing (18)
  • docs/components/connectors/hr-directory/README.md
  • docs/domain/ingestion-data-flow/specs/ADR/0001-rmt-with-version-and-unique-key.md
  • docs/domain/ingestion-data-flow/specs/ADR/0004-unique-key-formula.md
  • docs/shared/glossary/README.md
  • src/ingestion/connectors/hr-directory/active-directory/dbt/active_directory__to_class_people.sql
  • src/ingestion/connectors/hr-directory/active-directory/dbt/schema.yml
  • src/ingestion/connectors/hr-directory/bamboohr/dbt/bamboohr__to_class_people.sql
  • src/ingestion/connectors/hr-directory/bamboohr/dbt/bamboohr__working_hours.sql
  • src/ingestion/connectors/hr-directory/bamboohr/dbt/schema.yml
  • src/ingestion/connectors/hr-directory/ms-entra/dbt/ms_entra__to_class_people.sql
  • src/ingestion/connectors/hr-directory/ms-entra/dbt/schema.yml
  • src/ingestion/connectors/hr-directory/workday/dbt/schema.yml
  • src/ingestion/connectors/hr-directory/workday/dbt/workday__to_class_people.sql
  • src/ingestion/connectors/hr-directory/workday/dbt/workday__working_hours.sql
  • src/ingestion/dbt/macros/union_by_tag.sql
  • src/ingestion/dbt/tests/hr/assert_class_people_one_row_per_person.sql
  • src/ingestion/scripts/connectors-ddl/silver.sql
  • src/ingestion/silver/_shared/schema.yml
💤 Files with no reviewable changes (1)
  • src/ingestion/scripts/connectors-ddl/silver.sql

Comment on lines +67 to +79
> **Implementation status.** This document is the original HR Silver design. Parts of it describe components that were never built, and they must not be read as the current contract:
>
> - There is **no "SCD2 Merge" component**. Bronze → Silver is plain dbt (`<source>__to_class_people` staging views unioned by `union_by_tag` into `silver.class_people`). Every section describing row-closing writes (`UPDATE ... SET valid_to = ...`) is unimplemented design.
> - **`class_people` has no `valid_to`** and holds no version history — see below.
> - **`class_org_units` does not exist** as a table or a dbt model. `class_people.org_unit_id` is consequently unpopulated; department attribution currently flows through `department_name`.
>
> For the shipped data-flow contract see [ADR-0001](../../../domain/ingestion-data-flow/specs/ADR/0001-rmt-with-version-and-unique-key.md) and [ADR-0004](../../../domain/ingestion-data-flow/specs/ADR/0004-unique-key-formula.md).

The HR Silver Layer transforms raw HR directory data from Bronze source tables (BambooHR, MS Entra, Workday, LDAP/Active Directory) into two canonical, workspace-isolated Silver tables: `class_people` and `class_org_units`.

`class_people` is a **current-state snapshot**: exactly one row per person per source, keyed on an entity-level `unique_key`. `valid_from` records when the source last changed the record; there is no `valid_to`. It is `materialized='table'` and rebuilt in full on every run, so it cannot accumulate history by construction.

HR attribute history lives in the per-source SCD2 chain — `<source>__<entity>_snapshot` and `<source>__<entity>_fields_history` — which are `incremental`/`append` and therefore genuinely accumulate versions across syncs, tracking strictly more fields than `class_people` exposes. Do not add version rows to `class_people`: see ADR-0004 and ADR-0001 for the headcount-inflation bug this caused.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Remove the claim that class_org_units is currently produced.

Line 71 states that class_org_units does not exist. Line 75 states that the flow transforms data into class_people and class_org_units. Mark class_org_units as planned, or remove it from the shipped-flow description.

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

In `@docs/components/connectors/hr-directory/README.md` around lines 67 - 79,
Remove the shipped-flow claim that HR Silver produces class_org_units in the
paragraph describing canonical tables, keeping class_people as the only
currently produced table. Preserve the existing statement that class_org_units
does not exist, and describe it as planned only if that terminology is already
supported by the surrounding documentation.

Comment on lines +521 to +522
| `src/ingestion/connectors/hr-directory/bamboohr/dbt/bamboohr__to_class_people.sql` | bare `tenant_id` | `insight_tenant_id` (section 3.1) |
| `src/ingestion/connectors/hr-directory/bamboohr/dbt/bamboohr__to_class_people.sql` | `valid_from` | `effective_from` (section 4.2) |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Remove the incorrect valid_from convention violation.

Section 4.2 applies to records with an effective validity range. class_people is a current-state snapshot with no valid_to, and its valid_from records the source change time. Remove this entry, or document the snapshot timestamp as an explicit exception.

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

In `@docs/shared/glossary/README.md` around lines 521 - 522, Remove the
`valid_from` to `effective_from` entry for `bamboohr__to_class_people.sql` from
the glossary convention-violations table; treat `class_people` as a
current-state snapshot whose `valid_from` is the source change timestamp,
without adding an exception unless required by the surrounding documentation.

-- ORDER BY unique_key) collapses to one row per user. Do NOT add a version
-- axis here — that would make every changed record a second
-- permanently-"current" row (see ADR-0004).
CAST(coalesce(u.unique_key, '') AS String) AS unique_key,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep missing entity keys detectable in every connector.

All three models convert a missing unique_key into ''. This bypasses not_null validation and can let malformed identities enter silver.class_people.

  • src/ingestion/connectors/hr-directory/active-directory/dbt/active_directory__to_class_people.sql#L24-L24: preserve NULL, or quarantine the invalid row.
  • src/ingestion/connectors/hr-directory/bamboohr/dbt/bamboohr__to_class_people.sql#L24-L24: preserve NULL, or quarantine the invalid row.
  • src/ingestion/connectors/hr-directory/ms-entra/dbt/ms_entra__to_class_people.sql#L24-L24: preserve NULL, or quarantine the invalid row.
📍 Affects 3 files
  • src/ingestion/connectors/hr-directory/active-directory/dbt/active_directory__to_class_people.sql#L24-L24 (this comment)
  • src/ingestion/connectors/hr-directory/bamboohr/dbt/bamboohr__to_class_people.sql#L24-L24
  • src/ingestion/connectors/hr-directory/ms-entra/dbt/ms_entra__to_class_people.sql#L24-L24
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/ingestion/connectors/hr-directory/active-directory/dbt/active_directory__to_class_people.sql`
at line 24, Preserve missing unique_key values so not_null validation can detect
them, or quarantine rows lacking an entity key, instead of coalescing them to an
empty string. Apply this change to the unique_key projection in
active_directory__to_class_people.sql (line 24), bamboohr__to_class_people.sql
(line 24), and ms_entra__to_class_people.sql (line 24), while retaining the
existing valid-key behavior.

Comment on lines +5 to +13
description: >
Unified person registry from all HR sources (BambooHR, MS Entra, Workday). Current-state snapshot: exactly one row per person per source. NOT an SCD2 history table — HR attribute history lives in the per-source `*_snapshot` / `*_fields_history` chain, which accumulates across syncs (this model is `materialized='table'` and is rebuilt in full every run, so it cannot retain history). `valid_from` records when the source last changed the record; there is no `valid_to` — see ADR-0004.
columns:
- name: unique_key
description: >
Entity-level key (`{tenant}-{source}-{source_person_id}`) and the RMT ORDER BY column. MUST NOT carry a version axis: a per-version key stops silver RMT from collapsing versions, so every changed record becomes a second permanently-current row.
tests:
- not_null
- unique

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Correct the shared class_people contract.

Line 6 omits Active Directory, although active_directory__to_class_people.sql emits source = 'active-directory'. Line 6 also states that valid_from records the last source change, but Active Directory and MS Entra project whenCreated and createdDateTime. Define the provider-specific timestamp semantics accurately, or align all connector projections.

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

In `@src/ingestion/silver/_shared/schema.yml` around lines 5 - 13, Update the
shared class_people contract description to include Active Directory among the
HR sources and accurately document provider-specific valid_from semantics,
including that Active Directory and MS Entra use creation timestamps
(whenCreated and createdDateTime) rather than last-change timestamps; keep the
contract aligned with the existing connector projections.

@mitasovr
mitasovr added this pull request to the merge queue Aug 3, 2026
Merged via the queue into main with commit c9ab463 Aug 3, 2026
47 checks passed
@mitasovr
mitasovr deleted the fix/class-people-snapshot-grain branch August 3, 2026 04:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Employees appear twice in the people registry after their HR record changes

3 participants