# Task 1818 — Widen the loop gate to cover `app/**` outside `app/lib/`

**Date:** 2026-07-20
**Lane:** Platform / admin UI · activity view · scheduled work visibility
**Depends on:** 1810 (landed), 1814 (landed — the widened gate and its marker convention)

## Summary

`scripts/check-loops-registered.mjs` walks `server/**` and `app/lib/**`. The rest of
`app/**` is unscanned, so a standing server loop armed there would not be caught and
would be invisible on `/activity`, which is the omission mode 1810 and 1814 exist to
close.

This task widens the scan to the whole of `app/**` and marks the fifteen browser timers
already there `loop-exempt:client`.

## Premise corrections

The task file's approach section rests on three factual claims about the tree. All three
are wrong, and the first one voids one of its two candidate approaches outright. They are
recorded here because the task's stated reasoning cannot be followed as written.

### 1. There is no Next.js

The task describes the unscanned area as "Next.js route and component code" and names
`app/api/*/route.ts` as the shape of the loop that would escape.

`platform/ui` has no `next` dependency and no `next.config`. It is a Vite SPA with ten
HTML entries (`vite.config.ts`) plus a Hono server bundled by tsup from exactly two
entries, `server/index.ts` and `server/edge.ts` (`tsup.config.ts`). There is no `app/api/`
directory and no route file under `app/` at all. Server routes live in `server/routes/`,
which the gate already scans.

The task's second candidate — "treat `app/api/**` and any other server-executing subtree
as in scope while leaving component directories out" — therefore describes a layout that
cannot exist in this package. It is void rather than merely less attractive. The task
states that establishing "whichever the Next.js routing layout actually makes stable" is
the first work of this task; the answer is that there is no such layout, and the decision
collapses to the remaining candidate.

### 2. `'use client'` is unusable, but not for the stated reason

The task rules out the `'use client'` directive as a discriminator on the grounds that
"the 1814 census confirmed `app/lib/use-now-tick.ts` is a React hook carrying no such
directive".

`app/lib/use-now-tick.ts` carries `'use client'` on line 14, and carried it at 1814's own
commit (`git show main:…` returns the directive).

The conclusion survives on different evidence. Of the eleven files under `app/` outside
`app/lib/` that arm a browser timer, three carry no directive: `app/chat/page.tsx`,
`app/chat/transcript-render.tsx`, and `app/graph/settle-lock.ts`. A discriminator that
misses three of eleven cannot gate a build.

### 3. Fifteen sites, not sixteen

The task and its LANES row both count sixteen browser timers in the unscanned area.

Applying the gate's own `ARM` regex (`/\bsetInterval\s*\(/`) to `app/**` outside
`app/lib/`, skipping `__tests__`, yields **fifteen** sites across eleven files. The
difference is that eight further occurrences are `ReturnType<typeof setInterval>` type
positions, which have no `(` after the identifier and do not match. The sixteenth timer is
`app/lib/use-now-tick.ts`, which lives inside a scanned root and already carries its
marker.

## What the census found

Two findings beyond the count, both bearing on the design.

**No hidden standing loop.** Every one of the fifteen sites is genuinely a browser timer.
The two least obvious were checked directly: `app/graph/settle-lock.ts` samples
`network.getPositions()` to freeze vis-network canvas physics and is imported only by
`app/graph/page.tsx`; `app/chat/transcript-render.tsx` is a React render module ticking
`setNow(Date.now())`. This is the opposite of 1814, whose widening surfaced
`visitor-event.ts`, a standing loop that had been invisible the whole time. Here the
widening surfaces nothing: every new site resolves to a marker, and no `registerLoop` call
is added, so the registry's 26 production loops are unchanged.

**Server-reachable code outside `app/lib/` already exists.** A census of every
`from '…/app/…'` import in `server/` returns `app/lib/…` in every case but one:
`server/routes/whatsapp-reader.ts:43` imports `SUPPORTED_MIME_SET`, `MAX_FILE_BYTES` and
`MAX_ATTACHMENTS` from `app/admin-types.ts`. Those are runtime values, not types, so this
is genuinely server-executing code living outside the scanned tree today. It arms no
timer, so nothing is currently hidden, but the boundary "server code stops at `app/lib/`"
is already false and the design must not depend on it. No dynamic `import()` or
`require()` in `server/` reaches `app/`, and no file under `app/` outside `app/lib/`
imports a `node:` builtin.

## Approach

Scan the whole of `app/**`. Mark the fifteen browser timers `loop-exempt:client`.

Two alternatives were considered and rejected.

**Reachability from the two server entry points.** The gate could resolve the import graph
from `server/index.ts` and `server/edge.ts` and scan exactly what it reaches. This is the
most precise statement of the real semantic. It is rejected because it puts a module
resolver inside a gate whose own header records that hand-rolled parsing is what killed
its first version: that version stripped `//` comments, ate the closing backtick of every
template literal containing a URL, and reported `ok` on a real violation. The gate's
governing principle is to match raw lines and parse nothing.

**Close 1818 as void.** Since there is no route-file surface under `app/` in a Vite SPA, a
server loop arguably has nowhere there to hide. This is rejected because it rests on
"everything under `app/` is client today", which is the assumption the task exists to stop
relying on, and which `app/admin-types.ts` already partially falsifies.

## Design

### The scan root

`SCAN_ROOTS` becomes `['server', 'app']`, replacing `['server', 'app/lib']`.

`app/lib` is subsumed rather than listed alongside `app`. Listing both would walk `app/lib`
twice and double its contribution to `files=` and `sites=`, corrupting the exact counter
the task names as its evidence that the widening took effect.

### The fifteen markers

Each site gets `// loop-exempt:client <reason>` on its own line, with the reason specific
to that timer rather than a repeated phrase. `client` is already in the closed category set
established by 1814 (`per-connection`, `demand-singleton`, `client`, `registry-primitive`),
so no convention changes. The task puts the marker convention and category set explicitly
out of scope.

The sites:

| File | Line | What it ticks |
|---|---|---|
| `app/AdminShell.tsx` | 228 | 60s poll |
| `app/Sidebar.tsx` | 512, 534, 1609 | channel conversations, sessions, system stats |
| `app/activity/page.tsx` | 428 | 15s activity reload |
| `app/chat/page.tsx` | 700, 721 | pointer poll, 60s read |
| `app/chat/transcript-render.tsx` | 80 | 1s relative-time clock |
| `app/graph/page.tsx` | 1456 | layout poll |
| `app/graph/settle-lock.ts` | 137 | vis-network motion sampler |
| `app/hooks/useViewMode.ts` | 110 | view-mode poll |
| `app/hooks/useVoiceRecorder.ts` | 142 | recording elapsed timer |
| `app/useAdminAuth.ts` | 219 | auth refresh |
| `app/useElapsedTimer.ts` | 29, 35 | session tick, elapsed tick |

### The header

The gate's SCOPE block currently reads "Covers `server/**` and `app/lib/**` … NOT covered:
the rest of `app/**`, which is Next.js route and component code running in the visitor's
browser". Every clause of that becomes false or was already false.

It is rewritten to state the real boundary (`server/**` and `app/**`, excluding
`__tests__`), that the server bundles from two entries so `server/**` is where server code
starts, and that `app/admin-types.ts` is already server-reachable from outside `app/lib/`
— which is why the whole of `app/` is scanned rather than a subtree of it. A gate whose
header overclaims is the defect class this file exists to prevent, so the header moves in
the same change as the behaviour.

### Tests

The behaviour belongs in the project's own committed suite at
`scripts/__tests__/check-loops-registered.test.ts`, so these are committed rather than
ephemeral.

One existing test **encodes the hole this task closes**. `ignores files outside the two
scan roots` (line 87) proves its point with `app/chat/page.tsx`, asserting exit 0 and
`sites=0`. That file moves in-scope, so the test must be rewritten against a path that is
genuinely outside both roots, not merely extended around.

New cases:

- An unmarked module-scope `setInterval` in a component file under `app/` outside
  `app/lib/` fails with exit 1 and the message naming the file.
- A marked `client` site in that same area passes and is counted under `client=`.

The real-tree floor assertions rise so a walk that silently stops reading shows as
collapsed counts rather than a healthy `ok`.

### Mutation check

Required by the task on 1814's terms. A real unmarked `setInterval` is inserted into an
actual file under `app/` outside `app/lib/`, the gate is observed exiting 1 and naming
that file, and the line is removed. A negative test that passes because the signal is dead
is the failure this whole line of work exists to remove, and a synthetic fixture under
`--root` cannot prove the real scan root moved.

## Observability

`files=` rises from **284** to **393**: 109 non-test source files under `app/` outside
`app/lib/`. The predicted figure is asserted rather than "the number went up", so a partial
walk fails instead of passing.

`sites=` rises from 10 to 25, `marked=` from 9 to 24, and `client=` from 1 to 16. No
`registerLoop` call is added, because the census found no standing loop in the newly
covered area. That pairing — coverage widens, registered loop count holds at 26 — is the
finding, not an absence of one.

## Scope boundaries

**In scope:** the scan boundary for `app/**` outside `app/lib/`, the marking that decision
implies, the gate's SCOPE header, and the test surface above.

**Out of scope:** the marker convention and category set, settled in 1814; the two scan
roots already covered; the gate's raw-line matching, whose two known holes are documented
in its header and are accepted rather than open.

**Answered, not deferred: `app/admin-types.ts` stays where it is.** Being server-reachable
from outside `app/lib/` reads at first like a filing error worth correcting. It is not.
That file exists precisely so it cannot import `app/lib/attachments.ts` — its own comment
records the reason ("cannot import a Node.js module into a client component"), and
`attachments.ts` opens with `node:crypto`, `node:fs/promises` and `node:fs`. `app/lib/` is
the Node-touching tree: 56 of its 191 non-test source files import a `node:` builtin.
Moving the deliberately client-safe mirror into that directory would file it beside the
module it exists to avoid. Separately, the move would buy this task's outcome nothing,
because the widened scan covers the file wherever it lives. So there is no structural
question left open here and no follow-up task to file.

**A real adjacent risk, named and not fixed here.** `ACCEPT_MIME` / `SUPPORTED_MIME_SET` in
`app/admin-types.ts` mirror `app/lib/attachments.ts` by hand, with nothing keeping the two
in sync. That is a mime-allowlist consistency problem with no bearing on loop coverage, and
it is not touched by this task.
