# Data portal standing audit — implementation plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Arm a standing per-account reconcile that reveals an orphan R2 object, a phantom manifest row, or a stalled ingestion backlog — three conditions that today emit nothing at all.

**Architecture:** A pure set-difference in `lib/storage-broker/src/audit.ts` beside its two siblings; a caller in `ui/server/routes/storage-broker.ts` beside `runStorageAudit`, which reads each side under its own `catch` so neither failure can skip the log line; a `registerLoop` arming in `ui/server/index.ts`.

**Tech Stack:** TypeScript, vitest (ui server), node:test (storage-broker lib), Hono, the loop registry (Task 1810).

## Global Constraints

- The log line carries **counts only**, never object keys. Keys are `<ownerId>/<filename>` — real people's filenames.
- The audit is armed through `registerLoop`. A bare `setInterval` fails `scripts/check-loops-registered.mjs` at build time.
- A `d1Query` payload that does not parse **throws**. It never degrades to zero rows: zero rows against a populated bucket fabricates `orphanObjects=<everything>`.
- An account whose `data-portal.json` is absent, unparseable, or names no `portalDbName` and `bucketName` is **silent**.
- Node 22 for every vitest run in this worktree (`nvm use 22`).

---

### Task 1: The reconcile arithmetic

**Files:**
- Modify: `maxy-code/platform/lib/storage-broker/src/audit.ts` (append after `reconcilePages`)
- Test: `maxy-code/platform/lib/storage-broker/src/__tests__/audit.test.ts` (append)

**Interfaces:**
- Consumes: nothing.
- Produces: `reconcileDataPortal(objects, rows, nowMs): DataPortalReconcile`, exported through `lib/storage-broker/src/index.js`.

```ts
export interface DataPortalObject { key: string }
export interface DataPortalRow { objectKey: string; ingested: number; uploadedAt: string }
export interface DataPortalReconcile {
  objects: number
  rows: number
  orphanObjects: string[]
  phantomRows: string[]
  oldestUningestedHrs: number | null
}
```

- [ ] **Step 1: Write the failing tests**

Append to `__tests__/audit.test.ts` (this suite is `node:test`, matching the file's existing style):

```ts
test("reconcileDataPortal counts an object with no row as an orphan", () => {
  const r = reconcileDataPortal([{ key: "alice/a.pdf" }], [], 0);
  assert.deepEqual(r.orphanObjects, ["alice/a.pdf"]);
  assert.deepEqual(r.phantomRows, []);
  assert.equal(r.objects, 1);
  assert.equal(r.rows, 0);
});

test("reconcileDataPortal counts a row with no object as a phantom", () => {
  const r = reconcileDataPortal([], [row("alice/a.pdf", 1, "2026-07-20T00:00:00Z")], 0);
  assert.deepEqual(r.phantomRows, ["alice/a.pdf"]);
  assert.deepEqual(r.orphanObjects, []);
});

test("reconcileDataPortal counts a matched pair as neither", () => {
  const r = reconcileDataPortal(
    [{ key: "alice/a.pdf" }],
    [row("alice/a.pdf", 1, "2026-07-20T00:00:00Z")],
    0,
  );
  assert.deepEqual(r.orphanObjects, []);
  assert.deepEqual(r.phantomRows, []);
});

test("oldestUningestedHrs comes from the oldest ingested=0 row", () => {
  const now = Date.parse("2026-07-20T12:00:00Z");
  const r = reconcileDataPortal(
    [],
    [row("a", 0, "2026-07-20T09:00:00Z"), row("b", 0, "2026-07-20T11:00:00Z")],
    now,
  );
  assert.equal(r.oldestUningestedHrs, 3);
});

test("an ingested row is excluded from the backlog age", () => {
  const now = Date.parse("2026-07-20T12:00:00Z");
  const r = reconcileDataPortal(
    [],
    [row("a", 1, "2026-07-01T00:00:00Z"), row("b", 0, "2026-07-20T11:00:00Z")],
    now,
  );
  assert.equal(r.oldestUningestedHrs, 1);
});

test("oldestUningestedHrs is null when nothing is uningested", () => {
  const r = reconcileDataPortal([], [row("a", 1, "2026-07-20T00:00:00Z")], 0);
  assert.equal(r.oldestUningestedHrs, null);
});

test("an unparseable uploadedAt never yields NaN", () => {
  const now = Date.parse("2026-07-20T12:00:00Z");
  const only = reconcileDataPortal([], [row("a", 0, "not-a-date")], now);
  assert.equal(only.oldestUningestedHrs, null);
  const mixed = reconcileDataPortal(
    [],
    [row("a", 0, "not-a-date"), row("b", 0, "2026-07-20T11:00:00Z")],
    now,
  );
  assert.equal(mixed.oldestUningestedHrs, 1);
});
```

with this helper at the top of the appended block:

```ts
const row = (objectKey: string, ingested: number, uploadedAt: string) => ({
  objectKey,
  ingested,
  uploadedAt,
});
```

and `reconcileDataPortal` added to the file's existing import from `../audit.js`.

- [ ] **Step 2: Run the tests to verify they fail**

Run: `cd maxy-code/platform/lib/storage-broker && npx tsc -p tsconfig.json && node --test 'dist/__tests__/audit.test.js'`
Expected: FAIL — `reconcileDataPortal` is not exported.

- [ ] **Step 3: Implement**

Append to `audit.ts`:

```ts
// --- Data portal reconcile (Task 1704) -------------------------------------
//
// Third instance of the same no-event argument, one level down from the two
// above: these reconcile ownership records, this reconciles the objects
// themselves against the manifest rows that make them ingestible.
//
//   orphan object — an object in R2 with no manifest row. The upload returned
//                   200, the file is stored, and ingestion will never see it.
//   phantom row   — a manifest row whose object is absent. Ingestion will pull
//                   a missing file, and nothing says so until it runs.
//
// Keys are returned rather than only counted so the arithmetic is testable and
// a future caller has them, but the standing audit logs COUNTS ONLY: a key here
// is `<ownerId>/<filename>`, a real person's filename, and the brand journal is
// not where those belong. That is the same ownership discipline the in-process
// path owes for holding the account-wide credential and bypassing objectGate.

export interface DataPortalObject {
  key: string;
}

export interface DataPortalRow {
  objectKey: string;
  ingested: number;
  uploadedAt: string;
}

export interface DataPortalReconcile {
  objects: number;
  rows: number;
  orphanObjects: string[];
  phantomRows: string[];
  /** Null when nothing is uningested, or when no uningested row carries a
   *  parseable timestamp. Rendered `na` by the caller. */
  oldestUningestedHrs: number | null;
}

/** Pure over two lists and an injected clock, so the age arithmetic is
 *  deterministic under test rather than dependent on when the suite runs. */
export function reconcileDataPortal(
  objects: DataPortalObject[],
  rows: DataPortalRow[],
  nowMs: number,
): DataPortalReconcile {
  const objectKeys = new Set(objects.map((o) => o.key));
  const rowKeys = new Set(rows.map((r) => r.objectKey));

  // Date.parse of a truthy non-ISO value is NaN, which slips past a `??` guard
  // and renders as `oldestUningestedHrs=NaN` — the defect the availability
  // loop's snapshotAgeMs already had to fix. Unparseable rows are excluded from
  // the minimum; if that leaves none, the answer is null, never NaN.
  const uningestedMs = rows
    .filter((r) => r.ingested === 0)
    .map((r) => Date.parse(r.uploadedAt))
    .filter((ms) => !Number.isNaN(ms));

  return {
    objects: objectKeys.size,
    rows: rowKeys.size,
    orphanObjects: [...objectKeys].filter((k) => !rowKeys.has(k)),
    phantomRows: [...rowKeys].filter((k) => !objectKeys.has(k)),
    oldestUningestedHrs:
      uningestedMs.length === 0 ? null : (nowMs - Math.min(...uningestedMs)) / 3_600_000,
  };
}
```

- [ ] **Step 4: Run the tests to verify they pass**

Run: `cd maxy-code/platform/lib/storage-broker && npx tsc -p tsconfig.json && node --test 'dist/__tests__/audit.test.js'`
Expected: PASS, all cases.

- [ ] **Step 5: Mutation check**

Temporarily delete the `phantomRows` line's filter (return `[]`). Re-run: the phantom test must fail and the orphan test must still pass. Restore.

- [ ] **Step 6: Commit**

```bash
git add maxy-code/platform/lib/storage-broker/src/audit.ts maxy-code/platform/lib/storage-broker/src/__tests__/audit.test.ts
git commit -m "feat(data-portal): reconcile objects against manifest rows both ways"
```

---

### Task 2: The wrangler envelope parse

**Files:**
- Modify: `maxy-code/platform/ui/server/routes/storage-broker.ts` (append near `runStorageAudit`)
- Test: `maxy-code/platform/ui/server/__tests__/data-portal-audit.test.ts` (create)

**Interfaces:**
- Consumes: `DataPortalRow` from Task 1.
- Produces: `parseManifestRows(raw: unknown): DataPortalRow[]`, exported from the route module.

- [ ] **Step 1: Write the failing tests**

Create `data-portal-audit.test.ts`:

```ts
// @vitest-environment node
//
// Task 1704 — the standing data-portal audit.
//
// The property under test is not "it prints a line". It is that NO data-source
// failure can stop it printing one: a D1 throw, an R2 throw, and a malformed
// wrangler payload each still produce a line naming the degradation. An audit a
// failed read can silence is the same blind spot one layer up, in a feature
// that exists because failures emit nothing (.tasks/backlog/1351).

import { describe, it, expect, vi } from 'vitest'

vi.mock('../../app/lib/neo4j-store', () => ({
  getSession: () => ({ run: vi.fn(), close: vi.fn() }),
}))

const { parseManifestRows } = await import('../routes/storage-broker')

describe('parseManifestRows', () => {
  it('reads rows out of the wrangler --json envelope', () => {
    const raw = [{ results: [{ objectKey: 'a/x.pdf', ingested: 0, uploadedAt: '2026-07-20T00:00:00Z' }] }]
    expect(parseManifestRows(raw)).toEqual([
      { objectKey: 'a/x.pdf', ingested: 0, uploadedAt: '2026-07-20T00:00:00Z' },
    ])
  })

  it('reads an empty result set as no rows', () => {
    expect(parseManifestRows([{ results: [] }])).toEqual([])
  })

  it('throws on an envelope it does not recognise', () => {
    expect(() => parseManifestRows({ nope: true })).toThrow(/unexpected d1 payload/)
    expect(() => parseManifestRows(null)).toThrow(/unexpected d1 payload/)
    expect(() => parseManifestRows([{}])).toThrow(/unexpected d1 payload/)
  })

  it('throws on a row missing the fields the reconcile needs', () => {
    expect(() => parseManifestRows([{ results: [{ objectKey: 'a' }] }])).toThrow(/unexpected d1 row/)
  })
})
```

- [ ] **Step 2: Run to verify it fails**

Run: `cd maxy-code/platform/ui && npx vitest run server/__tests__/data-portal-audit.test.ts`
Expected: FAIL — `parseManifestRows` is not exported.

- [ ] **Step 3: Implement**

Append to `storage-broker.ts`, above where `runDataPortalAudit` will go:

```ts
// `d1Query` shells `wrangler d1 execute --json` and is typed `unknown`, so the
// shape is asserted here rather than assumed.
//
// It THROWS rather than returning [] on anything unrecognised, and that is the
// load-bearing half. An empty manifest against a populated bucket reconciles to
// `orphanObjects=<every object>` — a fabricated defect count read off a failed
// parse, which is exactly what reconcilePages refuses to print off a failed
// read. A throw lands in the d1-degraded branch, where the line says the read
// failed instead of asserting a number nobody measured.
export function parseManifestRows(raw: unknown): DataPortalRow[] {
  const envelope = Array.isArray(raw) ? raw[0] : null
  const results = (envelope as { results?: unknown } | null)?.results
  if (!Array.isArray(results)) {
    throw new Error(`unexpected d1 payload: ${JSON.stringify(raw)?.slice(0, 200)}`)
  }
  return results.map((r) => {
    const row = r as Partial<DataPortalRow>
    if (
      typeof row.objectKey !== 'string' ||
      typeof row.ingested !== 'number' ||
      typeof row.uploadedAt !== 'string'
    ) {
      throw new Error(`unexpected d1 row: ${JSON.stringify(r)?.slice(0, 200)}`)
    }
    return { objectKey: row.objectKey, ingested: row.ingested, uploadedAt: row.uploadedAt }
  })
}
```

Add `reconcileDataPortal` and `type DataPortalRow` to the existing import from `'../../../lib/storage-broker/src/index.js'`.

- [ ] **Step 4: Run to verify it passes**

Run: `cd maxy-code/platform/ui && npx vitest run server/__tests__/data-portal-audit.test.ts`
Expected: PASS, 4 tests.

- [ ] **Step 5: Commit**

```bash
git add maxy-code/platform/ui/server/routes/storage-broker.ts maxy-code/platform/ui/server/__tests__/data-portal-audit.test.ts
git commit -m "feat(data-portal): parse the d1 envelope, throwing rather than reading empty"
```

---

### Task 3: The audit run

**Files:**
- Modify: `maxy-code/platform/ui/server/routes/storage-broker.ts`
- Test: `maxy-code/platform/ui/server/__tests__/data-portal-audit.test.ts`

**Interfaces:**
- Consumes: `parseManifestRows` (Task 2), `reconcileDataPortal` (Task 1).
- Produces: `runDataPortalAudit(root: string): Promise<string>` — the resolved string is the loop's operator-visible note.

- [ ] **Step 1: Write the failing tests**

Extend the mock block at the top of `data-portal-audit.test.ts` so `makeHouseCfExec` is stubbed and its construction is observable, then append the cases. Full added content:

```ts
const cfExecConstructed = vi.fn()
let cfStub: { d1Query: (n: string, s: string) => Promise<unknown>; r2ObjectList: (b: string) => Promise<Array<{ key: string }>> }

vi.mock('../../../lib/storage-broker/src/index.js', async (importOriginal) => {
  const actual = await importOriginal<typeof import('../../../lib/storage-broker/src/index.js')>()
  return {
    ...actual,
    makeHouseCfExec: async (...args: unknown[]) => {
      cfExecConstructed(...args)
      return cfStub
    },
  }
})
```

and, after the `parseManifestRows` describe block:

```ts
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

const { runDataPortalAudit } = await import('../routes/storage-broker')

/** Builds a root whose sibling `data/accounts` carries the given configs, which
 *  is the layout runDataPortalAudit resolves (`root/../data/accounts`). */
function makeRoot(accounts: Record<string, string | null>): string {
  const base = mkdtempSync(join(tmpdir(), 'portal-audit-'))
  const root = join(base, 'platform')
  mkdirSync(root)
  for (const [id, config] of Object.entries(accounts)) {
    const dir = join(base, 'data', 'accounts', id)
    mkdirSync(dir, { recursive: true })
    if (config !== null) writeFileSync(join(dir, 'data-portal.json'), config)
  }
  return root
}

const PORTAL = JSON.stringify({ portalDbName: 'acc-a-portal', bucketName: 'acc-a-bucket' })

describe('runDataPortalAudit', () => {
  beforeEach(() => {
    cfExecConstructed.mockClear()
    logged.length = 0
  })

  const logged: string[] = []
  beforeEach(() => {
    vi.spyOn(console, 'error').mockImplementation((m: unknown) => { logged.push(String(m)) })
  })

  it('emits one line per portal account and stays silent for the rest', async () => {
    cfStub = {
      d1Query: async () => [{ results: [{ objectKey: 'alice/a.pdf', ingested: 0, uploadedAt: new Date(Date.now() - 7_200_000).toISOString() }] }],
      r2ObjectList: async () => [{ key: 'alice/a.pdf' }],
    }
    const root = makeRoot({ 'acc-a': PORTAL, 'acc-b': null, 'acc-c': '{"nothing":1}' })
    const note = await runDataPortalAudit(root)
    const lines = logged.filter((l) => l.startsWith('[data-portal-audit]'))
    expect(lines).toHaveLength(1)
    expect(lines[0]).toContain('account=acc-a objects=1 rows=1 orphanObjects=0 phantomRows=0')
    expect(lines[0]).toMatch(/oldestUningestedHrs=2(\.\d+)?/)
    expect(note).toBe('portals=1 degraded=0')
  })

  it('never mints a house token when no account has a portal', async () => {
    const root = makeRoot({ 'acc-b': null })
    const note = await runDataPortalAudit(root)
    expect(cfExecConstructed).not.toHaveBeenCalled()
    expect(note).toBe('portals=0 degraded=0')
  })

  it('still emits a line when the D1 read throws, carrying what R2 measured', async () => {
    cfStub = {
      d1Query: async () => { throw new Error('d1 boom') },
      r2ObjectList: async () => [{ key: 'alice/a.pdf' }],
    }
    const note = await runDataPortalAudit(makeRoot({ 'acc-a': PORTAL }))
    const line = logged.find((l) => l.startsWith('[data-portal-audit]')) ?? ''
    expect(line).toContain('objects=1 rows=na orphanObjects=na phantomRows=na oldestUningestedHrs=na')
    expect(line).toContain('degraded=d1')
    expect(note).toBe('portals=1 degraded=1')
  })

  it('still emits a line when the R2 read throws, keeping the backlog age', async () => {
    cfStub = {
      d1Query: async () => [{ results: [{ objectKey: 'alice/a.pdf', ingested: 0, uploadedAt: new Date(Date.now() - 3_600_000).toISOString() }] }],
      r2ObjectList: async () => { throw new Error('r2 boom') },
    }
    await runDataPortalAudit(makeRoot({ 'acc-a': PORTAL }))
    const line = logged.find((l) => l.startsWith('[data-portal-audit]')) ?? ''
    expect(line).toContain('objects=na rows=1 orphanObjects=na phantomRows=na')
    expect(line).toMatch(/oldestUningestedHrs=1(\.\d+)?/)
    expect(line).toContain('degraded=r2')
  })

  it('degrades on a malformed D1 payload rather than reporting every object an orphan', async () => {
    cfStub = {
      d1Query: async () => ({ unexpected: true }),
      r2ObjectList: async () => [{ key: 'alice/a.pdf' }, { key: 'bob/b.pdf' }],
    }
    await runDataPortalAudit(makeRoot({ 'acc-a': PORTAL }))
    const line = logged.find((l) => l.startsWith('[data-portal-audit]')) ?? ''
    expect(line).toContain('orphanObjects=na')
    expect(line).not.toContain('orphanObjects=2')
    expect(line).toContain('degraded=d1')
  })

  it('reports both sides down', async () => {
    cfStub = {
      d1Query: async () => { throw new Error('d1 boom') },
      r2ObjectList: async () => { throw new Error('r2 boom') },
    }
    await runDataPortalAudit(makeRoot({ 'acc-a': PORTAL }))
    const line = logged.find((l) => l.startsWith('[data-portal-audit]')) ?? ''
    expect(line).toContain('objects=na rows=na')
    expect(line).toContain('degraded=d1+r2')
  })
})
```

Add `beforeEach` to the vitest import.

- [ ] **Step 2: Run to verify it fails**

Run: `cd maxy-code/platform/ui && npx vitest run server/__tests__/data-portal-audit.test.ts`
Expected: FAIL — `runDataPortalAudit` is not exported.

- [ ] **Step 3: Implement**

Append to `storage-broker.ts`:

```ts
// ---------------------------------------------------------------------------
// The data portal standing audit (Task 1704). Its own function rather than an
// arm of runStorageAudit, for the same reason runPagesAudit is: folding them in
// would make one Cloudflare failure hide the other's line.
//
// WHERE THE LOG LINE SITS IS THE POINT. .tasks/backlog/1351 records that
// [calendar-reconcile]'s audit sits after its D1 read, so a d1Query throw skips
// the audit and the one line that would have shown a growing backlog never
// prints. In a feature that exists because failures emit nothing, an audit a
// data-source failure can silence is the same blind spot one layer up. So each
// side is read under its own catch and the line is emitted unconditionally
// afterwards — it is downstream of neither read.
//
// LIST-ONLY, DELIBERATELY. r2ObjectList moves no bytes, so the 100 MiB object
// cap (1695) does not apply. It also drains every page rather than stopping
// early, and this audit is the reason that rule exists: a truncated listing
// reads as "those objects are absent", manufacturing false phantomRows while
// hiding real orphanObjects. Task 1697's r2ObjectFind is the cheaper primitive
// for sizing ONE object and is wrong here, because reconciling needs the
// complete set on both sides. If this ever calls r2ObjectGet it must
// re-implement the byte cap and the fail-closed-on-unusable-size rule, because
// R2Object.size is built by type assertion and is whatever Cloudflare sent.
// ---------------------------------------------------------------------------

interface PortalAccount {
  accountId: string
  portalDbName: string
  bucketName: string
}

/** An account with no portal is not a fault and must not produce a line. Absent,
 *  unparseable, and present-but-naming-nothing are all silent — the same rule
 *  the availability loop applies to a config with no bookingDbName. */
function discoverPortalAccounts(accountsDir: string): PortalAccount[] {
  if (!existsSync(accountsDir)) return []
  const found: PortalAccount[] = []
  for (const accountId of readdirSync(accountsDir)) {
    const p = join(accountsDir, accountId, 'data-portal.json')
    if (!existsSync(p)) continue
    try {
      const cfg = JSON.parse(readFileSync(p, 'utf-8')) as { portalDbName?: string; bucketName?: string }
      if (typeof cfg.portalDbName === 'string' && cfg.portalDbName.length > 0 &&
          typeof cfg.bucketName === 'string' && cfg.bucketName.length > 0) {
        found.push({ accountId, portalDbName: cfg.portalDbName, bucketName: cfg.bucketName })
      }
    } catch {
      /* unparseable config — no portal, same as absent */
    }
  }
  return found
}

export async function runDataPortalAudit(root: string): Promise<string> {
  const portals = discoverPortalAccounts(resolve(root, '..', 'data', 'accounts'))
  // Returns BEFORE minting a house token when nothing has a portal. Most
  // installs have neither a data portal nor a house credential, and minting
  // unconditionally would make this loop report an error on every one of them
  // forever — noise that would train an operator to ignore the tag.
  if (portals.length === 0) return 'portals=0 degraded=0'

  const cf = await makeHouseCfExec(root)
  let degraded = 0

  for (const portal of portals) {
    let rows: DataPortalRow[] | null = null
    let d1Error: string | null = null
    try {
      rows = parseManifestRows(
        await cf.d1Query(portal.portalDbName, 'SELECT objectKey, ingested, uploadedAt FROM manifest'),
      )
    } catch (err) {
      d1Error = (err as Error).message
    }

    let objects: Array<{ key: string }> | null = null
    let r2Error: string | null = null
    try {
      objects = await cf.r2ObjectList(portal.bucketName)
    } catch (err) {
      r2Error = (err as Error).message
    }

    // Each field is whatever remains measurable. The backlog age survives an R2
    // failure because it is computable from the manifest alone — and of the
    // three conditions this audit exists for, a stalled ingestion is the one a
    // half-broken audit can still report.
    const both = rows !== null && objects !== null
    const r = both ? reconcileDataPortal(objects!, rows!, Date.now()) : null
    const age = rows === null
      ? 'na'
      : (reconcileDataPortal([], rows, Date.now()).oldestUningestedHrs?.toFixed(1) ?? 'na')

    const degradation = [d1Error !== null ? 'd1' : null, r2Error !== null ? 'r2' : null]
      .filter(Boolean)
      .join('+')
    if (degradation) degraded += 1

    console.error(
      `[data-portal-audit] account=${portal.accountId} ` +
        `objects=${objects?.length ?? 'na'} rows=${rows?.length ?? 'na'} ` +
        `orphanObjects=${r?.orphanObjects.length ?? 'na'} phantomRows=${r?.phantomRows.length ?? 'na'} ` +
        `oldestUningestedHrs=${age}` +
        (degradation ? ` degraded=${degradation} err="${d1Error ?? r2Error}"` : ''),
    )
  }

  return `portals=${portals.length} degraded=${degraded}`
}
```

- [ ] **Step 4: Run to verify it passes**

Run: `cd maxy-code/platform/ui && npx vitest run server/__tests__/data-portal-audit.test.ts`
Expected: PASS, 10 tests.

- [ ] **Step 5: Commit**

```bash
git add maxy-code/platform/ui/server/routes/storage-broker.ts maxy-code/platform/ui/server/__tests__/data-portal-audit.test.ts
git commit -m "feat(data-portal): audit each portal account, degrading rather than falling silent"
```

---

### Task 4: Arm it, and correct the doc

**Files:**
- Modify: `maxy-code/platform/ui/server/index.ts` (import at line 58; arm beside the storage/pages audit block)
- Modify: `maxy-code/.docs/data-portal.md` (§ Observability)

**Interfaces:**
- Consumes: `runDataPortalAudit` (Task 3).
- Produces: a `data-portal-audit` row on /activity.

- [ ] **Step 1: Arm the loop**

Widen the existing import:

```ts
import storageBrokerRoutes, { runStorageAudit, runPagesAudit, runDataPortalAudit } from './routes/storage-broker'
```

and append inside the same block that arms `storage-audit` and `pages-audit`:

```ts
  // Data portal reconcile (Task 1704). Same loop shape and rationale as the two
  // above; separate callback so one Cloudflare failure cannot suppress another
  // audit's line. First run offset past storage (20s), pages (35s) and
  // google-account (45s) so a boot does not fire four token mints at once.
  //
  // A DEGRADED RUN IS NOT AN ERROR. runDataPortalAudit catches each account's
  // D1 and R2 failures itself and reports them in the line, so it resolves with
  // a note and the registry records ok — the audit did the job it could and
  // said so. What reaches this catch is a genuine break: no house credential
  // where a portal exists, an unreachable registry. That rethrows, so /activity
  // shows a fresh Last run against a stale Last OK.
  const runDataPortalAuditSafe = () =>
    runDataPortalAudit(auditPlatformRoot).catch((err) => {
      console.error(`[data-portal-audit] error="${(err as Error).message}"`)
      throw err
    })
  registerLoop({
    name: 'data-portal-audit',
    intervalMs: STORAGE_AUDIT_INTERVAL_MS,
    firstRunDelayMs: 50_000,
    run: runDataPortalAuditSafe,
  })
```

- [ ] **Step 2: Verify the loop gate and the types**

Run: `cd maxy-code/platform/ui && node scripts/check-loops-registered.mjs && npx tsc --noEmit -p tsconfig.json`
Expected: gate passes, 0 type errors.

- [ ] **Step 3: Correct § Observability**

In `.docs/data-portal.md`, replace the paragraph beginning "The standing `[data-portal-audit]` … is **not built yet**" with the built behaviour: what the line carries, that counts are deliberate and keys are not logged, what each degradation still reports, and that absence of the tag means the audit is not armed and all three conditions are unobservable.

- [ ] **Step 4: Run the full storage and audit surface**

Run: `cd maxy-code/platform/ui && npx vitest run server/__tests__/data-portal-audit.test.ts server/__tests__/storage-broker-object-routes.test.ts server/__tests__/storage-audit-session-concurrency.test.ts`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add maxy-code/platform/ui/server/index.ts maxy-code/.docs/data-portal.md
git commit -m "feat(data-portal): arm the standing audit and correct the doc"
```

---

## Self-review

- **Spec coverage.** Reconcile → Task 1. Envelope parse → Task 2. Caller, discovery, degradation, counts-only line → Task 3. Arming, degraded-is-not-error, doc → Task 4. Mutation check → Task 1 Step 5.
- **Types.** `DataPortalRow` is defined in Task 1 and consumed by name in Tasks 2 and 3. `runDataPortalAudit` returns `Promise<string>` in Task 3 and is consumed as a note-returning run in Task 4.
- **No placeholders.** Every code step carries its code.
