Gladex Agent Logs

Agent run logs & app logs · env: prod · LAN-only investor surface

Overview
Run logs461 files, 13.8 MB
Latest run logrun-20260925-101629-59.log
Log directory/data/agent-logs
App log directory/opt/startup/prod/logs
Run logs (newest first, last 50)
FileSizeModified (UTC)
run-20260925-101629-59.log 274 KB 2026-09-25 08:55:40
run-20260925-084303-58.log 469 KB 2026-09-25 08:06:29
run-20260925-064014-57.log 294 KB 2026-09-25 06:33:03
run-20260925-052608-56.log 381 KB 2026-09-25 04:30:14
run-20260925-042134-55.log 200 KB 2026-09-25 03:16:08
run-20260925-031342-54.log 303 KB 2026-09-25 02:11:34
run-20260925-022932-53.log 212 KB 2026-09-25 01:03:42
run-20260925-012106-52.log 297 KB 2026-09-25 00:19:32
run-20260925-003542-51.log 153 KB 2026-09-24 23:11:06
run-20260924-234828-50.log 204 KB 2026-09-24 22:25:42
run-20260924-230237-49.log 303 KB 2026-09-24 21:38:28
run-20260924-222340-48.log 206 KB 2026-09-24 20:52:37
run-20260924-215353-47.log 146 KB 2026-09-24 20:13:40
run-20260924-210315-46.log 182 KB 2026-09-24 19:43:53
run-20260924-200755-45.log 181 KB 2026-09-24 18:53:15
run-20260924-192844-44.log 133 KB 2026-09-24 17:57:55
run-20260924-182059-43.log 227 KB 2026-09-24 17:18:44
run-20260924-164658-42.log 181 KB 2026-09-24 16:10:59
run-20260924-160206-41.log 101 KB 2026-09-24 14:36:58
run-20260924-153643-40.log 127 KB 2026-09-24 13:52:05
run-20260924-151001-39.log 130 KB 2026-09-24 13:26:43
run-20260924-144921-38.log 90 KB 2026-09-24 13:00:01
run-20260924-143001-37.log 63 KB 2026-09-24 12:39:21
run-20260924-141012-36.log 106 KB 2026-09-24 12:20:01
run-20260924-135151-35.log 75 KB 2026-09-24 12:00:12
run-20260924-133211-34.log 116 KB 2026-09-24 11:41:51
run-20260924-130932-33.log 67 KB 2026-09-24 11:22:11
run-20260924-115831-32.log 260 KB 2026-09-24 10:59:32
run-20260924-111405-31.log 117 KB 2026-09-24 09:48:31
run-20260924-102752-30.log 106 KB 2026-09-24 09:04:05
run-20260924-100538-29.log 81 KB 2026-09-24 08:17:52
run-20260924-092904-28.log 101 KB 2026-09-24 07:55:38
run-20260924-083526-27.log 116 KB 2026-09-24 07:19:04
run-20260924-080136-26.log 75 KB 2026-09-24 06:25:26
run-20260924-074910-25.log 23 KB 2026-09-24 05:51:36
run-20260924-072601-24.log 52 KB 2026-09-24 05:39:10
run-20260924-065657-23.log 156 KB 2026-09-24 05:16:01
run-20260924-063310-22.log 76 KB 2026-09-24 04:46:57
run-20260924-055309-21.log 107 KB 2026-09-24 04:23:10
run-20260924-052831-20.log 133 KB 2026-09-24 03:43:09
run-20260924-050107-19.log 69 KB 2026-09-24 03:18:31
run-20260924-044831-18.log 34 KB 2026-09-24 02:51:07
run-20260924-041948-17.log 156 KB 2026-09-24 02:38:31
run-20260924-035438-16.log 185 KB 2026-09-24 02:09:48
run-20260924-033002-15.log 245 KB 2026-09-24 01:44:38
run-20260924-025910-14.log 120 KB 2026-09-24 01:20:02
run-20260924-023430-13.log 97 KB 2026-09-24 00:49:10
run-20260924-015458-12.log 255 KB 2026-09-24 00:24:30
run-20260924-012751-11.log 179 KB 2026-09-23 23:44:58
run-20260924-005036-10.log 162 KB 2026-09-23 23:17:51
Tail — run-20260925-101629-59.log (last 200 lines)
- **`--ignored` on a tree with a large ignored build dir** makes `git status` walk it; if `app/src/php` ever gains one, the 30 s `GLADEX_SHIP_TIMEOUT` may need a pathspec exclude.
- The divergence guard compares only `gates[1].child` (dev-sync) — extending it to `gates[3].child` (ship-tree) would apply `[0.4.46]`'s principle to the new gate.

### Placement (deliberate — same as `[0.4.29]`–`[0.4.47]` which sit immediately above this one)
Parked at the bottom of an otherwise newest-first file on purpose. Promoting it to the top would make it the version-train head while `GLADEX_APP_VERSION` and all four `gladex` binaries still report `0.4.28`, turning `tests/test_app_version` and `tests/test_cli_version` red until CI bumps the train and rebuilds. That bump is main-loop work and is not part of this fix.
> build · mimo-v2.6-flash-free
$ cd /data/repo && cat BUDGET.md | tail -30
month: 2026-09
allowance: 5.00
spent: 1.50 (gladex.de — registered + DNS live since 2026-09-20, 1.50/mo)
remaining: 3.50
log:
  - 2026-09-20: init, no domains ordered yet
  - 2026-09-20: investor APPROVED gladex.de (1.50/mo). Awaiting registrar purchase + DNS creds.
  - 2026-09-20: purchase completed same day — RDAP status active (changed 2026-09-20T11:10:57+02:00), DNS wired via pdns wrapper (A apex/dev/git/info/stats/log/team -> 77.90.15.49, MX 10 gladex.de), dig-verified; DOMAIN.md holds evidence.
  - 2026-09-24: ledger narrative corrected — spent-line still said "purchase pending at registrar" 4 days after registration (queued candidate #1); numbers unchanged (1.50/3.50, no money moved), log history above kept verbatim.
rules: .de 1.50/mo, .com/.org/.net 2.50/mo. No rollover. All purchases through investor.
$ cd /data/repo && tail -60 agent-logs/PROGRESS.md
- **Mutations (8 new — M7/M8 on the promote tool, M13–M18 on the dashboard — planted on copies, the real tools never edited; precondition-asserted by grepping the search string for exactly one occurrence, `bash -n`/`py_compile`-validated, each run against the one scenario it targets)**: M7 the gate's `child` identity never recorded · M8 the `mailbox` state never recorded · M13 the refusal loses its date (`refusal_header()` reverted to a bare `not promotable:`) · M14 two *differing* binaries claimed as one child · M15 the `go-compile` row's binary never handed off (`SS_GCC_BIN=""`) · M16 the report's `commit-lint` child never read (`r_bin = ""`) · M17 an unrecorded child reported as `the same child (unmeasured)` · M18 the age rendered in **seconds** instead of the queued minutes. **All 8 caught.** M15/M16 deliberately fail on the *same* assertion (row→hand-off and report→guard are one chain: either link breaking must read as "the child was never recorded"), and M16's plant had to be rewritten mid-run because the emission line it targets moved when `refusal_header()` landed — a plant that cannot fire is not a caught mutation.
- **Live**: `promote-dev-to-prod --dry-run --force --format json` → `ready:false`, `sha:01f35e5`, `gates[dev-sync].child=/data/repo/tools/source-sync-check`, `gates[commit-lint].child=/data/repo/tools/repo-lint`, `gates[verdict]` with **no** `child`, `mailbox.newest=2026-09-21T18:29:21Z` + `age_seconds`; `system-status --format human` → `promote-gates [WARN] not promotable (newest reviewer-mailbox entry 2026-09-21T18:29:21Z, 4771m old): verdict REFUSED: no verdict in /data/repo/mailboxes/reviewer-to-main … | dev-sync OK | commit-lint OK (commit 01f35e5)` beside `go-compile [OK] … (commit 01f35e5)` — the two rows naming the same commit *and* the refusal naming its own age — under **ALL SYSTEMS HEALTHY**, exit 0, 30.5 s. Promotion stays refused in practice (exit 5, `reviewer-to-main/` holds no `VERDICT-*`); every invocation was `--dry-run`.
- **Full regression: 34 suites, 2019 assertions, 0 failed** (19 shell = 1538: budget 49, dns 40, domain 140, identity 28, ip-drift-check 133, ip-drift-cron 93, pdns 175, promote 67, **promote-json 108**, promote-lint 60, repo-lint 120, smtp 38, source-sync 30, go-compile 82, go-tests 66, mx_soa 31, **promote-gates 155**, unread 24, tls 99 + 15 PHP = 481) — **+59 over last run's 1,960 baseline** (= +24 + +35, exactly the two suites that grew), no other suite moved. `bash -n` clean on `tools/system-status`, `py_compile` clean on `tools/promote-dev-to-prod`; `test_changelog_api` 78/0, `test_app_version` 39/0, `test_cli_version` 35/0 after the CHANGELOG append.
- **Docs**: REGISTRY §promote-dev-to-prod (`gates[].child` + `mailbox` documented with the sample JSON corrected, `--help` epilog, suite bullet 84 → 108 / 6 → 8 mutations + this step's pre-fix replay + live transcript); REGISTRY §system-status (verdict table now shows the **dated** refusal and the guard's four child-identity outcomes, two new subsections for the mailbox state and the child identity, suite bullet 120 → 155 / M1–M18 with sections 14c + 18b, pre-fix replay 130/25, live transcript, status line); CHANGELOG `[0.4.43]` parked at the **bottom** with the placement note (train head stays `0.4.28` → version tests stay green) and `[0.4.42]`'s queue marked actioned; PROGRESS (this entry).
- **Safety**: model spend **0.00** (`*-free` only), no secrets/personal data in prompts or commits, **no money moved** (BUDGET unchanged: 1.50 spent / 3.50 remaining), **no DNS write**, **no service restart**, `/opt/startup/dev` and `/opt/startup/prod` untouched (repo-side tool only; `source-sync-check` 30/30; the live message DBs were read only for STEP 0's counts).
- **Still blocked (investor-owned)**: NEEDS-INVESTOR **#57 public investor-route gating A/B/C** and **SOA MNAME** (`a.misconfigured.dns.server.invalid.` → surfaced as a warning by `system-status` on every run) — cannot proceed unilaterally.
- **Next-candidate queued, not actioned**: (1) the age is **recomputed at read time** — `mailbox.age_seconds` is measured when the report is generated, so `4771m old` re-ages on every dashboard run even though nothing changed; dating the refusal from the report's own `timestamp` would make "old" mean "old when this report was made". (2) `4771m old` is arithmetic a reader must do (above a day `2d 23h` would be glanceable) — pinned as a contract by M18 today, so it needs a deliberate change, not a slip. (3) only one of the three gates' children is ever compared: `dev-sync`'s `source-sync-check` path is now recorded but nothing reads it — the same "one check seen twice" guard applied to that second pair.

## 2026-09-25T03:28Z main-loop run — STEP 0 clear; the `promote-gates` row dated its refusal from the mailbox's own `.gitkeep` placeholder — a "newest entry" timestamp beside the gate's "no verdict" on the same line — suite 108 → 118 assertions, 8 → 10 mutations
- **STEP 0 (done first, re-verified at run end)**: **0 unread `investor_to_agent` rows on BOTH DBs** (dev 0 of 24 rows, prod 0 of 3 — every row read); `INBOX.md` 65 `##` entries, **65 HANDLED, 0 unhandled** (verified per-entry with awk: no `## ` heading without a `HANDLED` marker); `mailboxes/*` 0 pending Dispatcher assignments (four dirs, only `.gitkeep`). No reply owed, nothing to mark — recorded here so the run still closes the investor loop explicitly. Investor-side note: **12 of our own `agent_to_investor` rows are still unread by the investor** (prod mirrors 16), including **msg #57 (A/B/C public-gating escalation)** — that ask stays open until they open it.
- **Defect class this step exists for (16th of the series: system-status, dns-verify, budget-show, tls-check, domain-availability-check, pdns-api, ip-drift-check, ip-drift-cron, repo-lint, go-tests, go-compile, promote-gates + its JSON report)**: found **while triaging** the queue rather than being the queue. The three queued next-candidates from `[0.4.43]` all concern the `mailbox.age_seconds` contract, so the run started by reading it — and the live report contradicted itself: `_mailbox_state()` counted **every** file in `mailboxes/reviewer-to-main`, whose only file is the committed `.gitkeep` (0 bytes, written when the mailbox dir was created in commit `6b9fbaa`, never touched by the reviewer). So the row printed `not promotable (newest reviewer-mailbox entry 2026-09-21T18:29:21Z, 4803m old): verdict REFUSED: no verdict in /data/repo/mailboxes/reviewer-to-main` — **an entry timestamp beside the verdict gate's own claim that there is none**, and one that re-aged on every dashboard run (4771m → 4803m across two runs) with nothing changing in the mailbox, because the "evidence" was a placeholder's fixed mtime. The gate filters `VERDICT-YYYYMMDD-HHMMSS-<slug>.md`; the mailbox scan filtered nothing — the two halves of one line disagreed about whether the mailbox held anything. The suite stayed **108/108 green through the defect** because `new_sandbox()` creates `mailboxes/reviewer-to-main` **without** `.gitkeep`: no test ever exercised the placeholder path, which is exactly the gap L5 closes. Pre-fix evidence captured: live baseline `system-status` line above (4803m), and the final suite against the `HEAD:tools/promote-dev-to-prod` blob → **111 passed / 7 failed**.
- **Contract now**: `_mailbox_state()` skips any name starting with `.` — the placeholder is creation-time scaffolding, not a reviewer touch — while **every other file still counts** (a stray non-verdict file still marks the last time the reviewer side was touched, per the original rationale, now scoped to files a person could have dropped). An empty or placeholder-only mailbox reports `newest`/`age_seconds` **null**, never a guessed number; the docstring that asserted "Every FILE counts (not only VERDICT-*)" now states the rule it actually implements, and `--help`'s mailbox epilog names `.gitkeep` as a non-entry. **`system-status` needed no change**: its `refusal_header()` already had an honest state for `newest == null`, so the live row became `not promotable (reviewer-mailbox holds no entries): verdict REFUSED: no verdict in /data/repo/mailboxes/reviewer-to-main … | dev-sync OK | commit-lint OK (commit d997229)` — both halves now agreeing.
- **Step taken (test-first)**: assertions written **before** the fix — `tests/test_promote_json.sh` **section L5** (placeholder-only mailbox → `newest`/`age_seconds` null, dir still named: 4 assertions; a `.gitkeep` *newer* than the newest verdict must not win the age — `mailbox.newest` must equal the verdict's exact UTC stamp and age in the 500..900 s window, not ~0 s: 3 assertions; `--help` names `.gitkeep`: 1) plus section L1's older sibling swapped from `.gitkeep` to a real `VERDICT-*` (its "newest wins over whatever listdir returns first" intent needs a file the mailbox counts). Red run pre-fix → **111 passed / 7 failed** (the five behavioral assertions + both new plants at 0 matching lines — the skip line did not exist yet); then `tools/promote-dev-to-prod` (skip + docstring + `--help`) → **118/118**.
- **Mutations (2 new, planted on copies — the real tool is never edited — precondition-asserted by grepping the search string for exactly one occurrence, `ast`-validated, each run against the one scenario it targets)**: **M9** the skip removed (`if False:` — the placeholder is counted again and the row prints its own 42 s age for a file nobody dated) · **M10** the skip over-broadened (`fn.startswith(".") or fn.startswith("VERDICT-")` — a real verdict entry dropped, so an "honest" null would come from a blind scan instead of an empty mailbox). **Both caught.**
- **Live**: `promote-dev-to-prod --dry-run --force --format json` → `"mailbox": {"newest": null, "age_seconds": null}` (only the `.gitkeep` is there); `system-status --format human` → the *holds no entries* row above under **ALL SYSTEMS HEALTHY**, exit 0 — the timestamp-versus-"no verdict" contradiction gone. Promotions remain refused in practice (exit 5, no `VERDICT-*` written) and nothing was promoted: every invocation is `--dry-run`.
- **Full regression: 34 suites, 2029 assertions, 0 failed** (19 shell = 1548: budget 49, dns 40, domain 140, identity 28, ip-drift-check 133, ip-drift-cron 93, pdns 175, promote 67, **promote-json 118**, promote-lint 60, repo-lint 120, smtp 38, source-sync 30, go-compile 82, go-tests 66, mx_soa 31, **promote-gates 155**, unread 24, tls 99 + 15 PHP = 481) — **+10 over last run's 2,019 baseline** (= exactly promote-json's growth), no other suite moved. A first pass reported `go-compile 81 passed` with a non-zero exit **while a live `system-status` overlapped it**; three standalone re-runs and the clean second pass were 82/0 — load contention from that overlap, not this change. `bash -n` + `py_compile` clean; `test_changelog_api` 78/0, `test_app_version` 39/0, `test_cli_version` 35/0 after the CHANGELOG append.
- **Docs**: REGISTRY §promote-dev-to-prod (sample JSON `mailbox` → null, contract paragraph rewritten around the placeholder rule, suite bullet 108 → 118 / 8 → 10 mutations + this step's pre-fix replay + L5 pins + live transcript corrected); REGISTRY §system-status (the *holds no entries* state now notes a placeholder-only mailbox reads it); CHANGELOG `[0.4.44]` parked at the **bottom** with the placement note (train head stays `0.4.28` → version tests stay green) and `[0.4.43]`'s three-item queue carried forward verbatim; PROGRESS (this entry).
- **Safety**: model spend **0.00** (`*-free` only), no secrets/personal data in prompts or commits, **no money moved** (BUDGET unchanged: 1.50 spent / 3.50 remaining), **no DNS write**, **no service restart**, `/opt/startup/dev` and `/opt/startup/prod` untouched (repo-side tool only; the live message DBs were read only for STEP 0's counts).
- **Still blocked (investor-owned)**: NEEDS-INVESTOR **#57 public investor-route gating A/B/C** and **SOA MNAME** (`a.misconfigured.dns.server.invalid.` → surfaced as a warning by `system-status` on every run) — cannot proceed unilaterally.
- **Next-candidate queued, not actioned** (all three carried from `[0.4.43]` unchanged — this run's step was the defect found *while reading* their contract): (1) the age is **recomputed at read time** — dating the refusal from the report's own `timestamp` would make "old" mean "old when this report was made"; (2) `4803m old` is arithmetic a reader must do (above a day `2d 23h` would be glanceable) — pinned as a contract by mutation M18, so it needs a deliberate change; (3) only one of the three gates' children is ever compared: `dev-sync`'s `source-sync-check` path is recorded but nothing reads it.

## 2026-09-25T04:20Z main-loop run — STEP 0 clear; `promote-gates` printed the child's SCAN-time age as if the report's own timestamps produced it, and `4803m old` was arithmetic every reader had to do — report-dated age + `3d 8h` rendering + six honest "cannot be dated" states, suites 118 → 127 and 155 → 176 assertions, 10 → 13 and 18 → 23 mutations
- **STEP 0 (done first, re-verified at run end)**: **0 unread `investor_to_agent` rows on BOTH DBs** (dev 0 of 24 rows, prod 0 of 3 — every row read); `INBOX.md` 65 `##` entries, all with a `HANDLED` marker, 0 unhandled; `mailboxes/*` 0 pending Dispatcher assignments (four dirs, only `.gitkeep`). `/root/Maildir/new` holds exactly one message, the same **self-sent** `tls-restore smoke test` from `root@startup-builder.lxd` — not investor traffic. **No reply owed, nothing to mark**, recorded here so the run still closes the investor loop explicitly. Investor-side note: **12 of our own `agent_to_investor` rows are still unread by the investor**, including **msg #57 (A/B/C public-gating escalation)** — that ask stays open until they open it.
- **Defect class this step exists for (17th of the series: system-status, dns-verify, budget-show, tls-check, domain-availability-check, pdns-api, ip-drift-check, ip-drift-cron, repo-lint, go-tests, go-compile, promote-gates + its JSON report)**: queue items **(1)** and **(2)** from `[0.4.44]`, verbatim — *"(1) the age is recomputed at read time, not recorded at refusal time … dating the refusal from the report's own `timestamp` would make 'old' mean 'old when this report was made'"* and *"(2) `4803m old` is arithmetic a reader must do (above a day `2d 23h` would be glanceable)"*. Item (1) is the same defect as the last sixteen: **a number whose "as of when" nobody could say.** `_mailbox_state()` ran in `promote()` *before* any gate, while `timestamp` is stamped in the `finally` *after* them — the commit-lint gate alone compiles a Go module — so `age_seconds` was a reading taken at an unlabelled moment and `refusal_header()` printed it on a line whose other evidence came from the report's own stamps: the row's arithmetic and the child's reading could not be reconciled by any reader, and `system-status` had no way to know which instant the number was anchored to. Item (2) was pure presentation of the same number — 288180 s printed as `4803m`, four digits nobody reads as "three days and eight hours". Pre-fix evidence captured: final promote suite vs `HEAD:tools/promote-dev-to-prod` blob → **120 passed / 7 failed** (L6 shipped `1s` for a 3 s gate sleep, L7 shipped `0`, the `--help` needle absent, M11–M13 unplantable); final dashboard suite vs `HEAD:tools/system-status` blob → **156 passed / 20 failed / 0 skipped** (the day scenario printed exactly `4803m old`, all five 18c states red, three `--help` needles absent, M18–M23 unplantable).
- **Contract now**: (a) **the child's age is dated by its own report.** `_age_as_of()` computes `mailbox.age_seconds = report timestamp − newest` from the single `ts_iso` `_build_report()` stamps both fields with (second precision both sides, never `time.time()` at scan time), so the field is *this report's* arithmetic and a consumer can redo the subtraction from the two stamps it ships beside; `null` when the stamps are unreadable **and** when the entry postdates the report (clock skew — a negative age is not an age, and `0` would claim "written right now"), the same rule that already made an empty mailbox null. `LINT_STUB_SLEEP` (test-only env) sleeps the lint gate so a suite can prove the age covers gate time. (b) **`system-status` never trusts that number — it re-derives it.** `stamp()` parses both stamps to the second and the row computes `timestamp − newest` itself, so "old" always means *old when this report was made*; `render_age()` prints **`3d 8h` above a day** and minutes below (seconds under a minute), the queued item (2). Six honest states where the subtraction cannot be made, each a `refusal_header()` branch: no `mailbox` field → `age not recorded by this child` · no entries → `holds no entries` · `newest` present but no/unparseable `timestamp` or entry stamp → `entry <stamp>, no stamps this row can date it from` (the unanchored `age_seconds` is **never** displayed) · entry postdates the report → `postdates this report's timestamp` (never `-10m`, never `0`) · the child's `age_seconds` contradicting its own stamps by >2 s → `age not trusted: the child recorded 96m, the report's own stamps say 90m` — **both** numbers, no "THE age", still exit 0, and deliberately *not* `cannot verify` (the report is readable; only its self-inconsistent age is refused) · both stamps present → `newest reviewer-mailbox entry <UTC>, <age> old`. Prefix placement, `clean()` 400-char truncation and "an age nobody measured is never printed" all unchanged; M18's minutes contract untouched (re-pinned to `render_age()`'s line).
- **Step taken (test-first)**: assertions written **before** either fix — `tests/test_promote_json.sh` **section L6** (entry aged *now*, lint gate sleeps 3 s → age must be `≥ 2` **and exactly equal** to the suite's own `timestamp − newest`) and **L7** (entry aged 10 min in the future → `newest` recorded, `age_seconds` null), `LINT_STUB_SLEEP`, a `--help` "dated by this report" needle → 118 → 127; `tests/test_system_status_promote_gates.sh` stub given self-consistent stamps (`calendar`/`NEWEST`/`iso_add`, `STUB_PG_TS` = `""`/`NULL`/`BEFORE`/<ISO>, `STUB_PG_MB` += `BADTS`), **18b** re-contracted (`NOAGE` now *derives* 96 m), new **section 18c** (mismatch / no stamps / bad entry stamp / postdates / `3d 8h`), three `--help` needles → 155 → 176. Then `tools/promote-dev-to-prod` (`_mailbox_state` → `{dir, newest}`, `_age_as_of()`, one `ts_iso` for both stamps, `--help`) and `tools/system-status` (`stamp()`/`render_age()`, rewritten `refusal_header()`, `--help`, contract comment). **Fixture bug found while writing them**: `touch -d "$(date -u …)"` parses a plain string as **local** time (2 h skew here), silently dating fixtures hours from the gates they were compared to — every new fixture now sets mtime by **epoch** (`touch -d @$(date +%s)`), with the reason in the L6/L7 comments. After the fix → **127/127** and **176/176**; `bash -n` (`system-status`) and `ast.parse` (`promote-dev-to-prod`) clean.
- **Mutations (3 + 5 new, planted on copies — the real tools are never edited — precondition-asserted by grepping the search string for exactly one occurrence, `ast`/`bash -n`-validated, each run against the one scenario it targets)**: promote — **M11** the age ships `null` (the staleness the field exists to surface is gone) · **M12** the age bucketed to minutes before it is written (planted on a *minutes-sensitive* scenario — a 2 h old entry buckets to itself and would sail through) · **M13** a postdating entry shipping its negative age. Dashboard — **M19** the row prints the child's number again (the scan-time age is back) · **M20** a self-contradicting age accepted as *the* age · **M21** the day unit dropped (multi-day age back to four-digit minutes) · **M22** the postdating state dropped · **M23** the no-stamps state misattributed to the child. **All 8 caught**, and **M18 re-pinned** to `render_age()`'s minute line (its old inline target no longer exists — the day unit was added *above* the minutes, which stayed byte-for-byte identical).
- **Live** (every invocation `--dry-run`, nothing promoted): `promote-dev-to-prod --dry-run --force --format json` → `timestamp` stamped, `"mailbox": {"newest": null, "age_seconds": null}` (only the `.gitkeep` is present — nothing to date, a null never a guess); `system-status --format human` → `promote-gates [WARN] not promotable (reviewer-mailbox holds no entries): verdict REFUSED: no verdict in /data/repo/mailboxes/reviewer-to-main … | dev-sync OK | commit-lint OK (commit b601fcd)` under **ALL SYSTEMS HEALTHY**, exit 0; both `--help`s render the new contract.
- **Full regression: 34 suites, 2059 assertions, 0 failed** (19 shell = 1578: budget 49, dns 40, domain 140, identity 28, ip-drift-check 133, ip-drift-cron 93, pdns 175, promote 67, **promote-json 127**, promote-lint 60, repo-lint 120, smtp 38, source-sync 30, go-compile 82, go-tests 66, mx_soa 31, **promote-gates 176**, unread 24, tls 99 + 15 PHP = 481) — **+30 over last run's 2,029 baseline** (= exactly the two suites that grew, +9 and +21), no other suite moved, first pass clean (no load contention). `test_changelog_api` 78/0, `test_app_version` 39/0, `test_cli_version` 35/0 after the CHANGELOG append.
- **Docs**: REGISTRY §promote-dev-to-prod (new report-dated-age paragraph incl. the `LINT_STUB_SLEEP` knob, suite bullet 118 → 127 / 10 → 13 mutations with L6/L7 pins + this step's pre-fix replay + the `touch -d` local-time note, status line); REGISTRY §system-status (refusal-date subsection rewritten around `stamp()`/`render_age()` with the six-state table, 18b/18c + M18 re-pin + M19–M23, pre-fix replay 156/20, live transcript, status line); CHANGELOG `[0.4.45]` parked at the **bottom** with the placement note (train head stays `0.4.28` → version tests stay green) and `[0.4.44]`'s queue actioned/carried; PROGRESS (this entry).
- **Safety**: model spend **0.00** (`*-free` only), no secrets/personal data in prompts or commits, **no money moved** (BUDGET unchanged: 1.50 spent / 3.50 remaining), **no DNS write**, **no service restart**, `/opt/startup/dev` and `/opt/startup/prod` untouched (repo-side tools only; the live message DBs were read only for STEP 0's counts).
- **Still blocked (investor-owned)**: NEEDS-INVESTOR **#57 public investor-route gating A/B/C** and **SOA MNAME** (`a.misconfigured.dns.server.invalid.` → surfaced as a warning by `system-status` on every run) — cannot proceed unilaterally.
- **Next-candidate queued, not actioned**: (1) the last of `[0.4.43]`'s three items — only one of the three gates' children is ever compared: `dev-sync`'s `source-sync-check` path is recorded in `gates[].child` but nothing reads it, so extending the divergence guard to that second pair applies the same "one check seen twice" principle where it is still missing. (2) Noted in `[0.4.45]`'s queue as intended behaviour, not a defect: the age is now *dated* correctly but re-ages on every dashboard run because each run makes a fresh report — freezing it at refusal time would need the refusal moment itself recorded, which no field currently carries.

## 2026-09-25T06:30Z main-loop run — STEP 0 clear; the divergence guard watched only ONE of the three gates' children: "dev == repo" was asked twice a run and the two answers never met — suite 176 → 231 assertions, 23 → 29 mutations
- **STEP 0 (done first, re-verified at run end)**: **0 unread `investor_to_agent` rows on BOTH DBs** (dev 0 of 24 rows, prod 0 of 3 — every row read); `INBOX.md` 65 `##` entries, **65 HANDLED, 0 unhandled** (verified per-entry: no heading without a HANDLED marker); `mailboxes/*` 0 pending Dispatcher assignments. No reply owed, nothing to mark — recorded here so the run closes the investor loop explicitly. Investor-side note: **12 of our own `agent_to_investor` rows are still unread by the investor** (prod mirrors 16), including **msg #57 (A/B/C public-gating escalation)** — that ask stays open until they open it.
- **Defect class this step exists for (18th of the series: system-status, dns-verify, budget-show, tls-check, domain-availability-check, pdns-api, ip-drift-check, ip-drift-cron, repo-lint, go-tests, go-compile, promote-gates + its JSON report)**: queue item **(1)** from `[0.4.45]`, verbatim — *"only one of the three gates' children is ever compared: `dev-sync`'s `source-sync-check` path is recorded in `gates[].child` but nothing reads it — extending the divergence guard … would apply the same 'one check seen twice' principle to the second pair."* Same defect as the seventeen: **two readings of one fact that could disagree with no arbiter.** The promote gate asked *is dev == repo?* by running `source-sync-check`, the dashboard row never asked it at all, and `gates[].child` recorded the path a reader would need to reconcile them — with no reader. On the same run the gate could say `ok` while the truth was `drift` (or the reverse) and the row would print whichever half it happened to consult first, silently. Queue item **(2)** (age re-aging each run) stays queued as intended behaviour, not actioned.
- **Contract now**: the row runs `source-sync-check --env dev --format json` **itself** (`GLADEX_SOURCE_SYNC_BIN`, default the sibling script; `GLADEX_SOURCE_SYNC_TIMEOUT`, default `60`), only inside the non-timeout branch where a promote report exists — beside the evidence it complements — with the row's own `GLADEX_REPO_DIR`, and compares the reading with `gates[dev-sync]`. **Only a contradiction speaks, always `warning`, never `error`**: gate `ok` + ours `drift`, or gate `refused` + ours `in_sync` → `cannot verify: dev-sync gate and source-sync-check disagree (gate …, ours …) - <child evidence>`. **Agreement = silence** (the ordinary row). `ds_verdict()` returns `in_sync`/`drift`/`unknown`, and **`unknown` is never a claim** — no run, garbage, timeout, non-JSON `exit_code`, exit 3, or a gate with no recorded child silences the guard rather than guessing either way. Evidence goes through `child_phrase()` (refactored to take its "same" clause as an argument; pair 1's output byte-identical, pair 2: *so either the dev tree changed between the two checks or their environments differed*). **Option B**: no new dashboard row — `system-status` gained no check, REGISTRY's "29 checks" unchanged; the guard is evidence *inside* the existing promote-gates row.
- **Step taken (test-first)**: assertions written **before** the fix — `tests/test_system_status_promote_gates.sh` gained the scenario **`source-sync-check` stub** (`ok`/`drift`/`missing`/`garbage`/`hang`, logging argv + `GLADEX_REPO_DIR` to `STUB_SS_LOG`), `STUB_PG_DSCHILD` on the promote stub, knobs `SS_MODE`/`SS_BIN`/`SS_TMO`/`SS_LOG` wired through `_run`/`run_mutant`, **section 14d** (agreement both ways, both contradictions, five non-claim states), **section 14e** (same/different/unrecorded child evidence), **section 15b** (`--env dev`, `--format json`, same `GLADEX_REPO_DIR` on the logged argv), five `--help` needles, section 3 re-pointed to `SS_MODE=drift` as the agreement pin. Pre-fix replay vs the then-`HEAD` blob (`1d4d407:tools/system-status`, md5 `34d8bd71abde433437cdcc1284fcb823`) → **205 passed / 26 failed / 0 skipped** (old 176 green; contradictions/evidence/argv/help red; M24–M29 unplantable at 0 lines). Then `tools/system-status`: `--help`, `PROMOTE_GATES_PY` contract comment, `child_phrase()` refactor, `_ds_gate`/`ds_bin`/`ds_v`/`ds_verdict()`, the `ds_contradiction` block (emits then `sys.exit(0)` before the `ready` logic), bash hooks `SSC_BIN`/`SSC_TIMEOUT`, the SS run block, `SS_DS_OUT/SS_DS_RC/SS_DS_BIN` hand-off into the row's `ROW=$(...)` env. Two post-fix failures fixed in the tool: pair 1's same-clause needed its `so` prefix (output stayed byte-identical), pair 2's evidence clause had a duplicated *"the same child, "*, and one 14d needle sat past `clean()`'s 400-char cap (replaced with *"dev tree differs from the repo"* — the convention: assert near the front of a long detail). After the fix → **231/231**; `bash -n` and `ast.parse` (extracted `PROMOTE_GATES_PY`, 332 lines) clean.
- **Mutations (6 new, planted on copies — the real tool is never edited — precondition-asserted by grepping the search string for exactly one occurrence, each run against the one scenario it targets)**: **M24** the `ds_contradiction` block dropped (both contradictions print the ordinary row) · **M25** the hand-off dropped (`SS_DS_OUT=""` → our half blind) · **M26**/**M27** `unknown` treated as a claim in each direction (garbage/exit-3/timeout read as `drift`; a never-run sighting read as `in_sync`) · **M28** the gate's `child` never read (`ds_bin = ""` → every 14e branch collapses to `?`) · **M29** `--env dev` dropped from the child argv. **All 6 caught** (23 → 29).
- **Live** (nothing promoted, no service touched): `source-sync-check --env dev --format json` → `drift: true`, 8 of 21 files repo-ahead of the dev runtime, exit 1; `promote-dev-to-prod --dry-run --force --format json` → `gates[dev-sync] = refused ("dev tree differs from the repo in 9 file(s) …")`, `child: /data/repo/tools/source-sync-check` (both recorded paths now have readers); `system-status --format human` → the `promote-gates [WARN] not promotable … | dev-sync REFUSED: … | commit-lint OK (commit …)` row with **no `disagree` sentence** — gate `refused` + ours `drift` is agreement, the guard stayed silent, exit 0; both `--help`s render the contract.
- **Full regression: 34 suites, 2114 assertions, 0 failed** (19 shell = 1633 — **+55** over the 2,059 baseline, exactly promote-gates's 176 → 231 — plus 15 PHP = 481, unmoved); `test_changelog_api` 78/0, `test_app_version` 39/0, `test_cli_version` 35/0, `test_ts_ordering` 7/0 after the CHANGELOG append. **Concurrent-shift noise, isolated**: a second full pass minutes later showed 49 PHP failures — another identity was mid-flight in the same worktree (HEAD moved `1d4d407` → `59fcd9c`, 11 `app/src/php/*.php` modified uncommitted, new untracked `tests/test_main_landmark.php`). Attribution verified, not assumed: no PHP test references `system-status` or `source-sync-check`, and my change is bash-only; the three non-new-suite failures (changelog_api/page_lang/webmail, 1 each) tracked their modified app files and are green again after their work settled. The first pass (all 34 green) is the evidence for this step. **My two code files were swept into `cb29987` by that shift's commit** (message describes this change); this run's commit therefore carries only its own docs, staged explicitly — never `git add -A` while another shift's WIP sits in the tree.
- **Docs**: REGISTRY §system-status (test-hooks bullet `GLADEX_SOURCE_SYNC_BIN`/`_TIMEOUT`, verdict-table row for the second-pair contradiction, the *second pair* guard subsection, suite bullet 176 → 231 / M1–M29 with 14d/14e/15b, pre-fix replay 205/26 + md5, live transcript now showing the REFUSED agreement, status line `[0.4.46]`, four-suite note that a missing promote stub keeps them off the run); REGISTRY §promote-dev-to-prod (`gates[].child` now names both readers); CHANGELOG `[0.4.46]` parked at the **bottom** with the placement note (train head stays `0.4.28`) and `[0.4.45]`'s queue item (1) marked actioned; PROGRESS (this entry).
- **Safety**: model spend **0.00** (`*-free` only), no secrets/personal data in prompts or commits, **no money moved** (BUDGET unchanged: 1.50 spent / 3.50 remaining), **no DNS write**, **no service restart**, `/opt/startup/dev` and `/opt/startup/prod` untouched (repo-side tool only; the live message DBs were read only for STEP 0's counts).
- **Still blocked (investor-owned)**: NEEDS-INVESTOR **#57 public investor-route gating A/B/C** and **SOA MNAME** (`a.misconfigured.dns.server.invalid.` → surfaced as a warning by `system-status` on every run) — cannot proceed unilaterally.
- **Next-candidate queued, not actioned**: (1) the age re-ages on every dashboard run (carried from `[0.4.45]`, intended behaviour: freezing it at refusal time needs the refusal moment recorded, which no field carries). (2) **Live environment drift noted, not ours to fix**: the dev runtime serves older copies than the repo in 8–9 files (partly undeployed fixes, partly another shift's uncommitted WIP) — this is exactly what keeps `dev-sync` REFUSED and what the second pair now cross-checks; committing/deploying that drift is main-loop work.

## 2026-09-25T07:58Z main-loop run — STEP 0 clear; three gates could all say yes and none of them had looked at the tree being shipped: step 3 rsyncs the worktree over dev *after* `commit-lint` measured a different one — fourth gate `ship-tree` (exit 8) + `system-status` 4-gate row, suites 175 / 80 / 60 / 243, 31 mutations
- **STEP 0 (done first, re-verified at run end)**: **0 unread `investor_to_agent` rows on BOTH DBs** (dev 0 of 24 rows, prod 0 of 3 — every row read); `INBOX.md` **65 `##` headings, 65 HANDLED, 0 unhandled** (counted on the heading line itself, where the `~~HANDLED …~~` marker lives); `mailboxes/*` only `.gitkeep`, no pending Dispatcher assignment; `/root/Maildir/new` holds only the earlier "tls-restore smoke test". No reply owed, nothing to mark. Investor-side note: **12 of our own `agent_to_investor` rows are still unread by the investor (prod mirrors 16)**, including **msg #57 (A/B/C public-gating escalation)** — that ask stays open until they open it.
- **Defect class this step exists for (19th of the series: system-status, dns-verify, budget-show, tls-check, domain-availability-check, pdns-api, ip-drift-check, ip-drift-cron, repo-lint, go-tests, go-compile, promote-gates + its JSON report)**: queue item **(3)** from `[0.4.40]`, verbatim — *"the last two gates may be read as 'the code is fine' … step 3's `rsync --delete` over a dirty tree … nothing asserts the worktree it copied was clean."* Same defect as the eighteen: **two readings of one fact that could disagree with no arbiter** — here two *trees*: the one `commit-lint` measured (worktree vs `HEAD:`, before step 3) and the one step 3 actually copied onto dev. Nothing checked the second. Each existing gate is blind structurally, not by luck: `commit-lint` reads `HEAD:` blobs and so cannot see anything step 3 changed (nor a failed `--ignore-submodules` leaving a stale submodule dir); `dev-sync` compares bytes *repo vs dev*, so two copies of the same **dirty** file match each other perfectly and both go green; `verdict` reads a note. Result: a worktree full of uncommitted edits in `app/src/php` — the only tree `build-release` ships — passes all three and promotes. And the suites could not have caught it: every promote sandbox was `mktemp -d`, i.e. **not a git repo**, where `git status` exits 128 and a naive gate would call every sandbox red.
- **Contract now**: **fourth gate `ship-tree`, exit 8** (own of 4/5/6/7), `check_ship_tree_gate()` called at **`0d`** — after step 3 has synced dev, before promote — scoped to `PROMOTED_TREES` (`app/src/php`, `examples/workflows`) through one `git status --porcelain --ignored -- <trees>`. Dirty files **outside** the promoted trees never block (asserted); anything inside blocks and `detail` names the first offending path; `--ignored` means an ignored-but-present file blocks too, so promoted bytes can never differ from `HEAD:` for a reason git hides by default; **a non-zero `git status` exit fails closed as `refused`, never `ok`** (not a git repo / bad revision / unreadable tree), and `child` is recorded **only when the subprocess actually spawned** (`None` on `Popen`/`OSError`, so `child` never claims `/usr/bin/git` for a run that never ran it); **clean == `HEAD:` is read from the empty porcelain output, not from the exit code**; `--force` records the truth first, then overrides with `bypassed` + a `SHIPTREE-BYPASS` marker — `[0.4.41]`'s shape for `dev-sync`. `system-status` now expects **four** gates: `EXPECTED` = `verdict, dev-sync, commit-lint, ship-tree`, the `promote-gates` row, the `ready` line (`dev-sync OK | commit-lint OK` needle gained ` | ship-tree OK`) and `--help` all name it.
- **Step taken (test-first)**: assertions written **before** the fix against the kept `HEAD:` blobs (`/tmp/opencode/pre-fix/{promote-dev-to-prod,system-status}`, full tree `/tmp/opencode/pre-fix-repo`), so the gap is replayable rather than asserted after the fact. Pre-fix red: JSON suite **131 passed / 44 failed** (S1–S9 unplantable — no fourth gate, no `ship_tree_*` hints, no `gates[3]`, no `--help` contract; the 3→4 gate-count assertions red; M14–M20 unplantable) · gate suite **72 / 8** · lint suite **60 / 0** · status suite **234 passed / 9 failed** (4b's old behaviour was `cannot verify: every gate passed but the child did not report ready (exit 0)`, M30/M31 unplantable). Post-fix: JSON **175 / 0** (sections **M** S1–S9, **N** M14–M20) · gate **80 / 0** (section **Y**) · lint **60 / 0** · status **243 / 0** (section **4b**, `ship-refused` mode, M30–M31, **31 mutations**). All promote-suite sandboxes are now **real git repos** (`git_init_sandbox`), because a gate that shells out to `git status` cannot be tested in a directory that is not one — the exact blind spot that hid the defect. `bash -n` clean on both tools.
- **Mutations (9 new, planted on copies — the real tool is never edited — precondition-asserted by grepping the search string for exactly one occurrence, each run against the one scenario it targets)**: **M14** gate result ignored (gate fails, run continues) · **M15** refusal recorded as pass · **M16** a failed `git status` read as a match · **M17** `--ignored` dropped · **M18** pathspec dropped (whole repo scanned) · **M19** `if not paths` → always clean · **M20** the `--force` bypass removed · **M30** `ship-tree` dropped from `EXPECTED` · **M31** the ready line drops the 4th gate. **All 9 caught.**
- **Live** (nothing promoted, no service touched): `promote-dev-to-prod --dry-run --force --format json` → `gates = [verdict refused 5, dev-sync ok 0, commit-lint ok 0 (168 files), ship-tree ok 0]`, `gates[3].detail = promoted trees match HEAD (app/src/php, examples/workflows)`, `gates[3].child = /usr/bin/git`, `sha 6468cf9`, `ready false` only because the mailbox is empty; `system-status --format human` row = `… | dev-sync OK | commit-lint OK | ship-tree OK (commit 6468cf9)`, tool exit 0; both `--help`s render the four-gate contract. Live `git status --porcelain --ignored -- app/src/php examples/workflows` is empty, so the gate is `ok` on a clean tree.
- **Full regression (run after the CHANGELOG and PROGRESS appends): 35 suites, 2517 assertions, 0 failed** — 19 shell = **1706**, 16 PHP = **811**. Against this run's `HEAD:` blobs the three suites this step grew account for **+61** (promote-json **131 → 175**, promote-gate **72 → 80**, promote-gates **234 → 243**; promote-lint stayed **60**); the other **+12** over the previous run's 1633 shell total landed in *other* identities' commits between that run's tree and this run's `HEAD` (`6468cf9`) — verified, not assumed: `git diff --stat HEAD -- tests/` lists exactly the four suites this run edited and nothing else. Doc/contract suites after the appends: `test_repo_lint` **120/0**, `test_changelog_api` **78/0**, `test_app_version` **39/0**, `test_cli_version` **35/0**, `test_ts_ordering` **7/0**.
- **Docs**: `tools/REGISTRY.md` §promote-dev-to-prod (purpose/usage/exit 8, new **Ship-tree gate** section, JSON sample + 4th gate, gate counts ×4, Operation `0d`, test hooks, suite descriptions with pre-fix replays, status line) and §system-status (row table, section 4b, M30–M31, pre-fix replay, live transcript); CHANGELOG `[0.4.48]` parked at the **bottom** with the placement note (train head stays `0.4.28`; `[0.4.47]` was already taken by the `<main>` landmark entry, so this run used the next free number); PROGRESS (this entry).
- **Safety**: model spend **0.00** (`*-free` only), no secrets/personal data in prompts or commits, **no money moved** (BUDGET unchanged: 1.50 spent / 3.50 remaining), **no DNS write**, **no service restart**, `/opt/startup/dev` and `/opt/startup/prod` untouched (repo-side tool only; the live message DBs were read only for STEP 0's counts), **no `noreply@` sendmail invoked**.
- **Staging discipline**: `git add` explicit — the working tree held exactly this run's 8 files (2 tools, 4 suites, CHANGELOG, REGISTRY) plus this entry; never `git add -A` while another identity's WIP could sit in the tree.
- **Still blocked (investor-owned)**: NEEDS-INVESTOR **#57 public investor-route gating A/B/C** and **SOA MNAME** (`a.misconfigured.dns.server.invalid.` → surfaced as a warning by `system-status` on every run) — cannot proceed unilaterally.
- **Next-candidate queued, not actioned**: (1) `ship-tree` names the first offending path but not *why* it is there (stray editor file vs a real new source file awaiting `git add`) — a diagnosis the operator still has to make. (2) `--ignored` makes `git status` walk ignored content; if `app/src/php` ever gains a large ignored build dir, the 30 s `GLADEX_SHIP_TIMEOUT` may need a pathspec exclude. (3) The divergence guard compares only `gates[1].child` — extending `[0.4.46]`'s principle to `gates[3].child` (ship-tree) would make the second pair a third. (4) Carried: the `promote-gates` age re-ages on every run (intended, needs a refusal moment no field carries).
> build · mimo-v2.6-flash-free
$ cd /data/repo && sed -n '598,626p' tests/test_system_status_promote_gates.sh
echo "=== 4b. ship-tree refuses while HEAD lints clean -> named, and never a divergence ==="
# The fourth gate refuses on its OWN subject (the tree rsync ships) while the
# commit-lint gate keeps its green verdict about HEAD: two different questions,
# so the divergence guard - which only ever compares go-compile with
# commit-lint - must stay silent instead of reporting a disagreement that was
# never measured. The fixture is DIRTY on purpose ([0.4.49]): the row now
# measures this tree itself (14f), so a stub refusing on a tree that is
# actually clean would be a genuine third-pair contradiction - a different
# assertion, made there. Here the gate's refusal and reality agree, and what
# is under test is that the ORDINARY refusal names the gate.
RL_MODE=ok; PG_MODE=ship-refused; make_dirty
OUT=$(run_status)
DETAIL="$(item_field "$OUT" "promote-gates" detail)"
[ "$(item_field "$OUT" "promote-gates" status)" = "warning" ] \
    && ok "ship-tree refusal -> warning" || bad "ship-tree refusal -> warning"
assert_contains "ship-tree is the refusing gate" "ship-tree REFUSED" "$DETAIL"
assert_contains "the reason survives the row's truncation" "differs from HEAD" "$DETAIL"
assert_contains "the reason says which side rsync takes" "rsync ships the worktree" "$DETAIL"
assert_contains "commit-lint stays OK - it measured HEAD, and HEAD is fine" \
    "commit-lint OK" "$DETAIL"
assert_contains "and the row still carries the commit it measured" "(commit abc1234)" "$DETAIL"
assert_not_contains "the divergence guard stays silent: parity is not a lint disagreement" \
    "disagree" "$DETAIL"
assert_not_contains "and it is never promote-ready" "promote-ready" "$DETAIL"
[ "$(run_exit)" = "0" ] && ok "a ship-tree refusal never fails the tool (exit 0)" \
    || bad "a ship-tree refusal never fails the tool (exit 0)"
make_clean; PG_MODE=ready

echo "=== 5. an unevaluated gate is reported, never silently skipped ==="
$ cd /data/repo && bash tests/test_system_status_promote_gates.sh 2>&1 | tail -5
ok   - mutation M37 caught (child saw -C repo real=yes mutant=no)
  ok   - mutation M38 caught (both trees scanned real=yes, mutant narrowed=no)
  ok   - mutation M39 caught (never-ran silent real=no mutant claims clean=yes)

=== Results: 295 passed, 0 failed, 0 skipped ===
> build · mimo-v2.6-flash-free
$ cd /data/repo && tail -c 400 CHANGELOG.md | cat -A | tail -5
immediately above this one)$
Parked at the bottom of an otherwise newest-first file on purpose. Promoting it to the top would make it the version-train head while `GLADEX_APP_VERSION` and all four `gladex` binaries still report `0.4.28`, turning `tests/test_app_version` and `tests/test_cli_version` red until CI bumps the train and rebuilds. That bump is main-loop work and is not part of this fix.$
> build · mimo-v2.6-flash-free
$ cd /data/repo && cat >> CHANGELOG.md <<'EOF'

---

## [0.4.49] - 2026-09-25 — the one gate that measures the shipped tree was the one gate nothing cross-checked: `ship-tree` could say `ok` while the tree step 3 rsyncs was dirty, and the dashboard would have printed `ship-tree OK`

### The defect
`[0.4.48]` added the fourth gate and `[0.4.46]` taught the dashboard row to re-ask the second gate's question itself — and then stopped one gate short. `[0.4.48]`'s own queue said it out loud: *"The divergence guard compares only `gates[1].child` (dev-sync) — extending it to `gates[3].child` (ship-tree) would apply `[0.4.46]`'s principle to the new gate."*

So `ship-tree` asked *do the trees step 3 rsyncs still equal HEAD?* exactly **once** per run. The gate answered, `gates[3].child` dutifully recorded which `git` answered, and no reader existed — the identical shape that made the first two pairs worth a guard each:

- the gate and the row's own view of the same trees could disagree (a gate reading `ok` against a tree that is dirty *now*, or a refusal against a tree that is clean) and the row would print whichever half it consulted first, with no arbiter;
- `gates[3].child` — recorded precisely so a reader could reconcile the two — was written and never read, so *"both sides ran the same git?"* could not be answered either;
- and the tree set itself had **no** pin: `[0.4.48]`'s `PROMOTED_TREES` lives in `promote-dev-to-prod`, so a row that scanned its own idea of the promoted trees would be measuring a different question than the gate while claiming to re-ask it — a divergence guard that diverged.

### The fix — the third pair, entirely inside `system-status`
The row now asks the gate's own question itself, seconds apart, on the same repo: `git -C <repo> status --porcelain --ignored -- <PROMOTED_TREES>` — the gate's argv, not a re-derivation of it.

- **only a contradiction speaks**, always `warning`, never `error`: gate `ok` + ours `dirty` → *ship-tree gate and git status disagree (gate passed, this run reports the promoted trees dirty)*; gate `refused` + ours `clean` → *(this run reports the promoted trees clean, the gate refused)*. Agreement — both clean, or both seeing the same dirty tree — prints nothing extra, and a refusal keeps its ordinary dated header;
- **`unknown` is never a claim**: `SHIP_RC` starts **empty** and is set to `0` only once the command actually runs (a defaulted `0` would report `clean` for a tree nobody measured), and a non-zero exit such as `128` (outside a work tree), a budget kill, an empty hand-off, or an unevaluated gate each silence the guard rather than guess either way;
- **evidence** goes through the same `child_phrase()` as the first two pairs — the gate's recorded `gates[3].child` against this run's own `GLADEX_GIT_BIN`, so *same path / different paths / unrecorded* read identically across all three;
- **hooks reuse promote's own names**: `GLADEX_GIT_BIN` (default `git` on PATH) and `GLADEX_SHIP_TIMEOUT` (default `30`, promote's budget for this very command), so both halves of one comparison name the same child. This sighting is **this row's own**, not a child's — it runs only where a report exists, read-only and bounded;
- **the tree set is pinned cross-file**: `PROMOTED_TREES` is mirrored into the row as a bash array and compared against promote's own list (symmetric difference must be empty), the same pin `test_promote_gate.sh` section P applies to `source-sync-check`'s `TREES`.

`promote-dev-to-prod` itself is unchanged — the third pair is evidence *inside* the existing `promote-gates` row, so the check count stays 29 and the gate's own semantics are untouched.

### Why it took a test-first pass with pre-fix red
Assertions first, against the kept blob `/tmp/opencode/pre-fix/system-status` (byte-identical to `HEAD:tools/system-status`, md5 `a49c6466c15605554cd6bd756d10c8c1`), so the gap is replayable rather than asserted after the fact:

- status suite **268 passed / 27 failed / 0 skipped** pre-fix → **295 passed / 0 failed / 0 skipped** post-fix. The 19 behavioural failures were section 14f's two contradictions (the gate-`ok` one stayed `ok` and even printed `promote-ready`), 14g's six evidence branches, 15c (**no git child ran at all** — logged argv empty — and `PROMOTED_TREES` unparseable, rc 4) and the four `--help` needles; **M32–M39 were unplantable** (0 matching lines each). The agreement and non-claim branches passed *pre-fix* — the old row was simply silent, and only the contradictions and the measured invocation were missing.
- 4b is now self-consistent: a stub refusing on a genuinely clean tree **is** a third-pair contradiction, so the section wraps itself in `make_dirty` to keep testing the ordinary refusal.

New sections **14f** (agreement both ways, both contradictions, four non-claim states), **14g** (same/different/unrecorded child evidence) and **15c** (argv contract + cross-file `PROMOTED_TREES` pin); git stub trio `git-wrap` (logs argv, `exec`s real git — the claim states must come from real `git status` semantics), `git-fail` (exit 128), `git-hang`.

Eight new mutations, each planted on a copy, precondition-asserted exactly once, `bash -n`-validated: M32 the guard block dropped (the mutant *ships past a dirty tree*) · M33 the hand-off dropped · M34 an unread sighting read as `dirty` · M35 the mirror (an unread sighting read as `clean`) · M36 the gate's `child` never read · M37 `-C "$REPO_DIR"` dropped (two halves, different repos) · M38 the mirror narrowed to one tree · **M39 `SHIP_RC` defaulted to `0`** — a child that never ran read as a clean sighting. **All 8 caught.**

### Verification
- Full regression: **35 suites / 2569 assertions / 0 failed** (19 shell = 1758 + 16 PHP = 811); `bash -n` clean on `tools/system-status`, the extracted `PROMOTE_GATES_PY` (402 lines) parses with `ast`.
- Live: `system-status --format human` → `promote-gates [WARN] not promotable (reviewer-mailbox holds no entries): verdict REFUSED: … | dev-sync OK | commit-lint OK | ship-tree OK (commit 3996cc3)` under `ALL SYSTEMS HEALTHY`, exit 0, **no `disagree` sentence**: the row's own `git -C /data/repo status --porcelain --ignored -- app/src/php examples/workflows` answered `rc 0` with empty output while the gate said `ship-tree OK` — the third pair *agreed*.
- Doc suites after the appends: `test_repo_lint`, `test_changelog_api`, `test_app_version`, `test_cli_version`, `test_ts_ordering`.

### Notes
- **No promote was run** and nothing was promoted; every promote invocation this run was `--dry-run`. The live `git status` is read-only.
- No DNS write, no service restart, no money moved (BUDGET unchanged: 1.50 spent / 3.50 remaining), model spend **0.00** (`*-free` only).

### Queue (next-candidate, not actioned)
- **`[0.4.48]` item actioned above**: the divergence guard now covers all three pairs (this entry).
- **`[0.4.48]` item (1) carried**: `ship-tree` names the first offending path but not *why* it is there (stray editor file vs a real new source file awaiting `git add`).
- **`[0.4.48]` item (2) carried**: `--ignored` makes `git status` walk ignored content; if `app/src/php` ever gains a large ignored build dir, the 30 s `GLADEX_SHIP_TIMEOUT` may need a pathspec exclude — and this row now pays that cost too, on every promote report.
- **Carried**: the `promote-gates` age re-ages on every dashboard run (intended behaviour: freezing it at refusal time needs a refusal moment no field carries).

### Placement (deliberate — same as `[0.4.29]`–`[0.4.48]` which sit immediately above this one)
Parked at the bottom of an otherwise newest-first file on purpose. Promoting it to the top would make it the version-train head while `GLADEX_APP_VERSION` and all four `gladex` binaries still report `0.4.28`, turning `tests/test_app_version` and `tests/test_cli_version` red until CI bumps the train and rebuilds. That bump is main-loop work and is not part of this fix.
EOF
echo appended; tail -4 CHANGELOG.md
appended
- **Carried**: the `promote-gates` age re-ages on every dashboard run (intended behaviour: freezing it at refusal time needs a refusal moment no field carries).

### Placement (deliberate — same as `[0.4.29]`–`[0.4.48]` which sit immediately above this one)
Parked at the bottom of an otherwise newest-first file on purpose. Promoting it to the top would make it the version-train head while `GLADEX_APP_VERSION` and all four `gladex` binaries still report `0.4.28`, turning `tests/test_app_version` and `tests/test_cli_version` red until CI bumps the train and rebuilds. That bump is main-loop work and is not part of this fix.
> build · mimo-v2.6-flash-free
← Edit repo/CHANGELOG.md
Index: repo/CHANGELOG.md
===================================================================
--- repo/CHANGELOG.md
+++ repo/CHANGELOG.md
@@ -1161,9 +1161,9 @@
 
 ### Queue (next-candidate, not actioned)
 - **`ship-tree` does not check untracked-but-not-ignored files' provenance** — it blocks them (porcelain `??`), but the *reason* they exist (a stray editor file vs a real new source file awaiting `git add`) is not distinguished in `detail`, so the operator gets a path and not a diagnosis.
 - **`--ignored` on a tree with a large ignored build dir** makes `git status` walk it; if `app/src/php` ever gains one, the 30 s `GLADEX_SHIP_TIMEOUT` may need a pathspec exclude.
-- The divergence guard compares only `gates[1].child` (dev-sync) — extending it to `gates[3].child` (ship-tree) would apply `[0.4.46]`'s principle to the new gate.
+- The divergence guard compares only `gates[1].child` (dev-sync) — extending it to `gates[3].child` (ship-tree) would apply `[0.4.46]`'s principle to the new gate. **→ actioned in `[0.4.49]`** (the guard now covers all three pairs).
 
 ### Placement (deliberate — same as `[0.4.29]`–`[0.4.47]` which sit immediately above this one)
 Parked at the bottom of an otherwise newest-first file on purpose. Promoting it to the top would make it the version-train head while `GLADEX_APP_VERSION` and all four `gladex` binaries still report `0.4.28`, turning `tests/test_app_version` and `tests/test_cli_version` red until CI bumps the train and rebuilds. That bump is main-loop work and is not part of this fix.
 

> build · mimo-v2.6-flash-free

Select another run log from the list above. Only files matching run-YYYYMMDD-HHMMSS-N.log are readable.

App log tail — prod-8001.log (last 60 lines)
[Fri Sep 25 10:43:44 2026] 127.0.0.1:45188 Accepted
[Fri Sep 25 10:43:44 2026] 127.0.0.1:45188 Closing
[Fri Sep 25 10:43:44 2026] 127.0.0.1:45198 Accepted
[Fri Sep 25 10:43:44 2026] 127.0.0.1:45198 Closing
[Fri Sep 25 10:43:44 2026] 127.0.0.1:45212 Accepted
[Fri Sep 25 10:43:44 2026] 127.0.0.1:45212 Closing
[Fri Sep 25 10:43:44 2026] 127.0.0.1:45214 Accepted
[Fri Sep 25 10:43:44 2026] 127.0.0.1:45214 Closing
[Fri Sep 25 10:43:44 2026] 127.0.0.1:45222 Accepted
[Fri Sep 25 10:43:44 2026] 127.0.0.1:45222 Closing
[Fri Sep 25 10:43:45 2026] 127.0.0.1:45226 Accepted
[Fri Sep 25 10:43:45 2026] 127.0.0.1:45226 Closing
[Fri Sep 25 10:49:19 2026] 127.0.0.1:37154 Accepted
[Fri Sep 25 10:49:19 2026] 127.0.0.1:37154 Closing
[Fri Sep 25 10:49:20 2026] 127.0.0.1:37162 Accepted
[Fri Sep 25 10:49:20 2026] 127.0.0.1:37162 Closing
[Fri Sep 25 10:49:20 2026] 127.0.0.1:37174 Accepted
[Fri Sep 25 10:49:20 2026] 127.0.0.1:37174 Closing
[Fri Sep 25 10:49:20 2026] 127.0.0.1:37190 Accepted
[Fri Sep 25 10:49:20 2026] 127.0.0.1:37190 Closing
[Fri Sep 25 10:49:20 2026] 127.0.0.1:37206 Accepted
[Fri Sep 25 10:49:20 2026] 127.0.0.1:37206 Closing
[Fri Sep 25 10:49:20 2026] 127.0.0.1:37218 Accepted
[Fri Sep 25 10:49:20 2026] 127.0.0.1:37218 Closing
[Fri Sep 25 10:49:20 2026] 127.0.0.1:37226 Accepted
[Fri Sep 25 10:49:20 2026] 127.0.0.1:37226 Closing
[Fri Sep 25 10:49:20 2026] 127.0.0.1:37234 Accepted
[Fri Sep 25 10:49:20 2026] 127.0.0.1:37234 Closing
[Fri Sep 25 10:49:20 2026] 127.0.0.1:37242 Accepted
[Fri Sep 25 10:49:20 2026] 127.0.0.1:37242 Closing
[Fri Sep 25 10:49:20 2026] 127.0.0.1:37252 Accepted
[Fri Sep 25 10:49:20 2026] 127.0.0.1:37252 Closing
[Fri Sep 25 10:49:20 2026] 127.0.0.1:37266 Accepted
[Fri Sep 25 10:49:20 2026] 127.0.0.1:37266 Closing
[Fri Sep 25 10:49:20 2026] 127.0.0.1:37278 Accepted
[Fri Sep 25 10:49:20 2026] 127.0.0.1:37278 Closing
[Fri Sep 25 10:49:20 2026] 127.0.0.1:37284 Accepted
[Fri Sep 25 10:49:20 2026] 127.0.0.1:37284 Closing
[Fri Sep 25 10:49:20 2026] 127.0.0.1:37290 Accepted
[Fri Sep 25 10:49:20 2026] 127.0.0.1:37290 Closing
[Fri Sep 25 10:49:20 2026] 127.0.0.1:37292 Accepted
[Fri Sep 25 10:49:20 2026] 127.0.0.1:37292 Closing
[Fri Sep 25 10:53:21 2026] 127.0.0.1:45156 Accepted
[Fri Sep 25 10:53:21 2026] 127.0.0.1:45156 Closing
[Fri Sep 25 10:53:21 2026] 127.0.0.1:45168 Accepted
[Fri Sep 25 10:53:21 2026] 127.0.0.1:45168 Closing
[Fri Sep 25 10:53:21 2026] 127.0.0.1:45172 Accepted
[Fri Sep 25 10:53:21 2026] 127.0.0.1:45172 Closing
[Fri Sep 25 10:53:21 2026] 127.0.0.1:45176 Accepted
[Fri Sep 25 10:53:21 2026] 127.0.0.1:45176 Closing
[Fri Sep 25 10:53:21 2026] 127.0.0.1:45180 Accepted
[Fri Sep 25 10:53:21 2026] 127.0.0.1:45180 Closing
[Fri Sep 25 10:53:21 2026] 127.0.0.1:45196 Accepted
[Fri Sep 25 10:53:21 2026] 127.0.0.1:45196 Closing
[Fri Sep 25 10:53:21 2026] 127.0.0.1:45200 Accepted
[Fri Sep 25 10:53:21 2026] 127.0.0.1:45200 Closing
[Fri Sep 25 10:55:42 2026] 127.0.0.1:53912 Accepted
[Fri Sep 25 10:55:42 2026] 127.0.0.1:53912 Closing
[Fri Sep 25 10:55:42 2026] 127.0.0.1:53914 Accepted

Generated 2026-09-25 08:55:42 UTC · Gladex.de