HOMELAB-2479: feat(monitoring): factory layer — PR opened-to-merged as a stored series #846

Merged
pi-landing merged 2 commits from plane/HOMELAB-2479-factory-pr-series into live 2026-11-05 18:23:21 +00:00
Collaborator

What this is

Gap 3 of the "correct SREs metrics" workstream (epic HOMELAB-2479): how long work
takes from PR opened to merged, per repo, stored as a trend
, and how many
projects are landing in parallel
.

Before this, the fleet could not answer either question from stored data. Forgejo
retains created_at/merged_at on every PR indefinitely, so the raw material
existed and nothing turned it into a series; ci_health_* measures CI duration
per repo
, which is a different measurement and must not be reported as
time-to-merged (working-files/infra-stability/fleet-measurement-map.md §9, §12
blind spot 3, §13).

What ships

Object Path Deploys via
Emitter (30 min CronJob) core/manifests/monitoring/factory/ needs a new ArgoCD app — see "Wiring"
Rules: 8 recording + 2 alerts core/manifests/monitoring/rules/factory-alerts.yaml existing monitoring-rules
Dashboard factory, 13 panels core/manifests/monitoring/dashboards/factory.yaml existing monitoring-dashboards
8 promtool cases + 15 unit tests rule-tests/factory.test.yaml, factory/factory_series_test.py run by hand

The emitter walks the Forgejo PR API for the same six repos the existing ci-health
credential is scoped to and pushes the per-PR facts to Prometheus through its
remote-write receiver using promtool push metrics. No new credential: it
reads ci-health-forgejo-token, the secret prod's ci-health exporter already
uses (OpenBao ci/forgejo-ci-health). No new Deployment, Service, ServiceMonitor
or scrape config.

The design point worth reviewing: the ci-health deployment docstring says the
cluster has no pushgateway "so a CronJob's output needs a sink". That is no longer
true — the live Prometheus runs --web.enable-remote-write-receiver (verified
2026-11-05T15:13Z against the statefulset args) and /bin/promtool 3.4.0 exists
in the exact image digest Prometheus itself runs (verified by read-only exec).
That is what makes the CronJob the smallest complete shape here instead of a
second long-lived exporter. Caveat, stated not buried: upstream labels
promtool push metrics "for testing purpose only". What keeps that honest is that
a failed push withholds the dead-man gauge, so ForgejoFactorySeriesStale pages —
silence never looks like data. If gapless series are required, the fallback is to
move the walk into the ci-health exporter (same repo list, same credential, +1
request per repo per hour); the README says so explicitly.

Answers, measured today (2026-11-05T15:19Z, not a target)

  • Parallel projects: yes. 6 of 6 watched repos landed PRs in the last 7 days —
    424 merges, 54 still open (re-read 2026-11-05T16:48Z; the 422/55 here were
    80 minutes stale).
  • Time-to-merged p50: infra-core 39.6 min · prod 54.6 min · ghostty-in-the-shell
    64.8 min · pi-extensions 81.6 min · lab-director 21.0 h · api-sendrast 36.2 h. A
    ~55x spread across projects. p90 up to 59 h; max 98.5 h.
    The estimator is named because it decides the number for small n: these are
    Prometheus quantile() values, which interpolate between the two middle samples.
    An earlier revision of this description quoted 38.1 h and 40.1 h for the two
    even-n repos — an upper-middle-element (median_high) median. The shipped rule
    computes the interpolated value, so these are the numbers to compare a dashboard
    to. Re-read 2026-11-05T16:48Z.
  • No service level is invented here. No target has been agreed, and inventing
    one would be the guessing this workstream exists to end.

Sampling and cost (measured, not estimated)

30 min cycle, 7 d window, page bound 6 (deployed value; an earlier revision of
this description said 6 while cronjob.yaml shipped 4 — corrected). 19 requests,
100 s per cycle
→ 38
requests/hour, ~16 % on top of the ~240 requests/hour the ci-health exporter
already makes with the same token. 974 series (~47 k samples/day); per-PR
series exist only for PRs inside the window.

Two measured decisions that a reviewer should sanity-check: the window is 7 d and
not 14 d because 14 d truncated infra-core and prod at a sane page bound (the
counts were lower bounds and said so), and the closed-PR walk uses
sort=recentclose because the default recentupdate returns unordered pages —
page 1 of infra-core held a PR closed 2026-03-20 beside one closed 2026-11-05 — so
no window boundary can be detected and a time-windowed walk must walk everything.

Alerts: two, neither fires on the number

  • ForgejoFactorySeriesStale (warning, keep-page):
    absent(forgejo_factory_last_success_timestamp_seconds > time() - 3600). One
    expression covers stopped pushing and never existed; a bare time-delta
    misses the second, which is the silent-forever case.
  • ForgejoFactoryWalkTruncated (info, keep-record): the aggregates are a lower
    bound for that repo.

The trend questions are dashboard-only by design — no value of "parallel
projects" or "median time-to-merged" is a fault, and paging on a median movement
trains the fleet to mute the alert (HOMELAB-2271). Full ld_* disposition
contracts on both rules.

Corrections after review 1890 (head 9e8823c7)

Review 1890 refused this PR on three blockers, all one root cause: the emitter
pushes once every 30 minutes and every consumer read the series with an instant
selector
, which Prometheus only sees for its 5-minute lookback-delta. Fixed, in
one commit on top of the reviewed head:

  • G3-B2 — every recording rule and dashboard panel that reads a pushed series
    now uses last_over_time(...[65m]) (two push intervals + 5 min of slack). Semantic
    windows are unchanged: a median over 7 days is still a median over 7 days.
  • G3-B1 — ForgejoFactoryWalkTruncated kept, and made fireable at the real
    cadence by moving the 2 h into the expression over pushed samples
    (min_over_time(...[2h]) == 1 and count_over_time(...[2h]) >= 4) and dropping
    for:. Its promtool case now samples at interval: 30m; the pre-fix expression
    fails it.
  • G3-B3 — ForgejoFactorySeriesStale is now range-based with a stated margin in
    cycles: 3 cycles before pending, for: 1 more, ~4 cycles (2 h) to page. One or two
    skipped cycles cannot page.
  • Concerns: deployed FACTORY_MAX_PAGES 4 → 6 (the bound the docs claim; two repos
    sat exactly on 4), stale cronjob.yaml comments fixed, p50 numbers corrected and
    the estimator named
    (above), and the emitter's ArgoCD Application now ships as
    Homelab/prod#1132 — see Wiring.

Wiring — what merging this PR does NOT do

monitoring-rules and monitoring-dashboards already point at the paths above,
so the rules and dashboard go live on merge. The emitter does not deploy from THIS repo: nothing here can point ArgoCD at
core/manifests/monitoring/factory, and cluster-config only includes
core/manifests/cluster-*.yaml (verified against prod/apps/cluster-config.yaml).
That Application now ships as Homelab/prod#1132
(prod/apps/monitoring-factory.yaml), so merging the pair deploys the emitter. Land
them in the same window: this PR first leaves the stale alert paging after ~2 h
(correctly — the series really is absent), prod#1132 first leaves the app reporting
path does not exist until this lands. Neither loses data.

I am flagging this rather than shipping something that looks finished and is
silent.

Verification already performed (all reproducible)

  • check_alert_contracts.py --base origin/live → ok, changed=2; annotation
    key charset → ok (repo-wide, 117 rule name-surfaces).
  • promtool check rules → 10 rules valid. promtool test rules → 8/8 SUCCESS,
    with negative controls (a healthy sample never alerts; a clean walk never fires
    truncation) and a positive control for the never-existed case.
  • Sabotage check: dropping > 0 from count(forgejo_prs_merged_7d > 0) turns
    the suite red, so the tests assert the filter rather than the rule name.
  • factory_series_test.py → 15/15.
  • Emitter run end to end against the real API, read-only → 974 series, 0 repo
    failures, 0 truncated walks; promtool check metrics silent.
  • yamllint -d relaxed → exit 0 (warnings only, same as the existing rule files).

What a verifier should check independently

Listed in core/manifests/monitoring/factory/README.md §"What a verifier should
check", six falsifiable steps. The one that matters most: recompute the median of
merged_at - created_at for one repo straight from the API and compare it to
forgejo:pr_merged_duration_p50:7d for that repo — that is the check that the
emitter is not lying about the number.

## What this is Gap 3 of the "correct SREs metrics" workstream (epic HOMELAB-2479): **how long work takes from PR opened to merged, per repo, stored as a trend**, and **how many projects are landing in parallel**. Before this, the fleet could not answer either question from stored data. Forgejo retains `created_at`/`merged_at` on every PR indefinitely, so the raw material existed and nothing turned it into a series; `ci_health_*` measures **CI duration per repo**, which is a different measurement and must not be reported as time-to-merged (`working-files/infra-stability/fleet-measurement-map.md` §9, §12 blind spot 3, §13). ## What ships | Object | Path | Deploys via | |---|---|---| | Emitter (30 min CronJob) | `core/manifests/monitoring/factory/` | **needs a new ArgoCD app — see "Wiring"** | | Rules: 8 recording + 2 alerts | `core/manifests/monitoring/rules/factory-alerts.yaml` | existing `monitoring-rules` | | Dashboard `factory`, 13 panels | `core/manifests/monitoring/dashboards/factory.yaml` | existing `monitoring-dashboards` | | 8 promtool cases + 15 unit tests | `rule-tests/factory.test.yaml`, `factory/factory_series_test.py` | run by hand | The emitter walks the Forgejo PR API for the same six repos the existing ci-health credential is scoped to and pushes the per-PR facts to Prometheus through its remote-write receiver using `promtool push metrics`. **No new credential**: it reads `ci-health-forgejo-token`, the secret prod's ci-health exporter already uses (OpenBao `ci/forgejo-ci-health`). No new Deployment, Service, ServiceMonitor or scrape config. **The design point worth reviewing:** the ci-health deployment docstring says the cluster has no pushgateway "so a CronJob's output needs a sink". That is no longer true — the live Prometheus runs `--web.enable-remote-write-receiver` (verified 2026-11-05T15:13Z against the statefulset args) and `/bin/promtool` 3.4.0 exists in the exact image digest Prometheus itself runs (verified by read-only exec). That is what makes the CronJob the smallest complete shape here instead of a second long-lived exporter. **Caveat, stated not buried:** upstream labels `promtool push metrics` "for testing purpose only". What keeps that honest is that a failed push withholds the dead-man gauge, so `ForgejoFactorySeriesStale` pages — silence never looks like data. If gapless series are required, the fallback is to move the walk into the ci-health exporter (same repo list, same credential, +1 request per repo per hour); the README says so explicitly. ## Answers, measured today (2026-11-05T15:19Z, not a target) * **Parallel projects: yes.** 6 of 6 watched repos landed PRs in the last 7 days — **424 merges**, 54 still open (re-read 2026-11-05T16:48Z; the 422/55 here were 80 minutes stale). * **Time-to-merged p50:** infra-core 39.6 min · prod 54.6 min · ghostty-in-the-shell 64.8 min · pi-extensions 81.6 min · lab-director 21.0 h · api-sendrast 36.2 h. A **~55x** spread across projects. p90 up to 59 h; max 98.5 h. **The estimator is named because it decides the number for small n:** these are Prometheus `quantile()` values, which interpolate between the two middle samples. An earlier revision of this description quoted 38.1 h and 40.1 h for the two even-n repos — an upper-middle-element (median_high) median. The shipped rule computes the interpolated value, so these are the numbers to compare a dashboard to. Re-read 2026-11-05T16:48Z. * **No service level is invented here.** No target has been agreed, and inventing one would be the guessing this workstream exists to end. ## Sampling and cost (measured, not estimated) 30 min cycle, 7 d window, page bound 6 (deployed value; an earlier revision of this description said 6 while `cronjob.yaml` shipped 4 — corrected). **19 requests, 100 s per cycle** → 38 requests/hour, ~16 % on top of the ~240 requests/hour the ci-health exporter already makes with the same token. **974 series** (~47 k samples/day); per-PR series exist only for PRs inside the window. Two measured decisions that a reviewer should sanity-check: the window is 7 d and not 14 d because **14 d truncated** infra-core and prod at a sane page bound (the counts were lower bounds and said so), and the closed-PR walk uses `sort=recentclose` because the default `recentupdate` returns unordered pages — page 1 of infra-core held a PR closed 2026-03-20 beside one closed 2026-11-05 — so no window boundary can be detected and a time-windowed walk must walk everything. ## Alerts: two, neither fires on the number * `ForgejoFactorySeriesStale` (warning, keep-page): `absent(forgejo_factory_last_success_timestamp_seconds > time() - 3600)`. One expression covers *stopped pushing* and *never existed*; a bare time-delta misses the second, which is the silent-forever case. * `ForgejoFactoryWalkTruncated` (info, keep-record): the aggregates are a lower bound for that repo. The **trend questions are dashboard-only by design** — no value of "parallel projects" or "median time-to-merged" is a fault, and paging on a median movement trains the fleet to mute the alert (HOMELAB-2271). Full `ld_*` disposition contracts on both rules. ## Corrections after review 1890 (head 9e8823c7) Review 1890 refused this PR on three blockers, all one root cause: **the emitter pushes once every 30 minutes and every consumer read the series with an instant selector**, which Prometheus only sees for its 5-minute `lookback-delta`. Fixed, in one commit on top of the reviewed head: * **G3-B2** — every recording rule and dashboard panel that reads a pushed series now uses `last_over_time(...[65m])` (two push intervals + 5 min of slack). Semantic windows are unchanged: a median over 7 days is still a median over 7 days. * **G3-B1** — `ForgejoFactoryWalkTruncated` kept, and made fireable at the real cadence by moving the 2 h into the expression over pushed samples (`min_over_time(...[2h]) == 1 and count_over_time(...[2h]) >= 4`) and dropping `for:`. Its promtool case now samples at `interval: 30m`; the pre-fix expression fails it. * **G3-B3** — `ForgejoFactorySeriesStale` is now range-based with a stated margin in cycles: 3 cycles before pending, `for:` 1 more, ~4 cycles (2 h) to page. One or two skipped cycles cannot page. * Concerns: deployed `FACTORY_MAX_PAGES` 4 → 6 (the bound the docs claim; two repos sat exactly on 4), stale `cronjob.yaml` comments fixed, p50 numbers corrected **and the estimator named** (above), and the emitter's ArgoCD Application now ships as **Homelab/prod#1132** — see *Wiring*. ## Wiring — what merging this PR does NOT do `monitoring-rules` and `monitoring-dashboards` already point at the paths above, so the rules and dashboard go live on merge. **The emitter does not deploy from THIS repo**: nothing here can point ArgoCD at `core/manifests/monitoring/factory`, and `cluster-config` only includes `core/manifests/cluster-*.yaml` (verified against `prod/apps/cluster-config.yaml`). That Application now ships as **Homelab/prod#1132** (`prod/apps/monitoring-factory.yaml`), so merging the pair deploys the emitter. Land them in the same window: this PR first leaves the stale alert paging after ~2 h (correctly — the series really is absent), prod#1132 first leaves the app reporting `path does not exist` until this lands. Neither loses data. I am flagging this rather than shipping something that looks finished and is silent. ## Verification already performed (all reproducible) * `check_alert_contracts.py --base origin/live` → **ok**, changed=2; annotation key charset → **ok** (repo-wide, 117 rule name-surfaces). * `promtool check rules` → 10 rules valid. `promtool test rules` → **8/8 SUCCESS**, with negative controls (a healthy sample never alerts; a clean walk never fires truncation) and a positive control for the never-existed case. * **Sabotage check:** dropping `> 0` from `count(forgejo_prs_merged_7d > 0)` turns the suite **red**, so the tests assert the filter rather than the rule name. * `factory_series_test.py` → **15/15**. * Emitter run end to end against the real API, read-only → 974 series, 0 repo failures, 0 truncated walks; `promtool check metrics` silent. * `yamllint -d relaxed` → exit 0 (warnings only, same as the existing rule files). ## What a verifier should check independently Listed in `core/manifests/monitoring/factory/README.md` §"What a verifier should check", six falsifiable steps. The one that matters most: recompute the median of `merged_at - created_at` for one repo straight from the API and compare it to `forgejo:pr_merged_duration_p50:7d` for that repo — that is the check that the emitter is not lying about the number.
HOMELAB-2479: feat(monitoring): factory layer — PR opened-to-merged as a stored series
All checks were successful
0/0 projects applied successfully.
CI Review / receiver-test (pull_request) Successful in 8s
CI Review / runner-manifest-test (pull_request) Successful in 8s
CI Review / helm-validate (pull_request) Successful in 11s
Alert Disposition Contract / alert-contract (pull_request) Successful in 12s
Lint & Validate / shellcheck (pull_request) Successful in 9s
CI Review / alert-receiver-test (pull_request) Successful in 22s
Lint & Validate / yaml-lint (pull_request) Successful in 18s
CI Review / record-sql-test (pull_request) Successful in 1m14s
CI Review / dispatcher-test (pull_request) Successful in 2m51s
CI Review / cockpit-test (pull_request) Successful in 2m44s
Lint & Validate / terraform-validate (pull_request) Successful in 4m27s
CI Review / cockpit-e2e (pull_request) Successful in 13m34s
471d4b1f94
Gap 3 of the "correct SREs metrics" workstream: the fleet had no series for how
long a change takes, and no count of projects moving in parallel. ci_health_*
measures CI duration per repo, which is a different measurement.

- core/manifests/monitoring/factory/: 30 min CronJob that walks the Forgejo PR
  API for the six repos the existing ci-health credential is scoped to and pushes
  the per-PR facts to Prometheus through its remote-write receiver with
  `promtool push metrics` (the live Prometheus has
  --web.enable-remote-write-receiver). No new Deployment, Service, ServiceMonitor
  or credential.
- core/manifests/monitoring/rules/factory-alerts.yaml: 8 recording rules
  (forgejo:repos_merging:count answers "parallel projects";
  forgejo:pr_merged_duration_p50:7d answers "is it getting worse") and 2 alerts
  that fire only when the MEASUREMENT breaks, both with full ld_* contracts.
- core/manifests/monitoring/dashboards/factory.yaml: uid factory, 13 panels.
- Tests: 8 promtool cases (positive + negative controls) and 15 unit tests.

Measured 2026-11-05T15:19Z: 19 requests / 100 s per cycle (38 req/h, +16% on the
credential), 974 series, 0 repo failures, 0 truncated walks. Baseline: 422 merges
in 7 d across 6 of 6 watched repos; p50 time-to-merged 39 min (infra-core) to
40 h (api-sendrast).

The rules and dashboard deploy on merge (existing ArgoCD apps); the emitter needs
a new prod/apps/monitoring-factory.yaml Application, which this PR states plainly
in the README rather than assuming.
Collaborator

Verdict of record — ld-reviewer — head 471d4b1f94: REQUEST CHANGES

model: omniroute/deepseek-flash
session: homelab-2479-gap3-review-1

Three blockers, one root cause, and it is not the code — it is the ingest model. The emitter pushes
remote-write samples once every 30 minutes; the live Prometheus runs query.lookback-delta: 5m
(/api/v1/status/flags on the cluster instance, read at 16:20Z) with evaluation_interval: 30s. Every
pushed series is therefore invisible to an instant selector for ~25 of every 30 minutes. I proved each
finding below against the shipped factory-alerts.yaml with the cluster's own promtool 3.4.0, in a
throwaway clone under /tmp (read-only; no push, no cluster write).

Reproduction used for B1/B3, verbatim (each block is expected to fail; a pass means the defect is gone):

# /tmp/rev846/pt/shipped-repro.test.yaml   (promtool 3.4.0 test rules)
rule_files:
- factory-alerts.yaml          # extracted from core/manifests/monitoring/rules/factory-alerts.yaml
evaluation_interval: 30s
tests:
- name: SHIPPED-1 walk_truncated=1 pushed every 30 min for 3 h
  interval: 30m
  input_series:
  - series: 'forgejo_factory_walk_truncated{repo="Homelab/infra-core"}'
    values: '1 1 1 1 1 1 1'
  alert_rule_test:
  - eval_time: 3h
    alertname: ForgejoFactoryWalkTruncated
    exp_alerts:
    - exp_labels: {severity: info, repo: Homelab/infra-core}
      exp_annotations: {}
- name: SHIPPED-3 healthy last_success at 40 min cadence (one skipped slot)
  interval: 40m
  input_series:
  - series: 'forgejo_factory_last_success_timestamp_seconds'
    values: '0+2400x9'
  alert_rule_test:
  - eval_time: 2h35m
    alertname: ForgejoFactorySeriesStale
    exp_alerts: []
- name: SHIPPED-4 recorded parallelism count mid-gap
  interval: 30m
  input_series:
  - series: 'forgejo_prs_merged_7d{repo="Homelab/infra-core"}'
    values: '150 150 150'
  - series: 'forgejo_prs_merged_7d{repo="Homelab/prod"}'
    values: '74 74 74'
  promql_expr_test:
  - expr: forgejo:repos_merging:count
    eval_time: 1h5m
    exp_samples:
    - labels: 'forgejo:repos_merging:count'
      value: 2

Result: SHIPPED-1 FAILED (got []), SHIPPED-3 FAILED (got the alert), SHIPPED-4 FAILED (got nil).
A fourth block, healthy last_success every 30 min, correctly stays silent — so the dead-man alert does
work at the shipped cadence. The three failures are the findings.


HOMELAB-2479-G3-B1 (blocker) — ForgejoFactoryWalkTruncated can never fire

expr: forgejo_factory_walk_truncated == 1 with for: 2h. The series is present only within 5 minutes of
each 30-minute push, so the pending state resets every cycle and the 2 h for never elapses. SHIPPED-1
shows walk_truncated == 1 for three continuous hours producing no alert.

The file's own claim for this rule — "This is the rule that keeps the series honest after a merge burst
or a repo that suddenly churns" — is therefore not delivered. This is the epic's named defect class
(a rule over a series that is never there is silent forever, HOMELAB-2628), inverted: the series is there
for 5 minutes of every 30, which is enough to look healthy in a unit test and not enough to alert.

Why the suite missed it: rule-tests/factory.test.yaml Test 7 ("truncated walk fires after 2h") samples
forgejo_factory_walk_truncated at interval: 1m — six times denser than the deployment. The test asserts
the expression, not the delivery cadence, and the body quotes it as evidence that truncation alerts work.

Fix: read through a range so the value is continuously true, e.g.
expr: last_over_time(forgejo_factory_walk_truncated[1h]) == 1, and add the case above (or any case at
interval: 30m) to factory.test.yaml so the suite can fail on this class.

All 8 recording rules read raw pushed series at evaluation time, and all 13 panels read either the raw
series or those recorded series (grep -n expr dashboards/factory.yaml — no last_over_time, no
max_over_time anywhere). SHIPPED-4: forgejo:repos_merging:count evaluates to 2 at the push instant and
to nil 35 minutes later, i.e. the recorded trend exists as 5-minute islands with 25-minute holes — about
one sixth of wall clock — and an instant query (an ad-hoc query, a stat panel without a range, an alert, a
future consumer) returns nothing for the other five sixths.

Consequence for the question this PR exists to answer: "how many projects are landing in parallel" and the
p50 trends are blank or up to an hour stale most of the time a person looks. And an empty panel reads
exactly like nothing merged — which is the misreading this PR's own alert description warns about
("do NOT read a flat line as 'no PRs merged'"). A metric that is only queryable 1/6 of the time is a large
improvement on guessing, but it is not the "stored series" the epic asked for.

Fix: the emitter's push content is complete on every cycle (it re-emits every per-PR series in the window),
so the read side only has to look back one cycle. Wrap the raw series in last_over_time(...[1h]) in the
recording rules and in the panel expressions — that is label-preserving, so
quantile(0.5, last_over_time(forgejo_pr_merged_duration_seconds[1h]) and last_over_time(forgejo_pr_merged_timestamp_seconds[1h]) > time() - 7*86400) by (repo)
keeps the current semantics. Pushing every 5 minutes instead would fix it too at 6x the Forgejo load, which
the docstring's own cost analysis rejects.

HOMELAB-2479-G3-B3 (blocker) — ForgejoFactorySeriesStale false-pages on one late cycle

absent(forgejo_factory_last_success_timestamp_seconds > time() - 3600) with for: 30m. Because the sample
is visible only for 5 minutes after each push, the expression is true for ~25 minutes of every healthy
30-minute cycle. The for: 30m therefore sits 5 minutes away from a false page: any cycle that lands later
than ~35 minutes after the previous one alerts. SHIPPED-3 shows exactly that at a 40-minute cadence, and the
alert's own description/ld_action then tell the operator the measurement path is broken and to start
running kubectl diagnostics — on a healthy emitter.

That is a plausible transient, not a hypothetical: startingDeadlineSeconds: 120 means a slot the
controller cannot start within two minutes is skipped entirely, which leaves a one-hour gap
(memory: host-storage-stalls-cause-outages is the same class of transient on this box). A false page on
the only paging rule in the layer is the mute-training outcome the file itself cites (HOMELAB-2271).

Fix: give the margin a cycle of headroom, or drop the cadence coupling entirely —
absent_over_time(forgejo_factory_last_success_timestamp_seconds[1h]) with for: 15m says what the rule
means ("no sample in the last hour") and is unaffected by how dense the pushes are. Keep the
"never existed" case covered; absent_over_time covers it.


What is right, verified independently (this is not a blanket rejection)

  • Gates, run in the throwaway clone at 471d4b1, origin/live merge-base 911810d:

    • python3 scripts/monitoring/check_alert_contracts.py --base origin/live → exit 0, changed=2, "ok: every added or modified rule carries a complete ld_* disposition contract".
    • promtool check rules (3.4.0) → SUCCESS: 10 rules found (8 record + 2 alert).
    • promtool test rules rule-tests/factory.test.yaml → SUCCESS (8 cases).
    • yamllint -d relaxed on the 6 changed .yaml files → exit 0, 126 line-length warnings, 0 errors.
    • python3 factory_series_test.py → Ran 15 tests … OK.
    • Sabotage confirmed the suite can go red: dropping > 0 from count(forgejo_prs_merged_7d > 0) → FAILED: got 2E+00, exp 1E+00. The body's sabotage claim is true.
  • CI is green at this exact head (runs 3133/3134/3135 on 471d4b1f94): alert-contract, shellcheck, terraform-validate, yaml-lint, cockpit/runner/receiver/record-sql suites — all success.

  • No new credential — verified: secret/ci-health-forgejo-token exists in monitoring; prod live manifests/monitoring/ci-health/externalsecret.yaml creates it with key token; CI_HEALTH_REPOS and FACTORY_REPOS are the same six repos, in the same order. Nothing minted, nothing widened, automountServiceAccountToken: false, no RBAC, non-root, read-only rootfs, capabilities dropped.

  • The push path is real, not assumed: the live pod runs quay.io/prometheus/prometheus@sha256:78ed1f9050eb9eaf766af6e580230b1c4965728650e332cd1ee918c0c4699775 (the exact digest pinned in the CronJob), its args include --web.enable-remote-write-receiver, /bin/promtool --version inside that container is 3.4.0, and svc/kube-prometheus-stack-prometheus:9090 exists. I also fed promtool push metrics to a throwaway local HTTP sink: it posts to exactly the given path (/api/v1/write, application/x-protobuf + snappy, remote-write 0.1.0) — no path doubling in the CronJob command. crane digest docker.io/library/python:3.12-alpine = sha256:e6dae589… — the pinned digest matches.

  • The emitter itself is careful code: strict iso_to_epoch that raises instead of shifting a duration; a truncation flag that is set only on the real "hit the bound without crossing the floor" path; a partial cycle that withholds the dead-man gauge and still emits forgejo_factory_repo_ok per repo; one retry on 429/5xx only; Forbid + activeDeadlineSeconds bounding load. The dashboard ConfigMap parses as JSON (13 panels, uid factory, grafana_dashboard: "1").

  • Aggregates recomputed from Forgejo (GET only, state=closed&sort=recentclose, window on merged_at) 2026-11-05T16:22Z — the emitter's arithmetic is sound for the repos with enough samples:

    repo merges 7d (mine / body) p50 (mine) p50 (body) p90 (body) max (body)
    Homelab/infra-core 149 / 150 39.3 min 39 min 4.7 h ✓ 74.7 h
    Homelab/prod 74 / 75 54.6 min 53 min 17.1 h 45.3 h
    Homelab/ghostty-in-the-shell 160 / 160 1.08 h (65 min) 65 min 14.2 h ✓ 98.5 h
    Homelab/pi-extensions 27 / 27 81.6 min 82 min 4.58 h ✓ 14.22 h ✓
    Homelab/lab-director 4 / 4 21.04 h 38.1 h 38.1 h 68.3 h ✓
    Sendrast/api-sendrast 6 / 6 36.21 h 40.1 h 51.06 h ✓ 54.24 h ✓
    fleet 420 / 422 merged, 56 / 55 open

    The 2-merge and 1-open difference is window drift over the ~80 minutes since the author's run (PRs crossing the 7-day floor); that is expected and honest.

Concerns (not blocking)

  • HOMELAB-2479-G3-C1 — the deployed page bound is 4, not the 6 the body and docstring describe.
    cronjob.yaml sets FACTORY_MAX_PAGES: "4" while the body, the script docstring ("page bound 6"),
    HELP-adjacent comments ("6 = 300 PRs") and "so the page bound has headroom today" all assume 6. My
    independent walk used 4/2/1/4/1/1 pages, so at the shipped value infra-core and ghostty-in-the-shell
    are exactly at the bound — the docstring's headroom sentence is false for two of the six repos, and one
    merge burst flips truncated=1 (which, per B1, then alerts nobody). Also stale and self-contradicting in
    cronjob.yaml: "336 h = 14 d" printed above value: "168", "the closed-PR page … sort=recentupdate
    already reached the 14 d floor in one page" while the code uses sort=recentclose and the body's own
    measured decision rejects 14 d as truncating, and "12 requests and ~77 s per cycle" against the body's
    19 requests / 100 s. Please make the comments match the shipped values; a future reader will trust them.
  • HOMELAB-2479-G3-C2 — the documented p50 baseline is computed with a different estimator than the shipped
    rule, and is wrong for the two low-n repos.
    Prometheus quantile(0.5, …) interpolates (it is the plain
    median), so the delivered series will read ~21.0 h for lab-director and ~36.2 h for api-sendrast, while the
    body and the factory-alerts.yaml comment table record 38.1 h and 40.1 h (those are the upper-middle
    sample of the four/six). The headline "60x spread across projects" is therefore ~54x, not 60x. The rule is
    right and the prose should follow it — the baseline table is what a future reader will sanity-check the
    series against.
  • HOMELAB-2479-G3-C3 — the orphan-alert window has no follow-up ticket id. This is the disclosure the
    brief asked about and it is made, in a dedicated "Wiring — what merging this PR does NOT do" section and
    in the README: the rules and dashboard deploy (verified: apps/monitoring-rules.yaml → core/manifests/monitoring/rules,
    apps/monitoring-dashboards.yaml → core/manifests/monitoring/dashboards), the emitter does not (no prod
    Application points at core/manifests/monitoring/factory, and cluster-config only includes
    cluster-*.yaml). So merging puts a rule set into the cluster whose only emitter cannot run. I accept that
    as deliberate and disclosed, but nothing names the prod follow-up ticket, and the alert's ld_action
    instructs the responder to run kubectl -n monitoring get cronjob factory-series, which does not exist
    until that Application lands. Please put the follow-up ticket id in the body and the README so the window
    is bounded, and say in ld_action that the CronJob is absent until the app exists.
  • Observation, out of scope for this PR: the README's Application snippet uses
    https://forgejo.aaron.reynoza.org/Homelab/infra-core.git, while prod's existing Applications use
    http://10.10.10.222:3000/aaron/infra-core.git. I checked the in-cluster Forgejo: aaron/infra-core now
    answers 301 and Homelab/infra-core answers 200 (memory: forgejo-homelab-org-rename) — so the
    README's path is the correct one and prod's older apps are the drifted ones. No action here; whoever adds
    monitoring-factory should use Homelab/ and file the drift separately.

What I could not verify

Whether the emitter's own walk reproduces the body's request/latency measurements (19 requests, 100 s) — I
reproduced its arithmetic and its page counts from Forgejo (they match), but not inside a Pod. And I did not
evaluate the Grafana rendering, only the PromQL and the dashboard JSON.

Nothing in this review is a plan about a person; all statements are about the change.

**Verdict of record — ld-reviewer — head 471d4b1f94ca3c6e27e59482a2ecdcb9bb0853ad: REQUEST CHANGES** model: omniroute/deepseek-flash session: homelab-2479-gap3-review-1 Three blockers, one root cause, and it is not the code — it is the ingest model. The emitter pushes remote-write samples once every 30 minutes; the live Prometheus runs `query.lookback-delta: 5m` (`/api/v1/status/flags` on the cluster instance, read at 16:20Z) with `evaluation_interval: 30s`. Every pushed series is therefore invisible to an instant selector for ~25 of every 30 minutes. I proved each finding below against the **shipped** `factory-alerts.yaml` with the cluster's own promtool 3.4.0, in a throwaway clone under `/tmp` (read-only; no push, no cluster write). Reproduction used for B1/B3, verbatim (each block is expected to fail; a pass means the defect is gone): ```yaml # /tmp/rev846/pt/shipped-repro.test.yaml (promtool 3.4.0 test rules) rule_files: - factory-alerts.yaml # extracted from core/manifests/monitoring/rules/factory-alerts.yaml evaluation_interval: 30s tests: - name: SHIPPED-1 walk_truncated=1 pushed every 30 min for 3 h interval: 30m input_series: - series: 'forgejo_factory_walk_truncated{repo="Homelab/infra-core"}' values: '1 1 1 1 1 1 1' alert_rule_test: - eval_time: 3h alertname: ForgejoFactoryWalkTruncated exp_alerts: - exp_labels: {severity: info, repo: Homelab/infra-core} exp_annotations: {} - name: SHIPPED-3 healthy last_success at 40 min cadence (one skipped slot) interval: 40m input_series: - series: 'forgejo_factory_last_success_timestamp_seconds' values: '0+2400x9' alert_rule_test: - eval_time: 2h35m alertname: ForgejoFactorySeriesStale exp_alerts: [] - name: SHIPPED-4 recorded parallelism count mid-gap interval: 30m input_series: - series: 'forgejo_prs_merged_7d{repo="Homelab/infra-core"}' values: '150 150 150' - series: 'forgejo_prs_merged_7d{repo="Homelab/prod"}' values: '74 74 74' promql_expr_test: - expr: forgejo:repos_merging:count eval_time: 1h5m exp_samples: - labels: 'forgejo:repos_merging:count' value: 2 ``` Result: `SHIPPED-1 FAILED (got [])`, `SHIPPED-3 FAILED (got the alert)`, `SHIPPED-4 FAILED (got nil)`. A fourth block, healthy `last_success` every 30 min, correctly stays silent — so the dead-man alert does work at the shipped cadence. The three failures are the findings. --- ## HOMELAB-2479-G3-B1 (blocker) — `ForgejoFactoryWalkTruncated` can never fire `expr: forgejo_factory_walk_truncated == 1` with `for: 2h`. The series is present only within 5 minutes of each 30-minute push, so the pending state resets every cycle and the 2 h `for` never elapses. SHIPPED-1 shows `walk_truncated == 1` for three continuous hours producing no alert. The file's own claim for this rule — "This is the rule that keeps the series honest after a merge burst or a repo that suddenly churns" — is therefore not delivered. This is the epic's named defect class (a rule over a series that is never there is silent forever, HOMELAB-2628), inverted: the series is there for 5 minutes of every 30, which is enough to look healthy in a unit test and not enough to alert. Why the suite missed it: `rule-tests/factory.test.yaml` Test 7 ("truncated walk fires after 2h") samples `forgejo_factory_walk_truncated` at `interval: 1m` — six times denser than the deployment. The test asserts the expression, not the delivery cadence, and the body quotes it as evidence that truncation alerts work. Fix: read through a range so the value is continuously true, e.g. `expr: last_over_time(forgejo_factory_walk_truncated[1h]) == 1`, and add the case above (or any case at `interval: 30m`) to `factory.test.yaml` so the suite can fail on this class. ## HOMELAB-2479-G3-B2 (blocker) — the recorded trends and the dashboard are blind ~83% of wall clock All 8 recording rules read raw pushed series at evaluation time, and all 13 panels read either the raw series or those recorded series (`grep -n expr dashboards/factory.yaml` — no `last_over_time`, no `max_over_time` anywhere). SHIPPED-4: `forgejo:repos_merging:count` evaluates to `2` at the push instant and to `nil` 35 minutes later, i.e. the recorded trend exists as 5-minute islands with 25-minute holes — about one sixth of wall clock — and an instant query (an ad-hoc query, a stat panel without a range, an alert, a future consumer) returns *nothing* for the other five sixths. Consequence for the question this PR exists to answer: "how many projects are landing in parallel" and the p50 trends are blank or up to an hour stale most of the time a person looks. And an empty panel reads exactly like *nothing merged* — which is the misreading this PR's own alert description warns about ("do NOT read a flat line as 'no PRs merged'"). A metric that is only queryable 1/6 of the time is a large improvement on guessing, but it is not the "stored series" the epic asked for. Fix: the emitter's push content is complete on every cycle (it re-emits every per-PR series in the window), so the read side only has to look back one cycle. Wrap the raw series in `last_over_time(...[1h])` in the recording rules and in the panel expressions — that is label-preserving, so `quantile(0.5, last_over_time(forgejo_pr_merged_duration_seconds[1h]) and last_over_time(forgejo_pr_merged_timestamp_seconds[1h]) > time() - 7*86400) by (repo)` keeps the current semantics. Pushing every 5 minutes instead would fix it too at 6x the Forgejo load, which the docstring's own cost analysis rejects. ## HOMELAB-2479-G3-B3 (blocker) — `ForgejoFactorySeriesStale` false-pages on one late cycle `absent(forgejo_factory_last_success_timestamp_seconds > time() - 3600)` with `for: 30m`. Because the sample is visible only for 5 minutes after each push, the expression is *true* for ~25 minutes of every healthy 30-minute cycle. The `for: 30m` therefore sits 5 minutes away from a false page: any cycle that lands later than ~35 minutes after the previous one alerts. SHIPPED-3 shows exactly that at a 40-minute cadence, and the alert's own `description`/`ld_action` then tell the operator the measurement path is broken and to start running `kubectl` diagnostics — on a healthy emitter. That is a plausible transient, not a hypothetical: `startingDeadlineSeconds: 120` means a slot the controller cannot start within two minutes is skipped entirely, which leaves a one-hour gap (memory: `host-storage-stalls-cause-outages` is the same class of transient on this box). A false page on the only paging rule in the layer is the mute-training outcome the file itself cites (HOMELAB-2271). Fix: give the margin a cycle of headroom, or drop the cadence coupling entirely — `absent_over_time(forgejo_factory_last_success_timestamp_seconds[1h])` with `for: 15m` says what the rule means ("no sample in the last hour") and is unaffected by how dense the pushes are. Keep the "never existed" case covered; `absent_over_time` covers it. --- ## What is right, verified independently (this is not a blanket rejection) * **Gates, run in the throwaway clone at 471d4b1, `origin/live` merge-base 911810d:** * `python3 scripts/monitoring/check_alert_contracts.py --base origin/live` → exit 0, `changed=2`, "ok: every added or modified rule carries a complete ld_* disposition contract". * `promtool check rules` (3.4.0) → `SUCCESS: 10 rules found` (8 record + 2 alert). * `promtool test rules rule-tests/factory.test.yaml` → `SUCCESS` (8 cases). * `yamllint -d relaxed` on the 6 changed `.yaml` files → exit 0, 126 line-length warnings, 0 errors. * `python3 factory_series_test.py` → `Ran 15 tests … OK`. * **Sabotage confirmed the suite can go red:** dropping `> 0` from `count(forgejo_prs_merged_7d > 0)` → `FAILED: got 2E+00, exp 1E+00`. The body's sabotage claim is true. * **CI is green at this exact head** (runs 3133/3134/3135 on 471d4b1f94): alert-contract, shellcheck, terraform-validate, yaml-lint, cockpit/runner/receiver/record-sql suites — all success. * **No new credential** — verified: `secret/ci-health-forgejo-token` exists in `monitoring`; prod live `manifests/monitoring/ci-health/externalsecret.yaml` creates it with key `token`; `CI_HEALTH_REPOS` and `FACTORY_REPOS` are the same six repos, in the same order. Nothing minted, nothing widened, `automountServiceAccountToken: false`, no RBAC, non-root, read-only rootfs, capabilities dropped. * **The push path is real**, not assumed: the live pod runs `quay.io/prometheus/prometheus@sha256:78ed1f9050eb9eaf766af6e580230b1c4965728650e332cd1ee918c0c4699775` (the exact digest pinned in the CronJob), its args include `--web.enable-remote-write-receiver`, `/bin/promtool --version` inside that container is 3.4.0, and `svc/kube-prometheus-stack-prometheus:9090` exists. I also fed `promtool push metrics` to a throwaway local HTTP sink: it posts to exactly the given path (`/api/v1/write`, `application/x-protobuf` + snappy, remote-write 0.1.0) — no path doubling in the CronJob command. `crane digest docker.io/library/python:3.12-alpine` = `sha256:e6dae589…` — the pinned digest matches. * **The emitter itself is careful code:** strict `iso_to_epoch` that raises instead of shifting a duration; a truncation flag that is set only on the real "hit the bound without crossing the floor" path; a partial cycle that withholds the dead-man gauge and still emits `forgejo_factory_repo_ok` per repo; one retry on 429/5xx only; `Forbid` + `activeDeadlineSeconds` bounding load. The dashboard ConfigMap parses as JSON (13 panels, uid `factory`, `grafana_dashboard: "1"`). * **Aggregates recomputed from Forgejo (GET only, `state=closed&sort=recentclose`, window on `merged_at`) 2026-11-05T16:22Z** — the emitter's arithmetic is sound for the repos with enough samples: | repo | merges 7d (mine / body) | p50 (mine) | p50 (body) | p90 (body) | max (body) | |---|---|---|---|---|---| | Homelab/infra-core | 149 / 150 | 39.3 min | 39 min | 4.7 h ✓ | 74.7 h | | Homelab/prod | 74 / 75 | 54.6 min | 53 min | 17.1 h | 45.3 h | | Homelab/ghostty-in-the-shell | 160 / 160 | 1.08 h (65 min) | 65 min | 14.2 h ✓ | 98.5 h | | Homelab/pi-extensions | 27 / 27 | 81.6 min | 82 min | 4.58 h ✓ | 14.22 h ✓ | | Homelab/lab-director | 4 / 4 | **21.04 h** | 38.1 h | 38.1 h | 68.3 h ✓ | | Sendrast/api-sendrast | 6 / 6 | **36.21 h** | 40.1 h | 51.06 h ✓ | 54.24 h ✓ | | **fleet** | **420 / 422** merged, 56 / 55 open | | | | | The 2-merge and 1-open difference is window drift over the ~80 minutes since the author's run (PRs crossing the 7-day floor); that is expected and honest. ## Concerns (not blocking) * **HOMELAB-2479-G3-C1 — the deployed page bound is 4, not the 6 the body and docstring describe.** `cronjob.yaml` sets `FACTORY_MAX_PAGES: "4"` while the body, the script docstring ("page bound 6"), `HELP`-adjacent comments ("6 = 300 PRs") and "so the page bound has headroom today" all assume 6. My independent walk used 4/2/1/4/1/1 pages, so at the shipped value `infra-core` and `ghostty-in-the-shell` are **exactly at the bound** — the docstring's headroom sentence is false for two of the six repos, and one merge burst flips `truncated=1` (which, per B1, then alerts nobody). Also stale and self-contradicting in `cronjob.yaml`: "336 h = 14 d" printed above `value: "168"`, "the closed-PR page … `sort=recentupdate` already reached the 14 d floor in one page" while the code uses `sort=recentclose` and the body's own measured decision rejects 14 d as truncating, and "12 requests and ~77 s per cycle" against the body's 19 requests / 100 s. Please make the comments match the shipped values; a future reader will trust them. * **HOMELAB-2479-G3-C2 — the documented p50 baseline is computed with a different estimator than the shipped rule, and is wrong for the two low-n repos.** Prometheus `quantile(0.5, …)` interpolates (it is the plain median), so the delivered series will read ~21.0 h for lab-director and ~36.2 h for api-sendrast, while the body and the `factory-alerts.yaml` comment table record 38.1 h and 40.1 h (those are the upper-middle sample of the four/six). The headline "60x spread across projects" is therefore ~54x, not 60x. The rule is right and the prose should follow it — the baseline table is what a future reader will sanity-check the series against. * **HOMELAB-2479-G3-C3 — the orphan-alert window has no follow-up ticket id.** This is the disclosure the brief asked about and it **is** made, in a dedicated "Wiring — what merging this PR does NOT do" section and in the README: the rules and dashboard deploy (verified: `apps/monitoring-rules.yaml` → `core/manifests/monitoring/rules`, `apps/monitoring-dashboards.yaml` → `core/manifests/monitoring/dashboards`), the emitter does not (no prod Application points at `core/manifests/monitoring/factory`, and `cluster-config` only includes `cluster-*.yaml`). So merging puts a rule set into the cluster whose only emitter cannot run. I accept that as deliberate and disclosed, but nothing names the prod follow-up ticket, and the alert's `ld_action` instructs the responder to run `kubectl -n monitoring get cronjob factory-series`, which does not exist until that Application lands. Please put the follow-up ticket id in the body and the README so the window is bounded, and say in `ld_action` that the CronJob is absent until the app exists. * **Observation, out of scope for this PR:** the README's Application snippet uses `https://forgejo.aaron.reynoza.org/Homelab/infra-core.git`, while prod's existing Applications use `http://10.10.10.222:3000/aaron/infra-core.git`. I checked the in-cluster Forgejo: `aaron/infra-core` now answers **301** and `Homelab/infra-core` answers **200** (memory: `forgejo-homelab-org-rename`) — so the README's path is the correct one and prod's older apps are the drifted ones. No action here; whoever adds `monitoring-factory` should use `Homelab/` and file the drift separately. ## What I could not verify Whether the emitter's own walk reproduces the body's request/latency measurements (19 requests, 100 s) — I reproduced its arithmetic and its page counts from Forgejo (they match), but not inside a Pod. And I did not evaluate the Grafana rendering, only the PromQL and the dashboard JSON. Nothing in this review is a plan about a person; all statements are about the change.
ld-reviewer requested changes 2026-11-05 16:30:47 +00:00
Dismissed
ld-reviewer left a comment
Collaborator

Verdict of record — ld-reviewer — head 471d4b1f94: REQUEST CHANGES

model: omniroute/deepseek-flash
session: homelab-2479-gap3-review-1

Three blockers, one root cause, and it is not the code — it is the ingest model. The emitter pushes
remote-write samples once every 30 minutes; the live Prometheus runs query.lookback-delta: 5m
(/api/v1/status/flags on the cluster instance, read at 16:20Z) with evaluation_interval: 30s. Every
pushed series is therefore invisible to an instant selector for ~25 of every 30 minutes. I proved each
finding below against the shipped factory-alerts.yaml with the cluster's own promtool 3.4.0, in a
throwaway clone under /tmp (read-only; no push, no cluster write).

Reproduction used for B1/B3, verbatim (each block is expected to fail; a pass means the defect is gone):

# /tmp/rev846/pt/shipped-repro.test.yaml   (promtool 3.4.0 test rules)
rule_files:
- factory-alerts.yaml          # extracted from core/manifests/monitoring/rules/factory-alerts.yaml
evaluation_interval: 30s
tests:
- name: SHIPPED-1 walk_truncated=1 pushed every 30 min for 3 h
  interval: 30m
  input_series:
  - series: 'forgejo_factory_walk_truncated{repo="Homelab/infra-core"}'
    values: '1 1 1 1 1 1 1'
  alert_rule_test:
  - eval_time: 3h
    alertname: ForgejoFactoryWalkTruncated
    exp_alerts:
    - exp_labels: {severity: info, repo: Homelab/infra-core}
      exp_annotations: {}
- name: SHIPPED-3 healthy last_success at 40 min cadence (one skipped slot)
  interval: 40m
  input_series:
  - series: 'forgejo_factory_last_success_timestamp_seconds'
    values: '0+2400x9'
  alert_rule_test:
  - eval_time: 2h35m
    alertname: ForgejoFactorySeriesStale
    exp_alerts: []
- name: SHIPPED-4 recorded parallelism count mid-gap
  interval: 30m
  input_series:
  - series: 'forgejo_prs_merged_7d{repo="Homelab/infra-core"}'
    values: '150 150 150'
  - series: 'forgejo_prs_merged_7d{repo="Homelab/prod"}'
    values: '74 74 74'
  promql_expr_test:
  - expr: forgejo:repos_merging:count
    eval_time: 1h5m
    exp_samples:
    - labels: 'forgejo:repos_merging:count'
      value: 2

Result: SHIPPED-1 FAILED (got []), SHIPPED-3 FAILED (got the alert), SHIPPED-4 FAILED (got nil).
A fourth block, healthy last_success every 30 min, correctly stays silent — so the dead-man alert does
work at the shipped cadence. The three failures are the findings.


HOMELAB-2479-G3-B1 (blocker) — ForgejoFactoryWalkTruncated can never fire

expr: forgejo_factory_walk_truncated == 1 with for: 2h. The series is present only within 5 minutes of
each 30-minute push, so the pending state resets every cycle and the 2 h for never elapses. SHIPPED-1
shows walk_truncated == 1 for three continuous hours producing no alert.

The file's own claim for this rule — "This is the rule that keeps the series honest after a merge burst
or a repo that suddenly churns" — is therefore not delivered. This is the epic's named defect class
(a rule over a series that is never there is silent forever, HOMELAB-2628), inverted: the series is there
for 5 minutes of every 30, which is enough to look healthy in a unit test and not enough to alert.

Why the suite missed it: rule-tests/factory.test.yaml Test 7 ("truncated walk fires after 2h") samples
forgejo_factory_walk_truncated at interval: 1m — six times denser than the deployment. The test asserts
the expression, not the delivery cadence, and the body quotes it as evidence that truncation alerts work.

Fix: read through a range so the value is continuously true, e.g.
expr: last_over_time(forgejo_factory_walk_truncated[1h]) == 1, and add the case above (or any case at
interval: 30m) to factory.test.yaml so the suite can fail on this class.

All 8 recording rules read raw pushed series at evaluation time, and all 13 panels read either the raw
series or those recorded series (grep -n expr dashboards/factory.yaml — no last_over_time, no
max_over_time anywhere). SHIPPED-4: forgejo:repos_merging:count evaluates to 2 at the push instant and
to nil 35 minutes later, i.e. the recorded trend exists as 5-minute islands with 25-minute holes — about
one sixth of wall clock — and an instant query (an ad-hoc query, a stat panel without a range, an alert, a
future consumer) returns nothing for the other five sixths.

Consequence for the question this PR exists to answer: "how many projects are landing in parallel" and the
p50 trends are blank or up to an hour stale most of the time a person looks. And an empty panel reads
exactly like nothing merged — which is the misreading this PR's own alert description warns about
("do NOT read a flat line as 'no PRs merged'"). A metric that is only queryable 1/6 of the time is a large
improvement on guessing, but it is not the "stored series" the epic asked for.

Fix: the emitter's push content is complete on every cycle (it re-emits every per-PR series in the window),
so the read side only has to look back one cycle. Wrap the raw series in last_over_time(...[1h]) in the
recording rules and in the panel expressions — that is label-preserving, so
quantile(0.5, last_over_time(forgejo_pr_merged_duration_seconds[1h]) and last_over_time(forgejo_pr_merged_timestamp_seconds[1h]) > time() - 7*86400) by (repo)
keeps the current semantics. Pushing every 5 minutes instead would fix it too at 6x the Forgejo load, which
the docstring's own cost analysis rejects.

HOMELAB-2479-G3-B3 (blocker) — ForgejoFactorySeriesStale false-pages on one late cycle

absent(forgejo_factory_last_success_timestamp_seconds > time() - 3600) with for: 30m. Because the sample
is visible only for 5 minutes after each push, the expression is true for ~25 minutes of every healthy
30-minute cycle. The for: 30m therefore sits 5 minutes away from a false page: any cycle that lands later
than ~35 minutes after the previous one alerts. SHIPPED-3 shows exactly that at a 40-minute cadence, and the
alert's own description/ld_action then tell the operator the measurement path is broken and to start
running kubectl diagnostics — on a healthy emitter.

That is a plausible transient, not a hypothetical: startingDeadlineSeconds: 120 means a slot the
controller cannot start within two minutes is skipped entirely, which leaves a one-hour gap
(memory: host-storage-stalls-cause-outages is the same class of transient on this box). A false page on
the only paging rule in the layer is the mute-training outcome the file itself cites (HOMELAB-2271).

Fix: give the margin a cycle of headroom, or drop the cadence coupling entirely —
absent_over_time(forgejo_factory_last_success_timestamp_seconds[1h]) with for: 15m says what the rule
means ("no sample in the last hour") and is unaffected by how dense the pushes are. Keep the
"never existed" case covered; absent_over_time covers it.


What is right, verified independently (this is not a blanket rejection)

  • Gates, run in the throwaway clone at 471d4b1, origin/live merge-base 911810d:

    • python3 scripts/monitoring/check_alert_contracts.py --base origin/live → exit 0, changed=2, "ok: every added or modified rule carries a complete ld_* disposition contract".
    • promtool check rules (3.4.0) → SUCCESS: 10 rules found (8 record + 2 alert).
    • promtool test rules rule-tests/factory.test.yaml → SUCCESS (8 cases).
    • yamllint -d relaxed on the 6 changed .yaml files → exit 0, 126 line-length warnings, 0 errors.
    • python3 factory_series_test.py → Ran 15 tests … OK.
    • Sabotage confirmed the suite can go red: dropping > 0 from count(forgejo_prs_merged_7d > 0) → FAILED: got 2E+00, exp 1E+00. The body's sabotage claim is true.
  • CI is green at this exact head (runs 3133/3134/3135 on 471d4b1f94): alert-contract, shellcheck, terraform-validate, yaml-lint, cockpit/runner/receiver/record-sql suites — all success.

  • No new credential — verified: secret/ci-health-forgejo-token exists in monitoring; prod live manifests/monitoring/ci-health/externalsecret.yaml creates it with key token; CI_HEALTH_REPOS and FACTORY_REPOS are the same six repos, in the same order. Nothing minted, nothing widened, automountServiceAccountToken: false, no RBAC, non-root, read-only rootfs, capabilities dropped.

  • The push path is real, not assumed: the live pod runs quay.io/prometheus/prometheus@sha256:78ed1f9050eb9eaf766af6e580230b1c4965728650e332cd1ee918c0c4699775 (the exact digest pinned in the CronJob), its args include --web.enable-remote-write-receiver, /bin/promtool --version inside that container is 3.4.0, and svc/kube-prometheus-stack-prometheus:9090 exists. I also fed promtool push metrics to a throwaway local HTTP sink: it posts to exactly the given path (/api/v1/write, application/x-protobuf + snappy, remote-write 0.1.0) — no path doubling in the CronJob command. crane digest docker.io/library/python:3.12-alpine = sha256:e6dae589… — the pinned digest matches.

  • The emitter itself is careful code: strict iso_to_epoch that raises instead of shifting a duration; a truncation flag that is set only on the real "hit the bound without crossing the floor" path; a partial cycle that withholds the dead-man gauge and still emits forgejo_factory_repo_ok per repo; one retry on 429/5xx only; Forbid + activeDeadlineSeconds bounding load. The dashboard ConfigMap parses as JSON (13 panels, uid factory, grafana_dashboard: "1").

  • Aggregates recomputed from Forgejo (GET only, state=closed&sort=recentclose, window on merged_at) 2026-11-05T16:22Z — the emitter's arithmetic is sound for the repos with enough samples:

    repo merges 7d (mine / body) p50 (mine) p50 (body) p90 (body) max (body)
    Homelab/infra-core 149 / 150 39.3 min 39 min 4.7 h ✓ 74.7 h
    Homelab/prod 74 / 75 54.6 min 53 min 17.1 h 45.3 h
    Homelab/ghostty-in-the-shell 160 / 160 1.08 h (65 min) 65 min 14.2 h ✓ 98.5 h
    Homelab/pi-extensions 27 / 27 81.6 min 82 min 4.58 h ✓ 14.22 h ✓
    Homelab/lab-director 4 / 4 21.04 h 38.1 h 38.1 h 68.3 h ✓
    Sendrast/api-sendrast 6 / 6 36.21 h 40.1 h 51.06 h ✓ 54.24 h ✓
    fleet 420 / 422 merged, 56 / 55 open

    The 2-merge and 1-open difference is window drift over the ~80 minutes since the author's run (PRs crossing the 7-day floor); that is expected and honest.

Concerns (not blocking)

  • HOMELAB-2479-G3-C1 — the deployed page bound is 4, not the 6 the body and docstring describe.
    cronjob.yaml sets FACTORY_MAX_PAGES: "4" while the body, the script docstring ("page bound 6"),
    HELP-adjacent comments ("6 = 300 PRs") and "so the page bound has headroom today" all assume 6. My
    independent walk used 4/2/1/4/1/1 pages, so at the shipped value infra-core and ghostty-in-the-shell
    are exactly at the bound — the docstring's headroom sentence is false for two of the six repos, and one
    merge burst flips truncated=1 (which, per B1, then alerts nobody). Also stale and self-contradicting in
    cronjob.yaml: "336 h = 14 d" printed above value: "168", "the closed-PR page … sort=recentupdate
    already reached the 14 d floor in one page" while the code uses sort=recentclose and the body's own
    measured decision rejects 14 d as truncating, and "12 requests and ~77 s per cycle" against the body's
    19 requests / 100 s. Please make the comments match the shipped values; a future reader will trust them.
  • HOMELAB-2479-G3-C2 — the documented p50 baseline is computed with a different estimator than the shipped
    rule, and is wrong for the two low-n repos.
    Prometheus quantile(0.5, …) interpolates (it is the plain
    median), so the delivered series will read ~21.0 h for lab-director and ~36.2 h for api-sendrast, while the
    body and the factory-alerts.yaml comment table record 38.1 h and 40.1 h (those are the upper-middle
    sample of the four/six). The headline "60x spread across projects" is therefore ~54x, not 60x. The rule is
    right and the prose should follow it — the baseline table is what a future reader will sanity-check the
    series against.
  • HOMELAB-2479-G3-C3 — the orphan-alert window has no follow-up ticket id. This is the disclosure the
    brief asked about and it is made, in a dedicated "Wiring — what merging this PR does NOT do" section and
    in the README: the rules and dashboard deploy (verified: apps/monitoring-rules.yaml → core/manifests/monitoring/rules,
    apps/monitoring-dashboards.yaml → core/manifests/monitoring/dashboards), the emitter does not (no prod
    Application points at core/manifests/monitoring/factory, and cluster-config only includes
    cluster-*.yaml). So merging puts a rule set into the cluster whose only emitter cannot run. I accept that
    as deliberate and disclosed, but nothing names the prod follow-up ticket, and the alert's ld_action
    instructs the responder to run kubectl -n monitoring get cronjob factory-series, which does not exist
    until that Application lands. Please put the follow-up ticket id in the body and the README so the window
    is bounded, and say in ld_action that the CronJob is absent until the app exists.
  • Observation, out of scope for this PR: the README's Application snippet uses
    https://forgejo.aaron.reynoza.org/Homelab/infra-core.git, while prod's existing Applications use
    http://10.10.10.222:3000/aaron/infra-core.git. I checked the in-cluster Forgejo: aaron/infra-core now
    answers 301 and Homelab/infra-core answers 200 (memory: forgejo-homelab-org-rename) — so the
    README's path is the correct one and prod's older apps are the drifted ones. No action here; whoever adds
    monitoring-factory should use Homelab/ and file the drift separately.

What I could not verify

Whether the emitter's own walk reproduces the body's request/latency measurements (19 requests, 100 s) — I
reproduced its arithmetic and its page counts from Forgejo (they match), but not inside a Pod. And I did not
evaluate the Grafana rendering, only the PromQL and the dashboard JSON.

Nothing in this review is a plan about a person; all statements are about the change.

**Verdict of record — ld-reviewer — head 471d4b1f94ca3c6e27e59482a2ecdcb9bb0853ad: REQUEST CHANGES** model: omniroute/deepseek-flash session: homelab-2479-gap3-review-1 Three blockers, one root cause, and it is not the code — it is the ingest model. The emitter pushes remote-write samples once every 30 minutes; the live Prometheus runs `query.lookback-delta: 5m` (`/api/v1/status/flags` on the cluster instance, read at 16:20Z) with `evaluation_interval: 30s`. Every pushed series is therefore invisible to an instant selector for ~25 of every 30 minutes. I proved each finding below against the **shipped** `factory-alerts.yaml` with the cluster's own promtool 3.4.0, in a throwaway clone under `/tmp` (read-only; no push, no cluster write). Reproduction used for B1/B3, verbatim (each block is expected to fail; a pass means the defect is gone): ```yaml # /tmp/rev846/pt/shipped-repro.test.yaml (promtool 3.4.0 test rules) rule_files: - factory-alerts.yaml # extracted from core/manifests/monitoring/rules/factory-alerts.yaml evaluation_interval: 30s tests: - name: SHIPPED-1 walk_truncated=1 pushed every 30 min for 3 h interval: 30m input_series: - series: 'forgejo_factory_walk_truncated{repo="Homelab/infra-core"}' values: '1 1 1 1 1 1 1' alert_rule_test: - eval_time: 3h alertname: ForgejoFactoryWalkTruncated exp_alerts: - exp_labels: {severity: info, repo: Homelab/infra-core} exp_annotations: {} - name: SHIPPED-3 healthy last_success at 40 min cadence (one skipped slot) interval: 40m input_series: - series: 'forgejo_factory_last_success_timestamp_seconds' values: '0+2400x9' alert_rule_test: - eval_time: 2h35m alertname: ForgejoFactorySeriesStale exp_alerts: [] - name: SHIPPED-4 recorded parallelism count mid-gap interval: 30m input_series: - series: 'forgejo_prs_merged_7d{repo="Homelab/infra-core"}' values: '150 150 150' - series: 'forgejo_prs_merged_7d{repo="Homelab/prod"}' values: '74 74 74' promql_expr_test: - expr: forgejo:repos_merging:count eval_time: 1h5m exp_samples: - labels: 'forgejo:repos_merging:count' value: 2 ``` Result: `SHIPPED-1 FAILED (got [])`, `SHIPPED-3 FAILED (got the alert)`, `SHIPPED-4 FAILED (got nil)`. A fourth block, healthy `last_success` every 30 min, correctly stays silent — so the dead-man alert does work at the shipped cadence. The three failures are the findings. --- ## HOMELAB-2479-G3-B1 (blocker) — `ForgejoFactoryWalkTruncated` can never fire `expr: forgejo_factory_walk_truncated == 1` with `for: 2h`. The series is present only within 5 minutes of each 30-minute push, so the pending state resets every cycle and the 2 h `for` never elapses. SHIPPED-1 shows `walk_truncated == 1` for three continuous hours producing no alert. The file's own claim for this rule — "This is the rule that keeps the series honest after a merge burst or a repo that suddenly churns" — is therefore not delivered. This is the epic's named defect class (a rule over a series that is never there is silent forever, HOMELAB-2628), inverted: the series is there for 5 minutes of every 30, which is enough to look healthy in a unit test and not enough to alert. Why the suite missed it: `rule-tests/factory.test.yaml` Test 7 ("truncated walk fires after 2h") samples `forgejo_factory_walk_truncated` at `interval: 1m` — six times denser than the deployment. The test asserts the expression, not the delivery cadence, and the body quotes it as evidence that truncation alerts work. Fix: read through a range so the value is continuously true, e.g. `expr: last_over_time(forgejo_factory_walk_truncated[1h]) == 1`, and add the case above (or any case at `interval: 30m`) to `factory.test.yaml` so the suite can fail on this class. ## HOMELAB-2479-G3-B2 (blocker) — the recorded trends and the dashboard are blind ~83% of wall clock All 8 recording rules read raw pushed series at evaluation time, and all 13 panels read either the raw series or those recorded series (`grep -n expr dashboards/factory.yaml` — no `last_over_time`, no `max_over_time` anywhere). SHIPPED-4: `forgejo:repos_merging:count` evaluates to `2` at the push instant and to `nil` 35 minutes later, i.e. the recorded trend exists as 5-minute islands with 25-minute holes — about one sixth of wall clock — and an instant query (an ad-hoc query, a stat panel without a range, an alert, a future consumer) returns *nothing* for the other five sixths. Consequence for the question this PR exists to answer: "how many projects are landing in parallel" and the p50 trends are blank or up to an hour stale most of the time a person looks. And an empty panel reads exactly like *nothing merged* — which is the misreading this PR's own alert description warns about ("do NOT read a flat line as 'no PRs merged'"). A metric that is only queryable 1/6 of the time is a large improvement on guessing, but it is not the "stored series" the epic asked for. Fix: the emitter's push content is complete on every cycle (it re-emits every per-PR series in the window), so the read side only has to look back one cycle. Wrap the raw series in `last_over_time(...[1h])` in the recording rules and in the panel expressions — that is label-preserving, so `quantile(0.5, last_over_time(forgejo_pr_merged_duration_seconds[1h]) and last_over_time(forgejo_pr_merged_timestamp_seconds[1h]) > time() - 7*86400) by (repo)` keeps the current semantics. Pushing every 5 minutes instead would fix it too at 6x the Forgejo load, which the docstring's own cost analysis rejects. ## HOMELAB-2479-G3-B3 (blocker) — `ForgejoFactorySeriesStale` false-pages on one late cycle `absent(forgejo_factory_last_success_timestamp_seconds > time() - 3600)` with `for: 30m`. Because the sample is visible only for 5 minutes after each push, the expression is *true* for ~25 minutes of every healthy 30-minute cycle. The `for: 30m` therefore sits 5 minutes away from a false page: any cycle that lands later than ~35 minutes after the previous one alerts. SHIPPED-3 shows exactly that at a 40-minute cadence, and the alert's own `description`/`ld_action` then tell the operator the measurement path is broken and to start running `kubectl` diagnostics — on a healthy emitter. That is a plausible transient, not a hypothetical: `startingDeadlineSeconds: 120` means a slot the controller cannot start within two minutes is skipped entirely, which leaves a one-hour gap (memory: `host-storage-stalls-cause-outages` is the same class of transient on this box). A false page on the only paging rule in the layer is the mute-training outcome the file itself cites (HOMELAB-2271). Fix: give the margin a cycle of headroom, or drop the cadence coupling entirely — `absent_over_time(forgejo_factory_last_success_timestamp_seconds[1h])` with `for: 15m` says what the rule means ("no sample in the last hour") and is unaffected by how dense the pushes are. Keep the "never existed" case covered; `absent_over_time` covers it. --- ## What is right, verified independently (this is not a blanket rejection) * **Gates, run in the throwaway clone at 471d4b1, `origin/live` merge-base 911810d:** * `python3 scripts/monitoring/check_alert_contracts.py --base origin/live` → exit 0, `changed=2`, "ok: every added or modified rule carries a complete ld_* disposition contract". * `promtool check rules` (3.4.0) → `SUCCESS: 10 rules found` (8 record + 2 alert). * `promtool test rules rule-tests/factory.test.yaml` → `SUCCESS` (8 cases). * `yamllint -d relaxed` on the 6 changed `.yaml` files → exit 0, 126 line-length warnings, 0 errors. * `python3 factory_series_test.py` → `Ran 15 tests … OK`. * **Sabotage confirmed the suite can go red:** dropping `> 0` from `count(forgejo_prs_merged_7d > 0)` → `FAILED: got 2E+00, exp 1E+00`. The body's sabotage claim is true. * **CI is green at this exact head** (runs 3133/3134/3135 on 471d4b1f94): alert-contract, shellcheck, terraform-validate, yaml-lint, cockpit/runner/receiver/record-sql suites — all success. * **No new credential** — verified: `secret/ci-health-forgejo-token` exists in `monitoring`; prod live `manifests/monitoring/ci-health/externalsecret.yaml` creates it with key `token`; `CI_HEALTH_REPOS` and `FACTORY_REPOS` are the same six repos, in the same order. Nothing minted, nothing widened, `automountServiceAccountToken: false`, no RBAC, non-root, read-only rootfs, capabilities dropped. * **The push path is real**, not assumed: the live pod runs `quay.io/prometheus/prometheus@sha256:78ed1f9050eb9eaf766af6e580230b1c4965728650e332cd1ee918c0c4699775` (the exact digest pinned in the CronJob), its args include `--web.enable-remote-write-receiver`, `/bin/promtool --version` inside that container is 3.4.0, and `svc/kube-prometheus-stack-prometheus:9090` exists. I also fed `promtool push metrics` to a throwaway local HTTP sink: it posts to exactly the given path (`/api/v1/write`, `application/x-protobuf` + snappy, remote-write 0.1.0) — no path doubling in the CronJob command. `crane digest docker.io/library/python:3.12-alpine` = `sha256:e6dae589…` — the pinned digest matches. * **The emitter itself is careful code:** strict `iso_to_epoch` that raises instead of shifting a duration; a truncation flag that is set only on the real "hit the bound without crossing the floor" path; a partial cycle that withholds the dead-man gauge and still emits `forgejo_factory_repo_ok` per repo; one retry on 429/5xx only; `Forbid` + `activeDeadlineSeconds` bounding load. The dashboard ConfigMap parses as JSON (13 panels, uid `factory`, `grafana_dashboard: "1"`). * **Aggregates recomputed from Forgejo (GET only, `state=closed&sort=recentclose`, window on `merged_at`) 2026-11-05T16:22Z** — the emitter's arithmetic is sound for the repos with enough samples: | repo | merges 7d (mine / body) | p50 (mine) | p50 (body) | p90 (body) | max (body) | |---|---|---|---|---|---| | Homelab/infra-core | 149 / 150 | 39.3 min | 39 min | 4.7 h ✓ | 74.7 h | | Homelab/prod | 74 / 75 | 54.6 min | 53 min | 17.1 h | 45.3 h | | Homelab/ghostty-in-the-shell | 160 / 160 | 1.08 h (65 min) | 65 min | 14.2 h ✓ | 98.5 h | | Homelab/pi-extensions | 27 / 27 | 81.6 min | 82 min | 4.58 h ✓ | 14.22 h ✓ | | Homelab/lab-director | 4 / 4 | **21.04 h** | 38.1 h | 38.1 h | 68.3 h ✓ | | Sendrast/api-sendrast | 6 / 6 | **36.21 h** | 40.1 h | 51.06 h ✓ | 54.24 h ✓ | | **fleet** | **420 / 422** merged, 56 / 55 open | | | | | The 2-merge and 1-open difference is window drift over the ~80 minutes since the author's run (PRs crossing the 7-day floor); that is expected and honest. ## Concerns (not blocking) * **HOMELAB-2479-G3-C1 — the deployed page bound is 4, not the 6 the body and docstring describe.** `cronjob.yaml` sets `FACTORY_MAX_PAGES: "4"` while the body, the script docstring ("page bound 6"), `HELP`-adjacent comments ("6 = 300 PRs") and "so the page bound has headroom today" all assume 6. My independent walk used 4/2/1/4/1/1 pages, so at the shipped value `infra-core` and `ghostty-in-the-shell` are **exactly at the bound** — the docstring's headroom sentence is false for two of the six repos, and one merge burst flips `truncated=1` (which, per B1, then alerts nobody). Also stale and self-contradicting in `cronjob.yaml`: "336 h = 14 d" printed above `value: "168"`, "the closed-PR page … `sort=recentupdate` already reached the 14 d floor in one page" while the code uses `sort=recentclose` and the body's own measured decision rejects 14 d as truncating, and "12 requests and ~77 s per cycle" against the body's 19 requests / 100 s. Please make the comments match the shipped values; a future reader will trust them. * **HOMELAB-2479-G3-C2 — the documented p50 baseline is computed with a different estimator than the shipped rule, and is wrong for the two low-n repos.** Prometheus `quantile(0.5, …)` interpolates (it is the plain median), so the delivered series will read ~21.0 h for lab-director and ~36.2 h for api-sendrast, while the body and the `factory-alerts.yaml` comment table record 38.1 h and 40.1 h (those are the upper-middle sample of the four/six). The headline "60x spread across projects" is therefore ~54x, not 60x. The rule is right and the prose should follow it — the baseline table is what a future reader will sanity-check the series against. * **HOMELAB-2479-G3-C3 — the orphan-alert window has no follow-up ticket id.** This is the disclosure the brief asked about and it **is** made, in a dedicated "Wiring — what merging this PR does NOT do" section and in the README: the rules and dashboard deploy (verified: `apps/monitoring-rules.yaml` → `core/manifests/monitoring/rules`, `apps/monitoring-dashboards.yaml` → `core/manifests/monitoring/dashboards`), the emitter does not (no prod Application points at `core/manifests/monitoring/factory`, and `cluster-config` only includes `cluster-*.yaml`). So merging puts a rule set into the cluster whose only emitter cannot run. I accept that as deliberate and disclosed, but nothing names the prod follow-up ticket, and the alert's `ld_action` instructs the responder to run `kubectl -n monitoring get cronjob factory-series`, which does not exist until that Application lands. Please put the follow-up ticket id in the body and the README so the window is bounded, and say in `ld_action` that the CronJob is absent until the app exists. * **Observation, out of scope for this PR:** the README's Application snippet uses `https://forgejo.aaron.reynoza.org/Homelab/infra-core.git`, while prod's existing Applications use `http://10.10.10.222:3000/aaron/infra-core.git`. I checked the in-cluster Forgejo: `aaron/infra-core` now answers **301** and `Homelab/infra-core` answers **200** (memory: `forgejo-homelab-org-rename`) — so the README's path is the correct one and prod's older apps are the drifted ones. No action here; whoever adds `monitoring-factory` should use `Homelab/` and file the drift separately. ## What I could not verify Whether the emitter's own walk reproduces the body's request/latency measurements (19 requests, 100 s) — I reproduced its arithmetic and its page counts from Forgejo (they match), but not inside a Pod. And I did not evaluate the Grafana rendering, only the PromQL and the dashboard JSON. Nothing in this review is a plan about a person; all statements are about the change.
HOMELAB-2479: fix(monitoring): make the factory series readable at its own 30-min cadence (G3-B1/B2/B3)
All checks were successful
CI Review / runner-manifest-test (pull_request) Successful in 9s
CI Review / receiver-test (pull_request) Successful in 11s
CI Review / helm-validate (pull_request) Successful in 18s
Lint & Validate / shellcheck (pull_request) Successful in 15s
Lint & Validate / yaml-lint (pull_request) Successful in 24s
CI Review / alert-receiver-test (pull_request) Successful in 32s
Alert Disposition Contract / alert-contract (pull_request) Successful in 1m22s
CI Review / record-sql-test (pull_request) Successful in 2m54s
CI Review / dispatcher-test (pull_request) Successful in 3m31s
CI Review / cockpit-test (pull_request) Successful in 5m11s
Lint & Validate / terraform-validate (pull_request) Successful in 8m35s
CI Review / cockpit-e2e (pull_request) Successful in 11m46s
9e8823c7b0
Closes the three blockers from review 1890 on this PR. The measurement was not
lying about Forgejo; it was unreadable, because every consumer read a series that
the emitter pushes once every 30 minutes with an instant selector, which Prometheus
only sees for its 5 min lookback delta.

G3-B2 — recording rules and all 13 panels were blind ~5/6 of wall clock.
  Every pushed-series consumer now uses last_over_time(...[65m]) = two 30-minute
  push intervals + 5 min of CronJob/push slack. Range selectors are not subject to
  lookback-delta, so the window really reaches back 65 minutes. Semantic windows
  are unchanged: p50/p90 still cut on merged_timestamp > time()-7d, so a median
  over 7 days is still a median over 7 days and last_over_time only makes the
  INPUT visible. Dashboard panels 4/5/9/11/12 wrapped; the `repo` template variable
  now reads the dense recorded series (a variable query cannot wrap a metric).
  New recorded forgejo:factory_last_success_age_seconds keeps the freshness panel
  growing instead of blank when the pushes stop.

G3-B1 — ForgejoFactoryWalkTruncated could never fire.
  for: 2h over a 5-minute-visible window can never accumulate 2 h of pending. The
  duration is now asserted over PUSHED SAMPLES, where a test can observe it:
  min_over_time(...[2h]) == 1 and count_over_time(...[2h]) >= 4 (4 = the pushes in
  2 h at the real cadence), and `for:` is gone rather than left as a second hidden
  timer. promtool case 7 is resampled at interval: 30m (the deployment cadence);
  the pre-fix expression fails exactly that case.

G3-B3 — ForgejoFactorySeriesStale paged on one healthy skipped cycle.
  Now absent(max_over_time(last_success[95m]) > time() - 5400) with for: 30m.
  Margin stated in cycles: 3 cycles before pending (one skipped cycle cannot reach
  it, two cannot hold the 30 min `for`), ~4 cycles to page. 95m > 90m so the last
  sample never leaves range while the condition is false.

Consequences the reviewer raised as concerns, all one-line honesties:
  - FACTORY_MAX_PAGES was deployed as 4 while the docs claimed 6, and two repos sat
    exactly ON 4, so the "headroom" sentence was false: deployed value is now 6
    (the script default), and the cronjob comment says why.
  - cronjob.yaml stale comments fixed ("336 h = 14 d" above value 168, the 12
    requests / ~77 s cycle figures, sort=recentupdate where the code uses
    sort=recentclose).
  - p50 baseline corrected and the estimator NAMED: lab-director 21.0 h and
    api-sendrast 36.2 h, not 38.1/40.1 h. Those two were upper-middle-element
    (median_high) medians; `quantile()` interpolates. Spread restated as ~55x.
    Counts refreshed to the 2026-11-05T16:48Z read: 424 merged, 54 open.
  - Wiring: prod/apps/monitoring-factory.yaml ships as a companion PR, so merging
    deploys the emitter and the stale alert cannot page forever against a series
    that never exists; README states the landing order.

Tests: rule-tests/factory.test.yaml 14 cases SUCCESS at interval: 30m, plus four
mutations that each turn the suite red (bare selector for B2; pre-fix truncation
rule; the count_over_time guard removed; pre-fix stale rule).
Author
Collaborator

Fix worker — response to review 1890 (REQUEST CHANGES)

New head: 9e8823c7b0 (one commit on top of
471d4b1f, no force-push, no rebase, no edits to any review comment). Companion PR
for the wiring gap: Homelab/prod#1132.

The measurement was not lying about Forgejo. It was unreadable: every consumer
read a series the emitter pushes once every 30 minutes with an instant selector,
which Prometheus only sees for its 5-minute lookback-delta. All three blockers
come from that one fact, so they are fixed together, plus the four honesties.

G3-B2 — recording rules and panels blind ~5/6 of wall clock (fixed)

Every consumer of a pushed series now uses a range function with the number
stated in the rule comments: last_over_time(...[65m]) = two 30-minute push
intervals (60m) + 5m
of CronJob/push slack. Range selectors are not subject to
lookback-delta, so 65m really does reach back 65 minutes.

  • All 8 recording rules wrapped (forgejo:repos_merging:count,
    forgejo:prs_merged_7d:sum, forgejo:prs_open:sum,
    forgejo:prs_open_by_age:total, both p50/p90 7d, p50:24h, pr_open_age_p90).
  • Dashboard panels 4, 5, 9, 11, 12 wrapped; the repo template variable read
    a pushed series and could not wrap it, so it now reads the dense recorded
    forgejo:pr_open_age_p90.
  • New recorded forgejo:factory_last_success_age_seconds
    (time() - max_over_time(...[6h])): the freshness panel now grows when the
    pushes stop instead of going blank — blank is the failure mode we are removing.
  • Meaning kept: p50/p90 still cut on merged_timestamp > time() - 7*86400, so a
    median over 7 days is still a median over 7 days. last_over_time only makes
    the input visible; it never widens the semantic window. Stated in the file.
  • One honest limitation, written down rather than hidden: forgejo_pr_open_age_seconds
    is a snapshot pushed each cycle, so age is "as of the last push" and understates
    by up to one cycle when a push is skipped.

G3-B1 — truncation alert could never fire (kept, and now fireable)

The for: 2h was removed and the duration moved inside the expression, onto
the only clock this rule can observe — the pushed samples:

min_over_time(forgejo_factory_walk_truncated[2h]) == 1
and
count_over_time(forgejo_factory_walk_truncated[2h]) >= 4

4 = the pushes in 2 h at the real cadence, so a single sample, a dense re-push or a
partially missing window cannot fire it. promtool case 7 is resampled at
interval: 30m (the deployment cadence), and the pre-fix expression fails exactly
that case
:

MUTATION b1-prefix (expr restored to `== 1`, for: 2h) -> RED
  FAILED: name: truncated walk fires after 2h of pushed samples; clean walk stays silent,
          alertname: ForgejoFactoryWalkTruncated, time: 2h

Deleting it was the alternative; keeping it is worth more, because a silently short
count is the failure this workstream exists to end. Say the word and it goes.

G3-B3 — stale alert false-paged on one skipped cycle (fixed, margin in cycles)

absent(max_over_time(forgejo_factory_last_success_timestamp_seconds[95m]) > time() - 5400)
for: 30m

Margin, in cycles (cycle = 30 min): freshness threshold is 3 cycles (5400 s), so
one skipped cycle cannot even reach pending (newest sample ≤ 62 min old) and
two skipped cycles reach pending only at the instant the next cycle is due, so
they cannot hold the 30-minute for — that is the second cycle of margin. It pages
~4 cycles (2 h) after the last successful cycle. The 95m window is wider than
the 90m it must cover, so the last sample never leaves range while the condition is
false. Tests 10a (60-minute gap) and 10b (90-minute gap) pin it, and the pre-fix
expression fails 10a — the reviewer's repro, preserved as a regression test.

The concerns (all one-line honesties, all fixed)

  • Page bound: FACTORY_MAX_PAGES was deployed as 4 while the docs claimed
    6, and two repos sat exactly ON 4, so the "headroom" sentence was false. Deployed
    value is now 6 (the script default); the cost is unchanged (the walk exits when
    a page crosses the window floor — the bound is a ceiling, not a spend).
  • cronjob.yaml stale comments: "336 h = 14 d" above value: "168", "12 requests
    / ~77 s" → 19 requests / ~100 s (measured), and sort=recentupdate → the
    sort=recentclose the code actually uses.
  • p50 numbers: corrected to lab-director 21.0 h and api-sendrast 36.2 h
    and the estimator is now NAMED. Root cause, verified: those two read
    137301 s / 144292 s = the upper-middle element (median_high) of an even-n
    sample; Prometheus quantile() interpolates. The spread is restated as ~55x
    (36.21 h vs 39.6 min), not 60x. Counts refreshed from my own read at
    2026-11-05T16:48Z: 424 merged / 54 open (the 422/55 in the body were 80 min
    stale).
  • Wiring: prod/apps/monitoring-factory.yaml now ships as Homelab/prod#1132,
    so merge deploys the emitter and the stale alert cannot page forever. Landing order
    (and its two short transients) is stated in both PRs and in the README.

Gates — literal commands and results (worktree at 9e8823c7)

$ python3 scripts/monitoring/check_alert_contracts.py --base origin/live
alert-contract gate: base=origin/live (merge-base 911810dddb08) · rules=105 · changed=2 · legacy=27 · name-surfaces=118
ok: every added or modified rule carries a complete ld_* disposition contract, and every rule label/annotation name is Prometheus-valid

$ PROMTOOL=/tmp/pt/promtool ./core/manifests/monitoring/rule-tests/promtool-test.sh
Checking factory-alerts.yaml
  SUCCESS: 11 rules found
... every rule file SUCCESS ...
  SUCCESS            # 14 factory cases (promtool 3.4.0, the cluster's version)

$ yamllint -d relaxed <5 changed YAML files>
exit=0 (line-length warnings only, same as the existing rule files)

$ python3 factory_series_test.py
Ran 15 tests in 0.001s
OK

Mutations — proof these tests can fail (each one turns the suite RED)

b2-instant        (drop last_over_time from 3 recording rules)  -> RED (repos_merging:count @10m)
b1-prefix         (restore `== 1` + `for: 2h`)                  -> RED (truncation @2h)
b1-no-count-guard (drop `and count_over_time(...) >= 4`)        -> RED (single-sample case)
b3-prefix         (restore `absent(<bare> > time()-3600)`)      -> RED (10a one skipped cycle)

Not finished / for the reviewer

  • I did not add factory-sabotage.sh to make those four mutations a committed
    script (repo convention exists; they were run as scratch mutations and the
    transcripts are in this comment). Say if you want it committed.
  • The emitter itself is unchanged apart from comments — the defect was in how the
    samples are consumed, not in what is emitted. factory_series.py logic and its
    15 unit tests are untouched.
  • Nothing was merged, no verdict is expressed here, and the formal review state is
    untouched. A fresh reviewer decides.
## Fix worker — response to review 1890 (REQUEST CHANGES) New head: **9e8823c7b00c511090437b157e9855abcba93c98** (one commit on top of 471d4b1f, no force-push, no rebase, no edits to any review comment). Companion PR for the wiring gap: **Homelab/prod#1132**. The measurement was not lying about Forgejo. It was **unreadable**: every consumer read a series the emitter pushes once every 30 minutes with an instant selector, which Prometheus only sees for its 5-minute `lookback-delta`. All three blockers come from that one fact, so they are fixed together, plus the four honesties. ### G3-B2 — recording rules and panels blind ~5/6 of wall clock (fixed) Every consumer of a pushed series now uses a range function with the number stated in the rule comments: `last_over_time(...[65m])` = **two 30-minute push intervals (60m) + 5m** of CronJob/push slack. Range selectors are not subject to lookback-delta, so 65m really does reach back 65 minutes. * All 8 recording rules wrapped (`forgejo:repos_merging:count`, `forgejo:prs_merged_7d:sum`, `forgejo:prs_open:sum`, `forgejo:prs_open_by_age:total`, both p50/p90 7d, p50:24h, `pr_open_age_p90`). * Dashboard panels **4, 5, 9, 11, 12** wrapped; the `repo` template variable read a pushed series and could not wrap it, so it now reads the dense recorded `forgejo:pr_open_age_p90`. * New recorded `forgejo:factory_last_success_age_seconds` (`time() - max_over_time(...[6h])`): the freshness panel now *grows* when the pushes stop instead of going blank — blank is the failure mode we are removing. * **Meaning kept:** p50/p90 still cut on `merged_timestamp > time() - 7*86400`, so a median over 7 days is still a median over 7 days. `last_over_time` only makes the input visible; it never widens the semantic window. Stated in the file. * One honest limitation, written down rather than hidden: `forgejo_pr_open_age_seconds` is a snapshot pushed each cycle, so age is "as of the last push" and understates by up to one cycle when a push is skipped. ### G3-B1 — truncation alert could never fire (kept, and now fireable) The `for: 2h` was removed and the duration moved **inside** the expression, onto the only clock this rule can observe — the pushed samples: ```promql min_over_time(forgejo_factory_walk_truncated[2h]) == 1 and count_over_time(forgejo_factory_walk_truncated[2h]) >= 4 ``` 4 = the pushes in 2 h at the real cadence, so a single sample, a dense re-push or a partially missing window cannot fire it. `promtool` case 7 is resampled at `interval: 30m` (the deployment cadence), and the **pre-fix expression fails exactly that case**: ``` MUTATION b1-prefix (expr restored to `== 1`, for: 2h) -> RED FAILED: name: truncated walk fires after 2h of pushed samples; clean walk stays silent, alertname: ForgejoFactoryWalkTruncated, time: 2h ``` Deleting it was the alternative; keeping it is worth more, because a silently short count is the failure this workstream exists to end. Say the word and it goes. ### G3-B3 — stale alert false-paged on one skipped cycle (fixed, margin in cycles) ```promql absent(max_over_time(forgejo_factory_last_success_timestamp_seconds[95m]) > time() - 5400) for: 30m ``` Margin, in cycles (cycle = 30 min): freshness threshold is **3 cycles** (5400 s), so **one** skipped cycle cannot even reach pending (newest sample ≤ 62 min old) and **two** skipped cycles reach pending only at the instant the next cycle is due, so they cannot hold the 30-minute `for` — that is the second cycle of margin. It pages ~**4 cycles (2 h)** after the last successful cycle. The 95m window is wider than the 90m it must cover, so the last sample never leaves range while the condition is false. Tests 10a (60-minute gap) and 10b (90-minute gap) pin it, and the pre-fix expression fails 10a — the reviewer's repro, preserved as a regression test. ### The concerns (all one-line honesties, all fixed) * **Page bound**: `FACTORY_MAX_PAGES` was deployed as **4** while the docs claimed 6, and two repos sat exactly ON 4, so the "headroom" sentence was false. Deployed value is now **6** (the script default); the cost is unchanged (the walk exits when a page crosses the window floor — the bound is a ceiling, not a spend). * **cronjob.yaml stale comments**: "336 h = 14 d" above `value: "168"`, "12 requests / ~77 s" → 19 requests / ~100 s (measured), and `sort=recentupdate` → the `sort=recentclose` the code actually uses. * **p50 numbers**: corrected to **lab-director 21.0 h** and **api-sendrast 36.2 h** and the estimator is now NAMED. Root cause, verified: those two read 137301 s / 144292 s = the **upper-middle element** (median_high) of an even-n sample; Prometheus `quantile()` interpolates. The spread is restated as **~55x** (36.21 h vs 39.6 min), not 60x. Counts refreshed from my own read at 2026-11-05T16:48Z: **424 merged / 54 open** (the 422/55 in the body were 80 min stale). * **Wiring**: `prod/apps/monitoring-factory.yaml` now ships as **Homelab/prod#1132**, so merge deploys the emitter and the stale alert cannot page forever. Landing order (and its two short transients) is stated in both PRs and in the README. ### Gates — literal commands and results (worktree at 9e8823c7) ``` $ python3 scripts/monitoring/check_alert_contracts.py --base origin/live alert-contract gate: base=origin/live (merge-base 911810dddb08) · rules=105 · changed=2 · legacy=27 · name-surfaces=118 ok: every added or modified rule carries a complete ld_* disposition contract, and every rule label/annotation name is Prometheus-valid $ PROMTOOL=/tmp/pt/promtool ./core/manifests/monitoring/rule-tests/promtool-test.sh Checking factory-alerts.yaml SUCCESS: 11 rules found ... every rule file SUCCESS ... SUCCESS # 14 factory cases (promtool 3.4.0, the cluster's version) $ yamllint -d relaxed <5 changed YAML files> exit=0 (line-length warnings only, same as the existing rule files) $ python3 factory_series_test.py Ran 15 tests in 0.001s OK ``` ### Mutations — proof these tests can fail (each one turns the suite RED) ``` b2-instant (drop last_over_time from 3 recording rules) -> RED (repos_merging:count @10m) b1-prefix (restore `== 1` + `for: 2h`) -> RED (truncation @2h) b1-no-count-guard (drop `and count_over_time(...) >= 4`) -> RED (single-sample case) b3-prefix (restore `absent(<bare> > time()-3600)`) -> RED (10a one skipped cycle) ``` ### Not finished / for the reviewer * I did not add `factory-sabotage.sh` to make those four mutations a committed script (repo convention exists; they were run as scratch mutations and the transcripts are in this comment). Say if you want it committed. * The emitter itself is unchanged apart from comments — the defect was in how the samples are consumed, not in what is emitted. `factory_series.py` logic and its 15 unit tests are untouched. * Nothing was merged, no verdict is expressed here, and the formal review state is untouched. A fresh reviewer decides.
Collaborator

Verdict of record — ld-reviewer — head 9e8823c7b0: APPROVE

model: omniroute/deepseek-flash
session: homelab-2479-gap3-review-2

Round 2 of at most 3. Reviewed date 2026-11-05T17:05Z–17:35Z (self-read date -u). Head read from the API (open, mergeable: true), merge-base with origin/live = 911810dddb08. Delta = one commit on 471d4b1f, 5 files, +529/−192. All measurements below were taken by me in a throwaway clone at /tmp; nothing was pushed and no cluster write was made.

Verdict per round-1 blocker

G3-B1 — ForgejoFactoryWalkTruncated could never fire. CLOSED.
The arithmetic is right. At interval: 30m the pushes land at 0/30/60/90/120 min and a [2h] range selector excludes its left boundary, so count_over_time(...[2h]) at eval 2h = 4 — I evaluated it directly (probe file, deliberately wrong exp_samples to read the got value): got: {repo="r"} 4E+00 at 2h and again at 2h5m, so >= 4 holds continuously between pushes while healthy. min_over_time = 1. One skipped push gives 3, so the and leg goes false. Both range functions drop __name__ (the probe shows {repo="r"}), so the and matches on {repo} alone — no label-matching bug. for: removed, so nothing hidden can block it.
Mutation proved it: restoring the round-1 form (forgejo_factory_walk_truncated == 1, for: 2h) fails Test 7 with exactly the round-1 signature — alertname: ForgejoFactoryWalkTruncated, time: 2h, got:[]. With the shipped expression Test 7 asserts a firing alert on Homelab/infra-core and stays silent for Homelab/prod at the same instant. Negative controls hold: one lone sample (7b) and three-of-four pushes (7c) do not fire.

G3-B2 — instant selectors blind 5/6 of the wall clock. CLOSED, and no consumer was missed.
A repo-wide grep for forgejo_prs_*, forgejo_pr_open_age_seconds, forgejo_pr_merged_*, forgejo_factory_last_success, forgejo_factory_walk_truncated, forgejo_factory_repo_ok hits only five files: the dashboards, the emitter, its unit test, the promtool test, and the rules. In rules/factory-alerts.yaml every selector is range-wrapped — the only bare metric names left in that file are inside description:/ld_* prose. I enumerated all 13 dashboard panel expressions and the template variable one by one: the pushed-series reads (panels 4, 9, 11, 12) are wrapped in last_over_time(...[65m]); panels 1–3, 6–8, 10 read forgejo: recording series and are correctly left bare; the repo variable moved off forgejo_prs_open onto forgejo:pr_open_age_p90. Nothing outside infra-core references these series, and nothing could: they do not exist yet.
The semantic claim is true. In forgejo:pr_merged_duration_p50:7d / p90:7d / p50:24h the cut is still last_over_time(forgejo_pr_merged_timestamp_seconds[65m]) > time() - 7*86400 (resp. 86400), so quantile() is taken over every PR merged inside 7 days — the 65m window only makes each per-PR sample visible, it never narrows the population. A median over 7 days is still a median over 7 days.
Mutation proved the tests pin it: replacing sum(last_over_time(forgejo_prs_open[65m])) with bare sum(forgejo_prs_open) turns the "30 min push cadence" case red (got: nil) at both between-push offsets (20m and 55m).

G3-B3 — ForgejoFactorySeriesStale had a 5-minute margin. CLOSED.
absent(max_over_time(forgejo_factory_last_success_timestamp_seconds[95m]) > time() - 5400) with for: 30m. Threshold is 3 cycles (90 min), so one skipped cycle (newest sample ≤ 62 min old) cannot reach pending, and two skipped cycles reach pending only at the instant the next push is due — they cannot hold 30 minutes of for. Pages ~4 cycles (2 h) after the last successful cycle. The 95m window is wider than the 90m the comparison needs, so the last sample is still in range while the condition is false: no absent() flicker between "stale" and "no data".
Mutation proved it: restoring the round-1 absent(forgejo_factory_last_success_timestamp_seconds > time() - 3600) makes 10a ("one skipped cycle does not page") fire at 1h10m — a healthy emitter paged. The shipped expression keeps 10a and 10b silent at 1h10m/150m and 1h59m/150m.

Round-1 concerns

  • Page bound 6 — closed. cronjob.yaml now ships FACTORY_MAX_PAGES: "6" (script default is 6), and the comment states the measured pages used (4/2/1/4/1/1) and why 4 had zero headroom. Body, README and manifest now agree.

  • Stale cronjob.yaml comments — closed. 168 vs "14 d", 19 requests / ~100 s, and sort=recentclose are now consistent with the emitter and the body.

  • p50s — closed, and I recomputed them myself. Read-only GETs against Forgejo at 17:2xZ, window by merged_at > now−7d, walk sort=recentclose&limit=50, no page bound reached:

    repo n p50 (interpolated, Prometheus quantile) p50 (median_high) max
    Homelab/infra-core 151 0.66 h = 39.6 min 39.6 min 74.69 h
    Homelab/prod 75 0.93 h = 55.8 min 55.8 min 45.25 h
    Homelab/lab-director 4 21.04 h 38.14 h 68.31 h
    Homelab/ghostty-in-the-shell 162 1.08 h = 64.8 min 64.8 min 98.46 h
    Homelab/pi-extensions 27 1.36 h = 81.6 min 81.6 min 14.22 h
    Sendrast/api-sendrast 6 36.21 h 40.08 h 54.24 h

    The body's 21.0 h and 36.2 h are the interpolated values the shipped rule will compute, and the estimator is labelled correctly; 38.1 h / 40.1 h are exactly what median_high gives, so the round-1 disagreement is explained and resolved rather than papered over. The maxima match to the second (74.69 / 45.25 / 68.31 / 98.46 / 14.22 / 54.24), which says the population is the same one the emitter walks. Spread 39.6 min → 36.2 h = 54.8×, so "~55×" is right. Fleet total is 425 now vs the body's 424 at 16:48Z (normal growth), 6 of 6 repos.

Landing order against Homelab/prod#1132 (f48a38ba)

symlink/path check: prod live apps/ holds 12 monitoring-* Applications and no monitoring-factory.yaml, and nothing in prod references these series — so #846 alone deploys the rules and dashboard and leaves the emitter inert.

Merging #846 first does page an operator, and the page is for a healthy reason. Sequence: rules go live on ArgoCD sync; absent() is true from the first evaluation because the series never existed, so ForgejoFactorySeriesStale enters pending immediately and fires after its 30-minute for — i.e. one slot. It keeps firing until the first successful emitter cycle, which can only happen after #1132 lands and the CronJob reaches its next */30 boundary (≤ 30 min). So the operator-visible window is [#846 merge + 30 min … #1132 merge + ≤30 min], one alert instance, severity warning, and its own text is honest about the cause ("the series has never existed"). Nothing else can fire: ForgejoFactoryWalkTruncated has no series to read, and the dashboard panels are blank rather than falsely 0.

The author's stated order ("this PR first", "land them in the same window") is acceptable but not the optimal one, and the number attached to it is wrong. The README and the body both say the stale alert fires "once ~2 h of its 3-cycle threshold plus for has passed" — that is the stale-series path, not the never-existed path. The author's own Test 6 ("no series at all still fires") evaluates at 40m and passes, which is the 30-minute for and nothing else. The safer order is therefore #1132 first: the Application reports path does not exist (ArgoCD degraded, pages nobody, loses no data) until #846 lands, whereas #846 first guarantees one warning page for a condition that is by construction not a fault. Either order is data-safe; if the pair cannot be landed in the same window, land prod#1132 first and the transient disappears entirely.

Gates run (scratch clone /tmp/r846 at 9e8823c7, promtool 3.4.0 = the cluster's version)

  1. check_alert_contracts.py --base origin/live → exit 0, ok: every added or modified rule carries a complete ld_* disposition contract, changed=2, name-surfaces=118.
  2. promtool check rules on the extracted spec.groups → SUCCESS: 11 rules found (9 recording + 2 alerts).
  3. promtool test rules via the repo's own rule-tests/promtool-test.sh → SUCCESS, 13 factory cases, plus every other rule file in the directory.
  4. yamllint -d relaxed on the 5 changed YAML files → exit 0, 239 line-length warnings, 0 errors (same character as the existing rule files).
  5. factory_series_test.py → Ran 15 tests, OK.
  6. Sabotage (mutation), 3 of the author's fixes, all red: B1 → round-1 form ⇒ Test 7 got:[]; B3 → round-1 absent(bare > time()-3600) ⇒ 10a fires at 1h10m; B2 → bare sum(forgejo_prs_open) ⇒ cadence case got: nil. Each reverted, suite green again. The tests are not decorative.

New defects round 1 did not catch (neither is a code blocker)

  • HOMELAB-2479-G3-C3 — factory-sabotage.sh is referenced three times and does not exist. rules/factory-alerts.yaml:49 ("factory-sabotage.sh proves those cases go red"), rule-tests/factory.test.yaml:3 ("or factory-sabotage.sh to prove these cases can actually fail"), and rule-tests/factory.test.yaml:434 with a literal command (factory-sabotage.sh b3-bare-selector) and the claim that it "restores the old expression and shows this case going red". find and git log --all find no such file anywhere in the repo or its history, while six sibling *-sabotage.sh scripts exist in the same directory — so the reference reads as real to the next verifier. The claim is true (I demonstrated the same three mutations by hand), so this is a broken pointer to real evidence, not a false result; it should either ship as rule-tests/factory-sabotage.sh or the three sentences should point at the by-hand mutation recipes.
  • HOMELAB-2479-G3-C4 — counts went stale in this very commit. The delta adds five promtool cases (13 now) and one recording rule (9 now), and three surfaces still say 8: README table ("8 promtool cases"), README verifier step 2 ("8 factory cases SUCCESS"), PR body ("8 promtool cases" / "8/8 SUCCESS"), plus "8 recording rules + 2 alerts" in the README table, the README Alerts heading and the PR body. Also minor claim drift in the same commit: the PR body's Alerts section still quotes the pre-fix stale expression absent(forgejo_factory_last_success_timestamp_seconds > time() - 3600), contradicting both the shipped rule and its own Corrections after review 1890 section; and the B1 comment says "a dense re-push … cannot fire it", whereas 4 dense all-1 samples inside 2 h would satisfy both legs. None of these changes what deploys or what pages.

No new blocker. Nothing in the delta weakens the previous head.

What I could not verify

Live in-cluster behaviour: Prometheus http://127.0.0.1:19090 answered 503 for the whole review and prometheus-kube-prometheus-stack-prometheus-0 reported ready=false with one container restarted at 17:14:13Z (pod start time 2026-11-03), so I could not re-read query.lookback-delta (carried from review 1's live flags read: 5m), could not re-hit /api/v1/status/flags, and could not observe the rules evaluating at 30 s. The 5m premise is also the conservative one — if it were larger, every last_over_time in this diff is simply less necessary, never wrong — so the fix does not depend on the number I could not re-measure today.

**Verdict of record — ld-reviewer — head 9e8823c7b00c511090437b157e9855abcba93c98: APPROVE** model: omniroute/deepseek-flash session: homelab-2479-gap3-review-2 Round 2 of at most 3. Reviewed date 2026-11-05T17:05Z–17:35Z (self-read `date -u`). Head read from the API (`open`, `mergeable: true`), merge-base with `origin/live` = `911810dddb08`. Delta = one commit on `471d4b1f`, 5 files, +529/−192. All measurements below were taken by me in a throwaway clone at `/tmp`; nothing was pushed and no cluster write was made. ## Verdict per round-1 blocker **G3-B1 — `ForgejoFactoryWalkTruncated` could never fire. CLOSED.** The arithmetic is right. At `interval: 30m` the pushes land at 0/30/60/90/120 min and a `[2h]` range selector excludes its left boundary, so `count_over_time(...[2h])` at eval 2h = **4** — I evaluated it directly (probe file, deliberately wrong `exp_samples` to read the `got` value): `got: {repo="r"} 4E+00` at 2h and again at 2h5m, so `>= 4` holds continuously between pushes while healthy. `min_over_time` = 1. One skipped push gives **3**, so the `and` leg goes false. Both range functions drop `__name__` (the probe shows `{repo="r"}`), so the `and` matches on `{repo}` alone — no label-matching bug. `for:` removed, so nothing hidden can block it. Mutation proved it: restoring the round-1 form (`forgejo_factory_walk_truncated == 1`, `for: 2h`) fails **Test 7** with exactly the round-1 signature — `alertname: ForgejoFactoryWalkTruncated, time: 2h, got:[]`. With the shipped expression Test 7 asserts a firing alert on `Homelab/infra-core` and stays silent for `Homelab/prod` at the same instant. Negative controls hold: one lone sample (7b) and three-of-four pushes (7c) do not fire. **G3-B2 — instant selectors blind 5/6 of the wall clock. CLOSED, and no consumer was missed.** A repo-wide grep for `forgejo_prs_*`, `forgejo_pr_open_age_seconds`, `forgejo_pr_merged_*`, `forgejo_factory_last_success`, `forgejo_factory_walk_truncated`, `forgejo_factory_repo_ok` hits only five files: the dashboards, the emitter, its unit test, the promtool test, and the rules. In `rules/factory-alerts.yaml` every selector is range-wrapped — the only bare metric names left in that file are inside `description:`/`ld_*` prose. I enumerated all 13 dashboard panel expressions and the template variable one by one: the pushed-series reads (panels 4, 9, 11, 12) are wrapped in `last_over_time(...[65m])`; panels 1–3, 6–8, 10 read `forgejo:` recording series and are correctly left bare; the `repo` variable moved off `forgejo_prs_open` onto `forgejo:pr_open_age_p90`. Nothing outside infra-core references these series, and nothing could: they do not exist yet. The semantic claim is true. In `forgejo:pr_merged_duration_p50:7d` / `p90:7d` / `p50:24h` the cut is still `last_over_time(forgejo_pr_merged_timestamp_seconds[65m]) > time() - 7*86400` (resp. `86400`), so `quantile()` is taken over every PR merged inside 7 days — the 65m window only makes each per-PR sample visible, it never narrows the population. A median over 7 days is still a median over 7 days. Mutation proved the tests pin it: replacing `sum(last_over_time(forgejo_prs_open[65m]))` with bare `sum(forgejo_prs_open)` turns the "30 min push cadence" case red (`got: nil`) at both between-push offsets (20m and 55m). **G3-B3 — `ForgejoFactorySeriesStale` had a 5-minute margin. CLOSED.** `absent(max_over_time(forgejo_factory_last_success_timestamp_seconds[95m]) > time() - 5400)` with `for: 30m`. Threshold is 3 cycles (90 min), so one skipped cycle (newest sample ≤ 62 min old) cannot reach pending, and two skipped cycles reach pending only at the instant the next push is due — they cannot hold 30 minutes of `for`. Pages ~4 cycles (2 h) after the last successful cycle. The 95m window is wider than the 90m the comparison needs, so the last sample is still in range while the condition is false: no `absent()` flicker between "stale" and "no data". Mutation proved it: restoring the round-1 `absent(forgejo_factory_last_success_timestamp_seconds > time() - 3600)` makes **10a** ("one skipped cycle does not page") fire at **1h10m** — a healthy emitter paged. The shipped expression keeps 10a and 10b silent at 1h10m/150m and 1h59m/150m. ## Round-1 concerns - **Page bound 6 — closed.** `cronjob.yaml` now ships `FACTORY_MAX_PAGES: "6"` (script default is 6), and the comment states the measured pages used (4/2/1/4/1/1) and why 4 had zero headroom. Body, README and manifest now agree. - **Stale `cronjob.yaml` comments — closed.** `168` vs "14 d", `19 requests / ~100 s`, and `sort=recentclose` are now consistent with the emitter and the body. - **p50s — closed, and I recomputed them myself.** Read-only GETs against Forgejo at 17:2xZ, window by `merged_at > now−7d`, walk `sort=recentclose&limit=50`, no page bound reached: | repo | n | p50 (interpolated, Prometheus `quantile`) | p50 (median_high) | max | |---|---|---|---|---| | Homelab/infra-core | 151 | 0.66 h = **39.6 min** | 39.6 min | 74.69 h | | Homelab/prod | 75 | 0.93 h = 55.8 min | 55.8 min | 45.25 h | | Homelab/lab-director | 4 | **21.04 h** | 38.14 h | 68.31 h | | Homelab/ghostty-in-the-shell | 162 | 1.08 h = 64.8 min | 64.8 min | 98.46 h | | Homelab/pi-extensions | 27 | 1.36 h = 81.6 min | 81.6 min | 14.22 h | | Sendrast/api-sendrast | 6 | **36.21 h** | 40.08 h | 54.24 h | The body's 21.0 h and 36.2 h are the **interpolated** values the shipped rule will compute, and the estimator is labelled correctly; 38.1 h / 40.1 h are exactly what `median_high` gives, so the round-1 disagreement is explained and resolved rather than papered over. The maxima match to the second (74.69 / 45.25 / 68.31 / 98.46 / 14.22 / 54.24), which says the population is the same one the emitter walks. Spread 39.6 min → 36.2 h = **54.8×**, so "~55×" is right. Fleet total is 425 now vs the body's 424 at 16:48Z (normal growth), 6 of 6 repos. ## Landing order against Homelab/prod#1132 (`f48a38ba`) `symlink`/`path` check: prod `live` `apps/` holds 12 `monitoring-*` Applications and **no** `monitoring-factory.yaml`, and nothing in prod references these series — so #846 alone deploys the rules and dashboard and leaves the emitter inert. **Merging #846 first does page an operator, and the page is for a healthy reason.** Sequence: rules go live on ArgoCD sync; `absent()` is true from the first evaluation because the series never existed, so `ForgejoFactorySeriesStale` enters pending immediately and **fires after its 30-minute `for`** — i.e. one slot. It keeps firing until the first successful emitter cycle, which can only happen after #1132 lands and the CronJob reaches its next `*/30` boundary (≤ 30 min). So the operator-visible window is **[#846 merge + 30 min … #1132 merge + ≤30 min]**, one alert instance, severity `warning`, and its own text is honest about the cause ("the series has never existed"). Nothing else can fire: `ForgejoFactoryWalkTruncated` has no series to read, and the dashboard panels are blank rather than falsely `0`. The author's stated order ("this PR first", "land them in the same window") is **acceptable but not the optimal one, and the number attached to it is wrong.** The README and the body both say the stale alert fires "once ~2 h of its 3-cycle threshold plus `for` has passed" — that is the *stale-series* path, not the never-existed path. The author's own Test 6 ("no series at all still fires") evaluates at **40m** and passes, which is the 30-minute `for` and nothing else. The safer order is therefore **#1132 first**: the Application reports `path does not exist` (ArgoCD degraded, pages nobody, loses no data) until #846 lands, whereas #846 first guarantees one warning page for a condition that is by construction not a fault. Either order is data-safe; if the pair cannot be landed in the same window, land prod#1132 first and the transient disappears entirely. ## Gates run (scratch clone `/tmp/r846` at 9e8823c7, promtool 3.4.0 = the cluster's version) 1. `check_alert_contracts.py --base origin/live` → **exit 0**, `ok: every added or modified rule carries a complete ld_* disposition contract`, changed=2, name-surfaces=118. 2. `promtool check rules` on the extracted `spec.groups` → **SUCCESS: 11 rules found** (9 recording + 2 alerts). 3. `promtool test rules` via the repo's own `rule-tests/promtool-test.sh` → **SUCCESS**, 13 factory cases, plus every other rule file in the directory. 4. `yamllint -d relaxed` on the 5 changed YAML files → **exit 0**, 239 line-length warnings, 0 errors (same character as the existing rule files). 5. `factory_series_test.py` → **Ran 15 tests, OK**. 6. **Sabotage (mutation), 3 of the author's fixes, all red:** B1 → round-1 form ⇒ Test 7 `got:[]`; B3 → round-1 `absent(bare > time()-3600)` ⇒ 10a fires at 1h10m; B2 → bare `sum(forgejo_prs_open)` ⇒ cadence case `got: nil`. Each reverted, suite green again. The tests are not decorative. ## New defects round 1 did not catch (neither is a code blocker) - **HOMELAB-2479-G3-C3 — `factory-sabotage.sh` is referenced three times and does not exist.** `rules/factory-alerts.yaml:49` ("`factory-sabotage.sh` proves those cases go red"), `rule-tests/factory.test.yaml:3` ("or factory-sabotage.sh to prove these cases can actually fail"), and `rule-tests/factory.test.yaml:434` with a literal command (`factory-sabotage.sh b3-bare-selector`) and the claim that it "restores the old expression and shows this case going red". `find` and `git log --all` find no such file anywhere in the repo or its history, while six sibling `*-sabotage.sh` scripts exist in the same directory — so the reference reads as real to the next verifier. The *claim* is true (I demonstrated the same three mutations by hand), so this is a broken pointer to real evidence, not a false result; it should either ship as `rule-tests/factory-sabotage.sh` or the three sentences should point at the by-hand mutation recipes. - **HOMELAB-2479-G3-C4 — counts went stale in this very commit.** The delta adds five promtool cases (13 now) and one recording rule (9 now), and three surfaces still say 8: README table ("8 promtool cases"), README verifier step 2 ("8 factory cases SUCCESS"), PR body ("8 promtool cases" / "8/8 SUCCESS"), plus "8 recording rules + 2 alerts" in the README table, the README Alerts heading and the PR body. Also minor claim drift in the same commit: the PR body's *Alerts* section still quotes the **pre-fix** stale expression `absent(forgejo_factory_last_success_timestamp_seconds > time() - 3600)`, contradicting both the shipped rule and its own *Corrections after review 1890* section; and the B1 comment says "a dense re-push … cannot fire it", whereas 4 dense all-`1` samples inside 2 h would satisfy both legs. None of these changes what deploys or what pages. No new blocker. Nothing in the delta weakens the previous head. ## What I could not verify Live in-cluster behaviour: Prometheus `http://127.0.0.1:19090` answered **503** for the whole review and `prometheus-kube-prometheus-stack-prometheus-0` reported `ready=false` with one container restarted at **17:14:13Z** (pod start time 2026-11-03), so I could not re-read `query.lookback-delta` (carried from review 1's live flags read: 5m), could not re-hit `/api/v1/status/flags`, and could not observe the rules evaluating at 30 s. The 5m premise is also the conservative one — if it were larger, every `last_over_time` in this diff is simply less necessary, never wrong — so the fix does not depend on the number I could not re-measure today.
ld-reviewer left a comment
Collaborator

Verdict of record — ld-reviewer — head 9e8823c7b0: APPROVE

model: omniroute/deepseek-flash
session: homelab-2479-gap3-review-2

Round 2 of at most 3. Reviewed date 2026-11-05T17:05Z–17:35Z (self-read date -u). Head read from the API (open, mergeable: true), merge-base with origin/live = 911810dddb08. Delta = one commit on 471d4b1f, 5 files, +529/−192. All measurements below were taken by me in a throwaway clone at /tmp; nothing was pushed and no cluster write was made.

Verdict per round-1 blocker

G3-B1 — ForgejoFactoryWalkTruncated could never fire. CLOSED.
The arithmetic is right. At interval: 30m the pushes land at 0/30/60/90/120 min and a [2h] range selector excludes its left boundary, so count_over_time(...[2h]) at eval 2h = 4 — I evaluated it directly (probe file, deliberately wrong exp_samples to read the got value): got: {repo="r"} 4E+00 at 2h and again at 2h5m, so >= 4 holds continuously between pushes while healthy. min_over_time = 1. One skipped push gives 3, so the and leg goes false. Both range functions drop __name__ (the probe shows {repo="r"}), so the and matches on {repo} alone — no label-matching bug. for: removed, so nothing hidden can block it.
Mutation proved it: restoring the round-1 form (forgejo_factory_walk_truncated == 1, for: 2h) fails Test 7 with exactly the round-1 signature — alertname: ForgejoFactoryWalkTruncated, time: 2h, got:[]. With the shipped expression Test 7 asserts a firing alert on Homelab/infra-core and stays silent for Homelab/prod at the same instant. Negative controls hold: one lone sample (7b) and three-of-four pushes (7c) do not fire.

G3-B2 — instant selectors blind 5/6 of the wall clock. CLOSED, and no consumer was missed.
A repo-wide grep for forgejo_prs_*, forgejo_pr_open_age_seconds, forgejo_pr_merged_*, forgejo_factory_last_success, forgejo_factory_walk_truncated, forgejo_factory_repo_ok hits only five files: the dashboards, the emitter, its unit test, the promtool test, and the rules. In rules/factory-alerts.yaml every selector is range-wrapped — the only bare metric names left in that file are inside description:/ld_* prose. I enumerated all 13 dashboard panel expressions and the template variable one by one: the pushed-series reads (panels 4, 9, 11, 12) are wrapped in last_over_time(...[65m]); panels 1–3, 6–8, 10 read forgejo: recording series and are correctly left bare; the repo variable moved off forgejo_prs_open onto forgejo:pr_open_age_p90. Nothing outside infra-core references these series, and nothing could: they do not exist yet.
The semantic claim is true. In forgejo:pr_merged_duration_p50:7d / p90:7d / p50:24h the cut is still last_over_time(forgejo_pr_merged_timestamp_seconds[65m]) > time() - 7*86400 (resp. 86400), so quantile() is taken over every PR merged inside 7 days — the 65m window only makes each per-PR sample visible, it never narrows the population. A median over 7 days is still a median over 7 days.
Mutation proved the tests pin it: replacing sum(last_over_time(forgejo_prs_open[65m])) with bare sum(forgejo_prs_open) turns the "30 min push cadence" case red (got: nil) at both between-push offsets (20m and 55m).

G3-B3 — ForgejoFactorySeriesStale had a 5-minute margin. CLOSED.
absent(max_over_time(forgejo_factory_last_success_timestamp_seconds[95m]) > time() - 5400) with for: 30m. Threshold is 3 cycles (90 min), so one skipped cycle (newest sample ≤ 62 min old) cannot reach pending, and two skipped cycles reach pending only at the instant the next push is due — they cannot hold 30 minutes of for. Pages ~4 cycles (2 h) after the last successful cycle. The 95m window is wider than the 90m the comparison needs, so the last sample is still in range while the condition is false: no absent() flicker between "stale" and "no data".
Mutation proved it: restoring the round-1 absent(forgejo_factory_last_success_timestamp_seconds > time() - 3600) makes 10a ("one skipped cycle does not page") fire at 1h10m — a healthy emitter paged. The shipped expression keeps 10a and 10b silent at 1h10m/150m and 1h59m/150m.

Formal review (round 2) — full analysis in comment 22126-style comment above

Gates, all green at this head: check_alert_contracts.py --base origin/live exit 0 (changed=2);
promtool check rules SUCCESS (9 recording + 2 alerts); promtool test rules SUCCESS (13 cases);
yamllint -d relaxed exit 0 (0 errors); factory_series_test.py 15/15 OK.
Three mutations of the author's own fixes each turn the suite red (B1 -> Test 7 got:[];
B3 -> 10a fires at 1h10m; B2 -> cadence case got: nil). p50s re-derived from Forgejo by me:
lab-director 21.04 h interpolated / 38.14 h median_high, api-sendrast 36.21 h / 40.08 h — the
interpolated values the shipped rule computes, estimator correctly labelled.

Concerns, neither blocking and neither affecting what deploys or what pages:

  • HOMELAB-2479-G3-C3 factory-sabotage.sh is referenced three times (factory-alerts.yaml:49,
    factory.test.yaml:3 and :434 with a literal command) and exists nowhere in the repo or its
    history; six sibling *-sabotage.sh scripts exist, so the pointer reads as real. The claim is
    true — I ran the mutations by hand — so this is a broken pointer, not a false result.
  • HOMELAB-2479-G3-C4 counts went stale in this commit: 13 promtool cases and 9 recording rules are
    documented as 8 in the README table, README verifier step 2 and the PR body; the body's Alerts
    section still quotes the pre-fix stale expression; and the B1 comment's "a dense re-push cannot
    fire it" is imprecise (4 dense all-1 samples would satisfy both legs).

Landing order: merging #846 before prod#1132 does page — ForgejoFactorySeriesStale fires 30 min
after the rules sync (never-existed path: absent() true at once, for: 30m) and clears only after
#1132 lands and the CronJob reaches a */30 boundary, so the window is [#846 merge + 30 min ...
#1132 merge + <=30 min], one warning instance, cause honestly stated. Data-safe either way, but
prod#1132 first is the quieter order (ArgoCD path does not exist pages nobody). The README and
body's "~2 h" for that transient is wrong: the author's own Test 6 pins the never-existed path at
40m.

Approving. No new blocker; nothing in the delta weakens the previous head.

**Verdict of record — ld-reviewer — head 9e8823c7b00c511090437b157e9855abcba93c98: APPROVE** model: omniroute/deepseek-flash session: homelab-2479-gap3-review-2 Round 2 of at most 3. Reviewed date 2026-11-05T17:05Z–17:35Z (self-read `date -u`). Head read from the API (`open`, `mergeable: true`), merge-base with `origin/live` = `911810dddb08`. Delta = one commit on `471d4b1f`, 5 files, +529/−192. All measurements below were taken by me in a throwaway clone at `/tmp`; nothing was pushed and no cluster write was made. ## Verdict per round-1 blocker **G3-B1 — `ForgejoFactoryWalkTruncated` could never fire. CLOSED.** The arithmetic is right. At `interval: 30m` the pushes land at 0/30/60/90/120 min and a `[2h]` range selector excludes its left boundary, so `count_over_time(...[2h])` at eval 2h = **4** — I evaluated it directly (probe file, deliberately wrong `exp_samples` to read the `got` value): `got: {repo="r"} 4E+00` at 2h and again at 2h5m, so `>= 4` holds continuously between pushes while healthy. `min_over_time` = 1. One skipped push gives **3**, so the `and` leg goes false. Both range functions drop `__name__` (the probe shows `{repo="r"}`), so the `and` matches on `{repo}` alone — no label-matching bug. `for:` removed, so nothing hidden can block it. Mutation proved it: restoring the round-1 form (`forgejo_factory_walk_truncated == 1`, `for: 2h`) fails **Test 7** with exactly the round-1 signature — `alertname: ForgejoFactoryWalkTruncated, time: 2h, got:[]`. With the shipped expression Test 7 asserts a firing alert on `Homelab/infra-core` and stays silent for `Homelab/prod` at the same instant. Negative controls hold: one lone sample (7b) and three-of-four pushes (7c) do not fire. **G3-B2 — instant selectors blind 5/6 of the wall clock. CLOSED, and no consumer was missed.** A repo-wide grep for `forgejo_prs_*`, `forgejo_pr_open_age_seconds`, `forgejo_pr_merged_*`, `forgejo_factory_last_success`, `forgejo_factory_walk_truncated`, `forgejo_factory_repo_ok` hits only five files: the dashboards, the emitter, its unit test, the promtool test, and the rules. In `rules/factory-alerts.yaml` every selector is range-wrapped — the only bare metric names left in that file are inside `description:`/`ld_*` prose. I enumerated all 13 dashboard panel expressions and the template variable one by one: the pushed-series reads (panels 4, 9, 11, 12) are wrapped in `last_over_time(...[65m])`; panels 1–3, 6–8, 10 read `forgejo:` recording series and are correctly left bare; the `repo` variable moved off `forgejo_prs_open` onto `forgejo:pr_open_age_p90`. Nothing outside infra-core references these series, and nothing could: they do not exist yet. The semantic claim is true. In `forgejo:pr_merged_duration_p50:7d` / `p90:7d` / `p50:24h` the cut is still `last_over_time(forgejo_pr_merged_timestamp_seconds[65m]) > time() - 7*86400` (resp. `86400`), so `quantile()` is taken over every PR merged inside 7 days — the 65m window only makes each per-PR sample visible, it never narrows the population. A median over 7 days is still a median over 7 days. Mutation proved the tests pin it: replacing `sum(last_over_time(forgejo_prs_open[65m]))` with bare `sum(forgejo_prs_open)` turns the "30 min push cadence" case red (`got: nil`) at both between-push offsets (20m and 55m). **G3-B3 — `ForgejoFactorySeriesStale` had a 5-minute margin. CLOSED.** `absent(max_over_time(forgejo_factory_last_success_timestamp_seconds[95m]) > time() - 5400)` with `for: 30m`. Threshold is 3 cycles (90 min), so one skipped cycle (newest sample ≤ 62 min old) cannot reach pending, and two skipped cycles reach pending only at the instant the next push is due — they cannot hold 30 minutes of `for`. Pages ~4 cycles (2 h) after the last successful cycle. The 95m window is wider than the 90m the comparison needs, so the last sample is still in range while the condition is false: no `absent()` flicker between "stale" and "no data". Mutation proved it: restoring the round-1 `absent(forgejo_factory_last_success_timestamp_seconds > time() - 3600)` makes **10a** ("one skipped cycle does not page") fire at **1h10m** — a healthy emitter paged. The shipped expression keeps 10a and 10b silent at 1h10m/150m and 1h59m/150m. ## Formal review (round 2) — full analysis in comment 22126-style comment above Gates, all green at this head: `check_alert_contracts.py --base origin/live` exit 0 (changed=2); `promtool check rules` SUCCESS (9 recording + 2 alerts); `promtool test rules` SUCCESS (13 cases); `yamllint -d relaxed` exit 0 (0 errors); `factory_series_test.py` 15/15 OK. Three mutations of the author's own fixes each turn the suite **red** (B1 -> Test 7 `got:[]`; B3 -> 10a fires at 1h10m; B2 -> cadence case `got: nil`). p50s re-derived from Forgejo by me: lab-director 21.04 h interpolated / 38.14 h median_high, api-sendrast 36.21 h / 40.08 h — the interpolated values the shipped rule computes, estimator correctly labelled. Concerns, neither blocking and neither affecting what deploys or what pages: - HOMELAB-2479-G3-C3 `factory-sabotage.sh` is referenced three times (factory-alerts.yaml:49, factory.test.yaml:3 and :434 with a literal command) and exists nowhere in the repo or its history; six sibling `*-sabotage.sh` scripts exist, so the pointer reads as real. The claim is true — I ran the mutations by hand — so this is a broken pointer, not a false result. - HOMELAB-2479-G3-C4 counts went stale in this commit: 13 promtool cases and 9 recording rules are documented as 8 in the README table, README verifier step 2 and the PR body; the body's Alerts section still quotes the pre-fix stale expression; and the B1 comment's "a dense re-push cannot fire it" is imprecise (4 dense all-1 samples would satisfy both legs). Landing order: merging #846 before prod#1132 does page — `ForgejoFactorySeriesStale` fires 30 min after the rules sync (never-existed path: `absent()` true at once, `for: 30m`) and clears only after #1132 lands and the CronJob reaches a `*/30` boundary, so the window is [#846 merge + 30 min ... #1132 merge + <=30 min], one warning instance, cause honestly stated. Data-safe either way, but **prod#1132 first is the quieter order** (ArgoCD `path does not exist` pages nobody). The README and body's "~2 h" for that transient is wrong: the author's own Test 6 pins the never-existed path at **40m**. Approving. No new blocker; nothing in the delta weakens the previous head.
Sign in to join this conversation.
No reviewers
No labels
operator-review
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Homelab/infra-core!846
No description provided.