0044 — The CI factory keeps one event store, filled by one collector, read by one app
- Status: Accepted; slice 1 in flight. Decided by Gera 2026-10-06 on the plan at docs.propflowai.co/a/ci-factory-app-plan-2026-10. The app shell is live at factory.propflowai.co (PropFlow-Technologies/factory#1, behind Cloudflare Access, Google only, propflowai.co emails), answering
503 no database yetas designed; the store and collector (factory#2) and the mini-side shim (gera-propflow/local-bin#448) are open; the D1 database waits on a token permission only Gera can grant. The Build table below is the record of what has landed. - Date: 2026-10-06
- Deciders: Gera (the plan: one app, one store, the mini collects as the App, old pages stay until covered). The shapes below were chosen by the driver and the worker that built them, per ADR-0004.
- Systems:
factory-collect(new),ci-factory-mq(and its siblingsci-factory-mq-health,ci-factory-flow: they keep running until covered),gh-app-token.
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
One app replaces the pages. factory.propflowai.co (Cloudflare Pages, repo PropFlow-Technologies/factory, its own deploy pipeline and review gate) replaces
/a/ci-factoryand/a/ci-factory-floor. Access: the Cloudflare Access app "CI Factory", Google IdP only, auto-redirect,@propflowai.coemails, 730 h sessions (the docs site's Access app has no IdP filter; ours does, by Gera's choice).One store of events, never totals. A D1 database
factory:runs,run_class(revisable: the class with aclassified_atstamp; no row = not yet classified),jobs,failures(facts, bucket, own/inherited),prs+pr_events,main_commits,deployments,cost_days+fleet_samples,efforts, andcollections. 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.One collector, on the mini, as the App.
collector/factory_collect.pyin 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:forwrangler 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 aspropflow-agent-smithon the App's budget (ADR-0042). The mini side is only a shim,~/.local/bin/factory-collectin gera-propflow/local-bin: lock, 15-minute launchd schedule, the two dedicated runtime worktrees forced to origin/main and refused unless clean (theci-factory-flowpattern), the identity, the budget read, the log line. It owns no data and no definition.Classification is reused, not re-implemented. The run classes have one owner, propflowai
scripts/ci-flow/flow-baseline.ts; the collector'sclassifypass 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-healthimports the installed module'sbucket_of. Job timings come from the flow script's check-run cache; failure facts and vCPU minutes fromhealth-cache.json, already on disk. A number computed twice under two definitions is the defect this store exists to end.A missed collection is a
collectionsrow, never a zero. Every collector command writes onecollectionsrow, 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 asFACTORY_AUTH_ID, so a pass on the user's fallback budget is stored asfallback-user, never asapp.Old pages stay until covered.
ci-factory-mq,-healthand-flowkeep 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 window10-05 19:38Z..10-06 19:38Z, 936 runs, classes 281/267/241/74/34/20/8/5, matched exactly in local SQLite).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
- Events, not totals, because the questions change faster than the collectors. Every page so far needed a new object; the store needs a new query.
- One collector because budgets, locks and logs multiply per collector. ADR-0042 moved three collectors onto the App; a fourth that re-read the same runs would be the same bill twice. The one read is written once and classified once.
- Reuse because the definitions already have owners and controls. The flow script's classes and the health pass's buckets each have a test suite and a history; a copy would drift from both.
- A new repo because the pipeline is its own. The app deploys on its own merge, gates on its own review, and is not a page in the docs site's build.
- A shim in local-bin because that is where the mini's scheduled readers live and are watched:
lib/gh_auth,fleet-controlsover its tests, the puller that deploys it.
Relation to other ADRs
- ADR-0009: the
collectionsrow and the shim's ok-only-after-the-child's-ok-line rule are the failure signals; SYSTEMS.md watchessweep ok auth=app(1800 s) andclassify ok auth=app(2700 s). - ADR-0041: the history object it added is the first total this store supersedes; its gap-row rule becomes the
collectionstable. - ADR-0042: the collector is one more reader on the App;
auth=on every ok line and in everycollectionsrow. - ADR-0004: this changes how the CI pages, the collectors and GitHub relate (one store between them instead of four objects), so it is written here at decision time.
- ADR-0003: the app learns of a dead collector from the rows' shape and the catalog from the log, not by calling anything.
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.