0044 — The CI factory keeps one event store, filled by one collector, read by one app

The problem

The CI factory is read from three pages on docs.propflowai.co (/a/ci-factory, /a/ci-factory-floor, the history strip) fed by four R2 objects (factory.json from a collector that runs on Fede's machine, mq.json, mq-health.json, flow.json + history.json from three collectors on the mini). Every object is a total already computed by whichever collector wrote it: a pass rate, a bucket count, a 24-hour headline row. The same run is counted by mq-health (its verdict), by flow-baseline (its class) and by factory.json (its station), under three vocabularies that do not reconcile; a question the pages did not anticipate ("how many of Tuesday's reds were one culprit?", "what did the queue cost per merged PR last week?") cannot be asked of a total, only of the events, and the events were thrown away once the total was written. Each page also carries its own freshness rule (20 min, 45 min, 13 h) and its own way of showing a dead collector, which ADR-0041 had to patch for one of them. And every collector that wanted a new number re-read GitHub for it, which is what ADR-0042 had to move onto the App's budget.

The decision

  1. One app replaces the pages. factory.propflowai.co (Cloudflare Pages, repo PropFlow-Technologies/factory, its own deploy pipeline and review gate) replaces /a/ci-factory and /a/ci-factory-floor. Access: the Cloudflare Access app "CI Factory", Google IdP only, auto-redirect, @propflowai.co emails, 730 h sessions (the docs site's Access app has no IdP filter; ours does, by Gera's choice).

  2. One store of events, never totals. A D1 database factory: runs, run_class (revisable: the class with a classified_at stamp; no row = not yet classified), jobs, failures (facts, bucket, own/inherited), prs + pr_events, main_commits, deployments, cost_days + fleet_samples, efforts, and collections. Every number a page shows is computed from rows at read time, so a new question is a new query, not a new collector. Writes are upserts on the natural key that never blank a known value (COALESCE(excluded.c, c)), so re-running an import is idempotent and sources compose; a class is the one exception, replaced because a newer pass's verdict supersedes the older one.

  3. One collector, on the mini, as the App. collector/factory_collect.py in the factory repo does every read and every write (--target d1:<uuid> over the D1 HTTP API with the token the mini's R2 writers already hold; sqlite: for dev and the controls; sql: for wrangler d1 execute --file). It runs on the mini because the sources are there (the health cache, the flow cache, the App's key) and it reads GitHub as propflow-agent-smith on the App's budget (ADR-0042). The mini side is only a shim, ~/.local/bin/factory-collect in gera-propflow/local-bin: lock, 15-minute launchd schedule, the two dedicated runtime worktrees forced to origin/main and refused unless clean (the ci-factory-flow pattern), the identity, the budget read, the log line. It owns no data and no definition.

  4. Classification is reused, not re-implemented. The run classes have one owner, propflowai scripts/ci-flow/flow-baseline.ts; the collector's classify pass runs that script over a rolling 30-hour window and imports its per-run array. The why-it-went-red buckets have one owner, ci-factory-mq-health; import-health imports the installed module's bucket_of. Job timings come from the flow script's check-run cache; failure facts and vCPU minutes from health-cache.json, already on disk. A number computed twice under two definitions is the defect this store exists to end.

  5. A missed collection is a collections row, never a zero. Every collector command writes one collections row, ok or not (kind, window, rows, calls, auth, controls, error, duration). The app reads the rows' shape to say "no reading since 14:20" and never renders an absence as a clean zero (ADR-0009, the two-mechanism rule of ADR-0041: the written record for a collector that came back, the catalog signal for one still down). The shim's ok line is written only when the child exited 0 and printed its own ok line for that command (sweep ok auth=… rows=N …), and the shim passes the identity it resolved as FACTORY_AUTH_ID, so a pass on the user's fallback budget is stored as fallback-user, never as app.

  6. Old pages stay until covered. ci-factory-mq, -health and -flow keep writing R2 and the docs pages keep reading it until factory.propflowai.co covers that page's questions; each page is retired by its own PR, with the control that the app's numbers match the page's over the same window (slice 1's control: the flow window 10-05 19:38Z..10-06 19:38Z, 936 runs, classes 281/267/241/74/34/20/8/5, matched exactly in local SQLite).

  7. The interim spend source is Fede's factory.json, read from R2. It is the only source of station counts and spend today; the sweep reads it as an input (one more event source) rather than asking Fede to change his collector first. When the store has its own cost rows the read is dropped.

Why this shape

Relation to other ADRs

Build

Part Repo What State
A PropFlow-Technologies/factory scaffold: app shell, API skeleton (/api/health 503 without a database), deploy pipeline, review gate, Access app factory#1 merged 2026-10-06; live
B PropFlow-Technologies/factory migrations/0001_init.sql (the store) + collector/factory_collect.py (import-flow, import-health, import-jobs, check; sweep and classify to follow) + 9 controls factory#2 open
B gera-propflow/local-bin factory-collect shim + launchd/co.propflow.factory-collect.plist (not loaded) + 23 controls local-bin#448 open
B agentflow this ADR; SYSTEMS.md factory-collect entry (proposed until the first sweep ok) this PR
C — D1 create + bind, migrations, backfill since 2026-09-30T22:40Z (the queue's first run), totals check against today's pages waits on Gera: Account · D1 · Edit on the deploy token

Known limits at close: the D1 database does not exist yet, so nothing has been written to it and the shim is inert by design; sweep and classify are the two collector commands the shim calls and are the driver's next commits in factory#2; Fede's factory.json collector runs on his machine and this store reads it rather than replacing it, until the cost rows exist.