# Phase 31 — The gateway route is green end to end, and the row says *which domain*
**Source:** operator request, 2026-10-23 (post-phase-30 review of `/gateway`) — "The only animated line is `:443 -> 127.0.0.1:4443`. I can't tell which `reeseapps.com` or `stackexpected.com` addresses flow to their reeselink counterparts (very important). Why are the dotted lines on the left grey? Shouldn't those be green too? They're active."
**Story:** n/a
**Context:** `src/dashboards/web/static/dash.css` (the `gateway pipes` section: `.pipe-path`, `.gate-link::before`/`::after`, `.gate-elbow`, `.gate-trunk`, `.gate-target`, `.gate-selector`, `.pipe-name`), `src/dashboards/web/static/page-gateway.js` (`leafRow`/`renderLeaf`/`groupElement`/`renderTrunk`/`selectorText`/`leafTitle`/`leafAria`/`endpointText`), `src/dashboards/web/pages/gateway.html` (the two panels, the four tallies, the three footer paragraphs), `src/dashboards/sources/gateway.py` (**read-only for this phase** — `connections[].out.{host,port,name,kind,selectors,address}`, `listeners[].{connections,clients,domains,metric}`), `tests/test_api.py` (the gateway page pins: `GATEWAY_PAGE_FIELDS`, `test_a_clipped_pipe_label_keeps_its_full_text_on_the_element`, `test_a_pipe_moves_only_while_it_carries_a_connection`, `test_the_pipe_animation_stills_in_both_states_under_reduced_motion`, `test_no_gateway_count_is_coloured_by_a_verdict`, `test_an_unattributed_endpoint_prints_a_dash_and_never_a_zero`, `test_every_pipe_is_an_accessible_image_built_from_the_payload`), `data/logs/27-task02-mobile-audit.cjs` (the nine-page sweep that must still exit 0).
## Objective
`/gateway` answers the operator's actual question — *"which inbound domain flows to which counterpart?"* — from the row itself, and **every route a config declares is green from its trunk to its endpoint** instead of grey for two thirds of its length. The spine and the tick stop being structural ink and take the same existence pair as the run and the switch; the leaf row leads with the config's **inbound** word (`brain.reeseapps.com`) and puts the counterpart (`reeseapps-brain-of-reese.reeselink.com:443`) under it; the trunk draws its own short line that moves on the one live number the kernel genuinely reports for that **port** (`listeners[].connections`), so the page is not dead ink while a name target waits for phase 32; and one filter box finds a domain among the 33 routes without inventing a taxonomy.
This phase touches **no Python**: the payload already carries every string and every number these four changes need. `sources/gateway.py` is deliberately left byte-identical so phase 32's join rewrite (`todo/32_gateway_resolved_target_liveness`) lands on an unchanged surface.
## Scope decisions taken with the operator (binding for this phase)
| # | Decision | Consequence |
|---|----------|-------------|
| 1 | **Colour is still existence, and the whole route carries it.** | `.gate-link::before`, `.gate-link::after` and `.gate-elbow` take `--gate-on` / `--gate-off`, not `--border-strong`. `on`/`off` moves onto the `
` so **one** class paints every segment of one row (spine halves, tick, run, switch) and a row can never show a green run hanging off a grey tick. The base rules declare `transparent`, so a row that is built and never rendered still draws nothing — the `.pipe-path`-declares-no-`stroke` rule, restated for borders. `severity.py` still has no `gateway.*` row; no verdict token enters this section. |
| 2 | **Motion stays the per-row run's claim and nothing else.** | The spine segment between two rows is **shared** by both leaves, so a dash travelling down it cannot be attributed to one route — animating it would put a per-route claim on a per-list structure. Motion therefore remains "≥ 1 ESTABLISHED socket on this pipe right now", carried by `.pipe.live`, and the CSS comment says why the connectors get colour and not motion. |
| 3 | **The row reads inbound → counterpart, both verbatim.** | `.gate-target`'s two lines swap rank: line 1 is the config's inbound word (`out.selectors`, falling back to `out.kind`), line 2 is `endpointText(out)` with a leading `→`. Neither string is edited, truncated in data, or reordered by the page; `title` and the pipe's `aria-label` carry **both** in full (gap 13). |
| 4 | **The trunk moves on the port's own real number.** | The trunk draws a short `.pipe.pipe-trunk` that is `live` when `listeners[].connections >= 1` and `still` when it is `0`, and gets **no** motion class when the socket table did not answer (`null` — never treated as `0`, never printed as `0`). Its `aria-label` says `N connections arrived on :PORT in this reading`: it is a fact about the port, and it never implies which leaf carries them. |
| 5 | **The filter is a client-side eye, not a query.** | One `` over rows **already in the payload**: matches on the inbound word(s), the target, the port and the haproxy `server` name; hides non-matching rows and the groups left empty; re-applies inside the existing patch pass (no rebuild, no re-fetch, no new endpoint); Escape clears it; the warnings panel and the header tallies are **never** filtered. |
| 6 | **The page draws no zones.** | No grouping, sorting or colouring derived from a domain suffix. `reeseapps.com` vs `stackexpected.com` is the operator's own typing into the filter, not a payload key — a page that split by suffix would be inventing a fact the two config files never stated (rule 2). |
| 7 | **No Python, no payload, no agent, no write.** | Phase 31 is CSS + page JS + markup + tests + docs. The verb-enumeration pin still finds `/api/ups/command` as the app's only upstream write, `routeInConfig()` still answers `true` unconditionally, and the switch stays unwired — **phase 33 is what wires it**, and this phase must not half-start it. |
| 8 | **The `—` keeps its meaning and loses its mystery.** | The footer's second paragraph says what a dash is *in the operator's terms* (this endpoint is written as a name and the page matches sockets by address) instead of restating the join's internals. Phase 32 removes the reason; until then one honest sentence, not a column of unexplained dashes. |
**Out of scope:** any change to `sources/gateway.py`, `api.py`, `poller.py`, `alerts.py` or `agent/host_agent.py`; DNS resolution and any live count on a **name** endpoint (phase 32); any config write, toggle handler, `POST` route or privileged helper (phase 33); the `--gate-*` pair's definition (it stays a substitution of `--ok`/`--fail` in `:root`); window chips (the page keeps one fixed 24 h window); any new dependency.
## Dependencies
- `30_gateway_connection_map` (complete) — the two panels, the trunk→leaf rows, the `--gate-on`/`--gate-off` pair, the unwired switch, the `connections[]`/`listeners[]` payload this phase re-labels and re-colours. This phase **corrects** that phase's decision-1 rendering (the connectors were left `--border-strong`) and restates its colour pins; it deletes no claim.
- `29_gateway_dashboard_page` (complete) — the page shell, the tallies, the chart disclosures, the degradation arms.
- `27_mobile_tables_and_audit` (complete) — `data/logs/27-task02-mobile-audit.cjs` must still exit 0 with `/gateway` in its nine-page sweep, at 360 px and 1280 px, after the row re-rank and the filter's arrival.
- **Forward, not blocking:** `32_gateway_resolved_target_liveness` (name → address resolution, so the leaf lines animate) and `33_gateway_route_switching` (the config-write path that gives the switch a handler). Both are written after this one; nothing here pre-judges their shape beyond decision 7.
## Tasks
1. `01_the_whole_route_is_green.md` — `dash.css` + `page-gateway.js`: `on`/`off` on the row, the spine/tick/run/switch coloured from one class, base borders `transparent`, the verdict-vocabulary pin restated.
2. `02_the_inbound_word_leads_the_row.md` — `page-gateway.js` + `dash.css` + `gateway.html`: the leaf's two lines swap rank (inbound word first, `→ counterpart` second), `title`/`aria-label` carry both, the `—` footer sentence rewritten, `GATEWAY_PAGE_FIELDS` and the clip pin updated rather than relaxed.
3. `03_the_trunk_moves_on_a_real_number.md` — `page-gateway.js` + `dash.css`: the trunk's own short pipe, `live`/`still`/neither from `listeners[].connections`, its accessible sentence naming the port, reduced-motion named.
4. `04_find_a_domain.md` — `gateway.html` + `page-gateway.js` + `dash.css`: `#gate-filter`, client-side match set, groups emptied by the filter collapse to their own "no route matches" state, an `N of M` chip, Escape to clear, no fetch.
5. `05_docs_and_live_verify.md` — `AGENTS.md` (the `/gateway` UI-conventions paragraph, the metric-naming/`sources/gateway.py` passages if their wording about colour and labels went stale, gap 13) + `README.md`, the browser pass at 1440 px and 360 px with screenshots, the mobile-audit re-run, and a read-only live check against the real ingress.
## Testing & Quality
- Served-byte pins in `tests/test_api.py`, **restated rather than deleted** wherever a class or line-rank moves: `test_no_gateway_count_is_coloured_by_a_verdict` keeps every "no verdict vocabulary here" assertion and gains "the connectors are coloured from `--gate-on`/`--gate-off` and their base colour is `transparent`"; `test_a_pipe_moves_only_while_it_carries_a_connection` is restated to cover the trunk pipe's *different* source of truth (`listeners[].connections`) without letting it imply a leaf; `test_the_pipe_animation_stills_in_both_states_under_reduced_motion` gains the trunk pipe; `test_a_clipped_pipe_label_keeps_its_full_text_on_the_element` is strengthened (both ends now, not just the target); `test_an_unattributed_endpoint_prints_a_dash_and_never_a_zero` is unchanged in claim.
- **New** pins: `test_the_whole_route_takes_one_existence_class` (the `
` carries `on`/`off`; CSS colours spine halves, tick, run and switch from it; no connector rule reads `--border-strong`), `test_the_leaf_row_leads_with_the_inbound_word` (line 1 is `selectors`-derived, line 2 is the endpoint; the served field table resolves both), `test_the_trunk_pipe_is_an_accessible_image_of_its_port`, `test_the_filter_matches_only_payload_text_and_costs_no_request` (no `fetchJSON` in the filter path, warnings panel untouched, `null` counts never match a query as text `"null"`).
- No new Python, so coverage cannot rise: the gate is that it does **not** fall (`--cov-fail-under=94` green) and that `node --check` passes on the changed script (`.agents/validate.sh` runs it).
- Integration: the page renders from a fixture-driven dev store in every degradation arm the payload can produce (`unreachable`, `supported: false`, per-section `available: false/null`), with the filter empty, matching, and matching nothing.
- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in the project, not just this phase's) and `bash .agents/validate.sh` is green, coverage floor included.
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it stands after this phase — fixing every inconsistency found, **including pre-existing ones not caused by this phase**. For this phase that specifically means: the `/gateway` UI-conventions paragraph in **both** files (the row now reads inbound word → counterpart; the connectors are existence-coloured; the trunk's line moves on the port's own count; the filter exists and what it may not do), the claim that the page's footer is three paragraphs (only if task 02 changes the count — it must not silently), and **gap 13** (the clipped-endpoint-label gap: task 02 shortens the primary line to the inbound word and task 04 makes long targets findable, so the gap is either closed with measurements or restated honestly with the new clip counts).
- Tasks update `README.md`/`AGENTS.md` immediately when their own changes are user-visible.
## Completion Criteria
- [ ] On live data at 1440 px: **every** drawn route — spine halves, tick, run, switch — computes to the `--gate-on` colour in dark *and* light theme; no grey dashed segment remains anywhere in the connection map; measured, not asserted in prose.
- [ ] The nginx `:443` fan-out reads as `brain.reeseapps.com` on line 1 and `→ reeseapps-brain-of-reese.reeselink.com:443` on line 2 for all of its leaves, and typing `stackexpected` into `#gate-filter` leaves only the five `stackexpected*` routes visible with the panel tallies unchanged.
- [ ] The trunk pipe moves on a port the kernel reports ≥ 1 ESTABLISHED socket for and sits still on one it reports `0` for; with the socket-table arm stubbed to `null` it draws green, motionless, and prints no `0`. `prefers-reduced-motion: reduce` stops every animated thing on the page including the trunk pipe and the switch thumb.
- [ ] `grep -n "border-strong" src/dashboards/web/static/dash.css` shows no gateway connector rule; `grep -n "severity\|--ok\|--warn\|--fail" ` over the gateway CSS section and `page-gateway.js` still finds only the warnings-panel `.warn` row class that `alerts.py` owns.
- [ ] No `addEventListener` for the switch exists; `routeInConfig()` still returns `true`; `git diff` for this phase touches **no** file under `src/dashboards/` except `web/` — the verb-enumeration pin still lists `/api/ups/command` as the only upstream write.
- [ ] `uv run pytest` (incl. `--cov` ≥ 94 floor), `uv run ruff check src tests agent`, `uv run pyright`, `node --check` on the changed script, `bash .agents/validate.sh` — all green.
- [ ] `data/logs/31-*.png` screenshots at 1440 px and 360 px (dark, light, filtered, and each degradation arm) and `data/logs/31-*.out` transcripts; `data/logs/27-task02-mobile-audit.cjs` still exits 0 across all nine pages with 0 FAIL / 0 FINDING.
- [ ] `GET /api/gateway/snapshot` on the live ingress unchanged byte-for-byte from before the phase (proof the phase was page-only), and `./scripts/dev-server.sh status` still reads all ten sources `ok`.
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass).
- [ ] No behaviour change in completed work: the other five dashboards and `/settings`/`/certs` render as before; the gateway charts, warnings panel, staleness dim and `404 {configured:false}` arm behave exactly as phase 29/30 left them.