Tracking board and role changes

The Norwegian business registry records every board member, CEO, auditor, and accountant for all ~1 million legal entities. tidybrreg provides two mechanisms to detect when these roles change: automated sync via brreg_sync(), and manual diffing via diff_roller_state().

Roller data: two types of role holder

Every role assignment is either person-held (board members, CEOs, sole proprietors) or entity-held (auditor firms, accountants). The two types have completely disjoint column patterns:

Column Person-held Entity-held
person_id 1968-05-04_opedal_anders_ NA
entity_org_nr NA 976389387
entity_name NA ERNST & YOUNG AS
first_name Anders NA
birth_date 1968-05-04 NA

Person-held roles make up ~80% of the register. Entity-held roles (auditors, accountants) make up ~20%.

Fetching roles

library(tidybrreg)
library(dplyr)

# Single entity
roles <- brreg_roles("923609016")
roles

brreg_roles() returns one row per role assignment with 18 columns. The role_group_code column identifies the category (STYR = board, DAGL = CEO, REVI = auditor, REGN = accountant), while role_code identifies the specific position (LEDE = chair, MEDL = member, etc.).

Board summary

roles |> brreg_board_summary()

brreg_board_summary() computes governance covariates from the role data. Resigned and deregistered roles are excluded from all counts. The n_employee_elected count identifies board members elected by employees (those with a non-NA elected_by value).

Detecting changes with diff_roller_state()

diff_roller_state() is the core change detection function. It takes two role state tibbles and returns a long-format changelog recording every field-level mutation.

old <- brreg_roles("810556722")
# ... time passes, board changes occur ...
new <- brreg_roles("810556722")

changes <- diff_roller_state(old, new)
changes

The changelog has 8 columns: timestamp, org_nr, registry, change_type, field, value_from, value_to, update_id.

Three types of change are detected:

  • entry: a new role assignment appears (new board member, new auditor)
  • exit: a role assignment disappears (board member steps down)
  • change: a field value changes on a continuing role (e.g. deceased FALSE → TRUE)

Roles are identified by a composite key: (org_nr, role_group_code, role_code, holder_id). For person-held roles, holder_id is the synthetic person_id. For entity-held roles, it is entity:{org_nr}. When an auditor switches from PwC to Deloitte, this appears as an exit + entry pair (different holder identity), not as a field modification.

Automated sync

brreg_sync() automates the download-diff-persist cycle. Two strategies are available for roller data:

Bulk method (default)

brreg_sync(types = "roller", roller_method = "bulk")

Downloads the full totalbestand (~131 MB), parses it, diffs against stored state, writes the changelog and updated state. The CDC endpoint is polled only for cursor advancement (capped at 5 pages). This is the recommended approach for daily or weekly syncs.

CDC method (per-org fallback)

brreg_sync(types = "roller", roller_method = "cdc")

Polls the CDC endpoint for affected org numbers, then calls brreg_roles() for each org individually and diffs per-org. Slower (one API call per affected entity), but provides per-event timestamp attribution. Useful for sub-daily monitoring of a known entity set.

Querying the changelog

After sync, the changelog is stored as Hive-partitioned Parquet:

# All roller changes
brreg_changes(registry = "roller")

# Board entries only
brreg_changes(registry = "roller", change_type = "entry")

# Changes for a specific company
brreg_changes(registry = "roller", org_nr = "923609016")

# Summary counts
brreg_change_summary(registry = "roller")

Example: monitoring board turnover

A typical use case is detecting board/management changes for a portfolio of companies:

# 1. Poll CDC for orgs with role changes
cdc <- brreg_updates(type = "roller", since = Sys.Date() - 7)

# 2. Fetch current roles for changed orgs
changed_orgs <- unique(cdc$org_nr)
current <- bind_rows(lapply(changed_orgs, function(org) {
  tryCatch(brreg_roles(org), error = function(e) tibble())
}))

# 3. Filter to board/management
board_mgmt <- current |>
  filter(role_group_code %in% c("STYR", "DAGL"))

# 4. Diff against previous state
previous <- arrow::read_parquet("previous_state.parquet") |>
  filter(org_nr %in% changed_orgs,
         role_group_code %in% c("STYR", "DAGL"))

changes <- diff_roller_state(previous, board_mgmt)

# 5. Inspect
changes |>
  filter(change_type %in% c("entry", "exit")) |>
  count(change_type, field = "role_group")

Schema evolution

State files written before v0.3.4 have 14 columns. Current state has 18 columns (added deregistered, ordering, elected_by, group_modified). When brreg_sync() encounters a legacy state file, it backfills the missing columns as NA before diffing. The resulting changelog contains change events for every role where the new columns have non-NA values (NA → actual value). This is a one-time migration artifact reflecting real data enrichment.