DB Olympics

Queries · q071 · Head of Risk and Payments · week 1, night 2

Role grant and revocation activity over time

“Is staff access actively granted and revoked over time (evidence of ongoing access management), and can we attribute those changes to an identifiable actor?”

Manager · asked the questionAylin DemirHead of Risk and Payments. Previously in fraud operations at a bank. Responsible for payment performance and for keeping bad actors out.
Analyst · planned the reportClaire DuboisTime-series minded: weekly and daily trends, before and after, seasonality.
Engineer · wrote the SQLGraceWrites tidy, layered CTEs with descriptive names, one step per CTE, then a final select.

Fictional people at a fictional company, each played by a language-model agent. Who they are and what they are told

  1. A business event lands on the Head of Risk and Payments's desk

    Week 1; shared with Rosa Van den Berg, Head of Data

    An enterprise prospect sent a security and compliance questionnaire covering account security, access control, privacy requests, data retention and audit trails. Sales wants answers backed by evidence from our own systems, not by policy documents.

  2. Aylin Demir writes to Claire Dubois

    The manager, at the start of week 1

    Subject: Evidence we need for the security/compliance questionnaire (this week's priority)

    Hi Claire,

    This questionnaire is going to a serious enterprise prospect, and Sales wants it backed by evidence from our own systems, not by our policy wording. Rosa and I are co-owning this, so your reports will feed both the questionnaire answers themselves and a broader readiness review Rosa's side is doing. Given you only get a handful of report slots a night, let's be selective and prioritize what a security reviewer will actually probe on. In my own words, here's what I need to know:

    1. Who can get into a tenant's account, and how tightly is that controlled? I want a sense of how access is granted and revoked in practice — e.g. how many people hold elevated/admin-type access, whether access grants look current and reviewed, and whether we can show that departed or inactive staff lose access promptly.
    2. Do we actually enforce strong login security, or does it just sit in a policy doc? I'd like evidence on real login behavior — things like how much of our staff and customer access is protected by extra verification steps, and whether we can see failed-login and suspicious-access patterns being caught rather than ignored.
    3. When someone asks us to access, correct, or delete their data, do we actually meet our own deadlines? This is the one I most expect to be pressed on. I want to see how data subject requests are tracked end to end — how many come in, how quickly they're handled, and whether any are overdue or slipping past the commitments we've made.
    4. Is there a real, tamper-evident trail of who did what, and is it kept long enough to be useful? I want evidence that meaningful changes and sensitive data access are actually being logged consistently (not just in theory), and that we're not quietly losing history before our stated retention period is up.
    5. If something does go wrong, do we notice? I'd like a sense of how many flagged security or compliance incidents we've had, how quickly they get resolved, and whether any are sitting open for a long time — that's the kind of thing a prospect's security team will zero in on.

    Given the slot constraints, please treat questions 3 and 1 as the top two for this week — those are the ones Sales says the prospect calls out by name. If we only get through three of the five, drop 5 first and fold it into next week.

    One more thing: since this report touches account security and personal data, please keep the output at a safe aggregate level — no need to name individual customers or staff unless I specifically ask.

    Let's sync briefly before you lock in Thursday night's batch so nothing gets wasted on a bounced or bundled request.

    Thanks, Aylin

    A note from the Head of Data, Rosa Van den Berg

    Hi Claire,

    Before we hand evidence to this prospect, I'd want us to be confident the underlying records are actually solid enough to stand behind in writing. A few things from my side:

    1. Completeness and continuity of the audit trail itself – do we have reason to believe the security/access logs are gap-free over the period we'd quote, or are there known stretches where logging was thin, delayed, or archived in a way that could make "no incidents" look like "no visibility"?
    2. One actor, one identity – when we pull evidence on who accessed or changed what, can we actually tie an action back to a single, consistent user record, or do duplicate/merged accounts and inconsistent actor references risk us over- or under-counting who touched sensitive data?
    3. Timestamp trust – for anything time-bound (response times on data requests, retention windows, how quickly access was revoked), are we confident the timestamps we'd cite are clean and consistently recorded, rather than something we'd need to caveat?

    If any of these raise flags on your end, I'd rather we know before it goes into a customer-facing answer than after.

    Thanks, Rosa

  3. Claire Dubois plans the night

    The analyst · week 1, night 2, request 3 of 3

    Reading night 1 critically before spending more slots:

    Report 1 (DSAR): Volumes are tiny (0-4/month) but several months show "still_open_overdue" = 1, including one from as far back as 2023-05 and 2024-07, still unresolved as of 2025-12-31. That is a serious red flag for the exact claim Aylin expects to be pressed on — unless it's a data-definition artifact. The engineer's note says "completed" = completed_at IS NOT NULL, bypassing the status column entirely. If a request was closed as e.g. "rejected" or "cancelled" without completed_at being populated, it would wrongly show as "still open and overdue" forever. I need to reconcile against the actual status column before we can honestly tell Aylin whether these are real breaches or a timestamp/status mismatch (this is exactly Rosa's "timestamp trust" concern).

    Report 2 (Access): My own request had a flawed proxy — I treated roles.is_system (="built-in, cannot be deleted role") as "elevated/admin access," but 100% of active staff (548/548) hold a system role, which only makes sense if is_system marks a default baseline role every user gets, not a privilege tier. I need to re-derive "elevated access" from actual permissions/role_permissions data instead. Also notable: only 2 staff users total are deactivated and zero are suspended/invited across 120 tenants and 3 years — worth separately checking whether access is actively managed at all (grants/revocations happening over time) rather than static.

    Report 3 (Audit log continuity): Mostly reassuring — audit_log volume is steady (~3-5k/month) after the Jan 2024 migration from archive-only logging, actor_user_id nulls track almost exactly with system/api actor_type (so nulls aren't unexplained — they're mostly non-human actors, which is a decent signal for Rosa's "one actor one identity" concern). One thing to flag: all of 2023's audit activity lives only in audit_log_archive (live table has 0 rows pre-Dec-2023) — that's an artifact of the archival job's cutoff, not a gap, but I should keep unioning both tables going forward rather than relying on audit_log alone in any future report.

    Tonight (3 slots), I'm staying on Aylin's top two priorities to fix these data-quality issues before we write anything down: (1) reconcile the DSAR "open/overdue" numbers against the actual status column and check SLA-window (due_at) consistency; (2) replace the flawed is_system access proxy with a real permission-category-based view of elevated access by staff status; (3) check whether role grants/revocations are actively happening over time (evidence access is "reviewed/current") and whether those changes are attributable to identifiable actors. I'm holding off on priority #2 (MFA/login security) and #4 (retention) until night 3, and will sync with Aylin before Thursday as agreed.

    Role grant and revocation activity over time
    Question
    Is staff access actively granted and revoked over time (evidence of ongoing access management), and can we attribute those changes to an identifiable actor?
    One row per
    calendar month (occurred_at), combining audit_log and audit_log_archive, filtered to entity_table = 'user_roles', from 2023-01 through 2025-12
    Columns
    • month (YYYY-MM)
    • role_grants_created (count where action = 'create')
    • role_grants_removed (count where action = 'delete')
    • net_change (created minus removed)
    • distinct_actor_user_id (count of distinct non-null actor_user_id performing these changes in the month)
    • pct_actor_user_id_null (share of these change rows where actor_user_id is null)
  4. Grace writes the SQL

    The engineer · 2 dry runs on the empty schema, then the submission

    Dry run 1 · STATUS: REJECTED
    WITH combined_log AS (
      SELECT tenant_id, action, actor_user_id, entity_table, occurred_at
      FROM audit_log
      UNION ALL
      SELECT tenant_id, action, actor_user_id, entity_table, occurred_at
      FROM audit_log_archive
    ),
    role_changes AS (
      SELECT
        occurred_at,
        action,
        actor_user_id
      FROM combined_log
      WHERE entity_table = 'user_roles'
        AND action IN ('create', 'delete')
        AND occurred_at >= DATE '2023-01-01'
        AND occurred_at < DATE '2026-01-01'
    ),
    months AS (
      SELECT DISTINCT month_label, month_start
      FROM calendar_days
      WHERE month_start >= DATE '2023-01-01' AND month_start < DATE '2026-01-01'
    ),
    monthly_changes AS (
      SELECT
        to_char(occurred_at, 'YYYY-MM') AS month_label,
        count(*) FILTER (WHERE action = 'create') AS role_grants_created,
        count(*) FILTER (WHERE action = 'delete') AS role_grants_removed,
        count(DISTINCT actor_user_id) AS distinct_actor_user_id,
        count(*) AS total_changes,
        count(*) FILTER (WHERE actor_user_id IS NULL) AS null_actor_changes
      FROM role_changes
      GROUP BY 1
    )
    SELECT
      months.month_label AS month,
      coalesce(monthly_changes.role_grants_created, 0) AS role_grants_created,
      coalesce(monthly_changes.role_grants_removed, 0) AS role_grants_removed,
      coalesce(monthly_changes.role_grants_created, 0) - coalesce(monthly_changes.role_grants_removed, 0) AS net_change,
      coalesce(monthly_changes.distinct_actor_user_id, 0) AS distinct_actor_user_id,
      CASE WHEN coalesce(monthly_changes.total_changes,0) = 0 THEN NULL
           ELSE round(100.0 * monthly_changes.null_actor_changes / monthly_changes.total_changes, 1)
      END AS pct_actor_user_id_null
    FROM months
    LEFT JOIN monthly_changes ON months.month_label = monthly_changes.month_label
    ORDER BY months.month_label
    Dry run 2 · 1 change from the dry run before · STATUS: COMPILES (dry run on the empty schema, no data read)
    WITH combined_log AS (  SELECT tenant_id, action, actor_user_id, entity_table, occurred_at  FROM audit_log  UNION ALL  SELECT tenant_id, action, actor_user_id, entity_table, occurred_at  FROM audit_log_archive),role_changes AS (  SELECT    occurred_at,    action,    actor_user_id  FROM combined_log  WHERE entity_table = 'user_roles'    AND action IN ('create', 'delete')    AND occurred_at >= DATE '2023-01-01'    AND occurred_at < DATE '2026-01-01'),months AS (  SELECT DISTINCT month_label, month_start  FROM calendar_days  WHERE month_start >= DATE '2023-01-01' AND month_start < DATE '2026-01-01'),monthly_changes AS (  SELECT    to_char(occurred_at, 'YYYY-MM') AS month_label,    strftime(occurred_at, '%Y-%m') AS month_label,    count(*) FILTER (WHERE action = 'create') AS role_grants_created,    count(*) FILTER (WHERE action = 'delete') AS role_grants_removed,    count(DISTINCT actor_user_id) AS distinct_actor_user_id,    count(*) AS total_changes,    count(*) FILTER (WHERE actor_user_id IS NULL) AS null_actor_changes  FROM role_changes  GROUP BY 1)SELECT  months.month_label AS month,  coalesce(monthly_changes.role_grants_created, 0) AS role_grants_created,  coalesce(monthly_changes.role_grants_removed, 0) AS role_grants_removed,  coalesce(monthly_changes.role_grants_created, 0) - coalesce(monthly_changes.role_grants_removed, 0) AS net_change,  coalesce(monthly_changes.distinct_actor_user_id, 0) AS distinct_actor_user_id,  CASE WHEN coalesce(monthly_changes.total_changes,0) = 0 THEN NULL       ELSE round(100.0 * monthly_changes.null_actor_changes / monthly_changes.total_changes, 1)  END AS pct_actor_user_id_nullFROM monthsLEFT JOIN monthly_changes ON months.month_label = monthly_changes.month_labelORDER BY months.month_label

    “Query compiles with the expected columns. Submitting.”

    Submitted · the SQL of the last dry run, unchanged · Submitted to tonight's batch. Your work on this request is done.

    “Submitted. The report gives one row per month (2023-01 through 2025-12) with role grants created, removed, net change, distinct actors, and the null-actor share, using a calendar spine so months with no activity still show as zero rather than being silently dropped.”

  5. The report comes back overnight

    As Claire Dubois saw it, from the small dataset the agents worked on

    month   | role_grants_created | role_grants_removed | net_change | distinct_actor_user_id | pct_actor_user_id_null
    --------+---------------------+---------------------+------------+------------------------+-----------------------
    2023-01 |                   0 |                   0 |          0 |                      0 |                   NULL
    2023-02 |                   0 |                   0 |          0 |                      0 |                   NULL
    2023-03 |                   0 |                   0 |          0 |                      0 |                   NULL
    2023-04 |                   0 |                   0 |          0 |                      0 |                   NULL
    2023-05 |                   0 |                   0 |          0 |                      0 |                   NULL
    2023-06 |                   0 |                   0 |          0 |                      0 |                   NULL
    2023-07 |                   0 |                   0 |          0 |                      0 |                   NULL
    2023-08 |                   0 |                   0 |          0 |                      0 |                   NULL
    2023-09 |                   0 |                   0 |          0 |                      0 |                   NULL
    2023-10 |                   0 |                   0 |          0 |                      0 |                   NULL
    2023-11 |                   0 |                   0 |          0 |                      0 |                   NULL
    2023-12 |                   0 |                   0 |          0 |                      0 |                   NULL
    2024-01 |                   0 |                   0 |          0 |                      0 |                   NULL
    2024-02 |                   0 |                   0 |          0 |                      0 |                   NULL
    2024-03 |                   0 |                   0 |          0 |                      0 |                   NULL
    2024-04 |                   0 |                   0 |          0 |                      0 |                   NULL
    2024-05 |                   0 |                   0 |          0 |                      0 |                   NULL
    2024-06 |                   0 |                   0 |          0 |                      0 |                   NULL
    2024-07 |                   0 |                   0 |          0 |                      0 |                   NULL
    2024-08 |                   0 |                   0 |          0 |                      0 |                   NULL
    2024-09 |                   0 |                   0 |          0 |                      0 |                   NULL
    2024-10 |                   0 |                   0 |          0 |                      0 |                   NULL
    2024-11 |                   0 |                   0 |          0 |                      0 |                   NULL
    2024-12 |                   0 |                   0 |          0 |                      0 |                   NULL
    2025-01 |                   0 |                   0 |          0 |                      0 |                   NULL
    2025-02 |                   0 |                   0 |          0 |                      0 |                   NULL
    2025-03 |                   0 |                   0 |          0 |                      0 |                   NULL
    2025-04 |                   0 |                   0 |          0 |                      0 |                   NULL
    2025-05 |                   0 |                   0 |          0 |                      0 |                   NULL
    2025-06 |                   0 |                   0 |          0 |                      0 |                   NULL
    2025-07 |                   0 |                   0 |          0 |                      0 |                   NULL
    2025-08 |                   0 |                   0 |          0 |                      0 |                   NULL
    2025-09 |                   0 |                   0 |          0 |                      0 |                   NULL
    2025-10 |                   0 |                   0 |          0 |                      0 |                   NULL
    2025-11 |                   0 |                   0 |          0 |                      0 |                   NULL
    2025-12 |                   0 |                   0 |          0 |                      0 |                   NULL
    (36 rows)
  6. What came of it

    The memo and the queries that followed; links found by title, so inferred

    Later queries that build on this report:

    It builds on:

  7. It becomes benchmark query q071

    Changed only where the benchmark needs the same result on every system

    • placeholderyear month
    • numericsdocs/NUMERICS.md: rounding removed; results keep full precision
    • determinismthe standard leaves the place of NULLs in a sort to the implementation: NULLS LAST ascending, NULLS FIRST descending, as in the reference results
    The benchmark text, against the submitted SQL
    WITH combined_log AS (  SELECT tenant_id, action, actor_user_id, entity_table, occurred_at  FROM audit_log  UNION ALL  SELECT tenant_id, action, actor_user_id, entity_table, occurred_at  FROM audit_log_archive),role_changes AS (  SELECT    occurred_at,    action,    actor_user_id  FROM combined_log  WHERE entity_table = 'user_roles'    AND action IN ('create', 'delete')    AND occurred_at >= DATE '2023-01-01'    AND occurred_at < DATE '2026-01-01'),months AS (  SELECT DISTINCT month_label, month_start  FROM calendar_days  WHERE month_start >= DATE '2023-01-01' AND month_start < DATE '2026-01-01'),monthly_changes AS (  SELECT    strftime(occurred_at, '%Y-%m') AS month_label,    {{year_month(occurred_at)}} AS month_label,    count(*) FILTER (WHERE action = 'create') AS role_grants_created,    count(*) FILTER (WHERE action = 'delete') AS role_grants_removed,    count(DISTINCT actor_user_id) AS distinct_actor_user_id,    count(*) AS total_changes,    count(*) FILTER (WHERE actor_user_id IS NULL) AS null_actor_changes  FROM role_changes  GROUP BY 1)SELECT  months.month_label AS month,  coalesce(monthly_changes.role_grants_created, 0) AS role_grants_created,  coalesce(monthly_changes.role_grants_removed, 0) AS role_grants_removed,  coalesce(monthly_changes.role_grants_created, 0) - coalesce(monthly_changes.role_grants_removed, 0) AS net_change,  coalesce(monthly_changes.distinct_actor_user_id, 0) AS distinct_actor_user_id,  CASE WHEN coalesce(monthly_changes.total_changes,0) = 0 THEN NULL       ELSE round(100.0 * monthly_changes.null_actor_changes / monthly_changes.total_changes, 1)       ELSE 100.0 * monthly_changes.null_actor_changes / monthly_changes.total_changes  END AS pct_actor_user_id_nullFROM monthsLEFT JOIN monthly_changes ON months.month_label = monthly_changes.month_labelORDER BY months.month_labelORDER BY months.month_label NULLS LAST
    WITH combined_log AS (
      SELECT tenant_id, action, actor_user_id, entity_table, occurred_at
      FROM audit_log
      UNION ALL
      SELECT tenant_id, action, actor_user_id, entity_table, occurred_at
      FROM audit_log_archive
    ),
    role_changes AS (
      SELECT
        occurred_at,
        action,
        actor_user_id
      FROM combined_log
      WHERE entity_table = 'user_roles'
        AND action IN ('create', 'delete')
        AND occurred_at >= DATE '2023-01-01'
        AND occurred_at < DATE '2026-01-01'
    ),
    months AS (
      SELECT DISTINCT month_label, month_start
      FROM calendar_days
      WHERE month_start >= DATE '2023-01-01' AND month_start < DATE '2026-01-01'
    ),
    monthly_changes AS (
      SELECT
        strftime(occurred_at, '%Y-%m') AS month_label,
        count(*) FILTER (WHERE action = 'create') AS role_grants_created,
        count(*) FILTER (WHERE action = 'delete') AS role_grants_removed,
        count(DISTINCT actor_user_id) AS distinct_actor_user_id,
        count(*) AS total_changes,
        count(*) FILTER (WHERE actor_user_id IS NULL) AS null_actor_changes
      FROM role_changes
      GROUP BY 1
    )
    SELECT
      months.month_label AS month,
      coalesce(monthly_changes.role_grants_created, 0) AS role_grants_created,
      coalesce(monthly_changes.role_grants_removed, 0) AS role_grants_removed,
      coalesce(monthly_changes.role_grants_created, 0) - coalesce(monthly_changes.role_grants_removed, 0) AS net_change,
      coalesce(monthly_changes.distinct_actor_user_id, 0) AS distinct_actor_user_id,
      CASE WHEN coalesce(monthly_changes.total_changes,0) = 0 THEN NULL
           ELSE 100.0 * monthly_changes.null_actor_changes / monthly_changes.total_changes
      END AS pct_actor_user_id_null
    FROM months
    LEFT JOIN monthly_changes ON months.month_label = monthly_changes.month_label
    ORDER BY months.month_label NULLS LAST
    1 ms10 ms100 msHyper5.3 msDuckDB6.1 msGizmoSQL10.7 msclickhouse_brew43.4 msFirebolt42.6 msDataFusion55.4 msClickHouse30.6 ms
  8. Everything, as recorded

    The agents' own records, as the role play wrote them; only the personal data of the person who ran it is redacted

    Grace's whole session, 9 turns
    • The request, as Grace received it
      Report request from Claire Dubois (analyst for the Head of Risk and Payments):
      
      Title: Role grant and revocation activity over time
      Question: Is staff access actively granted and revoked over time (evidence of ongoing access management), and can we attribute those changes to an identifiable actor?
      Grain: one row per calendar month (occurred_at), combining audit_log and audit_log_archive, filtered to entity_table = 'user_roles', from 2023-01 through 2025-12
      Columns:
      - month (YYYY-MM)
      - role_grants_created (count where action = 'create')
      - role_grants_removed (count where action = 'delete')
      - net_change (created minus removed)
      - distinct_actor_user_id (count of distinct non-null actor_user_id performing these changes in the month)
      - pct_actor_user_id_null (share of these change rows where actor_user_id is null)
      Details: Sources: audit_log UNION audit_log_archive (same shape; last night's report showed 2023 activity lives only in the archive table, so both must be combined to avoid a false gap), filtered to entity_table = 'user_roles'. This checks whether role access is a living, actively-managed process (grants and removals happening continuously, ideally net_change roughly stable / removals present) versus a static set that was configured once and never touched — and whether changes tie back to a real actor rather than an anonymous/system process. Order by month ascending.
      
      Check your query with the warehouse's dry_run tool, then hand it in with submit.
    • Grace calls dry_run
      WITH combined_log AS (
        SELECT tenant_id, action, actor_user_id, entity_table, occurred_at
        FROM audit_log
        UNION ALL
        SELECT tenant_id, action, actor_user_id, entity_table, occurred_at
        FROM audit_log_archive
      ),
      role_changes AS (
        SELECT
          occurred_at,
          action,
          actor_user_id
        FROM combined_log
        WHERE entity_table = 'user_roles'
          AND action IN ('create', 'delete')
          AND occurred_at >= DATE '2023-01-01'
          AND occurred_at < DATE '2026-01-01'
      ),
      months AS (
        SELECT DISTINCT month_label, month_start
        FROM calendar_days
        WHERE month_start >= DATE '2023-01-01' AND month_start < DATE '2026-01-01'
      ),
      monthly_changes AS (
        SELECT
          to_char(occurred_at, 'YYYY-MM') AS month_label,
          count(*) FILTER (WHERE action = 'create') AS role_grants_created,
          count(*) FILTER (WHERE action = 'delete') AS role_grants_removed,
          count(DISTINCT actor_user_id) AS distinct_actor_user_id,
          count(*) AS total_changes,
          count(*) FILTER (WHERE actor_user_id IS NULL) AS null_actor_changes
        FROM role_changes
        GROUP BY 1
      )
      SELECT
        months.month_label AS month,
        coalesce(monthly_changes.role_grants_created, 0) AS role_grants_created,
        coalesce(monthly_changes.role_grants_removed, 0) AS role_grants_removed,
        coalesce(monthly_changes.role_grants_created, 0) - coalesce(monthly_changes.role_grants_removed, 0) AS net_change,
        coalesce(monthly_changes.distinct_actor_user_id, 0) AS distinct_actor_user_id,
        CASE WHEN coalesce(monthly_changes.total_changes,0) = 0 THEN NULL
             ELSE round(100.0 * monthly_changes.null_actor_changes / monthly_changes.total_changes, 1)
        END AS pct_actor_user_id_null
      FROM months
      LEFT JOIN monthly_changes ON months.month_label = monthly_changes.month_label
      ORDER BY months.month_label
    • The warehouse answers
      STATUS: REJECTED
        - does not compile: Catalog Error: Scalar Function with name to_char does not exist!
    • Grace calls dry_run
      WITH combined_log AS (
        SELECT tenant_id, action, actor_user_id, entity_table, occurred_at
        FROM audit_log
        UNION ALL
        SELECT tenant_id, action, actor_user_id, entity_table, occurred_at
        FROM audit_log_archive
      ),
      role_changes AS (
        SELECT
          occurred_at,
          action,
          actor_user_id
        FROM combined_log
        WHERE entity_table = 'user_roles'
          AND action IN ('create', 'delete')
          AND occurred_at >= DATE '2023-01-01'
          AND occurred_at < DATE '2026-01-01'
      ),
      months AS (
        SELECT DISTINCT month_label, month_start
        FROM calendar_days
        WHERE month_start >= DATE '2023-01-01' AND month_start < DATE '2026-01-01'
      ),
      monthly_changes AS (
        SELECT
          strftime(occurred_at, '%Y-%m') AS month_label,
          count(*) FILTER (WHERE action = 'create') AS role_grants_created,
          count(*) FILTER (WHERE action = 'delete') AS role_grants_removed,
          count(DISTINCT actor_user_id) AS distinct_actor_user_id,
          count(*) AS total_changes,
          count(*) FILTER (WHERE actor_user_id IS NULL) AS null_actor_changes
        FROM role_changes
        GROUP BY 1
      )
      SELECT
        months.month_label AS month,
        coalesce(monthly_changes.role_grants_created, 0) AS role_grants_created,
        coalesce(monthly_changes.role_grants_removed, 0) AS role_grants_removed,
        coalesce(monthly_changes.role_grants_created, 0) - coalesce(monthly_changes.role_grants_removed, 0) AS net_change,
        coalesce(monthly_changes.distinct_actor_user_id, 0) AS distinct_actor_user_id,
        CASE WHEN coalesce(monthly_changes.total_changes,0) = 0 THEN NULL
             ELSE round(100.0 * monthly_changes.null_actor_changes / monthly_changes.total_changes, 1)
        END AS pct_actor_user_id_null
      FROM months
      LEFT JOIN monthly_changes ON months.month_label = monthly_changes.month_label
      ORDER BY months.month_label
    • The warehouse answers
      STATUS: COMPILES (dry run on the empty schema, no data read)
      COLUMNS: month, role_grants_created, role_grants_removed, net_change, distinct_actor_user_id, pct_actor_user_id_null
    • GraceQuery compiles with the expected columns. Submitting.
    • Grace calls submit
      WITH combined_log AS (
        SELECT tenant_id, action, actor_user_id, entity_table, occurred_at
        FROM audit_log
        UNION ALL
        SELECT tenant_id, action, actor_user_id, entity_table, occurred_at
        FROM audit_log_archive
      ),
      role_changes AS (
        SELECT
          occurred_at,
          action,
          actor_user_id
        FROM combined_log
        WHERE entity_table = 'user_roles'
          AND action IN ('create', 'delete')
          AND occurred_at >= DATE '2023-01-01'
          AND occurred_at < DATE '2026-01-01'
      ),
      months AS (
        SELECT DISTINCT month_label, month_start
        FROM calendar_days
        WHERE month_start >= DATE '2023-01-01' AND month_start < DATE '2026-01-01'
      ),
      monthly_changes AS (
        SELECT
          strftime(occurred_at, '%Y-%m') AS month_label,
          count(*) FILTER (WHERE action = 'create') AS role_grants_created,
          count(*) FILTER (WHERE action = 'delete') AS role_grants_removed,
          count(DISTINCT actor_user_id) AS distinct_actor_user_id,
          count(*) AS total_changes,
          count(*) FILTER (WHERE actor_user_id IS NULL) AS null_actor_changes
        FROM role_changes
        GROUP BY 1
      )
      SELECT
        months.month_label AS month,
        coalesce(monthly_changes.role_grants_created, 0) AS role_grants_created,
        coalesce(monthly_changes.role_grants_removed, 0) AS role_grants_removed,
        coalesce(monthly_changes.role_grants_created, 0) - coalesce(monthly_changes.role_grants_removed, 0) AS net_change,
        coalesce(monthly_changes.distinct_actor_user_id, 0) AS distinct_actor_user_id,
        CASE WHEN coalesce(monthly_changes.total_changes,0) = 0 THEN NULL
             ELSE round(100.0 * monthly_changes.null_actor_changes / monthly_changes.total_changes, 1)
        END AS pct_actor_user_id_null
      FROM months
      LEFT JOIN monthly_changes ON months.month_label = monthly_changes.month_label
      ORDER BY months.month_label
    • The warehouse answers
      Submitted to tonight's batch. Your work on this request is done.
    • GraceSubmitted. The report gives one row per month (2023-01 through 2025-12) with role grants created, removed, net change, distinct actors, and the null-actor share, using a calendar spine so months with no activity still show as zero rather than being silently dropped.