You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/08/23 14:54:07 UTC

(skywalking-horizon-ui) 01/11: refactor(audit): take token use out of the login audit, and give it a mechanism

This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch refactor/audit-drop-token-use
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 5dfc1e0c2dc5d9681ce6e17b38cd8517fa906a68
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Sun Aug 23 17:25:19 2026 +0800

    refactor(audit): take token use out of the login audit, and give it a mechanism
    
    Presenting an API or OAuth token on a request is not a login. Nobody signs
    in, so there is no moment to record and no principal who has just proved
    themselves — what a token did is a question about traffic, not about who got
    in. It had been recorded in `horizon_audit` anyway, as an hourly aggregate
    row sharing a table with sign-ins, and the two grains fought over one list:
    one row meant a person arriving, the next meant sixty seconds of a script.
    
    The login audit now records sign-ins and nothing else. Out with the token
    rows go `hour_bucket` and `count`, the partial unique index they existed for,
    the aggregate upsert, the in-memory bucket map with its eviction and
    `flushed` bookkeeping, the `login_token` statistics series, the `api-token`
    and `oauth-token` kinds, the "hourly total" row shape and the Uses column.
    The statistics chart is now five series of one kind, all comparable — where
    `login_token` counted requests and could dwarf every human sign-in in the
    hour beside it.
    
    Token usage returns as what it always was: a statistic, kept separately.
    
      - ONE ROW PER TOKEN PER HOUR, cluster-wide. An API token's id is stable for
        its life; an OAuth access token is named by its `jti`, minted per issuance
        rather than per request, so at the default 60-minute lifetime a
        well-behaved client also produces one row. Shortening
        `oauth.accessTokenMinutes` multiplies the rows for that hour, which is
        worth knowing before it surprises someone.
      - LOAD, APPEND, UPDATE, once per hour. A process reads the stored total
        before its first write of an hour, adds what it has seen, and writes the
        total back. Without the load a process that restarts mid-hour begins at
        zero and replaces a real total with a partial one — and a counter that
        has not read its hour refuses to write at all rather than guessing.
      - A per-hour credential cap, because a client minting a token per request
        is the one case the per-hour shape does not bound by itself. It degrades
        its own statistics rather than the table, and says how many it refused.
    
    The contract, the data structure and the cycle are deliberately
    backend-agnostic: `TokenUsageStore` and `TokenCounters` know about hours,
    credentials and counts, and nothing about upserts or SQL. Postgres is the
    first implementation, not the only one — a second backend inherits the
    correctness rather than re-deriving it, which matters because a store that
    forgot to load would silently overwrite and that is not a mistake worth
    letting two implementations make independently.
    
    Horizon carries no migration for the removed columns. The feature is
    unreleased, so there is no upgrade path to preserve.
    
    Verified against PostgreSQL 16: 400 uses flushed, the process restarted with
    its counters at zero, and the hour read back 412 rather than 12 — with one
    row in the table.
---
 apps/bff/src/store/audit/counters.test.ts          | 206 +--------------------
 apps/bff/src/store/audit/counters.ts               | 159 +---------------
 apps/bff/src/store/audit/postgres/live.test.ts     |  18 +-
 apps/bff/src/store/audit/postgres/rows.test.ts     |  18 +-
 apps/bff/src/store/audit/postgres/rows.ts          |   6 -
 apps/bff/src/store/audit/postgres/schema.ts        |  36 +++-
 apps/bff/src/store/audit/postgres/sql.test.ts      | 136 +++-----------
 apps/bff/src/store/audit/postgres/store.ts         |  79 ++------
 apps/bff/src/store/audit/postgres/token-store.ts   | 144 ++++++++++++++
 apps/bff/src/store/audit/service.test.ts           | 111 +----------
 apps/bff/src/store/audit/service.ts                |  43 +----
 apps/bff/src/store/audit/token-counters.test.ts    | 136 ++++++++++++++
 apps/bff/src/store/audit/token-counters.ts         | 182 ++++++++++++++++++
 apps/bff/src/store/audit/token-usage.ts            | 123 ++++++++++++
 apps/bff/src/store/audit/types.ts                  |  64 +------
 apps/bff/src/user/middleware.ts                    |   2 -
 apps/bff/src/user/oidc/route.test.ts               |   1 -
 apps/ui/src/api/scopes/admin-audit.ts              |  12 +-
 apps/ui/src/features/admin/audit/AuditList.vue     |  38 +---
 .../ui/src/features/admin/audit/AuditStatBlock.vue |   1 -
 apps/ui/src/features/admin/audit/AuditView.vue     |   2 -
 apps/ui/src/i18n/locales/de.json                   |  10 +-
 apps/ui/src/i18n/locales/en.json                   |  10 +-
 apps/ui/src/i18n/locales/es.json                   |  10 +-
 apps/ui/src/i18n/locales/fr.json                   |  10 +-
 apps/ui/src/i18n/locales/ja.json                   |  10 +-
 apps/ui/src/i18n/locales/ko.json                   |  10 +-
 apps/ui/src/i18n/locales/pt.json                   |  10 +-
 apps/ui/src/i18n/locales/zh-CN.json                |  10 +-
 docs/changelog/1.0.0.md                            |   2 +-
 docs/setup/login-audit.md                          |  16 +-
 test/e2e/playwright/specs/ui/audit.spec.ts         |  43 +----
 32 files changed, 720 insertions(+), 938 deletions(-)

diff --git a/apps/bff/src/store/audit/counters.test.ts b/apps/bff/src/store/audit/counters.test.ts
index 41164bf1..0b6d52b6 100644
--- a/apps/bff/src/store/audit/counters.test.ts
+++ b/apps/bff/src/store/audit/counters.test.ts
@@ -19,9 +19,6 @@ import { describe, it, expect } from 'vitest';
 import {
   AuditCounters,
   hourBucketOf,
-  hourBucketStart,
-  MAX_ENTRIES,
-  MAX_ENTRY_AGE_MS,
   MAX_HOUR_BUCKETS,
 } from './counters.js';
 
@@ -32,24 +29,6 @@ function counters(maxRowsPerHour = 1000): AuditCounters {
   return new AuditCounters({ maxRowsPerHour });
 }
 
-function use(username: string, at: number): { kind: 'api-token'; username: string; at: number } {
-  return { kind: 'api-token', username, at };
-}
-
-describe('hour buckets', () => {
-  it('is UTC yyyyMMddHH, and round-trips to the hour start', () => {
-    expect(hourBucketOf(T)).toBe(2026082214);
-    expect(hourBucketStart(2026082214)).toBe(Date.UTC(2026, 7, 22, 14));
-    expect(hourBucketOf(hourBucketStart(2026082214))).toBe(2026082214);
-  });
-
-  it('orders numerically the way it orders chronologically', () => {
-    expect(hourBucketOf(T)).toBeLessThan(hourBucketOf(T + HOUR));
-    // Across a midnight and a month end, where a naive format breaks.
-    expect(hourBucketOf(Date.UTC(2026, 7, 31, 23))).toBeLessThan(hourBucketOf(Date.UTC(2026, 8, 1, 0)));
-  });
-});
-
 describe('the write budget', () => {
   it('charges one row per sign-in and refuses past the cap', () => {
     const c = counters(3);
@@ -61,24 +40,6 @@ describe('the write budget', () => {
     expect(c.overBudgetAt(T)).toBe(1);
   });
 
-  /**
-   * The reason the budget counts ROWS and not writes. A cumulative bucket is
-   * re-upserted every flush, so charging per write would spend the budget on
-   * rows that already exist — at a 60s flush that is 60 writes per principal
-   * per hour, and ~17 principals would exhaust the default 1000 on a system
-   * doing nothing wrong.
-   */
-  it('charges a token key once, however many times it is used or flushed', () => {
-    const c = counters(1000);
-    for (let i = 0; i < 500; i += 1) c.countTokenUse(use('tok-a', T));
-    for (let i = 0; i < 60; i += 1) {
-      const pending = c.pendingAggregates();
-      c.markFlushed(pending);
-    }
-    expect(c.rowsAt(T)).toBe(1);
-    expect(c.overBudgetAt(T)).toBe(0);
-  });
-
   it('resets at the hour rollover', () => {
     const c = counters(1);
     expect(c.admitEvent('local', 1, T)).toBe(true);
@@ -86,92 +47,6 @@ describe('the write budget', () => {
     expect(c.admitEvent('local', 1, T + HOUR)).toBe(true);
     expect(c.overBudgetAt(T)).toBe(0);
   });
-
-  /** A key refused on budget must never reach the map, or the same event
-   *  would be counted by both `over_budget` and `token_lost`. */
-  it('counts a budget refusal as over-budget only, never as a lost token use', () => {
-    const c = counters(1);
-    c.countTokenUse(use('tok-a', T));
-    c.countTokenUse(use('tok-b', T));
-    expect(c.overBudgetAt(T)).toBe(1);
-    expect(c.unconfirmed).toBe(0);
-    const [stat] = c.takeStats('node-1');
-    expect(stat.overBudget).toBe(1);
-
-  });
-});
-
-describe('token buckets', () => {
-  it('accumulates cumulatively and never clears on flush, so a retry rewrites the same number', () => {
-    const c = counters();
-    c.countTokenUse(use('tok-a', T));
-    c.countTokenUse(use('tok-a', T));
-    const first = c.pendingAggregates();
-    expect(first[0].count).toBe(2);
-
-    // The flush "fails": nothing is marked. The same total is offered again.
-    expect(c.pendingAggregates()[0].count).toBe(2);
-
-    c.markFlushed(first);
-    expect(c.pendingAggregates()).toEqual([]);
-
-    c.countTokenUse(use('tok-a', T));
-    expect(c.pendingAggregates()[0].count).toBe(3);
-  });
-
-  it('stamps an aggregate at the hour start, not the last use', () => {
-    const c = counters();
-    c.countTokenUse(use('tok-a', T));
-    expect(c.pendingAggregates()[0].at).toBe(Date.UTC(2026, 7, 22, 14));
-  });
-
-  /** The principal is the credential that was presented. An API token is
-   *  named by its id, a Horizon OAuth token by the `sub` it was issued for —
-   *  never by the `jti`, which is minted per issuance and would make aggregate
-   *  rows grow with request volume. */
-  it('names an api-token by its id and an oauth-token by its principal', () => {
-    const c = counters();
-    c.countTokenUse({ kind: 'api-token', username: 'ab12cd', at: T });
-    c.countTokenUse({ kind: 'oauth-token', username: 'sre', at: T });
-    const byKind = Object.fromEntries(c.pendingAggregates().map((p) => [p.kind, p]));
-    expect(byKind['api-token'].username).toBe('ab12cd');
-    expect(byKind['oauth-token'].username).toBe('sre');
-  });
-});
-
-describe('eviction accounting', () => {
-  /** The distinction the whole `flushed` field exists for: routine rollover
-   *  of an up-to-date bucket must not look like data loss. */
-  it('loses nothing when an evicted bucket was fully flushed', () => {
-    const c = counters();
-    c.countTokenUse(use('tok-a', T));
-    c.markFlushed(c.pendingAggregates());
-    // Four distinct hours: the first is evicted.
-    for (let h = 1; h <= MAX_HOUR_BUCKETS; h += 1) c.countTokenUse(use('tok-a', T + h * HOUR));
-    expect(c.unconfirmed).toBe(0);
-  });
-
-  it('loses exactly the unflushed remainder when it was not', () => {
-    const c = counters();
-    c.countTokenUse(use('tok-a', T));
-    c.markFlushed(c.pendingAggregates());
-    for (let i = 0; i < 5; i += 1) c.countTokenUse(use('tok-a', T)); // 6 total, 1 flushed
-    for (let h = 1; h <= MAX_HOUR_BUCKETS; h += 1) c.countTokenUse(use('tok-a', T + h * HOUR));
-    expect(c.unconfirmed).toBe(5);
-  });
-
-  it('refuses new keys past the entry cap while existing ones keep counting', () => {
-    const c = counters(Number.MAX_SAFE_INTEGER);
-    for (let i = 0; i < MAX_ENTRIES; i += 1) c.countTokenUse(use(`tok-${i}`, T));
-    expect(c.bufferedEntries).toBe(MAX_ENTRIES);
-
-    c.countTokenUse(use('one-too-many', T));
-    expect(c.bufferedEntries).toBe(MAX_ENTRIES);
-    expect(c.unconfirmed).toBe(1);
-
-    c.countTokenUse(use('tok-0', T));
-    expect(c.pendingAggregates().find((p) => p.username === 'tok-0')?.count).toBe(2);
-  });
 });
 
 describe('statistics', () => {
@@ -190,7 +65,7 @@ describe('statistics', () => {
     // Break-glass counts as a password sign-in: it IS one, using the local
     // account a directory outage left as the only way in. The ROW still says
     // `break-glass`, which is where that distinction matters.
-    expect(s.login).toEqual({ local: 2, ldap: 1, oidc: 1, oauth: 1, token: 0 });
+    expect(s.login).toEqual({ local: 2, ldap: 1, oidc: 1, oauth: 1 });
     expect(s.rejected).toBe(1);
   });
 
@@ -231,85 +106,6 @@ describe('statistics', () => {
 });
 
 describe('regressions found by review', () => {
-  /**
-   * `markFlushed` re-reading the live count instead of the written one. The
-   * store round trip yields, `countTokenUse` keeps incrementing, and marking
-   * the CURRENT total flushed tells the bucket that uses it never sent are
-   * safely stored. At an hour boundary the key is never revisited, so the loss
-   * is permanent — and it recurs on the last flush of every hour.
-   */
-  it('marks only the count that was written, leaving later uses pending', () => {
-    const c = counters();
-    for (let i = 0; i < 5; i += 1) c.countTokenUse(use('tok-a', T));
-    const written = c.pendingAggregates();
-    expect(written[0].count).toBe(5);
-
-    // Three more land while the write is in flight.
-    for (let i = 0; i < 3; i += 1) c.countTokenUse(use('tok-a', T));
-    c.markFlushed(written);
-
-    expect(c.pendingAggregates()[0].count).toBe(8);
-  });
-
-  it('never moves flushed backwards when an out-of-order retry lands', () => {
-    const c = counters();
-    for (let i = 0; i < 5; i += 1) c.countTokenUse(use('tok-a', T));
-    const early = c.pendingAggregates();
-    for (let i = 0; i < 3; i += 1) c.countTokenUse(use('tok-a', T));
-    c.markFlushed(c.pendingAggregates()); // 8
-    c.markFlushed(early);                 // a stale 5 arriving late
-    expect(c.pendingAggregates()).toEqual([]);
-  });
-
-  /** Eviction only ran after a successful insert, so at the cap the insert
-   *  never happened and the eviction that would free space never ran — the
-   *  node stopped counting any new credential for the life of the process. */
-  it('recovers from a full map instead of wedging at the cap', () => {
-    const c = counters(Number.MAX_SAFE_INTEGER);
-    for (let i = 0; i < MAX_ENTRIES; i += 1) c.countTokenUse(use(`tok-${i}`, T));
-    expect(c.bufferedEntries).toBe(MAX_ENTRIES);
-
-    // Refusing new keys at the cap is the designed behaviour; being unable to
-    // ever reclaim is not. Eviction used to run only after a successful
-    // insert, so at the cap it could never run again and the node stopped
-    // counting new credentials for the life of the process.
-    const past = T + MAX_ENTRY_AGE_MS + HOUR;
-    c.countTokenUse(use('fresh', past));
-    expect(c.bufferedEntries).toBe(1);
-    expect(c.pendingAggregates().map((p) => p.username)).toEqual(['fresh']);
-  });
-
-  /**
-   * `countTokenUse` runs on EVERY authenticated request, so it must not scan
-   * the map. Reclaiming before the cap check (which is what stops the map
-   * wedging) made it O(map) — filling the cap became ~50M comparisons, and CI
-   * found it as a five-second timeout on a test that takes milliseconds here.
-   *
-   * The bound is deliberately loose: it is there to catch a return to
-   * quadratic, not to police milliseconds on a shared runner.
-   */
-  it('stays constant-time per use, including against a full map', () => {
-    const c = counters(Number.MAX_SAFE_INTEGER);
-    const started = Date.now();
-    for (let i = 0; i < MAX_ENTRIES; i += 1) c.countTokenUse(use(`tok-${i}`, T));
-    // Every one of these is refused, which is the path that rescanned.
-    for (let i = 0; i < 5_000; i += 1) c.countTokenUse(use(`overflow-${i}`, T));
-    expect(Date.now() - started).toBeLessThan(2_000);
-    expect(c.bufferedEntries).toBe(MAX_ENTRIES);
-  });
-
-  /** A key refused for MEMORY must not also spend a row of the budget: the
-   *  row it would have created will never exist, so charging for it lets
-   *  token churn starve real sign-ins. */
-  it('charges no budget for a key the entry cap refuses', () => {
-    const c = counters(MAX_ENTRIES + 100);
-    for (let i = 0; i < MAX_ENTRIES; i += 1) c.countTokenUse(use(`tok-${i}`, T));
-    const spent = c.rowsAt(T);
-    c.countTokenUse(use('refused', T));
-    expect(c.rowsAt(T)).toBe(spent);
-    expect(c.overBudgetAt(T)).toBe(0);
-  });
-
   /** A bare inequality re-armed the budget on ANY hour change, so one
    *  late-arriving row from a previous hour refunded the cap — alternate two
    *  hours and it never fills. */
diff --git a/apps/bff/src/store/audit/counters.ts b/apps/bff/src/store/audit/counters.ts
index 999d0f5d..66d14401 100644
--- a/apps/bff/src/store/audit/counters.ts
+++ b/apps/bff/src/store/audit/counters.ts
@@ -26,21 +26,15 @@
  */
 
 import type {
-  AuditAggregate,
   AuditKind,
   AuditLoginCounts,
   AuditStat,
-  TokenUse,
 } from './types.js';
 
 /** Hour buckets retained in memory. Eviction is oldest-first, so a sustained
  *  outage degrades to "recent hours are right, older ones were dropped"
  *  rather than to an OOM. */
 export const MAX_HOUR_BUCKETS = 3;
-/** Total token buckets. Past this, NEW keys are refused and existing ones keep
- *  counting — losing a new principal's first use beats losing everyone's. */
-export const MAX_ENTRIES = 10_000;
-export const MAX_ENTRY_AGE_MS = 6 * 60 * 60 * 1000;
 
 /**
  * `yyyyMMddHH` in **UTC** — SkyWalking's own time-bucket shape, so this table
@@ -70,21 +64,8 @@ export function hourBucketStart(bucket: number): number {
   return Date.UTC(year, month - 1, day, hour);
 }
 
-interface Bucket {
-  kind: 'api-token' | 'oauth-token';
-  username: string;
-  hourBucket: number;
-  /** Running total since this process started counting this key. NEVER reset
-   *  by a flush — that is what makes a retry idempotent. */
-  count: number;
-  /** `count` as of the last flush that committed. The gap between the two is
-   *  what an eviction actually loses. */
-  flushed: number;
-  lastAt: number;
-}
-
 function emptyLogin(): AuditLoginCounts {
-  return { local: 0, ldap: 0, oidc: 0, oauth: 0, token: 0 };
+  return { local: 0, ldap: 0, oidc: 0, oauth: 0 };
 }
 
 interface StatAccum {
@@ -101,7 +82,7 @@ function isEmpty(s: StatAccum): boolean {
   const l = s.login;
   return (
     l.local === 0 && l.ldap === 0 && l.oidc === 0 && l.oauth === 0 &&
-    l.token === 0 && s.rejected === 0 && s.overBudget === 0
+    s.rejected === 0 && s.overBudget === 0
   );
 }
 
@@ -110,13 +91,6 @@ export interface CountersOptions {
 }
 
 export class AuditCounters {
-  private readonly buckets = new Map<string, Bucket>();
-  /** Hours present in `buckets`, maintained incrementally so the hot path can
-   *  ask "is this hour new?" without scanning. */
-  private readonly hours = new Set<number>();
-  /** The hour a cap-triggered eviction was last attempted in, so a
-   *  permanently full map does not rescan on every refused key. */
-  private capEvictHour = 0;
   private readonly stats = new Map<number, StatAccum>();
 
   /** The hour the budget below applies to. */
@@ -135,10 +109,6 @@ export class AuditCounters {
 
   constructor(private readonly opts: CountersOptions) {}
 
-  get bufferedEntries(): number {
-    return this.buckets.size;
-  }
-
   get unconfirmed(): number {
     return this.unconfirmedSinceStart;
   }
@@ -274,7 +244,6 @@ export class AuditCounters {
       // directory outage left as the only way in. The row records
       // `break-glass`; the chart counts it where it belongs.
       case 'break-glass': s.login.local += 1; break;
-      case 'api-token': case 'oauth-token': s.login.token += 1; break;
       // One `kind`, two series: verifying a signed ID token and reading an
       // address from a userinfo call are different assurances, so the caller
       // passes the protocol rather than leaving `sso` uncounted.
@@ -283,95 +252,6 @@ export class AuditCounters {
     return true;
   }
 
-  /** Count one accepted token use. Refused tokens never reach here. */
-  countTokenUse(use: TokenUse): void {
-    const hourBucket = hourBucketOf(use.at);
-    const key = `${hourBucket}|${use.kind}|${use.username}`;
-    const existing = this.buckets.get(key);
-    if (existing) {
-      existing.count += 1;
-      existing.lastAt = use.at;
-      this.stat(hourBucket).login.token += 1;
-      return;
-    }
-    // Reclaim BEFORE testing the cap — evicting only after a successful
-    // insert wedges the map permanently, because at the cap the insert never
-    // happens and the eviction that would free space never runs.
-    //
-    // But NOT on every use. `countTokenUse` runs on every authenticated
-    // request, and a scan of the whole map here makes that O(map): filling
-    // 10 000 keys becomes 50M comparisons, which is a real cost on a busy
-    // node and not merely a slow test. Eviction can only ever free something
-    // when a new HOUR appears or when the map is actually full, so those are
-    // the only two triggers — and the second is attempted once per hour, or a
-    // permanently full map would scan on every refused key.
-    const newHour = !this.hours.has(hourBucket);
-    const atCap = this.buckets.size >= MAX_ENTRIES;
-    if (newHour || (atCap && this.capEvictHour !== hourBucket)) {
-      if (atCap) this.capEvictHour = hourBucket;
-      this.evictBuckets(use.at, hourBucket);
-    }
-    if (this.buckets.size >= MAX_ENTRIES) {
-      // Refused for MEMORY, so it costs no budget — the row it would have
-      // created will never exist. Reserving first would let token churn eat
-      // the sign-in budget while producing nothing.
-      this.unconfirmedSinceStart += 1;
-      return;
-    }
-    // A new key is a new row, so it spends one. A key rejected on budget never
-    // reaches the map, so `over_budget` and `token_lost` cannot both count it.
-    if (!this.reserveRow(use.at, use.username)) return;
-    this.buckets.set(key, {
-      kind: use.kind,
-      username: use.username,
-      hourBucket,
-      count: 1,
-      flushed: 0,
-      lastAt: use.at,
-    });
-    this.hours.add(hourBucket);
-    this.stat(hourBucket).login.token += 1;
-  }
-
-  /** Buckets with uses not yet written. A bucket whose `count` equals its
-   *  `flushed` has nothing to say and is skipped rather than re-upserted. */
-  pendingAggregates(): Array<Omit<AuditAggregate, 'shape'> & { key: string }> {
-    const out: Array<Omit<AuditAggregate, 'shape'> & { key: string }> = [];
-    for (const [key, b] of this.buckets) {
-      if (b.count === b.flushed) continue;
-      out.push({
-        key,
-        kind: b.kind,
-        username: b.username,
-        hourBucket: b.hourBucket,
-        // The bucket start, not the last use: an aggregate names an hour.
-        at: hourBucketStart(b.hourBucket),
-        count: b.count,
-      });
-    }
-    return out;
-  }
-
-  /**
-   * Record that a flush committed, at the counts that were ACTUALLY WRITTEN.
-   *
-   * Re-reading `b.count` here instead would silently lose every use that
-   * arrived during the write: `pendingAggregates` freezes the count by value,
-   * the store round trip yields the event loop, and `countTokenUse` keeps
-   * incrementing. Marking the live total flushed would tell the bucket that
-   * uses it never sent are safely stored — and at an hour boundary the key is
-   * never revisited, so the loss is permanent and recurs on the last flush of
-   * every hour.
-   *
-   * `Math.max` because an out-of-order retry must never move `flushed` back.
-   */
-  markFlushed(written: ReadonlyArray<{ key: string; count: number }>): void {
-    for (const { key, count } of written) {
-      const b = this.buckets.get(key);
-      if (b) b.flushed = Math.max(b.flushed, count);
-    }
-  }
-
   /** Statistics deltas ready to append, and the accumulators are reset — stat
    *  rows are per-interval deltas, not running totals (they are summed on
    *  read, and a node's identity is not part of their key). */
@@ -400,7 +280,6 @@ export class AuditCounters {
       s.login.ldap += st.login.ldap;
       s.login.oidc += st.login.oidc;
       s.login.oauth += st.login.oauth;
-      s.login.token += st.login.token;
       s.rejected += st.rejected;
       s.overBudget += st.overBudget;
     }
@@ -413,40 +292,6 @@ export class AuditCounters {
     this.unconfirmedSinceStart += rows;
   }
 
-  /**
-   * Age and count bounds.
-   *
-   * EVICTION IS NOT LOSS. Counts are cumulative and a flush does not clear
-   * them, so a bucket whose running total was last written at 412 and is
-   * evicted at 412 lost nothing. Reporting every routine hour-rollover as a
-   * drop would put a permanently climbing number in front of an operator
-   * whose system is working perfectly. Only `count - flushed` is at risk, and
-   * even that is UNCONFIRMED rather than lost — see `unconfirmed`.
-   */
-  private evictBuckets(now: number, incoming: number): void {
-    // `incoming` is about to be inserted, so it counts toward the retained
-    // hours and is never itself a candidate — eviction runs BEFORE the insert
-    // (so a full map can recover), which would otherwise let the map hold one
-    // hour more than the bound.
-    const hours = [...new Set([...this.buckets.values()].map((b) => b.hourBucket).concat(incoming))]
-      .sort((a, b) => a - b);
-    const doomed = new Set(hours.slice(0, Math.max(0, hours.length - MAX_HOUR_BUCKETS)));
-    doomed.delete(incoming);
-    for (const [key, b] of this.buckets) {
-      const tooOld = now - b.lastAt > MAX_ENTRY_AGE_MS;
-      if (!doomed.has(b.hourBucket) && !tooOld) continue;
-      // `flushed` only advances on a CONFIRMED write, so this gap includes
-      // counts a commit-then-timeout actually persisted. Unconfirmed, not lost.
-      const unconfirmed = b.count - b.flushed;
-      if (unconfirmed > 0) {
-        this.unconfirmedSinceStart += unconfirmed;
-      }
-      this.buckets.delete(key);
-    }
-    this.hours.clear();
-    for (const b of this.buckets.values()) this.hours.add(b.hourBucket);
-  }
-
   /** `incoming` is about to be added and is never a candidate. */
   private evictStats(incoming: number): void {
     const hours = [...this.stats.keys(), incoming].sort((a, b) => a - b);
diff --git a/apps/bff/src/store/audit/postgres/live.test.ts b/apps/bff/src/store/audit/postgres/live.test.ts
index 8b90a7a5..d74b094f 100644
--- a/apps/bff/src/store/audit/postgres/live.test.ts
+++ b/apps/bff/src/store/audit/postgres/live.test.ts
@@ -95,22 +95,6 @@ describe.skipIf(!URL)('the audit log against a real PostgreSQL', () => {
     expect(typeof page.rows[0].id).toBe('string');
   });
 
-  /** The statement that was invalid SQL for an entire phase: written as
-   *  `INSERT ... SELECT ... FROM (VALUES ...)` it fails 42804 every time. */
-  it('upserts a token aggregate cumulatively, leaving exactly one row', async () => {
-    for (let i = 0; i < 5; i += 1) svc.countTokenUse({ kind: 'api-token', username: 'ab12cd', at: AT });
-    await svc.tick();
-    let agg = (await svc.query({ pageNum: 1, pageSize: 50, kind: ['api-token'] })).rows;
-    expect(agg).toHaveLength(1);
-    expect(agg[0].count).toBe(5);
-
-    for (let i = 0; i < 4; i += 1) svc.countTokenUse({ kind: 'api-token', username: 'ab12cd', at: AT });
-    await svc.tick();
-    agg = (await svc.query({ pageNum: 1, pageSize: 50, kind: ['api-token'] })).rows;
-    expect(agg, 'a second flush must overwrite, not append').toHaveLength(1);
-    expect(agg[0].count).toBe(9);
-  });
-
   /** The filter names ONE principal: a fragment finds nothing, and a wildcard
    *  is just a name nobody has rather than a full table read. */
   it('matches one principal exactly', async () => {
@@ -158,7 +142,7 @@ describe.skipIf(!URL)('the audit log against a real PostgreSQL', () => {
     // chart non-uniform in time.
     expect(stat.columns).toHaveLength(2);
     const counted = stat.columns.reduce(
-      (n, c) => n + c.login.local + c.login.ldap + c.login.token + c.rejected, 0,
+      (n, c) => n + c.login.local + c.login.ldap + c.rejected, 0,
     );
     expect(counted).toBeGreaterThan(0);
     expect(stat.horizonNodes).toBe(1);
diff --git a/apps/bff/src/store/audit/postgres/rows.test.ts b/apps/bff/src/store/audit/postgres/rows.test.ts
index b5a6c841..3dea558a 100644
--- a/apps/bff/src/store/audit/postgres/rows.test.ts
+++ b/apps/bff/src/store/audit/postgres/rows.test.ts
@@ -17,7 +17,7 @@
 
 import { describe, it, expect } from 'vitest';
 import { toEntry, toId, toInet, toNumber, valuesClause, type RawAuditRow } from './rows.js';
-import { CHUNK_ROWS, EVENT_COLUMNS, AGGREGATE_COLUMNS } from './store.js';
+import { CHUNK_ROWS, EVENT_COLUMNS } from './store.js';
 
 describe('bigint handling', () => {
   /**
@@ -30,11 +30,6 @@ describe('bigint handling', () => {
     expect(toNumber('412', 'count')).toBe(412);
   });
 
-  it('treats a NULL aggregate as zero rather than NaN', () => {
-    expect(toNumber(null, 'sum')).toBe(0);
-    expect(toNumber(undefined, 'sum')).toBe(0);
-  });
-
   it('throws rather than yielding NaN, so a bad value cannot be silently rendered', () => {
     expect(() => toNumber('not-a-number', 'count')).toThrow(/count/);
   });
@@ -74,7 +69,7 @@ describe('multi-row VALUES', () => {
    *  cannot notice a column being added, which is the only way this budget
    *  ever moves. */
   it('stays inside the bind-parameter cap at the chunk size the store uses', () => {
-    const widest = Math.max(EVENT_COLUMNS.length, AGGREGATE_COLUMNS.length);
+    const widest = EVENT_COLUMNS.length;
     expect(CHUNK_ROWS * widest).toBeLessThan(65535);
   });
 });
@@ -85,24 +80,17 @@ describe('row mapping', () => {
     provider: null, protocol: null, roles: null,
     outcome: 1, reason: null, username: 'alice', mail: null,
     client_ip: null, horizon_ip: null, horizon_node: 'pod-1:abc',
-    hour_bucket: null, count: '1',
   };
 
   it('maps an event row without inventing optional fields', () => {
     const e = toEntry(base);
     expect(e).toEqual({
       id: '42', at: Date.UTC(2026, 7, 22, 14, 30), kind: 'local', outcome: 1,
-      username: 'alice', count: 1, horizonNode: 'pod-1:abc',
+      username: 'alice', horizonNode: 'pod-1:abc',
     });
-    expect('hourBucket' in e).toBe(false);
     expect('reason' in e).toBe(false);
   });
 
-  it('marks an aggregate row by its hour bucket and carries the count', () => {
-    const e = toEntry({ ...base, hour_bucket: '2026082214', count: '412', kind: 'api-token' });
-    expect(e.hourBucket).toBe(2026082214);
-    expect(e.count).toBe(412);
-  });
 
   it('carries a refusal with its reason', () => {
     const e = toEntry({ ...base, outcome: 0, reason: 'no_roles' });
diff --git a/apps/bff/src/store/audit/postgres/rows.ts b/apps/bff/src/store/audit/postgres/rows.ts
index 97e7c09d..5130eb1d 100644
--- a/apps/bff/src/store/audit/postgres/rows.ts
+++ b/apps/bff/src/store/audit/postgres/rows.ts
@@ -73,8 +73,6 @@ export interface RawAuditRow {
   client_ip: string | null;
   horizon_ip: string | null;
   horizon_node: string;
-  hour_bucket: unknown;
-  count: unknown;
 }
 
 export function toEntry(row: RawAuditRow): AuditEntry {
@@ -84,7 +82,6 @@ export function toEntry(row: RawAuditRow): AuditEntry {
     kind: row.kind as AuditKind,
     outcome: (row.outcome === 1 ? 1 : 0) as 0 | 1,
     username: row.username,
-    count: toNumber(row.count, 'count'),
     horizonNode: row.horizon_node,
     ...(row.reason ? { reason: row.reason as AuditReason } : {}),
     ...(row.mail ? { mail: row.mail } : {}),
@@ -93,9 +90,6 @@ export function toEntry(row: RawAuditRow): AuditEntry {
     ...(row.roles ? { roles: row.roles } : {}),
     ...(row.client_ip ? { clientIp: row.client_ip } : {}),
     ...(row.horizon_ip ? { horizonIp: row.horizon_ip } : {}),
-    ...(row.hour_bucket === null || row.hour_bucket === undefined
-      ? {}
-      : { hourBucket: toNumber(row.hour_bucket, 'hour_bucket') }),
   };
 }
 
diff --git a/apps/bff/src/store/audit/postgres/schema.ts b/apps/bff/src/store/audit/postgres/schema.ts
index 93be0baf..408951b7 100644
--- a/apps/bff/src/store/audit/postgres/schema.ts
+++ b/apps/bff/src/store/audit/postgres/schema.ts
@@ -55,9 +55,7 @@ export const SCHEMA_STATEMENTS: readonly string[] = [
      roles              text,
      client_ip          inet,
      horizon_ip         inet,
-     horizon_node       text        NOT NULL,
-     hour_bucket        bigint,
-     count              int         NOT NULL DEFAULT 1
+     horizon_node       text        NOT NULL
    )`,
 
   // `CREATE TABLE IF NOT EXISTS` is a no-op once the table exists, so it can
@@ -87,10 +85,6 @@ export const SCHEMA_STATEMENTS: readonly string[] = [
   // and username only, and an index nothing queries is write cost for
   // nothing. Those columns are still recorded and still shown on a row.
 
-  // One row per credential-hour per writing process.
-  `CREATE UNIQUE INDEX IF NOT EXISTS horizon_audit_bucket_idx
-     ON horizon_audit (hour_bucket, kind, username, horizon_node)
-     WHERE hour_bucket IS NOT NULL`,
 
   // Statistics. A SURROGATE key, and rows that are per-interval deltas:
   // `horizon_node` is best-effort attribution, so keying on it would make two
@@ -103,11 +97,37 @@ export const SCHEMA_STATEMENTS: readonly string[] = [
      login_ldap        int NOT NULL DEFAULT 0,
      login_oidc        int NOT NULL DEFAULT 0,
      login_oauth       int NOT NULL DEFAULT 0,
-     login_token       int NOT NULL DEFAULT 0,
      rejected          int NOT NULL DEFAULT 0,
      over_budget       int NOT NULL DEFAULT 0
    )`,
 
+  // Token usage. A STATISTIC, not an audit record: presenting a token is not
+  // a sign-in, so it has no place in `horizon_audit` and no row per request.
+  //
+  // One row per credential per hour, CLUSTER-WIDE — `horizon_node` is
+  // attribution, deliberately NOT part of the key, because the count is the
+  // deployment's, not one process's. A writer loads the stored total, adds
+  // what it has seen, and writes the total back, so a restarted process
+  // resumes rather than replacing a real total with a partial one.
+  `CREATE TABLE IF NOT EXISTS horizon_token_usage (
+     hour_bucket   bigint NOT NULL,
+     kind          text   NOT NULL,
+     token_id      text   NOT NULL,
+     username      text   NOT NULL,
+     count         bigint NOT NULL,
+     horizon_node  text   NOT NULL,
+     PRIMARY KEY (hour_bucket, token_id)
+   )`,
+
+  // The list reads a time range newest-first; the hour bucket IS the time.
+  `CREATE INDEX IF NOT EXISTS horizon_token_usage_hour_idx
+     ON horizon_token_usage (hour_bucket DESC)`,
+
+  // "which hours did this credential appear in" — the question the page asks
+  // when someone types a token id.
+  `CREATE INDEX IF NOT EXISTS horizon_token_usage_token_idx
+     ON horizon_token_usage (token_id, hour_bucket DESC)`,
+
   `CREATE INDEX IF NOT EXISTS horizon_audit_stat_hour_idx
      ON horizon_audit_stat (hour_bucket)`,
 ];
diff --git a/apps/bff/src/store/audit/postgres/sql.test.ts b/apps/bff/src/store/audit/postgres/sql.test.ts
index 64e96efc..1b0c0393 100644
--- a/apps/bff/src/store/audit/postgres/sql.test.ts
+++ b/apps/bff/src/store/audit/postgres/sql.test.ts
@@ -34,7 +34,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
 import { PGlite } from '@electric-sql/pglite';
 import { SCHEMA_STATEMENTS } from './schema.js';
 import { toNumber, valuesClause } from './rows.js';
-import { EVENT_COLUMNS as EVENT_COLS, AGGREGATE_COLUMNS as AGG_COLS } from './store.js';
+import { EVENT_COLUMNS as EVENT_COLS } from './store.js';
 
 let db: PGlite;
 
@@ -42,7 +42,6 @@ let db: PGlite;
 // is how the suite came to exercise a different statement than the store
 // sends: a column added to the store left this copy behind, silently.
 const EVENT_COLUMNS = EVENT_COLS.join(',');
-const AGGREGATE_COLUMNS = AGG_COLS.join(',');
 
 beforeAll(async () => {
   db = new PGlite();
@@ -68,12 +67,13 @@ describe('the schema', () => {
     for (const s of SCHEMA_STATEMENTS) await expect(db.query(s)).resolves.toBeDefined();
   });
 
-  it('creates the indexes the list and the aggregate upsert depend on', async () => {
+  it('creates the indexes the list depends on', async () => {
     const r = await db.query<{ indexname: string }>(
       `SELECT indexname FROM pg_indexes WHERE tablename = 'horizon_audit'`,
     );
     const names = r.rows.map((x) => x.indexname);
-    expect(names).toContain('horizon_audit_bucket_idx');
+    expect(names).toContain('horizon_audit_at_idx');
+    expect(names).toContain('horizon_audit_username_idx');
   });
 });
 
@@ -97,61 +97,11 @@ describe('writing sign-in rows', () => {
   });
 });
 
-describe('writing token aggregates', () => {
-  /**
-   * The statement this whole file exists for. Written as
-   * `INSERT ... SELECT ... FROM (VALUES ...)` it fails with 42804 on every
-   * call, because untyped parameters in a VALUES used as a FROM item resolve
-   * to `text` and the target column types are not visible through the
-   * sub-SELECT. A plain multi-row VALUES infers them from the table.
-   */
-  const upsert =
-    `INSERT INTO horizon_audit (${AGGREGATE_COLUMNS}) VALUES ${valuesClause(1, AGG_COLS.length)}` +
-    ` ON CONFLICT (hour_bucket, kind, username, horizon_node)` +
-    ` WHERE hour_bucket IS NOT NULL` +
-    ` DO UPDATE SET count = EXCLUDED.count, at = EXCLUDED.at`;
-
-  it('inserts an aggregate row', async () => {
-    await db.query(upsert, [
-      new Date('2026-08-22T14:00:00Z'), 'api-token', 'ab12cd', null, 'pod-1:aaa', 2026082214, 5, 1,
-    ]);
-    const r = await db.query<{ count: string }>(
-      `SELECT count FROM horizon_audit WHERE username = 'ab12cd'`,
-    );
-    expect(Number(r.rows[0].count)).toBe(5);
-  });
-
-  /** Cumulative counts overwrite, so a retry after a commit-then-timeout
-   *  writes the same number rather than doubling it. */
-  it('overwrites the same credential-hour rather than adding a row', async () => {
-    await db.query(upsert, [
-      new Date('2026-08-22T14:00:00Z'), 'api-token', 'ab12cd', null, 'pod-1:aaa', 2026082214, 9, 1,
-    ]);
-    const r = await db.query<{ n: number; count: string }>(
-      `SELECT count(*)::int AS n, max(count) AS count FROM horizon_audit WHERE username = 'ab12cd'`,
-    );
-    expect(r.rows[0].n).toBe(1);
-    expect(Number(r.rows[0].count)).toBe(9);
-  });
-
-  /** A different process is a different row: replicas each hold their own
-   *  cumulative count and must never collide on one. */
-  it('keeps a second node separate', async () => {
-    await db.query(upsert, [
-      new Date('2026-08-22T14:00:00Z'), 'api-token', 'ab12cd', null, 'pod-2:bbb', 2026082214, 3, 1,
-    ]);
-    const r = await db.query<{ n: number }>(
-      `SELECT count(*)::int AS n FROM horizon_audit WHERE username = 'ab12cd'`,
-    );
-    expect(r.rows[0].n).toBe(2);
-  });
-});
-
 describe('reading', () => {
   it('runs the list query with every filter at once', async () => {
     const sql =
       `SELECT id, at, kind, provider, outcome, reason, username, mail, roles, host(client_ip) AS client_ip, host(horizon_ip) AS horizon_ip,
-              horizon_node, hour_bucket, count
+              horizon_node
          FROM horizon_audit
         WHERE at >= $1 AND at < $2 AND kind = ANY($3) AND username = $4
         ORDER BY at DESC, id DESC LIMIT $5`;
@@ -179,24 +129,15 @@ describe('reading', () => {
   });
 
   it('runs the statistics aggregation', async () => {
-    await db.query(
-      `INSERT INTO horizon_audit_stat
-         (hour_bucket, horizon_node, login_local, login_ldap,
-          login_oidc, login_oauth, login_token, rejected, over_budget)
-       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
-      [2026082214, 'pod-1:aaa', 3, 0, 1, 0, 12, 1, 0],
-    );
-    await db.query(
-      `INSERT INTO horizon_audit_stat
+    const insert = `INSERT INTO horizon_audit_stat
          (hour_bucket, horizon_node, login_local, login_ldap,
-          login_oidc, login_oauth, login_token, rejected, over_budget)
-       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
-      [2026082214, 'pod-2:bbb', 2, 0, 0, 0, 0, 0, 0],
-    );
+          login_oidc, login_oauth, rejected, over_budget)
+       VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`;
+    await db.query(insert, [2026082214, 'pod-1:aaa', 3, 0, 1, 0, 1, 0]);
+    await db.query(insert, [2026082214, 'pod-2:bbb', 2, 0, 0, 0, 0, 0]);
     const r = await db.query<Record<string, unknown>>(
-      `SELECT hour_bucket,
-              SUM(login_local) AS login_local,
-              SUM(login_token) AS login_token,
+      `SELECT SUM(login_local) AS login_local,
+              SUM(login_oidc)  AS login_oidc,
               SUM(rejected)    AS rejected,
               string_agg(DISTINCT split_part(horizon_node, ':', 1), ',') AS nodes
          FROM horizon_audit_stat
@@ -208,7 +149,7 @@ describe('reading', () => {
     // Delta rows SUM rather than overwrite, and two pods that share a host
     // name would still each contribute a row.
     expect(Number(r.rows[0].login_local)).toBe(5);
-    expect(Number(r.rows[0].login_token)).toBe(12);
+    expect(Number(r.rows[0].login_oidc)).toBe(1);
     expect(String(r.rows[0].nodes).split(',').sort()).toEqual(['pod-1', 'pod-2']);
   });
 
@@ -224,13 +165,17 @@ describe('reading', () => {
    * on anything else.
    */
   it('hands back bigint columns in a form the row mapper accepts either way', async () => {
-    const r = await db.query<{ hour_bucket: unknown; id: unknown }>(
-      `SELECT id, hour_bucket FROM horizon_audit WHERE hour_bucket IS NOT NULL LIMIT 1`,
+    // `horizon_audit.id` and `horizon_audit_stat.hour_bucket` are the two
+    // bigints the mapper reads back.
+    const r = await db.query<{ id: unknown }>('SELECT id FROM horizon_audit LIMIT 1');
+    expect(['string', 'number']).toContain(typeof r.rows[0].id);
+    const h = await db.query<{ hour_bucket: unknown }>(
+      'SELECT hour_bucket FROM horizon_audit_stat LIMIT 1',
     );
-    for (const v of [r.rows[0].hour_bucket, r.rows[0].id]) {
-      expect(['string', 'number']).toContain(typeof v);
+    if (h.rows.length > 0) {
+      expect(['string', 'number']).toContain(typeof h.rows[0].hour_bucket);
+      expect(toNumber(h.rows[0].hour_bucket, 'hour_bucket')).toBeGreaterThan(0);
     }
-    expect(toNumber(r.rows[0].hour_bucket, 'hour_bucket')).toBe(2026082214);
   });
 });
 
@@ -248,40 +193,3 @@ describe('retention', () => {
     expect(left.rows[0].n).toBe(0);
   });
 });
-
-/**
- * The probe used to match on an index NAME alone. `ON CONFLICT` infers its
- * target from the index's properties, so a plain or full index carrying the
- * right name passes a name check and then fails every upsert with 42P10 —
- * and `CREATE INDEX IF NOT EXISTS` will not replace it.
- */
-describe('the aggregate conflict index', () => {
-  const probeSql = `SELECT count(*)::int AS n
-       FROM pg_index i
-       JOIN pg_class c ON c.oid = i.indexrelid
-      WHERE i.indrelid = to_regclass($1)
-        AND c.relname = $2
-        AND i.indisunique
-        AND i.indpred IS NOT NULL`;
-
-  it('recognises the shipped index', async () => {
-    const r = await db.query<{ n: number }>(probeSql, ['horizon_audit', 'horizon_audit_bucket_idx']);
-    expect(r.rows[0].n).toBe(1);
-  });
-
-  it('rejects a same-named index that is not unique and not partial', async () => {
-    await db.exec(`
-      CREATE TABLE decoy (hour_bucket bigint, kind text, username text, horizon_node text);
-      CREATE INDEX horizon_audit_bucket_idx_decoy ON decoy (hour_bucket);
-    `);
-    const r = await db.query<{ n: number }>(probeSql, ['decoy', 'horizon_audit_bucket_idx_decoy']);
-    // Right name, wrong properties — the upsert would fail 42P10.
-    expect(r.rows[0].n).toBe(0);
-  });
-
-  it('rejects a unique index that is not partial', async () => {
-    await db.exec(`CREATE UNIQUE INDEX decoy_full_idx ON decoy (hour_bucket, kind, username, horizon_node)`);
-    const r = await db.query<{ n: number }>(probeSql, ['decoy', 'decoy_full_idx']);
-    expect(r.rows[0].n).toBe(0);
-  });
-});
diff --git a/apps/bff/src/store/audit/postgres/store.ts b/apps/bff/src/store/audit/postgres/store.ts
index 1e6cb77e..33d7ccc0 100644
--- a/apps/bff/src/store/audit/postgres/store.ts
+++ b/apps/bff/src/store/audit/postgres/store.ts
@@ -32,7 +32,6 @@ import { overFetchSize, takeOverFetched } from '../../../logic/paging/read-page.
 import { hourBucketOf } from '../counters.js';
 import {
   AuditStoreError,
-  type AuditAggregate,
   type AuditPageResult,
   type AuditEvent,
   AUDIT_STAT_WINDOWS,
@@ -72,14 +71,10 @@ export const EVENT_COLUMNS = [
   'client_ip', 'horizon_ip', 'horizon_node',
 ] as const;
 
-export const AGGREGATE_COLUMNS = [
-  'at', 'kind', 'username', 'horizon_ip', 'horizon_node', 'hour_bucket', 'count', 'outcome',
-] as const;
-
 const SELECT_COLUMNS =
   'id, at, kind, provider, protocol, outcome, reason, username, mail, roles, ' +
   'host(client_ip) AS client_ip, host(horizon_ip) AS horizon_ip, ' +
-  'horizon_node, hour_bucket, count';
+  'horizon_node';
 
 /**
  * Map a driver failure to the fixed vocabulary.
@@ -303,30 +298,21 @@ export class PostgresAuditStore implements AuditStore {
       await this.db.query(`SELECT ${SELECT_COLUMNS} FROM horizon_audit LIMIT 0`);
       await this.db.query(
         `SELECT hour_bucket, horizon_node, login_local, login_ldap,
-                login_oidc, login_oauth, login_token, rejected, over_budget
+                login_oidc, login_oauth, rejected, over_budget
            FROM horizon_audit_stat LIMIT 0`,
       );
-      // The aggregate upsert infers its conflict target from a PARTIAL UNIQUE
-      // index, and no amount of introspection proves inference will succeed:
-      // the name can match, the index can be unique and partial, and the KEY
-      // COLUMNS or the predicate can still differ — whereupon every flush
-      // fails with 42P10 and `CREATE INDEX IF NOT EXISTS` never repairs it,
-      // because the name is taken.
-      //
-      // So the probe runs the STATEMENT instead of describing the index. This
-      // cannot drift from what the writer sends, because it is what the writer
-      // sends; a rollback leaves nothing behind. Checking properties one by
-      // one was the previous attempt and it kept being incomplete.
+      // The probe runs the STATEMENT the writer sends, rolled back, rather
+      // than describing the table. Introspection kept being incomplete: a
+      // column can be present and the wrong type, and no amount of comparing
+      // catalogue rows proves an INSERT will bind. This cannot drift, because
+      // it is what the writer sends.
       const client = await this.db.connect();
       try {
         await client.query('BEGIN');
         await client.query(
-          `INSERT INTO horizon_audit (${AGGREGATE_COLUMNS.join(',')}) VALUES ` +
-            valuesClause(1, AGGREGATE_COLUMNS.length) +
-            ` ON CONFLICT (hour_bucket, kind, username, horizon_node) ` +
-            `WHERE hour_bucket IS NOT NULL ` +
-            `DO UPDATE SET count = EXCLUDED.count, at = EXCLUDED.at`,
-          [new Date(0), 'api-token', '__horizon_probe__', null, '__probe__', 0, 0, 1],
+          `INSERT INTO horizon_audit (${EVENT_COLUMNS.join(',')}) VALUES ` +
+            valuesClause(1, EVENT_COLUMNS.length),
+          [new Date(0), 'local', null, null, 1, null, '__horizon_probe__', null, null, null, null, '__probe__'],
         );
       } finally {
         // Always, including on the failure path — the probe must leave the
@@ -368,52 +354,17 @@ export class PostgresAuditStore implements AuditStore {
     }
   }
 
-  async writeAggregates(rows: ReadonlyArray<AuditAggregate & StoreStamp>): Promise<void> {
-    for (let i = 0; i < rows.length; i += CHUNK_ROWS) {
-      const chunk = rows.slice(i, i + CHUNK_ROWS);
-      const params = chunk.flatMap((r) => [
-        new Date(r.at), r.kind, r.username, toInet(r.horizonIp),
-        r.horizonNode, r.hourBucket, r.count, 1,
-      ]);
-      try {
-        // A plain multi-row VALUES, NOT `INSERT ... SELECT ... FROM (VALUES ...)`.
-        // The difference is load-bearing rather than stylistic: `pg` sends Parse
-        // with no declared parameter types, and inside a VALUES used as a FROM
-        // item every all-unknown column resolves to `text` — the target
-        // column's type is not visible through the sub-SELECT. Postgres then
-        // refuses the assignment (42804, "column at is of type timestamptz but
-        // expression is of type text") and the statement fails every time. In
-        // this form each sublist is coerced to the target columns first, so the
-        // parameters are inferred from the table and no casts are needed.
-        //
-        // Cumulative counts, so overwrite rather than add: a retry after a
-        // commit-then-timeout writes the same number instead of doubling it.
-        // The `WHERE` on the conflict target is required — it is what makes the
-        // partial unique index inferrable.
-        await this.db.query(
-          `INSERT INTO horizon_audit (${AGGREGATE_COLUMNS.join(',')}) VALUES ` +
-            valuesClause(chunk.length, AGGREGATE_COLUMNS.length) +
-            ` ON CONFLICT (hour_bucket, kind, username, horizon_node) ` +
-            `WHERE hour_bucket IS NOT NULL ` +
-            `DO UPDATE SET count = EXCLUDED.count, at = EXCLUDED.at`,
-          params,
-        );
-      } catch (err) {
-        fail(err);
-      }
-    }
-  }
 
   async writeStat(stat: AuditStat): Promise<void> {
     try {
       await this.db.query(
         `INSERT INTO horizon_audit_stat
            (hour_bucket, horizon_node, login_local, login_ldap,
-            login_oidc, login_oauth, login_token, rejected, over_budget)
-         VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
+            login_oidc, login_oauth, rejected, over_budget)
+         VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
         [
           stat.hourBucket, stat.horizonNode, stat.login.local, stat.login.ldap,
-          stat.login.oidc, stat.login.oauth, stat.login.token,
+          stat.login.oidc, stat.login.oauth,
           stat.rejected, stat.overBudget,
         ],
       );
@@ -488,7 +439,6 @@ export class PostgresAuditStore implements AuditStore {
                 SUM(login_ldap)        AS login_ldap,
                 SUM(login_oidc)        AS login_oidc,
                 SUM(login_oauth)       AS login_oauth,
-                SUM(login_token)       AS login_token,
                 SUM(rejected)          AS rejected,
                 SUM(over_budget)       AS over_budget,
                 -- The distinct writers this hour, as one comma-joined value.
@@ -518,7 +468,6 @@ export class PostgresAuditStore implements AuditStore {
             ldap: toNumber(r.login_ldap, 'login_ldap'),
             oidc: toNumber(r.login_oidc, 'login_oidc'),
             oauth: toNumber(r.login_oauth, 'login_oauth'),
-            token: toNumber(r.login_token, 'login_token'),
           },
           rejected: toNumber(r.rejected, 'rejected'),
         });
@@ -536,7 +485,7 @@ export class PostgresAuditStore implements AuditStore {
         columns.push(
           byHour.get(bucket) ?? {
             hourBucket: bucket,
-            login: { local: 0, ldap: 0, oidc: 0, oauth: 0, token: 0 },
+            login: { local: 0, ldap: 0, oidc: 0, oauth: 0 },
             rejected: 0,
           },
         );
diff --git a/apps/bff/src/store/audit/postgres/token-store.ts b/apps/bff/src/store/audit/postgres/token-store.ts
new file mode 100644
index 00000000..9f69abf5
--- /dev/null
+++ b/apps/bff/src/store/audit/postgres/token-store.ts
@@ -0,0 +1,144 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * The Postgres half of token usage — the FIRST implementation of
+ * `TokenUsageStore`, not the only one. Everything backend-specific stops
+ * here: the load-append-update cycle itself lives in `TokenCounters`, so a
+ * second backend inherits the correctness rather than re-deriving it.
+ */
+
+import type pg from 'pg';
+import { hourBucketStart } from '../counters.js';
+import { overFetchSize, takeOverFetched, type PageResult } from '../../../logic/paging/read-page.js';
+import { toNumber } from './rows.js';
+import type {
+  TokenKind,
+  TokenUsage,
+  TokenUsageEntry,
+  TokenUsageFilter,
+  TokenUsageStore,
+} from '../token-usage.js';
+import type { StoreStamp } from '../types.js';
+
+/** Rows per statement, matching the audit writer's chunking. */
+const CHUNK_ROWS = 500;
+
+interface RawRow {
+  hour_bucket: unknown;
+  kind: string;
+  token_id: string;
+  username: string;
+  count: unknown;
+  horizon_node: string;
+}
+
+function toEntry(row: RawRow): TokenUsageEntry {
+  const hourBucket = toNumber(row.hour_bucket, 'hour_bucket');
+  return {
+    hourBucket,
+    at: hourBucketStart(hourBucket),
+    kind: row.kind as TokenKind,
+    tokenId: row.token_id,
+    username: row.username,
+    count: toNumber(row.count, 'count'),
+    horizonNode: row.horizon_node,
+  };
+}
+
+const COLUMNS = 'hour_bucket, kind, token_id, username, count, horizon_node';
+
+/** `($1,$2,…),($7,$8,…)` for a multi-row insert. */
+function valuesClause(rows: number, cols: number): string {
+  const out: string[] = [];
+  for (let r = 0; r < rows; r += 1) {
+    const ps: string[] = [];
+    for (let c = 1; c <= cols; c += 1) ps.push(`$${r * cols + c}`);
+    out.push(`(${ps.join(',')})`);
+  }
+  return out.join(',');
+}
+
+export class PostgresTokenUsageStore implements TokenUsageStore {
+  constructor(private readonly db: Pick<pg.Pool, 'query'>) {}
+
+  async loadHour(hourBucket: number): Promise<TokenUsage[]> {
+    const res = await this.db.query<RawRow>(
+      `SELECT ${COLUMNS} FROM horizon_token_usage WHERE hour_bucket = $1`,
+      [hourBucket],
+    );
+    return res.rows.map((r) => {
+      const { at: _at, horizonNode: _node, ...usage } = toEntry(r);
+      void _at; void _node;
+      return usage;
+    });
+  }
+
+  async writeUsage(rows: ReadonlyArray<TokenUsage & StoreStamp>): Promise<void> {
+    for (let i = 0; i < rows.length; i += CHUNK_ROWS) {
+      const chunk = rows.slice(i, i + CHUNK_ROWS);
+      const params = chunk.flatMap((r) => [
+        r.hourBucket, r.kind, r.tokenId, r.username, r.count, r.horizonNode,
+      ]);
+      // The total is CUMULATIVE, so the update overwrites rather than adds:
+      // writing the same total twice leaves the same row, which is what makes
+      // a retry after a commit-then-timeout safe.
+      await this.db.query(
+        `INSERT INTO horizon_token_usage (${COLUMNS}) VALUES ${valuesClause(chunk.length, 6)} ` +
+          `ON CONFLICT (hour_bucket, token_id) ` +
+          `DO UPDATE SET count = EXCLUDED.count, username = EXCLUDED.username, ` +
+          `horizon_node = EXCLUDED.horizon_node`,
+        params,
+      );
+    }
+  }
+
+  async query(filter: TokenUsageFilter): Promise<PageResult<TokenUsageEntry>> {
+    const where: string[] = [];
+    const params: unknown[] = [];
+    const bind = (v: unknown): string => `$${params.push(v)}`;
+
+    // The hour bucket IS the time, so the range is expressed in buckets rather
+    // than joining against a timestamp the table does not carry.
+    if (filter.from !== undefined) where.push(`hour_bucket >= ${bind(bucketOf(filter.from))}`);
+    if (filter.to !== undefined) where.push(`hour_bucket < ${bind(bucketOf(filter.to))}`);
+    if (filter.kind?.length) where.push(`kind = ANY(${bind(filter.kind)})`);
+    if (filter.tokenId) where.push(`token_id = ${bind(filter.tokenId)}`);
+    if (filter.username) where.push(`username = ${bind(filter.username)}`);
+
+    const size = Math.max(1, filter.pageSize);
+    const limit = overFetchSize(size);
+    const offset = (Math.max(1, filter.pageNum) - 1) * size;
+    const res = await this.db.query<RawRow>(
+      `SELECT ${COLUMNS} FROM horizon_token_usage
+       ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
+       ORDER BY hour_bucket DESC, token_id ASC LIMIT ${bind(limit)} OFFSET ${bind(offset)}`,
+      params,
+    );
+    const { rows, hasNext } = takeOverFetched(res.rows.map(toEntry), size);
+    return { rows, pageNum: filter.pageNum, pageSize: size, hasNext };
+  }
+}
+
+/** epoch ms → `yyyyMMddHH`, UTC. */
+function bucketOf(ms: number): number {
+  const d = new Date(ms);
+  return d.getUTCFullYear() * 1_000_000
+    + (d.getUTCMonth() + 1) * 10_000
+    + d.getUTCDate() * 100
+    + d.getUTCHours();
+}
diff --git a/apps/bff/src/store/audit/service.test.ts b/apps/bff/src/store/audit/service.test.ts
index 3ee6550b..ca7ba874 100644
--- a/apps/bff/src/store/audit/service.test.ts
+++ b/apps/bff/src/store/audit/service.test.ts
@@ -22,7 +22,6 @@ import { BufferedAuditService } from './service.js';
 import { logger } from '../../logger.js';
 import {
   AuditStoreError,
-  type AuditAggregate,
   type AuditEvent,
   type AuditStat,
   type AuditStore,
@@ -50,7 +49,6 @@ function config(over: Partial<AuditConfig> = {}): AuditConfig {
 
 class FakeStore implements AuditStore {
   events: Array<AuditEvent & StoreStamp> = [];
-  aggregates: Array<AuditAggregate & StoreStamp> = [];
   stats: AuditStat[] = [];
   sweeps = 0;
   opens = 0;
@@ -85,10 +83,6 @@ class FakeStore implements AuditStore {
     await this.pass('writeEvents');
     this.events.push(...rows);
   }
-  async writeAggregates(rows: ReadonlyArray<AuditAggregate & StoreStamp>): Promise<void> {
-    await this.pass('writeAggregates');
-    this.aggregates.push(...rows);
-  }
   async writeStat(stat: AuditStat): Promise<void> {
     await this.pass('writeStat');
     this.stats.push(stat);
@@ -179,12 +173,11 @@ describe('batching', () => {
   it('does not let the row trigger drive the slower cadences', async () => {
     const svc = service({ eventBatchRows: 1, flushIntervalSeconds: 60, eventBatchSeconds: 15 });
     await svc.tick();
-    svc.countTokenUse({ kind: 'api-token', username: 'ab12cd', at: T });
     for (let i = 0; i < 100; i += 1) signIn(svc);
     await vi.waitFor(() => expect(store.events.length).toBeGreaterThan(0));
     // Rows went out, but neither slower cadence fired: those hang off the tick
     // counter, which the row trigger must never advance.
-    expect(store.aggregates).toHaveLength(0);
+    expect(store.stats).toHaveLength(0);
     expect(store.sweeps).toBe(0);
   });
 
@@ -208,20 +201,21 @@ describe('cadences hang off one tick counter', () => {
   it('still runs the slower cadences while event flushes are constantly in flight', async () => {
     const svc = service({ eventBatchRows: 1, flushIntervalSeconds: 15, eventBatchSeconds: 15 });
     await svc.tick();
-    svc.countTokenUse({ kind: 'api-token', username: 'ab12cd', at: T });
     for (let i = 0; i < 50; i += 1) signIn(svc);
     await svc.tick();
-    expect(store.aggregates).toHaveLength(1);
+    expect(store.stats).toHaveLength(1);
   });
 
-  it('flushes events every tick and aggregates every fourth', async () => {
+  it('flushes events every tick and the slower cadences every fourth', async () => {
     const svc = service({ flushIntervalSeconds: 60, eventBatchSeconds: 15 });
-    svc.countTokenUse({ kind: 'api-token', username: 'ab12cd', at: T });
+    // A sign-in, so the statistics accumulator has something to hand over —
+    // an empty interval is skipped rather than written.
+    signIn(svc);
     for (let i = 1; i <= 3; i += 1) await svc.tick();
-    expect(store.aggregates).toHaveLength(0);
+    expect(store.stats).toHaveLength(0);
     await svc.tick();
-    expect(store.aggregates).toHaveLength(1);
-    expect(store.aggregates[0].count).toBe(1);
+    expect(store.stats).toHaveLength(1);
+    expect(store.stats[0].login.local).toBe(1);
   });
 
   it('sweeps on its own much slower cadence', async () => {
@@ -257,20 +251,6 @@ describe('failure handling', () => {
     expect(store.events).toHaveLength(1);
   });
 
-  /** Cumulative counts are what make the aggregate retry idempotent: the
-   *  second attempt writes the same total, not an increment. */
-  it('re-sends the same cumulative total after a failed aggregate flush', async () => {
-    const svc = service({ flushIntervalSeconds: 15, eventBatchSeconds: 15 });
-    svc.countTokenUse({ kind: 'api-token', username: 'ab12cd', at: T });
-    svc.countTokenUse({ kind: 'api-token', username: 'ab12cd', at: T });
-    store.failWith = new AuditStoreError('timeout');
-    await svc.tick();
-    store.failWith = null;
-    await svc.tick();
-    expect(store.aggregates).toHaveLength(1);
-    expect(store.aggregates[0].count).toBe(2);
-  });
-
   it('folds failed statistics back in so nothing is silently skipped', async () => {
     const svc = service({ flushIntervalSeconds: 15, eventBatchSeconds: 15 });
     signIn(svc);
@@ -311,10 +291,9 @@ describe('shutdown', () => {
     const svc = service();
     await svc.tick();
     signIn(svc);
-    svc.countTokenUse({ kind: 'api-token', username: 'ab12cd', at: T });
     await svc.stop();
     expect(store.events).toHaveLength(1);
-    expect(store.aggregates).toHaveLength(1);
+    expect(store.stats).toHaveLength(1);
     expect(store.stats.length).toBeGreaterThan(0);
   });
 });
@@ -395,28 +374,6 @@ describe('regressions found by review', () => {
     expect(h.available).toBe(false);
     expect(h.error).toBe('schema_error');
   });
-
-  /** Uses landing during the aggregate round trip must stay pending, or the
-   *  stored count silently under-reports for that credential-hour. */
-  it('does not mark token uses flushed that arrived during the write', async () => {
-    let landDuringWrite: (() => void) | null = null;
-    store.writeAggregates = async (rows) => {
-      landDuringWrite?.();
-      store.aggregates.push(...rows);
-    };
-    const svc = service({ flushIntervalSeconds: 15, eventBatchSeconds: 15 });
-    await svc.tick();
-    for (let i = 0; i < 5; i += 1) svc.countTokenUse({ kind: 'api-token', username: 'ab12cd', at: T });
-    landDuringWrite = () => {
-      for (let i = 0; i < 3; i += 1) svc.countTokenUse({ kind: 'api-token', username: 'ab12cd', at: T });
-    };
-    await svc.tick();
-    expect(store.aggregates.at(-1)?.count).toBe(5);
-
-    landDuringWrite = null;
-    await svc.tick();
-    expect(store.aggregates.at(-1)?.count).toBe(8);
-  });
 });
 
 describe('shutdown regressions found by review', () => {
@@ -439,43 +396,6 @@ describe('shutdown regressions found by review', () => {
     await svc.stop();
     expect(store.events.length).toBe(500);
   });
-
-  /**
-   * `stop()` waited for the event flush but not the aggregate one. An older
-   * cumulative count landing after the newer shutdown write leaves the store
-   * BEHIND what this process believes it flushed, and nothing revisits the
-   * bucket — so the loss is permanent.
-   */
-  it('joins an in-flight aggregate write instead of racing it', async () => {
-    let release = (): void => {};
-    const gate = new Promise<void>((r) => { release = r; });
-    let firstCall = true;
-    const seen: number[] = [];
-    store.writeAggregates = async (rows) => {
-      const counts = rows.map((r) => r.count);
-      if (firstCall) {
-        firstCall = false;
-        await gate; // the OLD write parks mid-flight
-      }
-      seen.push(...counts);
-      store.aggregates.push(...rows);
-    };
-
-    const svc = service({ flushIntervalSeconds: 15, eventBatchSeconds: 15 });
-    await svc.tick();
-    for (let i = 0; i < 5; i += 1) svc.countTokenUse({ kind: 'api-token', username: 'ab12cd', at: T });
-    const parked = svc.tick();            // writes 5, parks
-    for (let i = 0; i < 3; i += 1) svc.countTokenUse({ kind: 'api-token', username: 'ab12cd', at: T });
-
-    const stopped = svc.stop();
-    release();
-    await Promise.all([parked, stopped]);
-
-    // Whatever order the writes were issued in, the LAST count the store saw
-    // must not be smaller than an earlier one.
-    expect(seen.length).toBeGreaterThan(0);
-    expect(seen[seen.length - 1]).toBeGreaterThanOrEqual(Math.max(...seen));
-  });
 });
 
 /**
@@ -624,17 +544,6 @@ describe('the node stamp', () => {
     });
     expect((await stampOf(svc)).horizonIp).toBe('192.0.2.9');
   });
-
-  it('stamps aggregate rows with the same address as sign-in rows', async () => {
-    process.env.POD_IP = '10.42.0.17';
-    const svc = service();
-    await svc.tick();
-    svc.countTokenUse({ kind: 'api-token', username: 'ab12cd', at: T });
-    // Aggregates ride the every-fourth tick (flushIntervalSeconds /
-    // eventBatchSeconds), so drive the timer rather than reaching inside.
-    for (let i = 0; i < 4; i += 1) await svc.tick();
-    expect(store.aggregates[0]?.horizonIp).toBe('10.42.0.17');
-  });
 });
 
 /** Cadences are whole ticks, so one that does not divide by
diff --git a/apps/bff/src/store/audit/service.ts b/apps/bff/src/store/audit/service.ts
index 99e269e7..cad6e5dc 100644
--- a/apps/bff/src/store/audit/service.ts
+++ b/apps/bff/src/store/audit/service.ts
@@ -50,7 +50,6 @@ import {
   type AuditStore,
   type StoreError,
   type StoreStamp,
-  type TokenUse,
 } from './types.js';
 
 /** Buffered sign-in rows. Matches the token map's cap so one outage cannot
@@ -169,9 +168,6 @@ export class BufferedAuditService implements AuditService {
    * caller waits for the real result.
    */
   private flushing: Promise<void> | null = null;
-  /** The in-flight aggregate write, for the same reason `flushing` is a
-   *  promise: shutdown has to JOIN it, not race it. */
-  private aggregating: Promise<void> | null = null;
   /** The in-flight statistics append. `takeStats` DETACHES the accumulator
    *  before the write, so a shutdown that races it finds nothing to write,
    *  exits reporting success, and drops the interval the parked write is
@@ -267,9 +263,6 @@ export class BufferedAuditService implements AuditService {
     }
   }
 
-  countTokenUse(use: TokenUse): void {
-    this.counters.countTokenUse(use);
-  }
 
   async start(): Promise<void> {
     if (this.starting) return this.starting;
@@ -334,9 +327,9 @@ export class BufferedAuditService implements AuditService {
     // The tick too, and not only the two write handles: a tick already inside
     // its statistics or sweep phase has released those, so joining them alone
     // let shutdown close the pool while the store was still being written.
-    await Promise.allSettled([this.tickInFlight, this.flushing, this.aggregating, this.statting].filter(Boolean));
+    await Promise.allSettled([this.tickInFlight, this.flushing, this.statting].filter(Boolean));
 
-    await Promise.allSettled([this.tickInFlight, this.flushing, this.aggregating, this.statting].filter(Boolean));
+    await Promise.allSettled([this.tickInFlight, this.flushing, this.statting].filter(Boolean));
 
     // A store that never opened is not a reason to discard the buffer — the
     // failure may have been transient and this is the last chance to write.
@@ -351,7 +344,6 @@ export class BufferedAuditService implements AuditService {
       await this.flushEvents();
       if (this.buffer.length >= before) break; // making no progress; stop trying
     }
-    await this.flushAggregates();
     await this.flushStats();
     await this.store.close();
   }
@@ -423,10 +415,7 @@ export class BufferedAuditService implements AuditService {
     if (!this.available) await this.tryOpen();
     if (!this.available) return;
     await this.flushEvents();
-    if (this.ticks % this.aggregateEvery === 0) {
-      await this.flushAggregates();
-      await this.flushStats();
-    }
+    if (this.ticks % this.aggregateEvery === 0) await this.flushStats();
     // Re-checked rather than hoisted: the sweep is the longest store call in
     // the feature, and `stop()` may have been entered during the phases above.
     if (this.ticks % this.sweepEvery === 0 && !this.stopping) await this.runSweep();
@@ -499,32 +488,7 @@ export class BufferedAuditService implements AuditService {
     await this.flushEvents();
   }
 
-  private async flushAggregates(): Promise<void> {
-    if (this.aggregating) return this.aggregating;
-    this.aggregating = this.writeAggregates().finally(() => {
-      this.aggregating = null;
-    });
-    return this.aggregating;
-  }
 
-  private async writeAggregates(): Promise<void> {
-    const pending = this.counters.pendingAggregates();
-    if (pending.length === 0) return;
-    try {
-      await this.store.writeAggregates(
-        pending.map(({ key, ...row }) => {
-          void key;
-          return { ...row, shape: 'aggregate' as const, ...this.stamp };
-        }),
-      );
-      // Only after the write commits, and at the counts that were WRITTEN —
-      // uses that landed during the round trip must stay pending.
-      this.counters.markFlushed(pending.map((p) => ({ key: p.key, count: p.count })));
-      this.markAvailable();
-    } catch (err) {
-      this.markUnavailable(codeOf(err));
-    }
-  }
 
   private async flushStats(): Promise<void> {
     if (this.statting) return this.statting;
@@ -622,7 +586,6 @@ export class DisabledAuditService implements AuditService {
     private readonly problem?: string,
   ) {}
   recordEvent(): void {}
-  countTokenUse(): void {}
   async query(): Promise<AuditPageResult> {
     return { rows: [], pageNum: 1, pageSize: 0, hasNext: false };
   }
diff --git a/apps/bff/src/store/audit/token-counters.test.ts b/apps/bff/src/store/audit/token-counters.test.ts
new file mode 100644
index 00000000..c5a27063
--- /dev/null
+++ b/apps/bff/src/store/audit/token-counters.test.ts
@@ -0,0 +1,136 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * The load-append-update cycle, which is the whole reason this is a mechanism
+ * rather than a store detail. Both backends inherit these properties.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { TokenCounters, MAX_TOKENS_PER_HOUR } from './token-counters.js';
+import { hourBucketOf } from './counters.js';
+
+const AT = Date.UTC(2026, 7, 23, 7, 30);
+const HOUR = hourBucketOf(AT);
+const use = (tokenId = 'ab12cd', at = AT) =>
+  ({ kind: 'api-token' as const, tokenId, username: 'sre', at });
+
+describe('counting', () => {
+  it('is one row per token per hour, however many uses', () => {
+    const c = new TokenCounters();
+    c.loadHour(HOUR, []);
+    for (let i = 0; i < 40; i += 1) c.count(use());
+    expect(c.pending()).toEqual([
+      { hourBucket: HOUR, kind: 'api-token', tokenId: 'ab12cd', username: 'sre', count: 40 },
+    ]);
+  });
+
+  it('separates credentials, and separates hours', () => {
+    const c = new TokenCounters();
+    const nextHour = AT + 3_600_000;
+    c.loadHour(HOUR, []);
+    c.loadHour(hourBucketOf(nextHour), []);
+    c.count(use('aaa'));
+    c.count(use('bbb'));
+    c.count(use('aaa', nextHour));
+    expect(c.pending()).toHaveLength(3);
+  });
+});
+
+/**
+ * The property the whole design exists for. A process that restarts mid-hour
+ * begins at zero; without the load its next write REPLACES the hour's real
+ * total with a partial one.
+ */
+describe('a restarted process resumes rather than overwrites', () => {
+  it('adds to what is already stored', () => {
+    const c = new TokenCounters();
+    c.loadHour(HOUR, [
+      { hourBucket: HOUR, kind: 'api-token', tokenId: 'ab12cd', username: 'sre', count: 400 },
+    ]);
+    for (let i = 0; i < 12; i += 1) c.count(use());
+    expect(c.pending()[0]?.count).toBe(412);
+  });
+
+  it('refuses to write an hour it has not read', () => {
+    const c = new TokenCounters();
+    c.count(use());
+    // Writing now would claim the hour's total is 1.
+    expect(c.pending()).toEqual([]);
+    expect(c.hoursNeedingLoad()).toEqual([HOUR]);
+    c.loadHour(HOUR, [
+      { hourBucket: HOUR, kind: 'api-token', tokenId: 'ab12cd', username: 'sre', count: 400 },
+    ]);
+    expect(c.pending()[0]?.count).toBe(401);
+  });
+
+  it('keeps a credential it never saw, so a write does not drop it', () => {
+    const c = new TokenCounters();
+    c.loadHour(HOUR, [
+      { hourBucket: HOUR, kind: 'api-token', tokenId: 'other', username: 'ci', count: 7 },
+    ]);
+    c.count(use('mine'));
+    const rows = c.pending();
+    // `other` has nothing pending, so it is not rewritten — but it is retained
+    // and would not be clobbered by this process's own write.
+    expect(rows.map((r) => r.tokenId)).toEqual(['mine']);
+  });
+});
+
+describe('marking written', () => {
+  it('counts only what was submitted, so uses during the write stay pending', () => {
+    const c = new TokenCounters();
+    c.loadHour(HOUR, []);
+    for (let i = 0; i < 5; i += 1) c.count(use());
+    const submitted = c.pending();
+    expect(submitted[0]?.count).toBe(5);
+    // Four more arrive while the write is on the wire.
+    for (let i = 0; i < 4; i += 1) c.count(use());
+    c.markWritten(submitted);
+    expect(c.pending()[0]?.count).toBe(9);
+  });
+
+  it('is idempotent — replaying the same write changes nothing', () => {
+    const c = new TokenCounters();
+    c.loadHour(HOUR, []);
+    for (let i = 0; i < 5; i += 1) c.count(use());
+    const submitted = c.pending();
+    c.markWritten(submitted);
+    c.markWritten(submitted);
+    expect(c.pending()).toEqual([]);
+  });
+});
+
+/** A client minting a credential per request is the one case the per-hour
+ *  shape does not bound by itself. */
+describe('the per-hour credential cap', () => {
+  it('refuses new credentials past the cap and says how many', () => {
+    const c = new TokenCounters();
+    c.loadHour(HOUR, []);
+    for (let i = 0; i < MAX_TOKENS_PER_HOUR + 25; i += 1) c.count(use(`t${i}`));
+    expect(c.pending().length).toBeLessThanOrEqual(MAX_TOKENS_PER_HOUR);
+    expect(c.refused).toBe(25);
+  });
+
+  it('keeps counting credentials it already knows', () => {
+    const c = new TokenCounters();
+    c.loadHour(HOUR, []);
+    for (let i = 0; i < MAX_TOKENS_PER_HOUR; i += 1) c.count(use(`t${i}`));
+    c.count(use('t0'));
+    expect(c.pending().find((r) => r.tokenId === 't0')?.count).toBe(2);
+  });
+});
diff --git a/apps/bff/src/store/audit/token-counters.ts b/apps/bff/src/store/audit/token-counters.ts
new file mode 100644
index 00000000..e450b893
--- /dev/null
+++ b/apps/bff/src/store/audit/token-counters.ts
@@ -0,0 +1,182 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * The in-memory half of token usage: counting on the request path, and
+ * deciding what to write.
+ *
+ * Backend-agnostic by construction — it knows about hours, credentials and
+ * counts, and nothing about upserts, measures or SQL.
+ */
+
+import { hourBucketOf } from './counters.js';
+import type { TokenKind, TokenUsage } from './token-usage.js';
+
+/** Hours kept in memory. An hour older than this cannot still be accruing,
+ *  and holding it open only risks writing a stale total over a live one. */
+export const MAX_TOKEN_HOURS = 3;
+/** Distinct credentials tracked per hour. Past this a NEW credential is not
+ *  counted — a client that mints a token per request would otherwise grow the
+ *  map with traffic, which is the one pathological case the per-hour shape
+ *  does not bound by itself. Existing credentials keep counting. */
+export const MAX_TOKENS_PER_HOUR = 5_000;
+
+interface Cell {
+  hourBucket: number;
+  kind: TokenKind;
+  tokenId: string;
+  username: string;
+  /** Uses this process has seen and NOT yet written. */
+  pending: number;
+  /** The total already stored for this hour, from `loadHour` or from our own
+   *  last successful write. `pending + stored` is what gets written next. */
+  stored: number;
+  /** Whether the stored total has been read for this hour. Until it has, this
+   *  process must not write: it would replace a real total with a partial. */
+  loaded: boolean;
+}
+
+export interface TokenUse {
+  kind: TokenKind;
+  tokenId: string;
+  username: string;
+  at: number;
+}
+
+/**
+ * Counts token use, and produces the rows a flush should write.
+ *
+ * The load-append-update cycle lives here rather than in the store, so both
+ * backends inherit it: a store that forgot to load would silently overwrite,
+ * and that is not a mistake worth letting two implementations make
+ * independently.
+ */
+export class TokenCounters {
+  private readonly cells = new Map<string, Cell>();
+  /** Hours whose stored totals have been read. */
+  private readonly loadedHours = new Set<number>();
+  private refusedSinceStart = 0;
+
+  /** Credentials refused because the per-hour cap was reached. Diagnostic:
+   *  a non-zero value means a client is minting credentials, not that the
+   *  store is unhealthy. */
+  get refused(): number {
+    return this.refusedSinceStart;
+  }
+
+  /** Hours holding unwritten use, oldest first. The caller loads each before
+   *  its first write — see `loadHour`. */
+  hoursNeedingLoad(): number[] {
+    const out = new Set<number>();
+    for (const c of this.cells.values()) if (!c.loaded) out.add(c.hourBucket);
+    return [...out].sort((a, b) => a - b);
+  }
+
+  /** On the request path. Never touches a store, never awaits. */
+  count(use: TokenUse): void {
+    const hourBucket = hourBucketOf(use.at);
+    const key = `${hourBucket}|${use.tokenId}`;
+    const existing = this.cells.get(key);
+    if (existing) {
+      existing.pending += 1;
+      return;
+    }
+    this.evict(hourBucket);
+    if (this.cells.size >= MAX_TOKENS_PER_HOUR) {
+      this.refusedSinceStart += 1;
+      return;
+    }
+    this.cells.set(key, {
+      hourBucket,
+      kind: use.kind,
+      tokenId: use.tokenId,
+      username: use.username,
+      pending: 1,
+      stored: 0,
+      loaded: this.loadedHours.has(hourBucket),
+    });
+  }
+
+  /**
+   * Seed an hour from what the store already holds.
+   *
+   * This is the step that makes a restart safe: a process that begins at zero
+   * and writes would replace the hour's real total with its own partial one.
+   */
+  loadHour(hourBucket: number, stored: readonly TokenUsage[]): void {
+    this.loadedHours.add(hourBucket);
+    for (const row of stored) {
+      const key = `${hourBucket}|${row.tokenId}`;
+      const cell = this.cells.get(key);
+      if (cell) {
+        cell.stored = row.count;
+        cell.loaded = true;
+        continue;
+      }
+      // A credential this process has not seen this hour. Kept so its total
+      // survives a write from here rather than being dropped.
+      this.cells.set(key, { ...row, pending: 0, stored: row.count, loaded: true });
+    }
+    for (const cell of this.cells.values()) {
+      if (cell.hourBucket === hourBucket) cell.loaded = true;
+    }
+  }
+
+  /** The rows to write: cumulative totals for every loaded hour with unwritten
+   *  use. An hour that has not been loaded is skipped, not guessed at. */
+  pending(): TokenUsage[] {
+    const out: TokenUsage[] = [];
+    for (const c of this.cells.values()) {
+      if (!c.loaded || c.pending === 0) continue;
+      out.push({
+        hourBucket: c.hourBucket,
+        kind: c.kind,
+        tokenId: c.tokenId,
+        username: c.username,
+        count: c.stored + c.pending,
+      });
+    }
+    return out;
+  }
+
+  /**
+   * Move the written total into `stored`.
+   *
+   * Only what was SUBMITTED, not the live count: uses that arrived while the
+   * write was in flight stay pending, or they would be marked stored without
+   * ever having been sent.
+   */
+  markWritten(written: readonly TokenUsage[]): void {
+    for (const row of written) {
+      const cell = this.cells.get(`${row.hourBucket}|${row.tokenId}`);
+      if (!cell) continue;
+      const justWritten = row.count - cell.stored;
+      cell.stored = row.count;
+      cell.pending = Math.max(0, cell.pending - justWritten);
+    }
+  }
+
+  private evict(incoming: number): void {
+    const hours = [...new Set([...this.cells.values()].map((c) => c.hourBucket).concat(incoming))]
+      .sort((a, b) => a - b);
+    const doomed = new Set(hours.slice(0, Math.max(0, hours.length - MAX_TOKEN_HOURS)));
+    doomed.delete(incoming);
+    if (doomed.size === 0) return;
+    for (const [key, c] of this.cells) if (doomed.has(c.hourBucket)) this.cells.delete(key);
+    for (const h of doomed) this.loadedHours.delete(h);
+  }
+}
diff --git a/apps/bff/src/store/audit/token-usage.ts b/apps/bff/src/store/audit/token-usage.ts
new file mode 100644
index 00000000..92a6809e
--- /dev/null
+++ b/apps/bff/src/store/audit/token-usage.ts
@@ -0,0 +1,123 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Token usage — a STATISTIC, deliberately not part of the login audit.
+ *
+ * Presenting an API or OAuth token on a request is not a sign-in: nobody logs
+ * in, so there is no moment to record and no principal who just proved
+ * themselves. What a token did is a question about traffic. Keeping it out of
+ * `horizon_audit` is what lets that table mean one thing — every row is a
+ * person getting in — and lets this one answer "how much, how fast, by which
+ * credential" without the two grains fighting over one list.
+ *
+ * The shape is ONE ROW PER TOKEN PER HOUR, cluster-wide:
+ *
+ *   - An API token's id is stable for the credential's life, so an hour is one
+ *     row however many requests it served.
+ *   - An OAuth access token is named by its `jti`, which is minted per
+ *     ISSUANCE rather than per request. At the default 60-minute lifetime a
+ *     well-behaved client holds one for the hour and produces one row too.
+ *     Shortening `oauth.accessTokenMinutes` multiplies the rows for that hour
+ *     accordingly — the table grows with that setting as well as with people.
+ *
+ * The cadence is LOAD, APPEND, UPDATE, once per hour:
+ *
+ *   1. The first time a process touches an hour it READS the stored count.
+ *   2. It adds what it has seen in memory since.
+ *   3. It writes the total back.
+ *
+ * The load is what makes a restart safe. Without it a process that restarts
+ * mid-hour begins at zero and its next write REPLACES the hour's real total
+ * with a partial one — the same resurrection bug that a purely cumulative
+ * writer has, arriving through the front door.
+ *
+ * This contract is backend-agnostic on purpose. Postgres implements it with
+ * an upsert; BanyanDB will implement it as a Measure, where a write is an
+ * upsert natively and the highest `version` wins. Neither mechanism leaks
+ * here.
+ */
+
+import type { StoreError, StoreStamp } from './types.js';
+import type { PageResult } from '../../logic/paging/read-page.js';
+
+/** How a credential was presented. Both are tokens; neither is a login. */
+export type TokenKind = 'api-token' | 'oauth-token';
+
+/** One credential's use within one UTC hour. */
+export interface TokenUsage {
+  /** `yyyyMMddHH`, UTC — the same shape the audit statistics use. */
+  hourBucket: number;
+  kind: TokenKind;
+  /**
+   * The credential. An API token's id from the tokens file, or an OAuth
+   * access token's `jti`. NEVER the token itself, and never its hash: an id
+   * names a credential without being one.
+   */
+  tokenId: string;
+  /** The account the token acts as. Recorded so a reader can answer "whose
+   *  token is this" without joining against a config file that may since have
+   *  changed. */
+  username: string;
+  /** Uses in this hour. CUMULATIVE, not a delta — the writer loads, adds and
+   *  writes the total back, so a retry writes the same number rather than
+   *  doubling it. */
+  count: number;
+}
+
+/** A row as read back. */
+export interface TokenUsageEntry extends TokenUsage {
+  /** The hour's start, epoch ms — so a reader need not parse `hourBucket`. */
+  at: number;
+  /** Which Horizon process last wrote this hour. Attribution, never a key:
+   *  the row is cluster-wide, so any node may have written it last. */
+  horizonNode: string;
+}
+
+/** Time range, and who. Nothing else — the same restraint the audit list has. */
+export interface TokenUsageFilter {
+  /** epoch ms; `from` inclusive, `to` exclusive. */
+  from?: number;
+  to?: number;
+  /** EXACT match, like the audit's Login ID. */
+  tokenId?: string;
+  username?: string;
+  kind?: TokenKind[];
+  pageNum: number;
+  pageSize: number;
+}
+
+/**
+ * What a backend must provide.
+ *
+ * Three operations, and the first is the one that makes the mechanism work:
+ * `loadHour` is not a convenience, it is what a correct writer does before its
+ * first write of an hour.
+ */
+export interface TokenUsageStore {
+  /** The stored counts for one hour, so a starting process can resume rather
+   *  than overwrite. Empty when the hour has no rows yet. */
+  loadHour(hourBucket: number): Promise<TokenUsage[]>;
+  /** Write the cumulative totals. Idempotent for a given `(hourBucket,
+   *  tokenId)`: writing the same total twice must leave the same row. */
+  writeUsage(rows: ReadonlyArray<TokenUsage & StoreStamp>): Promise<void>;
+  query(filter: TokenUsageFilter): Promise<PageResult<TokenUsageEntry>>;
+}
+
+/** Raised by a store; the code vocabulary is shared with the audit store so
+ *  one health surface can describe both. */
+export type TokenUsageError = StoreError;
diff --git a/apps/bff/src/store/audit/types.ts b/apps/bff/src/store/audit/types.ts
index 17000e69..cd052b76 100644
--- a/apps/bff/src/store/audit/types.ts
+++ b/apps/bff/src/store/audit/types.ts
@@ -53,17 +53,13 @@ export type AuditKind =
   | 'local'
   | 'ldap'
   | 'break-glass'
-  | 'sso'
-  | 'api-token'
-  | 'oauth-token';
+  | 'sso';
 
 export const AUDIT_KINDS: readonly AuditKind[] = [
   'local',
   'ldap',
   'break-glass',
   'sso',
-  'api-token',
-  'oauth-token',
 ];
 
 /** The only reasons that reach a ROW. Both are refusals a caller can reach
@@ -79,9 +75,8 @@ export interface AuditFields {
   /** epoch ms, UTC. */
   at: number;
   kind: AuditKind;
-  /** The verified principal — never null, never caller-supplied text.
-   *  Login kinds hold the login name or verified email; `api-token` holds the
-   *  token id; `oauth-token` holds the `sub`. */
+  /** The verified principal — never null, never caller-supplied text: the
+   *  login name, or the verified email address on the SSO path. */
   username: string;
   /** SSO only as the code stands — local users have no email field and the
    *  LDAP path requests no mail attribute. */
@@ -120,43 +115,17 @@ export interface AuditEvent extends AuditFields {
   clientIp?: string;
 }
 
-/**
- * One flushed token bucket. No outcome: a refused token produces no
- * aggregate, so this shape is accepted by construction.
- *
- * `username` is the token id for `api-token` and the **`sub`** for
- * `oauth-token` — never the `jti`. A `jti` names one issuance and is minted
- * fresh on every access-token call, so keying on it would make aggregate rows
- * grow with request volume rather than with the user count.
- */
-export interface AuditAggregate extends AuditFields {
-  shape: 'aggregate';
-  kind: 'api-token' | 'oauth-token';
-  /** yyyyMMddHH, UTC. */
-  hourBucket: number;
-  /** CUMULATIVE since this process started — never a delta. A flush writes
-   *  the running total and does not clear it, so a retry after a
-   *  commit-then-timeout overwrites with the same number. */
-  count: number;
-}
-
-export type NewAuditEntry = AuditEvent | AuditAggregate;
-
 /** What the service stamps on every row. The store never invents identity. */
 export interface StoreStamp {
   /** `<hostname>:<boot-id>` — one Horizon PROCESS. Not an OAP node, not a
-   *  k8s node: a restart opens a new one, so replicas never collide on a
-   *  cumulative count. */
+   *  k8s node: a restart opens a new one, which is what keeps two replicas
+   *  on one host distinguishable in the statistics. */
   horizonNode: string;
   /** Best effort, and a hint rather than an identifier. */
   horizonIp?: string;
 }
 
-/**
- * A row as READ BACK — flat rather than discriminated, because a reader is
- * looking at storage, where both shapes share one table and `hourBucket` is
- * what tells them apart.
- */
+/** A row as READ BACK. One shape: every row is a single sign-in. */
 export interface AuditEntry extends AuditFields, StoreStamp {
   /** Opaque and monotonic. A STRING: Postgres hands `bigint` back as text
    *  because it exceeds `Number.MAX_SAFE_INTEGER`, and narrowing it to a
@@ -165,18 +134,6 @@ export interface AuditEntry extends AuditFields, StoreStamp {
   outcome: 0 | 1;
   reason?: AuditReason;
   clientIp?: string;
-  /** Present if and only if this is an aggregate row. */
-  hourBucket?: number;
-  /** 1 on an event row. */
-  count: number;
-}
-
-/** One accepted token use, counted in memory. Refused tokens never reach here. */
-export interface TokenUse {
-  kind: 'api-token' | 'oauth-token';
-  /** The token id, or the `sub`. */
-  username: string;
-  at: number;
 }
 
 /**
@@ -248,9 +205,6 @@ export interface AuditLoginCounts {
   ldap: number;
   oidc: number;
   oauth: number;
-  /** Token USES — the sum of aggregate counts, not a row count. One row can
-   *  represent ten thousand uses. */
-  token: number;
 }
 
 /**
@@ -359,10 +313,6 @@ export interface AuditService {
    */
   recordEvent(event: Omit<AuditEvent, 'shape'>): void;
 
-  /** Count one ACCEPTED token use. In-memory and synchronous; the service
-   *  tick writes it per hour. Refused tokens are not passed here at all. */
-  countTokenUse(use: TokenUse): void;
-
   query(filter: AuditFilter): Promise<AuditPageResult>;
   queryStat(window: AuditStatWindow): Promise<AuditStatResult>;
   health(): Promise<AuditHealth>;
@@ -383,8 +333,6 @@ export interface AuditStore {
    *  wrong for an audit record — a duplicated sign-in is visible and
    *  harmless, a missing one is neither. */
   writeEvents(rows: ReadonlyArray<AuditEvent & StoreStamp>): Promise<void>;
-  /** Cumulative counts, upserted, so this retry IS idempotent. */
-  writeAggregates(rows: ReadonlyArray<AuditAggregate & StoreStamp>): Promise<void>;
   /** Appends one interval's delta. */
   writeStat(stat: AuditStat): Promise<void>;
 
diff --git a/apps/bff/src/user/middleware.ts b/apps/bff/src/user/middleware.ts
index d52e3a64..75c0e36e 100644
--- a/apps/bff/src/user/middleware.ts
+++ b/apps/bff/src/user/middleware.ts
@@ -139,7 +139,6 @@ export function requireAuth(deps: AuthDeps) {
       // Keyed on the PRINCIPAL, not the `jti`: a fresh id is minted on every
       // access-token call, so keying on it would make aggregate rows grow with
       // request volume rather than with the user count.
-      deps.audit?.countTokenUse({ kind: 'oauth-token', username: oauth.username, at: now });
       return;
     }
     const bearer = await deps.tokens?.resolve(req.headers.authorization);
@@ -160,7 +159,6 @@ export function requireAuth(deps: AuthDeps) {
       // The token id is the principal: it names the credential that was
       // presented. Who that credential belongs to is a fact about
       // configuration — the tokens file holds it — not a fact about this use.
-      deps.audit?.countTokenUse({ kind: 'api-token', username: bearer.tokenId, at: now });
       return;
     }
     const cookieName = deps.config.current.session.cookieName;
diff --git a/apps/bff/src/user/oidc/route.test.ts b/apps/bff/src/user/oidc/route.test.ts
index b0b357a2..c6e9fa39 100644
--- a/apps/bff/src/user/oidc/route.test.ts
+++ b/apps/bff/src/user/oidc/route.test.ts
@@ -78,7 +78,6 @@ function recorder(): { events: Array<Omit<AuditEvent, 'shape'>>; service: AuditS
   const events: Array<Omit<AuditEvent, 'shape'>> = [];
   const service = {
     recordEvent: (e: Omit<AuditEvent, 'shape'>) => void events.push(e),
-    countTokenUse: () => {},
     query: async () => ({ rows: [], pageNum: 1, pageSize: 0, hasNext: false }),
     queryStat: async () => ({ columns: [], overBudget: 0, writeUncertain: 0, tokenLost: 0, horizonNodes: 0 }),
     health: async () => ({}) as never,
diff --git a/apps/ui/src/api/scopes/admin-audit.ts b/apps/ui/src/api/scopes/admin-audit.ts
index 6d669b96..d2215e63 100644
--- a/apps/ui/src/api/scopes/admin-audit.ts
+++ b/apps/ui/src/api/scopes/admin-audit.ts
@@ -17,7 +17,7 @@
 
 import type { BffClient } from '../client';
 
-export type AuditKind = 'local' | 'ldap' | 'break-glass' | 'sso' | 'api-token' | 'oauth-token';
+export type AuditKind = 'local' | 'ldap' | 'break-glass' | 'sso';
 export type AuditReason = 'no_roles' | 'zero_group_mappings';
 export type AuditStatWindow = 2 | 6 | 12;
 
@@ -27,8 +27,8 @@ export interface AuditEntry {
   kind: AuditKind;
   outcome: 0 | 1;
   reason?: AuditReason;
-  /** The verified principal. A login name, a verified email, or a token id —
-   *  `kind` says how to read it. Never an unverified attempt. */
+  /** The verified principal — a login name, or a verified email on the SSO
+   *  path. Never an unverified attempt. */
   username: string;
   mail?: string;
   provider?: string;
@@ -41,11 +41,6 @@ export interface AuditEntry {
   clientIp?: string;
   horizonNode: string;
   horizonIp?: string;
-  /** Present only on an aggregate row — a credential-hour rather than a
-   *  single sign-in. */
-  hourBucket?: number;
-  /** 1 on a sign-in row; the accumulated uses on an aggregate. */
-  count: number;
 }
 
 export interface AuditPage {
@@ -66,7 +61,6 @@ export interface AuditLoginCounts {
   ldap: number;
   oidc: number;
   oauth: number;
-  token: number;
 }
 
 export interface AuditStatColumn {
diff --git a/apps/ui/src/features/admin/audit/AuditList.vue b/apps/ui/src/features/admin/audit/AuditList.vue
index 61123973..2cac5696 100644
--- a/apps/ui/src/features/admin/audit/AuditList.vue
+++ b/apps/ui/src/features/admin/audit/AuditList.vue
@@ -47,8 +47,6 @@ const KIND_LABELS: Record<string, string> = {
   ldap: 'LDAP',
   'break-glass': 'Break-glass',
   sso: 'Single sign-on',
-  'api-token': 'API token',
-  'oauth-token': 'OAuth token',
 };
 /** The chart draws OIDC and OAuth as separate series, so the row names them
  *  apart too. Rows written before the protocol was stored have none, and fall
@@ -64,26 +62,6 @@ const REASON_LABELS: Record<string, string> = {
   zero_group_mappings: 'No group mapped to a role',
 };
 
-/** yyyyMMddHH is a UTC label; render it in the reader's zone like every other
- *  time on the page, rather than showing the raw integer. */
-function hourLabel(bucket: number): string {
-  const hour = bucket % 100;
-  const day = Math.floor(bucket / 100) % 100;
-  const month = Math.floor(bucket / 10_000) % 100;
-  const year = Math.floor(bucket / 1_000_000);
-  const d = new Date(Date.UTC(year, month - 1, day, hour));
-  const z = (n: number) => String(n).padStart(2, '0');
-  return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())} ${z(d.getHours())}:00`;
-}
-
-/** A row that summarises a credential-hour rather than recording one sign-in.
- *  Only token use produces these: Horizon mints a token id per request, so
- *  counting them per credential-hour is what keeps the table growing with
- *  people rather than with traffic. */
-function isAggregate(row: AuditEntry): boolean {
-  return row.hourBucket !== undefined;
-}
-
 function when(ms: number): string {
   return timestampLabel(ms);
 }
@@ -107,14 +85,12 @@ function when(ms: number): string {
             <th>{{ t('Login ID') }}</th>
             <th>{{ t('Result') }}</th>
             <th>{{ t('Client address') }}</th>
-            <th class="list__num">{{ t('Uses') }}</th>
           </tr>
         </thead>
         <tbody>
           <template v-for="row in rows" :key="row.id">
             <tr
               class="list__row"
-              :class="{ 'list__row--agg': isAggregate(row) }"
               tabindex="0"
               role="button"
               :aria-expanded="open === row.id"
@@ -122,13 +98,7 @@ function when(ms: number): string {
               @keydown.enter.prevent="open = open === row.id ? null : row.id"
               @keydown.space.prevent="open = open === row.id ? null : row.id"
             >
-              <td class="list__when">
-                <template v-if="isAggregate(row)">
-                  {{ hourLabel(row.hourBucket as number) }}
-                  <span class="list__tag">{{ t('hourly total') }}</span>
-                </template>
-                <template v-else>{{ when(row.at) }}</template>
-              </td>
+              <td class="list__when">{{ when(row.at) }}</td>
               <td>{{ channel(row) }}</td>
               <!-- Only single sign-on has one; every other channel is Horizon
                    itself, which is not a provider worth naming. -->
@@ -145,10 +115,9 @@ function when(ms: number): string {
               <!-- Blank on an aggregate row, where it means "not applicable"
                    rather than "unknown": a credential-hour has no one address. -->
               <td class="list__ip">{{ row.clientIp ?? '—' }}</td>
-              <td class="list__num">{{ isAggregate(row) ? row.count : '' }}</td>
             </tr>
             <tr v-if="open === row.id" class="list__detail">
-              <td colspan="7">
+              <td colspan="6">
                 <dl class="list__dl">
                   <template v-if="row.mail">
                     <dt>{{ t('Verified email') }}</dt><dd>{{ row.mail }}</dd>
@@ -156,9 +125,6 @@ function when(ms: number): string {
                   <template v-if="row.roles">
                     <dt>{{ t('Roles granted') }}</dt><dd>{{ row.roles }}</dd>
                   </template>
-                  <template v-if="row.hourBucket">
-                    <dt>{{ t('Counted for the hour') }}</dt><dd>{{ hourLabel(row.hourBucket) }}</dd>
-                  </template>
                   <dt>{{ t('Recorded by') }}</dt>
                   <dd>{{ row.horizonNode }}<template v-if="row.horizonIp"> ({{ row.horizonIp }})</template></dd>
                 </dl>
diff --git a/apps/ui/src/features/admin/audit/AuditStatBlock.vue b/apps/ui/src/features/admin/audit/AuditStatBlock.vue
index 75dd6a7e..f3d49b56 100644
--- a/apps/ui/src/features/admin/audit/AuditStatBlock.vue
+++ b/apps/ui/src/features/admin/audit/AuditStatBlock.vue
@@ -56,7 +56,6 @@ const SERIES = [
   { key: 'ldap', label: 'LDAP', token: 'var(--sw-cyan)' },
   { key: 'oidc', label: 'Single sign-on (OIDC)', token: 'var(--sw-purple)' },
   { key: 'oauth', label: 'Single sign-on (OAuth)', token: 'var(--sw-pink)' },
-  { key: 'token', label: 'Tokens', token: 'var(--sw-info)' },
   { key: 'rejected', label: 'Refused', token: 'var(--sw-err)' },
 ] as const;
 
diff --git a/apps/ui/src/features/admin/audit/AuditView.vue b/apps/ui/src/features/admin/audit/AuditView.vue
index 554ad961..b0fd9b5b 100644
--- a/apps/ui/src/features/admin/audit/AuditView.vue
+++ b/apps/ui/src/features/admin/audit/AuditView.vue
@@ -50,8 +50,6 @@ const KINDS: { value: AuditKind; label: string }[] = [
   { value: 'ldap', label: 'LDAP' },
   { value: 'break-glass', label: 'Break-glass' },
   { value: 'sso', label: 'Single sign-on' },
-  { value: 'api-token', label: 'API token' },
-  { value: 'oauth-token', label: 'OAuth token' },
 ];
 
 onMounted(() => void page.refresh());
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index 42497073..a578af66 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -100,7 +100,6 @@
   "API dependency config": "Konfiguration der API-Abhängigkeiten",
   "API dependency feed failed — check the BFF and OAP.": "API-Abhängigkeits-Feed fehlgeschlagen – BFF und OAP prüfen.",
   "API map": "API-Karte",
-  "API token": "API-Token",
   "ARMED": "SCHARF",
   "Accepted": "Zugelassen",
   "Access": "Zugriff",
@@ -384,7 +383,6 @@
   "Couldn't reach the OAP query port. Confirm the OAP host is up and the configured query URL still resolves from this Horizon server.": "Der OAP-Abfrageport konnte nicht erreicht werden. Prüfe, ob der OAP-Host erreichbar ist und die konfigurierte Abfrage-URL von diesem Horizon-Server noch aufgelöst wird.",
   "Couldn’t load the 3D map.": "Die 3D-Karte konnte nicht geladen werden.",
   "Count": "Anzahl",
-  "Counted for the hour": "Gezählt für die Stunde",
   "Create": "Erstellen",
   "Create a new async profiling task": "Neue Async-Profiling-Aufgabe erstellen",
   "Create a new eBPF task": "Neue eBPF-Aufgabe erstellen",
@@ -1084,7 +1082,6 @@
   "OAP's Zipkin v2 endpoint, source for the OpenTelemetry & Zipkin trace menu (shown when a layer's trace source is zipkin or both). This is the only page affected — if it's unreachable, native traces and every other observability page keep working.": "Der Zipkin-v2-Endpunkt von OAP, Quelle für das Trace-Menü von OpenTelemetry und Zipkin (sichtbar, wenn die Trace-Quelle eines Layers zipkin oder both ist). Nur diese Seite ist betroffen — bei Nichterreichbarkeit funktionieren native Traces [...]
   "OAP-stored": "auf OAP gespeichert",
   "OAuth": "OAuth",
-  "OAuth token": "OAuth token",
   "OIDC": "OIDC",
   "OK": "OK",
   "OTel & Zipkin Traces": "OTel- & Zipkin-Traces",
@@ -1776,7 +1773,6 @@
   "Users & access admin": "Benutzer- und Zugriffsverwaltung",
   "Users come from LDAP.": "Benutzer kommen aus LDAP.",
   "Users defined": "Definierte Benutzer",
-  "Uses": "Verwendungen",
   "Value": "Wert",
   "Value comes from the service count (listServices) — no MQE.": "Der Wert stammt aus der Service-Anzahl (listServices) — kein MQE.",
   "Value map (enum → label, color)": "Wertzuordnung (Enum → Label, Farbe)",
@@ -2807,6 +2803,8 @@
   "Query": "Abfragen",
   "Login ID": "Anmelde-ID",
   "Auth Channel": "Auth-Kanal",
-  "hourly total": "Stundensumme",
-  "Estimated, summed across nodes.": "Geschätzt, über alle Knoten summiert."
+  "Estimated, summed across nodes.": "Geschätzt, über alle Knoten summiert.",
+  "API token": "API-Token",
+  "OAuth token": "OAuth token",
+  "Uses": "Verwendungen"
 }
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 17530c62..e56dd774 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -100,7 +100,6 @@
   "API dependency config": "API dependency config",
   "API dependency feed failed — check the BFF and OAP.": "API dependency feed failed — check the BFF and OAP.",
   "API map": "API map",
-  "API token": "API token",
   "ARMED": "ARMED",
   "Accepted": "Accepted",
   "Access": "Access",
@@ -384,7 +383,6 @@
   "Couldn't reach the OAP query port. Confirm the OAP host is up and the configured query URL still resolves from this Horizon server.": "Couldn't reach the OAP query port. Confirm the OAP host is up and the configured query URL still resolves from this Horizon server.",
   "Couldn’t load the 3D map.": "Couldn’t load the 3D map.",
   "Count": "Count",
-  "Counted for the hour": "Counted for the hour",
   "Create": "Create",
   "Create a new async profiling task": "Create a new async profiling task",
   "Create a new eBPF task": "Create a new eBPF task",
@@ -1084,7 +1082,6 @@
   "OAP's Zipkin v2 endpoint, source for the OpenTelemetry & Zipkin trace menu (shown when a layer's trace source is zipkin or both). This is the only page affected — if it's unreachable, native traces and every other observability page keep working.": "OAP's Zipkin v2 endpoint, source for the OpenTelemetry & Zipkin trace menu (shown when a layer's trace source is zipkin or both). This is the only page affected — if it's unreachable, native traces and every other observability page keep w [...]
   "OAP-stored": "OAP-stored",
   "OAuth": "OAuth",
-  "OAuth token": "OAuth token",
   "OIDC": "OIDC",
   "OK": "OK",
   "OTel & Zipkin Traces": "OTel & Zipkin Traces",
@@ -1776,7 +1773,6 @@
   "Users & access admin": "Users & access admin",
   "Users come from LDAP.": "Users come from LDAP.",
   "Users defined": "Users defined",
-  "Uses": "Uses",
   "Value": "Value",
   "Value comes from the service count (listServices) — no MQE.": "Value comes from the service count (listServices) — no MQE.",
   "Value map (enum → label, color)": "Value map (enum → label, color)",
@@ -2807,6 +2803,8 @@
   "Query": "Query",
   "Login ID": "Login ID",
   "Auth Channel": "Auth Channel",
-  "hourly total": "hourly total",
-  "Estimated, summed across nodes.": "Estimated, summed across nodes."
+  "Estimated, summed across nodes.": "Estimated, summed across nodes.",
+  "API token": "API token",
+  "OAuth token": "OAuth token",
+  "Uses": "Uses"
 }
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index b0cc2898..40a97b24 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -100,7 +100,6 @@
   "API dependency config": "Configuración de dependencias de API",
   "API dependency feed failed — check the BFF and OAP.": "Falló la obtención de dependencias de API — revisa el BFF y OAP.",
   "API map": "Mapa de API",
-  "API token": "Token de API",
   "ARMED": "ARMADO",
   "Accepted": "Aceptado",
   "Access": "Acceso",
@@ -384,7 +383,6 @@
   "Couldn't reach the OAP query port. Confirm the OAP host is up and the configured query URL still resolves from this Horizon server.": "No se pudo alcanzar el puerto de consulta de OAP. Confirma que el host de OAP esté arriba y que la URL de consulta configurada siga resolviéndose desde este servidor Horizon.",
   "Couldn’t load the 3D map.": "No se pudo cargar el mapa 3D.",
   "Count": "Cantidad",
-  "Counted for the hour": "Contabilizado en la hora",
   "Create": "Crear",
   "Create a new async profiling task": "Crear una nueva tarea de perfilado asíncrono",
   "Create a new eBPF task": "Crear una nueva tarea eBPF",
@@ -1084,7 +1082,6 @@
   "OAP's Zipkin v2 endpoint, source for the OpenTelemetry & Zipkin trace menu (shown when a layer's trace source is zipkin or both). This is the only page affected — if it's unreachable, native traces and every other observability page keep working.": "Endpoint Zipkin v2 de OAP, fuente del menú de trazas de OpenTelemetry y Zipkin (visible cuando la fuente de trazas de un layer es zipkin o both). Es la única página afectada — si no es accesible, las trazas nativas y todas las demás página [...]
   "OAP-stored": "almacenado en OAP",
   "OAuth": "OAuth",
-  "OAuth token": "OAuth token",
   "OIDC": "OIDC",
   "OK": "OK",
   "OTel & Zipkin Traces": "Trazas OTel y Zipkin",
@@ -1776,7 +1773,6 @@
   "Users & access admin": "Administración de usuarios y accesos",
   "Users come from LDAP.": "Los usuarios provienen de LDAP.",
   "Users defined": "Usuarios definidos",
-  "Uses": "Usos",
   "Value": "Valor",
   "Value comes from the service count (listServices) — no MQE.": "El valor proviene del número de servicios (listServices) — sin MQE.",
   "Value map (enum → label, color)": "Mapa de valores (enum → etiqueta, color)",
@@ -2807,6 +2803,8 @@
   "Query": "Consultar",
   "Login ID": "ID de inicio de sesión",
   "Auth Channel": "Canal de autenticación",
-  "hourly total": "total por hora",
-  "Estimated, summed across nodes.": "Estimado, sumado entre nodos."
+  "Estimated, summed across nodes.": "Estimado, sumado entre nodos.",
+  "API token": "Token de API",
+  "OAuth token": "OAuth token",
+  "Uses": "Usos"
 }
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index a064f393..03420c53 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -100,7 +100,6 @@
   "API dependency config": "Configuration des dépendances d'API",
   "API dependency feed failed — check the BFF and OAP.": "Échec du flux de dépendances d'API — vérifiez le BFF et OAP.",
   "API map": "Carte des API",
-  "API token": "Jeton d'API",
   "ARMED": "ARMÉ",
   "Accepted": "Acceptée",
   "Access": "Accès",
@@ -384,7 +383,6 @@
   "Couldn't reach the OAP query port. Confirm the OAP host is up and the configured query URL still resolves from this Horizon server.": "Impossible d'atteindre le port de requête OAP. Confirmez que l'hôte OAP est en service et que l'URL de requête configurée résout toujours depuis ce serveur Horizon.",
   "Couldn’t load the 3D map.": "Impossible de charger la carte 3D.",
   "Count": "Nombre",
-  "Counted for the hour": "Comptabilisé pour l'heure",
   "Create": "Créer",
   "Create a new async profiling task": "Créer une tâche de profilage asynchrone",
   "Create a new eBPF task": "Créer une tâche eBPF",
@@ -1084,7 +1082,6 @@
   "OAP's Zipkin v2 endpoint, source for the OpenTelemetry & Zipkin trace menu (shown when a layer's trace source is zipkin or both). This is the only page affected — if it's unreachable, native traces and every other observability page keep working.": "Endpoint Zipkin v2 d'OAP, source du menu de traces OpenTelemetry et Zipkin (visible quand la source de traces d'un layer est zipkin ou both). C'est la seule page concernée — s'il est inaccessible, les traces natives et toutes les autres pa [...]
   "OAP-stored": "stocké sur OAP",
   "OAuth": "OAuth",
-  "OAuth token": "OAuth token",
   "OIDC": "OIDC",
   "OK": "OK",
   "OTel & Zipkin Traces": "Traces OTel et Zipkin",
@@ -1776,7 +1773,6 @@
   "Users & access admin": "Administration des utilisateurs et des accès",
   "Users come from LDAP.": "Les utilisateurs proviennent de LDAP.",
   "Users defined": "Utilisateurs définis",
-  "Uses": "Utilisations",
   "Value": "Valeur",
   "Value comes from the service count (listServices) — no MQE.": "La valeur provient du nombre de services (listServices) — pas de MQE.",
   "Value map (enum → label, color)": "Table de valeurs (enum → libellé, couleur)",
@@ -2807,6 +2803,8 @@
   "Query": "Interroger",
   "Login ID": "Identifiant de connexion",
   "Auth Channel": "Canal d'authentification",
-  "hourly total": "total horaire",
-  "Estimated, summed across nodes.": "Estimation, cumulée sur tous les nœuds."
+  "Estimated, summed across nodes.": "Estimation, cumulée sur tous les nœuds.",
+  "API token": "Jeton d'API",
+  "OAuth token": "OAuth token",
+  "Uses": "Utilisations"
 }
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 7f98d09f..9dd2fbf0 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -100,7 +100,6 @@
   "API dependency config": "API 依存関係の設定",
   "API dependency feed failed — check the BFF and OAP.": "API 依存関係の取得に失敗しました — BFF と OAP を確認してください。",
   "API map": "API マップ",
-  "API token": "API トークン",
   "ARMED": "ARMED",
   "Accepted": "許可",
   "Access": "アクセス範囲",
@@ -384,7 +383,6 @@
   "Couldn't reach the OAP query port. Confirm the OAP host is up and the configured query URL still resolves from this Horizon server.": "OAP の query ポートに到達できませんでした。OAP ホストが稼働しており、設定された query URL がこの Horizon サーバーから解決できることを確認してください。",
   "Couldn’t load the 3D map.": "3D マップを読み込めませんでした。",
   "Count": "件数",
-  "Counted for the hour": "集計対象の時間",
   "Create": "作成",
   "Create a new async profiling task": "新しい async プロファイリングタスクを作成",
   "Create a new eBPF task": "新しい eBPF タスクを作成",
@@ -1084,7 +1082,6 @@
   "OAP's Zipkin v2 endpoint, source for the OpenTelemetry & Zipkin trace menu (shown when a layer's trace source is zipkin or both). This is the only page affected — if it's unreachable, native traces and every other observability page keep working.": "OAP の Zipkin v2 エンドポイントで、OpenTelemetry および Zipkin トレース メニューのデータ ソースです(レイヤーの trace ソースが zipkin または both のときに表示)。影響を受けるのはこのページのみで、到達不可でもネイティブ トレースおよび他の可観測ページはそのまま動作します。",
   "OAP-stored": "OAP 保存版",
   "OAuth": "OAuth",
-  "OAuth token": "OAuth token",
   "OIDC": "OIDC",
   "OK": "OK",
   "OTel & Zipkin Traces": "OTel & Zipkin トレース",
@@ -1776,7 +1773,6 @@
   "Users & access admin": "ユーザー / アクセス管理",
   "Users come from LDAP.": "ユーザーは LDAP から取得されます。",
   "Users defined": "定義済みユーザー",
-  "Uses": "使用回数",
   "Value": "値",
   "Value comes from the service count (listServices) — no MQE.": "値はサービス数(listServices)から取得され、MQE は使いません。",
   "Value map (enum → label, color)": "値マップ(enum → ラベル、色)",
@@ -2807,6 +2803,8 @@
   "Query": "検索",
   "Login ID": "ログイン ID",
   "Auth Channel": "認証チャネル",
-  "hourly total": "1 時間の合計",
-  "Estimated, summed across nodes.": "概算値、全ノードの合計です。"
+  "Estimated, summed across nodes.": "概算値、全ノードの合計です。",
+  "API token": "API トークン",
+  "OAuth token": "OAuth token",
+  "Uses": "使用回数"
 }
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index ecfb2f93..1d73d9be 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -100,7 +100,6 @@
   "API dependency config": "API 의존성 설정",
   "API dependency feed failed — check the BFF and OAP.": "API 의존성 피드를 가져오지 못했습니다 — BFF와 OAP를 확인하세요.",
   "API map": "API 맵",
-  "API token": "API 토큰",
   "ARMED": "ARMED",
   "Accepted": "허용됨",
   "Access": "접근 범위",
@@ -384,7 +383,6 @@
   "Couldn't reach the OAP query port. Confirm the OAP host is up and the configured query URL still resolves from this Horizon server.": "OAP 쿼리 포트에 연결할 수 없습니다. OAP 호스트가 작동 중이고 설정된 쿼리 URL이 이 Horizon 서버에서 여전히 해석되는지 확인하세요.",
   "Couldn’t load the 3D map.": "3D 맵을 불러오지 못했습니다.",
   "Count": "개수",
-  "Counted for the hour": "집계 시간",
   "Create": "생성",
   "Create a new async profiling task": "새 async 프로파일링 작업 생성",
   "Create a new eBPF task": "새 eBPF 작업 생성",
@@ -1084,7 +1082,6 @@
   "OAP's Zipkin v2 endpoint, source for the OpenTelemetry & Zipkin trace menu (shown when a layer's trace source is zipkin or both). This is the only page affected — if it's unreachable, native traces and every other observability page keep working.": "OAP의 Zipkin v2 엔드포인트로, OpenTelemetry 및 Zipkin 트레이스 메뉴의 데이터 소스입니다(레이어의 trace 소스가 zipkin 또는 both일 때 표시). 영향을 받는 페이지는 이 페이지뿐이며, 연결 불가 상태여도 네이티브 트레이스와 다른 모든 옵저버빌리티 페이지는 정상 동작합니다.",
   "OAP-stored": "OAP 저장본",
   "OAuth": "OAuth",
-  "OAuth token": "OAuth token",
   "OIDC": "OIDC",
   "OK": "OK",
   "OTel & Zipkin Traces": "OTel & Zipkin 트레이스",
@@ -1776,7 +1773,6 @@
   "Users & access admin": "사용자 및 접근 관리",
   "Users come from LDAP.": "사용자는 LDAP에서 가져옵니다.",
   "Users defined": "정의된 사용자",
-  "Uses": "사용 횟수",
   "Value": "값",
   "Value comes from the service count (listServices) — no MQE.": "값은 서비스 수(listServices)에서 가져오며 MQE를 사용하지 않습니다.",
   "Value map (enum → label, color)": "값 매핑 (enum → 라벨, 색상)",
@@ -2807,6 +2803,8 @@
   "Query": "조회",
   "Login ID": "로그인 ID",
   "Auth Channel": "인증 채널",
-  "hourly total": "시간별 합계",
-  "Estimated, summed across nodes.": "추정치이며 모든 노드의 합계입니다."
+  "Estimated, summed across nodes.": "추정치이며 모든 노드의 합계입니다.",
+  "API token": "API 토큰",
+  "OAuth token": "OAuth token",
+  "Uses": "사용 횟수"
 }
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index 1b4ac3b9..66a9e6d6 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -100,7 +100,6 @@
   "API dependency config": "Configuração de dependências de API",
   "API dependency feed failed — check the BFF and OAP.": "Falha no feed de dependências de API — verifique o BFF e o OAP.",
   "API map": "Mapa de APIs",
-  "API token": "Token de API",
   "ARMED": "ARMADO",
   "Accepted": "Aceito",
   "Access": "Acesso",
@@ -384,7 +383,6 @@
   "Couldn't reach the OAP query port. Confirm the OAP host is up and the configured query URL still resolves from this Horizon server.": "Não foi possível alcançar a porta de query do OAP. Confirme se o host do OAP está no ar e se a URL de query configurada ainda resolve a partir deste servidor Horizon.",
   "Couldn’t load the 3D map.": "Não foi possível carregar o mapa 3D.",
   "Count": "Contagem",
-  "Counted for the hour": "Contabilizado na hora",
   "Create": "Criar",
   "Create a new async profiling task": "Criar uma nova tarefa de profiling assíncrono",
   "Create a new eBPF task": "Criar uma nova tarefa de eBPF",
@@ -1084,7 +1082,6 @@
   "OAP's Zipkin v2 endpoint, source for the OpenTelemetry & Zipkin trace menu (shown when a layer's trace source is zipkin or both). This is the only page affected — if it's unreachable, native traces and every other observability page keep working.": "Endpoint Zipkin v2 do OAP, fonte do menu de traces OpenTelemetry e Zipkin (exibido quando a fonte de traces de um layer é zipkin ou both). É a única página afetada — se ficar inacessível, os traces nativos e todas as outras páginas de obse [...]
   "OAP-stored": "armazenado no OAP",
   "OAuth": "OAuth",
-  "OAuth token": "OAuth token",
   "OIDC": "OIDC",
   "OK": "OK",
   "OTel & Zipkin Traces": "Traces OTel e Zipkin",
@@ -1776,7 +1773,6 @@
   "Users & access admin": "Administração de usuários e acessos",
   "Users come from LDAP.": "Os usuários vêm do LDAP.",
   "Users defined": "Usuários definidos",
-  "Uses": "Usos",
   "Value": "Valor",
   "Value comes from the service count (listServices) — no MQE.": "O valor vem da contagem de serviços (listServices) — sem MQE.",
   "Value map (enum → label, color)": "Mapa de valores (enum → rótulo, cor)",
@@ -2807,6 +2803,8 @@
   "Query": "Consultar",
   "Login ID": "ID de acesso",
   "Auth Channel": "Canal de autenticação",
-  "hourly total": "total por hora",
-  "Estimated, summed across nodes.": "Estimado, somado entre nós."
+  "Estimated, summed across nodes.": "Estimado, somado entre nós.",
+  "API token": "Token de API",
+  "OAuth token": "OAuth token",
+  "Uses": "Usos"
 }
diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json
index dc43837d..f1828282 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -100,7 +100,6 @@
   "API dependency config": "API 依赖配置",
   "API dependency feed failed — check the BFF and OAP.": "API 依赖数据获取失败 — 请检查 BFF 与 OAP。",
   "API map": "API 拓扑",
-  "API token": "API 令牌",
   "ARMED": "ARMED",
   "Accepted": "已通过",
   "Access": "访问范围",
@@ -384,7 +383,6 @@
   "Couldn't reach the OAP query port. Confirm the OAP host is up and the configured query URL still resolves from this Horizon server.": "无法连接 OAP 查询端口。请确认 OAP 主机仍在运行,且当前 Horizon 服务器仍能解析所配置的查询 URL。",
   "Couldn’t load the 3D map.": "无法加载 3D 地图。",
   "Count": "计数",
-  "Counted for the hour": "计入小时",
   "Create": "创建",
   "Create a new async profiling task": "创建新的异步剖析任务",
   "Create a new eBPF task": "创建新的 eBPF 任务",
@@ -1084,7 +1082,6 @@
   "OAP's Zipkin v2 endpoint, source for the OpenTelemetry & Zipkin trace menu (shown when a layer's trace source is zipkin or both). This is the only page affected — if it's unreachable, native traces and every other observability page keep working.": "OAP 的 Zipkin v2 端点,是 OpenTelemetry 与 Zipkin 链路菜单的数据源(当某个 layer 的 trace 源为 zipkin 或 both 时显示)。仅此一处会受影响 —— 即便不可达,原生 trace 以及其他所有可观测页面仍可正常使用。",
   "OAP-stored": "OAP 已存储",
   "OAuth": "OAuth",
-  "OAuth token": "OAuth token",
   "OIDC": "OIDC",
   "OK": "正常",
   "OTel & Zipkin Traces": "OTel & Zipkin Trace",
@@ -1776,7 +1773,6 @@
   "Users & access admin": "用户与访问管理",
   "Users come from LDAP.": "用户来自 LDAP。",
   "Users defined": "已定义用户",
-  "Uses": "使用次数",
   "Value": "值",
   "Value comes from the service count (listServices) — no MQE.": "数值来自服务数量(listServices)—— 无需 MQE。",
   "Value map (enum → label, color)": "取值映射(枚举 → 文案、颜色)",
@@ -2807,6 +2803,8 @@
   "Query": "查询",
   "Login ID": "登录 ID",
   "Auth Channel": "认证渠道",
-  "hourly total": "每小时合计",
-  "Estimated, summed across nodes.": "估算值,为所有节点的合计。"
+  "Estimated, summed across nodes.": "估算值,为所有节点的合计。",
+  "API token": "API 令牌",
+  "OAuth token": "OAuth token",
+  "Uses": "使用次数"
 }
diff --git a/docs/changelog/1.0.0.md b/docs/changelog/1.0.0.md
index 07c1d0a1..9aecfd00 100644
--- a/docs/changelog/1.0.0.md
+++ b/docs/changelog/1.0.0.md
@@ -3,7 +3,7 @@
 ### Login audit (new)
 
 - **Horizon can keep a durable record of who signed in, when, and from where.** Optional and off by default, backed by a shared **Postgres** database rather than a file — which is what makes it a record and not a pile of per-pod fragments. Turn it on with `audit.enabled` and `audit.provider: postgres`; Horizon creates its own tables, keeps rows for `retentionDays` (90 by default) and expires them on its own. The new **Admin → Login audit** page shows an hourly summary stacked by how peop [...]
-- **It records only what a valid credential produced, and that is a security property rather than a scoping decision.** Successful sign-ins (password, LDAP, break-glass, single sign-on), token use counted per hour — by credential for an API token, by account for an OAuth one, since Horizon mints a fresh OAuth token id per request and counting those would grow the table with traffic rather than with people — and exactly two refusals — both of which happen *after* authentication already su [...]
+- **It records only what a valid credential produced, and that is a security property rather than a scoping decision.** Successful sign-ins (password, LDAP, break-glass, single sign-on) and exactly two refusals — both of which happen *after* authentication already succeeded: an account with no roles, and an LDAP account matching no group mapping. A wrong password, an unknown user, an expired token, a mangled SSO callback: those stay in the application log, because they are the ones an an [...]
 - **A database can never delay a sign-in, and cannot stop one.** Sign-ins are buffered in memory and written in the background in batches, so the login path performs no database work at all — an unreachable Postgres is invisible to the person signing in. When it does go down you get one `error` line naming a sanitised cause and one `info` line when it returns saying how long it was out and how much was lost, rather than one line per sign-in; the page shows an explicit "cannot be reached" [...]
 - **`audit:read` is not granted by a wildcard.** A role holding `"*:read"` gets every other read in Horizon and not this one, and neither does the read-only scope an MCP client is granted by default — because the log holds verified email addresses and client addresses, and a read-only integration should not acquire them as a side effect. Grant it by name, or use the administrator role. There is no write permission and no delete: the log is append and query.
 - **`server.trustProxy` is new, and `true` is refused.** Without it Horizon records the address it is talking to, which behind an ingress is the ingress — every row naming the same host. Set a hop count (`1` for a single proxy) or your ingress address/CIDR. `true` is rejected at startup rather than accepted: it trusts the whole `X-Forwarded-For` header, so any caller could choose the address Horizon writes down, and an address column that looks authoritative while being attacker-chosen i [...]
diff --git a/docs/setup/login-audit.md b/docs/setup/login-audit.md
index 37e1efb4..c58bee92 100644
--- a/docs/setup/login-audit.md
+++ b/docs/setup/login-audit.md
@@ -11,7 +11,6 @@ It records sign-ins, not changes. "Who changed what" is not this feature: templa
 So a row exists for:
 
 - every **successful** sign-in — password, LDAP, break-glass, single sign-on;
-- every **use of a token**, counted per hour rather than one row per request — per credential for an API token, and per **person** for an OAuth token (see below);
 - exactly **two refusals**, both of which happen *after* authentication has already succeeded:
   - the account authenticated but has no roles;
   - the LDAP account authenticated but matched no group mapping.
@@ -27,18 +26,17 @@ A refused **password** sign-in is logged at `warn`, which is the shipped product
 | | |
 |---|---|
 | Time | to the second |
-| Auth Channel | password, LDAP, break-glass, single sign-on, API token, OAuth token. Single sign-on also names the protocol that proved the identity — **OIDC** means a signed ID token Horizon verified, **OAuth** means an address read from the provider's userinfo call, which is the weaker of the two |
+| Auth Channel | password, LDAP, break-glass, single sign-on. Single sign-on also names the protocol that proved the identity — **OIDC** means a signed ID token Horizon verified, **OAuth** means an address read from the provider's userinfo call, which is the weaker of the two |
 | Provider | for single sign-on, which provider proved the identity — the id you gave it in `auth.sso.providers` |
-| Login ID | the login name, the verified email address, an API token's id, or — for an OAuth token — the account it was issued for. "Auth Channel" says which |
+| Login ID | the login name, or the verified email address on the single sign-on path. "Auth Channel" says which |
 | Roles | what the sign-in granted, comma-separated. Recorded rather than looked up later, because a role table changes and the question is what this person was given at this moment |
 | Result | accepted, or refused with the reason |
 | Client address | where the sign-in came from (see [Addresses behind a proxy](#addresses-behind-a-proxy)) |
-| Uses | on an hourly-total row only, how many times that credential was used in the hour. Blank on a sign-in, which is always one |
 | Recorded by | which Horizon process wrote the row, and its address |
 
-It records the **sign-in**, and nothing about the systems around it: no OAP state, and no configuration detail of the identity provider beyond which one it was.
+**Token use is not recorded here, and that is the boundary the page draws.** Presenting an API or OAuth token on a request is not a login — nobody signs in — so it produces no row. What a token did is a matter of *traffic*, counted separately, not of who got in and when. A token that is refused leaves a line in the application log like any other refused credential.
 
-**An OAuth token is recorded per person, not per credential**, and that is deliberate rather than an omission. Horizon mints a fresh token identifier on every access-token call, so counting by credential would make the table grow with request volume instead of with the number of people using it — a client that refreshes on every request would produce thousands of rows a day on its own. The row therefore names the account the token was issued for. Telling one OAuth client from another is  [...]
+It records the **sign-in**, and nothing about the systems around it: no OAP state, and no configuration detail of the identity provider beyond which one it was.
 
 **Passwords, tokens, session identifiers and single sign-on exchange material are never recorded.** Neither is the hash a token is verified against. The token *id* is recorded, which names a credential without being one.
 
@@ -113,11 +111,9 @@ The summary is stacked by how people signed in, over the last 2, 6 or 12 hours.
 
 Records refused by the hourly limit are deliberately *not* drawn as a bar. They appear as a note instead when the count is non-zero, because they describe records that were never written, and charting them beside records that were would read as extra volume.
 
-Two other counts are kept but **not shown on the page**: writes whose outcome could not be confirmed, and token uses discarded when a long outage exhausted the in-memory buffers. They are *unconfirmed* rather than known-lost — a write that commits and then times out leaves the row in the database and the count here, and nothing on Horizon's side can tell that from a write that never landed. Both reach the application log rather than the page: the recovery line names how many accumulated  [...]
-
-**Filters** are **Time range**, **Auth Channel** and **Login ID** — the same three the list is headed with. `Login ID` matches **exactly** — it names one principal, so a partial name finds nothing. A token id is shown in full on its row and can be copied from there. There is deliberately nothing else: this page is read, not queried, and every extra predicate is an index to carry and another way to ask the database something slow. The other fields on a row are for reading once you have found it.
+One other count is kept but **not shown on the page**: writes whose outcome could not be confirmed. They are *unconfirmed* rather than known-lost — a write that commits and then times out leaves the row in the database and the count here, and nothing on Horizon's side can tell that from a write that never landed. Both reach the application log rather than the page: the recovery line names how many accumulated during an outage.
 
-**The list holds two shapes of row, and marks them apart.** Most rows are a single sign-in at a single instant. Rows tagged **hourly total** are not: they summarise one credential's use over one hour, and are the only way token traffic is recorded. Such a row shows the hour rather than a timestamp, a count in `Uses`, and no client address — a credential-hour has no single address, so the cell is blank because the question does not apply, not because the answer is unknown.
+**Filters** are **Time range**, **Auth Channel** and **Login ID** — the same three the list is headed with. `Login ID` matches **exactly** — it names one principal, so a partial name finds nothing. There is deliberately nothing else: this page is read, not queried, and every extra predicate is an index to carry and another way to ask the database something slow. The other fields on a row are for reading once you have found it.
 
 The list pages 50 at a time and reports only whether there is more, never a total. Selecting a row expands the investigation fields.
 
diff --git a/test/e2e/playwright/specs/ui/audit.spec.ts b/test/e2e/playwright/specs/ui/audit.spec.ts
index 89a646b1..04428931 100644
--- a/test/e2e/playwright/specs/ui/audit.spec.ts
+++ b/test/e2e/playwright/specs/ui/audit.spec.ts
@@ -16,7 +16,7 @@
  */
 
 import { test, expect, request as playwrightRequest } from '@playwright/test';
-import { E2E_TOKEN, E2E_TOKEN_ID, E2E_USER } from '../fixture.js';
+import { E2E_USER } from '../fixture.js';
 
 /**
  * The login audit log, end to end: a real sign-in and a real token use become
@@ -117,47 +117,6 @@ test.describe('login audit', () => {
     await expect(page.getByText(/no sign-ins recorded in this window/i)).toHaveCount(0);
   });
 
-  /**
-   * Token use is COUNTED, not written per request: many uses collapse into one
-   * cumulative row per credential per hour. Nothing else in the suite exercises
-   * that path, and it is the one the store's upsert serves — a statement that
-   * was invalid SQL for an entire development phase while every unit test
-   * passed.
-   */
-  test('counts API-token use into one aggregated row', async ({ page, baseURL }) => {
-    // A context with NO storage state, so the token is the only credential
-    // presented. The shared `request` fixture carries the signed-in session
-    // cookie, which would satisfy the route by itself and prove nothing.
-    //
-    // And an AUTHENTICATED route: `/api/health` is public, so it never reaches
-    // the middleware that resolves a bearer, and no use would be counted.
-    const api = await anonymousApi(baseURL);
-    const uses = 4;
-    for (let i = 0; i < uses; i += 1) {
-      const res = await api.get('/api/menu', {
-        headers: { Authorization: `Bearer ${E2E_TOKEN}` },
-      });
-      expect(
-        res.status(),
-        `the fixture token must be accepted on an authenticated route; body: ${(await res.text()).slice(0, 200)}`,
-      ).toBe(200);
-    }
-    await api.dispose();
-
-    await page.goto(AUDIT_URL);
-    await expectRecording(page);
-    // The token is recorded by its ID — the credential presented — never by
-    // the user it names.
-    await reloadUntil(page, `tbody tr:has-text("${E2E_TOKEN_ID}")`, 'the token aggregate');
-    const row = page.locator('tbody tr', { hasText: E2E_TOKEN_ID }).first();
-
-    // The count column proves aggregation rather than one row per request:
-    // `uses` requests must appear as a single row carrying a number, and the
-    // list must not have grown by `uses` rows.
-    await expect(row).toContainText(new RegExp(`\\b[${uses}-9]\\b`));
-    await expect(page.locator('tbody tr', { hasText: E2E_TOKEN_ID })).toHaveCount(1);
-  });
-
   test('does not record a refused credential', async ({ page, baseURL }) => {
     // An unauthenticated caller must not be able to put a row in the table —
     // the property the whole valid-credential-only rule exists for.