# UI Screens Inventory

**Status:** v1.223
**Owner:** datapipelines.co core
**Depends on:** [Pipeline Editor](/docs/pipeline-editor.md), [Design System](/docs/pipeline-editor.md#34-design-system-acmedesign-tokens), [REST API](/docs/rest-api.md), [Auth & Security](/docs/auth.md), [Templates](/docs/templates.md), [Configuration Reference](/docs/configuration.md)
**Last updated:** 2026-11-02 (#784); 2026-10-27 (#725); 2026-10-26 (#726; #739 merge follow-up; #739); 2026-10-25 (#641; #714; #701; #670; #632; #632 merge follow-up); 2026-10-24 (#701; #668; #684); 2026-10-23 (#623 merge follow-up); 2026-10-22 (#681; #623; #652; #660; #644); 2026-10-21 (#642; #654; #591; #659; #589; #640); 2026-10-20 (#574; #595; #588; #541; #634; #635; #622 merge follow-up; #622; #599; #604; #639; #638; #610; #618; #627; #625; #598b); 2026-10-19 (#598a merge follow-up; #617; #609; #598a; #605; #619; #585; #584; #613; #509; #608; #614 + #611; #612; #615); 2026-10-18 (#583; #573; #607; #606; #565; #582; #581; #580; #579); 2026-10-17 (#556; #578; #554); 2026-10-16 (#551 merge follow-up; #551; #555; #552; #553; #544; #536; #535; #533; #537); 2026-10-15 (#433; #528; #471; #466); 2026-10-14 (#510; #514; #516; #513); 2026-10-14 (#524, #512 merge follow-up); 2026-10-13 (#512); 2026-10-11 (#494; #478); 2026-10-11 (#432); 2026-10-10 (#503); 2026-10-10 (#502); 2026-10-10 (#473 merge follow-up); 2026-10-09 (#491; #465; #412; #462; #460; #459; #416; #442a; #398; #392; #426; #420; #408; #422; #402; #401, #407; #399; #415; #383; #376; #400, #409; #374; #386, #387; #364; #350, #395; #371; #349; L4b, #353; 348-c, #358; L3b, #10)
---

## 1. Purpose

The pipeline editor is fully specified, but the app has many other screens. This spec inventories **every page in the application** — its URL, purpose, what it shows, what REST endpoints it calls, what design system primitives it uses, and whether it uses htmx or vanilla JS.

These are standard CRUD + list/detail screens. They don't need pipeline-editor-level detail (no Cytoscape, no SSE, no Alpine complexity). They use **Thymeleaf + htmx + design system primitives** — server renders HTML, htmx swaps partials for interactions.

---

## 2. Design Principles

1. **Server-rendered by default.** Thymeleaf renders full HTML pages. htmx handles partial updates (search, filter, pagination, form submission) without full page reload. Navigation is boosted (§3.2): a section click fetches the SAME full page and swaps only the main region — the MPA is the design, the document-per-click feel is not required by it.
2. **htmx for these screens, fetch for the pipeline editor.** htmx is the right tool for "server renders partial HTML, swap it in" patterns. The pipeline editor is the exception (graph + SSE requires vanilla JS).
3. **Design system everywhere.** Every screen uses `@acme/design-tokens` tokens and `.ds-*` primitives. No exceptions, no hardcoded colors. Where a class named below has no counterpart in the vendored `primitives.css` (`.ds-spinner`, `.ds-toast*`, `.ds-empty-state` and `.ds-avatar` are the candidates — the roster is ~80 classes and is not enumerated in these docs), the app defines it in `app.css` **derived from design tokens**, never from literal values ([Pipeline Editor §3.4](/docs/pipeline-editor.md#34-design-system-acmedesign-tokens)). Confirm against the vendored file at implementation time before adding an app-level class.
4. **Two layouts, and only two (090 §C, normative).** App screens share the shell — primary menu, resource browser, top bar, boosted `<main id="app-main">` — from `layouts/default.html`. **Ceremony screens** — the login page and the forced-password-change gate — use `layouts/auth.html`: a header with the brand mark and Documentation link, a content main and a footer, with the resolved theme, font preloads, progress bar, htmx and the toast stack. The login main holds the two-column story/preview and sign-in composition (#619); the forced-password-change gate keeps its centred card. No menu, no resource browser, no top bar, no workspace switcher, no `#app-main`, no `hx-boost` anywhere in the layout. A ceremony screen exists to move a visitor across the session boundary; until that move completes there is nothing to navigate to, so a shell around it is a menu of dead ends — and, since `#app-main` is the boosted swap target, a shell around it is also a screen that can be swapped into an app page. `AuthLayoutRenderTest` pins both layouts, including that the login page renders no shell even when handed an authenticated model.
   *Before 090 the login page decorated with `layouts/default`, which renders the shell whenever `authenticated` is true, so a signed-in visitor who opened `/login` in a second tab got the sign-in form inside a working app (owner's walk, 2026-09-07). Both halves are closed: the layout above, and the redirect in §4.1.*
5. **Three URL spaces, never mixed.** Pages, HTML fragments, and JSON live under distinct prefixes — see §2.1.
6. **The server holds no UI state.** The app is stateless behind a load balancer with no sticky sessions ([Deployment](/docs/deployment.md)); every user preference that must survive a request lives on the `users` row, never in an `HttpSession`.
7. **Scopes are not asserted here.** The per-screen scope column in §4 is a convenience view of the authoritative matrix in [Auth §7.6](/docs/auth.md#76-operation-matrix--the-permission-catalog-authoritative).
8. **Structure must survive a bad monitor — non-text contrast floors (WCAG 1.4.11).** Boundaries a user needs to identify a component are NOT text and have their own floors, enforced by the design-system audit (its `npm run build` runs it first; the vendored copy is guarded here by `VendoredNonTextContrastTest` in `modules/web`): `border-default`/`border-hover`/`border-focus` at **3:1** on the surface they are drawn on (inputs, panes, cards), `border-subtle` at **2:1** (separators — 3:1 makes every table heavy), `surface-selected` at **1.5:1** with a left accent bar of `border-focus` width `3px` (a tint alone cannot reach 3:1 without turning grey). A pane or card boundary is a `border-default` line, never a tint alone. Text keeps its 4.5:1 floors, unchanged. #606 distinguishes those boundaries from required separators (`--border-subtle`, 2:1) and nonessential decoration (`--app-line-decorative`, derived from the separator and panel surface); nothing required may rely on the decorative role. Where the approved prototype draws no line, the app draws none.
9. **Every table is the data table (282, §3.7).** One house component — `.dt-frame` › `.dt-viewport` › `table.ds-table`, one rule set (`data-table.css`) and one enhancer (`data-table.js`) — draws every table in the app: the header stays while the rows scroll, the scrollbar starts under the header (the cap), columns sort and resize, a wide table's first column can hold, and loading and empty are rows of the same table. No screen styles a table of its own; a new table is framed or `DataTableCssTokenTest` names it. A grid library was assessed and set aside (#282: AG Grid's theming injects styles at runtime, and the CSP is `style-src 'self'` with no nonce).

### 2.1 Route Convention

Three disjoint URL spaces, plus one shape that lives on the first of them. A given URL belongs to exactly one space, and the response media type follows from the space — not from the caller.

| Space | Prefix | Returns | Called by |
|---|---|---|---|
| **UI pages** | root paths — `/`, `/pipelines`, `/pipelines/{id}`, `/executions/{id}`, `/settings/api-keys`, `/admin/users` | full HTML document (Thymeleaf layout + content) | browser navigation |
| **Page-route mutation (PRG)** | a POST on a root path — `/workspaces/create`, `/workspaces/{name}/join\|members\|members/{id}/remove\|delete`, `/workspace/switch`, `/promotion/promote`, `/login` | a **redirect** back to the page it came from (Spring's `redirect:` view — 302), carrying `?ok=`/`?error=` | a plain `<form method="post">` |
| **htmx partials** | `/partials/**` | HTML **fragment** (no layout, no envelope) | htmx `hx-get`/`hx-post`/`hx-patch`/`hx-delete` |
| **JSON API** | `/api/v1/**` | JSON [response envelope](/docs/rest-api.md#4-response-envelopes) | agents, MCP, programmatic clients, the pipeline editor's `fetch` calls |

**Page-route mutations are the exception, and the list above is closed.** A mutation may live on a page route for exactly two reasons: its success changes the **SHELL** — the workspace switch re-mints the session cookie, a new or deleted workspace changes the switcher, a promotion changes the deployment the whole screen describes — or it must work **without JS**, which is what makes workspace administration and sign-in usable when the app is misbehaving. Everything else mutates at `/partials` and reports through a §5.1 toast.

Its response contract, so the idiom cannot drift into a third one:

- **A redirect back to the page**, never a rendered body. Post/redirect/get is what makes a reload harmless, and it is what lets the whole shell re-render around a changed identity.
- **`?ok=` / `?error=` KEYS, never free text**: a short token (`?ok=created`, `?error=user_not_found`) that the layout maps to copy. A message passed through the query string is untranslatable, unstylable, and forgeable by anyone who can hand a user a link.
- **The outcome is still a §5.1 toast** — this space has no second error idiom. The layout renders the key into the hidden `#toast-flash` bin inside `#app-main` (076 §B) and `toast.js` adopts it into the persistent `#toast` stack, so a redirect flash and an htmx refusal reach the user as the same object. The key→copy table lives in `layouts/default.html` beside the bin: an unmapped key renders nothing, which is the safe direction.
- Every one of these handlers is **session-only** where it changes privilege or identity, and each is listed with its reason in `MutatingHandlerScopeFloorTest.PAGE_ROUTE_MUTATIONS`. A new one fails that test until the reason is written down.

**htmx never calls `/api/v1`.** A JSON envelope is not a swappable fragment; pointing `hx-*` at the REST API would require client-side rendering, which principle 1 rules out. Every htmx interaction in §4 targets `/partials/**`.

**Partials are a presentation layer, not a second implementation.** A `/partials/**` controller calls the *same* application service as its REST counterpart and renders the result into a Thymeleaf fragment. `POST /partials/api-keys` and `POST /api/v1/auth/api-keys` ([REST §16.1](/docs/rest-api.md#161-api-keys-creation-is-on-the-keys-page--keys-v2)) differ only in how the response is serialized — same service, same validation, same refusal.

**Auth on partials.** `/partials/**` is authenticated by the `dp_session` JWT cookie (never by `DP-API-Key` — API keys are for agents). State-changing partial requests (`POST`/`PATCH`/`DELETE`) are CSRF-protected: the frontend sends the `dp_csrf` cookie value in the `DP-CSRF-Token` header ([Auth §8.4](/docs/auth.md#84-api-endpoints-auth-via-api-key-or-jwt)). This is wired once in the layout (§3), not per-screen.

**The one carve-out — file downloads.** Result downloads (§4.9) are plain `<a href>` full-page navigations to the REST cursor endpoint, because the response is a file (CSV/Arrow/JSON), not a fragment. That is a browser navigation, not an htmx swap, so it does not violate the rule above; it authenticates with the same `dp_session` cookie.

---

## 3. Common Layout

All authenticated pages use a shared layout:

```
┌──────────────┬──────────────────────┬──────────────────────────────────────┐
│ ▣ datapipe…  │ Pipelines  9      ⟨⟨ │ Build / Pipelines  📁 [⌘K]  ☀  (MS)  │ --app-header-h (58px)
│ ┌──────────┐ ├──────────────────────┼──────────────────────────────────────┤
│ │TS workspc│ │ [search] ✕ ↻         │                                      │
│ └──────────┘ │ ▸ acme               │                                      │
│  Home        │   ▸ mobility         │            Page content              │
│  BUILD       │     daily_by_zone    │      (<main id="app-main">, the      │
│  Pipelines   │ ▸ finance            │       one scroll container)          │
│  Templates   │                      │                                      │
│  Datasources │                      │                                      │
│  EXPLORE     │                      │                                      │
│  Dashboards  │                      │                                      │
│  Visualiz…   │                      │                                      │
│  Param. Sets │                      │                                      │
│  OPERATE  …  │                      │                                      │
│  ORGANISAT…  │ View all pipelines ⟲ │                                      │
│ ◀ Collapse   │                      ┆ ← #browser-boundary (resize)         │
└──────────────┴──────────────────────┴──────────────────────────────────────┘
 menu: 202px       browser: 340 / 320 / 270 by band,   main: minmax(0, 1fr),
 (64px compact)    280–480 by the user; hidden off a    never under 320px
                   family page (its width goes to main)
```

Thymeleaf layout: `layouts/default.html` — includes navbar, design system CSS, htmx, theme switcher.

The layout also wires these once, for every shell page (`ShellPage`, #652): the four `@ControllerAdvice`s below select on that marker, so a JSON (`@RestController`) handler never pays the shell's reads (§3.4).

- **Theme resolution.** The active design-system theme is `users.theme_preference` when set, otherwise the deployment default `datapipelines.ui.theme` ([Configuration §3.10](/docs/configuration.md#310-ui)). `UiWorkspaceAdvice` resolves it per request into `${activeTheme}` for every shell screen (#652; a controller that forgets it renders a `themes/null.css` URL that 404s, leaving every design token unresolved — no borders, no surfaces); individual controllers may still set it explicitly, which simply overrides the advice's value with the same one. Emitted as the `href` of the `#theme-link` stylesheet element ([Pipeline Editor §3.4](/docs/pipeline-editor.md#34-design-system-acmedesign-tokens)) — see §4.11. The deployment setting is the default, not a ceiling: a user preference overrides it for that user only, and an unset preference is indistinguishable from today's config-only behaviour.
- **Nav chrome (027 UI pass).** The navbar is a sticky, full-width bar whose links and Logout render only for authenticated requests (`UiWorkspaceAdvice.authenticated` — anonymous screens like Login see the brand only). The active section is highlighted from `UiWorkspaceAdvice.currentPath` on first paint, and mirrored client-side by `shell.js` after boosted swaps (§3.2). Nav and content share the `.app-container` shell (app.css) — full-bleed with one gutter since 076 (§3.1). App-level chrome and table polish live in `static/css/app.css` — the vendored design system files are synced from design-system-starter and are never edited in this repo.
- **CSRF for htmx.** `hx-headers` on `<body>` carries the `dp_csrf` cookie value as `DP-CSRF-Token`; because `hx-headers` is inherited, every descendant htmx request is covered ([Auth §8.4](/docs/auth.md#84-api-endpoints-auth-via-api-key-or-jwt)).
- **Workspace switcher + context (workspaces design §9).** The navbar carries a `<select>` of the principal's memberships (`UiWorkspaceAdvice` fills it for every shell screen, #652); choosing one POSTs `/workspace/switch`, which re-stamps the session JWT's `active_workspace` claim and re-issues `dp_session`, so full-page navigations follow the switch. The layout's `hx-headers` ALSO carries `DP-Workspace: <active>` for every htmx partial call — both mechanisms agree because the switcher drives both. A principal with zero memberships sees no switcher and empty states, never an error page (workspaces design §7).
- **Toast region.** A persistent `<div id="toast" aria-live="polite"></div>` OUTSIDE the swapped region, plus `static/js/toast.js`, which together implement §5.1 Notifications — including `bridgeErrors`, the small `htmx:beforeSwap` listener that admits a 4xx/5xx to the swap only when the server retargeted it at `#toast` by header. No htmx extension is loaded; this replaces the `response-targets` prescription this spec once carried. Server-rendered redirect flashes (`?ok=`/`?error=`) render into a hidden `#toast-flash` bin INSIDE `#app-main` so a boosted response carries them; `toast.js` adopts them into the persistent stack on init and after every settle.

```html
<head>
    <!-- …design system load order per Pipeline Editor §3.4… -->
    <link rel="stylesheet" id="theme-link"
          th:href="@{'/vendor/design-system/themes/' + ${activeTheme} + '.css'}">
</head>
<body th:attr="hx-headers=|{&quot;DP-CSRF-Token&quot;: &quot;${csrfToken}&quot;}|">
    <div id="toast" aria-live="polite"></div>
    <!-- … -->
</body>
```

htmx is **vendored** (webjar) like the rest of the frontend stack — no CDN references, and no htmx extensions ([Pipeline Editor §4.2](/docs/pipeline-editor.md)).

### 3.0 Every app stylesheet loads from the layout head (090 §A/§B, normative)

**No page template may carry its own `<link rel="stylesheet">`.** The `content` fragment renders *inside*
`#app-main`, which is the boosted-swap target (§3.2): htmx replaces that region's markup and the browser only
then discovers the link. An inserted stylesheet does not block the already-painted document, so the screen
paints at least one frame with none of its own rules — cached sheet or not.

Measured on the explorers (090, chromium, seeded tree, geometry read at the first animation frame after
`htmx:afterSwap`, `template-tree.css` still page-scoped):

| viewport | rail | first frame after the swap | settled |
|---|---|---|---|
| 1920 | expanded | tree 1640px, detail's left edge EQUAL to the tree's — the panes stacked | tree 480px, detail at tree.right + 24 |
| 2560 | expanded | tree 2280px, stacked | tree 480px, gapped |
| 2560 | collapsed | tree 2452px, stacked | tree 480px, gapped |

That unstyled frame is both of the round's reports at once — "css is applied after data load", and a detail
pane sitting hard against the rail. `template-tree.css` moved to `layouts/default.html`'s `<head>` and the two
list templates' own links are gone; `ExplorerPaneGeometryBrowserTest` asserts the pane contract at the FIRST
FRAME, not only on the settled page, at 1440/1920/2560 × rail expanded/collapsed × both explorers.

**Cumulative layout shift is not the instrument for this.** `PerformanceObserver('layout-shift')` scored the
broken navigation at 0.0000: the panes were *inserted* in the wrong geometry rather than moved out of a right
one, and an insertion is not a shift. The CLS budget (< 0.05 across a boosted navigation) is still asserted —
it is the right instrument for the font rule in §3.3 — but first-frame geometry is what pins this.

`pipeline-editor.css` and `docs.css` are still page-scoped and carry the same defect (`template-editor.css` was
hoisted by #398, with the template workspace's own sheet).
The fix is identical (hoist the link) and belongs to whichever round owns those surfaces.

### 3.1 Shell and width policy (076, normative)

Measured on the owner's ~3,000px window (2026-09-05): four content widths across eleven screens — a 1600px cap on most, bespoke narrower columns on Settings, full-bleed on Templates and the editor. The policy that replaces them:

so the frame binds only on wider windows and on non-family pages, where the browser column is gone. Record-only (ruling 2, owner rulings 2026-10-19): on a window wider than that (≈1702px with the menu expanded and the browser hidden) a canvas page's heading, which keeps the whole track, and a framed page's heading, centred in the 1500px frame, no longer share a left edge — by design (record §4: canvases are not centred inside an artificial frame); the tests measure 1512, where the two still match. The one opt-out is the class **`app-canvas`** on the page's root (a direct child of `<main>`; `.app-main:has(> .app-canvas)` lifts the cap), because "operational canvases, authored dashboards and result tables use the available width when their existing layout requires it; do not crop or center them inside an artificial 1500px canvas" (record §4): the pipeline editor (`.pe-root`), the template workspace (`.tw-root`), the schedules explorer (`.tplx-page`), the dashboard board (`.dp-workspace`) and the execution result page. A table on a catalog, an overview, a settings, admin or keys page does not make it a canvas; those pages stay framed. `.app-main-bleed` and the editor's `fullBleed` opt-in stay deleted, and `--app-content-max` no longer caps `<main>` (the name returned in #502 as the editor's reading-pane measure, `.pe-doc`). `EditorLayoutRenderTest` pins the editor and a list page rendering the SAME `<main>`; `MainFrameBrowserTest` measures the bands, the 946px reference, the binding frame at 2200px and the opt-outs.
2. **Reading content gets a reading column, not a different container.** Docs prose, empty states and forms sit in `.app-reading` (`max-width: 90ch`, LEFT-aligned inside the full-bleed main — never centred). Tables and trees never use it. **Amended 079 §D:** Settings, Admin and Workspaces do NOT — a 90ch measure is right for prose and wrong for a grid of independent cards, which is what left three quarters of a wide window empty; Settings is a two-column card grid at ≥1100px and one column below (owner ruling 2026-09-05, option (a)). `.app-reading` stays on Docs, where the content really is prose.
3. **Width lives in classes, never inline.** No app template carries an inline `max-width` or `grid-template-columns` — modals, search inputs, auth cards and the stats grid use the `app.css` classes (`app-modal*`, `app-search-input`, `app-card-auth`, `app-stats-grid`, …). `InlineWidthAuditTest` scans the templates and fails the build on a regression. And a class named must exist: `UtilityClassExistenceAuditTest` sweeps every app template's `class`/`th:class`/`th:classappend` value and every first-party script's string literal for `u-*` names no served stylesheet defines, and fails the build naming the file and line (#608).

### 3.2 Boosted navigation (the app shell, 076)

`hx-boost="true"` on the `<nav>` and on `<main id="app-main">`. A section click — or any in-content navigation — fetches the SAME full page (there is no second template variant) and swaps only the main region; the nav, the workspace switcher and the toast stack persist, the URL pushes, back/forward ride htmx history. `#app-main` also carries **`hx-history-elt` (#358)**: the history cache snapshots THE MAIN REGION, not the `<body>` — footer scripts are never cached and never re-executed on a Back.

- **The history cache is region-scoped, and its entries are shape-guarded (#358).** Without `hx-history-elt` htmx snapshots the whole body — footer scripts included — and a cached restore re-created and re-executed every one of them (a second Alpine, a second observer, stacked bindings). The shell owns two defences, one rule (a body-shaped entry serialises the `<main id="app-main">` OPENING TAG as a CHILD; a main-shaped one cannot contain it — and the guard matches that serialised start tag, `shell.js` `historyEntryIsBodyShaped`, never the bare attribute literal `id="app-main"`, which user text may carry (#364)): at init a one-time purge drops every body-shaped entry from `sessionStorage`, and a body-shaped entry that is still HIT at restore time is dropped and the restore takes a full fetch — htmx's own `refreshOnHistoryMiss` policy (a cancelled `htmx:historyCacheHit` in htmx 2.0.10 would simply die, never falling through to the server fetch). Pages whose scripts want transient state (sizes, placements) keep the 287 registry discipline below. **The workspaces' own entries sit beside it (#402):** an in-page version or tab switch (the pipeline workspace, §4.4; the dashboards, visualizations and templates workspaces) pushes an entry whose state is `{dpWorkspace: {family, version, tab}}` — never `htmx: true`, so htmx's popstate ignores it and `static/js/workspace/history.js`'s ONE window listener replays it in page; such an entry never enters the cache, and the helper keeps htmx's own current-path record (`htmx-current-path-for-history`, an htmx 2.0.10 internal pinned by `workspace-history.test.mjs`) on the live URL, so a boosted leave after a switch is snapshotted under the URL the reader returns to (a cache HIT). An entry of ours that pops while another screen is showing is handed to htmx's own restore.
- **A late history miss never lands (#660).** A catalog restore is a miss (#591); a miss still in flight when the reader navigates again — a cache hit, another miss, a boosted request or a foreign popstate — is aborted by `shell.js`, so the screen always matches the address bar.
- **The swap policy lives in `static/js/shell.js`, not in attributes.** `hx-target`/`hx-select`/`hx-swap` are deliberately NOT set on `<main>`: htmx inherits them into every child request, which would retarget the screens' partial swaps (search results, tree levels, dashboard stats) at `#app-main`. Instead `shell.js` listens for `htmx:beforeSwap` and, only for requests htmx flags as boosted, retargets at `#app-main` with `select="#app-main"` and `outerHTML show:window:top`.
- **Full navigations remain** — marked `hx-boost="false"` and pinned by `ShellRenderTest`: `/logout`, the OIDC redirects and file downloads (external links too — htmx 2 rejects cross-origin requests). **Amended 090 §C:** `/login` and the forced-change gate no longer opt OUT of boosting; they render `layouts/auth`, which has no `hx-boost` and no `#app-main` at all, so there is no boosting to opt out of. Removing the machinery beats marking each link.
- **The progress signal.** A document load used to say "loading" with a white flash; a swap must not flash, so one 2px bar under the nav (`#app-progress`, tokens only, reduced-motion respected) does the talking. Since 085 §D it shows for EVERY htmx request, boosted or partial, counted in flight between `htmx:beforeRequest` and `htmx:afterRequest` — the full loading-state contract (busy control, delayed skeleton) is §5.1's.
- **Active-section state.** Server-computed from `currentPath` for the first paint; after swaps `shell.js` mirrors the segment-boundary rule (§3.4) off `data-nav-section` + `window.location.pathname`.
- **Scripts re-arm per swap — or never re-execute at all.** Page scripts whose tags ride inside `#app-main` re-execute on arrival; anything document-level installs ONCE per session. **Since #494 every family page's scripts load through ONE page-script loader, `static/js/page-assets.mjs`** (imported by `persistent-navigation.mjs`): a page declares them in inert `<template data-page-assets>` catalogs inside `#app-main`; the loader admits only `/js` and `/vendor` paths (`assetPath`), executes each entry in order and once per document whichever page asks first (a source whose global already exists — a pre-booted Alpine — never executes again), and calls each `data-page-mount` entry's mount once per catalog node, after the bare tags a swap or restore re-created in `#app-main` have executed (a full load's order): on arrival, after a boosted swap and on every cached restore, where the snapshot itself replays nothing. A plain catalog (libraries, mount glue, the lifecycle dialogs) executes while a navigation prepares; an `on-mount` catalog (`data-page-assets="on-mount"`: the pipeline editor's `pe-runtime-scripts`, the parameter workspace's `ps-runtime-scripts`) is only fetched then and executes once its page is in place. Since #514 the template workspace's seven scripts share one ordered `on-mount` catalog, with `workspace.js` last as `TemplateWorkspaceMount` to wire each arrival and cached restore; the only bare tag still inside `#app-main` is the editor's parser-blocking `splitter.js`, which sizes the dock before first paint and deliberately re-executes per arrival. The pipeline editor is the deep case since #358: its `.pe-root` is served — and cached — with `x-ignore`, and its catalog's last entry, `runtime.js`, is the mount whose single `mutateDom` activation (context read, then destroy-before-bind) is the ONE initializer the restored root can get. The editor tears down on host-replacing swaps — the execution stream is DETACHED, never cancelled (`dispose()`: the run continues server-side, visible on `/executions`), the Cytoscape instance is destroyed, timers and document listeners come off — and a run that outlives the navigation is re-attached on the restored page through the execution replay stream, its terminal event arriving exactly once. The shell's own pending/busy chrome comes off before every snapshot (`snapshotClean`) and is purged from restored pages (`htmx:historyRestore`), so a restored link never looks clicked or answers `aria-disabled`. `toast.js` and `template-explorer.js` follow the same idempotent-init contract; `editorJsTest` pins them. **Since #350 `template-explorer.js` is loaded ONCE by the layout's footer** (the resource browser's trees need it on every page) alongside the reusable REST tree host; an explorer page's own tag still rides inside `#app-main`, and its re-execution on a boosted visit is a guarded RE-INIT of the one module — never a second closure (the old per-closure `docWired` flag stacked a document `toggle`/`htmx:afterSwap` pair per visit; `template-explorer.test.mjs` re-requires the file to pin it, `PipelineSidebarTreeStateBrowserTest` counts the listeners per source file across boosted and history navigation).

### 3.4 The shell chrome (079, normative)

The 076 horizontal navbar became a left rail plus a top bar
(`design-records/mocks/2026-09-05-app-shell-v2.html`, owner approved 2026-09-05). The boosted
-navigation contract of §3.2 is unchanged — same `nav.app-nav`, same `hx-boost`, same swap
target, same `data-nav-section` mirror. Only where the links sit changed, and what the shell
must therefore keep true after a swap grew. **Since #554 the shell is three sibling columns**
(design record 2026-10-16 §3–§4, owner approved; umbrella #547): the primary menu
(`#app-rail`), the resource browser (`#app-browser`) and `<main id="app-main">`. The browser is
never a row inside the menu, and both live outside the boosted swap.

**Path ownership (#659).** A section or family owns `/<section>` and `/<section>/…`, the same under `/overview/`; nothing else. `AppNav.owns` defines the segment boundary for the server, including off-rail breadcrumbs; the layout computes the active section once through `AppNav.activeFor` and compares by equality. `shell.js`, `sidebar-state.mjs` and `persistent-navigation.mjs` mirror that boundary after navigation. `/pipelinesx` activates no item, names no crumb and opens no browser; `/dashboards` belongs only to Dashboards. Guards: `AppNavTest`, `ShellRenderTest`, `shell.test.mjs` and `sidebar-state.test.mjs`.

**The shell model fills for every shell page (`ShellPage`, #652).** The four `@ControllerAdvice`s — `UiWorkspaceAdvice` (theme, switcher, `currentPath`), `AppShellAdvice` (the avatar, the breadcrumb, the browser header's counts), `ReleaseFlashAdvice` (the `?ok=` flash bin, §4.13) and `SiteOriginAdvice` — select on `assignableTypes = [ShellPage]`, the marker every shell-page controller implements; a `@RestController` never does, so its handlers pay none of the shell's reads. That reverses the regression #652 fixed: the old `annotations = [Controller]` selector matched REST through its meta-annotation, so every JSON request paid two `users` reads and a `workspaces` list it never rendered. The named exceptions: `SkillController`, `SitemapController` and `LlmsTxtController` serve text (every handler `@ResponseBody`), and `VisualizationPreviewController` is the session-less, capability-authenticated preview page that supplies its own `activeTheme`. Selection is per bean: the `@ResponseBody` methods on `DocsController` (`/docs/{slug}.md`), `ApiKeysPartialController` (`/partials/mcp-key/secret`) and `TemplateEditorController` (`POST /partials/templates/editor/edit`) still run the advices, including one-shot flash consumption when the release code is supplied. Guards: `ShellPageGuardTest` (the partition over the module's sources) and `ShellAdviceSelectionTest` (the selector, as Spring resolves it — zero shell reads for REST, exactly one round for a page).

**The shell is the viewport (2026-09-11, owner).** `.app-shell` is exactly `100dvh` tall and
`<main>` is the one scroll container under the bar; the document never scrolls. A screen built
from panes (the explorers, the editors) never drags the rail and the bar when one pane runs a
few pixels long, and a long single-column screen (`/workspaces`, `/docs`, `/settings`) scrolls
inside `<main>` with the chrome fixed. The pane arithmetic (`calc(100dvh - var(--header-height)
- …)`) is unchanged. `AppShellBrowserTest` pins it: no app screen makes the document taller
than the viewport at 1440×900 or 1920×1080, and `/docs` proves the model by overflowing
`<main>`. **The deployment default theme is `dark`** (`datapipelines.ui.theme`, matching the
marketing site); each user can choose light/system/a palette in Settings.

**The primary menu** (`#app-rail`, `--app-menu-width`: 202px expanded, 64px compact). Brand, then the workspace switcher as
a card (keeping its POST form and its `<select>`, which is present and operable and simply
styled transparent over the card), then the destinations, then the collapse control at the foot. The
groups and their order are the record's: Home alone above them (D58, #10 L3b — the landing item
`Dashboard` was RENAMED Home; the route `/dashboard` is unchanged), then
**Build** (Pipelines, Templates, Datasources), **Explore** (Dashboards, Visualizations, Parameter Sets —
`AppNav.EXPLORE`, #554), **Operate** and **Organisation**. A group whose every item the reader's role
removed renders no link and vanishes (`.app-nav-group:not(:has(.app-nav-link))`). The nav packs to the
top; the free space below it is deliberate. The menu holds no tree: expanding a folder at any depth moves
no menu item and changes no menu width (`ShellColumnsBrowserTest`, the record's first acceptance). The
compact menu keeps its group spacing and drops the uppercase group labels; each item's label is visually
hidden (still its accessible name) and ONE `#app-nav-tip` tooltip, shown by `shell.js` beside the hovered
or keyboard-focused item, names it for sighted readers.

**Type and spacing (#605):** primary navigation uses `--app-text-ui` (13px), `--app-leading-ui` (1.5), weight 400, minimum 36px rows (`--app-control-h`), 8px/12px padding, a 12px icon gap and 8px radius, with 12px menu gutters. Group captions use `--app-text-meta` (10px), uppercase `--app-tracking-caption` (1.2px) and `--app-weight-emphasis` (650), starting after 24px. The wordmark uses the 14px heading size and the same 650 weight; retained `--tracking-tight` computes to −0.35px (the record rounds to −0.4px). The selected label is `--text-primary`, its icon `--text-link`, with the selected tint and `--border-focus` bar; Minimal uses `--text-inverted` for both inks because its normal inks and selected surface are black. The dedicated `--app-nav-indicator-w` enforces the normative 3px bar (the old focus-offset token rendered 2px); label x is 55px = 12px gutter + 3px bar + 12px padding + 16px icon + 12px gap. The workspace switcher uses 12px padding/gap, a 32px avatar and 8px radius; the redundant “workspace” caption is removed, and the operable select retains its accessible name and role badge. The collapse control uses the row metrics; top-bar controls use UI text and contextual/secondary text uses Support. `MenuScaleBrowserTest` measures expanded 1512×982, compact and 390px drawer layouts and checks selected-label/icon contrast in every bundled theme.

**The resource browser (#554; formerly the in-rail accordion of #460/#478).** Pipelines, Templates,
Dashboards, Visualizations and Parameter Sets share the reusable REST search-and-tree, hosted in their
own column: a 58px header (`--app-header-h`) with the family title, the admitted count and **Hide**; the
tree's search/clear/refresh toolbar; the tree; a footer with **View all <family>** (the catalog) and
**Reset resource browser width**. The five panels keep their `#nav-tree-<family>` ids and
`data-nav-tree="<family>"`; one is visible. The column is shown on a family page only — the active family
is derived from the path (`familyForPath`; a family's overview `/overview/<family>` is that family's, #556), never stored — and `hidden` elsewhere, its width returned to
main. The Datasources overview is the deliberate non-family (#583): no `browserFamily` covers it, so the column stays hidden there and the rail's Datasources item lights through the segment-boundary rule (§3.4; §4.5d). Each family's tree mounts lazily the first time it is shown and stays mounted for the life of the
document, so switching family and returning finds its query, open folders and scroll as they were, with no
refetch. Initialization requests only the root and leaves all folders closed. A catalog's **Browse folders**
(`data-nav-tree-reveal`) shows the browser and puts focus in its search; the top bar's chevron-right
(`#browser-open`, named "Browse <Family>") at the browser's edge reopens a hidden browser on desktop.
On a phone the same button uses a folder glyph to open the Browse resources drawer. Clicking a family
menu destination on desktop, including the active family, shows a hidden browser and clears the saved Hide without
moving focus; a non-family destination preserves the preference. **Hide** and the menu's collapse are
independent controls: icon mode never closes the browser and Hide never changes the page.
Expanding a folder automatically pages its immediate children, including folders and
leaves beyond 200, without fetching grandchildren, up to the **level ceiling**: ten 200-row pages per load
(`PAGE_CEILING` in `tree/state.mjs`, #465). A level stopped at the ceiling states how many rows it holds and
offers **Load more**, which continues from the kept cursor with a fresh ten-page budget; a page that lands
re-renders only its own level, never the whole panel. Complete valid levels remain in memory and keep keyed
DOM rows, focus and scroll during navigation.

The eight selection rules of the record (§3), as built: (1) the family link leads to the family's
**overview** (§4.25) — as built, all six do: the five tree families since #556/#579–#582 and the Datasources
item since #583 (`/overview/datasources`) — and View all stays
the explicit catalog action;
a desktop family menu click also shows a hidden browser and clears its saved Hide;
(2) a leaf opens its existing workspace URL and is marked current, the family's browser state kept;
(3) a folder toggles only itself; (4) a deep link selects its family, with no recursive fetch to reveal the
leaf — it is marked current when present; (5) a non-family page hides the browser and returning restores
the in-document state; (6) back/forward restores page, family and current leaf through the existing history
integration (`historyRestore` re-syncs the column); (7) collapse and Hide are independent, and the
desktop chevron-right / phone folder opener carries the family's name; (8) a workspace or account switch
is a fresh document, so no prior
workspace's rows, count or selection can show. All, Recent and Pinned (§7) are not built: the browser
shows All and the search (Recent/Pinned are #557's ruling, built after this).

**Refresh (#465).** The named Refresh button, a return to the tab (`visibilitychange`, every mounted tree)
and an affected-parent invalidation after a change all refresh the same way: the level is refetched into a
hidden staging level and swapped in when that completes (or stops at the ceiling), so its rows, focus and
scroll stay while the request runs and no Loading row appears. Every visible open level below then reloads
the same way, after its parent; a closed level held in memory is emptied so its next expand fetches it
fresh; a folder the refetch no longer lists leaves the open set with its cached levels. A failed refresh
keeps the rows it had and offers Retry. In search mode the result set refreshes in the same staged way.

Name search uses the family's `/api/v1/{family}/tree/search`, literal case-insensitive canonical/display
name matching, and all matching artifacts plus their ancestors. Every returned path opens automatically;
search never crawls browse endpoints. The named lucide **Clear search** button and deleting the query
cancel pending search and browse work, collapse every folder and show only the normal top level. A late
reply cannot undo this reset. Partial results remain visible with an incomplete state and Retry; an empty
complete answer differs from an error. Each box takes its `maxLength` from the layout's
`data-dp-search-max-length` attribute — the server's one search bound rendered once, so no script retypes
it — and a too-long refusal that still reaches the page (an older cached copy, a pasted value a script set)
names the limit in the status row instead of the generic failed state. See [REST §24](/docs/rest-api.md#24-first-party-navigation-trees).

The widget's source, root, context, href and activation callback are host parameters. Selection-only hosts
use buttons. The common renderer owns roving focus, arrow navigation, Home/End and activation; focus is
separate from current-page selection, and the focus ring answers keyboard focus alone, drawn on the
focused row's line and never around the subtree its item contains (#516) — one ring: the vendored global
`:focus-visible` box-shadow is reset on tree items (#644). A mouse click leaves the selected-row tint.
Labels use text sinks and fixed vendored lucide icons.

**The row (#604, re-cut by #701 — owner ruling 2026-10-24).** The browser column sits on the record's browser surface: `--surface-overlay`, one step
above the page. A row is a card there and ONE line — the activate (`.dp-tree-activate`) paints 12px vertical padding
(`--space-3`) and 8px horizontal padding (`--space-2`), the 8px radius (`--radius-md`) and the tints: hover `--surface-hover`, selection
`--surface-selected` with the title in primary ink (`--text-primary`) and the metadata in secondary
(`--text-secondary`) — the record's primary-ink selection, not the link blue the other trees keep. The
name is its own region, `span.dp-tree-title` at the **resource name** role (`--app-text-name`, 12px on
1.55 leading), on a single line that truncates with an ellipsis when the row leaves it too little width;
the control's `title` attribute carries the full canonical name, and the label's carries it too. The
badges — version, lifecycle state, needs-review, and the optional pin indication — live in a trailing
`span.dp-tree-meta` on the SAME line, after the title, `flex: none`: the label yields width to them,
never the reverse, so every indicator stays inside the rail at any browser width and the panel never
scrolls sideways for a name (#541). The browser's rows are COMPACT at every width: the indent per
level is 12px (`--space-3`) since #784, guide lines unchanged (#739's owner ruling 2026-10-26 set
8px, `--space-2`). The same day's
approved spacing reduction keeps that indent and reduces horizontal row insets to 8px (`--space-2`),
row gaps to 4px (`--space-1`), and badge gaps and side padding to 2px (`--space-0-5`). Vertical row
and badge padding, type sizes and the 4px row rhythm stay. Earlier spacing put a depth-ten
three-badge row 26px outside the 240px rail after its name had already shrunk to 0px; these
horizontal savings preserve the badges and the hierarchy. #701's withdrawn width threshold hid a
name on widening (131px at 381px compact, 0px at 382px full), so there is none — a wider browser
only gives a name more room. #739 measured a deep three-badge leaf with no name at all at the
then-240px minimum; at #784's 280px floor a sliver of the name remains — the tooltip still carries
it — and a badge may use the trailing row/scroll inset, still inside
the rail. When no badge is visible the region stays in the DOM and its box does not — no dead
gap after the title. The glyph occupies the former chevron slot: a folder shows `folder` closed
and `folder-open` expanded, following `aria-expanded`; leaves carry the family's menu glyph:
Pipelines `workflow`, Templates `code`, Dashboards `boxes`, Visualizations `chart-line`, Parameter
Sets `sliders-horizontal`. Both are vertically centred and neither rotates. A nested row draws a
1px `--border-subtle` vertical from its parent's glyph centre to the LAST child's own row centre,
plus a horizontal tick into each child's glyph (6px since #784's 12px indent), continuous across the 4px
row rhythm. An expanded last child's subtree never extends that parent's guide; a single child's
guide spans parent centre to child centre. Root rows and all search results have no guides.
Hover/selection tints paint over the guide inside the control, as in the 085 §A exemplar.
The browser consumes its own `--dp-tree-indent`, `--dp-tree-inset`, `--dp-tree-row-h` and `--dp-tree-guide-x` geometry;
the explorer's `--tpl-*` properties stay separate. The row's DOM contract
is kept: the control stays the line's first child (folders' is a button, leaves' an anchor whose
`title` is the full path), the icon region leads it, and every `aria` attribute is untouched. Rows sit
4px (`--space-1`) apart, and the #516 keyboard ring stays on the row's line, which takes the card's
radius while focused on the browser's rows.
`BrowserResourceRowBrowserTest` measures the row — surface token, padding, radius, title role, the
one-line height (a control is padding plus one 18.6px line, folder and leaf alike), the centred glyph,
the same-line badge bounds, the ellipsis and the truncation of a long unbroken name, the folder/leaf
glyph references, the 4px rhythm, and hover and selection colours — off the real page; at the 280px
minimum and the 320px default it measures a depth-three and a depth-ten row on the pipelines tree
(`v13` and `draft`) and on the templates tree (`v13`, `draft` and `needs review`) in both themes:
every badge inside the rail, no sideways scroll, the full name in the tooltip, and the compact indent
and badge padding; at the 480px maximum the rows are still compact and every name is wider than at
320px. The #739 guide case measures parent/child centres, ticks, gap continuity, an expanded
last child and a single child in both themes, with a trailing status row and guide-free search;
`TreeIndentAndVersionMenuBrowserTest` pins the 12px label/icon step. The renderer accepts only the
five family glyph ids plus its `file` default and falls back to `file` for unknown options.

**Width (#554).** The menu's two widths are fixed; the browser's is the reader's. Its default follows the
band — 340px at 1600px and wider, 320px from 1101 to 1599px, 270px from 761 to 1100px — and the
`#browser-boundary` separator (`role="separator"`, pointer drag, arrows ±16px, Home 280, End to the
maximum, `aria-valuemin/max/now` kept current) sets a preference from 280 to 480px, clamped to leave main
`--app-main-min-width` (320px): `--app-browser-width` is `clamp()` over the preference, so a narrow window
changes the effective width and never the preference, and an arrow pressed at the clamp writes nothing.
Opening a folder never changes the width. **Storage** holds widths and booleans only, per user and
workspace — never a query, a path, a title or an expansion; when storage cannot be read the shell renders
its defaults:

| Key | Holds | Written by | Status |
|---|---|---|---|
| `dp-rail` | `"1"` compact / `"0"` explicitly expanded (the menu) | `shell.js`; read pre-paint by `rail.js` | kept, same semantics |
| `dp-browser-width:<workspace>` | the preferred browser width, 280–480 | `browser-resize.mjs`; read pre-paint by `rail.js` | new; any other value is removed |
| `dp-browser-hidden:<workspace>` | `"1"` when the reader hid the browser | `shell.js`; read pre-paint by `rail.js` | new; removed by a desktop family menu click; any other value is removed |
| `dp-rail-width:<workspace>` | the old rail+tree width | — | retired: removed on sight by `rail.js`, never reinterpreted (it measured a different composition) |
| `dp-tree-open:<workspace>`, `dp-tree-panel:<workspace>:<family>` | the old accordion's open family | — | retired: removed on sight by `tree/sidebar.mjs` |

Workspace/account switches create a fresh document.

Artifact, catalog and version navigation preserves the menu and the browser through `#app-main` swaps. The navigation host
validates the destination and prepares dependencies before requesting the final freshly authorized boosted
response; this adds one preparation GET (two server renders per navigation, by design). The shell's progress
bar shows from the click: preparation counts as one in-flight request (#465). Failed or superseded preparation leaves the outgoing page usable,
and late final replies are rejected. Signed-in charts share one lazily loaded vendored 3D Plotly bundle;
standalone previews keep their isolated bundle selection. Cached history contains inert page markup and
mounts each restored runtime once. Pipeline disposal detaches observation without cancelling execution;
dashboard disposal retains refresh cancellation. Sidebar view tokens bind caches to exact admitted membership;
foreign responses refuse admission, and a changed view clears cached levels before Retry.

- **The brand mark (163, #157, "D1").** Three rings converging into an outlined tile with three
  rising bars — the tile is a 4-unit `currentColor` stroke and the bars are `currentColor`
  fills; nothing in the geometry depends on the theme, so there is no filled slab to go wrong
  in dark mode. It exists once, as the `partials/brand-mark.html` fragment, and is included by
  both `.app-brand-tile` copies in the rail (expanded and collapsed) and by the public site's
  header and footer, which retired their separate line icon so the product carries one mark.
  The ceremony screens carry the same fragment since 161 (#161) — the auth layout's brand link
  and the login card's brand had kept the retired filled tile as inline copies, which is what
  `BrandMarkParityRenderTest` now renders and what its source sweep of every template forbids.
- **Counts (#554: in the browser header).** The browser header carries the active family's admitted
  count from `NavCounts` — all five families, keyed by the closed `NavigationFamily` set, one cheap
  `COUNT(*)` each; for a lensed principal (a promoter) the count is the SIZE of the admitted set, so
  the header never says "3 pipelines" beside an empty tree. Counts are no longer repeated beside menu
  items (record §3). **Freshness (#574):** the counts are read on every shell render, with no cache —
  the header and the tree are two reads of the same tables in the same navigation, so after a create,
  release, discard or delete, in the UI or over MCP, they cannot disagree past one navigation (until
  #574 a 60-second TTL with no eviction let the header say "Pipelines 0" above a new row). The read
  waits for the first count the page renders, once per request — the shell's model is built for JSON
  API requests too, and those read none. A count that cannot be read — including a lens whose higher
  environment is unavailable — renders **no count**, never a zero.
- **The Admin item follows the reader's authority (143, T315).** It leads only where its
  reader may go, or it is absent: a **super admin** gets `/admin/users` (instance user
  administration, `user.manage`, boosted like every item); a **workspace admin** gets
  `/workspaces#workspace-members` — the ACTIVE workspace's members section on Workspaces
  (`workspace.members.manage` is judged against the active workspace, §4.13), as a **full
  navigation** (`hx-boost="false"`, so the browser scrolls to the fragment); a viewer, an
  author or a pure promoter gets **no Admin item**. Both booleans (`navAdminUsers`,
  `navAdminMembers`) are `UiWorkspaceAdvice`'s, derived by `RoleModel.shell` from the same
  predicates as the verb booleans — never from the role label — so every full paint (login,
  a workspace switch) carries them, including screens whose controllers stamp no roles; a
  boosted swap keeps the rail as painted. The instance entry is judged WITHOUT the workspace
  (the authority is the user's, so a super admin whose active workspace is gone keeps it) and
  narrowed by the credential axis like every rung (no key holds `admin`, O-2). Active state
  is the path rule unchanged: `/admin/*` lights Admin; on `/workspaces` the Workspaces item is
  the active one and the members shortcut never lights by itself.
- **The collapsed (compact) state** is a class on `<html>`, written by `/js/rail.js` — a
  parser-blocking script in the layout's `<head>` — before the first paint and toggled by
  `shell.js` afterwards. It was the layout's one inline script until 188 (#188): the enforced
  Content-Security-Policy has no `'unsafe-inline'`, so **no template carries an inline
  `<script>`, an `on*=` handler or a `style=` attribute** (`InlineScriptAuditTest`; the
  browser suite holds every page to zero CSP violations). The reason the rail script cannot
  be deferred still holds — a deferred script runs after the document paints, so the menu
  would render at 202px and snap to 64px on every navigation (and a hidden or resized browser
  would jump) — which is why it is its own blocking file rather than part of `shell.js`. Since
  #554 it also stamps the browser's preferred width and Hide (the storage table above).
- **The active item** is `--brand-soft` background, `--text-primary` label and `--text-link` icon
  (Minimal uses `--text-inverted` for both), **plus a 3px
  `--border-focus` left bar**. The bar is not decoration: §2.8 is normative that
  `surface-selected` reaches only 1.5:1 and a tint alone cannot carry selection. The mock
  paints the tint only; the bar is the accessible half of the same signal.
- **Icons** (085 §B) all come from ONE source: the vendored sprite
  `static/vendor/icons/lucide-sprite.svg` (lucide-static 1.39.0, ISC — the license text is
  vendored beside it as `LICENSE.lucide`), referenced everywhere as
  `<svg class="ds-icon ds-icon-{size}" aria-hidden="true"><use href="/vendor/icons/lucide-sprite.svg#NAME"/></svg>`.
  The stroke is `currentColor`, so a glyph always inherits its context's text colour and no
  use site carries a colour override; `.ds-icon` (icons.css) owns the size and
  `pointer-events: none`. The subset is EXACTLY the referenced set — 47 glyphs, each
  SHA-256-recorded per upstream file in `vendor-manifest.json`. `db`/`table`/`boxes`/
  `workflow` keep their 059 ids because the editor's `TYPE_ICONS` map was born with them
  (`db` is lucide's `database`; the other three coincide with lucide's names), and
  CALCULATOR finally draws its own `calculator` glyph instead of the `file` stand-in.
  Size maps by context, never by what looked right on the day: nav, top bar, menu rows and
  action/close buttons are `ds-icon-sm` (16); the editor's canvas tiles and view controls
  are `ds-icon-md` (20); tight inline glyphs — tree chevrons, the arrows inside text links —
  are `ds-icon-xs` (12). The ad-hoc inline copies this replaces (sixteen shell glyphs at
  two stroke weights), the `&times;` close glyphs and the `&larr;`/`&rarr;` arrows are
  retired; `IconSpriteAuditTest` enforces referenced = vendored = manifest-subset in both
  directions, so the app cannot drift back into two icon sources. The server-rendered tree
  rows (085 §A) draw from the same sprite: a rotating chevron-right, folder/folder-open on
  folders, the family's glyph on leaves (`table`, `calendar-clock`); the resource browser's REST tree
  draws `folder`/`folder-open` on folders and the family's menu glyph on leaves (`workflow`,
  `code`, `boxes`, `chart-line`, `sliders-horizontal`; `tree/render.mjs`, #739). Its leaf and folder
  glyphs share the former chevron slot; labels align among siblings and sit 8px right of their
  parent's, with parent-to-child guides only while browsing. Server-rendered explorers retain
  their separate chevron slot and their own indent geometry; `TreeIndentAndVersionMenuBrowserTest`
  measures the resource browser's two families. The `file-code` glyph left the sprite with the
  server-rendered sidebar levels (#466); `file` remains for pipeline-editor fact icons and the
  reusable renderer's default.

- **The role badge (114).** A `.ds-badge.app-ws-role` under the workspace name inside the
  switcher card, carrying the role the signed-in person holds IN THAT WORKSPACE — `viewer`,
  `author`, `promoter`, `workspace admin`, or `super admin` for an instance super admin in any
  workspace, membership or not (D7). It is the membership's ONE role ([`WorkspaceRole`](/docs/enums.md#8c-workspacerole--the-one-role-a-membership-holds), D1), narrowed to
  nothing else since #215 slice (b): scopes are gone and no key renders a screen (the MCP key is confined to `/mcp`). `data-role` is the
  stable hook the tests read. It sits INSIDE the `app-rail-label` span, so the collapsed rail
  hides it with the workspace name rather than leaving a word with nothing to qualify; at all
  three widths (§3.6) it is the same element in the same place. The role decides what the rest
  of the app renders — the inventory is [§4.3e](#43e-role-visibility--every-verb-and-the-role-boolean-that-renders-it-114-normative).

- **The switcher's options are full-paint facts (#256).** The `<select>` renders from
  `UiWorkspaceAdvice.workspaceOptions` (`listOwn(...).filter { it.workspaceActive }`) on every
  FULL paint only — a boosted swap replaces `#app-main` and cannot reach the rail. So any
  page-route mutation whose success changes the option list navigates in FULL (`hx-boost="false"`,
  097 §2.1 — the same rule the switch form has always followed): the create form since #170,
  and deactivate / reactivate / delete since #256, whose guard asserts each verb's before/after
  option list with no reload the test performs (`WorkspacesCreateBrowserTest`: deactivate → the
  option is absent without a reload, reactivate → present, delete → absent). The switch itself
  is the third shell-changing verb and re-issues the session cookie — a full navigation by
  construction.

**The top bar** (`--header-height`). Breadcrumb (`<group> / <page>`, group muted, page bold — the group reads
Explore for the three Explore families), on a family page the browser opener (desktop chevron-right / phone folder, §3.4),
the search field **palette** (161), the light/dark toggle, and the avatar menu. The **MCP-key
chip** (179) sat between the toggle and the menu until keys v2 (233, A15) retired it — see below.
From 761 to 1100px the search field itself hides (§3.6) and a search **icon button** stands in
(#159): it reveals the same palette in place, and ⌘K drives it too. The top bar uses the plain page surface (`--surface-page`) with a decorative bottom edge (`--app-line-decorative`), matching the approved prototype (#606).

- **The MCP-key chip is retired** (keys v2 — 233, A15, 2026-09-25). 179 (D16, ruling 7) put
  the caller's one login-minted `user` key in the top bar with Copy and delete-to-rotate, and
  #213 made the copy show-once. Keys v2 retired the login mint: every key, `mcp` included, is
  created on the Keys page (§4.19) by a member whose role allows it, with the role the creator
  may give, and its plaintext is shown once at creation. The chip's template, its two verbs
  (`mcp-key-copy`, `mcp-key-delete`), `GET /partials/mcp-key/chip` and the shell's copy handler
  are gone; `GET /partials/mcp-key/secret` (`mcp_key.own`) survives for the V37-migrated keys'
  sealed copies with no page element calling it — #248 decides its fate. `/settings/api-keys`
  (§4.10) points at the Keys page.

- **The breadcrumb** is server-rendered from `AppNav.crumbFor(currentPath)` and re-derived
  client-side after a boosted swap from the active rail link's own `data-nav-group` /
  `data-nav-label`. `ShellRenderTest` asserts the Kotlin table and the rendered markup agree
  for every link, so the highlighted section and the crumb cannot drift apart. #610: below
  760px (the phone band, §3.6) the bar displays NONE of it — the frozen prototype's ≤760
  rule; the element stays in the DOM so the boosted-swap writes keep working, and the band
  returns it at 761 (`MainFrameBrowserTest` pins the hide at 320 and 390 and the
  leaf-only/group split at 761/1101).
- **The search field is real (161, #155).** A `<input role="combobox">` in the top bar — and
  the same control in the menu drawer's head at 760px and below — opens a palette on focus/click or
  **⌘K / Ctrl+K** and fetches `GET /partials/search?q=…` over htmx (`input changed
  delay:200ms`; a BLANK query fetches nothing — `shell.js` cancels the request, so an empty
  box never claims to have matched nothing). The answer (`SearchController` /
  `SearchBrowseModel`, workspace- and role-scoped like the screens it jumps to) groups up to
  8 hits each under **Pipelines / Templates / Executions**, every hit a link (pipeline → its
  editor, template → the template editor, execution → its detail; executions show status and
  started-at), with a "more…" row per group opening the FILTERED list page (`?q=`, `?status=`,
  `?pipeline_id=`). The keyboard is the combobox contract: ↑/↓ move the active row (focus
  stays in the input; `aria-activedescendant`), Enter opens it, Esc or an outside click
  closes, and any boosted navigation closes the palette. Both copies (top bar + drawer) share
  the behaviour through `data-search-*` hooks; ⌘K drives whichever copy is displayed, and in the
  761–1100px band — where neither is (#159) — it drives the band's entry button, a topbar icon
  button lit only in that band, which reveals the topbar copy in place (no third copy of the
  control: same ids, same fetch) and stands down when the palette closes. The search is read-only for every
  role — a viewer sees what a viewer may open.
- **The avatar** renders the OIDC `picture` claim when there is one and initials otherwise.
  The claim IS stored: `users.profile_picture_url`, written by `OidcSuccessHandler` through
  `UserRepository` on every login, and rendered on Settings since 025.
- **The menu** carries the signed-in identity, Appearance, the theme swatches, Settings, API
  keys, **Report a problem** and Log out (which keeps its POST form and `hx-boost="false"`).
  Report a problem (127) is the one external choice: the GitHub bug form, `target="_blank"
  rel="noopener"`, never boosted, its URL read off the same `SitePages` constant the site
  renders from (surfaced to every template by `SiteOriginAdvice`) — the app tab never
  navigates. Escape, an outside
  click, and **choosing any item** (a mode, a swatch, Settings, API keys, Report a problem,
  Log out) close it,
  with focus returned to the avatar; arrow keys move focus within it without activating
  anything; the trigger carries `aria-expanded` and the popover `role="menu"`. (Until
  2026-09-13 only Escape and an outside click closed it: a theme change left the menu hanging,
  and Settings — a boosted swap of `#app-main` alone — carried it open onto the next screen.)

**One theme preference, three views.** The design system ships ONE STYLESHEET PER LOOK, so
`light`, `dark` and `auto` are three of the nine values `users.theme_preference` can take and
the six palettes (`saas`, `ocean`, `forest`, `healthcare`, `minimal`, `professional`) are the
rest. There is no separate "mode" column and the app does not invent one. The top bar's
sun/moon toggle, the menu's Appearance segment, the menu's swatches and Settings' Mode row and
Theme select are five controls over that single field, all PATCHing
`/partials/profile/theme`, which answers with an out-of-band swap of `#theme-link` plus a
toast. `shell.js` reads the SWAPPED href back and brings every control and `<html data-theme>`
in line — reading the href rather than the value we asked for is what makes a refused write
leave the controls where they were.

---

### 3.3 Type and density scale (076; the compact scale #555, normative)

One scale, decided once — measured against the drift the owner saw: page titles at three sizes, dates monospace in one table and proportional in the next. **#555** made it the compact scale of the application-shell record (#547, §5 typography and §6 spacing, shape and density): a 13px interface, 12px support copy and one 24px page title, 36px controls, 44px tabs, 8px and 12px radii — defined ONCE as role tokens in `app.css` (items 4–7).

**The faces (079 §G, normative; sans family re-decided by 551).** The app sets in **Noto Sans** (sans, 551 —
the prototype's face, the owner's bundled-family decision, record 2026-10-16 §5) and **JetBrains Mono** (mono),
both vendored — `static/vendor/fonts/noto-sans/` and `static/vendor/fonts/jetbrains-mono/`. JetBrains Mono comes
from the project's own GitHub release asset; Noto Sans comes from the pinned `@fontsource-variable/noto-sans`
5.3.0 registry package (upstream v42 of google/fonts — the notofonts releases ship TTF only, not a web asset),
variable weight 100–900, latin + latin-ext subsets in normal and italic, with the hashes and the tarball's
sha256 recorded in `vendor/design-system/vendor-manifest.json` alongside every other vendored asset. Both are
**SIL Open Font License 1.1**, which the owner confirmed compatible with this repository's AGPL-3.0
(2026-09-05); each directory carries its `OFL.txt` verbatim, as the licence requires of a redistribution. Both
normal subsets are preloaded in the authenticated layouts — under `font-display: optional` a subset that is not
ready in time is dropped for that page load, and ext-glyph resource names would render in the system face; the
italics are declared but not preloaded. **No Google Fonts and no CDN** — a webfont from a third-party host is a
runtime dependency on someone else's uptime and a per-visitor request to someone else's log.

This closes a gap the design system could not: it *names* both faces in `--_font-sans`/`--_font-mono` but ships
neither, and the two themes the mode toggle switches between — `light` and `dark` — name **neither** face at all
(`minimal` likewise). So every machine without Inter installed rendered the app in its system UI font, which is
why the running app never looked like the approved mocks. `app.css` therefore restates `--font-sans`/`--font-mono`
at the app level with the vendored faces first and the design system's own stacks behind them, so a blocked or
failed download degrades to exactly the previous rendering. Six `@font-face` blocks (#551: Noto Sans latin and latin-ext,
each upright and italic; JetBrains Mono 400 and 500); the latin and latin-ext upright sans subsets and the regular mono
are `<link rel="preload" as="font" crossorigin>`ed in **both** layouts, the italic sans and the mono 500 are not (the app renders almost no italic, and an unused preload is a wasted request).

**`font-display: optional`, amended 090 §B (was `swap`).** `swap` paints in the fallback and REFLOWS when the face
lands. Measured (090, chromium, every font response held 1.2s, geometry sampled at the first animation frame after
`DOMContentLoaded` and again after `document.fonts.ready`): the `/dashboard` page heading went 243.5px → 251.7px
(+8.2px, +3.4%) and the `/templates` heading 611.7px → 618.2px (+6.5px). CLS stayed at 0 and 0.0075 — the reflow is
HORIZONTAL inside a block whose box does not move, which is why the budget alone could not see it. The owner did not
report a jumping page; they reported text that changes after it has been read, and 6–8px of re-flowed measure on
every heading is that. `optional` gives a ~100ms block period and NO swap window: the face is either ready in time
and used from the first paint, or dropped for that page load and used from the next one, by which point the file is
in the HTTP cache. Post-paint reflow becomes structurally impossible rather than small. Verified by re-running the
same held-font measurement: first-frame and settled widths now agree exactly (238.4/238.4 and 611.7/611.7), CLS 0.
The cost is that a first visit over a slow link renders in the fallback stack. `fallback` (100ms block + a 3s swap
window) is the alternative if that is judged too high; it would want size-adjusted fallback faces first, and the
ratios measured for 090 were Inter / `system-ui` = **102.59%** and JetBrains Mono / `ui-monospace` = **109.48%**.
Those are historical measurements of 090's faces, not a Noto Sans fallback calibration (#551); Noto Sans has not been
measured here. The `--_font-sans` stack such fallback faces must be inserted into lives in the vendored design-system themes.
`VendoredFontsAuditTest` fails the build if a file goes missing, a hash drifts, an `OFL.txt` disappears, or any
stylesheet or template starts naming a font host over the network. The marketing site (`site/**`) does not load
`app.css` and keeps the system fallback this round.

1. **Page title**: `.ds-headline` on every screen's `h1` — one size, no inline `font-size` (`TypeScaleAuditTest` fails the build on a regression). Section headings are `.ds-title`; small uppercase section labels (eyebrows) are `.ds-caption`. **Amended 079 §E, 103 §D and #555:** inside `.app-main` the headline is the title role of item 4 — 24px, semibold, `−0.025em`, a 1.35 leading so a wrapped title stays one block — and 21px below 768px; `.ds-title` (and a card header's bare `h2`/`h3`) is the 12px semibold card-title role, 14px inside a dialog. The value moved; the rule that there is exactly ONE of it, expressed as a property of the class rather than of a screen, did not. Every page header is `.app-page-h` — title, a muted `.app-page-sub` subtitle of at most 70ch, actions right.
2. **Every table is the data table component (§3.7) — frame, viewport, sticky header, the cap — with `font-variant-numeric: tabular-nums`** (`data-table.css`, which folded app.css's old `.ds-table` block in 282). Dates and timestamps are ALWAYS proportional — never inside a `.num`/mono cell.
3. **Mono is for identifiers only**: machine names, ids, template refs (`path @ vN`), SQL, keys/prefixes, `context_key → value`. Display names, usernames, badges and dates are prose. The per-table decision:

| Screen | Mono columns | Prose columns |
|---|---|---|
| Dashboard recent executions / §4.8 history | (pipeline machine path on `title` only) | display name, status, triggered_by/via, started_at, duration (`.num`) |
| §4.9 node stats | node id, Context `key → value` | rows in/out, duration (`.num`) |
| §4.19 API keys | key prefix | name, kind, role, acts as, created by, associations, created/expires/last used |
| §4.12 admin users | — | email, display name, provider, created, status, actions (`.num`) |
| §4.13 workspaces | workspace name | role, members, created, actions |
| §4.17 promotion | pipeline/template path, target URL | versions (`.num`), status |
| §4.5 datasources | JDBC URL, username | name, dialect, workspace, last test |
| §4.3/§4.6 detail versions | template ref / path | released_by, created, versions (`.num`) |

4. **The scale is ROLES (#555).** A screen never names a size; it names a role. The roles live in one `:root` block near the top of `app.css` (`--app-text-*`, `--app-leading-*`, `--app-control-h` …), every one a length (§2 principle 3 forbids hard-coded colours, not lengths), most of them a design-system step in rem on the vendored 16px root, so a reader's browser text-size setting scales the whole scale together. The vendored `--text-sm`/`--text-xs` are **not** re-bound: a global re-bind would also resize what item 6 preserves (the dashboards' authored KPI and table renderers, the docs prose, the editor's SQL surfaces read those tokens). Chrome moved onto the roles rule by rule; content kept its tokens.

| Role | Token | rem | px | Leading · weight · tracking | For |
|---|---|---|---|---|---|
| Base UI | `--app-text-ui` | 0.8125 | 13 | 1.5 (19.5px) | body, controls and the values in them, notices, empty states, dialog copy |
| Support | `--app-text-support` | 0.75 | 12 | 1.5 | subtitles, secondary lines, labels, form help and errors (never smaller), tabs, table cells, card titles (semibold); a template reaches it with `.u-text-sm`, the one support utility (#565) |
| Resource name | `--app-text-name` | 0.75 | 12 | `--app-leading-name` 1.55 (18.6px) | names and paths in a catalog or tree; browser rows use §3.4’s one-line ellipsis and full-name tooltips, other surfaces wrap |
| Heading | `--app-text-heading` | 0.875 | 14 | — | a dialog's title, a detail pane's title |
| Browser title | `--app-text-browser-title` | 0.875 | 14 | weight 600 | the resource browser's heading (#554 consumes it) |
| Page title | `--app-text-title` | 1.5 | 24 | `--app-leading-title` 1.35 (32.4px) · `--app-weight-title` 600 · `--app-tracking-title` −0.025em (−0.6px) | the ONE `.ds-headline` |
| Page title, phone | `--app-text-title-phone` | 1.3125 | 21 | 1.35 (28.35px) | the same title at 760px and below (the phone band since #554) |
| Badge | `--app-text-badge` | 0.6875 | 11 | weight 500, sentence case | status badges and chips, version chips, a table's column labels, counts on tabs |
| Meta | `--app-text-meta` | 0.625 | 10 | `--app-tracking-caption` 0.12em when uppercase | short metadata and group captions ONLY — never a name, a value, an error or a paragraph. Two ways in: an uppercase group caption is `.ds-caption` (or `.app-eyebrow`); a short inline annotation beside a name — "(v3 released)", "· has a key" — is `.u-text-meta`, sentence case, no tracking (#565, owner 2026-10-18) |
| Emphasis weight | `--app-weight-emphasis` | — | — | 650 | navigation group captions and the brand wordmark (record §5) |
| Phone text entry | `--app-text-entry-phone` | 1 | 16 | — | text inputs, selects and textareas at 760px and below, so a phone browser does not zoom the page on focus |

| Shape role | Token | Value | For |
|---|---|---|---|
| Navigation indicator | `--app-nav-indicator-w` | 3px | selected navigation row, paired with the tint (§2.8) |
| Control height | `--app-control-h` | `--space-9`, 36px | a button, input or select — a minimum; a wrapping label grows it |
| Tab height | `--app-tab-h` | 2.75rem, 44px | a tab: 12px padding, an 18px line, the 2px underline |
| Tab gap | `--app-tab-gap` | `--space-6`, 24px | between two tabs of a strip; a tab has no inline padding, so its underline is its label's width |
| Header height | `--app-header-h` | 3.625rem, 58px | the shell header (#554 consumes it) |
| Icon button | `--app-icon-h` | `--space-8`, 32px | an icon-only button, square, around a 16px glyph |
| Control radius | `--app-radius-control` | `--radius-md`, 8px | controls |
| Panel radius | `--app-radius-panel` | `--radius-lg`, 12px | cards, panels, dialogs, a board's card |
| Badge radius | `--app-radius-badge` | `--radius-sm`, 4px | a status badge or chip |
| Dialog width | `--app-dialog-max` | 560px | an ordinary dialog; it is also never wider than 90vw |
| Coarse target | `--app-target-min` | 44px | the smallest target under `@media (pointer: coarse)` |

Spacing has no role tokens: the record's 4/8/12/16/20/24/32/40/48 scale is the design system's own `--space-1` … `--space-12`.

5. **The component baseline (#555).** A block right after the `body` rule applies the roles to the vendored primitives at the primitives' OWN specificity — it beats `primitives.css` and loses to every later or more specific screen rule — so no `!important` exists anywhere in it. The phone text-entry and coarse-pointer rules use `:root :is(…)` so a later single-class control rule cannot undo them.

| Component | Baseline |
|---|---|
| Body | 13px on a 1.5 leading |
| Button (`.ds-button`) | ≥36px high, 8px/12px padding, 13px, 8px radius |
| Small or icon button (`.ds-button-sm`, a dialog's close) | ≥32px, 4px/8px padding, 12px; the close is 32×32 around its 16px glyph |
| Input, select (`.ds-input`, `.ds-select`, `.app-filter-control`) | ≥36px high, 13px, 8px radius; 16px text at 760px and below |
| Field label, help, error | 12px |
| Tab (`.ds-tab` and every screen's tab: `.pe-tab`, `.tw-tab`, `.tplx-tab`, `.dp-ws-tab`; the editor dock's `.pe-dock-tab` for its height) | ≥44px: 12px text on the UI leading, 12px padding above and below and none at the sides, the 2px underline as wide as the label; 24px between two tabs (`--app-tab-gap`). The dock's strip is 44px through `--pe-dock-tabs-h: var(--app-tab-h)` (#565; it was 38px) — a grid row of the dock, which splitter.js measures live; its tabs keep their 12px side padding, inset underline and 2px gap |
| Table (`.ds-table`; a framed one through `data-table.css`'s `--dt-font`/`--dt-head-font`) | 12px cells, 11px column labels; row padding, the cap and the measured sticky header unchanged (§3.7) |
| Badge, chip (`.ds-badge`, `.app-chip`, `.app-method`, the version chips) | 11px at 500, 4px radius, 2px/8px padding, sentence case |
| Card, panel (`.ds-card`) | 12px radius, 20px padding, the 1px boundary, NO shadow; a card header 16px/20px, a card body 20px |
| Dialog (`.app-modal`) | 12px radius, 20px padding, the floating `--shadow-lg`, ≤560px and ≤90vw (desktop; see House exceptions); title 14px |
| Caption, eyebrow (`.ds-caption`, `.app-eyebrow`) | 10px, uppercase, 0.12em |
| Notices and states (`.ds-toast`, `.app-alert`, `.app-inline-refusal`, `.app-empty`, `.ds-empty-description`) | 13px; an empty state's title 14px |
| Coarse pointer | every button, input and select ≥44px high; a small or icon button ≥44×44 — the HIT REGION grows, never the type or the glyph |
| Primary navigation row (`.app-nav-link`) | 13px/400, 1.5 leading; ≥36px; 8px/12px padding; 12px icon gap; 8px radius; 12px menu gutters; primary selected label and accent icon (Minimal: inverted ink), tint plus 3px indicator |
| Workspace switcher (`.app-ws`) | 12px padding/gap, 32px avatar, 8px radius; UI workspace name, 11px role badge, no redundant caption; transparent select remains operable |
| Resource row (`.app-browser .dp-tree-activate`) | ONE line: a 12px/18.6px title truncating with an ellipsis (the full canonical name in the control's and label's tooltips), version/draft/needs-review badges on the same line after it, `flex: none` — the label yields width to them, a badge-less row leaves no metadata box; 12px vertical / 8px horizontal padding, 4px control gaps, 2px badge gaps and sides, 8px radius, 4px between rows; 8px indent per level with browse-only guides; folder/folder-open and family leaf glyphs centred on the line, never rotating (§3.4); selected: tint + primary-ink title, secondary metadata |

**House exceptions (ruled).** Where the house departs from the application-shell record on purpose, the departure is listed here with its reason and its measurement; nothing else in this section departs from the record.

| Exception | The record (#547, §6) | The house | Why | Measured |
|---|---|---|---|---|
| Phone dialogs (#565; owner 2026-10-18) | a dialog is at most 560px and at most 90vw, with 20px body padding | at 760px and below `.app-modal` is full width (`max-width: none`, `max-height: 100dvh`) with 16px padding (`--gap-md`): the phone block of `app.css`, later in the file than the `-sm`/`-md`/`-lg` max-widths it overrides; above 760px the record's 560px / 90vw / 20px apply | a 90vw dialog on a 390px phone leaves 19.5px gutters and narrows the form's fields | `CompactScaleBrowserTest`, 390×844: the datasource dialog is 390px wide (the scrim has no padding, the dialog no margin) with 16px padding |

6. **Preserved: content keeps its own typography (#555).** What the scale does NOT touch, because it is content rather than chrome — each keeps an explicit size of its own and so never inherits the 13px body: the dashboards' authored renderers (`.dp-dashboard-kpi-value`/`-unit`/`-comparison`, `.dp-dashboard-table`) and a board card's padding (board layout); the docs prose (`docs.css` `.doc-body` and its headings, code and tables); the template editor's SQL and JSON surfaces (`#templateBody`, `.te-readonly`, `#contextJsonTextarea`, `.te-render-out`, `.te-render-error`); the pipeline editor's canvas (`.pe-card*` through its `--pe-card-fs*` variables, `.pe-port*`, `.pe-marker-*`), its SQL (`.pe-sql`), its event log (`.pe-events`, `.pe-ev-*`) and its error detail (`.pe-error-*`); code and figures in `app.css` (`.app-main code` and `pre`, `.app-code`, `.app-errblock`, `.tf-pane-input`, `.tf-diff`/`.tf-detail`, the `.app-stat-v` figure and its unit); the login page's illustrative miniature (`.app-login-preview`, `.app-login-step`, `.app-login-connector` and `.app-login-result` in `auth.css`, sized as a picture); `.viz-json` and `.ps-insp-code`. A glyph drawn as a character (a dismiss `×`) keeps its size: it is an icon. The shell chrome uses these roles: rail, switcher, breadcrumbs, search and account menu through #605; the status pill through #614.

7. **Measured, not asserted (#555).** `CompactScaleBrowserTest` opens the real screens in Chromium at **1512×982** and, with a touch-capable context, **390×844**, and reads `getComputedStyle` (font size, line height, weight, radius, padding, max width) and `getBoundingClientRect` (height, width) off the rendered element — never off the stylesheet: the pipelines catalog (body, title, subtitle, a resource name, a badge, a page action, the search box), the home page's caption, the datasource form in its dialog (input, select, label, close button, dialog width, padding and radius), a board (card title; and, in a case of its own, the authored KPI value, which must read the SAME at the base and the tip), the executions data table (cell and column label), the pipeline workspace (root text, a tab with its side padding and the gap to the next tab, the dock's tab height (#565), the release dialog) and the template workspace (the same tab readings). On the phone it adds the coarse-pointer media query, the 21px title, 16px text entry, the 44px targets and the datasource dialog's width and padding (the house exception above, #565). Type sizes and line heights are held to ±0.05px; control heights are floors (≥36px, ≥44px under a coarse pointer), a tab's height is 44 ±2px (the dock's 44 ±0.5px), the gap between two tabs 24 ±0.5px, and the icon button 32 ±0.5px square.


### 3.5 Feedback and atmosphere (103, normative)

Owner, 2026-09-08: *"algoschool.app looks so native SPA than datapipelines although I am using the same design library."* Measured (notes T195), Algo School has **zero `hx-boost`** — every navigation there is a full document load — and reads as the more native app anyway. We are the more SPA of the two architecturally (boosted `#app-main` swaps, a progress bar) and gave the reader none of what that app gives: a click-time pending state, entrance motion, heading weight, consistent elevation. This section is that layer. It changes **no architecture**: no client router, no view-transitions dependency, no new library — `static/js/shell.js` and `static/css/app.css`.

1. **The click is acknowledged, in the frame it happens.** A boosted link click marks the link `is-pending` (dimmed, `aria-disabled="true"`, `cursor: progress`, `pointer-events: none` so a second click cannot fire) and its group — the rail's `<nav>`, the breadcrumb, or the boosting element for an in-content link — `is-pending-scope`. `shell.js` decides what qualifies with the SAME lookup htmx performs (`closest("[hx-boost]")` must read `true`), so nothing htmx would refuse to boost is ever dimmed. A form submitted through htmx pends its **submit button**: htmx names the `<form>` as the requesting element, so §5.1's per-element busy marking never reaches the control the reader actually pressed.
2. **The status pill waits 150ms.** `#app-status-pill` (`role="status"`, `aria-live="polite"`, one element, class-toggled, no inline style) shows a spinner and "Loading…" only if the swap has still not arrived after 150ms — the same no-flash arm, for the same reason, as §5.1's delayed skeleton. It is `hidden` when off rather than transparent: a live region announces when its content becomes rendered, and a permanently-rendered region with unchanging text announces nothing.
3. **The pending state is cleared by the union of three endings**, because no one of them is total: `htmx:afterSettle` (the normal path — `htmx:afterRequest` would clear it one frame early, while the outgoing screen is still painted), every htmx error/abort event (`afterSettle` never fires for a response error, a network error, a timeout or an abort — 085 §D's finding), and `pageshow` (a bfcache restore re-paints the DOM with `is-pending` still on it and fires no htmx event at all).
4. **The swap arrives with motion.** After a boosted swap the new `#app-main` carries `app-enter`: a 150ms fade plus a 4px slide-up, **transform and opacity only**, so it cannot move a box and cannot spend the 0.05 cumulative-layout-shift budget `ExplorerPaneGeometryBrowserTest` holds over boosted navigation. The class comes off on `animationend` **and** on a fallback timer, because under `prefers-reduced-motion: reduce` app.css sets `animation: none` and an animation that never runs never ends. Which swaps qualify is decided by the `htmx:beforeSwap` handler that retargets the response and carried on a one-shot — htmx's own swap events carry the swap's `eventInfo`, not the response's, and have no `boosted` flag. **The class is applied on `htmx:afterSettle`, not `htmx:afterSwap`:** at afterSwap the element is still mid-ceremony (`htmx-swapping htmx-added htmx-settling`) and htmx replaces it a frame later, which starts the entrance and then CANCELS it — measured at `animationstart@771 … animationcancel@788`, with every class-level assertion still passing. `ShellFeelBrowserTest` therefore asserts the animation EVENTS (start, then end, never cancel), not the class.
5. **The ground is the page (#606):** `--surface-page`, plain and unpatterned, as the owner-approved application-shell record's §6 and frozen prototype specify (owner ruling, 2026-10-18). The old `.app-backdrop` glow and dot-grid element and its stacking layer are removed; the editor's flow-canvas grid, login-stage grid and drawer scrim remain. Body text is measured against the main's painted page surface on every vendored theme. Boundaries keep the 3:1 role and required separators the 2:1 role; only nonessential edges use the lighter decorative role (§2 principle 8). The shared components follow the same roles (#609). The dashboard filters pane and dock, the parameter card, the editor's minimap, view controls and dock top, and the version-row menu are `--border-default` boundaries. List and dialog rows, code and JSON blocks, the editor's keyboard hint, the schedule fieldset and the evidence screenshot frame are `--border-subtle` separators. `ComponentBoundariesBrowserTest` measures each edge against both adjacent surfaces on every vendored theme. Four vendored pairs stay below their floor, listed in that test and tracked by #628: `--border-default` on `--surface-page` (2.81–2.93:1, seven light themes) and on `--surface-hover` (2.53–2.77:1, under the version menu); `--border-subtle` on `--surface-page` (1.87–1.94:1, seven light themes) and on `--surface-inset` (1.76–1.84:1, every light theme).
6. **Cards are drawn; dialogs float** (#555 amends 103's "cards read as raised"). An ordinary card or panel carries NO shadow — its 1px boundary is the whole edge (§2 principle 8 is normative: a card boundary is a line), as the application-shell record's §6 asks. The hover lift (`translateY(-1px)` + `--shadow-md`) stays only on a card that is itself a link, which promises the click it has; a card that merely carries a primary action no longer lifts — the button is the affordance. A dialog (`.app-modal`) is the one raised panel: `--shadow-lg`.
7. **Headings carry weight.** Page titles are §3.3's title role — 24px (`--text-2xl`) at semibold with `−0.025em` and a 1.35 leading (#555 amends 103's bold and tight leading to the record's values; 21px on a phone) — still a property of `.ds-headline` inside `.app-main` so no screen picks its own size (`TypeScaleAuditTest`). Card titles are 12px semibold on the UI leading (#555: at 12px the old bold and tight tracking closed the word up). `.app-eyebrow` is the shared eyebrow class, also used by the login page — uppercase, the 10px meta role with the 0.12em caption tracking (#555; was `--text-xs` at 0.08em), brand colour. The faces follow §3.3's faces paragraph; #551 supersedes the 2026-09-08 Inter-only ruling.
8. **Controls transition.** The design system already transitions `.ds-button` and `.ds-input` colour, border and shadow (`primitives.css`) — what this round adds is the missing half: a 1px `:active` press on buttons, focus transitions on the bare `<select>`/`<textarea>` controls that are not `.ds-input`, and a 100ms background transition on rail rows. All token durations and easings; all silenced by the `prefers-reduced-motion` block.

**Text contrast on the page surface** is measured by `ShellFeelBrowserTest`: it reads the main's actual text color and its painted ancestor background on **every** vendored theme. The theme list comes from the rendered appearance controls, derived from `VendoredThemes.names()`, so a new vendored theme joins the sweep automatically. Each pair must meet §2 principle 8's 4.5:1 text floor. **Danger, success and warning text each have one colour: `--app-danger-ink` (#509), `--app-success-ink` and `--app-warning-ink` (#618)**. None of them is the role's fill accent: `--accent-danger` is the solid danger button's slab, 2.74–3.89:1 as text on the dark surfaces, and `--accent-success` / `--accent-warning` read 4.39 / 4.31:1 on professional's inset surface, with forest's warning at 3.88:1. None is the vendored `--text-danger|success|warning` read directly either. Each ink is built the same way from the design system's text role. In dark it is the role as vendored. In light it is that colour at 3% lower oklch lightness, hue and chroma kept, because the unadjusted roles miss the floor on the inset and hover surfaces: light, saas and ocean's red (owner ruling 2026-10-19), and light's and healthcare's warning (4.43, 4.23). So on ocean and saas, success text is the theme's teal text role, not its green accent. Borders, fills, dots and chip tints keep the accents (3:1, non-text). The vendored `.ds-badge-*` and `.ds-toast-*` keep their accents on their own tints (computed ≥ 4.54:1, not swept). `DangerInkContrastBrowserTest` measures the chip, the ghost danger button at rest and hovered, and the ink on every surface on every vendored theme, plus `auto`'s dark half. `StatusInkContrastBrowserTest` does the same for success and warning: the `.u-*` utilities and each ink on the five surfaces, the chips and the warning alert on their tints, and a hovered ghost button. `DangerInkSourceAuditTest` and `StatusInkSourceAuditTest` hold every first-party text site to its ink. `ShellSurfacesBrowserTest` measures page, top bar and rail surfaces at 1512, 390 and 1920px and checks the classified shell edges against their roles across the same themes. SQL highlight roles that reuse a status hue read its ink (`--app-warning-ink` for pipeline strings and both number roles, `--app-success-ink` for template strings; #634).

**Not in scope:** the three list-screen first-paint strategies (rows / skeleton+load / empty+load) are 097's `BrowseModel` work and are untouched here.

### 3.6 The shell at four widths (110; re-cut by #554, normative)

One breakpoint table for the whole shell — the menu, the resource browser, the top bar and the main gutter at every width the app is used at. Since #554 the edges are the design record's (§4): 1600, 1101 and 761, with the `.98` ceilings (`max-width: 760.98px`, `1100.98px`) so a fractional viewport never falls between two bands. It nests with the explorers' own drawer (`.tplx-tree` becomes a drawer at 1100px and below) and the login split (below 900px); it does not fight either.

| Width | Menu (`#app-rail`) | Resource browser (`#app-browser`) | Topbar | Main gutter |
|---|---|---|---|---|
| ≥ 1600 px | 202px expanded; 64px compact on the user's choice (`rail-collapsed`, remembered in `dp-rail`) | 340px default; the user's 280–480px preference, clamped to leave main 320px | as today | `--app-main-pad` = 40px (`--space-10`) |
| 1101–1599 px | as above | 320px default, same range and clamp | as today | `--app-main-pad` = 32px (`--space-8`) |
| 761–1100 px | **starts compact** (64px, icons + named tooltips) unless the user expanded it — same class, same key: a stored `"0"` (`rail-expanded`) opts out of the band default | 270px default, same range — under the clamp the 280px floor wins over it (280px at a 900px viewport); with the menu expanded the clamp tightens (378px at a 900px viewport) | search text hidden and the search ICON BUTTON stands in (#159: it reveals the topbar copy in place; ⌘K drives it too), crumbs truncate to the LEAF with the full path in `title` (106's pattern on `h2.tplx-detail-title`) | `--app-main-pad` = 24px (`--space-6`) |
| ≤ 760 px | **off-canvas Navigation drawer** (`rail-open`): not in the grid (`grid-template-columns: 1fr`), `position: fixed`, `z-index: var(--z-drawer)`, translated off-screen, 232px (`--app-menu-drawer`, the drawer width before #554: its brand row also seats the 48px close control) with its labels whatever `dp-rail` says | **off-canvas Browse resources drawer** (`browser-open`), at most 360px and never wider than the viewport | `#rail-open` (Navigation) first, `#browser-open` (Browse resources, folder glyph) on a family page, **no breadcrumb** (#610 — the frozen prototype's ≤760 rule: `.app-crumbs` is `display:none` in app.css's phone block, the element stays in the DOM for shell.js's boosted-swap writes, and `#mode-toggle` inherits the crumbs' `margin-right: auto` right-edge pin), workspace switcher as its avatar only, user menu; **nothing wraps**; the search moves INTO the menu drawer's head — side padding `--app-main-pad` (20px, #613 — the bar and `<main>` share a left edge) | `--app-main-pad` = 20px (`--space-5`); the top bar pads the same token (#613) |

The main gutter is one token (#573): every rule whose geometry assumes it reads `--app-main-pad`, never a spacing token directly, so a band change moves them together — the full-height calcs of `.pe-root` (`pipeline-editor.css`), `.dp-workspace` on its board tab (`dashboards.css`), `.tplx-page` (`template-tree.css`), `.tw-root` (`template-workspace.css`) and the page-flow table's `--dt-flow-max-h` (`data-table.css`), and the status pill's three offsets (`app.css`: desktop, anonymous shell, phone). The desktop and anonymous offsets also add `--app-frame-inset`, the 1500px frame's centring offset in its track (0 on a canvas page and wherever the track is 1500 or less), so on a wider framed page the pill starts at the content's edge rather than the track's (#614; `MainFrameBrowserTest` reads it at 2200 framed, on the editor and signed out, and at 1512). `.tplx-splitter`'s `--gap-lg` is `.tplx-body`'s own column gap, not the gutter. `MainFrameBrowserTest` reads `<main>`'s vertical overflow on the editor, the template workspace and the board at 1512: a calc left on the old token overflows by twice the difference. The desktop top bar is NOT banded (owner rulings 2026-10-19, ruling 1's second half): above 760px it keeps its own 24px `--gap-lg` at every width, measured identical at the 1512 and 1600 bands while `<main>` moves 32 → 40 (`MainFrameBrowserTest` reads the bar at both bands; #613).

Rules the table rides on, all testable:

1. **One element per drawer, one class each.** The Navigation drawer is the SAME `<aside class="app-rail">` and the Browse resources drawer the SAME `<aside class="app-browser">` — no second nav, no second tree. Their states are `rail-open` and `browser-open` on `<html>`, set and cleared by `shell.js` through one `setOpen` discipline, exported for `node --test`, and NEVER persisted: a drawer is closed on every load. **One drawer is open at a time**: opening one closes the other. Leaving the band with a drawer open releases it (the class, the opener's `aria-expanded` and every `inert`), without moving focus.
2. **The default flip is pure CSS**, so it survives a live resize. The user's explicit choice travels the SAME key and the SAME pre-paint script: a stored `"1"` carries `rail-collapsed` (as always); a stored `"0"` carries `rail-expanded`, which opts out of the 761–1100 default.
3. **Closing is a union**: `#rail-close` for the menu and the header's **Hide** (`#browser-hide`) for the browser, Escape (a document-level listener installed once — never per-swap), the one `.app-rail-backdrop` scrim, and `htmx:afterSettle` for BOOSTED navigations only (the same one-shot `applyBoostSwap` arms for the entrance decides the close, so a background partial settling while a drawer is open cannot slam it shut).
4. **Focus is choreographed on change only, and contained while open**: opening moves focus into the drawer (the first nav link; the browser's search), closing returns it to the drawer's opener (`#rail-open` / `#browser-open`), and `aria-expanded` mirrors the state — but a swap that arrives while the drawers are closed moves nothing. While a drawer is open every other child of `.app-shell` (the scrim excepted) carries `inert`, so nothing behind it can take focus or a click, and Tab wraps inside the drawer (`ShellColumnsBrowserTest`). Body scroll is locked while open (`overflow: hidden` on `<html>` via the same classes, no inline style).
5. **Motion is `transform` on the drawers and `opacity` on the backdrop**, both silenced under `prefers-reduced-motion: reduce`. No column width animates.
6. **Touch targets**: every control in the drawers and the topbar is at least `var(--field-height-lg)` tall at 760px and below; the nav links get the same minimum at that width only.

### 3.7 The data table (282, normative)

Owner, 2026-09-27: *"UI is the first thing people see."* Before 282 the app's tables were plain HTML: a card scrolled sideways (`.app-card-table`, `overflow-x: auto`), the PAGE scrolled the rows, every header scrolled away, and in the editor's result dock the viewport's scrollbar ran beside the header (the owner's report: *"a vertical scrollbar in the header"*). #282 is the house table that replaces them, ported from the owner-approved preview (the store's `notes/design/282-data-table-v2/`, *"Love this."*) — the preview is the look; this section is the contract.

**Markup** — the server renders it; nothing here builds a cell from data:

```html
<div class="dt-frame [dt-scroll | dt-fill] [dt-flush] [dt-nowrap] [dt-freeze] [dt-fit] [dt-compact] [dt-zebra]"
     [data-dt-paged]>
  <div class="dt-viewport" tabindex="-1">
    <table class="ds-table"> <thead>…</thead> <tbody>…</tbody> </table>
  </div>
</div>
```

The table stays a `.ds-table` (the vendored primitive's type, padding and hover) directly inside the viewport; a table swapped out-of-band keeps its bare `<table>` fragment root (§4.5's rule) — the frame is on the PAGE, around it (`api/keys.html`'s `keysTable`).

**Files and load order.** `static/css/data-table.css` loads from the layout head right AFTER `app.css` (it folds app.css's table layers and must win over them) and BEFORE the page sheets (`template-tree.css`, `schedules.css`) whose table tweaks build on it — [Pipeline Editor §3.4](/docs/pipeline-editor.md#34-design-system-acmedesign-tokens) lists the order. `static/js/data-table.js` loads after `shell.js` (Enter on a row reuses shell.js's click handler). Both are vendored house code: no dependency, no build step, one IIFE. Every colour, radius, gap and type size is a token; the lengths with no token (the drag floor `--dt-col-min`, the dense columns' automatic ceiling `--dt-col-max`, the handle `--dt-handle`, the viewport heights `--dt-max-h`) are custom properties on the frame, and the script reads the sheet's numbers (resolving rem and em itself — `getComputedStyle` hands a custom property back unresolved) rather than carrying its own.

**The header and the cap.** The header cells are `position: sticky; top: 0` inside the viewport, `border-collapse: separate` so their bottom line (an inset shadow) travels with them, on an opaque `--surface-inset`; body cells are opaque too (`--dt-surface`: `--surface-default`, `--surface-raised` inside a card) so rows pass UNDER the header and the frozen column. The frame's `::after` is the cap: `--dt-sbw` wide (the viewport's measured `offsetWidth − clientWidth`, 0 on overlay scrollbars — nothing is drawn) and `--dt-head-h` tall (the header's measured height), painted as the header, so the scrollbar visibly starts where the body starts. The script re-measures on a `ResizeObserver` (deferred one frame — a size change inside its callback loops it), on `document.fonts.ready`, and on every row change.

**FIXED or PAGE-FLOW — decided per table (the list below; the next lane does not re-decide):**

- **FIXED** (`dt-scroll`): the viewport has a height — `--dt-max-h`, `min(40rem, 60dvh)` by default, `dt-h-sm` 22rem (the ceiling `tplx-scroll` gave a growing list), or a page class setting the variable (`.sch-messages-scroll`, 24rem) — and the gutter is reserved (`scrollbar-gutter: stable`), so the header never jumps when rows arrive. **`dt-fill`** is FIXED by the parent: the frame is a flex item taking the rest of a column (the result dock).
- **PAGE-FLOW** (the default): no height of its own. While the table fits its frame the script sets **`dt-fits`** and the viewport's overflow becomes `clip` — not a scroll container — so the header sticks to the page's own scroller: `<main>` (§3.4, *the shell is the viewport*), under the top bar. Measured on the lane instance, not assumed: Chromium stops a `top: 0` sticky cell at the scroller's CONTENT edge, i.e. `<main>`'s padding, `--app-main-pad` (24px when measured; 32 at 1512 since #573), below the bar, with rows showing through the band above the header; so while it fits the script sets **`--dt-flow-top`** to MINUS the scroller's top padding, and the header lands on the bar's bottom edge (60 = 60 at 1440×600 on `/admin/users`). The same measurement puts a table in a dialog on the dialog's edge. A table WIDER than its frame (a narrow window, a drag) drops `dt-fits` and keeps a bounded box (`--dt-flow-max-h`, the window below the bar) that scrolls both ways with the header sticking inside it — that is also the no-script state, so a table can never widen the document (110 §B's rule, kept). The decision compares the table's width with the FRAME's inner width, never the viewport's, so a scrollbar appearing cannot flip it back and forth.

| Template | Table | Kind | Modifiers |
|---|---|---|---|
| `partials/executions.html` (§4.8) | the executions list | FIXED | `dt-nowrap`, `data-dt-paged` (20 a page); empty = a state row |
| `partials/recent-executions.html` + `dashboard.html` (§4.2) | recent executions | FIXED `dt-h-sm` | `dt-flush` (the card's body), `dt-nowrap`; loading = three skeleton rows under the same `head` fragment; empty = a state row |
| `pipelines/editor.html` (§4.4) | the result dock | FIXED by its pane (`dt-fill`) | `dt-nowrap`, `dt-freeze`, `data-dt-paged` |
| `partials/execution-result.html` (§4.9) | the execution's result page | FIXED | `dt-nowrap`, `dt-freeze`, `data-dt-paged` |
| `partials/datasource-facts.html` (§4.5b) | learned facts (dialog and lake detail) | FIXED | prose wraps |
| `schedules/run.html` (§4.20) | a run's Messages | FIXED (`.sch-messages-scroll`, 24rem) | — |
| `schedules/detail.html` (§4.20) | next five occurrences; parameters | FIXED `dt-h-sm` | `dt-fit` |
| `pipelines/editor.html` (§4.4, #349) | the Parameters tab's declared schema | FIXED `dt-h-sm` | `dt-fit` (client-rendered rows; the observer upgrades) |
| `partials/pipeline-versions.html` (§4.3b/§4.4, #349) | the version history | FIXED `dt-h-sm` | `dt-fit`; the ⋯ verb menus ride the rows; `data-version-row` is the client's viewed-mark hook |
| `partials/pipeline-runs.html` (§4.3b/§4.4, #349) | the pipeline's runs | FIXED `dt-h-sm` | rows carry `data-href` (the keyboard contract); visibility unchanged |
| `partials/pipeline-usage.html` (§4.3b/§4.4, #349) | endpoints / parents / dashboards / schedules | PAGE-FLOW `dt-fit` | one table per half; the parent links name the canonical workspace |
| `admin/users.html` (§4.12) | users | PAGE-FLOW | — |
| `api/keys.html` (§4.19) | keys | PAGE-FLOW | `dt-flush`; dates sort by `data-sort-value` |
| `api/console.html` (§4.18) | published endpoints | PAGE-FLOW | `dt-flush` |
| `partials/datasources.html` (§4.5) | datasources | PAGE-FLOW | `dt-flush`, `dt-freeze`, `data-dt-paged` (25 a page) |
| `partials/datasource-grants.html` (§4.5a) | grants (dialog) | PAGE-FLOW | — |
| `workspaces/index.html` (§4.13) | your workspaces; members | PAGE-FLOW | — |
| `promotion/index.html` (§4.17) | the plan (promoter's form; reader's copy) | PAGE-FLOW | — |
| `executions/detail.html` (§4.9) | the execution family | PAGE-FLOW | — |
| `partials/execution-node-stats.html` (§4.9) | node stats | PAGE-FLOW | — |
| `partials/execution-node-operations.html` (§4.9) | node operations | PAGE-FLOW | `dt-freeze` (nine columns) |

Dense data is FIXED; a short list a person reads top to bottom is PAGE-FLOW. `templates/endpoints/**` is not in the sweep (lane 274's tree at the time); a table not yet framed keeps the pre-282 box (its own border, `display: block; overflow-x: auto`) from the fallback rule in `data-table.css`, and the markup audit names it.

**Modifiers.** `dt-flush` — the card's own border closes the box (a separator line meets the card head); `dt-nowrap` — one line per row, an ellipsis where a column is narrower than its value; `dt-freeze` — the first column is sticky at the left and casts a soft shadow once the viewport has scrolled sideways (`is-scrolled-x`); `dt-fit` — the frame hugs its table ("never the full row", was `.tplx-fit-table`); `dt-compact` and `dt-zebra` — the preview's density and zebra switches, available, used by no screen yet.

**Sort** (client-side, over the rendered rows). Every header cell with visible text becomes a `<button class="dt-sort">` (the label and the sprite's `chevron-right`, turned) unless it carries `data-sort="off"` (visible-text action columns: Actions, Send). A header whose only text is screen-reader-only (`u-sr-only` — the Edit, Actions and Details columns of the versions, keys and evidence partials) is not sortable and gets no control (#617). The same rule excludes the other visually hidden utilities (`app-visually-hidden`, `sr-only`, `ps-visually-hidden`) and resize handles from the header text. A click walks **none → ascending → descending → none**, sets `aria-sort` on that cell (`none` on the others) and MOVES the rendered `<tr>` nodes; it never rebuilds a row. The type is the header's `data-type` (`number`, `date`, `text`), else `number` for a `.num` column, else text; a cell sorts by its `data-sort-value` when the server gave one (a relative age's instant — the keys page), else a `<time datetime>` inside it, else the chosen option of a `<select>` in it (a member's role), else its text. **Nulls sort last in both directions** (an empty cell or a lone dash); equal keys keep their natural order. A paged list sorts the page it shows, and the button's title says so (`data-dt-paged` → "Sort this page by …"); **a page-sized replacement clears the client sort** (288): when every data row is new to the table — the htmx pagers swapping their frame, or any keyed-by-content re-render — the new page shows the server's order and every `aria-sort` returns to `none`, so the state the label claims is always the state the rows are in. A row edit and an append keep the sort. **A held sort names itself** (288): while a column is sorted, its button's title becomes "Sorting this page by X — click for highest first / — click to clear" beside the `aria-sort`, which matters on the one producer whose paging keeps the sort (the dock's cursor paging re-renders its rows in place, index-keyed, so the held sort persists by element identity — never silently). State rows (a single cell spanning the row, `.dt-state-row`, `.dt-skeleton-row`) never move.

**Resize.** Every header cell carries a resize handle at its right edge (`.dt-resizer`, `--dt-handle` wide; `data-resize="off"` opts out). A drag freezes the rendered widths onto a `<colgroup>` the script owns and switches the table to `table-layout: fixed` with its width the sum of its columns, so a drag changes ONE column's width and no other (the table grows or shrinks); `--dt-col-min` is the floor; a double-click gives the column back its width from before the drags. A `dt-nowrap` table is frozen at load (each column at most `--dt-col-max`, the slack shared so it still fills its viewport) and re-fitted when its frame changes width until a person has dragged; a wrapping table stays in the browser's own layout until the first drag. Widths are not remembered across page loads.

**Keyboard rows.** A row that carries `data-href` (the executions tables, `app-row-clickable`) is focusable; **Enter** dispatches a click on it, so shell.js's one delegated handler navigates (§3.4's 188 note — the script never reads or writes `data-href`); **↑/↓** move between such rows. **The viewport is keyboard-scrollable** (288): the markup renders `tabindex="-1"` (programmatically focusable only — the pre-upgrade, no-script state), and the enhancer flips it to **`tabindex="0"`** so Tab reaches a FIXED viewport and its arrows scroll it, the sheet's `.dt-viewport:focus-visible` ring (2px `--border-focus`, inside the box) marking the focus. **Only a viewport that actually scrolls gets the tab stop** (301): the enhancer checks the computed overflow — a fitting page-flow frame is `overflow: clip`, not a scroll container, and its inert `-1` stays — and content that overflows the container, re-checked in `measure()` on every state change, so the tab order holds only the viewports the arrows answer.

**States are rows of the same table**, so the header never disappears with the rows: the empty state is a `.dt-state-row` (one cell across the row holding `.dt-state` — the title in `<b>`, the follow-up beside it); the loading state is `.dt-skeleton-row`s of `.dt-skel` bars (a shimmer, off under `prefers-reduced-motion`), server-rendered — the dashboard's recent-executions placeholder. Both are Thymeleaf, never script-built. The columns' LABELS never change between states; their widths follow the rows (auto layout), so a skeleton's columns are not the loaded rows' widths.

**When tables arrive.** At load, on `htmx:afterSwap` / `htmx:oobAfterSwap` for the swapped subtree, and — because three of the producers are not htmx (Alpine's dock rows, the schedules page's cloned `<template>`s, a htmx swap of a `tbody` alone) — through one document-level `MutationObserver` that upgrades a new table and re-runs the (idempotent) ensure steps when a table's rows change: a header a renderer rewrote gets its button back, a new row is focusable, an active sort is re-applied (a replaced page clears it, the sort rule above). The observer pays **one discovery query per top-level root, not one per added element** (288 #2): a batch's nodes with no ancestor in the batch carry the whole subtree, so a swap of a large canvas costs a constant number of subtree queries, not one per node. A table is upgraded once (`data-dt-ready` and the instance map). **The history snapshot carries no CSSOM styles:** htmx caches the page (the history element is `#app-main` since #358 — the layout's `hx-history-elt` scopes the cache to the workspace region, so footer scripts are never cached at all) as markup before a boosted swap, and a width serialised into a `style` attribute would come back on Back as an inline style the CSP refuses. The strip runs from shell.js's ONE `htmx:beforeHistorySave` listener (287): scripts that write such styles register a cleanup on `window.__dpHistoryStyleCleanups` — data-table's (frame, table, cols), the version menu's placement (which also closes open menus: the snapshot, like the shell's own skeletons, carries no transient chrome), and the pipeline workspace runtime's (it disarms its root with `x-ignore`, empties the generated canvas DOM and strips the styles its libraries wrote) — and each strips only its own write set; the restored table is upgraded again by the observer below. The hand-rolled modals need no cleanup: they show and hide through the `u-backdrop-hidden` CLASS, never an inline `display`, so there is nothing to serialise.

**CSP** (`script-src 'self'`, `style-src 'self'`, no nonce — `SecurityHeaders`): no template carries a `style` attribute (`InlineScriptAuditTest`, `InlineWidthAuditTest`), and the script writes no `style` attribute and parses no markup — widths and the measured variables go through the CSSOM (`el.style.width`, `style.setProperty`), which the policy permits. The preview's `<col style>` and skeleton widths were prototype shortcuts; the product sets widths through the CSSOM and the skeleton's bar lengths by position in CSS.

**The legacy table classes** — `.ds-table`'s boxed block (app.css, 076 §D / 079 §E): folded into `data-table.css` (the frame draws the box; an unframed table keeps it as the fallback); `.app-card-table`: reduced to ONE rule, a card whose body is a flush data table (`padding: 0; overflow: clip` — `clip`, never a scroll container, or it would take a page-flow header's scroller away from `<main>`); its identifier-breaking rules (098 §A) moved to `data-table.css` for every wrapping frame, its header-wrap rule retired; `.u-scroll-x` and its `:has(> .ds-table)` frame rule: retired (their one user, the execution result, is a frame); `.tplx-fit-table` (template-tree.css): retired, now `dt-fit`; `.pe-result-table-container` (pipeline-editor.css): reduced to the dock's inset and flex placement — the frame draws the box and its viewport scrolls, 032's rule (the box belongs to what does not scroll) kept; `.tplx-scroll` stays for the one non-table list that wears it (the template detail pane). `.facts-table` and `.mini-table` are the MARKETING SITE's (`static/site/css/site.css`, `templates/site/**`), which loads neither `app.css` nor this layout — untouched.

**Guards.** `DataTableCssTokenTest` (tokens only; the load order; the folded layers gone from their old sheets; every app template's `<table>` directly inside a `.dt-viewport` in a `.dt-frame`), `data-table.test.mjs` on `node --test` (visible versus screen-reader-only header text, mixed visible/hidden labels, explicit sort opt-out, empty headers, hidden utility aliases and resize handles; the type, the cell value, the comparator with nulls last, the stable sort, the cycle, the width clamp and fill, the length resolution), `AppCssTokenAuditTest` (the card clips, the viewport scrolls), and `DataTableBrowserTest` in a real browser WITH scrollbars (Playwright hides them by default, which would prove the cap only at 0px): the executions list's header holds at `scrollTop = 200`, `--dt-sbw` equals the measured scrollbar and the cap is that wide and the header's height tall, a header click reorders the page and sets `aria-sort`, a drag moves one column; the dock's header holds and its frozen column sits at the viewport's left at `scrollLeft = 300`; a page-flow header sticks under the top bar; zero CSP violations; screenshots light and dark at 1440 / 1024 / 400.

`TemplateWorkspaceBrowserTest` and `DashboardWorkspaceBrowserTest` pin the screen-reader-only Actions and Edit headers (#608/#617): accessible text and 1×1 geometry remain, no sort control or `aria-sort` is added, and clicking the header leaves the first data row unchanged.

**Not in scope:** a grid library (the CSP, above), virtual scrolling (every list is server-paged), server-side sort (a paged list sorts its page, and says so), remembered column widths, sorting across pages.

---

## 4. Screen Catalog

**On the "Auth required" column:** it restates, per screen, the permission the REST operations that screen drives declare. The **authoritative** definition is the permission catalog in [Auth §7.6](/docs/auth.md#76-operation-matrix--the-permission-catalog-authoritative) — if this column and that matrix ever disagree, the matrix wins and this doc is wrong. There are no scopes since #215 ([Auth §7.5](/docs/auth.md#75-key-roles)): a screen renders by the session's ROLE. Actions the current principal's role does not hold are **not rendered** (not merely disabled), and the server re-checks on every partial request — the UI is a convenience, never the enforcement point.

### 4.1 Login

| Attribute | Value |
|---|---|
| URL | `GET /login` (page), `POST /login` (local password form) |
| Auth required | No — and a signed-in visitor is **redirected to `/dashboard`** (090 §C) |
| Layout | `layouts/auth` (§2 principle 4) — header (mark + wordmark, Documentation), two columns at ≥801px (story 54% / sign-in 46%, form content 360px), footer; no shell |
| Purpose | Sign-in — configured provider buttons first, then the separator and local email/password form; only the enabled methods render |
| Design primitives | `.ds-input`, `.ds-button`, `.ds-button-primary`, `.app-eyebrow`, `ds-icon` glyphs; ceremony geometry uses `--app-auth-*` roles in `auth.css` |
| JS | `/js/login.js` (local accounts only): Show/Hide password, inline validation; the form posts natively |
| htmx | None |

Content: the approved companion design (application-shell record §13): the real mark and wordmark with a plain Documentation link; a short product introduction, three-step illustrative workflow (Connect / Transform / Deliver) and review-and-release caption; the focused sign-in area; a slim page footer. The DAG ceremony is retired. At the 1512px reference, the form content is 360px, the heading 30px, base text and labels 13px, and controls at least 44px. The phone layout hides the story and Documentation link, prioritizes the form with 24px gutters and uses 16px text entry. Both vendored dark/light themes and bundled Noto Sans apply; anonymous visitors receive the deployment theme, with no theme toggle. Provider buttons precede the "or with email" separator and labelled local form, whose password visibility and inline validation progressively enhance the native POST and single automatic CSRF input. Static administrator-access guidance and the self-hosted note follow the enabled sign-in methods; there is no registration or password-reset flow. When local accounts are enabled ([Auth §5A](/docs/auth.md#5a-local-password-accounts-optional)), the form and its external script render. Only the methods actually enabled render: an OIDC-only deployment shows just the buttons (no form, separator, toggle or script); a local-only deployment shows just the form. Provider buttons are **dynamic** — the controller reads the `ClientRegistrationRepository` and passes the provider list to Thymeleaf; button text is the `display-name` from the provider config ([Auth §5.1](/docs/auth.md#51-provider-configuration-generic), [§5.3](/docs/auth.md#53-login-page-dynamic--renders-buttons-for-each-configured-provider)), rendered unprefixed — the configured text is the whole label. No hardcoded provider names anywhere in the UI.

**A live session never sees this screen (090 §C).** `/login` is `permitAll`, so the JWT filter authenticates a
valid `dp_session` before the request reaches `UiController` — which is why the form used to render inside the app
shell for a signed-in visitor who opened it in a second tab. The GET now answers `302 /dashboard` whenever the
SecurityContext holds an `AuthenticatedPrincipal`. The check is on the PRINCIPAL, not on the cookie's presence: an
expired, tampered or deactivated session leaves the context empty and still gets the form, which is exactly what
`?error=expired` exists for. `/oauth2/authorization/{provider}` — the other door, reachable by bookmark — is closed
by `OidcSignedInBounceFilter`, installed ahead of Spring Security's `OAuth2AuthorizationRequestRedirectFilter`
(which is ordered BEFORE this chain's own credential filters, so the filter validates the cookie itself through the
same `JwtService.validate` + `UserService.isActive` pair the JWT filter uses). Everything that is not a live
session — no cookie, expired, invalid, deactivated owner — proceeds to the provider untouched.

Failure states are inline banners in the `?error=` idiom: `expired`, `domain_not_allowed`, `oidc_error` (OIDC), `identity_mismatch` (#187 — the email is already linked to a different sign-in identity; nothing was changed, and the banner says to ask an administrator for a reset, [Auth §4.2](/docs/auth.md#42-user-provisioning)); `credentials` (unknown email or wrong password — deliberately identical, [Auth §5A.5](/docs/auth.md#5a5-enumeration-resistance-and-the-password-policy)), `locked` (per-account lockout), `inactive` (deactivated account, either method — and, since 180, where a live session lands when its account is deactivated mid-life: the JWT filter clears the cookie and the entry point sends the next page navigation here, [Auth §11A.3](/docs/auth.md#11a3-deactivation)).

### 4.2 Dashboard

| Attribute | Value |
|---|---|
| URL | `GET /dashboard` |
| Auth required | Yes (`read`); the Recent executions panel follows `execution.read` (D11, 177) — a promoter's dashboard does not draw it — and the stats tiles count the runs `visibleTo` admits, decided in SQL: every run with `execution.read_all`, own plus the workspace's SCHEDULED runs (#250 — R3) with `execution.read`, and for a promoter (who holds neither) their OWN runs (#293 — the answer the explorers' Runs tabs and the search palette already gave; the tiles used to read zero). The **Pipelines** tile counts the caller's VIEW (178): for a promoter, the released-and-newer set the lens admits ([Auth §11A.1](/docs/auth.md#11a1-the-404-rule)), the same number the rail badge and the explorer show |
| Purpose | Landing page — overview of recent activity |
| Design primitives | `.ds-card`, `.ds-badge`, the data table (§3.7) |
| JS | None |
| htmx | Yes — refresh sections independently (`hx-get="/partials/recent-executions"`, rendered only for `canReadExecutions`) |

Content:
- **Recent executions** (last 10): pipeline **display name** (the machine folder-path name on hover; T114 — the truncated pipeline UUID is gone everywhere), status badge, duration, timestamp. Clickable → execution detail.
- **My pipelines** (top 5 by updated_at): name, description, version. Clickable → pipeline editor.
- **Quick stats**: total pipelines, total executions today, success rate.

### 4.3 Pipelines — the catalog page and the resource browser (#350, #554)

| Attribute | Value |
|---|---|
| URL | `GET /pipelines` (`?q=` — the deep link every "find this pipeline" link uses: the search palette's "All pipelines matching", datasource delete, the node-SQL and Details child links, the schedules detail, the workspace's phone note; `?lifecycle=` — #556's filter, the overview's drill-down) |
| Auth required | Yes (`read`) |
| Purpose | The **catalog**: one flat, server-paged list of full paths — every pipeline the caller may read (newest first, the service's own page) or the matches of `q` — each row a link into the pipeline's workspace (§4.4). The folder TREE is the resource browser's (§3.4). Since #556 the menu's Pipelines item lands on the overview (§4.25); the catalog is its **View all**, the browser footer's, and the target of the overview's linked counts and bars |
| Design primitives | `.ds-input`, `.ds-badge`, `.ds-button`, `.ds-empty`; the rows are `template-tree.css`'s `.tpl-results` / `a.tpl-result` |
| JS | none of its own — `tree/sidebar.mjs` owns **Browse folders** (`data-nav-tree-reveal`), which shows the resource browser's Pipelines tree (the Browse resources drawer at 760px and below) and puts focus in its search; since #554 it leaves the menu's collapse alone |
| htmx | the search input (`#pipeline-filter-q`, `input changed delay:300ms, search`, `hx-sync="this:replace"`) and the shared §5 pager re-fetch ONLY the list into its stable root `#pipeline-list-wrapper` (`GET /partials/pipelines`, `outerHTML`); the resource browser uses REST tree/search (§3.4) |

**Owner ruling 2026-10-02 (#350):** with the tree in the sidebar, the page keeps the catalog the
workspace spec retains (§3.2: "#350 removes the redundant tree panel from the landing page") — a
flat list, not a second tree and not a detail pane: a pipeline is read, run and managed in ONE
place, its workspace. Since 067 pipeline names are **folder paths**
([Template Hierarchy §14](https://github.com/msabiransari/datapipelines/blob/main/docs/template-hierarchy-design.md)); the folders are browsed in the resource browser.

- **The lifecycle filter (#556).** A select beside the search (`#pipeline-filter-lifecycle`, "All
  lifecycles" first) narrows the list to one of the inventory's buckets: `draft` (draft only),
  `released` (released only), `released-with-draft`, `with-drafts` or `with-releases` — the
  wire values of `LifecycleBucket`, whose predicates the overview's counts read, so a filtered
  total equals the overview's number (`OverviewE2eTest`). The search and the select each include
  the other, the pager carries both, and a filter with no rows is a filtered empty state with
  **Clear filters**. Any other value, `discarded` included, is a 400 with a constant reason: a
  discarded-only pipeline has no workspace page to open. Through a promoter's lens the filter
  reads admitted RELEASED versions only, as the inventory does.
- **Separate catalog and tree transport.** `GET /partials/pipelines` (`pipeline.read`, the lens)
  serves the catalog's flat list — the only presentation it has: the server-rendered `scope=nav`
  and prefix fragments were removed with #466, and a stale `scope` or `prefix` is ignored (the
  request answers the catalog list). The resource browser uses the REST tree/search routes (§3.4,
  rest-api §24), with independent bounded continuation for complete immediate levels and
  name-search matches.
- **Every row is a link** to the canonical workspace `/pipelines/{id}` — no version, the
  current-first rule (§4.4) — through the persistent main-content navigation host.
- **A new pipeline appears as `v1 draft`** (D55, 099): every row's version badge names the
  **working** version — the draft's number when one exists, the released one otherwise — beside
  the "pending release" badge. A pre-077 flat name (no folder) is an ordinary catalog row; the
  resource browser's root lists folders only.
- **No verbs.** The catalog renders no lifecycle verb: Release, Discard, Restore, Switch and the
  purges are the workspace's (§4.4, §4.3d). The authoring note says how pipelines arrive (agents
  over the MCP server, D1); the pre-067 "Create Pipeline" stub (T108) and the "browser authoring
  is on the roadmap" sentence are gone — definitions are AI-native.
- **Empty and no-match are different answers:** an empty workspace (no `q`) says how pipelines
  arrive; a search that matches nothing offers Clear search, which empties the search box and
  re-fetches the unfiltered list in one request (§5.1, #432); a lensed caller whose target cannot
  be read gets the promotion page's sentence (178).
- There is deliberately **no datasource filter**: the one shipped before v1.12 was labelled
  datasources but populated from `${dialects}`, the controller never had the parameter, and
  `PipelineRecord` carries no datasource field (deferred).
- **What moved, and where (spec capability inventory):** folder browse, search, counts, paging,
  loading/empty/refusal/retry → the resource browser (§3.4, §4.6a); the selected pipeline's header, reading
  and acting columns (§4.3b) → the workspace's header and tabs (§4.4, #349); the lifecycle verbs
  → the workspace's header and Versions tab (§4.3d — #395 made Switch/Discard/Restore work
  there); the tree-badge refresh → an explicit Refresh or affected-parent invalidation.
- Guards: `PipelineSidebarTreeBrowserTest`, `PipelineSidebarTreeStateBrowserTest`,
  `PipelinePartialControllerTest`, `PipelineUiControllerTest`, `PipelineExplorerRenderTest`,
  `template-explorer.test.mjs`.

#### 4.3a The divider handle — the pane explorers (104)

> **Since #350 the pipelines page, and since #398 the templates page, have no pane, divider or
> drawer** — §4.3a and §4.3c describe the pane the SCHEDULES page (§20) still draws, the one page
> explorer left; the lake-table tree (§4.5) still inherits the pane class. The shared
> `dp.pane.explorer-tree` key keeps its name.


The tree pane's width was a stylesheet's guess, and it was re-guessed three times: `clamp(260px, 30vw, 480px)` left the tree 14% of the owner's 3491px window, `26vw` uncapped gave it 908px for a column of short names, and the divider itself then had to be drawn. The pane did carry the browser's native `resize: horizontal` — at the pane's **bottom-right corner**, which is where nobody looks for a divider, so every round was spent editing CSS instead.

The native resizer is gone. In its place, the same `static/js/splitter.js` the editor's dock uses (§4.4), bound to a `role="separator"` handle overlaying `.tplx-detail`'s left border — the pane boundary the eye already reads as the divider. Since 141 the grip is a **visible pill at rest** (`3px × 40px`, `--border-default`; brand on hover/focus/drag) and every handle carries `Drag to resize · double-click to reset` as both its tooltip and its `aria-description` — the grip was transparent at rest until then, and a resize nobody can see is a resize nobody uses (the owner never found it):

| | |
|---|---|
| Pointer | Drag the handle. `setPointerCapture`, so the drag survives leaving the 12px strip; `touch-action: none`, so a touch drag is a resize and not a scroll |
| Keyboard | Focus it (it is in the tab order) and use ←/→: ±16px, Shift ±64px; Home = floor, End = ceiling. `aria-valuemin`/`max`/`now` move with it |
| Reset | Double-click — the remembered width is forgotten and the pane falls back to `clamp(260px, 22vw, 40rem)`, which is the stylesheet's own default and not a number JS repeats |
| Bounds | Floor 260px, ceiling 40vw. A window that shrinks past a remembered width pulls it back in |
| Memory | `localStorage` key `dp.pane.explorer-tree`, **shared by the pane explorers** (Schedules and the lake-table tree) — the width follows the user between them |

The size lands as ONE CSS custom property, `--tplx-tree-w`, written on `<html>` (not on the pane: `#app-main` is the boosted-swap target, so a property on the pane would die with every navigation). `template-tree.css` derives both the pane's `width` and the handle's `x` from it through a single `--tplx-tree-size`, with the default as the `var()` fallback — which is what makes "reset" mean "remove the property". The module is a parser-blocking script above the markup, so a remembered width is the width of the first painted frame; `ExplorerPaneGeometryBrowserTest` measures the default at four viewport widths, the drag, the reload and the reset, and holds the CLS budget.

`InlineWidthAuditTest` is unaffected and stays green: it bans a static `style="…"` attribute in a template, and this is a script setting one custom property on the root — the shape its KDoc now names as the allowed one.

The datasource lake-table tree (§4.5) uses the same pane class and so inherits the remembered width, but has no detail pane beside it and therefore no handle.

#### 4.3b The detail pane — reading and acting (106, owner-approved mock 2026-09-08; REMOVED by #401)

> **#401 removed the pipeline detail pane**: the fragment (`partials/pipeline-detail.html`), its
> route (`GET /partials/pipelines/detail`, off the `pipeline.read` §7.6 row in the same commit)
> and the explorer legs of the lifecycle dialogs — no page has rendered the pane since #350 (the
> catalog's rows open the workspace, §4.4, whose header and tabs carry everything below, #349's
> capability map). `PipelineBrowseModel.fillDetail` and its region fills left with it. The text
> below is the record of what the pane was; the runs and usage fragments it lazy-loaded live on
> as the workspace's tabs.


The pane used to be a stack of four tables (badges, Settings, Parameters, Versions) in one
column. The owner's mock, layout B, splits it into what a reader does and what an operator
does, and both explorers render the same shape.

**1 — Header.** The folder path is an EYEBROW (`.tplx-detail-path`, mono, muted) and the leaf
name is the `h2`; the full path still rides on `title`. The actions sat on the right: **Open**
(primary, the canonical read workspace — #348) plus the lifecycle verbs 101 shipped, opened as
§4.3d dialogs since 102 —
`Release v<n>…` when a draft exists, **Discard v<cur>…** when the current version is released,
**Purge pipeline…** only in the `{D}` shape, **Switch…** at ≥ 2 live eligible versions, at most
one destructive among them. Each hx-get its dialog partial into `#px-dialog` — until #401, which
removed the pane and with it these headers and their target (the dialogs now open only from the
workspace's Versions tab, §4.4, and answer `HX-Redirect` only); the POST went
to the dialog's own partial route, which calls the service 101 wired and answers Shape A / Shape
C (§5.1) — the plain `window.confirm` strings 106 shipped on `data-confirm`, and the `fetch`
path that drove them, are gone.

**2 — Reading column** (`.tplx-read`, the wider one). *Overview*: the chips (`v<n>`, the draft /
released state, node count, `tempdb · <engine>`, datasource count), the description at a **78ch
measure**, and a key/value strip — Datasources (name + dialect, each linked to its detail),
Templates (`id@version`, each linked into §4.6), Created (when · by whom), Last run (status chip
· duration · rows · relative time, linking to the execution). *Parameters*: name / type /
default with a `required` chip, a table **as wide as its content** with its own 22rem scroll.
The one-row **"Settings" table is gone** — the staging engine is a chip.

*Checks* (140): when the working version's body declares `checks[]`, Overview gains a Checks
sub-section — the count in the header note, a **Run checks** button (the execute floor; hidden
from a credential that cannot execute), and the list itself, lazy-loaded on paint
(`hx-trigger="load"`) from the read-only partial:

| Fragment | Route | Model |
|---|---|---|
| `partials/pipeline-checks` | `GET /partials/pipelines/{id}/versions/{v}/checks` (read, session) | the version's check definitions, each with its latest `pipeline_check_runs` row — "not run" when never run |
| same, refreshed | `POST /partials/pipelines/{id}/versions/{v}/checks/run` (execute floor, session) | a FRESH run on the shared `PipelineCheckRunner`, `via = ui`, the session principal, the request's correlation id |

One row per check: the verdict chip in the run-status idiom (`app-chip-ok` pass /
`app-chip-bad` fail / `app-chip-warn` error), the name, its datasource, the expectation as one
compact clause (`= 74.62 ± 0.01`, `74 ≤ x ≤ 75`, `6 rows`), the SERVER's observed value (an
error names its reason instead — `observed` exists only because a run produced it), and
when · via. Both routes are session-only (`auth.session.required` for a key — the REST twins
of rest-api §5.16 are the key surface); the editor's Details pane (§4.4) shows the same count
read off the loaded body JSON and lazy-loads the same partial for the working version.

There is deliberately **no "via UI / MCP / API" chip on Created**, though the mock shows one:
nothing records the surface a pipeline CREATE arrived on. There is no `pipeline.created` audit
event and `pipelines` carries no `triggered_via`, so the chip would have to be inferred. It
returns when a create is audited with its surface.

**3 — Acting column** (`.tplx-act`, narrower, its own scroll). ONE tabbed card:

| Tab | First paint? | Fragment |
|---|---|---|
| Versions | yes | rendered inline with the detail — `partials/pipeline-versions` |
| Runs | on the tab's first click | `GET /partials/pipelines/{id}/runs` → `partials/pipeline-runs` |
| Usage | on the tab's first click | `GET /partials/pipelines/{id}/usage` → `partials/pipeline-usage` |

Versions are the **house table** (§3.7; #349, spec §4.4): `partials/pipeline-versions.html` is ONE
fragment — a FIXED `dt-h-sm` table, `dt-fit`, `tr[data-version-row]` rows — rendered here in the
explorer's detail pane and in the workspace's Versions tab alike (`PipelineExplorerRenderTest` pins
`<table class="ds-table">` and the absence of the old compact rows). The readable-width lesson
stands: the 106 round's first 1440px screenshot showed a version row's meta cell set one character
per line beside three ghost buttons in an `auto` track, so `ExplorerDetailBrowserTest` asserts the
widest cell's READABLE WIDTH and line count at 1440/1920/2560 rather than its presence. Each row states version · status · created · who · runs, marks the sticky
pointer as `current` (D60 — which is not "the latest released"), and carries a per-row overflow
menu (⋯) with exactly the verbs §3.5 allows that row's status — DRAFT → Release, Purge;
RELEASED → Discard, Switch-to (when not current); DISCARDED → Restore; a verb the table refuses
is absent, not disabled (§4.3d). The open menu is a `popover="manual"` in the browser's top
layer, placed under (or, out of room, above) its ⋯ by `lifecycle-dialog.js` — the tab panel
scrolls, and a menu positioned inside it was clipped whenever the list was shorter than the
menu (owner, 2026-09-12: one version, no menu). It closes on outside click, Escape, scroll,
resize and when a dialog opens.

**Runs** is this pipeline's last 20 executions, so reading a pipeline no longer means leaving for
§4.8 and filtering it back down. Its visibility is §4.8's (#275): an admin sees the
workspace's runs, a viewer or author her OWN runs plus every SCHEDULED run (R3, the same
`findVisible` read), and a promoter — who reaches this `pipeline.read` pane without
`execution.read` — her own only. The template workspace's Runs tab (§4.7) and the search palette's
executions group read the same way. A second surface over the same rows is not a wider
one. **Usage** is the published endpoints serving the pipeline and the live pipeline versions
pinning it, and it runs the **same query 101's discard refusal runs**
(`PipelineRepository.findLiveParentsPinningVersion`, the evidence behind
`pipeline.version.pinned`), so what the user reads before pressing Discard is what the server
will decide on — **for a caller whose lens narrows (a promoter), through both halves of the lens (#340): the parent pipeline's
NAME must be admitted AND only RELEASED parent versions are listed, so a draft's number never reaches her (the query returns
DRAFT parent versions too, and the badge counts only what the tab lists)**. **Dashboards joined the list with #320:** a heading lists the live dashboard versions whose
sources pin a version of this pipeline (`referencing_dashboards` — asked per version through the port
`PipelineService.refuseIfPinned` asks), through the dashboard lens, and each is refusal evidence, so it counts in the
badge. Schedules joined the list with #259: a third heading lists the live schedules
whose `target_ref` names the pipeline — name, a link to `/schedules?id=<id>` and the
enabled/paused/blocked state — read through the scheduler's lensed by-target read
(`ScheduleService.listByTarget`, over the indexed `target_ref` column), so a hidden target's
schedule is absent exactly as §20.1 hides it. A schedule is not refusal evidence — the discard
succeeds and the schedule then blocks (`pointer_null` / `target_not_found`) at its next
occurrence, which is the consequence the heading exists to show — so the tab badge counts
refusal evidence only, and the empty state speaks only when none of the three lists has a row.

`PipelineBrowseModel.fillDetail` supplied every region in ONE call (the method left with the
pane, #401), with the lifecycle flags
computed beside the query that produced the rows — a button is rendered because the server
would accept it, never because a template read a status string. Two web-side joins sit beside
`PipelineNames` for its reason (`dag` and `pipeline-contract` own the stamped rows and neither
may reach into `auth`): `ActorNames` (who made a version — every stamp is a bare `users.id`) and
`PipelineRunStats` (one `GROUP BY`, not one `COUNT` per row).

#### 4.3c Responsive — the pane explorers (106 §B)

The breakpoints are defined ONCE, in `template-tree.css`, and honoured by §4.3 and §4.6:

| Width | Panes | Detail | Notes |
|---|---|---|---|
| ≥ 1440px | tree \| detail | reading `1.35fr` \| acting `1fr`, gap `--gap-lg` | the acting card sticks and scrolls inside itself |
| 1100–1439px | tree \| detail | the columns STACK (reading, then acting) | the tab panel's ceiling becomes 28rem |
| < 1100px | the tree is a **DRAWER** | full width | a "Browse" button in the page header opens it as an overlay over the app's backdrop idiom; selecting a leaf closes it and loads the detail; the header's actions wrap under the title; tables scroll inside their cards, never the page; 104's divider handle is hidden — there is no boundary for it to sit on |
| < 640px | — | — | chips wrap, the key/value strip is one column, the tab labels drop their counts |

The tree's WIDTH inside its pane stays 104's (`--tplx-tree-w`, its handle and its clamp); 106
decides only whether the pane is a column or an overlay. `ExplorerDetailBrowserTest` measures
all seven of the owner's widths — 390, 768, 1100, 1440, 1920, 2560, 3491 — for document
overflow, the two-column vs stacked geometry, the drawer's open/close, and a layout-shift
budget of 0.05 on a selection.

**The shell at 390px (re-measured by #573, 2026-10-18):** the document overflow 106 recorded
below 768px (dashboard 154px, executions 139, pipelines 237, templates 257, datasources 275,
api-console 75 — the full-width rail beside the top bar) is gone since #554 took the menu and the
browser out of the grid on a phone: all six screens measure **0px** at 390, before and after
#573's 20px phone band. `MainFrameBrowserTest` reads them; 106 still asserts that the explorer
REGION fits its own box at 390 and that nothing inside the detail sticks out of the detail.

#### 4.3d Lifecycle verbs — the two workspaces (102; the explorers' legs retired by #401 and #398)

Every version-lifecycle verb 101 shipped ([Versioning §3.5](/docs/versioning.md#35-the-lifecycle-table))
is reachable from a **confirm dialog**, one partial per verb, opened into a single per-screen
container — `#tx-dialog` (the template workspace, §4.7 — since #398 the template dialogs' ONLY
container; the explorer's pane and the editor page it replaced are gone), `#pe-dialog` (the
pipeline workspace, §4.4 — since #401 the pipeline dialogs' ONLY container; the explorer's
`#px-dialog` left with its pane) — emptied on close, `Escape` closed, focus
landing on the first control, the destructive button `ds-button-danger` and last in tab order.
The exemplar is §4.5's delete dialog (094 §B): the question is asked BEFORE the button exists,
the refused branch renders NO button, and the POST re-runs the guard because the screen is
never the authority. A dialog route is session-only (`LifecycleVerbs.requireSession`) — an API
key is refused `auth.session.required` exactly as the REST verbs refuse it; the dialogs call the
same services 101 wired, never the REST controllers over HTTP and never a second copy of a guard.

| Verb | Dialog (`GET`, into the container) | What it shows before its one button | Shapes that render it |
|---|---|---|---|
| Release | `…/lifecycle/release` | the draft's number, who last wrote it and when, every template pin with its status — a DISCARDED or MISSING pin is refused colour, says "release the template first", and the button is NOT rendered; else "Releasing makes v`<n>` the current version and locks it." **A DRAFT pin (142)** renders in a consent group instead: "Also release these `<N>` draft templates with the pipeline", a checkbox `releasePinnedTemplates` CHECKED by default, one row per pinned version naming the template, the version and — from the used-by service — "also pinned by `<K>` other draft pipeline(s)" (the promoter is releasing a shared object). Checked, Release is enabled and the POST cascades (versioning §5.3); unchecked, `lifecycle-dialog.js`'s consent arm withholds the submit and the rows fall back to the refused wording ("release the template first" — pure CSS `:has()`, no script), so the order rule stays visible. The reload's flash then names what was released: "Released. Also released: `<template@version>`, …" — SERVER-DERIVED (#407): the release POST holds the cascade's own list once in the actor's session (`ReleaseFlash`), and the workspace GET the `HX-Redirect` lands consumes it (a crafted `?ok=`, another page, another actor or a minute gone by renders only "The draft is now the current version and is locked, and the draft templates it pinned were released with it."). When the draft declares `checks[]` (140): the dialog RUNS them as it opens (`POST …/checks/run?footer=release` on `hx-trigger="load"`), the Release submit starts DISABLED, and the run's own fragment decides the footer out-of-band — all pass enables Release; anything short of PASS keeps it withheld; both footer submits are consent-gated too and offers the **Override** disclosure: a required ≥ 10-char `overrideChecksReason` textarea (armed client-side at 10 trimmed chars by `lifecycle-dialog.js`'s min-chars arm — the dialogs' own layer, since Alpine is the editor's, not the explorers'), copy naming the overridden check ids and that the reason is recorded on the release's audit event, and a "Release anyway" submit riding the SAME release POST. A draft with no checks says "No checks on this version" and behaves exactly as before. **A pin that reads `needs_review` (7e)** — the pinned template version cites a retired learned fact ([Templates §3.4](/docs/templates.md#34-implements-and-drift)) — adds ONE warning row per such pin above the confirm (`[data-release-needs-review]`, a `needs review` warning badge, the pin, and "cites a retired fact: `<fact id>` — superseded by `<fact id>`", or `(retired: <reason>)` for a plain retirement), plus "Releasing is not blocked. Re-cite the successor with templates_update to clear the mark." A WARNING, never a refusal: the confirm is untouched in every footer shape, and the POST's own `warnings` (REST §5.10) come from the same port read. All text through `th:text`. | any with a DRAFT |
| Purge draft | `…/lifecycle/purge?version=v` | the version and its execution count; exact parent pipeline names, versions and nodes and dashboard names, versions and statuses appear when pinned, with NO destructive form (#462). A sole draft uses stored any-version dashboard pins, otherwise live/exact; parents remain live/exact. Unpinned, the button reads "Purge v`<n>` and `<k>` runs" and needs the typed confirm `v<n>`; POST rechecks pins | any with a DRAFT |
| Discard | `…/lifecycle/discard?version=v` | whether the version is current (then the §3.4 fallback: "v`<m>` becomes current" or "nothing eligible remains — the pipeline will have no current version and its endpoints will answer 503"); parents that pin it (listed, no button) **and, since #320, the dashboards whose sources pin it (`referencing_dashboards`, listed, no button)**; **the schedules that run the pipeline (273)** — from the SAME by-target read the Usage tab makes, lensed for the caller (a promoter's view is R3's; the route itself stays `pipeline.version.manage`) — one row per schedule naming it, its condition (`enabled`/`paused`/`blocked`, the Usage tab's vocabulary) and its stored next occurrence, under "Schedules that run this pipeline — discarding does not pause them: at its next run each blocks (the pointer is gone) until repointed or deleted" (the §5.2 `pointer_null` shape, said before the button); "Discard is reversible — Restore brings it back." | RELEASED rows |
| Restore | `…/lifecycle/restore?version=v` | whether restoring moves the pointer (v > current, or current is NULL); "Restoring makes v`<n>` a live release again." | DISCARDED rows |
| Purge entity | `…/lifecycle/purge-entity` | only in the `{D}` shape (else the `last_release` branch, no button); **since #471, when the sole draft is pinned, exact parent pipeline names, versions and nodes and dashboard names, versions and statuses appear when pinned, with no button** — the same ports the POST's `refuseIfPinned(entityPurge = true)` asks; else the exclusive draft templates the service offers, with a checkbox "also purge these `<k>` templates (they are pinned by nothing else)"; **since #335, beneath the offer, the draft templates the offer KEEPS** — a parameter set or a visualization also pins them (`ExclusiveDraftTemplates.keptIds`, the list the REST response reports as `kept_draft_templates`) — each with the sets and visualizations that hold it, under "`<k>` draft template(s) stay — something else pins them" (nothing kept renders nothing; unlensed, the verb is author-or-above); typed confirm is the entity's NAME | `{D}` |
| Switch | `…/lifecycle/switch` | the live versions as radio rows (RELEASED always; DRAFT under development posture), the current one marked, discarded ones disabled with "restore first"; "endpoints published on this pipeline serve v`<n>` after this." Not authoring-gated (§3.1: the receiver's rollback lever) | ≥ 2 live eligible versions |

**The Release form posts the hash the dialog read (#416).** All four families' Release dialogs
(pipelines, templates, dashboards, visualizations) carry ONE hidden input in the submitted form —
`bodyHash` (pipelines, templates, dashboards) or `body_hash` (visualizations, beside
`release_pinned_templates`) — filled from the SAME draft read that supplies the dialog's version and
last-writer facts, placed before the checks footer on the pipeline dialog so the run's out-of-band
Release / Override submit posts it too. The POST binds it as REQUIRED (a missing value is a 400 at
binding, before any read) and releases AT it: the draft is read only to refuse the no-draft case, and
its CURRENT hash is never the one released. A draft that changed after the dialog opened is therefore
`pipeline.version.conflict` / `template.version.conflict` / `dashboard.version.conflict` /
`visualization.version.conflict` (409, Shape C), and the visualization evidence gate, which judges the
CURRENT body, still answers first when the new draft has no green run. A refused no-draft dialog renders
no form and so no hash. The hash is an optimistic-concurrency token already present in the version
reads, not a credential. `ReleaseDialogHashRenderTest` pins the rendered field per family;
`LifecycleDialogBrowserTest` drives a pipeline dialog stale and sees the 409 with nothing released.

The templates twin is addressed by NAME in query/body ([§9.6](https://github.com/msabiransari/datapipelines/blob/main/docs/template-hierarchy-design.md#96-addressing-the-name-never-travels-in-a-url-path-segment-normative-measured)):
`GET /partials/templates/lifecycle/{verb}?name=&version=`. It has every dialog above except
Switch — templates are pinned by version; there is no served pointer to switch. The pipeline
purge-draft POST rides the **versioned** purge (`DELETE /api/v1/pipelines/{id}/versions/{v}`
beneath): the dialog names an explicit version, so there is no two-writer hash protocol to
honour, and the typed confirm carries the same fact the button states.

**Success is `HX-Redirect`; refusal is Shape C everywhere.** No Shape A leg is left for either
family: #401 removed the pipelines explorer's and #398 the templates explorer's, each with the
pane it re-rendered (the `toast-oob` splice, the `lifecycle-changed` payload and the tree badge
rewrite it drove, the `from` ternaries). A POST that lands answers `HX-Redirect` onto the
workspace's Versions tab (`/pipelines/{id}?tab=versions&ok=…`, `/templates/{name}?tab=versions&ok=…`)
with the layout's flash toast, or — for the verb that removes the whole entity — onto the
family's catalog (`/templates?ok=template_purged`). The page's draft state (`PEDraft`, the
version chips) is embedded across the document, so the honest refresh is the document itself.
Every refusal — `pinned` with its pinners, `last_release`, `not_released`, `not_discarded`,
`not_eligible`, `not_draft`, `version.conflict`, `authoring.disabled`, `template.in_use` —
arrives as §5.1 Shape C carrying the real 4xx, never a native dialog.
**#395 (with #350), then #401, then #398: on both workspaces that is true of EVERY verb.** Release
and the purges always answered the redirect; Switch, Discard and Restore (the Versions tab's row
verbs) used to post to the explorer's `#pipeline-detail` from the workspace too — an element the
workspace does not have, so htmx refused the swap target and no request was ever sent. Their
dialogs are `from`-aware (#395: `from=editor`: no target, `hx-swap="none"`, a hidden `from`),
and their POSTs answer `HX-Redirect` onto
`/pipelines/{id}?tab=versions&ok=discarded|restored|switched` with the layout's flash
(`Version discarded` / `Version restored` / `Current version switched`); the toast states what
happened and the reloaded Versions tab shows the pointer the service left. The templates'
dialogs carry the same `from` (#398: the workspace sends `editor`, and an absent value resolves
there too — the dialogs' one page is the workspace) and add no switch: templates are pinned by
version (§4.7).

**One destructive verb per entity per view** (owner rule). The detail header — and since #395 the
pipeline WORKSPACE's header, now that the explorer pane is gone — shows at most one of Purge
pipeline / Discard / Purge draft — `{D}` gets Purge pipeline beside Release (on the workspace: the
entity dialog with its exclusive draft templates and #335's kept list, in place of Purge draft); a released
current gets Discard; a draft with no header-destructive gets Purge draft; everything else
lives on the row it names. The version rows (§4.3b's compact list) carry their verbs in a
per-row overflow menu (⋯): DRAFT → Release, Purge; RELEASED → Discard, Switch-to (when not
current); DISCARDED → Restore. A verb the §3.5 table refuses for that row's shape is NOT
rendered — absent, not disabled — so the table and the screen cannot disagree; the POST still
re-runs the guard.

**No native `window.confirm` / `window.alert` anywhere in the lifecycle UI** — a static test
pins it (the four editor sites and the explorers' verb wiring are grep'd; the `hx-confirm` on
`api/console.html` is htmx's own attribute, not a native call, and is excluded by path).

#### 4.3e Role visibility — every verb, and the role boolean that renders it (114, normative)

**The role decides what is rendered; the server decides what is allowed** (RBAC design §7).
Round 1 (112) made the server right: every verb is refused by role through the two-axis matrix.
This is the other half — a verb the ACTIVE membership's role cannot perform is **not rendered**.

Rendering a verb the server will refuse is not a safe default: it teaches a person the product
is broken, and the refusal arrives after they have already decided to act. Round 112 shipped
exactly that state — a viewer saw Release, Purge, Register and New key, clicked, and got a 403
with no explanation the screen could give (`ScopeInterceptor` answers before the handler, and
htmx does not swap a 4xx, so the click simply did nothing).

**Hide, don't disable.** A verb the role cannot perform is absent — no greyed button with a
tooltip; a viewer is not being teased. The exceptions are two, both stated at their sites: the
editor's Release stays *disabled with its reason* when the DRAFT is invalid for someone who CAN
release (a state refusal, and the reason is actionable), and the promotion screen keeps its plan
readable for every role the page admits while naming who can send it (§4.17).

One helper answers the question for every screen — `web/ui/RoleModel`, stamping `canRead`,
`canExecute`, `canReadExecutions`, `canAuthor`, `canReadPromotion`, `canPromote`,
`canAdminWorkspace`, `isSuperAdmin` and `roleLabel` into the model (the two `canRead*` booleans
are 177's — a page-wide read that follows its own §7.6 row). No controller derives a role boolean
of its own. Every verb control carries a `data-verb` attribute, which is both the tests' hook and
the inventory: `grep -rn 'data-verb=' modules/web/src/main/resources/templates` is the list, and a
control outside a role guard fails `RoleVisibilityRenderTest` — **with no exemptions** since 177:
the dialog fragments a route used to guard alone stamp the role model and guard their verb in the
markup too.

Each boolean narrows for an API-key principal by the key's SCOPE as well as its issuer's role
([Auth §7.6](/docs/auth.md#76-operation-matrix--the-permission-catalog-authoritative) — the credential axis), so a
`read` key is never shown an author's affordances because the person who minted it is an author.

| Screen | Verb(s) | Rendered when | §7.6 permission |
|---|---|---|---|
| Pipelines catalog (§4.3, since #350) | none — the rows open the workspace | — | `pipeline.read` |
| Pipeline workspace (§4.4) | Purge draft, Release; Purge pipeline in the `{D}` shape (#395); the Versions tab's Discard, Restore, Purge | `canAuthor` | `pipeline.version.manage` / `pipeline.release` / `pipeline.delete` |
| Pipeline workspace (§4.4) | Switch served version (the Versions tab's Switch to) | `canAuthor` (O-1 collapsed onto the author with D8) | `pipeline.switch_version` |
| Pipeline editor | Execute, Cancel | `canExecute` — **viewer-level** (D3); never the promoter (D5) | `pipeline.execute` / `execution.cancel` |
| Templates catalog and workspace (§4.6/§4.7) | Create, Edit, Discard, Restore, Purge, Release | `canAuthor` | `template.create` / `template.update` / `template.version.manage` / `template.delete` / `template.release` |
| Template workspace (§4.7) | the Source tab's Edit, and the Render tab's context form and **Preview** (143, #398) | `canAuthor` — everyone else reads the working version in the read-only pane | `template.update` / `template.render` (the preview POST) |
| Template workspace — the script face (§4.7, 7d) | **Save draft** (`script-save`), **Run suite** (`script-run-suite`), the four editable panes | `canAuthor` **and** the displayed version is the working DRAFT — everyone else (and an author on a released version, who gets **Edit**) reads the four panes as `<pre>` blocks | `template.update` / `template.evaluate` |
| Shell (§3.4) | the **Admin** item (143) | `navAdminUsers` (super admin → `/admin/users`) or `navAdminMembers` (workspace admin → `/workspaces#workspace-members`); absent otherwise | `user.manage` / `workspace.members.manage` |
| Shell (§3.4) | the **Executions**, **Promotion** and **Workspaces** rail items (177) | `navExecutions` (= `canReadExecutions`), `navPromotion` (= `canReadPromotion`), `navWorkspaces` (workspace admin, super admin, or a principal with no workspace — the no-workspace page is the one screen that explains their state) | `execution.read` / `promotion.read` / `workspace.read` |
| Shell (§3.4) | the header search (161, #155) | every role — a READ over what the session's workspace already shows; each result is a link to a page the destination screen's own guards govern | `pipeline.read` |
| Dashboard (§4.2) | the Recent executions panel (177) | `canReadExecutions` — the promoter's dashboard does not draw it; the stat tiles are not a verb and count the promoter's OWN runs (#293) | `execution.read` |
| Every pipeline/template read — the catalogs and the resource browser's trees (§4.3/§4.6), the workspaces' tabs, the header search, the browser header's count (the rail badges before #554), the dashboard tile (178) | not a verb: WHAT the screen shows. A **promoter** sees the LENS — released pipelines and templates newer than the promotion target's, and the endpoints of those pipelines; a hidden object is absent (its URL is the not-found page, its id 404s), a visible object's pending draft is invisible (the detail panes, the editors' read-only view, the version lists, the node-SQL section, the checks pane and the used-by list show the release only — 178b), and when the target cannot be read the lists are empty and say why | the principal's `LensedView`, passed into every read ([Auth §11A.1](/docs/auth.md#11a1-the-404-rule)) | `pipeline.read` / `template.read` / `endpoint.read` (the cell is `lens`) |
| Datasources (§4.5) | Register, Edit, Delete | `canAdminWorkspace` | `datasource.manage` |
| Datasources | **Test** | `canExecute` — the connection test **follows execute** (ratified 2026-09-20) | `datasource.test` |
| Datasource grants (§4.5a) | Grants, Grant, Revoke | `isSuperAdmin` | `datasource.grant` |
| API keys (§4.19) | New API key / Delete / Edit associations / Edit dashboard folders (L5) — a `server` key's Delete: `isSuperAdmin` only (#215) | `canAdminWorkspace` or `isSuperAdmin` | `api_key.create` / `api_key.revoke` (+ `server_key.revoke` for a server key) / `api_key.bind` (179, D17) / `dashboard.key.bind` (L5) |
| Top bar (§4.3e) | MCP key copy / delete-to-rotate | every role, own key (`mcpKey != null`) | `mcp_key.own` (179, D16) |
| Promotion (§4.17) | the page itself | `canReadPromotion` — author, promoter, admins (owner rule 13) | `promotion.read` |
| Promotion (§4.17) | Promote — and, since 143, the whole submission form (Send column, selection boxes) | `canPromote` in the SOURCE workspace; an author gets the plan as a plain table | `promotion.promote` |
| Workspaces (§4.13) | the page itself | `canAdminWorkspace` or `isSuperAdmin` (D13) — the switcher in the chrome stays every member's | `workspace.read` / `workspace.switch` |
| Workspaces (§4.13) | Members: add (with a role), change role (the dropdown's Save — one htmx partial), revoke the member's key (#200, only when one is live), remove, revoke invitation | `canAdminWorkspace`, **in the ACTIVE workspace** | `workspace.members.manage` |
| Workspaces | Display name | `canAdminWorkspace` | `workspace.update` |
| Workspaces | Create, Deactivate, Reactivate, Delete | `isSuperAdmin` | `workspace.create` / `workspace.lifecycle` |
| Execution detail (§4.9) | Cancel | `canExecute` **and** the execution is RUNNING | `execution.cancel` |
| Schedules (§4.20) | New schedule, Edit, Pause / Resume, Unblock, Run now, Delete (and the form's Save, the delete dialog's confirm) | `canAuthor` — the author / workspace admin / super admin set, which is exactly the five write rows (`SchedulesUiControllerTest` pins it); the schedule's STATE then picks among the rendered ones (Pause while enabled, Resume while paused, Unblock and no Run now while blocked) | `schedule.create` / `schedule.update` / `schedule.pause` (pause, resume, unblock) / `schedule.run` / `schedule.delete` |
| Schedules (§4.20) | the run's execution link and its pipeline messages | `canReadExecutions` — a promoter reads the scheduler's trail only | `execution.read` |
| Shell (§3.4) | the **Schedules** rail item | every role — `schedule.read` is every member's (the promoter through the lens) | `schedule.read` |
| Lifecycle dialogs (§4.3d) | the Release / Discard / Purge / Restore / Switch confirm buttons | `canAuthor` (177 — the route already refuses the wrong role; the markup now says so too) | the dialog's own verb's permission |
| Datasource dialogs (§4.5) | Save changes, Delete confirm | `canAdminWorkspace` | `datasource.manage` |
| Shell (§3.4) | the role badge | always | — |

A viewer reaches the PIPELINE editor through the explorer's **Open in editor** link and executes
there (122): the page route's floor is `pipeline.execute` — the permission the screen exists to
perform for its lowest role, D3 — so the verbs this table gives the viewer are actually
reachable, and the read-only line on that screen is 114 §A's. A **promoter** does not reach it at
all (D5: no execute), which is the same answer the rail gives by not drawing Executions. **The
TEMPLATE workspace floors at `read` (143, T315, owner ruling; the editor page became the workspace
in #398):** the tree's and the catalog's links render for every reader, so the page they lead to
must open for every reader — read-only, like the pipeline workspace. `readOnly` is the author permission combined with the version rule
(`TemplateSourceModel.fill` since 7d — moved unchanged out of `TemplateEditorController.fillSource` so the script face's routes share the one rule), on the page and on the source partial; the editable
textarea, Preview, the context rail and Release are an author's; the read-only line renders for a
viewer and for a promoter — a promoter, through the lens (178): a template the lens hides opens as not-found, exactly as an unknown name does. Every version-row Open carries its row's version.

Three rows are worth reading twice, because each is a place a reasonable guess is wrong:

- **Datasource Test follows EXECUTE, not admin** (ratified 2026-09-20). Every role that may run
  a pipeline against the datasource may ask whether it answers — viewer included — and the
  promoter, who runs nothing, may not. Register/Edit/Delete stay the workspace admin's; the MCP
  twin `datasources_test` sits on the same row.
- **Key REVOKE is `view`, not `author`.** Only ISSUANCE carries the author gate (§7.4). Hiding
  Revoke from a viewer would strand a DEMOTED author with a live key they are not allowed to
  withdraw — the opposite of what O-2 is for.
- **Member verbs follow the ACTIVE workspace.** `ScopeInterceptor` judges
  `workspace.members.manage` against `principal.workspace`, never the workspace named in the
  path, so an admin of X working in Y cannot manage X's members until they switch. §4.13's
  member table therefore renders for the active workspace only, and names the others.

### 4.4 Pipeline Editor

Fully specified in [Pipeline Editor spec](/docs/pipeline-editor.md). Only the rows that touch THIS document's shared contracts are noted here:

- **The route is the version-explicit read page (#348), composed by #349:** `GET /pipelines/{id}?version=N&tab=flow|overview|parameters|runs|usage|versions`, floored at `pipeline.read` — the page renders the caller's lens, so a promoter reads admitted RELEASED content and never a draft. An explicit version is shown exactly or answered with the house 404 (never clamped, never re-resolved); invalid version syntax is the house 400. The default is the ACTUAL current pointer (a development-posture current draft shows as the draft it is), then an accessible draft, then a **Choose a version** state over the admitted history — never "latest release", never a hidden pointer. The old `/pipelines/{id}/editor` URL is a compatibility redirect into this page. Entry links preserve the rail through prepared main-content swaps.
- **Six tabs, one page (#349, spec §4.1):** Flow (the graph + the dock) is the default; Overview, Parameters, Runs, Usage and Versions are the reading surfaces. A tab change is NAVIGATION ONLY — it swaps no execution state and cancels no run; the URL carries `tab=` (absent for Flow), and since #402 each user tab change PUSHES an entry of the workspace's own (`workspace/history.js`, §3.2) — Back/Forward re-select the tab in page; re-selecting the active tab mints nothing. The tab set is the closed set the server resolves (unknown → flow; `runs` without the execution read → flow BEFORE any runs read), mirrored client-side in `tabs.js`. **Runs renders — and lazily fetches — only for a caller with the execution read**, and its absence costs nothing: a promoter's strip is five tabs and their dock is Node Details alone, proven on the wire (zero execution requests across every tab). Runs and Usage load ONCE, on the tab's first open, generation-stamped. The Overview pane is client-rendered from the page's ONE lens-filtered workspace block (the admitted history, the record facts, the datasource dialect map across every admitted body); the Parameters tab composes the viewed version's declared schema in the house table with the RUN OVERRIDES as a separate section, reachable beside Execute through the topbar's Run parameters button; the Versions tab composes the explorer's fragment (per-row verbs, dialogs into `#pe-dialog` with `from=editor` — a success reloads the whole workspace). The settings sidebar is GONE — its description and parameters moved to Overview and Parameters, and no second sidebar renders (§4.1's "no duplicate full description/settings sidebar").
- **Overview last run visibility (#392):** Last run is the latest run visible to the caller under the Runs tab's #275 rule; a promoter sees only their own.
- **The viewed version moves IN PAGE (#349, spec §4.3):** the header selector and the Versions tab's Open links apply a version through the admitted REST version read without a document reload — URL (a pushed entry of the workspace's own once the body lands, #402; a refused read pushes nothing), chip, body block, workspace pin, selector marks and Versions-tab marks move as one transition, and the active run keeps its stream and identity. The page/view state key is workspace + pipeline + viewed version + request generation: every in-page read (node SQL, checks, runs, usage) carries the token it was issued under and a stale response — success OR failure — is cancelled at `htmx:beforeSwap` and never paints (A→B→A included; the token rides the REQUEST, since a newer request re-stamps any sink-carried copy). A superseded in-flight read is ABORTED by the one `hx-sync="this:replace"` requester — htmx's default drops a request while one is in flight, and this build's `abort` mode aborts without issuing (both verified in the vendored 2.0.10 source). Run input drafts are PER VERSION within the page: switching schemas cannot reuse another version's fields, revisit restores compatible overrides, and nothing persists (no localStorage, no navigation URL).
- **Back/Forward replay the in-page switches (#402, spec A12):** v1 → v2 → Overview, then Back twice, lands on v2/Flow and then v1/Flow, and Forward replays both — on the LIVE instance (no reload, the same runtime epoch and activation count, one component), without touching an active run's stream or identity. A replay re-issues the SAME lensed version read (a version the read refuses is the house banner, never a body) and never mints an entry. The page's workspace block and the root's `data-active-tab` are kept truthful on every switch (the pin fields, the rows' viewed mark and the active tab; the admitted rows, the Overview facts and the dialect map kept), because they are what a cached restore re-reads: a boosted leave after a switch restores from the cache with its selector intact, and Back from there replays the workspace's entries in the restored instance. An entry carries `version` and `tab` only — never a parameter value.
- **Run facts attach only on their own version (#349, spec §4.2):** the execution tabs (Results | Errors | Events) render only for a caller with the execution read and belong to ONE captured run, named by the identity strip — pipeline, run version, status, execution id and the effective SUBMITTED parameters (the declared keys, never the resolved Context's platform tiers) — with **View run's version** beside it when the page is viewing another version. The graph paint, the node states, the Details pane's run-derived rows (Last run, the measured operation, the calculator Context resolutions, the child-execution link) all gate on run version = viewed version; a v1 run's progress never wears the v2 body. Node Details follows the VIEWED version and stays in the bottom dock beside the execution tabs — there is no right inspector.

- **Rendered for (114):** Release is `canPromote`, Purge draft is `canAuthor`, Execute and Cancel are `canExecute` (viewer-level, D-R3) — [§4.3e](#43e-role-visibility--every-verb-and-the-role-boolean-that-renders-it-114-normative). A viewer's editor LOADS and is read-only: the verbs are absent and one quiet `.app-note` line reads `Read-only — you are a viewer in <workspace>`. No toast: nothing failed.
- **Draft lifecycle actions (102):** the topbar's Release / Discard draft buttons open the §4.3d dialogs in `#pe-dialog` — the release dialog shows the draft's pins, offers to release DRAFT template pins with the pipeline (142, checked by default) and refuses on a DISCARDED or MISSING one; the discard dialog is the PURGE (versioning §5.4 — the pre-102 confirm text claiming "an executed draft is kept as history" was false and is gone). A success answers `HX-Redirect` back to the editor with a flash toast (the page's draft state is document-wide); a refusal is §5.1 Shape C. No native `confirm`/`alert` remains on this screen (the pre-102 `draft.js` carried three — a static test pins the count at zero).
- **Heading composition (#607, the #558 exemplar; shell record §9):** the workspace's top area is the shared resource heading, in this order: the **context line**, the **page title**, the **version/status line**, the **actions** in their own area, then the tabs, then the workspace. `.pe-topbar` is the heading BLOCK (no surface, floor or rule of its own — the tab strip's bottom edge is the one rule under it) holding the overviews' `.app-page-h` row: the title column `.pe-crumb` beside `.app-page-actions`. The context line `.pe-context-line` is the back link to Pipelines and the folder `.pe-crumb-path` at the **Support** role (12px, secondary colour, wrapping). The title is the ONE `h1.ds-headline.pe-crumb-name` at the **Page title** role (24px · 600 · 32.4px leading; 21px at the phone band) and wraps (`overflow-wrap: anywhere`) — never truncated or ellipsised. The version/status line `.pe-version-line` carries the viewed-version chip, the version selector and the read-only note. **The draft state is said once:** the chip says the viewed version's status, so the selector's VIEWED row drops its `· current`/`· draft` suffix (the other rows keep theirs; every row's title keeps the full text — `init.js` `selectorRows`, `workspace-view.test.mjs`). The actions — the lifecycle group, Run parameters, the run status, Execute, Cancel — sit beside the title while both fit and wrap under it when they do not (the title column's basis is 24rem); the tab strip has no inline padding, so the first tab starts at the title's left edge. **Alignment is measured:** at 1512 the heading block's top and the title's left edge equal `/overview/pipelines`' heading top and h1 left (82 = 82, 526 = 526; 388 = 388 with the menu compact) — the title itself sits one context line lower than the overview's h1, which has no context line. `PipelineWorkspaceHeadingBrowserTest` holds the measurements at 1512 (expanded and compact), the wrap from 1280 down to 761 and the author's verb set. The other workspaces copy this block.
- **Measured node operations (149):** while a node runs its card footer shows the measured operation from `node_progress` — Querying / Fetching / Waiting for tempdb / Writing / Committing with the live cumulative count — and the Details pane carries Operation, Progress, Rows, Time in and Commit rows; the a11y node list carries the same as each row's description ([Pipeline Editor §5.3, §8.1, §10.7](/docs/pipeline-editor.md)). The arrows and the write are the next bullet's (151, #127).
- **Dependency arrows are orderings; the write is a port (151, #127):** every `depends_on` entry is one **dependency** edge — "the node it points to waits for the node it leaves", which is all a dependency on a DDL node means too — whose states are static facts about its two nodes: `active` while the target runs (dashed brand, **still**), `satisfied` once the source completed (`--edge-done`), `unmet` once the source failed or was aborted. **No count ever rides an edge** (the pre-151 client copied the producer's `rows_out` onto every outgoing arrow, so one staged table read by two consumers looked like two writes) and **nothing moves along an edge** (the rAF dash flow is retired). What a node writes, and where, is its **output port**: one row on the producer's own card — a stub with the port dot leading into the destination (`tempdb.stg`, `warehouse.facts`, `caller`; a DML statement names its source, no table; DDL, an output-less PIPELINE and a CALCULATOR have none) — driven by the #125 `node_progress` samples through the 149 reducer: `waiting for tempdb connection` (amber, still), `writing · N written` (the ONLY write-flow on the canvas, on the stub), `committing`/`finalizing`, `committed · N rows` / `N written · commit not observed` / `failed · rolled back`, and `one statement` for a CTAS whose write is inside the statement. A lost stream freezes every indicator and appends "— stream lost" rather than inventing an outcome; the recovery poll settles End from the polled status and closes open operations as not observed. The legend gains **Depends on** / **Output write** chips (not aria-hidden — they are the graph's key); the Details pane gains `Depends on` (each ordering with its state) and `Required by` rows; tapping an arrow announces what it means and selects the waiting node ([Pipeline Editor §5.3a](/docs/pipeline-editor.md)). Guarded by `graph-dependency-edges.test.mjs`, `graph-output-port.test.mjs`, `sse-stream-loss.test.mjs`, `PipelineEditorArrowClarityBrowserTest` (one port with `committed · 900,000 rows`, two unlabelled dependencies, a real run) and `PipelineEditorLeaseWaitBrowserTest` (a real wait on one staging connection).
- **Execution boundaries (150, #126; redesigned by 151, #144):** the canvas derives two view-only **Start** and **End** markers from the authored graph — Start connects to every node with no `depends_on`, every leaf connects to End. Since 151 they are **shapes, not pills**: Start a disc with the `play` glyph, End a rounded square with `square`, the word under the shape, in a compact transparent box so nothing about them reads as a card. **Start is the run button** for a viewer who may execute — the SAME server-rendered `canExecute` that renders the toolbar's Execute, stamped on `.pe-root` as `data-can-execute` (never re-derived in JS): `role="button"`, focusable, Enter/Space, `aria-label="Start execution"`, filled in the run button's own accent; its activation calls the same `executePipeline()` the toolbar calls — one code path, parameters and draft pin identical. **While the run is active the same disc is the Cancel control (159, #148)**: named `Cancel execution`, the word `Cancel`, the toolbar Cancel's square glyph and danger fill under the running pulse, never `aria-disabled`, and its activation calls the same `cancelExecution()` the toolbar's Cancel calls — the mirror of Execute → Running… + Cancel; on any terminal state it is Start again. A viewer who may not execute sees the plain marker in every state (reading `Running…` while the run is active). A press on the disc is a button press, not a canvas gesture: it is stopped before Cytoscape in the capture phase, because Cytoscape's own mousedown re-rendered the disc under a held button and a hand's click then never became a click (the cause of "Start does nothing" on the live editor); keyboard focus stays on the disc across its re-renders. In a browser session every workspace member may execute (D-R3), viewers included; a render without the right draws a plain outlined disc (`role="img"`, no affordance — `RoleVisibilityRenderTest`). They are still not executable NODES — no authored JSON, no NodeType, no statistics, unselectable, nothing to open, synthetic ids that cannot collide with an authored id (the kind is data). **End is never a trigger**: a muted outline while idle, then the authoritative outcome's fill and word — `Finished` (success) on `pipeline_completed`, `Failed` (danger) on `pipeline_failed`, `Stopped` (warning) on `execution_aborted` — with the run clock's elapsed time under it; never a node event, so one finished branch never reads as a finished execution, and eligible roots (execution started, node not yet) show no `active` connector until their `node_started`. Boundary connectors are their own kind and never labelled; the minimap keeps the two silhouettes; `#cy-canvas` is `role="group"` (an image role would hide the button from assistive tech). A cancelled-or-never-started node ends Aborted beside them (#135). ([Pipeline Editor §5.3b](/docs/pipeline-editor.md); `graph-markers.test.mjs`, `PipelineEditorStartMarkerBrowserTest`, `PipelineEditorBoundariesBrowserTest`.)
- **The v2 canvas (080, owner-approved mock):** dotted-grid stage, the mock's node card (type-accent icon tile, id + eyebrow, mono facts, footer state + run numbers), bezier edges with three states, minimap, legend, controls and the fit-that-never-zooms-in ceiling. Its tokens are ONE block at the top of `app.css` (`/* app tokens (080): node-type accents */`) — the five node-type accent pairs plus `--brand(-soft)`, `--border-faint`, `--grid-dot` and the `--edge*` strokes — bridged to design-system tokens so every theme re-skins it. 079's shell v2 is told the block exists and must not redeclare it.
- **The dock (080):** Details | Results | Errors | Events — the 065 inspector overlay is a tab now (owner ruling 2026-09-05), the dock is always present, the chevron collapse is the only contraction, and there is still no close. **Notifications:** unchanged in shape — `pipeline_completed` and `execution_aborted` toast via `DpToast.show` (§5.1 Shape D), `pipeline_failed` keeps the modal — but exactly-once is now structural: the 076 afterSettle rescue stacked Alpine components on a history-restored root (one click → N executions → N toasts), and it destroys the stale tree before re-binding (`editor-toast-once.test.mjs`). Every SSE event of every kind also lands in the **Events** tab in arrival order. **The dock's height is the user's (104).** It was a fixed 232px that could only be collapsed and restored — the owner's report was *"it has a fixed height. You can min/max it but cannot change the height. I want it to be flexible and the user should be able to drag the height."* A `role="separator"` handle straddles the dock's top border, a `--border-default` boundary since #609 (drag, or focus it and use the arrows: ±16px, Shift ±64px, Home/End for the floor and ceiling, double-click to reset). Since 141 the grip is a visible pill at rest (`40px × 3px`, `--border-default`; brand on hover/focus/drag) rather than a transparent strip that only lit on hover. The floor is 120px and the ceiling leaves the canvas a 180px readable strip (160 until #615). **On a short window (#615, owner ruling 2026-10-19)** the canvas keeps room for its view controls: `.pe-body` never goes below `--pe-dock-canvas-floor` (180px; the controls are a 159px column 16px off the canvas's bottom edge, kept 4px clear of its top) and the dock's pane row is `minmax(0, --pe-dock-pane-h)`, so an opening dock (a node, a tab, a run's first failure) takes only what is left. The dock also STARTS collapsed when opening it at its default would leave the canvas under `--pe-dock-auto-collapse-below` (320px: the controls plus a 150px drag above the floor), unless a remembered pane height says the person chose one. Collapsed at 1280×720 and 1440×900, open at the 1512×982 reference. Until anything moves the dock (a tab, a node, the chevron, a run) the default follows `.pe-panes` as it resizes — the heading above settles after the first measurement, and the window can change — and then the state is the person's. A dock the start-up default collapsed opens on a run's first results; one the person collapsed stays collapsed (065). `dock.js` `defaultState` decides; `PipelineEditorDockResizeBrowserTest` pins all three windows. The size is one CSS custom property (`--pe-dock-pane-h`) written on `<html>` and remembered in `localStorage` under `dp.pane.editor-dock`; `static/js/splitter.js` is a parser-blocking script ABOVE the markup, so a remembered height is the height of the FIRST frame rather than a shift onto it. Collapse still wins while it is on — expanding restores the remembered height. **The canvas follows:** `.pe-body` is `flex: 1`, so a taller dock is a shorter stage, and graph.js's stage `ResizeObserver` now routes through `handleStageResize` — `cy.resize()` always, re-fit ONLY if the view was still the fit (082/098: fit never zooms IN, and a user who has panned to a corner keeps their view). One path also covers the rail collapsing and the window resizing. The row transition is switched off for the duration of a drag, which is also the only motion `prefers-reduced-motion` users would have met here.
- **The settings sidebar is withdrawn (#349, spec §4.1) — 141's resize contract went with it.** 141 made the 280px settings sidebar the user's width (a `splitter.js` handle, `--pe-sidebar-w` on `<html>`, the `dp.pane.editor-sidebar` key, a 220px floor and a 50vw ceiling, a drawer below 1024px). The owner's workspace ruling of 2026-09-30 withdraws the PANE: the graph owns the available width, and the description and the declared parameters live in the Overview and Parameters tabs (the six-tabs bullet above; [Pipeline Editor §4.3](/docs/pipeline-editor.md)). `pe-sidebar` and the `editor-sidebar` splitter occur zero times in `editor.html`, `pipeline-editor.css` and `init.js`; `PipelineWorkspaceLayoutBrowserTest` pins their absence (no settings sidebar, no second splitter handle), re-reads the dock's drag contract as the composition's control and asserts the tab strip stays reachable while the dock is collapsed. The 141 sidebar-resize suite is deleted with the pane; the dock's own resize (the bullet above) is unchanged and remains `PipelineEditorDockResizeBrowserTest`'s. Since #535 the module carries no wiring for the withdrawn pane either: `static/js/splitter.js` wires the dock and the explorer tree only.
- **SQL section (§8.3 there):** the Details tab loads `GET /partials/pipelines/{id}/nodes/{nodeId}/sql` (a `pipeline.read` read partial, htmx.ajax on selection) and highlights it client-side with the zero-dependency `sql-highlight.js`; the copy confirmation is a live-region announcement plus a 1.5s button-label swap — deliberately NOT a toast (high-frequency, self-evident). CALCULATOR and PIPELINE nodes skip the fetch (client-built evaluation / child mapping).
- **The data blobs cannot break out of their script blocks (185):** the editor's two `<script type="application/json">` blocks (`#pipeline-data`, `#pipeline-lifecycle`) are inserted with `th:utext`, so the controller writes both through `ScriptSafeJson.forScriptBlock` — a closing-tag sequence inside any free-text field the pipeline carries (display name, description, node labels, template names) is escaped and the JSON parses back unchanged client-side; `ScriptBlockUtextAuditTest` holds the document's closed `th:utext` allowlist and `PipelineEditorJsonRenderTest` reads the render back through a real HTML parser.
- **Result grid:** the execution result table is the data table (§3.7) — a frame FIXED by its pane (`dt-fill`): the header and the first column hold while a wide result scrolls both ways, the scrollbar starts under the header, the columns sort (this page) and resize; the bespoke `.pe-result-table` styles are gone. Paging stays client-side cursor paging (the §10.5 contract there).
- **Template reference (§9.4 there):** a node's template is a read-only reference display — `acme/finance/monthly_revenue @ v3`, one line with the FULL reference on `title`, in the Details tab's key/value grid **and** in the server-rendered `partials/pipeline-node-sql` **and** in the `template-missing` empty state. **There is no template picker on this screen**; template selection happens through pipeline JSON authoring, import and MCP. **If a picker is ever added, it reuses the resource browser's REST tree component (§3.4, rest-api §24) — it does not get its own client-side tree.** (§4.6's server-rendered prefix fragment, which this sentence named before, was removed with #466.)
- **The SCRIPT node (7d, #7; [transform-nodes design §9.3](https://github.com/msabiransari/datapipelines/blob/main/docs/superpowers/specs/2026-09-09-transform-nodes-design.md)):** its card wears its own accent (`TYPE_TOKEN.SCRIPT = "script"` → `app.css`'s `--type-script`, a teal derived from the theme's info and success accents — no literal colour) and the sprite's `code` glyph (the rail's template glyph: a SCRIPT is a pinned function), and carries three fact lines: **the language and the pin** — `jsonata · …/order_lines.jsonata @ v3`; the language is the pinned template's `type`, which is not on the node JSON, so the card reads `script · …` until `graph.js` resolves the pin the way it resolves datasource dialects (one `GET /api/v1/templates/versions` per distinct pin, `template.read`, capped at 50; a failure or a lens-hidden version leaves the honest `script`); **what it reads and where it writes** — `2 inputs → tempdb.order_lines`, `→ caller`, or `→ $line_count` for a value-mode node's Context key; **rejects and strict** when set — `rejects → tempdb.order_rejects · strict`. The output is fact line 2, not a port row (a SCRIPT's write is not a `node_progress` operation today). Every value passes through `buildCardHtml`'s escaper (a template name planted with `<script>` renders as text — `graph-script-card.test.mjs`). The **Details** pane lists Template, Language and Mode (both read off the resolved pin — the mode is the CONTRACT's, never the node's; `resolving…` until the lookup lands), Inputs as bound (`orders ← stg_orders · tz ← $org_timezone`), Output, Rejects and Strict, plus the node deadline; no Query Timeout row (a SCRIPT runs no statement — `SettingsRules`' statement types). The definition pane shows the node as a function call — the template, its language and mode, the inputs map, the output and the rejects — escaped by construction; nothing is fetched (a SCRIPT has no SQL to render). A pinned version marked `needs_review` (lane 7e's flag, read off the resolved pin) shows the marker on the card's kind line and in the Details header and rows. The legend names **Script**. Read-only, as every node is. (`ScriptNodeCardBrowserTest` — three SCRIPT shapes saved through 7c's validator.)
- **Failure display (057/T85, re-homed by 065, consolidated by 080):** `node_failed`'s `error` object — the failure record — renders in the dock's **Errors tab**: one entry per failed node, newest last, `node id · code` as its summary line, then the message, correlation id, details, the rendered SQL and the exception chain **root-cause-first**, one Copy button. The 065 per-node inspector section is gone with the overlay — the tab is the record's one home beside the modal's one-line summary. `pipeline_failed`'s execution-level record joins the same list (deduped on node + code + message). `PEErrorDetails.build` remains the one view-model (`sse-node-failure.test.mjs` pins it). Under `error-detail=structured` the SQL and Exception sections are simply absent — no apology. The recovery poll's banner names the error code.
- **Page width (065 → 076 → #573):** the editor renders the same `<main>` as every page, `.app-container.app-main` (§3.1: padded by the band, framed at 1500px), and opts out of the frame only, by `app-canvas` on its root (`.pe-root`), so it keeps the whole track. 065's `fullBleed` model attribute and its `app-main-bleed` class, the editor's escape from a 1600px `--app-content-max` cap on `<main>`, were deleted in 076 (the token name returned in #502 as the measure of the editor's reading panes, `.pe-doc`, never `<main>`'s); `EditorLayoutRenderTest` pins their absence and the shared `<main>` on the editor and on the pipelines list.
- **Editor entry preserves the rail (#460):** the host validates the destination and dependencies before
  the boosted main-content swap. The pipeline runtime's inert catalog and exclusive activation prevent
  Alpine from initializing before its dependencies; cached history mounts once. See Pipeline Editor §4.1.
- **Phone widths (110; the band is the page since #607, owner ruling 2026-10-18): desktop-first by decision, not omission.** At 760px and below (#554's phone band; 768 before) the page is the `.app-wide-screen-note` band — "Open on a wider screen to inspect the graph" (the pre-#348 "to edit" named an authoring act this read page does not carry), the pipeline's name, its viewed-version badge (the same `viewedLabel` the heading's chip reads) and a link back to the Pipelines explorer — and NOTHING of the workspace is laid out beneath it: `pipeline-editor.css`'s phone block sets `.pe-root { display: none }` at 760.98px and below, so no heading, tab or dock control can be clipped, half-reached or scrolled to (before #607 the workspace stayed rendered and scrollable underneath). The band's description wraps anywhere, so a long name never widens the page at 320. No editor script is touched: the workspace still initialises beneath the band, and widening the viewport without a reload (a phone or tablet rotated, a window resized) lays the graph out and fits it through graph.js's stage `ResizeObserver` (the dock bullet's `handleStageResize`); a desktop-to-phone-and-back round trip returns to the fitted graph. `PipelineWorkspaceHeadingBrowserTest` holds 390, 320 and both rotations. The template workspace (§4.6) keeps the 110 behaviour — band above a rendered workspace — until its own heading slice.

### 4.5 Datasource List

| Attribute | Value |
|---|---|
| URL | `GET /datasources` |
| Auth required | Yes (`read` to browse; `datasource.test` to test a connection; workspace-bound create behind the `member-datasources-enabled` gate, global create/manage `admin` — workspaces D8) |
| Purpose | Browse, test, register, EDIT and DELETE datasource connections in the active workspace |
| Design primitives | the data table (§3.7), `.ds-badge`, `.ds-button`, `.ds-modal` |
| JS | `static/js/toast.js` (layout-global: arms every `.ds-toast` appended to `#toast` — auto-dismiss + close) |
| htmx | Yes — test connection button (`hx-post="/partials/datasources/{name}/test"`, `hx-target="#toast"`, `hx-swap="beforeend"` — the result is a §5.1 Notifications toast, never a row swap), search + dialect filter + pager (`hx-get="/partials/datasources"` into `#datasource-list-wrapper`, `outerHTML` — the fragment root carries the id, so the swap target survives every refresh; the pager is the shared §5 fragment; a standing filter that matches nothing offers **Clear filters**, which resets the search AND the dialect and re-fetches the list bare — a dialect-only miss with an empty box is this state too, never "No datasources yet" (#510) — §5.1, #432), register modal (`hx-post` on `/partials/datasources`, `#register-result` target — success is §5.1 Shape A: the success node closes the modal, the refreshed list and the toast ride along out-of-band, no `HX-Redirect` and no page reload). **A table partial travels as a whole `<table>` on any out-of-band path**: a `<tbody>` (or `<tr>`, `<td>`…) carrying `hx-swap-oob` nested in a `<div>` is silently DISCARDED by the browser's HTML fragment parser — table-only tags outside table context are dropped tokens, so the swap "succeeds" with empty content and no error anywhere (030 F-1; §4.10's keys table is the reference shape) |

Content: table of the ACTIVE workspace's datasources (name + `readonly` badge, dialect badge, workspace column — `global` or the bound name, URL, username, **last test**). Per-row "Test" button (`datasource.test` scope) → connection result as a §5.1 Notifications toast; the table itself is never re-rendered mid-interaction. "Register Datasource" button → modal form with: name, display name, dialect dropdown, JDBC URL, username, password, description, and two checkboxes — `readonly` (always settable, [Datasources §5.7](/docs/datasources.md#57-readonly-datasources-flag-semantics-and-enforcement-layers)) and `global` (**admin-only; visible-disabled for everyone else**, workspaces D8: unchecked binds to the active workspace). The register action applies the SAME D8 rules as REST §9.1 (`DatasourceWorkspaceRules` — one component, two surfaces) and crosses the same registry save boundary. A register REFUSAL stays inline in the modal (the screen-local `htmx:responseError` path — the modal must not close over an error, so it deliberately carries no `HX-Retarget`).

**The Tables column (162, #156).** Every row carries a link to the detail page — "Lake tables" for a LAKE datasource (its registered catalog, [§4.5c](#45c-datasource-detail--a-read-only-tables-view-162-156)), "Tables" for every other dialect (its live schemas → tables → columns, same section). It is `data-read`, not `data-verb` — a viewer's row renders it exactly like every other row, the same [Datasource learned facts](#45b-datasource-learned-facts-118) precedent.

**The Last test column (061/T84).** Each row shows the outcome of the LAST connection test against that datasource ([Datasources §8.1B](/docs/datasources.md#81b-the-last-tests-outcome-is-stored-and-listed)): a `ds-badge-success` **ok** or `ds-badge-danger` **failed** badge, the timestamp, and the driver's message in the cell's `title`. A datasource nobody has probed says **never tested** rather than rendering blank, because an empty cell on a health column reads as health. This column exists because LISTING a datasource does not connect to it: on 2026-09-02 this screen showed `sample-trips` as fine while every demo pipeline failed at CONNECT with `password authentication failed` — the screen could be silent but not wrong. Nothing on this screen polls; the column shows what the last test (the Test button, the REST probe, or the startup bootstrap check) found.

The listing is workspace-scoped exactly like REST §9.2 (`listVisible`: active-bound + global, repository-level); a datasource bound to another workspace is absent, and its by-name test behaves as not-found. Rename is not offered — a datasource name is immutable (delete + re-create, blocked while referenced — including by a HISTORICAL pipeline version, [Datasources §6.2](/docs/datasources.md#62-in-use-check-on-delete)). The search covers every column the table renders (the §5.1 Search rule): name and the `readonly` badge, the dialect wire value, the workspace column (the bound name or the literal `global`), the JDBC URL, the username, and the last-test state (`ok` / `failed` / `never tested`, so "show me the broken ones" is a search) — plus `description`, searchable though only the modal shows it.

**The Connection pool section (094).** The register modal and the edit dialog each carry a `<details>` labelled **"Connection pool — defaults"**, collapsed. Expanded it renders the eight tunable HikariCP keys ([Datasources §5](/docs/datasources.md#5-connection-pool-configuration)) — each with its unit, a one-line meaning, and a help line naming the layer the prefilled default came from ("Default 10 — HikariCP's own", "Default 2 — this server's"). `readOnly` is rendered DISABLED, mirroring the datasource's own `readonly` flag: it is §5.6-refused as a pool property, so a free field would be an input whose every value the validator rejects. Refused keys are never rendered at all. **A field left at its default is not persisted**, so a later change to a product default still reaches datasources created through this form. The dialect select re-fetches the section (`hx-get="/partials/datasources/pool-fields"`, `hx-target="#ds-pool-fields"`, `outerHTML`) because the effective default is the DIALECT's to decide. Out-of-range values are refused server-side and render inline like any other save refusal.

**Edit (094).** A per-row **Edit** action (`datasource.manage` — ws admin) fetches the dialog into the screen's single empty `#ds-dialog` container. The `name` and `dialect` are shown disabled — the name is immutable and re-pointing a live datasource at a different driver would silently take every pipeline that references it by name along. A blank secret KEEPS the stored credential (the stored one is never rendered back, [Datasources §7.1](/docs/datasources.md#71-encryption-at-rest)). The `global` checkbox is admin-only and carries a hidden companion field, because an unchecked HTML checkbox posts nothing and an admin must be able to un-global a datasource. Same D8 rules and same `registry.save` boundary as REST §9.4.

**Delete (094).** A per-row **Delete** action opens a dialog that asks the USAGE question FIRST, from the same any-version scan the REST `409 datasource.in_use` uses ([Datasources §6.2](/docs/datasources.md#62-in-use-check-on-delete)):

1. A member on a GLOBAL datasource is told admin is required, and nothing else is offered.
2. In use → the referencing rows, `pipeline › node (v3 released)`, with links. **There is no confirm button on this branch**, so the refusal cannot be clicked past.
3. Unused → a confirm that NAMES the datasource, and says that queries already running finish first (the §5.2 retirement lifecycle is what makes that true).

The POST re-runs the guard regardless: a pipeline can start referencing the datasource between the dialog opening and the button being pressed, and the screen is never the authority. Success is §5.1 Shape A — the success node closes the dialog, the refreshed list and the toast ride along out-of-band. Both dialogs are delivered as a WHOLE backdrop rather than a body swapped into a pre-rendered shell: `.u-backdrop` is `display: flex`, so the dialog is on screen the moment htmx swaps it, with no open() script to fall out of step with the markup, and closing is emptying the container. A 4xx refusal renders inline and does NOT close the dialog — the success node carries a `data-ds-saved` marker the refusal never has, which is the same distinction the register modal draws with `data-error` (022/F9).

**Rendered for (114).** Register, Edit and Delete are `canAdminWorkspace` — [Auth §7.6](/docs/auth.md#76-operation-matrix--the-permission-catalog-authoritative) puts `datasource.manage` on the workspace admin, not on `author`: a datasource is a live database credential. **Test** renders by `canExecute` (`datasource.test` — the connection test **follows execute**, ratified 2026-09-20: every role that may run a pipeline against the datasource may ask whether it answers; the promoter, who runs nothing, may not). An author or viewer sees the list, the badges, the Last test column and Test; a promoter sees no action at all. Register additionally needs the DEPLOYMENT's `member-datasources-enabled` gate — two gates, both required — so a non-admin on a locked-down server sees no Register even if their role would allow it (the demo shape: open datasource creation is an SSRF primitive from the server's network position). The `global` checkbox's admin-only rule is unchanged.

**§4.13's workspaces screen** owns workspace lifecycle; **[§4.5a](#45a-datasource-grants-114)** owns which workspaces can SEE a datasource.

### 4.5a Datasource grants (114)

| Attribute | Value |
|---|---|
| URL | `GET /partials/datasources/{name}/grants` (a dialog into `#ds-dialog`) |
| Auth required | Yes — **super admin** (`datasource.grant`, [Auth §7.6](/docs/auth.md#76-operation-matrix--the-permission-catalog-authoritative)) |
| Purpose | Decide which workspaces can see a datasource at all (RBAC design §4, D-R7) |
| Design primitives | `.u-backdrop`, `.ds-card`, the data table (§3.7), `.ds-badge`, `.ds-empty`, `.ds-select` |
| htmx | Yes — the same whole-backdrop-into-`#ds-dialog` contract §4.5's edit and delete dialogs use (094 §A/§B); each mutation re-renders THIS fragment, so the table and the select stay in step with the rows just written |

**Visibility IS the grant.** Round 1 replaced `datasources.workspace_id` (NULL = "global") with
`datasource_workspaces`: a datasource is registered once — credentials are instance secrets — and
granted to N workspaces. There is no global datasource. A workspace with no grant does not see the
row at all: `datasource.not_found`, never a 403, so the name cannot be probed.

Opened from the datasources list's per-row **Grants** button. It shows every workspace the
datasource is granted to (name, granted by, when), a Revoke per row, and an add form whose
`<select>` lists the ACTIVE workspaces that do not already hold a grant — re-granting is a legal
no-op, and an option whose only outcome is "no change" is noise. The registering workspace's own
grant (created automatically when a workspace admin registers a datasource) is **marked** with a
badge and is still revocable: the REST surface allows it, and a screen that refuses what the
server allows is the mirror of the defect §4.3e exists to remove.

**Why super admin and not workspace admin.** The grant LIST names every workspace on the instance
that holds one, which is exactly the cross-workspace disclosure D-R5 withholds from everyone below
super admin — the same reason `DatasourceGrantsController`'s READ carries the operation too. A
workspace admin still registers datasources bound to their own workspace (§4.5) and gets that
workspace's grant automatically; handing one to somebody ELSE's workspace is this screen.

Every grant and revoke is audited (`datasource.granted` / `datasource.revoked`), which is D-R7's
"every grant is audited" — the row records who and when, and the event records the decision.

### 4.5b Datasource learned facts (118)

| Attribute | Value |
|---|---|
| URL | `GET /partials/datasources/{name}/facts` (a dialog into `#ds-dialog`); the same table inline on every datasource's detail page (`GET /datasources/{name}`, [§4.5c](#45c-datasource-detail--a-read-only-tables-view-162-156)) |
| Auth required | Yes — any member (`semantic.read`): the facts are a READ, and which rows a workspace sees is the store's own predicate, not the screen's |
| Purpose | Show what agents LEARNED about a datasource that its schema could not say — units, time zones, sampling, grain, what coded values mean, joins, caveats — with trust badges ([learned-semantic-layer design](https://github.com/msabiransari/datapipelines/blob/main/docs/superpowers/specs/2026-09-11-learned-semantic-layer-design.md) §7.3) |
| Design primitives | `.u-backdrop`, `.ds-card`, the data table (§3.7), `.ds-badge`, `.ds-empty` |
| htmx | Yes — the whole-backdrop-into-`#ds-dialog` contract of §4.5's dialogs (094 §A/§B, 114 §C.2) |

**Read-only in round 1.** One row per fact, oldest first: the OBJECT it is about (`schema.table.column`), the kind badge (plus a `workspace` badge on a WORKSPACE-scope fact — one organisation's meaning, D-S1), the fact text (plus a `conflict` badge when another live fact of the same kind sits on the same refs — D-S5: both shown, neither wins), the TRUST badge — `observed`/`verified` success, `needs_review` warning, `stale` danger, `asserted` default — with the drift message under it when the read-time check demoted it (§6: "column X no longer exists"), the evidence summary (or "none — asserted"), and the provenance: when, through what (`mcp`/`session`/`api_key`), a `via another workspace` badge when the active workspace did not record it, and the source pipeline as a link ONLY when this workspace can read it (D-S9). Recording, verifying and retiring from the UI are round 2; the empty state says so and names the verbs that exist today (the `semantics_*` MCP tools).

**Rendered for.** The row's **Facts** button is every member's — it is `data-read`, not `data-verb`, because a viewer's datasources screen renders no verbs (§4.3e) and this button changes nothing, exactly like the LAKE row's **Tables** link. The dialog and the detail section render the same fragment (`partials/datasource-facts`) from the same model (`DatasourceFactsModel`), so the two cannot drift. An invisible datasource answers the inline refusal an unknown name gets (the §5.3 gate, before the service runs).

### 4.5c Datasource detail — a read-only Tables view (162, #156)

| Attribute | Value |
|---|---|
| URL | `GET /datasources/{name}` |
| Auth required | Yes — any member (`datasource.read`): a viewer may read, the same floor as the list |
| Purpose | Show every datasource's tables, and every discovered-schema dialect's columns, read-only |
| Design primitives | `.tpl-tree`, `.tpl-level`, `.tpl-folder`, `.tpl-leaf`, `.ds-badge`, `.ds-empty` — the 058/067 explorer's shared classes |
| JS | `template-explorer.js` (unchanged: the `<details>`/`<summary>` disclosure and keyboard nav it already drives) |
| htmx | Yes — one level per request, `hx-trigger="click once"`, `hx-target="next .tpl-level"`, `outerHTML` — the same lazy-per-level contract as the LAKE tree below |

Until this round the detail route served only LAKE; every other dialect's row had no Tables button and no detail page at all. Now every visible datasource has one, and the SAME page branches on dialect:

**LAKE** keeps its round-089 registry tree exactly as before: the dp-lake catalog's registered tables as a read-only namespace tree, one level per request, format and partition-column badges on each leaf, no selection and no form (registration is REST/MCP-only, R10). The section heading and the list's button both read **"Lake tables"**.

**Every other dialect** gets a NEW tree, live through [`SchemaIntrospector`](/docs/datasources.md#7a-schema-introspection) — the same read the REST `/api/v1/datasources/{name}/schemas|tables|columns` endpoints and the `datasources_get_schemas/get_tables/get_columns` MCP tools use, called directly rather than through those `author`-scoped surfaces (a viewer may read this screen). The section heading and the list's button both read **"Tables"**. Three levels, at most:

1. **Schemas**, as namespace folders — omitted entirely on a schemaless dialect (SQLite): the root level IS the tables level, the introspector's own "an empty schemas list is not an error" answer.
2. **Tables**, one schema's own (or, on a schemaless dialect, the whole datasource's), paged 25 at a time exactly like the list itself; each row shows its raw JDBC type (`TABLE`, `VIEW`, …) and expands into its own columns level. Unlike a LAKE namespace folder (which cannot be empty — it is derived from the child that produced it), a real schema can hold zero tables, and renders that as its own empty state.
3. **Columns** of one table — name, canonical type, nullability — unpaged, static rows with no action (a column has no verb on this screen, exactly like a LAKE leaf).

**A failed introspection** (an unreachable datasource, a driver that cannot report its current schema for an unqualified columns read) renders the catalogued §13 code and message INLINE, in the level that failed — never a blank pane, never a stack trace, and never a toast: a toast is for an ACTION that failed, and this is a read the person is already looking at.

**Rendered for.** Read-only by construction on every dialect: no `data-verb` anywhere in the tree, the same role floor as the list itself (a viewer may open it). The list's **Tables**/**Lake tables** button is `data-read`, exactly like the Facts button above. An invisible or unknown datasource name redirects to the listing, on every route this section adds.

### 4.5d Datasources overview (#583)

| Attribute | Value |
|---|---|
| URL | `GET /overview/datasources` |
| Auth required | Yes — any member (`datasource.read`; [Auth §7.6](/docs/auth.md#76-operation-matrix--the-permission-catalog-authoritative) names the route in the `datasource.read` row's Surfaces cell): a datasource has no lens, so every member reads the same granted set. GET only; nothing on the page writes |
| Purpose | The Datasources home page: what the active workspace's admitted set holds, its connection health, its dialects, and where it was added last |
| Design primitives | the §4.25 frame (`overview/family.html`'s loops, the `.app-overview-*` rules, design tokens only), `.ds-badge` for the last-test badges |
| JS | `overview.mjs` (Copy prompt), through the page-assets template (§3.0); no other script |
| htmx | none of its own — every link is a boosted navigation |

**Frame (#638; regions #639).** The page renders the v2 frame's regions (§4.25). The **summary strip** holds one metric, Datasources, with its help "Granted to this workspace." as the note and no dot; View all's badge is the same number. The **analytics grid** holds the two charts as bars panels at the one bar pitch, each with a one-line description — "The last recorded test per datasource, one outcome each." and "Every datasource once, by its SQL dialect." — and an info button opening its own dialog section, "How connection health is counted" and "How dialects are counted", which state the Inventory rules below in the reader's words. **Continue your work** reads "A clear next step for your datasources" and opens on one task row, **Browse datasources** (`/datasources`), above Recently added; the guide's related links close it as one row, "Read through a datasource". **Quick start** reads "Explore a datasource with your agent", one sentence and three short steps — Register (a person), Explore (the agent), Build — with Prepare a starter prompt (the prompt dialog; its text is unchanged) and How it works (the dialog section: what datasources are for, the three detailed steps, and **Humans register datasources**). Nothing it counts or links changes.

**Inventory.** One count tile — the visible set (`listVisible`: active-bound + granted + global, repository-level, §4.5) — and two charts. **Connection health** buckets each admitted datasource by its last test outcome: ok, failed, never tested (semantic success, danger, warning respectively, #642); the bars are unlinked (a bucket is not a filter — the list's search covers last-test state, §4.5) and "never tested" is a bucket, never a blank (the §4.5 Last-test rule). **By dialect** renders one bar per dialect PRESENT, each linking the catalog's existing `?dialect=` filter (the register-row precedent, #186); a dialect nobody holds renders no bar at all.

**Recently added to this workspace** lists up to five GRANTS of the active workspace (`datasource_workspaces.granted_at`, newest first, name ASC as tie-break), each a link to the datasource's detail page. There is no lifecycle badge — a datasource has no draft/release lifecycle — and no "Review drafts": the section's only action is each entry's link. An entry is granted, not modified: the timestamp is the grant's.

**Not a browser family.** The overview is NOT a `browserFamily`: the resource browser and its opener stay hidden (§3.4), because there is no datasources tree — the page is an ordinary section page like the catalog. The rail's Datasources item still lights and the crumb names Datasources: its active rule covers both `/datasources` and `/overview/datasources` inline in the layout, and `AppNav.matches` already reads the overview path as the section's. The menu item links the overview like the five tree families (§4.25 rule 1 as built).

**States.** An empty workspace — the first-run state — renders a "0" tile, zero-width health bars, an EMPTY dialect chart (no bar, no 500: the widths are shares of the tallest of an empty set, taken as zero) and the section note "No datasources have been added to this workspace yet." A read that fails says so with no number (§4.25's rule). The guide (`overview/guide-datasources.html`) fills the frame's fragments: a subtitle, the Quick start steps, an explore-first starter prompt with the register-is-human note in its How it works, and related families.

Guards: `DatasourceOverviewControllerTest` (the model: tile, charts, buckets, recent, fail-closed reads, the empty workspace), `OverviewDatasourcesRenderTest` (the loops: widths, links, escaping, the guide, the empty notes; #639: the v2 regions through the real controller — the metric's note, the badge, both panels' descriptions and dialogs, the task row, the Quick start steps, the prompt equal to its base text byte for byte), `DatasourceRepositoryIntegrationTest` (the partition, ordering and workspace scoping in SQL), `WorkspaceSurfacesE2eTest` (the wire: exactly the admitted set's numbers, another workspace's rows absent), `OverviewDatasourcesBrowserTest` (modal-seeded rows through the real probe; the item lands on the overview; the browser stays closed; View all's badge, the task row, the health dialog opening and closing; Copy prompt with zero requests).

### 4.6 Templates — the catalog page and the resource browser (#398, #554)

| Attribute | Value |
|---|---|
| URL | `GET /templates` (`?q=` — the deep link every "find this template" link uses; the search covers id/path, display name, description and the dialect badge's wire value, the §5.1 Search rule) |
| Auth required | Yes (`read` to browse; `author` to create — the create button is role-gated per [§4.3e](#43e-role-visibility--every-verb-and-the-role-boolean-that-renders-it-114-normative)) |
| Purpose | The **catalog**: one flat, server-paged list of full paths — every template the caller may read when `q` is empty, or the matches of `q` — with the dialect and type filters, and (an author's) the create modal. Each row links into the template workspace (§4.7). The folder TREE is the resource browser's (§3.4). The menu's Templates item lands on `/overview/templates` (§4.25, #579), whose View all, linked metrics (since #599 the summary strip's and its foot's) and Review your drafts lead here — the metrics and the row with `?lifecycle=` (the five offered buckets; `discarded` and unknown values are the house 400) |
| Design primitives | `.ds-input`, `.ds-badge`, `.ds-button`, `.ds-empty`; the rows are `template-tree.css`'s `.tpl-results` / `a.tpl-result`; the create modal is `.u-backdrop` + `.ds-card` |
| JS | the create modal's lifecycle (`/js/template-create-modal.js`: open/close, inline refusal, dialect-conditional-on-type, the script blocks); none of its own otherwise — `tree/sidebar.mjs` owns **Browse folders** (`data-nav-tree-reveal`), which shows the resource browser's Templates tree and puts focus in its search |
| htmx | the search input and the two filter selects re-fetch ONLY the list into its stable root `#template-list-wrapper` (`GET /partials/templates`, `outerHTML`); the resource browser uses REST tree/search (§3.4) |

**#398 (the #396 ruling's first reuse of the workspace pattern):** with the tree in the sidebar,
the page keeps the catalog the pipelines ruling retained (§4.3's owner ruling, read onto
templates) — a flat list, not a second tree and not a detail pane: a template is read,
rendered, evaluated and version-managed in ONE place, its workspace (§4.7). Since 067 template
names are **folder paths** ([Template Hierarchy §14](https://github.com/msabiransari/datapipelines/blob/main/docs/template-hierarchy-design.md)); the
folders are browsed in the resource browser.

- **Separate catalog and tree transport.** `GET /partials/templates` (`template.read`, the lens)
  serves the catalog's flat list — the only presentation it has: the server-rendered `scope=nav`
  and prefix fragments were removed with #466, and a stale `scope` or `prefix` is ignored (the
  request answers the catalog list). The resource browser uses the REST tree/search routes (§3.4,
  rest-api §24), with independent bounded continuation for complete immediate levels and
  name-search matches.
- **Every row is a link** to the canonical workspace `/templates/{name}` — no version: the
  release-first rule (§4.7) resolves it, and a draft is an explicit choice — through prepared
  main-content navigation, preserving the shell.
- **The badges a row carries** (nothing disappears): the type (`sql`/`html`/`jsonata`/`javascript`,
  rendered from `TemplateType`, so the filter and the create modal cannot drift from the enum),
  the dialect when the type carries one, the compact **draft** badge (versioning §7: unreleased
  edits stay visible) and the **needs review** marker (7d renders, 7e computes — a version citing
  a retired or superseded fact is MARKED on read, never blocked).
- **The filters (`dialect`, `type`) are the CATALOG's** (exact matches on the version row; the
  dialect match is repository-level `ILIKE`, so a `sqlite` query finds templates whose names
  never mention it). The resource browser's tree carries its search alone — the filters narrow the
  catalog's list and the tree's folders are the unfiltered lens's answer (§3.4's rule: the browser
  searches, the page filters).
- **No lifecycle verbs.** The catalog renders no verb: Release, Edit, the purges, discard and
  restore are the workspace's (§4.7, §4.3d). Create stays here (Q1(b), owner ruling
  2026-10-02 — the browser keeps authoring templates; the engineer's edit-and-test loop runs in
  the workspace).
- **Create** (`author`/`admin`) is the modal posting to `POST /partials/templates`, §5.1 Shape
  A: the success node closes the modal and the refreshed catalog rides along out-of-band with
  the toast. It keeps its **`type` selector** and makes `dialect` conditional — required for
  `sql`, disabled and absent for `html` and the script types (the control is *disabled*, not
  merely hidden, so it does not post; the controller drops it either way and `chk_type_dialect`
  is the database's backstop). The name field's `pattern` and `maxlength` are **rendered from
  the server's own grammar** (`TemplateNameGrammar`) — never a regex retyped beside it; the
  server validates every write regardless and its rejection is the one that counts (§9.5).
  There is no rename affordance on this form or anywhere else: `name` is a create-time input,
  full stop.
- **A script type (7d).** Choosing `jsonata` or `javascript` hides and disables `dialect`
  (as for `html`) and shows the **Contract / Invariants / Tests** field — three JSON textareas,
  disabled (so not posted) for any other type — prefilled with the design record's §2.2
  example. A script carries its contract, invariants and tests inside its version, one
  empty-input case is mandatory, and the create runs the suite (7b's gate). The blocks bind
  through the same binder the workspace's Save uses (7b's strict deserializer — a typo is
  `template.contract_invalid` `unknown_field`, naming its block); a refusal lands in the
  modal's slot as `[pane] code — message`.
- **Empty and no-match are different answers:** an empty workspace (no `q`) says how templates
  arrive (the agent, or the create form, under a folder); a search that matches nothing offers
  Clear search, which empties the search box and keeps the dialect and type filters: its one
  request carries the two it keeps, so the list is their first page (§5.1, #432); the state is
  chosen by the model's `filtered` flag — ANY standing filter, never `q` alone (#510) — so with
  only a dialect or type standing (box empty), the catalog answers with the no-match state and
  **Clear filters**, which resets all three and fetches the unfiltered first page; a lensed caller
  whose target cannot be read gets the promotion page's sentence (178).

#### 4.6a The resource browser (§3.4's Templates panel; the sidebar tree before #554)

The Templates item links `/overview/templates` (#579), whose **View all** and family links lead the
catalog; on a Templates page the resource browser shows the panel
(`#nav-tree-templates`) mounted with the reusable REST search-and-tree component (§3.4).
The root contains closed folders only; expansion exhausts immediate-child pages. Search
returns all matches and ancestors, initially expanded; clear closes every folder. A leaf
enters its workspace through prepared main-content navigation and is marked `aria-current`
when loaded. User width is authoritative; only the browser's Hide and its preferred width persist (§3.4's storage table).
A leaf whose listed version reads `needs_review` (REST §24's flag, #533) carries the catalog
row's **needs review** marker — the same warning badge, `data-needs-review` and title — after
its version and draft badges, inside the panel's view however long the name (#541); the tree keeps complete levels, so a drift that changed a fact and
not the template shows on the panel's Refresh.
Guards: `TemplateSidebarTreeBrowserTest`, `ReusableRestTreeBrowserTest`, `ShellRenderTest`,
`ScriptImplementsBrowserTest` (the marker).

#### 4.6b What moved where (the §4.6-explorer capability inventory)

Every capability the old two-pane explorer offered has a new home and an executed guard —
"nothing disappears" (#5):

| Capability (old explorer) | New home | Guard |
|---|---|---|
| Folder browse, one level per request, counts, paging, empty/overflow | the resource browser (§4.6a) | `TemplateSidebarTreeBrowserTest`, `TemplateTreeRenderTest`, `TemplatePartialControllerTest` |
| Search (flat full-path list) | the resource browser's search (nav) and the catalog (page) | `TemplateSidebarTreeBrowserTest`, `TemplateUiControllerTest` |
| Dialect/type filters | the catalog's controls (§4.6) | `TemplateUiControllerTest`, `TemplatesGoldenPathBrowserTest` |
| The detail pane's header verbs (Release, Discard, Purge draft/template) | the workspace header + Versions tab (§4.7) | `TemplateWorkspaceBrowserTest`, `LifecycleDialogBrowserTest` |
| "Open in editor" | the workspace IS the destination; the old route redirects | `TemplateEditorControllerTest`, `TemplateWorkspaceRouteTest` |
| Overview card (chips, description, References, created) | the workspace's Overview tab (§4.7) | `TemplateWorkspaceBrowserTest`, `TemplateWorkspaceControllerTest` |
| Used by card (pipelines, parameter sets, visualizations; both lens halves) | the workspace's Used by tab | `TemplateWorkspaceControllerTest`, `TemplateWorkspaceBrowserTest` |
| Versions table (status, in-use phrase, provenance, ⋯ verbs) | the workspace's Versions tab (house table) | `TemplateWorkspaceBrowserTest`, `TemplateLifecycleDialogControllerTest` |
| Source tab (the body, read-only) | the workspace's Source tab | `TemplateWorkspaceBrowserTest`, `ViewerEditorRenderTest` |
| Runs tab (derived runs, #275 visibility) | the workspace's Runs tab (lazy-once) | `TemplateWorkspaceBrowserTest`, `TemplatePartialControllerTest` |
| `needs_review` marker (leaf, row, detail) | catalog rows, tree leaves, the workspace chip | `TemplateExplorerRenderTest` (row), `TreeControllerTest` + `reusable-tree.test.mjs` (leaf, #533), `ScriptFaceRenderTest` (chip), `ScriptImplementsBrowserTest` (all three) |
| The create modal | the catalog page (Q1(b), unchanged verbs) | `TemplateCreatePartialTest`, `TemplateCreateScriptTest` |

### 4.7 The Template Workspace

| Attribute | Value |
|---|---|
| URL | `GET /templates/{*name}?version={N}&tab={source\|overview\|render\|runs\|used-by\|versions}` — the name IS the id, the folder path; it travels in the path HERE only (the capture-everything variable; `TemplateWorkspaceRouteTest` pins the routing: a dotted, multi-segment name captures whole and the literal `/templates` and `/templates/editor` routes win). Everywhere else the name stays a query parameter (§9.6) |
| Auth required | Yes — the page floors at `read` (`template.read`, the pipelines workspace's rule): the screen's lowest role reads it. Edit and Save post to `template.update` routes; Preview posts to `template.render`; Run suite posts to `template.evaluate`; the lifecycle verbs are their own rows — [§4.3e](#43e-role-visibility--every-verb-and-the-role-boolean-that-renders-it-114-normative) |
| Purpose | THE place a template is read, rendered, evaluated and version-managed: version-explicit (the current release by default; an explicit version never falls back; a draft is explicit and visibly a draft), tabbed, with nothing of the old explorer or editor missing (§4.6b) |
| Design primitives | `.ds-card`, `.ds-button`, `.ds-badge`, `.ds-form` + `static/css/template-editor.css` (the source column and the script face, unchanged) and `static/css/template-workspace.css` (the top bar, the selector, the tab strip — design tokens only) — both head-loaded (§3.0 is normative: no page template carries its own stylesheet link). The version selector's viewed row carries the menu's selection expression (§3.1, §2.8; #625): the `--brand-soft` tint, `--text-primary` ink for the label and its `· current` suffix (Minimal `--text-inverted`) and a 3px `--border-focus` left bar from `--app-nav-indicator-w`, which every row reserves transparent so a version switch moves nothing; `TemplateVersionSelectorBrowserTest` measures it on every bundled theme and both halves of auto |
| JS | One `template[data-page-assets="on-mount"]` catalog through the page-script loader (§3.2), once per document: `/js/pipeline-editor/sql-highlight.js`, `/js/template-editor/preview.js`, `/js/csrf.js` (before lifecycle), then lifecycle, script face and lifecycle dialogs; `/js/template-editor/workspace.js` is LAST with `data-page-mount` (`TemplateWorkspaceMount`), wiring each live or restored tab strip once and synchronizing lifecycle's context mode with the visible Key/Value or JSON controls. `/js/template-editor/workspace.js` (the tab state machine — pure, node-tested; the tab strip and `?tab=` URL are its DOM glue), `/js/template-editor/lifecycle.js` (the Render tab's context rows, the Key/Value ⇄ JSON toggle, the preview call), `/js/template-script-face.js` (the unsaved-changes marker, the result scroll), `/js/lifecycle-dialog.js` (§4.3d's dialogs into `#tx-dialog`); the preview output is highlighted with the shared dependency-free SQL tokenizer (032) — the editable textarea is deliberately plain |
| htmx | the version selector and the Versions tab's Open are boosted, rail-preserving navigations (`hx-boost="true"`, since `1d7b8457a`; #632); the selected version resolves server-side and is read-only (R5 by construction, independent of transport). Open in Templates and the back link are ordinary links with no explicit `hx-boost` attribute; the Runs tab lazy-loads once (`GET /partials/templates/runs`); **Edit** posts to `/partials/templates/editor/edit` (`#tpl-edit-refusal`, `innerHTML`; success answers `HX-Redirect` onto the workspace with the draft explicit); **Save draft** posts the face to `/partials/templates/script-face/save`; **Preview** posts to `/partials/templates/render` |

**The resolution order (workspace spec §3.1, read onto a family without execution).** The
caller's lens narrows every read; the house 404 covers every absence the URL can name — an
unknown name, a foreign one, a lens-hidden one, and an explicit version that is absent or not
admitted (a draft asked through a promoter's lens is the same 404 an unknown number is, so a
status cannot be probed by number). Never clamp, never fall back:

1. an explicit admitted version — its own body;
2. the current RELEASE (`findLatest` resolves the served pointer to a live release row);
3. no current release and an admitted draft (the draft-only shape) — that draft, labelled;
4. otherwise the choose-a-version state over the admitted history, or the empty state when
   nothing is admitted — no Edit, no Save, no render affordance until a body is selected.

A malformed `version` is the house 400 (`PipelineWorkspaceModel.parseRequestedVersion`, the
pipelines twin's own function); an unknown `tab` resolves to Source.

**The tabs — the floor (#398; six, the pipelines workspace's shape, regrouped only where the
family differs):**

Keyboard focus on a `.tw-tab` shows one ring: the vendored global `:focus-visible` box-shadow
is reset on the tab, leaving its own outline (#684).

- **Source** (the default): the VIEWED version's body — the source column for `sql`/`html`
  (R5: a selected version is read-only, with its badge and release provenance; the editable
  textarea carries the WORKING draft only), or the **script face** for `jsonata`/`javascript`
  ([4.7a](#47a-the-script-face-7d-7--the-source-tab-of-a-script)); beside it, the version's Imports table. **Edit** is
  the one way out of the read-only view (Q1(b): kept on the working draft's surface, as the
  editor page offered it): it copies the selected version into a new draft — or opens the draft
  that already exists, writing nothing — and lands on the workspace with the draft's version
  explicit. There is deliberately no save affordance on an sql/html column: the draft body is
  written through `PUT /api/v1/templates` (the id is in the body) or the face's Save.
- **Overview**: the chips (`v<n>`, draft/released, `type`, `dialect`, `engine`, the
  `needs_review` marker and the facts it cites), the description, the **References** reading —
  the identifiers the body interpolates, labelled as a derived scan and never as a declared
  contract (a template declares no parameters; only a pipeline does; a script shows its
  contract's declared mode and inputs instead) — and the created provenance plus the
  current/draft pointers.
- **Render** (authors, non-script): the Render Context panel — Key/Value rows and the JSON
  textarea over one underlying value — and the preview (`POST /partials/templates/render` of
  the STORED version the page is viewing; a blank render is "(empty output)"). A reader is not
  offered a control the server would refuse (143): the tab is not drawn, and a `tab=render`
  request resolves to Source before any render state is read. A script has no Render tab
  (its preview would feed a Freemarker render a script does not have).
- **Runs**: the derived fragment (`GET /partials/templates/runs`, lazy on the tab's first
  open) — the recent executions of the pipelines that pin this template. The derivation is
  stated on the fragment: an execution names a pipeline and a version, never the templates its
  nodes rendered, so this answers "has anything that uses this template run lately" and claims
  no more (the fan-out over pinning pipelines is capped, `USED_BY_FANOUT`); the visibility
  rules are the execution-history screen's (#275).
- **Used by**: every pin of any version, from any stored aggregate — pipelines, and since #320
  parameter sets and visualizations, each under its own lens (178b), from the same evidence
  the `template.in_use` refusal names. One row per PIN (two nodes pinning two versions is two
  facts); the header counts objects per kind ("2 pipelines · 1 parameter set", "nothing" when
  none pins it); the per-version "N uses" phrases stay on the version rows, lens-true (#340).
- **Versions**: the admitted history as the house table (§3.7) — version, status badge, created
  (ago · actor · via), the lens-true uses phrase, and the per-row ⋯ menu (Open = the canonical
  with `?version=` explicit; Release, Purge, Discard, Restore by the row's status AND the
  caller's role). **No Switch** — templates are pinned by exact version, so there is no served
  pointer a human would roll back (§4.6 records the absence; `template.switch_version` stays
  REST-only). The header carries the one destructive (102 §B.1): Purge template in the {D}
  shape, else Discard of the resolved release, else Purge draft — beside Release when a draft
  exists, all `canAuthor`.

**State and roles.** A tab change is in-page navigation only (`hidden` attributes; no fetch is
cancelled — a template does not execute). A user switch pushes a tab-only `templates` entry
through `static/js/workspace/history.js`, with the tab always stated in `?tab=`. The first
paint keeps the arrival URL; Back/Forward re-select in the same document without adding entries.
The helper synchronizes htmx's current-path record, so a boosted leave after a switch snapshots
under the current URL and Back is a cache hit with the selected pane and a working tab strip.
One window listener dispatches by family; detached roots or a different pathname hand the entry
to htmx, and a restored root re-arms once (#528). A version switch uses boosted,
rail-preserving main-content navigation (`hx-boost="true"`; #632): every tab's facts
re-resolve server-side for the selected version, independently of transport, which keeps a
stale Overview from lying about a switched version. The resource browser's foreign-response
guard (the REST payload's `view_token` admission,
§3.4, rest-api §24) applies to every workspace navigation as to any page. A promoter's lens: a hidden template, draft or
version answers the family's 404 — never a disabled row; no route returns a row the CSS hides.
Role-forbidden controls are absent (RoleVisibilityRenderTest); the fragments' endpoints
enforce their own permission and lens independently of the page's markup.

**Lifecycle dialogs (102, §4.3d).** Every dialog is addressed by NAME in the query; the
dialogs' GETs carry `from` (the workspace sends `editor`, the surface the POSTs serve), and
every success answers `HX-Redirect` onto the workspace's Versions tab with a layout flash
(`?tab=versions&ok=released|draft_purged|discarded|restored`) — or onto the catalog
(`?ok=template_purged`) when the verb removed the whole template (the tree must lose the
leaf). The pre-read guards are unchanged: a refused branch opens with no button; the POST
re-runs the guard; the typed confirm names the version (the entity purge's names the
template's NAME — §5.1's typed-confirm convention). The explorer's Shape A leg (the
detail-pane re-render and the `lifecycle-changed` badge rewrite) is gone with the pane; the
redirect's fresh page re-reads the tree under the same per-workspace state.

#### 4.7a The script face (7d, #7) — the Source tab of a script

Specified by the [transform-nodes design §9.3](https://github.com/msabiransari/datapipelines/blob/main/docs/superpowers/specs/2026-09-09-transform-nodes-design.md).
A `jsonata` / `javascript` template's Source tab is the **script face**
(`partials/template-script-face :: face`, keeping the column's stable `#template-source`
id): four panes — **Body** (labelled with its language), **Contract**, **Invariants**,
**Tests** — two by two above 1100px, one column below. Each is a plain `<textarea>` with the
create modal's mono class (no editor component, no grammar, no CDN — the record's §11 defers
a code editor). The three block panes are JSON, pretty-printed from the stored version on
every paint **losslessly**: an absent optional stays absent, but a `null` inside the data (a
test row's `"customer_id": null`, a JSON-null expected output) is kept — `ScriptFaceModelTest`
pins the round trip. There is no Render tab and no Freemarker preview (a script renders
nothing; `templates_render` refuses the type).

| Route | Permission | What it does |
|---|---|---|
| `GET /partials/templates/script-face?name=&version=` | `template.read` | the face for a version — the same `TemplateSourceModel` rule as the page decides `readOnly` |
| `POST /partials/templates/script-face/save` | `template.update` | **Save draft** — the four panes through 7b's write path: its strict deserializer, the `TemplateValidator` (the save gate runs the whole suite) and `TemplateDraftService` (the service `PUT /api/v1/templates` uses) under the draft's `body_hash` precondition |
| `POST /partials/templates/script-face/run-suite` | `template.evaluate` | **Run suite** — the panes AS TYPED, nothing written |

**Editable only on the working DRAFT, for an author.** A viewer, a promoter, a RELEASED
working version and any older version picked in the selector render the four panes as
read-only `<pre>` blocks the size of the textareas (never disabled controls). An author on a
released version gets the existing **Edit**, which copies it — body AND the three blocks —
into a new draft (7d fixed `asDraft`, which carried the body alone and made the copy a
`blocks_missing` refusal); so Save always writes an existing draft in place and never creates
one behind the header's back.

**Save draft.** A success re-renders the face over the stored draft — the panes as stored, the
new hash, and a `Saved — draft vN now hashes to …` line. A refusal is retargeted into the
result region only (`HX-Retarget: #tf-result`), so the panes keep exactly what was typed, and
each refusal names its **pane** (a pane that is not JSON; 7b's `unknown_field` by its path;
`syntax_error`/`freemarker_forbidden` → Body; `contract_invalid` → Contract, or Tests for
`empty_case_missing`/`row_case_lists_table`/`expect_shape`; `invariant_invalid` → Invariants;
`test_failed` → Tests), 7b's code and its message and details; a stale hash is
`template.version.conflict` against the whole draft. Save needs a draft
(`template.version.not_draft` otherwise — Edit is the way in).

**Run suite evaluates the panes as typed (owner ruling 2026-09-25).** 7b's save gate runs the
whole suite and refuses any failing case, so a SAVED draft is always green — a run over the
stored version could never show red. Run suite therefore takes the four panes, unsaved,
through 7b's machinery in two passes under ONE `suite-timeout-seconds` budget: the
`TemplateValidator` (exactly what Save would run — a static refusal names its pane and the
suite does not run, as at save; its verdict is the result's "Save would accept / refuse with …"
line, so a green list and a refusing Save cannot both appear), then each case through
`ScriptTestRunner.runCase` (the same runner, pool and per-case limits) for what the gate's
one line cannot say. The result list: `N of M cases pass`, and per case its verdict; a failing
case shows **the first difference's path** (`$.rejects[0].reason`), **expected and actual** as
text in `<pre>`, or the expected refusal and the code the run refused with; **every invariant**
with holds/is false and its message; a case past the deadline says `not run — the suite
timeout was reached`, never silently dropped. After a Run suite or a Save refusal the result
region is scrolled into view (`template-script-face.js` — it lands under four tall panes).
The first keystroke in a pane shows an `unsaved changes` badge: Release publishes the last
SAVED draft, never what is typed.

Every user-supplied string — the panes, case and invariant names and messages, the diff sides,
refusal messages — renders through `th:text`, never `th:utext` (`ScriptFaceRenderTest`
plants markup in each pane). Release and Purge draft are the §4.3d dialogs, unchanged.


### 4.8 Execution History

| Attribute | Value |
|---|---|
| URL | `GET /executions` |
| Auth required | Yes — `execution.read` (D11, 177): a viewer or author sees their OWN runs plus every SCHEDULED run of the workspace (#250 — R3, the same `findVisible` read the REST listing does), a workspace admin (`execution.read_all`) every run of the workspace (endpoint-key runs included), a **promoter is refused by role** (`auth.role_required`) and the rail does not draw the Executions item for one ([§4.3e](#43e-role-visibility--every-verb-and-the-role-boolean-that-renders-it-114-normative)) |
| Purpose | Browse past executions, filter by pipeline/status/date |
| Design primitives | the data table (§3.7), `.ds-badge`, `.ds-input` |
| JS | None |
| htmx | Yes — filters (pipeline, status, date range), pagination (`hx-get="/partials/executions"` into `#execution-table`, `innerHTML`, with `hx-include="#execution-filters"` re-sending the filter form by id; the pager offsets are server-rendered into `hx-vals` via `th:attr` literal substitution) |

Content: table of executions (pipeline **display name** — machine path on hover, T114 —, version **with a `DRAFT` label when that version was a draft when it ran** (versioning §8's derivation, `data-draft-run` for a test to read), status badge, triggered_by, triggered_via badge, started_at, duration). The label matters more since D55: a v1 run is routinely a draft run, so the number alone would not say whether a result came from reviewed content. The execution detail screen carries the same badge beside its `v1`. Clickable → execution detail. This screen deliberately keeps its own `#execution-table` / `innerHTML` / `hx-include="#execution-filters"` contract rather than adopting the §5 outerHTML one — it satisfies every §5.1 guarantee (stable target, controls outside the fragment, spinner, toasts), and the pager's `hx-vals` offsets are server-rendered via `th:attr="hx-vals=|{...}|"` (a plain-attribute `[[...]]` inlining reaches the browser unprocessed — Thymeleaf processes inlining in text nodes, not attribute values).

### 4.9 Execution Detail

| Attribute | Value |
|---|---|
| URL | `GET /executions/{execution_id}` |
| Auth required | Yes — `execution.read` + ownership of the execution (D11: own unless `execution.read_all`; another member's run is the 404, never 403; a promoter is refused by role). Cancelling a running execution is `execution.cancel` (`canExecute`; another member's run needs `execution.cancel_all`) |
| Purpose | View execution metadata, node stats, result, replay events |
| Design primitives | `.ds-card`, the data table (§3.7), `.ds-badge`, `.ds-code-block` |
| JS | Light — result preview pagination if large |
| htmx | Yes — result pagination (`hx-get="/partials/executions/{id}/result?offset=..."`), cancel (`hx-delete="/partials/executions/{id}"` — success is §5.1 Shape A: the cancelled-state badge swap plus an OOB toast; the 403/404/409 refusals are `ResponseStatusException`s answered with full error pages by `UiExceptionHandler` — a recorded gap for partial requests, not a toast) |

Content:
- **Header**: pipeline name + version, status badge, timing, triggered_by + via.
- **Node operations table** (149): what each node DID, from the durable `node_progress` record ([REST API §6.4.9](/docs/rest-api.md#649-node_progress)) — operation, real destination, last observed state, fetched/written counts, elapsed, the commit badge (`committed` — also on a failed node whose commit was confirmed / `rolled back` — a confirmed undo / `not committed` / **`not observed`** — a terminal sample without commit evidence, e.g. a driver that never confirmed the commit before the deadline) and the per-state time share; an operation never observed to end says "(not observed to end)" and carries no commit badge; beyond event retention the card says "No node operations recorded". `partials/execution-node-operations`, derived by `NodeOperationHistory` (last sample per node).
- **Node stats table**: per-node status, duration, rows_out, error — and, for a CALCULATOR node, a **Context** column showing `context_key → computed value` (mono; `node_stats_json` has carried the pair since 072 — the run detail page now actually reads it, which `pipeline-contract.md` and `dag-executor.md` always claimed).
- **Error details** (if failed): the structured failure record (057) — code badge, message, user_message, correlation id, node line, details JSON, doc_url link, the rendered SQL (`:name` form), and the exception chain collapsed root-cause-first with frames in monospace (`partials/execution-error`, shared with the result partial's failure branch). Not a raw JSON dump.
- **Cancel button** (only while the execution is `RUNNING`; `execute` scope + ownership): backed by `DELETE /api/v1/executions/{id}`, moving the execution to `ABORTED` ([REST §10.4](/docs/rest-api.md#104-cancel-execution)).
- **Result panel** — see below.
- **Event replay**: link to `GET /executions/{id}/events` (SSE stream replay).

#### Result panel (cursor-backed, TTL-bounded)

There is **one** result path and this screen uses it: every completed caller result is materialized in Redis and read back through the uniform cursor ([REST §7](/docs/rest-api.md#7-result-delivery)). There is no separate "small result" case, and no claim-check special-casing.

The panel has exactly three states, decided by the cursor response:

| Condition | Panel |
|---|---|
| Execution completed **within** its result TTL | Preview table (first page, `datapipelines.result.page-size-rows`) + pager + download links |
| Execution completed, TTL elapsed (`410 result.expired`) | Empty-state card: **"Result expired — re-run the pipeline to regenerate it."** with a "Re-run" button (`execute` scope). Node stats, timings and errors remain visible — only the rows are gone |
| Execution has no caller node | "This execution produced no caller result." (a pure write-back pipeline is legal and emits no result — [Pipeline Contract](/docs/pipeline-contract.md)) |
| Execution **failed** (`410 result.execution_failed`) | The structured failure record — the same `partials/execution-error` fragment the Error card renders (057): code, message, correlation id, node line, SQL, root-first exception chain. One bare string never substitutes for the record again |

- **Preview / pagination**: each pager click is an htmx `GET /partials/executions/{id}/result?offset=…&limit=…` that swaps the table body; server-side it is the same cursor with the same ownership check. Row order is stable across pages because the result was fully materialized before the cursor existed.
- **Effective expiry** is shown next to the panel title (from the cursor's `expires_at`). Expiry is **fixed at result-write time** — paging through the preview does not extend it, so a long browsing session can hit the expired state mid-way; the panel then swaps itself to the expired card.
- **Download buttons** (JSON / CSV / Arrow) are the *same* cursor endpoint with a different `format` parameter — not three separate mechanisms. They are plain `<a href>` links to `/api/v1/executions/{id}/result?format={json|csv|arrow}` (the download carve-out in §2.1), and they are hidden once the TTL has elapsed.

For anything that must outlive the TTL, the answer is not a longer TTL: write it back with `output.target: "datasource"` ([REST §7.1](/docs/rest-api.md#71-model)).

### 4.10 API Keys — the pointer (091; repointed 179)

**This screen is a pointer.** 179 split the keys in two (D16/D17) and pointed here at the
top-bar chip; keys v2 (233, A15) retired the chip and the login mint, so since 2026-09-25 every
key — MCP keys with the role the creator may give, endpoint and server keys — is created on the
Keys page (§4.19), its plaintext shown once at creation, revoked there to rotate. This screen
says exactly that, reads no keys at all, and links to `/api-keys` only for the roles that reach
it.

| Attribute | Value |
|---|---|
| URL | `GET /settings/api-keys` |
| Auth required | Yes — any authenticated principal (the key VERBS live on §4.19 and §4.3e — [§4.3e](#43e-role-visibility--every-verb-and-the-role-boolean-that-renders-it-114-normative)) |
| Purpose | Point at the Keys page (§4.19) — every key is created there since keys v2 retired the top-bar chip |
| Design primitives | `.ds-card`, `.app-empty`, `.ds-button` |
| JS | None |
| htmx | No |

**Why the route survives.** `AppNav`'s off-rail breadcrumb table and every bookmark point here;
answering them with a 404 to save one template would be a worse deal than rendering a sentence
and the links.

### 4.11 User Settings

**Amended 079 §D** — a two-column card grid at ≥1100px (Profile, Appearance, Session,
Password, API), one column below; `.app-reading` is gone from this screen (§3.1). The API card's
**Role** row (#215 — it was a Scopes row until scopes were removed) is one chip: the session's
role in the active workspace. Appearance gains a Mode row (Light / Dark /
System) alongside the Theme select — **two views of ONE field**, see §3.4 — and a Density row
whose Compact option is rendered DISABLED: the preference has nowhere to live until `users`
grows a column, and a control that silently changes nothing is worse than an absent one. The
`#themeSelect` id is load-bearing beyond this screen: `siteShots`' `app` set drives its dark
pass through that exact select, because it is the control a user has. The API-keys card became
a link to §4.18, and the page-foot Logout button is gone — logging out is a shell action and
lives in the avatar menu, where every screen has it.


| Attribute | Value |
|---|---|
| URL | `GET /settings` |
| Auth required | Yes — any signed-in person, own profile only (no permission requirement) |
| Purpose | Profile info, theme preference |
| Design primitives | `.ds-card`, `.ds-avatar`, `.ds-select` |
| JS | None |
| htmx | Yes — theme switch (`hx-patch="/partials/profile/theme"`, the `<select name="theme">` posts its own value on `change`) |

Content:
- **Profile**: avatar (`profile_picture_url`), display name, email — all read-only here; they are owned by the OIDC provider and refreshed at each login ([Auth §4.2](/docs/auth.md#42-user-provisioning)).
- **Provider badge**: renders the **configured provider's `display-name`** for `users.provider`, resolved from the `ClientRegistrationRepository` at render time — exactly like the login buttons (§4.1). `provider` is free text, whatever registration name the deployment configured ([Auth §4.1](/docs/auth.md#41-user-entity)); no provider name is hardcoded in a template, and an unrecognized value falls back to the raw `provider` string rather than a guess.
- **Theme selector**: dropdown of the vendored design-system themes. On change, htmx `PATCH /partials/profile/theme` → the server **UPDATEs the `users` row** (`theme_preference`) for the authenticated user and returns an out-of-band swap of the layout's `#theme-link` stylesheet element, so the new theme applies immediately without a page reload (all tokens cascade — [Pipeline Editor §3.4](/docs/pipeline-editor.md#34-design-system-acmedesign-tokens)). The confirmation is a §5.1 toast (Shape B — the select fires `hx-swap="none"` and has no content target).
  - **Persisted on the user row, never in session state.** The server is stateless behind a load balancer with no sticky sessions (§2 principle 6) — a session-held preference would be lost on the next request that lands on another instance and would not survive re-login. `users.theme_preference` is nullable; `NULL` means "use the deployment default", `datapipelines.ui.theme` ([Configuration](/docs/configuration.md)).
  - Submitted values are validated against the vendored theme list; an unknown theme is rejected (`400`) and surfaced as a danger toast (§5.1 Shape C — the refusal keeps its status and is retargeted at `#toast`, admitted by `toast.js`'s `bridgeErrors`) — never written through.
- **Session info**: JWT issued at, expires at. "Logout" button → `POST /logout` ([REST §16.4](/docs/rest-api.md#164-logout-browser-session)), a normal CSRF-protected form post, not an htmx swap.

### 4.12 Admin: User Management (admin scope only)

| Attribute | Value |
|---|---|
| URL | `GET /admin/users` |
| Auth required | Yes (`admin`) |
| Purpose | View all users, activate/deactivate, grant/revoke admin |
| Design primitives | the data table (§3.7), `.ds-badge`, `.ds-button` |
| JS | None |
| htmx | Yes — search/pagination (`hx-get="/partials/admin/users"`), activate/deactivate and admin grant/revoke (`hx-patch="/partials/admin/users/{id}/{action}"`, row-level swap), identity reset (#187, its own literal route `hx-patch="/partials/admin/users/{id}/identity-reset"` — `user.identity_reset`, [Auth §7.6](/docs/auth.md#76-operation-matrix--the-permission-catalog-authoritative)) |

Content: table of all users (email, display_name, `is_active` and `is_admin` as `.ds-badge` variants — success/danger for status, primary/default for role — local-access status). Admin can toggle `is_active` and `is_admin` per user, and — for local accounts ([Auth §5A.1](/docs/auth.md#5a1-accounts)) — create local users, reset passwords, disable local access, and clear lockouts.

- Partials delegate to [REST §16.3](/docs/rest-api.md#163-user-administration-super-admin--usermanage) (`activate`, `deactivate`, `grant-admin`, `revoke-admin`), which writes the `auth.user.*` audit events. Every row action keeps its `#user-row-{id}` outerHTML swap and reports the outcome as a success toast naming the action and the user's email (§5.1 Shape A).
- **Create local user** (rendered only when local accounts are enabled): email + optional display name, plus the OPTIONAL workspace and its role (113 §B.3, [Auth §4.6](/docs/auth.md#46-invitations)) — a workspace name field and the same single-select role dropdown the workspaces page uses (D22: `viewer` / `author` / `promoter` / `workspace admin`). When the workspace is named, the new account's membership is written in the same act: no invitation is needed because the user row exists by the time the membership is written ([Auth §4.6](/docs/auth.md#46-invitations) rule 1), and a workspace the super admin cannot add to is refused as a toast note while the USER ROW still stands (the fix is the workspaces screen). The server generates a random one-time password shown to the admin exactly once (out-of-band notice — PERSISTENT and inline per §5.1's hard rule; the success toast only points at it) with `must_change_password = TRUE` — there is no email flow, so the admin conveys it out-of-band ([Auth §5A.1](/docs/auth.md#5a1-accounts)). A taken email answers `409` and an invalid email `400`, both as danger toasts (§5.1 Shape C) — before the toast bridge existed these refusals were invisible: htmx never swapped the 4xx bodies and the screen had no error listener.
- **Reset PW** issues a new one-time password under the same rules (and clears any lockout); **Disable local** clears the hash (account becomes OIDC-only); **Unlock** clears the lockout only. The `Local` column shows `local`, `local · locked`, or `—` (OIDC-only).
- **Reset identity** (#187) — the explicit answer to a refused login with `?error=identity_mismatch`: returns the row to the bootstrap placeholder so the NEXT OIDC sign-in with that email claims it (audited `auth.user.identity_reset`, [Auth §4.2](/docs/auth.md#42-user-provisioning)). Liveness, admin flag, password and memberships are untouched — a deactivated user stays deactivated. Never needed after a mere role change; only when an account must move to a different sign-in identity.
- **When mail is configured** (137, [Configuration §3.27](/docs/configuration.md#327-mail), [Auth §5A.8](/docs/auth.md#5a8-mail-the-welcome-mail-and-the-new-user-notice)) the create and reset responses **do not show the one-time password** — it was emailed to the user, and two copies of a credential are one too many. The same persistent `#admin-notice` box says *Emailed to \<address\> — sending…* and polls its own outcome (`hx-get="/partials/admin/users/{id}/mail/{kind}?act={act}"`, every 2 s, swapping itself) until the claim row is terminal: *sent* (with the first-login sentence) or *failed: \<error\>* (with "reset the password to send again"). A terminal render carries no `hx-get`. The toast says the password was emailed and, as before, only points at the notice. `AdminUsersPartialController` reads `MailProperties.enabled` — one Thymeleaf branch in `partials/admin-user-saved`; with mail off the password renders exactly as above.
- Deactivation copy states the effect window: existing JWTs and API keys stop working within the liveness-cache TTL (~60s — `datapipelines.auth.api-keys.cache-ttl-seconds`, default 60), not instantly and not at JWT expiry. Since 180 the refusal is `auth.principal_deactivated` on every API surface, a page navigation lands on `/login?error=inactive`, and the promotion peer folds a deactivated owner into its one answer ([Auth §11A.3](/docs/auth.md#11a3-deactivation)). Nothing is revoked: Activate restores the same sessions and keys.
- There are no scopes ([Auth §7.5](/docs/auth.md#75-key-roles)): what a person may do is their membership's role in each workspace, and the "grant admin" toggle is the one INSTANCE authority (`users.is_admin`, super admin) — there is no per-user permission editor to build. The list shows people only (`kind = 'human'`, [Auth §4.7](/docs/auth.md#47-key-identities)): a key's identity is managed through its key, and every row action answers 404 for one.

### 4.13 Workspaces (workspaces design §9; members and deactivation rewritten by 114)

| Attribute | Value |
|---|---|
| URL | `GET /workspaces` |
| Auth required | Yes — **a workspace admin's or a super admin's page** (D13, `workspace.read` → workspace admin; [§4.3e](#43e-role-visibility--every-verb-and-the-role-boolean-that-renders-it-114-normative), [Auth §7.6](/docs/auth.md#76-operation-matrix--the-permission-catalog-authoritative)). A viewer, author or promoter is refused by role (`auth.role_required`) and the rail does not draw the Workspaces item for them; what every member keeps is the **switcher** in the chrome (§3.4), whose `POST /workspace/switch` is its own row (`workspace.switch`, every role) and re-issues the session token. A principal with NO active workspace still reaches the page — it renders the no-workspace state below, the one screen that explains their situation. Per-verb on the page: members and the display name need `canAdminWorkspace` **in the ACTIVE workspace**; create, deactivate, reactivate and delete need `isSuperAdmin`. The members section carries `id="workspace-members"` (143): it is where the rail's Admin item lands a workspace admin |
| Purpose | Administer the workspaces you administer: their members and roles, the display name, and — for a super admin — create, deactivate, reactivate and delete |
| Design primitives | the data table (§3.7), `.ds-badge`, `.ds-button`, `.ds-input`, `.ds-card`, `.ds-empty`, `.app-note` |
| JS | One `onchange` submit on the rail switcher (`<noscript>` fallback button included). Nothing else — the flags-era "tick author when admin is ticked" listener went with the checkboxes (177) |
| htmx | **One partial** (177, D22): the member row's role Save posts `POST /partials/workspaces/{name}/members/{userId}/role` and swaps the row (`hx-target="closest tr"`, `outerHTML`) with a success toast out-of-band; a refusal is a toast alone (`HX-Retarget: #toast`) and the row keeps the selection the server still holds. Every other verb is a plain CSRF-protected form post with `redirect:` outcomes (`?ok=`/`?error=` query state), rendered into the layout's `#toast` stack |

Content, in order:

**A create form**, super admins only (D-R11 removed the provisioning modes, and with them
`workspace.creation_forbidden` and the joinable list; 114 removed the stale "provisioning mode on
this server" footnote and the dead join section that survived them). It navigates in FULL —
`hx-boost="false"`, the switch form's rule — because creating a workspace changes the SHELL: the
rail's switcher is filled on every full render, and a boosted swap (which replaces only `#app-main`)
left the new workspace out of the switcher until a manual reload (#170).

**Your workspaces** — name, the role you hold there, the active marker, and the verbs. Every
workspace on the instance for a super admin, with a `inactive` badge and a **Reactivate** verb on
the deactivated ones, because this is the screen that brings one back; a member never sees a
deactivated workspace at all ([Auth §11A.3](/docs/auth.md#11a3-deactivation) — deactivation must not be
a signal anybody can read). **Deactivate**, **Reactivate** and **Delete** are super-admin verbs
(`workspace.lifecycle`); deactivation purges nothing, ever, and Reactivate is one row away,
which is why neither carries a typed confirm. All three navigate in FULL — `hx-boost="false"`
(#256, the create form's rule): each success changes the SHELL (the switcher's options come
from `workspaceOptions`, filtered to ACTIVE workspaces, on every full render), and a boosted
swap replaced only `#app-main`, so a deactivated workspace stayed selectable and a reactivated
one missing until a manual reload. `WorkspacesCreateBrowserTest` extends #170's no-reload
assertion to each verb: deactivate → the option is absent, reactivate → present, delete →
absent. The role label is the membership's ONE role
(`viewer` / `author` / `promoter` / `workspace admin`) printed by the same `RoleModel.labelOf` the
shell badge and the members table use — the row's own word ([`WorkspaceRole`](/docs/enums.md#8c-workspacerole--the-one-role-a-membership-holds), D1).

**The degraded reads (#336).** The members listing and the key-owner read can fail — a metadata
store refusal — and a failed read is a degraded state, distinct from the empty one: the members
section renders a `ds-empty` notice, **"Members could not be loaded"** (`data-members-degraded`)
or **"Key owners could not be loaded"** (`data-key-owners-degraded`), fixed text only, the rest of
the page and the members table unchanged beneath it. The failure's evidence is the controller's
log line (class + SQLState), never the page. A catalogued REFUSAL (`AuthException` — the role and
visibility verdicts) is not a failure: it renders exactly as before, the empty state, because the
role model above has already filtered what this caller sees.

**Members of the ACTIVE workspace** (114 §C.1; the row rewritten by 177, D22; the key state and
verb by #200) — one section: name/email plus the member's KEY state ("has a key" / "no key" —
the login-minted credential's existence, never an id or prefix), **one role dropdown** per member
(`<select name="role">`, the four workspace roles, the member's current role selected) with a Save,
**Revoke key** (only when a live key exists — a button over "no key" would revoke nothing and say
otherwise; the member's session is untouched and their next sign-in mints fresh), and Remove. Each
row is the `partials/workspace-member-row` fragment — the same markup the Save's response swaps back
in, so the page and the response cannot drift. The add form at the foot carries the same dropdown
(defaulting to `viewer`), so a member arrives with the role the operator meant: before 114 it
posted `email` only and every member added from the UI was a viewer. Every verb goes through the SAME
`WorkspaceService` methods the REST surface calls (`addMember`, `setMemberRole`, `revokeMemberKey`,
`removeMember`) — there is no second code path, so the last-admin rule is enforced once.

- Changing a role is a **REPLACE** of one value: the dropdown shows the role the server holds, Save
  posts the selected one, and the swapped-in row shows what the database now holds. A role outside
  the four (a hand-edited form) is refused before the service — a `400` toast on the partial,
  `?error=unknown_role` on the add form — never stored.
- **Nobody administers their own row** (#208, owner ruling 2026-09-22): the signed-in member's row draws no Save, Revoke key or Remove, and the service refuses the three anyway with `409 workspace.self_membership` (banner / partial toast: "Ask another workspace admin"). **A super admin's row reads "super admin"** and carries no role dropdown — the instance authority outranks whatever membership role the row holds.
- **The last admin cannot be demoted or removed** — `409 workspace.last_admin`, rendered as a §5.1
  error toast that names the remedy ("This is the last workspace admin. Give someone else the
  workspace admin role first."). A workspace nobody administers cannot be repaired from inside it;
  the refused row keeps its selection.
- **Only the ACTIVE workspace gets a member table.** `ScopeInterceptor` judges
  `workspace.members.manage` against `principal.workspace`, never the workspace named in the path,
  so an admin of X working in Y had every member form for X rendered and every one of them refused
  — invisibly, because the interceptor answers before the handler and htmx does not swap a 4xx.
  The other administered workspaces are named in an `.app-note` with the Switch that reaches them.
- **Pending invitations** (113, wired at the 113/114 merge): ghost rows under the members
  table — email, an `invited` badge, the role the invitation carries, "Becomes a member at
  first sign-in", and Revoke (`POST /workspaces/{name}/invitations/revoke`, email as a form
  field, `workspace.members.manage` — the same guard as the member rows). Never mixed into the
  member rows. The add form's outcome toast distinguishes `member_added` from `member_invited`
  (no account with that email yet), so "added" is never said of someone who cannot sign in.

The **switcher in the rail** (§3.4) drives the active workspace and carries the role badge; the
screen's Switch buttons POST the same `/workspace/switch`. Expected refusals and successes alike
render as §5.1 toasts; the generic error page is reserved for the unexpected (§6).

**The no-workspace state** — a principal with zero ACTIVE memberships (never added, removed from
the last one, or every workspace they belong to deactivated) gets `workspaces/none` instead of an
empty list: what happened, who to ask, and — super admins only — the create form. It is
deliberately not an error page: nothing failed, and `error/403` would name the wrong problem.
Reachable at the 113/114 merge: `ScopeMatrix.allowed` lets a SESSION through `workspace.read`
with no workspace context ([Auth §11A.1](/docs/auth.md#11a1-the-404-rule)) — "list the workspaces you
belong to" is the one operation that is meaningful with none. Every other governed route still
answers such a principal `404 workspace.not_found` before any handler runs, and a key never
gets the exception.

### 4.14 Change password (local accounts)

| Attribute | Value |
|---|---|
| URL | `GET /settings/password` |
| Auth required | Yes (any authenticated session with a local password) |
| Layout | `layouts/default` for a voluntary change; **`layouts/auth`** when `must_change_password` is set (090 §C) — the controller returns `settings/password-forced`, and both views render one `partials/password-card` fragment |
| Purpose | Self-service password change — and the one screen the §5A.4 forced-change gate lets a `must_change_password` user reach |
| Design primitives | `.ds-card`, `.ds-input`, `.ds-button--primary` |
| JS | None |
| htmx | Yes — `hx-post="/partials/account/password"` (success is §5.1 Shape B, toast-only; failures stay inline in `#password-change-result` via the screen's own 4xx listener) |

Content: current / new / confirm fields with the policy floor stated inline (at least 12 characters, [Auth §5A.5](/docs/auth.md#5a5-enumeration-resistance-and-the-password-policy)). A `must_change_password` user additionally sees the one-time-password warning banner — every other route redirects here until the change succeeds ([Auth §5A.4](/docs/auth.md#5a4-forced-password-change)). An account without a local password (OIDC-only) sees an explanatory note instead of the form. Success is a §5.1 toast (Shape B); failure outcomes are field-level/credential validation and stay INLINE in `#password-change-result` — wrong current password, policy violation, confirmation mismatch — delivered by the screen's own `htmx:responseError` listener, because htmx never swaps 4xx and these refusals deliberately carry no `HX-Retarget` (this is the one screen where Shape B and inline errors coexist); the forced-change gate releases on the next navigation.

**Two views, one card (090 §C).** A user held here by `must_change_password` is inside the authentication ceremony, not on an app page: the §5A.4 interceptor refuses every other route, so the shell's rail would be ten links that all bounce straight back. That user gets `settings/password-forced` and the auth layout; a voluntary change from Settings keeps the shell. The choice is made in `UserSettingsController`, **not** by a conditional decorator in the template — Thymeleaf resolves `__${...}__` preprocessing while PARSING and caches the parsed template, so the first request through would freeze the layout for every request after it (measured, 090).

### 4.15 Marketing site (public)

| Attribute | Value |
|---|---|
| URL | `GET /` |
| Auth required | No — the one public page (with `/site/**` assets); everything else defaults to authenticated |
| Purpose | The product marketing page, written for the BUYER (115): the question, the reviewed answer, and where it goes — hero with the worked example, the seven capability sections with their captures (175), the video slot, benefits, learned context, review, today/planned, FAQ |
| Design primitives | The vendored design system via `/vendor/design-system/**` (the same copy the app serves; the retired `website/` directory carried a second vendored copy) |
| JS | `static/site/js/site.js` — theme toggle + copy-to-clipboard only; fully readable without it |
| htmx | No |

**The screenshots are a command, not a folder** (070 §C, extended 093, 175). `./gradlew siteShots -PshotsUrl=… -PshotsEmail=… -PshotsPassword=…` drives a REAL demo deployment with Playwright and overwrites `static/site/img/**`; `-PshotsHeroOut=<dir>` additionally writes the hero's poster (2400 px wide, device scale 2) and OG (1200x630) copies somewhere outside the app. Three sets: `site` (the default — light theme asserted), `app` (every screen at two widths in both themes), and `home` (175 — the home page's seven capability figures at DPR 2, `-PshotsTheme=dark|light` driven through the settings select and restored afterwards, read-only so it can run against the live deployment; every non-GET request is logged). It refuses rather than photographs: a promotion screen with no usable target, a full-length key still on screen, and — 093 — a pipeline-editor canvas whose node surface resolved to the brand colour are all SKIPPED with the reason printed, because the site's own rule is that a screen which renders broken is reported, not photographed.

**The page shows the captures themselves (169, reshaped 175).** The homepage tells the product story as one section per core capability — connect, agent-authored pipelines, no warehouse, the run's result, the release, the API, workspaces (`#cap-connect` … `#cap-workspaces`) — each with ONE full-width figure from the driver's `home` set: 1440×900 at `deviceScaleFactor = 2` (2880×1800 PNGs, `home-*.png`), photographed in the app's DARK theme on the light page. Three of the seven are the LIVE demo deployment read-only (`home-connect`, `home-agent-builds`, `home-no-warehouse` — the set never executes, publishes, mints, probes or creates there, and logs every non-GET request); `home-run-result.png`, `home-serve-api.png` and `home-workspaces.png` need state a read-only account has no business manufacturing on prod (a fresh SUCCESS execution, published endpoint + keys, two memberships), so they come from a throwaway demo stack — `home-review-release.png` too, whose release dialog is seeded, photographed and never submitted. `/how-it-works#application` keeps the three reserved frames (`#shot-pipeline` → `editor-hero.png`, `#shot-results` → `execution-result.png`, `#shot-release` → `release-step.png`) with the two-column gallery of every remaining `site`-set shot below. Every `<img>` names a packaged file under `/site/img/`, declares its intrinsic size, describes what is on screen in its alt, and lazy-loads except the first capability figure; `SiteRenderTest`'s image guard holds all of it and `SiteSeoMetaTest`'s alt sweep reads the render. Re-running the siteShots command — `-PshotsSet=home -PshotsTheme=dark -PshotsOnly=<names>` when a round only re-took a few — refreshes the page; a hand-taken or retouched picture has no path in.

Content: a single static page (`templates/site/index.html` + `static/site/**`), served by the app since v1.15 — the marketing site and the product are one deployment (owner decision 2026-08-31). The only dynamic fact, the MCP tool count, is baked at render time from `McpToolCatalog` (a compile-time constant — no DB access on any public route). Defence is `Cache-Control: public` on both `/` and `/site/**`, deliberately NOT the login rate limiter (OPEN-ITEMS T46: its `remoteAddr` key is the load balancer's address behind the documented deployment, so a limiter would let one client 429 the homepage). Signed-in users hitting `/` get the marketing page too — no auto-redirect; the dashboard is one nav link away at `/dashboard`. Emergency static fallback: `./gradlew :modules:web:websiteExport` renders the same template with facts baked ([Deployment §6.7](/docs/deployment.md#67-marketing-site--in-product-docs)).

**Since 073 the public site is fifteen pages, not one** (089 added `/dp-lake`). The homepage keeps its `<h1>`; its `<title>`, meta description, canonical and `og:`/`twitter:` tags now come from the `SitePages` registry, which is also what `/sitemap.xml` lists and what the SEO guards measure — one set of strings, three consumers. Each additional page is a Thymeleaf template under `templates/site/`, served by `SitePagesController` in the same shape as `/`: GET-only, anonymous, constant content, short public cache window, no datastore.

**Since 115 the homepage speaks to the buyer, and the engineering has its own page.** The home page's reader is a founder or product owner whose customers are asking to see their data, so it sells the outcome — the sentence, the result table, the URL the app calls — in a fixed section order: hero → before/after → the artifact → verified-by-you → what-you-get (today/planned badges) → who-it's-for → demo → open source → roadmap → FAQ (eight buyer questions). Two house rules hold above the fold: the buyer's vocabulary only — never pipeline, DAG, federated, Iceberg, DuckDB, Parquet, MCP, JDBC or staging (`SiteBuyerLanguageTest` holds the fold from `<main` to the end of `#artifact` to that, and asserts the words landed on `/how-it-works` instead — the mechanism moved, nothing was hidden) — and show the artifact, not the process. Unshipped features, including embedded dashboards, are labelled planned without release dates across the marketing site (#120). The roadmap pages retain their last-updated datelines for content freshness; there is no mocked dashboard screenshot. The engineering sections the homepage used to carry (engine strip, "What's in the box", the security teaser) live on `/how-it-works` under the tutorial (174: the agent loop the page used to open with is retired — the executed eight-step tutorial IS the loop); its FAQ is `SiteFaqsHub.HOW_IT_WORKS`; the home FAQ is now `SiteFaqs.HOME`'s eight buyer questions, which also open `/faq`.

**Since 124 every number the site states is derived, never typed.** `SiteFacts` (`ui/site/SiteFacts.kt`) is built once per request from the code that owns each fact — the engine count and the copy's engine list from `Dialect.entries` (display names from the one `SitePages.ENGINES` map), the tool counts from `McpToolCatalog`, the calculator kinds from `CalculatorRegistry`, the API-key kinds from `ApiKeyKind`, the learned-fact kinds and scopes from the semantics enums, and the demo families' engine counts from the vendored manifests via the handler — and `PublicPage.render` exposes it to every template as `facts`; the titles, descriptions and FAQ answers in `SitePages`/`SiteFaqs` are string templates over it, with spelled-out numbers from one `numberWord()` helper. The ruling behind it (owner, 2026-09-13): **eight engines, not six** — H2 is a legitimate engine an engineer uses like Postgres or MySQL and LAKE is dp-lake, so every dialect has its `/mcp-server/{engine}` page and `SiteEngineFactsGuardTest` holds the page set EQUAL to `Dialect.entries` (a ninth dialect cannot ship without a page, a page cannot outlive its dialect); tempdb is implicit and is never counted or listed. The guard that keeps it true is `SiteHandTypedCountsGuardTest`: it sweeps every site template and every Kotlin file under `ui/site/` and fails the build on a spelled-out or numeric count adjacent to engines, tools, calculators, kinds, node types or error codes outside a `${facts…}` expression — nothing is allowlisted, so a count that is not a fact of the code is removed from the copy (red at base on 40 hits, 11 of them the "six engines"/"eight engines" disagreement itself).

| URL | Template | What it is |
|---|---|---|
| `/mcp-server-for-sql-databases` | `site/pillar.html` | The cluster pillar: the credential problem in the searcher's words, the engines, the governance guarantees, the full MCP tool list |
| `/mcp-server/{engine}` — one per dialect | `site/engine.html` | ONE template over the `SitePages.ENGINES` rows: dialect, driver, license, whether the driver is in the published image, client config, the seeded pipeline that reads that engine |
| `/add-mcp-server-to-claude-code` | `site/add-mcp-server.html` | The four-step client setup — run a server, mint a scoped key, paste one config block, check the tool list — with the 401/403 troubleshooting table |
| `/ai-data-pipeline` | `site/ai-data-pipeline.html` | What an agent-authored pipeline is here: real schemas in, a versioned JSON artifact out, run governed |
| `/text-to-sql-agent` | `site/text-to-sql-agent.html` | The after-state a text-to-SQL tool leaves out. States plainly that we do not generate SQL |
| `/compare/airflow`, `/compare/dbt` | `site/compare-airflow.html`, `site/compare-dbt.html` | Honest comparisons, each leading with "use them instead when…" |
| `/compare/dagster-vs-airflow` | `site/compare-dagster-vs-airflow.html` | An EDITORIAL comparison (173 §B): what Dagster, Airflow and Prefect are for, in their own documentation's words (every product claim cites its source URL in the claim comment), when a team chooses each, and datapipelines named once as the thing that composes with any of them. `SiteFaqsCluster.COMPARE_DAGSTER_AIRFLOW` |
| `/federated-query` | `site/federated-query.html` | The staging join, with `nyc/mobility/revenue_by_borough`'s real SQL and the limits of an in-memory staging database |
| `/dp-lake` | `site/dp-lake.html` | dp-lake (089): Parquet and Iceberg on S3 read in place, dp-catalog, partition pruning, the four-engine showcase pipeline, and the honest limits — egress, the Iceberg metadata-file rule, no scheduler |
| `/how-it-works` | `site/how-it-works.html` | The tutorial page (174): `/how-it-works` is the product's step-by-step guide — run it locally, mint the agent's key, connect Claude Code / Cursor / Copilot over MCP (the client blocks are one fragment shared with `/add-mcp-server-to-claude-code`, so the two pages cannot drift), watch the agent author and execute a federated pipeline over the demo data, release it, publish the endpoint, scope a key to it and call it — every command, output block and capture executed on a real deployment before it was written. 169's `#application` gallery stays; the 115 engineering depth (mechanism, engine strip, "What's in the box", security teaser) follows under its own opener — where every term the buyer's home page keeps below the fold now lives — and `SiteFaqsHub.HOW_IT_WORKS` closes it. The agent-loop section the depth used to open with is retired: the executed tutorial IS the loop |
| `/demo-data` | `site/demo-data.html` | The demo data, table by table (116): what the three published sample-data families hold, where each came from, what to ask it, and the licence each ships under |
| `/pricing` | `site/pricing.html` | The pricing page (119 §C.4): there is no price — AGPL-3.0, self-hosted, every feature; what free costs you honestly; the no-paid-tier promise under the roadmap's dated `status` markup (the freshness guard covers it, so the page carries a `data-roadmap-updated` dateline); AGPL in two sentences; the owner's contact offer from `CONTACT_EMAIL`; `SiteFaqs.PRICING` |
| `/semantic-layer` | `site/semantic-layer.html` | The learned semantic layer (119 §B): the category term answered honestly — the learn-with-evidence loop, the two scopes, the trust ladder, drift at read, today/next in the dated `status` markup, the dbt/Cube comparison, the three `semantics_*` tools and the twelve-kind table rendered verbatim from `docs/enums.md` §19 (`SiteSemanticLayerGuardsTest` holds page and doc to one truth), with the real `facts[]` wire example quoted from 118's E2E capture; `SiteFaqs.SEMANTIC_LAYER` |

Chrome is shared: `templates/site/_layout.html` owns the `<head>`, the header and the footer site map, and the homepage uses its header/footer fragments — two copies of a nav is how one of them stops linking a page that exists. The footer's engine column is built from the registry, so a new engine page is linked from every page the moment its row lands.

**One vertical rhythm, tokened (119 §A).** Measured on 2026-09-11 at 1440×1000, the gap between a section's `h2` and its first block was a different number in every section (0, 16, 20, 24 and 32px). The rule now: ONE token, `--site-gap-head` (20px phone, 24px from 48rem), applied by ONE selector on the section layout — `.section .container > h2 + *`, with `.faq-group > h2 + *` and `.faq-block > h2 + *` for the two structures that wrap a section h2 one level deeper — and never by a block's own margin. A `.section-lede` deliberately keeps 16px under its h2 (the `h2 + .section-lede` override) and passes the head gap on to ITS first sibling (`.section-lede + *`). Cards: 24px padding and 24px grid gutters from 64rem (20/16 below); internal rhythm set once on `.card` (title → body 8px flex gap, body → a trailing spec link 12px); `align-items: start` inside `.feature-group` so a card is as tall as its text — and a card body is capped at **90 words** (`SiteCardBudgetTest`; the surplus moves into a "read the spec →" link to the cited docs section). The hero shot clamps to `--site-hero-max` (`min(44vh, 620px)` desktop) so the proof strip starts inside the fold; on a phone the hero stacks copy → numbers → picture. Group labels (`.group-title`) take the kicker treatment with `--space-8` above and `--space-4` below, no rule under them. All of it is held in a real browser by `SiteRhythmBrowserTest` (computed styles on `/`, `/how-it-works`, `/demo-data`, `/faq` at 1440×1000 and 390×844: head gaps, card padding, row-stretch, fold, and `scrollWidth == clientWidth`).

**The demo-data page's tables are generated from vendored manifests (116), pinned to the deploy versions.** The three published `manifest.json` files — one per sample-data family, at exactly the version `deploy/env/defaults.env` pins (`SAMPLE_VERSION`, `SAMPLE_TRADE_VERSION`, `SAMPLE_LAKE_VERSION`) — are vendored at `modules/web/src/main/resources/site/demo/`, parsed once at startup by `SiteDemoData` (missing or unparsable fails fast, the `DocsCatalog` posture), and rendered as the family tables: engine, table, row count, sizes, object paths and the licence stamps are model fields, never typed rows. The prose adds only what no manifest carries — grain and key columns from the families' DDL files, windows from the locks' param lines, and the attribution sentences from the manifests' provenance rows. `SiteDemoDataGuardsTest` holds each vendored manifest to its deploy pin, holds the rendered rows to exact completeness against the manifests, and proves the row counts are bound (not literal); a repack that bumps a pin and forgets the page fails the build. A family whose manifest carries no licence stamp renders a visible "not yet verified" status and makes no licence claim.

**Guards** (all in `modules/web/src/test/.../ui/site/`): `SiteSeoMetaTest` renders every registry page through its real controller and asserts title/description/canonical/`og:`, the 70- and 155-character display limits, one `<h1>` with no skipped levels, and a descriptive `alt` on every image; `SiteClaimCitationTest` extends the 024b claim rule to every site template; `SiteBuyerLanguageTest` (115) holds the home page's fold to buyer vocabulary and pins the moved vocabulary on `/how-it-works`; `SiteEngineFactsGuardTest` parses [Datasources §4.1](/docs/datasources.md#41-dialect-catalog) and [Deployment §3.5](/docs/deployment.md#35-jdbc-driver-matrix-what-ships-in-the-image), holds the engine-page set equal to `Dialect.entries`, and asserts every row's driver, license and bundled flag against them; `SiteHandTypedCountsGuardTest` (124) fails the build on a hand-typed count anywhere in site copy; `SiteJsonLdTest` parses the homepage's three `application/ld+json` blocks; `SitemapControllerTest` asserts the sitemap against the registry and the packaged docs; `SiteKeywordCoverageTest` (173) pins every measured phrase to its page and surface; `LlmsTextTest` holds `/llms.txt` to the sitemap; the E2E sweep fetches every `<loc>` anonymously.

**The nav is host-aware (119 §C.1).** The header's last item is `/login` on every deployment, but its label is computed from the request: on the public origin (`SITE_ORIGIN`, the same constant the canonical tag uses) it reads **Try the live demo**; on a customer's own deployment it reads **Sign in** (`SiteOriginAdvice` live; `SitePageRenderer`'s serverName offline — the static export passes the public host, so the exported site reads "Try the live demo"). The GitHub link wears a static star count baked from the build-time `GITHUB_STARS` constant (refreshed by hand at release; no visitor's browser fetches api.github.com), an `AGPL-3.0` chip beside it links `/pricing`, and `SiteOpenSourceSignalsTest` pins both host branches, the badge, and the one-contact-address rule (`CONTACT_EMAIL`; a mailto sweep fails any page carrying a second address).

**Every measured search phrase has a named surface, and a guard keeps it there (173).** The keyword study (private store `notes/2026-09-04-seo-keywords.md` + addendum 4) measured 64 phrases; `SiteKeywordCoverageTest` is that plan as a table — phrase → registry page → surface (`TITLE`, `H1`, `H2` or a `BODY` sentence inside `<main>`) — matched exactly the way the store's `sweep.py` matches (lower-case, dash variants folded, visible text only). A rewrite that drops a heading or trims the sentence fails naming the phrase, the page and the surface. The renames it needed on eleven pages are recorded as retirements in `site-baseline-outline.json` with their phrase, and every registry route beyond the 59-route baseline is listed under the fixture's `additions` with its reason. The engine pages carry the searcher's other name for an engine ("PostgreSQL", "MSSQL") as a `searchAliases` row field rendered into one opening sentence — never a template `if`.

**Structured data (073 §D, 173 §C).** The homepage publishes three `application/ld+json` blocks — `SoftwareApplication` (the description is `SITE_SUMMARY`, the same sentence `/llms.txt` opens with), `Organization` (name, url, the 512px brand logo, `sameAs` → the repository) and `FAQPage` — and every public doc page publishes a `TechArticle` (headline = the doc's H1, description, canonical) plus a `BreadcrumbList` (Docs → page), written from Kotlin by `DocJsonLd` through Jackson so a title cannot break the JSON. `SiteJsonLdTest` and `DocsJsonLdTest` parse every block off the render. No `WebSite` block: the site declares no search action, so it would add nothing.

**The Lighthouse budget (111 §C)** is a script, not a build gate: `scripts/site-lighthouse.sh` serves `modules/web/build/website-export` (run `./gradlew :modules:web:websiteExport` first; the export strips the claim comments — the citation guards read the source templates, not the export) on a free local port and drives **pinned** Lighthouse 12.8.2 in headless Chrome over `/`, `/how-it-works`, `/faq`, `/tableau`, `/published-api` and `/for/saas-teams`, measuring the performance, accessibility, best-practices and SEO categories. **95 is the floor in every category on every page** — a lower score names the page, the category and the number, and exits 1; the fix is the cause, never the floor. The first floor failure was structural, not per-page: five render-blocking stylesheets and a parser-blocking script put ~370 KB (two fonts included) on the critical chain, and the same pages scored 85–92. The fixes live in the head, not in the harness: `site-chrome.css` bundles the whole chain into one generated request (`scripts/build-site-chrome.sh` regenerates it; `SiteCssBundleParityTest` fails the build when any source drifts), the theme swap sheet loads disabled, the two JetBrains Mono faces load in a late `media="print"` sheet that `site.js` flips before paint (slow connections keep the fallback — the same trade `font-display: optional` already makes), and the body/LCP face is preloaded. The server is python3's own `http.server` building blocks with HTTP/1.1 keep-alive and gzip — the bare one-liner speaks HTTP/1.0 with raw bytes and scored the identical pages 1–3 points lower, a harness property, not a page one. The version is pinned so scores are comparable across runs, and the script is deliberately NOT wired into `./gradlew build`: the run needs the network (npx) and a Chrome binary, and the build gate stays hermetic. Run it from the handback and re-run it at merge.

### 4.15a Crawler surfaces (073)

| URL | Served by | Notes |
|---|---|---|
| `/robots.txt` | `static/robots.txt` | Allow all, plus the `Sitemap:` line. The private surface is deliberately not listed — `robots.txt` is public, and the filter chain is the control |
| `/sitemap.xml` | `SitemapController` | Generated from `SitePages.ALL` + the docs index + every packaged doc slug. No `lastmod`, deliberately (145 §7): a build timestamp on every URL told crawlers every page had changed on every deploy |
| `/llms.txt` | `LlmsTxtController` → `LlmsText.index` | The agent index (173 §C.1), in the llmstxt.org shape: H1, a blockquote summary (`SITE_SUMMARY`), then the four `/explore` groups as `- [title](absolute url): description` lines, docs linked at their `.md` twins, and the directory itself. `text/markdown`, the pages' 5-minute public cache. Derived from the same registries as the sitemap — `LlmsTextTest` holds every marketing `<loc>` in it and nothing beyond the sitemap |
| `/llms-full.txt` | `LlmsTxtController` → `LlmsText.full` | The same header, then every packaged doc's link-rewritten Markdown under `# <title>` with a `Source:` line naming its `.md` address (the doc's own leading H1 is folded into the separator) |
| `/docs/{slug}.md` — and `/docs/{slug}` with `Accept: text/markdown` | `DocsController.markdown` (one handler, two patterns; Spring's `produces` negotiation picks it only when Markdown is asked for) | The packaged Markdown itself (173 §C.2) with the §4.16 link rewrite applied to the source: a relative link to a packaged doc becomes `/docs/{slug}.md#fragment`, anything else the GitHub URL. Unknown slug → a Markdown 404 naming `/llms.txt`. The HTML page carries `<link rel="alternate" type="text/markdown" href="/docs/{slug}.md">`; `SiteDocsE2eTest` fetches every twin anonymously and walks every site-relative link in the served text to a 200 |



### 4.16 Documentation (in-product)

| Attribute | Value |
|---|---|
| URL | `GET /docs` (grouped index), `GET /docs/{slug}`; since 173 `GET /docs/{slug}.md` (the raw Markdown — §4.15a) |
| Auth required | **No, since 073** — both routes are `permitAll`. The viewer renders Markdown packaged in the jar (`DocsCatalog`'s only collaborator is a `ClassLoader`); no principal, no workspace and no datastore is reached on either route, and the same content is already public in the AGPL repository |
| Purpose | The operations manual and spec set for the version being run — packaged into the jar, so the docs can never describe a different server |
| Design primitives | `.ds-card` (index); `.doc-body` typography from `static/css/docs.css` (token-derived) |
| JS | None |
| htmx | No |

Content: the packaged `docs/*.md` set (the exclusion policy — `docs/superpowers/`, `semantic-layer-research.md`, `SPEC-REVIEW-2026-08.md` — lives in `modules/web/build.gradle.kts`), grouped Operations manual / Contracts / Reference, rendered to HTML once at startup. Relative links resolve in-app to `/docs/{slug}` or rewrite to their canonical GitHub URL; heading anchors use the same slug algorithm `scripts/docs-audit.sh` validates against. Rendered markdown is inserted as data (`th:utext`) — `${...}` placeholders in config examples display verbatim.

**Two chromes, one body (073).** An anonymous request renders `docs/index-public` / `docs/doc-public` — the marketing header and footer, an SEO head (`<title>` = the doc's H1 + " — datapipelines.co docs", a meta description taken from the doc's first real paragraph, a canonical at `https://datapipelines.co/docs/{slug}`, `og:` tags and breadcrumbs) and no link into the signed-in app. A signed-in request keeps `docs/index` / `docs/doc` in the application chrome, unchanged: making the docs public must not evict a logged-in reader to the marketing site. `DocsController` chooses on the `authenticated` model attribute `UiWorkspaceAdvice` already fills, so there is one definition of "is there a principal". The meta description skips each spec's `**Status:** …` metadata block by design — it describes the file's bookkeeping, not its subject.

The signed-in index's page header carries the app's second **Report a problem** link (127 — the first is §3.4's avatar-menu item): the same bug-form URL from the same constant, a new tab.

---

### 4.17 Promotion (055)

| Attribute | Value |
|---|---|
| URL | `GET /promotion` |
| Auth required | Yes — **session only** for the Promote action (`POST /promotion/promote`); the listing needs `read` |
| Rendered for (114) | The PLAN renders for every member — it is a read, and hiding it would leave an author unable to see what is waiting. **Promote** renders for `canPromote` **in the SOURCE workspace** (the ACTIVE one: `PromotionUiController` reads `principal.requireWorkspace()` and the interceptor judges `promotion.promote` against that same context; no target-side role is consulted). A member without it reads a line naming who to ask — the one place this round explains an absence rather than leaving one, because a promotion screen with no button and no words reads as broken. **Since 143 the reader's plan is a plain table** (`data-promotion-plan="read-only"`: Pipeline / Here / On target) — no `<form>`, no Send column, no selection boxes; the form with its controls renders for `canPromote` only |
| Purpose | Push released content from this deployment to its one configured higher environment ([Versioning §10](/docs/versioning.md#10-promotion-ui-driven-separate-use-case)) |
| Endpoints called | The target's [REST API §18](/docs/rest-api.md#18-promotion-endpoints-receiver) pair, server-side. The browser never talks to the target |
| Design primitives | the data table (§3.7; the listing), `.ds-empty` (the three empty states — the "Could not read the target" one is reused by every list screen a promoter sees while the target is unreadable, 178), `.ds-badge` (the target label and `absent`) |
| JS | None |
| htmx | No — a plain form POST with a redirect flash. A promotion is a whole-environment action, not a fragment swap |

**#194 lane D — parameter-set rows.** The plan carries the workspace's parameter sets in the
same `PromotableView` computation (the record's §8.3): a released set newer than the target's
is one more row (Name / Here / On target), pushed AFTER the templates and BEFORE the pipelines
when selected. The rows ride the model (`Plan.promotableParameterSets`); the table markup is
owned by the 282 round. **Since 313 the rows are their own table** — the pipelines table's
frame, Send column and role guard, heading "Parameter sets", checkboxes `name="parameter_set"`
— rendered whenever the plan carries set candidates, for the promoter inside the form and for
a reader as a second read-only table; the "Nothing to promote" empty state covers BOTH lists,
and the success flash names the applied set count (`?ok=promoted&…&parameter_sets=N`, the
toast's sentence). A set-only selection is a valid submit; `nothing_selected` fires only when
BOTH lists arrive empty.

**#10 L1c — the transfer families' rows.** The same shape twice more: headings "Visualizations"
and "Dashboards", checkboxes `name="visualization"` / `name="dashboard"` (`value` = the
artifact's NAME, the address the promotion travels by), the same Send column and role guard —
inside the form for a promoter, read-only tables (`data-promotion-plan-visualizations` /
`-dashboards`) for a reader. A dashboard row brings its pinned visualizations with it: the
sender derives their entries (§10.4's skip rule — already on the target at the same version and
hash is omitted, idempotent) and the receiver lands the batch's visualizations before any
dashboard (D61). The plan's lists are the LENS: the dashboards whose every source pipeline the
pipeline lens admits AND that are newer than the target's entry — a dashboard the target already
holds at the same version and hash is hidden — and the pins of admitted dashboards that are
newer. The empty state and the form's condition now cover ALL FOUR lists; the flash carries
`visualizations=N dashboards=N`; the action reads the two new fields and calls the sender's
six-argument `promote`. Guards: `PromotionUiControllerTest` (the six-argument call, the counts
in the flash), `PromotionTwoDeploymentE2eTest` Orders 60–61 (the sender's promote; the form post
promotes both families to uat, version and hash equal, read from uat's database),
`PromotionTransferBrowserTest` (the family tables inside the form for a promoter, light and
dark screens; the reader arm with no input anywhere — against a stub target, the browser harness
boots one app).

**178 — the listing IS the lens.** The plan this page renders is `PromotableView`, the same
computation every other read a promoter makes narrows by ([Auth §11A.1](/docs/auth.md#11a1-the-404-rule),
[Versioning §10.2](/docs/versioning.md#102-the-listing-rule-what-the-ui-shows)): what is listed here is
exactly what a promoter sees in the explorers, the search, the rail badges and over MCP, and
nothing a promoter sees elsewhere is missing here. The inventory behind it is cached per
workspace (`inventory-cache-ttl-seconds`, default a minute), so the page and the lens agree
within a window; a successful Promote invalidates the entry. The third empty state ("Could not
read the target", with the code) is the one place the target's state is shown as an ERROR to a
promoter — every list screen shows the same sentence as its empty state instead, and no read
ever answers a 502.

**The `body_invalid` flash is a toast (L1c-c).** The receiver's count bounds and strict readers refuse
`visualization.validation.body_invalid` / `dashboard.validation.body_invalid` — the aggregate batch ceiling among
them — and the action flashes that code's last segment. Until L1c-c the key was UNMAPPED in this page's bin, and an
unmapped key renders nothing: a push that did not happen was silent on the very screen that issued it. The page now
renders the toast ("the target's validation refused the batch — for example a count bound on the batch's size — and
rolled all of it back"), pointing the operator at the REST answer, which names the bound, the count and the key.
Apostrophes inside these fragment-expression literals are TYPOGRAPHIC (`’`): an `&#39;` entity is decoded before the
expression parses, where the apostrophe terminates the string literal — the `missing_datasources` and `key_invalid`
branches shipped with exactly that latent render-500 and were fixed in the same round.

**079 §F: the redirect flash is a TOAST, not a banner.** `?ok=`/`?error=` used to render two
full-width `.ds-card` blocks at the top of the screen that pushed the page down and stayed
until the next navigation. They are now §5.1 Shape A — the workspaces pattern. What is NOT
shared is the vocabulary: promotion's outcomes are its own ("the target already serves that
version"), and folding them into the layout's shared code→text map would put one screen's
language in every screen's shell. So this screen renders its OWN hidden bin, marked
`[data-toast-flash]`, and `toast.js` drains every marked bin rather than only the layout's
`#toast-flash`. Any screen with its own refusal vocabulary can now do the same.

**This screen is the only way to promote.** [Versioning §10.1](/docs/versioning.md#101-policy) D8 makes promotion a human, UI-triggered action: there is no MCP tool and no schedule, and 055's fence excluded `modules/mcp-server` so that is mechanical rather than remembered.

The listing is exactly [§10.2](/docs/versioning.md#102-the-listing-rule-what-the-ui-shows)'s set — RELEASED, and a version strictly greater than the target's, with same-hash entries dropped. Drafts and same-version entries never appear. The rule lives in the sender service, not in the template, so the screen cannot drift from it.

Three states, told apart because the operator's next step differs in each:

| State | What the screen says |
|---|---|
| No target configured | Names the two config keys to set. This is a deployment change, not a promotion |
| Target reachable, nothing to promote | "Nothing to promote", with the count of pipelines examined — so an empty list reads as *in sync*, not *broken* |
| Target unreachable or refusing | The error CODE, in place, on the listing page — not a generic error page. `pipeline.promotion.target_unreachable` and `pipeline.promotion.target_is_authoring` need different fixes |

A target with `authoring-enabled: true` renders its own banner and disables the button ([D7](/docs/versioning.md#101-policy): dev is where drafts live). That is a courtesy, not the control — the sender re-checks it and the receiver refuses independently.

Each row shows the pipeline's version here and on the target (`absent` when the target does not have it). Selection defaults to all. The Promote action re-runs every §10.3 guard against a FRESH inventory server-side and recomputes the dependency closure from scratch: what the screen showed may be minutes old, and a release that landed in between must not sail through on a stale decision. Outcomes return as `?ok=` / `?error=` flashes rendered by the layout's toast stack (§5.1), so a refusal lands on the listing the operator can act on.

---

### 4.18 API section (079 §C, closes T139; read-only since 179)

| Attribute | Value |
|---|---|
| URL | `GET /api-console` — the API home's **View all** (§4.18a); the menu's API item lands on `/overview/api-console` since #585 and lights on both |
| Auth required | Yes — `read` (`endpoint.read`), the same permission `EndpointsController.list` declares |
| Purpose | The API family's full listing: published endpoints, the API keys associated with each, the legacy paths and the MCP connection. The family's home — counts, charts, the newest endpoints and keys, and how a program gets access — is §4.18a; this page keeps its URL and its table |
| Design primitives | `.ds-card`, the data table (§3.7), `.app-chip`, `.app-code`, `.app-empty` |
| JS | None |
| htmx | None — since 179 the page is a read-only inventory. The key verbs moved to `/api-keys` (§4.19) |

**Why the route is `/api-console` and not something under `/api`.** That prefix is the
programmatic surface, split in two — the `/api/v1` REST envelope and the published
endpoints (`/api/<category>/…`, R-EP5) this page lists — and §2.1's three-URL-space rule says a page never lives in the
JSON space. `api-console` is a different first path segment from `api`, so Spring's
segment-wise matching cannot let either shadow the other. It needs no `SecurityConfig` entry:
that chain's `permitAll` list is explicit and everything else is `.anyRequest().authenticated()`.
Because `@RequiredScope` is enforced on any path once declared (the `/api` and `/partials`
prefixes govern only where an UNannotated handler is default-denied), the annotation is a real
gate here. No key reaches it: an `endpoint`-kind key reaches only the published-endpoint
surface ([Auth §7.7](/docs/auth.md#77-key-kinds-and-published-endpoint-bindings)) and the MCP key only `/mcp` (#215 B2).

**Read-only about everything on it (179).** The keys card and its minting modal lived here
from 091 to 179; they moved to `/api-keys` (§4.19). What remains is the inventory — plus a
**Manage** link per endpoint row and **Manage API keys** in the header for the workspace
admin and super admin. Your own `mcp` key is not here at all: keys v2 (A15) retired the
top-bar chip and the login mint — you create it on the Keys page (§4.19), like every kind.

Two cards.

1. **Published endpoints** — from `EndpointPublishService.list(principal)`, one batch lookup
   for pipeline names and one for released versions (never a query per row, the rule
   `PipelineNames` was written for). Columns: path, pipeline, timeout, **API keys**.
   - The method is always **GET**: `PublishedEndpointController` refuses everything else with
     `Allow: GET`, so it is a fact about the surface rather than a column that could vary.
   - The version shown is the **released** one, not `pipelines.current_version`. An endpoint
     pins a PIPELINE and serves its latest release, so a draft number would describe something
     no call will ever run. A pipeline with no release renders "no released version" — an
     endpoint can outlive the release it was published against, and that is worth seeing.
   - **API keys** names the keys associated at the EXACT node (179: names, not a count —
     "which keys" is the operator's question). It is deliberately not the EFFECTIVE
     authorization, which walks ancestors and takes the nearest node carrying any binding
     (`EndpointAuthorizer`); and no associations does not mean nobody can call it — a `user`
     key pinned to this workspace with `execute` still may. The **Manage** link (admins only)
     leads to the association editor on `/api-keys`.
   - **There is no "calls in the last 24 h" column, and the mock has one.** Nothing in this
     system records endpoint serves: no counter column on `published_endpoints`, no Micrometer
     counter, no query. The only trace is an append-only `endpoint.served` audit row whose one
     production reader is a per-execution boolean and whose KDoc argues explicitly against
     broadening it. A column filled from a sample would be a plausible number and a false one,
     so the card states the absence instead. Adding the column means adding the counter first.
2. **MCP server** — the connection JSON, the live tool count and the confinement note. The
   count is `McpToolCatalog.NAMES.size` at request time, never a literal, exactly as the
   marketing site renders it. The header name is `ApiKeyCredential.HEADER`. The server URL
   comes from `datapipelines.auth.base-url`, the deployment's DECLARED external origin —
   never derived from the request, for the reason `OidcConfig` documents: a hostile `Host` /
   `X-Forwarded-Host` would otherwise choose a URL a reader is invited to paste into an agent's
   config next to a live API key. Unset, the card shows the `{host}/mcp` placeholder the
   marketing site already uses rather than guessing. The key the config wants is created on
   the Keys page (§4.19 — keys v2 A15 retired the top-bar chip and the login mint).

Since #585 the card is ONE fragment, `partials/mcp-connection.html` (`card(note)`), stamped by
`McpConnectionCard` and shared with the API home (§4.18a): the config, the placeholder and the
tool count cannot drift between the two pages; each page passes its own sentence under the
config. Since #622 its **Copy config** button copies the `<pre>`'s placeholder config on both
pages through that fragment's same-origin module. It says **Copied**, then restores the button
label; the live status says **Config copied to the clipboard.** Copy sends no request and grants
no access. If the clipboard is refused or absent, the config is selected, the button says
**Select and copy**, and the status explains Ctrl+C (⌘C on a Mac).

### 4.18a API home (#585)

| Attribute | Value |
|---|---|
| URL | `GET /overview/api-console` — the API menu item's destination; **View all** leads to `/api-console` (§4.18) |
| Auth required | Yes — any member (`endpoint.read`; [Auth §7.6](/docs/auth.md#76-operation-matrix--the-permission-catalog-authoritative) names the route in the `endpoint.read` row's Surfaces cell). GET only; nothing on the page writes |
| Purpose | The API family's home: how many paths are published and what they serve, which keys the reader can see by kind, the newest of each, how a program gets access, and the MCP connection |
| Design primitives | the §4.25 frame (`overview/family.html`'s loops, the `.app-overview-*` rules, design tokens only); the MCP card is the console's fragment (`.ds-card`, `.app-chip`, `.app-code`) |
| JS | `overview.mjs` (Copy prompt), through the page-assets template (§3.0); `mcp-connection.mjs` (Copy config, #622), loaded by the shared MCP card's fragment (§4.18); no other script |
| htmx | none of its own — every link is a boosted navigation |

**Two reads, each failing on its own.** The endpoint numbers are the console's own reads —
`EndpointPublishService.list` and `listLegacy`, behind the promoter lens, so a promoter's three
endpoint metrics are the admitted pipelines' endpoints and each says so under its value (the
frame's per-metric lens note, #621). The key numbers are the Keys page's narrowing
(`keysVisibleTo`: the whole workspace with `api_key.read`, otherwise the reader's own `mcp`
keys). The keys read is NOT lensed: the keys metric carries no lens note, so a promoter's strip
is headed "Inventory" — the frame says "Admitted inventory" only when every metric passed the lens. A failed endpoints read is
the frame's unavailable inventory (no tile, no number, the sentence "The endpoints could not be
read."); a failed keys read drops only the keys tile, empties only the keys chart (caption "Keys
by kind — unavailable") and says so in Recently created keys. The endpoints card still answers.

**Frame (#638; regions #639).** The page renders the v2 frame's regions (§4.25). The **summary strip** holds the four tiles below as metrics, each with its help as the note; Serving a release and Serving a draft carry the dot of their state bar (`released`, `warning`), Endpoints and the keys metric none. View all's badge is Endpoints. The **analytics grid** holds the two charts as bars panels with descriptions — "Every published path once, by what a call to it runs now." and "Live keys only, one bar per kind." — and info buttons opening "How endpoints are counted" and "How keys are counted"; when the keys read fails, the keys panel has no description, no info button and no dialog section. **Continue your work** reads "A clear next step for your endpoints and keys" and opens on **Open the console** (`/api-console`) and — only when the keys read answered — **Manage keys** (`/api-keys`), above the two recent sections; the related links close it as one row, "What keys reach". **Quick start** reads "Publish an endpoint with your agent", one sentence and three short steps — Publish, Grant, Call — with Prepare a starter prompt (the prompt dialog; its text is unchanged) and How it works. The shared MCP card (`extra`) renders full width under the work grid. Nothing it counts or links changes.

**Inventory.** Four tiles: **Endpoints** (every published path, legacy ones included — the four
state bars added together; links the console), **Serving a release**, **Serving a draft**, and
**Your keys** — or **Workspace keys** for a reader holding `api_key.read` — counting every key the
Keys page lists for that reader, live, expired and revoked (links `/api-keys`). Two charts:
**Endpoints by state** — serving a release, serving a draft (the pointer names a draft, D63),
nothing to serve (no pointer), legacy path (a stored path the grammar refuses, #274), each bar
linking the console. **Keys by kind** — LIVE keys only, one bar per `ApiKeyKind` (a zero is a
zero), each linking the Keys page, its note naming what it did not count ("Not counted: N
expired, M revoked."). A key is revoked before it is expired: one key, one bucket. There is no
calls chart: call counts are not recorded anywhere in the system (#603 holds it), and the guide
says so.

**Recently published / Recently created keys.** Up to five each, `created_at` newest first. An
endpoint entry reads `/api<path> · <pipeline> · v<n>[ · draft]` or `· nothing to serve` and
links the console. Legacy rows are not listed, and a legacy row's `retired_reason` never
renders here. A key entry reads `<name> · <kind> · live|expired|revoked` and links the Keys
page. A key's id, prefix, hash or sealed copy never reaches the model, so no `dpk_` appears on
the page beyond the config's placeholder. `RecentItem` carries one name, so the secondary facts
ride in it. There is no Review drafts.

**Guide.** `overview/guide-api-console.html`: a subtitle and Quick start's three short steps;
How it works, titled **How a program gets access** — what the API is for, then three steps
naming who does each: publish (an agent's `endpoints_create`, or a
program's `POST /api/v1/endpoints`; there is no form in the app), create and associate a key (a
workspace admin on the Keys page, with a Manage API keys link for a workspace or super admin;
no agent mints a key), call it (`GET /api/<category>/<version>/<path>` with the key header),
and the note that call counts are not recorded. The prompt dialog, **Publish an endpoint with
your agent**, holds an editable starter prompt that follows the skill's endpoints flow and stops
at publishing. It never asks the agent to create, associate or
copy a key. The shared MCP card follows, with its own **Copy config** button since #622 (§4.18):
the fragment's `mcp-connection.mjs` copies the `<pre>`'s placeholder config, and `overview.mjs`
still copies only the prompt's textarea. Related: Pipelines, Dashboards, and the REST, MCP and
Claude Code references.

**Not a browser family.** Like Datasources (§4.5d), the API is not a `browserFamily`: the browser
and its opener stay hidden; the rail's API item lights on `/api-console` and
`/overview/api-console` alike (the segment-boundary rule, §3.4) and the crumb names API.

**States.** An empty workspace renders four "0" tiles, zero-width bars, "Nothing is published
yet. Quick start says how." and "No keys yet. A key is created by a person on the Keys
page." The steps are always shown, short in Quick start and in full in How it works.

Guards: `OverviewApiInventoryTest` (the buckets, every kind, live/expired/revoked once, the Keys
page's narrowing by role), `OverviewApiControllerTest` (tiles, charts, recent entries, the
independent failures, no key id or hash in the model), `OverviewApiRenderTest` (the guide, the
prompt's words, the shared card's placeholder and catalog size, exactly one `dpk_`, the manage
link by role, the unavailable and empty states; #639: the v2 regions through the real
controller — the four notes, the two dots equal to their bars, the badge, the panels and dialogs,
the task rows, the prompt equal to its base text byte for byte; a promoter's endpoint metrics
lensed and the keys metric not, under "Inventory" (#621); a failed keys read with no keys metric,
row or info button), `OverviewApiE2eTest` (real database and lens:
for viewer, author, promoter and workspace admin the tiles equal the console's rows and the Keys
page's rows; a second workspace leaks nothing), `ShellRenderTest` (the item lights on both paths,
the browser hidden), `OverviewApiBrowserTest` (the rail lands here; a key counted by name, its
value nowhere; the Manage keys row and the keys dialog; the chip equals the console's; Copy
prompt with zero requests; screenshots at
1512, 900 and 390), `AppShellBrowserTest` (rail → home → View all → console).

---

### 4.19 API keys (keys v2 — the ONE creation path for every kind)

| Attribute | Value |
|---|---|
| URL | `GET /api-keys` |
| Auth required | Yes — any authenticated principal; the page lists keys the caller CREATED (`mcp_key.own`), and its verbs are `mcp_key.create` (create), `mcp_key.revoke_own` (a creator's delete — the service checks `created_by`) / `api_key.revoke` + `server_key.revoke` (any key of the workspace), `api_key.bind` (associations) |
| Purpose | Create, delete and associate keys of every kind — the `mcp` key an agent presents at `/mcp`, the `endpoint` keys programs call published endpoints with, the promotion peer's `server` key, and (L5, #367) the `dashboard` keys an external application's backend presents at the runtime routes |
| Design primitives | `.ds-card`, the data table (§3.7), `.app-chip`, `.app-picker`, `.app-modal` |
| JS | The create modal, the kind-conditional role/associations fields, and the select-the-secret reveal |
| htmx | `hx-post="/partials/api-keys"` (create, into `#keyCreated`); `hx-delete="/partials/api-keys/{id}"` (delete, into `#keys-table-body`); `hx-post="/partials/api-keys/{id}/bindings"` (save an association SET); `hx-post="/partials/api-keys/{id}/dashboard-bindings"` (L5 — save a dashboard key's FOLDER set) — each with a §5.1 Shape A out-of-band piece |

The table lists the keys the signed-in person CREATED in this workspace: name + `dpk_…` prefix
(12 characters, D16's length), kind, **role** (the role CHOSEN at creation — keys v2 A13/A14: a
member role on an `mcp` key, `api caller` on an `endpoint` key, `promotion receiver` on a `server`
key, `dashboard viewer` on a `dashboard` key), **acts as** (the key's own identity, rendered "<key name> (API key)" — what its runs and
received versions are attributed to, [Auth §4.7](/docs/auth.md#47-key-identities)), **created by** (the
person, `api_keys.created_by`), associated endpoints (`/nyc/**` form; a `dashboard` key's FOLDERS, `finance/dashboards/**` form; the root reads as the
whole-tree wildcard), created, expires, last used — relative in the cell, absolute (UTC) on
hover, like every table here. Deleted and expired keys keep their row and lose their verbs.

- **Create** — one modal (**New API key**): **Kind → Role → Name → Expiry → Associations**. Kind
  is one radio card per kind the caller may create, each sentence naming what the kind reaches
  (`ApiKeyForm.kindChoices`): **MCP key** (`mcp` — "an agent's key; it reaches /mcp and nothing
  else"; `mcp_key.create`, held by author, promoter and workspace admin) with a **Role** select
  offering exactly the roles the SUBSET RULE allows the caller (A14 — an author is offered
  `author` only, a promoter `promoter` only, a workspace admin or super admin all three;
  `RolePermissions.offerable` is the one predicate, and no key can carry `viewer` — A15 — or
  `super_admin` — B1); **API key** (`endpoint`, the api caller role — "the paths you bind it to
  are its whole reach"; `api_key.create`, workspace admins and super admins) and, to a super
  admin only, **Server key** (`server`, the promotion receiver role, no bindings;
  `server_key.create`), and — to the same holders as the API key — **Dashboard key** (`dashboard`,
  the `dashboard_viewer` role, L5 #367: "the folders you bind it to are the dashboards it may
  render and refresh; an unbound key serves nothing"; `api_key.create`). It takes NO bindings at
  create — they are the card's own editor. The service re-checks both the per-kind permission and the subset rule
  (`WorkspaceService.requireIssuancePermission`, `RolePermissions.offerable`), so a forged
  request meets the same refusals the dialog never offers.
  Expiry is the same server-resolved select §4.18 used to host (a custom date expires at the
  end of that day, UTC; a bad one is `400 auth.api_key.expiry_invalid`). Associations are the
  published-path picker: only LITERAL prefixes are offered, because `EndpointAuthorizer` walks
  the concrete request path's ancestors and a `{variable}` node would authorise nothing.
  A name already taken by a live key of the workspace is `409 auth.key_name_taken` (A18).
  The secret is shown ONCE in a persistent inline panel; the response refreshes the whole
  table out-of-band (at TABLE level — a `tbody` OOB element dies in the browser's fragment
  parser) and points a toast at the panel.
- **Delete** — revokes the key. The creator's own delete (`mcp_key.revoke_own`, the service
  checks `created_by`) and the workspace admin's any-key delete (`api_key.revoke`; a `server`
  key's is `server_key.revoke` and stays a super admin's — #215, owner ruling 2026-09-24 — so a
  workspace admin sees the row but not the verb, and the service refuses a hand-crafted delete
  with `auth.role_required`). Revoking an `endpoint` or `server` key deactivates its identity in
  the same transaction; removing the member who CREATED a key revokes it too (A17/B6), and the
  row then reads "created by <name> (removed)".
- **Edit associations** — a per-row disclosure with the picker pre-checked to the key's
  current bindings; Save posts the whole SET and the service writes the delta (add/remove),
  so a checkbox never maps to "add" or "remove" by itself. A `dashboard` key's row (L5) has
  its OWN disclosure — **Edit dashboard folders** — posting to its own route with the FOLDER
  picker (the workspace's dashboard-name folders, root first), never a branch inside the
  endpoint form; the route is `dashboard.key.bind`.

**One fragment, three renders.** The page, the post-create refresh and the rows a delete or
association save swaps in all render `api/keys :: keysTable` / `:: keyRows` from one row model
(`ApiKeyRows`).

### 4.20 Schedules

| Attribute | Value |
|---|---|
| URL | `GET /schedules` — optional `?condition=enabled\|paused\|blocked` filters the list (#641; any other value is a 400), optional `?outcome=succeeded\|failed\|in-progress\|did-not-run\|never-ran` filters it by each schedule's last run (#725; any other value is a 400; the two compose), `?id=<schedule>` selects one (the tree opens down to its leaf) and `&run=<run>` opens that run; the address bar follows the reader's selection and filter |
| Auth required | Yes — `schedule.read`, every member (a promoter through the promoter lens: schedules of the pipelines the lens admits). Its verbs are the five `schedule.*` write rows ([Auth §7.6](/docs/auth.md#76-operation-matrix--the-permission-catalog-authoritative)) — author, workspace admin, super admin — and render by `canAuthor` (§4.3e) |
| Purpose | Browse the workspace's schedules as a folder explorer; read when one runs next and what its runs did; create, edit, pause, resume, unblock, Run now and delete them. The menu's Schedules item lands on the Schedules home, `/overview/schedules` (§4.20a, #584), whose View all and New schedule lead here |
| Design primitives | The explorer chrome is §4.3's (`template-tree.css` — `tplx-*`, `tpl-*`, the divider, the drawer below 1100px); `.ds-card`, the data table (§3.7), `.ds-badge`, `.app-chip`, `.app-alert`, `.ds-field`, `.app-modal`; the page's own rules are `schedules.css` (tokens only, head-loaded — §3.0) |
| JS | `static/js/schedules/` — `model.js` (pure: levels, search, presets, the merged trail, refusal → field, parameter coercion, idempotency; `schedules-model.test.mjs`), `api.js` (the ONE request path: §20 with the session and `DP-CSRF-Token`), `dom.js`, `explorer.js`, `detail.js`, `run.js`, `form.js`, `page.js`; plus the shared `template-explorer.js` (keyboard), `explorer-detail.js` (drawer) and `splitter.js` |
| htmx | **None.** The scheduler design revision §6 (owner, 2026-09-22): the UI uses REST for every schedule operation, reads included, and no parallel partial route exists — the page is a server-rendered SHELL whose every read and write is a `fetch` to [REST §20](/docs/rest-api.md#20-schedules) |

**Why no partial, not even for reads.** The pipelines explorer renders its tree from `/partials/pipelines`; a Schedules twin would be a second list query beside §20.1 that the promoter lens and the page envelope would have to be kept equal to. So the markup the page draws is `<template>` skeletons rendered by Thymeleaf on the page (`schedules/explorer.html`, `detail.html`, `run.html`, `form.html`) — a verb outside the reader's role is not in the skeleton at all — and the scripts clone them and fill `data-slot` elements with `textContent`, never `innerHTML` (names, parameter values, reasons and trail text are user data). Outcomes arrive as REST JSON, which carries no fragment to splice, so success and refusal toasts are §5.1 Shape D (`DpToast.show`, the one client-side builder).

**The explorer (left).** §4.3's tree, classes, ARIA and keyboard, with one difference §20 imposes: §20.1 answers a folder's whole SUBTREE by `prefix`, not one level, so a level's direct sub-folders are derived from the names (`model.buildLevel`) — the template-hierarchy §9.1 rule ("no client-side tree assembly") is relaxed here because there is no folder-level REST read and the list is bounded by `max-schedules-per-workspace` (100 by default). It is still one read per level: the root without a prefix, a folder on its FIRST opening with `prefix=<folder>` — so an opened folder shows the server's current answer. A leaf carries the condition badge when the schedule is paused or blocked. **Search** replaces the tree with a flat list of full paths matching every column the list renders — path, pipeline, pattern, its preset wording, zone, condition (§5.1). **Empty**: an author's copy offers New schedule; a viewer's says who creates schedules; a promoter's says the lens shows released pipelines' schedules only. **Loading** is the skeleton rows under `aria-busy`; a failed read renders the envelope's user message, code and correlation id with Try again.

**The condition filter (#641).** A select beside the search (`#schedule-filter-condition`, "All conditions" first) narrows the list to one condition — `enabled`, `paused` or `blocked`, the words a schedule's `condition` reads (blocked wins over paused). It is §4.3's lifecycle filter without htmx: the page binds `?condition=` and stamps the parsed word on the page root, the select's change re-reads §20.1 WITH `condition` (the predicate is the server's database read, before the promoter lens — [REST §20.1](/docs/rest-api.md#201-list-schedules)), and the address bar carries the word first (`/schedules?condition=blocked&id=…`), dropping it at All. A filtered view is a flat list of full paths, as a search is, and the search composes over the filtered set; a filter with no rows is a filtered empty state naming it ("No blocked schedules.", "No paused schedules match “q”.") with **Clear filters**. After a pause, resume or unblock the detail's answer is the schedule's new truth: a row whose condition no longer matches leaves the list (`model.matchesCondition`), with no list re-read. Any other value — another case, surrounding space — is a 400 with a constant reason (nothing of the input in it). Through a promoter's lens the filter reads admitted schedules only, so each filtered list equals the Schedules home's tile for that condition (§4.20a, `OverviewSchedulesE2eTest`).

**The outcome filter (#725).** A second select beside the condition's (`#schedule-filter-outcome`, "All outcomes" first) narrows the list to the schedules whose NEWEST run falls in one of the Schedules home's five Last run buckets — Succeeded, Failed, In progress, Did not run, Never ran, the chart's own labels (`ScheduleOutcomeFilter`), over the words `succeeded`, `failed`, `in-progress`, `did-not-run`, `never-ran`. It is the condition filter's twin: the page binds `?outcome=` and stamps the parsed word on the page root (`data-schedule-outcome`), the select's change re-reads §20.1 WITH `outcome` (the server's database read, before the promoter lens — [REST §20.1](/docs/rest-api.md#201-list-schedules)), and the address bar carries it after the condition (`/schedules?condition=paused&outcome=failed&id=…`), dropping it at All. Either filter, or both, flattens the list; the search composes over the filtered set; the filtered empty state names both words when both are set ("No blocked schedules whose last run failed.", "No schedules that never ran.") and **Clear filters** resets both selects with ONE re-read. There is no client-side prune: a §20 schedule carries no run state and the verbs that re-render a row (pause, resume, unblock) record no run, so none can move a row between buckets; a run that starts or ends while the list is open — Run now included — does not re-sort it. The list reflects the read; the next read reflects the run. Any other value is a 400 with a constant reason. Each filtered list equals the Schedules home's bar for that bucket (§4.20a, `LastRunOutcomeFilterInvariantIntegrationTest`).

**The detail (right)** — §4.3b's three regions from three reads (§20.4, §20.9, §20.10):

- **Header** — the folder eyebrow and the leaf; the verbs by role, then by state: Delete… (the one destructive verb, a confirm dialog carrying `If-Match`; soft — runs and executions stay), Pause (enabled) / Resume (paused), Edit, **Run now** (refused while blocked, so hidden then). Each verb's success re-renders from the schedule the server returned.
- **Blocked** — when §20.4 carries `blocked`: the reason in words (scheduler.md §5.2) with its code, since when, a link to the run that blocked it, and the recovery verb beside it — **Unblock** (which re-validates first; its refusal is a toast and the block stays). A reader's copy names who can unblock.
- **Reading** — Overview: condition, executor, missed-run policy; the pipeline (linked to its editor for a reader who may open it, else to the explorer's search) with `current version` and the version the latest run used; the preset wording with the pattern under it, the timezone, the policy, the next due time (or why there is none: paused / blocked), revision and updated/created **by name** (#261 — `created_by_name` / `updated_by_name`, the ids §20 carried beside them). **Next five occurrences** — §20.9 verbatim: the wall clock the scheduler computed in the schedule's zone, the UTC offset then in force and the UTC instant (the DST rule is the server's; the browser never re-derives a time). **Parameters** — the literal values.
- **Acting** — the **Runs** card (§20.10, 20 at a time, "Show older runs"): state + reason, origin (`scheduled` / `catch-up` / `manual`), **due** (the occurrence; a manual run's row says when it was asked for) and **started** on their own lines — read against each other — **took** (the EXECUTION's own duration: §20's `execution_duration_ms` answers it straight off the run since #258 — for runs finished before that field existed the page falls back to the per-execution §10.2 read), Details and the execution link (`/executions/{id}`, rendered for `canReadExecutions`). While a run is queued, starting or running the card re-reads every 4 s; the schedule is re-read when a run ends (it may have blocked). A failed refresh keeps the runs shown and marks them stale — one toast per outage — until a refresh answers. The answer owns the selection it started for: a late reply for a schedule no longer selected, or a page since left, marks nothing, toasts nothing and arms no poll; the outage (and its one toast) starts fresh with every selection and after a successful reload.

**A run** (the Details dialog, §20.11): state, reason, origin; due or requested — a manual run's row names **who requested it** (`requested_by_name`, #261) — started, finished, took, the admission window and capacity retries; what ran (`prepared`: pipeline, version, status, body hash); the execution link; the frozen job and parameters — and, beside them (slice 3), **resolved parameters** (`prepared.resolved_parameters`): the literal map the run executed with, its bindings already resolved to dates on the run's frozen reference. **Messages** is the record's R10: the run's own trail (`schedule_run_events`) and — when it launched an execution and the reader holds `execution.read` — that execution's durable events (§10.3A `?format=json`), merged in ONE time order, each line labelled `scheduler` or `pipeline`; the trail's `requested_by` reads as the person's name (#261); the pane says which case it shows when the pipeline half is absent (no execution; a role that reads no executions; past retention — the 410; a failed read). An **unknown** run names its recovery in order: open the execution and check its targets, then unblock — and says why Run now is not a retry.

**The form** (create and edit, one dialog): **Name** — the pipelines' folder-path grammar, no leading slash (the help text says so — the endpoint paths DO start with one), checked by the server on save (the existing folders are suggested); **Pipeline** — suggestions from §5.7's search over the pipelines the reader may read, the job fixed to `{pipeline, version: "current"}` plus the optional `parameter_bindings` (slice 3); **When** — presets that WRITE a five-field pattern (every hour at :MM, every day at, every week on, every month on day) or a custom pattern, the timezone (region ids the scheduler accepts, the browser's own preselected on create) and **Next five**, re-read from §20.3 as the pattern or zone changes; **If occurrences are missed** — skip / run the latest once; **Parameters** — the declared parameters of the version the pipeline's current pointer names now, as literal values (R7; blank = the declared default). A `DATE` parameter's field adds the **binding source** selector — **Fixed value** (the default) / **Today** / **Yesterday** (§20.2): a preset hides the fixed-value input by class and travels in `parameter_bindings`, each run resolving the value on the schedule's frozen reference; the selection round-trips, so an edit re-opens with the preset selected. Other types keep the plain field. A §20 refusal lands beside its field with the code's OWN user message — every field-placed schedule code has a per-code line in the catalogue (#280; the family line "Check the reported field and try again." is what the person saw before): the name grammar and name-taken at the name; the pattern, the minimum interval and the zone at their fields; the pipeline field's refusals (`target_not_found`, `target_not_released` — no released version to follow — `payload_invalid`, `executor_unknown`); the binder's per-parameter refusals at the parameters. The pipeline is also named at PICK time: the moment the typed or picked pipeline resolves with no current version, the pipeline field shows the release-first line — the same sentence the save's `target_not_released` carries, cleared when a pipeline with a current version is picked — while the suggestions stay complete (a list entry's DRAFT status also covers a released pipeline with a draft in flight, so marking or filtering it would lie). Any refusal that names no field (the schedule state codes, the binding refusals, idempotency replays) is a toast. Create carries a fresh `Idempotency-Key` per attempt, reused only to retry the same request after no answer; edit carries `If-Match`, and a stale revision (`409 schedule.revision_conflict`) is shown inside the form with **Reload the current schedule** (the form then shows the server's schedule; the unsaved changes are replaced) — save again.

**Notifications (#442a)** follows Parameters in the author-only form: comma-separated Recipients (empty = no mail), then Started, Succeeded, Failed/cancelled/could-not-start, Outcome unknown and Schedule blocked. The last three default checked; create/edit round-trip the settings. Server refusals land on the Notifications fieldset. While the deployment's computed delivery state ([Scheduler §2.3](/docs/scheduler.md#23-notification-settings-442), the schedule's `notifications.delivery`) is `off`, a warning note under the fieldset names the reason — “Scheduler mail is not enabled on this deployment — these settings are saved, but no mail is sent.” (`disabled`), “… this deployment has no mail server configured …” (`mail_unconfigured`) or “… no recipient domains are allowed on this development deployment …” (`no_allowlist`); while it is `on` there is no note (#442b). Details show only recipient count and selected event labels (or “Off — no recipients”), plus the off reason in the same words (“mail not enabled on this deployment”, …) while off; addresses never appear there. REST reveals addresses only to readers with schedule-update permission.

**A run's Notifications** (#442b, the Details dialog above Messages, §20.11 `deliveries`; hidden when the run has none): one row per notice and recipient — **Notice** (the subject's state words: Started, Completed, Failed, Could not start, Stopped — cancelled, Stopped — instance shut down, Outcome unknown, Update on an unknown run, Schedule blocked), **Recipient** (filled only for a `schedule.update` holder; everyone else sees the row with no part of the address), **State** as a badge — `pending` default, `accepted` success, `failed` danger, `uncertain` warning, never the word “delivered”, and a line above the table says accepted means the mail relay took the message — **Attempts** (with “· resent N×” once resent) and **Last error** (the sanitized code). A `failed` or `uncertain` row carries **Resend** for a `schedule.update` holder (server-rendered behind the role guard, shown by the row's state; §20.13): the toast says “Resend queued”, and for an `uncertain` row warns that the first send may have arrived and the recipient can get the notice twice; the row is replaced by the answer's delivery (now `pending`). A refusal (`backlog_full`, `not_resendable`, `not_found`, 403) is a “Resend failed” toast and the button is usable again.

**Not in this slice**: any key or role assignment (R2: a schedule fires as the system identity). The agent manual has no scheduling area (schedules are REST and UI only, record §6).

Guards: `SchedulesUiControllerTest` (the route's row, `canAuthor` = the five write rows, the zone list against the scheduler's parser; #641: the condition word bound, the 400 for any other; #725: the outcome word bound, the 400 for any other, both words together), `SchedulesPageRenderTest` (the role ladder at the render; no htmx request attribute and no partial route; the script order; #641: the select and the stamp, the filtered empty skeleton; #725: the last-run select and its stamp), `ScheduleConditionFilterIntegrationTest` (#641: each word lists exactly the rows whose `condition` reads it, offset and keyset reads, through a narrowed lens; the refusal's details), `ScheduleOutcomeFilterIntegrationTest` (#725: its twin for `outcome` — the newest run decides, composed with a prefix and a condition), `schedules-explorer-load.test.mjs` (#725: both filters in one read; Clear filters is one read), `SchedulesCssTokenTest`, `schedules-model.test.mjs`, `ApiErrorCatalogUserMessageTest` (#280 — every field-placed code carries its override), and the browser suites `SchedulesBrowserTest` (explorer by prefix and search; create with field-level refusals asserting the catalogue's own words, the draft-only pipeline named at pick time and refused at save with the release-first words, and the next five equal to §20.9; the stale revision; pause/resume/block/unblock; Run now with the merged trail; delete; the role ladder and a viewer's refused POST from the page's own request path; light/dark; no overflow at 1100/1440/1920) and `SchedulesShotsBrowserTest` (the picture set).

### 4.20a Schedules home (#584)

| Attribute | Value |
|---|---|
| URL | `GET /overview/schedules` |
| Auth required | Yes — `schedule.read`, every member ([Auth §7.6](/docs/auth.md#76-operation-matrix--the-permission-catalog-authoritative) names the route in the `schedule.read` row's Surfaces cell); a promoter through the promoter lens, so the page counts only the schedules of the pipelines the lens admits, under the heading "Admitted inventory". GET only; nothing on the page writes |
| Purpose | The Schedules home page: how many schedules the workspace holds and in which condition, how each one's latest run ended, what fires next and what changed last; View all opens the explorer (§4.20) |
| Design primitives | the §4.25 frame (`overview/family.html`'s loops, the `.app-overview-*` rules, design tokens only) |
| JS | `overview.mjs`, through the page-assets template (§3.0) — it finds no Copy prompt button on this page; no other script |
| htmx | none of its own — every link is a boosted navigation |

**One read.** Every number and list on the page comes from ONE `ScheduleService.summary`: §20.1's own `list` at the workspace cap (`max-schedules-per-workspace`) through the reader's lens, plus one read of each admitted schedule's newest run state (`ScheduleRunRepository.latestStates`, scoped to the workspace). The page and the explorer therefore count the same admitted set by construction — §4.20's rule that no second list query sits beside §20.1. The summary reads one row past the cap, so a workspace holding more live schedules than its cap renders the unavailable state ("This workspace holds more schedules than its limit…"), never a short count. The read never touches `notification_recipients`, and a block's reason is not rendered here (the explorer's detail names it).

**Frame (#638; regions #639).** The page renders the v2 frame's regions (§4.25). The **summary strip** holds the four tiles below as metrics, each with its help as the note; Enabled, Paused and Blocked carry the dot of their By condition bar (`success`, `warning`, `danger`), Schedules none. Every metric passes the lens, so a promoter's strip is headed "Admitted inventory". View all's badge is Schedules. The **analytics grid** holds the two charts as bars panels at the one bar pitch, with descriptions — "Every live schedule once, by the condition the explorer shows." and "Each schedule once, by how its latest run ended." — and info buttons opening "How conditions are counted" and "How last runs are counted", each naming its buckets with their counts. **Continue your work** reads "A clear next step for your schedules" and opens on its task rows above Up next and Recently changed with the gap between them (#620): while any schedule is blocked, **Unblock N schedules** first (`octagon-alert`, danger tint, "N schedules are stopped by a failure until someone unblocks them.", to `/schedules?condition=blocked` — the explorer's condition filter, #641), then **See what runs next** (`/schedules`); the related links close it as one row, "What a schedule runs". With no blocked schedule there is no Unblock row. New schedule is the heading's primary action (the guide's `primary` fragment). **Quick start** holds How to create one; How it works is a dialog section with no prompt dialog beside it. Nothing it counts or links changes.

**Inventory.** Four count tiles — Schedules (each live schedule once), Enabled, Paused, Blocked — and two charts. **By condition**: enabled / paused / blocked, the condition the explorer shows (blocked wins over paused). **Last run**: each schedule once, by its newest run's state — Succeeded (`succeeded`); Failed (`failed`, `aborted`, `unknown`); In progress (`queued`, `starting`, `running`); Did not run (`not_started`, `skipped`, `cancelled`); Never ran (no run). Its note says it is a snapshot, not a history. The Enabled, Paused and Blocked tiles and the three By condition bars link the explorer filtered to that condition, `/schedules?condition=<word>` (#641 — the dialect `OverviewFamily.filtered` gives the other homes), and each Last run bar links the explorer filtered to that last run, `/schedules?outcome=<word>` (#725, §4.20's outcome filter — the same newest run read with the same bucket states, so the bar's count is the filtered list's rows); the condition note says so: "Each bar and each condition tile opens the explorer filtered to that condition; each Last run bar opens it filtered to that last run." The Schedules tile stays unlinked — no filter selects every schedule — and View all opens the whole list.

**Continue your work.** **Up next** — up to five schedules the dispatcher would fire (enabled AND not blocked; a paused schedule keeps a stale `next_due_at` and is never listed), soonest next run first, name as the tie-break; a due time already past reads "due now". **Recently changed** — up to five by `updated_at`, newest first. Each entry links the explorer's leaf, `/schedules?id=<id>`. There is no Review drafts (a schedule has no lifecycle) and no condition on a row (owner ruling 2026-10-19 on #584: the frame's entry has no slot for one; the condition is on the schedule's page).

**Guide** (`overview/guide-schedules.html`). A subtitle; How it works says what schedules are for, that schedules are managed here and over the REST API with no agent tool, and links §20. Quick start is **How to create one** — a person's three steps on the explorer (the pipeline and its current version; the pattern, zone, preview and missed-run policy; recipients, with "scheduler mail is not enabled yet, so nothing sends") and, for `canAuthor` only, **New schedule** (to `/schedules`, where the form is); every other role reads who creates schedules. There is NO agent section and no starter prompt: no MCP tool creates or reads a schedule (§4.20's "Not in this slice"). Related: Pipelines; Executions for a role that reads them (`canReadExecutions` — never the promoter's); the REST reference (§20).

**Not a browser family.** As §4.5d: the browser and its opener stay hidden. The rail's Schedules item links the home and lights on both `/schedules` and `/overview/schedules` (the layout's `navPath`, the overview root taken off); the crumb names Schedules.

**States.** An empty workspace renders four "0" tiles, zero-width bars and the notes "No enabled schedule is waiting to run." and "No schedules yet." A failed read says "Schedules could not be read." with no number, and each list says it could not be read.

Guards: `ScheduleSummaryTest` (every run state in exactly one bucket; the counts from §20.1's list, never `countLive`; the Up next predicate; the cap; the projection carries no recipient), `ScheduleSummaryIntegrationTest` (newest run per schedule in SQL, workspace-scoped; the summary equals the list unlensed and narrowed), `OverviewSchedulesControllerTest`, `OverviewSchedulesRenderTest` (no agent card; New schedule by role; related links by role; the charts' tables; escaping; no digit when unavailable; #639: the v2 regions through the real controller — the four notes in order, each dot equal to its bar, the badge, the panels and dialogs, New schedule for an author and never a viewer; #641: the Unblock row first iff a schedule is blocked, the condition tiles and bars linked, Schedules not; #725: the five Last run bars linked to `?outcome=<word>`), `LastRunOutcomeFilterInvariantIntegrationTest` (#725: each bucket's `?outcome=` list is, by id, the set the summary counts in it — unlensed and narrowed, a `created_at` tie included — the five partition the list, and each schedule's run probe is an index scan keyed on `schedule_id`, never a scan of `schedule_runs`), `ShellRenderTest` (the item, crumb and hidden browser), `MatrixRowReachabilityTest` (the §7.6 cell), `OverviewSchedulesE2eTest` (the page total equals `GET /api/v1/schedules` for a viewer and through the promoter's real lens; another workspace absent; #641: each condition tile equals the `?condition=` walk, viewer and promoter, and the Unblock row's count and link), `OverviewSchedulesBrowserTest` (the item lands on the home; zeros and New schedule in a fresh workspace; the condition dialog opens and closes with focus back on its button; the task row; an Up next row opens its detail; #641: the Unblock row opens the explorer filtered to blocked, and All brings the tree back; #725: the Succeeded and Did not run bars each open the explorer filtered to that last run with the bar's count as its rows, and All outcomes brings the tree back).

### 4.21 Dashboards — the catalog, the resource browser and the workspace (#400, #554; the L3b pages before it)

**The catalog.**

| Attribute | Value |
|---|---|
| URL | `GET /dashboards` (`?q=&lifecycle=&offset=` the deep links) |
| Auth required | Yes — `dashboard.read`, every role (a promoter through the lens: released dashboards whose every source pipeline her pipeline lens admits; a hidden one is absent from the list) |
| Purpose | The flat catalog landing (since #580 the menu's Dashboards item links `/overview/dashboards`, §4.25, which reaches this page through View all, the summary strip's linked metrics and Review your drafts — since #599): every dashboard the caller may read — or the matches of `q` (a dashboard whose name, display name or description contains the term, case-insensitive, `%` and `_` literal; the term trimmed and cut to 200 characters first, #504b) — one row each, a row linking the workspace. The page and the pager's total come from the lensed SQL read, so a match past the 200th dashboard is listed and counted (#512). With `lifecycle` (#580) the list is one lifecycle bucket, chosen by the inventory's own predicate (§4.3's rule): `draft`, `released`, `released-with-draft`, `with-drafts` or `with-releases`; `discarded` is not offered, since a discarded-only dashboard has no workspace page, and an unknown or unoffered value is the house 400 |
| Design primitives | §4.3's catalog shape ([Pipelines](#43-pipelines--the-catalog-page-and-the-resource-browser-350-554), #350's owner ruling — one flat, server-paged list, no tree, no detail pane): `.app-catalog-search`, `.tpl-results`, the shared pager; `dashboards.css` dresses the page's own bits |
| JS | None of its own — the search control is the htmx SPA pattern (a debounced `hx-get` into the stable `#dash-list-wrapper` root, focus and value preserved) |
| htmx | Yes — the search, the lifecycle control and the pager re-fetch `GET /partials/dashboards` into `#dash-list-wrapper`, `outerHTML`, each carrying the other's value |

Content: the flat rows at their WORKING version — a row's badge names the version and marks a
draft (versioning §7: unreleased edits stay visible; a draft-ONLY dashboard's row says so —
the list page's "opening a dashboard with no release says so" promise survives as a ROW
state, and opening one selects its admitted draft directly, labelled, with the Board pane
mounted (#537); no admitted draft leaves the choose-a-version state below (#409)).
**Rows preserve the menu and the browser** through prepared main-content navigation (#460, §3.4). Empty state:
dashboards are authored through MCP. **Filtered by lifecycle** (#580), the count of rows equals the overview's count for
that bucket under every lens: a promoter's filtered rows are the admitted names that hold a released version, each shown
at a release, never a draft, while the unfiltered list keeps the current-pointer rule (§4.25, #563). A filter that
matches nothing says "No dashboards match: <bucket>" with Clear filters (and Clear search when a term is set). Browse folders shows the resource browser. The tree
loads immediate levels completely, searches canonical/display names with fully expanded ancestors,
and Clear collapses to the normal root. The catalog retains its flat search and pager contract.

**The workspace.**

| Attribute | Value |
|---|---|
| URL | `GET /dashboards/{id}?version=N&tab=…` — one URL names one dashboard, one viewed version and one tab (`GET /dashboards/{id}/preview?version=N` is a 303 redirect onto it: #369's deep links survive) |
| Auth required | Yes — `dashboard.read` (D50 makes every reader an executor; the page must NOT declare `dashboard.execute` to "simplify"). Absent, foreign or lens-hidden → the family's 404. A NAMED version that does not resolve (absent, DISCARDED, or draft under a narrowing lens) → the family's 404 NAMING the version. The default selects the current release, else the admitted draft re-read through the caller's lens, labelled (#537). No served release, none named and no admitted draft → the choose-a-version state (#409): the heading shows the NAME, the admitted history is offered, and "no release yet" says what state the dashboard is in — never the empty `h1` and the "not found" the old board page answered for a dashboard the tree had just linked |
| Purpose | The tabbed, version-explicit workspace (#396's ruling): the Board tab is today's board page exactly; Overview, Refreshes, Versions and Keys are the other reading surfaces; the Versions tab carries the lifecycle verbs' dialogs. The browser NEVER authors — agents author over MCP; a person releases from the Versions tab |
| Tabs (the floor) | **Board** — the runtime mounts the released or named version's grid; since #473 the pane is the FILTERS panel beside the board (the parameters controls, never inside the chart container; at 820 px or narrower in the board region a drawer behind the toolbar's Filters button, closed by widening that region back past it (including resizing the resource browser at its boundary with the content, at a fixed viewport); no panel and no button while no parameter is visible), every chart a bordered CARD whose heading names the pinned visualization (§6.6's border-speaks-the-state rule — no state ever shows a badge; the border is the state: an animated edge while loading, green briefly on success, the warning accent when stale (#544), red on error or abort, dashed for no data, #536), and the ACTIVITY DOCK beneath both (Events/Errors/History; History is the refreshes partial rendered at load), kept visible by the full-height flex column whose panes shrink without scrolling `<main>` (#612). The refusal region stands in for a board that cannot run. **Overview** — the viewed version's definition, read-only: the sources with their pinned pipeline versions and statuses, the parameter set, the pinned visualizations with each pin's status (RELEASED, or DRAFT: previewable in a draft, released before publication, #459), the layout summary; no authoring control anywhere. **Refreshes** — the caller's refreshes full width (the dock's History tab is the board pane's copy of the same partial), version-independent as #369 documents. **Versions** — the admitted history with served/draft/discarded markers, created and released relative in the cell and absolute UTC on hover (§3.7; the keys rule, §4.19), and the lifecycle dialogs: Release (the one D61 consent for a DRAFT visualization pin), Purge draft, Discard, Restore, Purge version, Switch (make current), Purge dashboard — each a confirm dialog into `#dp-dialog` whose POST calls the SAME service the REST route wires and answers `HX-Redirect` back onto this tab with a flash toast. **Keys** — the `dashboard` keys bound to this dashboard, read-only, linking the Keys page's editor; rendered only for a caller with `dashboard.key.bind` (workspace admin, super admin) |
| Grid rows | Every row of the board's grid is one `--dashboard-row-unit` (app.css, `var(--space-20)` = 5rem) — a fixed track, so a slot of `h` rows is `h` units plus its gaps tall and a chart fills its slot instead of collapsing (#371). The layout's `breakpoint_px` (640 px when absent) is measured on the board host: a narrower board places every item across the full width, stacked in grid order (row, then column) with its row span unchanged. A 767 px viewport gives a 719 px board and keeps 6/6/3/9; a 1280 px viewport gives a 694 px board, which keeps the default grid but stacks with a configured 700 px threshold. Widening the user-resizable navigation rail (#460's separator, keyboard included) narrows the board below 640 px; resetting the rail (Home on the separator) restores the grid. A 0-wide host keeps its stored grid until the observer sees it laid out; without `ResizeObserver`, the stored grid holds (#412; implementation spec §3.2, dashboards.md §2.2/§6.2). A small Plotly body keeps `--space-16` (64 px): its card grows within the scrollable slot when the heading wraps, retaining the border around the whole figure; tables and KPIs keep their sizing (#536). A chart in a 2-row slot keeps a plot area of at least half its slot: compact margins, the axes' labels sized by `automargin` (#386, dashboards.md §6.3). The visualization test preview (§4.22) mounts the same grid under the same rule |
| Renderer failure | A rejected renderer keeps the fixed in-card error sentence and notification, and logs a bounded diagnostic to the browser console: fixed prefix, occurrence, code, message and first four stack lines (dashboards.md §6.6, #536). |
| Design primitives | `dashboards.css` (the workspace chrome, the runtime's emitted classes and the page's layout) and the vendored `plotly.css` (the §6.6 design-around's real stylesheet) — both head-loaded (§3.0 is normative: no page template carries its own stylesheet link) |
| JS | The board: `static/js/datapipelines-dashboard.js` + the three renderers and `static/js/dashboards-page.js` — singleton libraries with a per-container mount: it reads exactly two data attributes (the dashboard id on the container; the selected version on `data-dp-dashboard-version` whenever explicitly requested or different from the served current release, including a draft selected by default (#537); only a released default omits it and keeps the glue's `version: "released"`), and `window.__dpPage` is its test seam (since #473 naming the dock too). Since #473 the mount also passes the filters panel as the adapter's `parametersContainer` and wires the dock and the drawer; every listener it puts on `document` or `window` belongs to that mount and is removed when the board leaves `#app-main` (the history save's teardown), so an in-shell revisit holds one set. The dock's log lives in `static/js/dashboards/events-dock.js` (a plain ES2019 script), loaded through the same page-asset catalog (`template[data-page-assets]`, §3.2) before the glue. The workspace: `static/js/dashboards/workspace.js` over the SHARED tab core `static/js/workspace/tabs.js` (the one admission and transition rule; the pipeline editor's `pipeline-editor/tabs.js` runs the same machine since #420) — in-page tab switches (each one PUSHES a tab-only entry through `static/js/workspace/history.js`, #402 — Back/Forward re-select in page; a restored root re-wires once and re-paints the tab it left), lazy-once tab reads, and the Board pane's reveal running the runtime instance's OWN `resize()` (a chart booted into a hidden pane re-fits). The dialogs: `static/js/lifecycle-dialog.js` (the fifth container, `#dp-dialog`) |
| htmx | The events pane's bounded poll (below), the lazy tabs' one read, the dialogs' GET/POST pairs. Every link that names a version or board uses prepared main-content navigation and one lazily loaded compatible Plotly bundle; the tab strip never navigates. A lifecycle POST answers `HX-Redirect` — a full navigation back onto the Versions tab, the flash bin rendering the toast |


The signed-in shell lazily loads the vendored 3D superset bundle once and reuses it for
both 2D and 3D viewed versions ([Dashboards §6.4](/docs/dashboards.md)). Inert dependency markup
is validated before replacement; cached restoration mounts the new container exactly once.
The runtime's incompatible/two-bundle refusal remains. Standalone previews retain their
isolated bundle choice. An admitted draft selected by default mounts its board directly (#537).
A refusal or a choose-a-version page with no admitted draft mounts no chart.

**The events pane** — the caller's refreshes of the open dashboard, newest first; since #473 the
Board tab renders it AT LOAD in the activity dock's History tab, and the Refreshes tab fetches the
same partial (`GET /partials/dashboards/{id}/refreshes`, the REST
refreshes route's own read through `DashboardRuntime`, so the lens and the
own/`execution.read_all` visibility are the route's; it floors at `dashboard.execute` with
that route — the page that embeds it stays on `dashboard.read`). Each row shows status,
scope and age, and links its executions to `/executions/{id}` — for a reader who reads
executions; a promoter's refresh names none ([REST §23.3](/docs/rest-api.md),
[Dashboards §5.7](/docs/dashboards.md)). **Updates are a bounded poll**
(`hx-trigger="load delay:15s, every 15s"`, the pane re-fetching ITSELF with `outerHTML` —
each embedded copy polls itself, the dock's no differently),
deliberately not the runtime's notification hooks: the pane's source of truth is the DURABLE
refresh record (including the `execution.read_all` reader's wider view), the hooks fire only
for refreshes THIS browser started and would need a second client-side row renderer, and the
poll keeps working when the runtime instance failed to boot. The first re-fetch is delayed
(the server just rendered the rows); the poll dies with the page — leaving is a full
navigation, so the element and its timer leave the DOM with it.

Guards: `DashboardUiRenderTest` (the tree level's contract and the lens fixture; the
catalog's flat rows and the draft-only row state; the ONE-bundle contract at the render for
a 2d and a 3d board; the named version's attribute channel and the un-boosted selector; the
choose-a-version state (#409) and the refusal state with the name kept; the pane's rows,
execution links and poll attributes), `DashboardWorkspaceControllerTest` (the resolution
table, the default draft's board-version attribute and runtime read (#537), the tab set,
the permission-derived affordances, #409's no-runtime-call when no draft is admitted), the
`DashboardWorkspaceModelTest` (the version resolution: explicit → `findServedVersion` or the
404 naming the version; default → the served release, else the admitted draft labelled
(#537), else the choose-a-version state; a narrowing lens never serves a draft), `DashboardLifecycleDialogControllerTest` (the seven verbs' golden
paths, the typed confirm before the service, the #332 audit order, the redirect targets,
the session gate), `ShellRenderTest` (Home; the Dashboards item, its lazy branch and its
search), `ReadFloorTest` (the pages and the read tabs in `DASHBOARDS`, the Keys tab in its
own `dashboard.key.bind` family, the pane in `DASHBOARD_RUNTIME`, the dialogs in
`LIFECYCLE_DIALOGS`), `RoleVisibilityRenderTest` (the ladder: no verb for a viewer or a
promoter, the Keys pane below ws_admin absent, every verb guarded),
`MatrixRowReachabilityTest`/`RoleWalkE2eTest`/`PermissionSeamE2eTest` (the §7.6 Surfaces
cells), `workspace-tabs.test.mjs` + `dashboard-workspace.test.mjs` (the shared core and the
glue's decision rules on node --test), and the browser suites `DashboardPagesBrowserTest` +
`DashboardWorkspaceBrowserTest` + `DashboardPageConformanceBrowserTest` (the §10.5 page
half: bootstrap, freshness, the parameter lock, notifications, abort, connection loss,
disposal across a back/forward pass, two instances of one dashboard on one page; the tab
strip's in-page swaps and the hidden board's re-fit; #409's state and its booting draft
link; the promoter's lens with the no-POST collector; the dialogs into `#dp-dialog`; zero
CSP violations on every tab in both themes; light/dark screens).

---

### 4.22 Visualization test preview (session-less — #353)

| Attribute | Value |
|---|---|
| URL | `GET /visualizations/{id}/preview?session=<preview capability>` (`&theme=light\|dark` optional) |
| Auth required | **No session** — the FIRST capability-authenticated page: its ONLY credential is the preview capability in `?session=` (a `PublicPaths` entry, auth.md §8.3), minted by a test session's start and valid for THAT visualization version until the session's results are submitted or its deadline passes; the starter's current `visualization.update` is re-checked on every load. No cookie is read and none is set. Every refusal — wrong, expired, revoked, another visualization's, a malformed id — renders the one **unavailable** state with HTTP 404, byte-identical whatever the cause |
| Purpose | An agent's browser (Playwright) checks each saved test case against the real renderer before it submits its verdicts ([Dashboards §3.4.1](/docs/dashboards.md)); a person may open the same link |
| Design primitives | A standalone page with NO layout (no rail, no top bar, no htmx, no CSRF token — rendering one would set a cookie): the design-system sheets, `app.css`, `dashboards.css` (`dp-preview*`, the runtime's status chips) and the vendored `plotly.css` in its own head — the one page outside §3.0's head-loading rule, because it has no layout to load them; `.ds-card` per case, `.ds-empty` for the unavailable state |
| JS | L3a's vendored runtime + the three renderers (the ONE Plotly bundle the server chose, declared on its tag) and `static/js/visualization-preview.js` — the glue, a FILE: it reads the page's one `<script type="application/json" id="dp-preview-data">` block (written through `ScriptSafeJson`) and mounts one runtime instance per case in the runtime's **fixture mode** — no request leaves the page |
| htmx | None |

Content: the visualization's display name, folder-path name and version, the deadline, and one card
per test case in the body's order — the case name and its assertions as text, then the board the
runtime mounts. Each case section is `section[data-dp-case="<name>"]` and gains `data-dp-ready="true"`
when its instance's bootstrap held (or `data-dp-error="<code>"`); the chart's status chip is the
composite adapter's `.dp-dashboard-status[data-dp-state]` (`success`/`ready`, `no-data`, `error` with
the evaluator's code). The page never echoes its capability, carries `<meta name="referrer"
content="no-referrer">`, and renders case names and assertion labels through `th:text` only; the
agent's notes and environment never appear here.

Guards: `VisualizationPreviewControllerTest` (the one 404 state, the per-case configuration and
results, the script-safe block, the closed theme set), `VisualizationTestSurfacesE2eTest` (no cookie
in or out, the 404 body-equal across causes, confinement), `VisualizationPreviewBrowserTest` (both
themes under the live CSP with zero violations, the rendered trace count per case, the whole
start → preview → screenshot → submit → upload → release flow), `PublicPathsTest`,
`PublicRouteWalkerTest`, `PublicContractE2eTest`.

**The signed-in twin (#399).** A person reads the same per-case rendering on the workspace's
Preview tab ([§4.24](#424-visualizations--the-catalog-the-resource-browser-and-the-workspace-399-396-554)):
the same builder writes the same configuration block, keyed by the version instead of the run and
carrying no deadline, behind `visualization.read` and the session. This page stays the agent's
capability page — its public route admits nothing of the workspace.

---

### 4.23 Parameter Sets (the catalog and the workspace page — #374, #357 S1)

**The catalog page.**

| Attribute | Value |
|---|---|
| URL | `GET /parameter-sets` (`?q=` searches it, `?lifecycle=` keeps one inventory bucket, `?offset=` pages it) |
| Auth required | Yes — `parameter_set.read`, every role (a promoter through the lens: only sets whose current version is RELEASED and admitted; a hidden set is absent — not listed, not counted, not paged, not a search hit) |
| Purpose | Find a parameter set and open its workspace. Since #582 the menu's Parameter Sets item leads to `/overview/parameter-sets` (§4.25), whose linked metrics and Review your drafts link here filtered (since #599 the summary strip's); View all — the overview's and the resource browser's — lands here unfiltered |
| Design primitives | The house list: `.ds-empty` for the empty state, the shared §5 pager; `parameter-sets.css` (`ps-*`), head-loaded (§3.0 is normative) |
| JS | None of its own |
| htmx | The route's rows are the same read the resource browser's tree is built from; a row preserves the rail through main-content navigation |

Content: a flat, server-paged list (25 rows per page) of every set the lens admits — folder-path name,
display name and the version chip — or, under `?q=`, the matches: a case-insensitive substring of the
name, the display name or the description (#415), `ParameterSetService.search`'s answer, the same lensed
read the resource browser's branch searches with. It is the page `/parameter-sets` always meant to be (owner ruling
2026-10-02 for the artifact families: the page is a CATALOG, the tree lives in the rail, §3.4). The SPA
search control re-fetches ONLY the list fragment into the stable `#parameter-set-list-wrapper` root
(§4.3's contract: the control is never re-rendered, so focus and value survive); `?q=` stays the deep
link every "find this parameter set" link uses.
**The lifecycle filter (#582).** `?lifecycle=` takes §4.3's wire values `draft`, `released`,
`released-with-draft`, `with-drafts` and `with-releases`, composes with `?q=` and `?offset=`, and keeps the
sets the overview's inventory counts in that bucket, so on one workspace the filtered total equals the
overview's number under every lens. A Lifecycle control beside the search box sends it with every search;
an empty filtered list says which filter emptied it and offers Clear search and Clear filters; the pager
carries it. `discarded` is not offered and, like any unknown value, is the house 400 before any read: a
discarded-only set has no workspace page (its pointer falls to none when its last release is discarded,
so the workspace route answers 404). Through a promoter's lens the filter reads the admitted names'
RELEASED versions only — the inventory's rule, not the unfiltered catalog's current-pointer rule above — and
lists each set at a released version, never at a draft.
`GET /partials/parameter-sets/tree` keeps its historical path and, since #466, renders the catalog list
alone: its `scope=nav` and prefix presentations were removed, and a stale `scope` or `prefix` is ignored.
The resource browser uses its REST tree/search routes (§3.4, rest-api §24), under the same read lens.

**The workspace page.**

| Attribute | Value |
|---|---|
| URL | `GET /parameter-sets/{id}?version=N&tab=workspace` — one URL names one set, one VIEWED version and one tab |
| Auth required | Yes — `parameter_set.read`. A set the caller cannot see — absent, another workspace's, lens-hidden — is the family's ONE house 404; so is an explicit `?version=` that does not exist or is not admitted (under a narrowing lens a draft number is the same 404 an unknown number is, so a status cannot be probed by number). A `?version=` that is not a positive integer is the house 400 |
| Purpose | Read a set's structure at a chosen version and, for a role that may evaluate, drive its live form |
| Design primitives | `parameter-sets.css`; a Cytoscape + dagre graph on design tokens only (colours resolved through a probe element — the editor graph's recipe); `.ds-badge`, `.ds-empty`, the shared form controls |
| JS | `static/js/parameter-workspace/{model,graph,inspector,workspace,runtime}.js` and the L3a dashboard runtime's parameters-only entry, `DatapipelinesDashboard.initParameters` (`static/js/datapipelines-dashboard.js`) — files only, no inline script (CSP). The vendors and modules ride an inert `<template id="ps-runtime-scripts" data-page-assets="on-mount">` catalog that the shell's page-script loader (`page-assets.mjs`, §3.2, #494) executes in order, once per document; its last entry, `runtime.js`, is the `data-page-mount` that activates the `.ps-root`, so the fragment carries no executable tag and an htmx history restore never re-executes a script |
| htmx | None beyond the shell's boost; the version links are plain links |

**Which version the page shows** — the resolution, in order: (1) an explicit admitted `?version=N` shows ITS
body, never a nearby one (no clamping); (2) otherwise the set's CURRENT pointer, when the caller's lens
admits that version; (3) otherwise an accessible DRAFT, labelled as one; (4) otherwise the **choose-a-version**
state over the admitted history — no graph, no form, no invented pointer. The header chip prints the one
fact string (`vN · released · current`, `vN · draft`), and the version links (`nav.ps-versions`,
`aria-current` on the viewed one) are the in-page switch. A promoter's lens admits RELEASED versions only, so
her page carries no draft pointer and her version list is the released ones. `?tab=` is the closed set
`workspace | history`; any other value is `workspace`, and `history` resolves to the workspace pane until
the history surface lands (S3).

**Content.** Two script-safe JSON blocks (`#ps-data`, the set's body through `ScriptSafeJson`; `#ps-workspace`,
the ONE source of the displayed and the submitted version — a malformed block is a visible refusal with ZERO
requests, never a default); the **graph** (`#ps-graph`: one node per parameter, an edge per `depends_on`, the
badge the source kind) with a text live region for its selection — and, since #383, with the evaluation's LIVE
STATE: each applied frame moves its parameter's node through waiting → admitted → running → resolved (a failed
selector → failed, carrying the frame's catalogued code; a parameter without a terminal state fails from the
`evaluation_failed` terminal frame), the terminal response's `reset` parameters carry a dashed reset mark, the
live region announces one short sentence per frame (`city is running`, never the frame's JSON), and the page
keeps a frame log (`window.PSWorkspace.frames`, in memory only, reset per mount — never persisted) of the
attempt's frames the guards read — the runtime witness's board vocabulary (`scope: "dashboard"`, #473) is not
recorded and changes nothing here; the **inspector** (`#ps-inspector`: the selected
parameter's definition and, once evaluated, its state — value, origin, reset, hidden, disabled, options,
errors — as TEXT only, plus the stream's code and detail for a parameter a live frame marked failed); and the
**live form** (`#ps-form-host`).

**The live form — roles.** The form is rendered, and `initParameters` is called, only for a role that may
evaluate (`parameter_set.evaluate`: viewer, author, workspace admin, super admin). A promoter's page renders
the structure (graph and inspector) and **no control and no request** — `[data-ps-read-only]` carries the
hint; the markup is not a CSS-hidden control. The form POSTs the OBSERVED evaluation
`POST /api/v1/parameter-sets/{id}/evaluations` (rest-api §21.5 — §21.3's act streamed as Server-Sent Events)
with the VIEWED version, the WHOLE selection set, a fresh v4 `evaluation_id` per attempt (minted by the
runtime, never stored) and the `instance_id`, the session credential and the `DP-CSRF-Token` double-submit
header; the page adds no route, no scope row and no permission. The frames drive the graph's live state
(above); `evaluation_completed`'s response applies the form state exactly as the plain evaluate's did. A
parameter VALUE leaves the form only in that POST body — never a URL, history state,
storage or log line. A change made while an evaluate is pending SUPERSEDES it — the person's newest selection is the one worth
answering, so the pending attempt is finished and its STREAM is closed (the server's disconnect grace then
aborts the evaluation, §21.5), and every frame whose `evaluation_id` is not the current attempt's is dropped
before it can touch state or DOM (a dashboard's form keeps
the refusal instead, `parameters.locked`). A stream that ends without a terminal frame is the transport
failure ([Dashboards §6.6](/docs/dashboards.md#66-the-states-notifications-and-the-csp)'s rule): the form keeps its
last committed selections and the lock is released by the deadline, never earlier; a `: revoked` comment shows
the runtime's revoked notice the same way. A refused evaluate renders the server's code and message in `#ps-form-error`;
`evaluation_failed` ends the attempt with its catalogued code and every unfinished node fails on the graph.

Guards: `ParameterSetsWorkspaceModelTest` (the resolution order, never-clamp, the lens-hidden 404, the draft
arms, the tab set), `ParameterSetsBrowseModelTest`, `ParameterSetsUiControllerTest` (the routes' scopes, the 400/404,
the evaluate flag per role), `ParameterSetsPartialControllerTest` (the catalog route's dispatch, #415; no nav
presentation, #466), `ParameterSetsRenderTest` (the page's markup contract: the blocks, the form only
for an evaluator, the promoter's page, the draft label, the choose-a-version state, the script-safe
blocks; #415: the search fragment's rows, its pager's `q`, the two empty states),
`ParameterSetServiceIntegrationTest` (the search's columns, its literal metacharacters, its lens truth and
its blank-`q`-is-`listAll` rule, #415), `ShellRenderTest` (the Parameter Sets branch and its search input),
`ReadFloorTest` (the `PARAMETER_SETS` family and
its floor), `BrowseModelConventionTest`, `MatrixRowReachabilityTest`/`RoleWalkE2eTest` (the §7.6 Surfaces
cells), `parameter-set-init.test.mjs` (the runtime's parameters-only entry),
`parameter-set-stream.test.mjs` and `ParameterWorkspaceStreamBrowserTest` (#383 — the streamed evaluation:
the observed route's transport, the supersede that closes the prior attempt's stream, the id check that drops
another attempt's frames, the §6.6 path, `: revoked`, the state machine in `parameter-workspace-graph-state.test.mjs`,
and the browser witness for spec §12's scenarios 3, 6, 7 and 10 with the promoter arm),
`parameter-workspace-model.test.mjs`, `SampleDataParameterSetsContentTest`
and `ExampleContentSeederTest` (the demo set `nyc/parameters/geo_filters` is seeded through the seeder, §4.23's
demo content), and the browser suite `ParameterSetPagesBrowserTest` (the catalog search, the branch search and
the keyboard, #415).

**History — the evaluation records (#376, #357 S3; workspace spec §6.4, R3).** The header carries two section links
under it, `Workspace` and `History` (`nav.ps-tabs`, main-content links, `aria-current="page"` on the current one; the
viewed `version` rides along): one canonical URL each, `?tab=workspace` (the default) and `?tab=history`. The History
arm renders **no live form, no graph and no body block** — the workspace state block says `hasBody: false`, so opening
the tab evaluates nothing (an evaluate would be a record of its own). It paints the house table (§3.7: the fixed
`dt-frame dt-scroll dt-nowrap` frame, `data-dt-paged`, `data-table.js` from the shell — no second renderer), one row per
evaluation record of the SET, whoever ran it — this page, a dashboard, the REST route or an MCP client: **Started** (UTC),
**Caller** (`PAGE`, `DASHBOARD`, `REST`, `MCP`), **Principal** (the person's name, or `key dpk_…`), **Version**, **Status**
(the house chip; the enum on `data-status`), **Valid**, **Outcomes** and **Queries** (counts), **Took**. 25 a page,
newest first; Previous/Next page `GET /partials/parameter-sets/{id}/evaluations?offset=` (the same browse model the page
painted with). A row's Started cell is a button that loads `GET /partials/parameter-sets/{id}/evaluations/{evaluationId}`
into the region below the table: the record's header (caller, principal, correlation id when one exists — a dashboard
refresh's id —, status with its catalogued `outcome_code`, the stamps), the per-parameter outcomes as recorded
(`resolved` / `reset` / `error` + the code and its reason word), and the statement attempts — parameter, datasource,
template pin, queued / started / ended to the millisecond, outcome (`EXECUTED`, `REFUSED`, `FAILED`, `TIMEOUT`, `ABORTED`;
`in flight` while open), rows, code — so two selectors that ran together show OVERLAPPING ranges, as stored, never
re-serialised. A record never holds a value, a selection, SQL or a driver message, so none can render; every field is
`th:text`.

Roles: the tab and both partials are `parameter_set.read` (the owner's §11.2 ruling — no new permission): viewer, author,
workspace admin and super admin read every record; a promoter reads through the LENS — a set the lens hides is the
workspace page's own 404 on the page and on both partials (same status, same body), and under a narrowing lens only the
records of the versions it admits (released) are listed or openable, so a draft's parameter names never reach a promoter.
No REST or MCP history surface (§11.6).

Guards: `ParameterSetEvaluationsBrowseModelTest` (the lens before any read, the version narrowing, the pager, the text
projection), `ParameterSetHistoryRenderTest` (the section links, the History arm without form/graph/body, the row's open
target, the pager, escaping), `ParameterSetsUiControllerTest` (the history arm's state block), `BrowseModelConventionTest`
(the page and the partial share the model), `ReadFloorTest`, `RoleWalkE2eTest`, `ParameterEvaluationHistoryE2eTest`
(scenarios 9 and 10: every caller's record, the promoter's 404 parity, the draft record hidden from the lens), and the
browser suite `ParameterSetHistoryBrowserTest` (both themes, the overlapping attempts, a timed-out attempt).

**Demo content.** Every personal workspace seeded with the NYC demo family gets `nyc/parameters/geo_filters`
(RELEASED v1): a year → month → pickup-zone cascade over the `sample-trips` Postgres datasource through three
pinned selector templates, a hard-coded `measure` choice and a typed `min_trips` input. The seeder derives
the set's id per workspace and states the body's shape hash; the examples file carries neither.

### 4.24 Visualizations — the catalog, the resource browser and the workspace (#399, #396, #554)

**The catalog.**

| Attribute | Value |
|---|---|
| URL | `GET /visualizations` (`?q=&lifecycle=&offset=` the deep links) |
| Auth required | Yes — `visualization.read`, every role (a promoter through the lens: the visualizations a dashboard her lens admits pins; a hidden one is absent — not listed, not counted, not paged) |
| Purpose | The flat catalog (View all; the menu's Visualizations item leads to `/overview/visualizations` since #581, §4.25): every visualization the caller may read — or the matches of `q` over name, display name and description — one row each, a row linking the workspace |
| Design primitives | §4.21's catalog shape exactly (`.app-catalog-search`, `.tpl-results`, the shared pager); `visualizations.css` (head-loaded, §3.0) dresses the page's own bits with design tokens only |
| JS | None of its own — the search is the htmx SPA pattern (a debounced `hx-get` into the stable `#viz-list-wrapper` root) |
| htmx | Yes — the search and the pager re-fetch `GET /partials/visualizations` into `#viz-list-wrapper`, `outerHTML` |

Content: flat rows at their listed version, with draft badges and the existing paged catalog search.
Since #581, `lifecycle=draft|released|released-with-draft|with-drafts|with-releases` composes with `q`
and paging on the page and partial. Unknown or unoffered values return a constant 400. The filter
uses the inventory's admitted-version membership in SQL for both rows and total. A narrowed reader
gets RELEASED versions even if an admitted object's pointer names a draft. An admitted current
release keeps precedence; filtered searches match the selected release's metadata, so a hidden draft's
title or description cannot affect matches. The unfiltered catalog keeps its current-pointer rule. Discarded only stays unlinked and is not offered, because its
workspace has no working version. Search and lifecycle controls include each other; the pager
carries both, Clear search keeps the lifecycle, and Clear filters resets both.
Rows and version links preserve the menu and the browser through prepared main-content navigation. The Visualizations
panel of the resource browser uses the shared REST component (§3.4), with complete lazy levels, server name search
and expanded ancestors, and clear-to-root. The signed-in chart loader reuses one compatible 3D bundle.

**The workspace.**

| Attribute | Value |
|---|---|
| URL | `GET /visualizations/{id}?version=N&tab=…` — one URL names one visualization, one viewed version and one tab. No `version` views the current release, else the working draft |
| Auth required | Yes — `visualization.read`. Absent, foreign or lens-hidden → the family's 404; a named version that is DISCARDED or does not resolve for the caller → the family's 404. A malformed `version` (not a positive integer) is a 400 whose message is a constant and never echoes the input. `tab` is the closed set below — anything else is Preview, never an error |
| Purpose | The tabbed, version-explicit workspace (#396's ruling, §4.21's shape): read a visualization's version, its test evidence and the dashboards that pin it; a person releases and manages versions from the Versions tab. The browser NEVER authors — agents author over MCP |
| Tabs (the floor) | **Preview (test fixtures)** — the DEFAULT. The viewed version's saved test cases rendered in the runtime's **fixture mode**, ONE case at a time behind a case selector (a case mounts on its first selection; a ten-case visualization boots one chart, not ten). The tab's label and its lead sentence say it shows fixtures; no case → the empty state, verbatim: "no test cases — the preview renders a visualization's test-case fixtures; live data runs inside a dashboard". No request leaves the pane — the configuration block is §4.22's, built by the SAME builder (`PreviewViews.page`), keyed `workspace:<id>:v<n>` and carrying no expiry. **Overview** — the viewed version's definition, read-only: name, display name, description, the renderer and its bundle, the inputs and their columns, the transform pin with its status and bindings, the test-case count and the body hash. **Evidence** — the version's test runs, newest first, and it SAYS its cap ("the newest 100 runs at most"); a run's detail shows its screenshot through the existing screenshot route (whose rules it follows — the image only when a screenshot was stored), else the text "no screenshot", and its cases, environment and mechanical report as text. **Used by** — the dashboard versions that pin this visualization, read through the caller's DASHBOARD lens (`DashboardService.pinnedBy`), each linking `/dashboards/{id}`; none → "no dashboard pins this visualization". **Versions** — the admitted history with current/draft/discarded markers, created and released relative in the cell and absolute UTC on hover (§3.7; the keys rule, §4.19), each version a boosted, rail-preserving link onto `?version=` (prepared main-content navigation, `hx-boost="true"` since 1d7b8457a; the server resolves the version and declares the ONE compatible bundle the loader mounts — §4.21's rule), the lifecycle verbs (below) and the Export link to `GET /api/v1/visualizations/{id}/export` |
| Lifecycle dialogs | Each verb is a GET (the dialog, into `#dp-dialog`) + POST pair under `/partials/visualizations/{id}/lifecycle/…`, session-only, CSRF-protected, the POST calling the SAME service verb the REST route calls and answering `HX-Redirect` — built server-side from the id and a whitelisted `from` — back onto the Versions tab (the entity purge onto the catalog), the flash bin rendering the toast. **release** (the refusals listed BEFORE the button in the service's order — no test case, the transform pin not released, then the evidence gate; the ONE `release_pinned_templates` consent checkbox when the pin is a DRAFT, D61; the form posts `body_hash`, the hash of the draft the dialog read, and the release is made AT it — #416, §4.3d), **purge-draft** and **purge-version** (typed confirm; the dashboards pinning that version listed), **discard** (names the version the current pointer falls back to, by the repository's D60 rule), **restore** (says whether the pointer moves), **switch**, **purge-entity** (typed confirm naming the visualization; only a sole draft). The buttons render only for a role whose `holds` admits the verb's §7.6 row — a viewer and a promoter see none |
| Design primitives | `dashboards.css` (the workspace frame and the runtime's emitted classes), `visualizations.css` and the vendored `plotly.css` — all head-loaded (§3.0) |
| JS | L3a's vendored runtime + the three renderers (the ONE Plotly bundle the server chose for the VIEWED version, declared on its tag; none in the choose-a-version state), `static/js/visualization-preview.js` (§4.22's glue, exposing its per-case mount as `window.DatapipelinesPreviewMount`), `static/js/workspace/panes.js` (the pane glue — the strip, the lazy panes, and the tab switches as pushed tab-only history entries of the `visualizations` family through `static/js/workspace/history.js` (#426): Back/Forward re-select in page through the helper's ONE window listener, a cached restore re-wires the strip once (the guard is an expando, never the `data-dp-ws-wired` attribute) and shows the tab it left, an entry the root cannot replay is handed to htmx — over the SHARED tab core `static/js/workspace/tabs.js`) and `static/js/visualizations/workspace.js` (the closed tab set and the one-case-at-a-time Preview) |
| htmx | The lazy tabs' one read each (a hidden tab causes no fetch), the evidence run detail, the dialogs' GET/POST pairs. Every link that names a version uses prepared main-content navigation and one lazily loaded compatible Plotly bundle; the tab strip never navigates |

Every case name, assertion label, run field and JSON document renders through `th:text`
(`textContent` in the glue); the Preview tab's configuration block is the page's one `th:utext`
slot, written through `ScriptSafeJson`. The workspace is NOT the session-less capability page
of §4.22: the public `/visualizations/{id}/preview` route admits none of these routes.

Guards: `VisualizationUiRenderTest` (the tree level, the flat search, the catalog, the ONE
bundle for a 2d and a 3d version, the hidden panes, the choose-a-version state, the Preview's
empty text verbatim, its selector and escaping, the Evidence cap and the screenshot / "no
screenshot" split, the Used-by link and empty state, the verbs per role, every dialog's states),
`VisualizationUiControllerTest` (the version resolution, the closed tab set, the non-echoing 400,
DISCARDED and lens-hidden 404s, the affordances per role, the lensed catalog),
`VisualizationPartialControllerTest` (the tree, the tabs' 400/404s, the evidence filter and cap,
the dashboard lens on Used by), `VisualizationLifecycleDialogControllerTest` (the verbs' audits,
the typed confirm before the service, the redirect whitelist, the session gate),
`VisualizationLifecycleDialogModelTest` (the refusal order, the consent, the D60 fallback, the
pinners per version), `VisualizationPreviewBlockTest` (the workspace block is the capability
block field for field), `ShellRenderTest` (the Visualizations branch and its search),
`ReadFloorTest` (the pages and tabs in `VISUALIZATIONS`, the dialogs in `LIFECYCLE_DIALOGS`),
`ScriptBlockUtextAuditTest`, `MutatingHandlerScopeFloorTest`, `PublicPathsTest` /
`PublicRouteWalkerTest`, `MatrixRowReachabilityTest`/`RoleWalkE2eTest`/`PermissionSeamE2eTest`
(the §7.6 Surfaces cells), `visualization-workspace.test.mjs` (the glue's decision rules on
node --test), and the browser suites `VisualizationSidebarTreeBrowserTest` +
`VisualizationWorkspaceBrowserTest` (every tab in both themes under the live CSP with zero
violations, the promoter's page issuing no lifecycle request, the 2d and 3d bundles, three widths).


### 4.25 Family overviews (#556)

| Attribute | Value |
|---|---|
| URL | `GET /overview/pipelines`, `/overview/templates`, `/overview/dashboards`, `/overview/visualizations`, `/overview/parameter-sets` — five literal routes, no path variable, no query; the Datasources overview joins them since #583 as `GET /overview/datasources` (§4.5d) |
| Auth required | Yes — each family's own read permission (`pipeline.read`, `template.read`, `dashboard.read`, `visualization.read`, `parameter_set.read`, and the Datasources overview's `datasource.read`; [Auth §7.6](/docs/auth.md#76-operation-matrix--the-permission-catalog-authoritative) names each route in its row's Surfaces cell); every role, a promoter through the lens (Datasources has no lens). GET only; nothing on the page writes |
| Purpose | A family's home page: what the workspace holds in that family, by lifecycle, and — for a family whose guide is written — what the family is for, how to start one with an agent, and where work left off |
| Design primitives | `.ds-card`, `.ds-button`, `.ds-badge`, `.ds-input`, `.ds-textarea`, `.ds-icon` (vendored lucide glyphs), one native `<dialog>`; the house data-table frame (§3.7) around each visually hidden table twin; the `.app-overview-*` rules in `app.css`, design tokens, the house type scale and the house breakpoints only (#638) |
| JS | `overview.mjs` on every overview, through the page-assets template (§3.0): the dialog (opened by key, closed by Escape, the close button or a backdrop click, focus returned, the table twin borrowed and given back), the question field, Copy prompt and Reset; no other script |
| htmx | none of its own — every link is a boosted navigation |

One template, `overview/family.html`, renders all five over one model (`OverviewController`,
`OverviewPage`); the Datasources overview (#583) reuses the SAME template and `OverviewPage` loops over
its own controller (`DatasourceOverviewController`, §4.5d) — a datasource has no `OverviewFamily`:
no tree, no lifecycle buckets, and the browser never opens for it. The shell treats `/overview/<family>`
as that family's in all four path rules (the
layout's `activeSection`/`browserFamily`, `AppNav.matches`, `shell.js` `isActiveSection`, `familyForPath`):
the family's menu item is active, the crumb names it and its resource browser panel opens (§3.4).
The Datasources overview is the deliberate exception — no `browserFamily`, so its browser never opens
while its item lights through the segment-boundary rule (§3.4; §4.5d).
Every tree family's menu item links its overview, `/overview/<family>` — Pipelines since #556, the
other four since #579–#582; the Datasources item links `/overview/datasources` since #583, and the
Schedules and API items their homes, `/overview/schedules` and `/overview/api-console`, since
#584/#585.

**Hierarchy (#638, the approved 2026-10-20 direction).** Six regions, in order, each a
`<section>` (or the dialog) named by its heading and carrying a `data-overview-*` hook:

1. **Heading** — an eyebrow with the family's icon, "Workspace" and the workspace name as a chip;
   the family title; the guide's `subtitle` line when it has one (Pipelines: "Build repeatable
   workflows. Understand what's live and what's next."); a promoter's line that the counts are the
   admitted inventory; **View all** (the catalog) with its count badge; and the primary action:
   **Create with your agent** (opens the prompt dialog) for a guide with a `prompt` fragment, else
   the guide's `primary` fragment (Schedules' New schedule), else none.
2. **Summary strip** (`data-overview-summary`) — the family's metrics side by side, each a label, a
   value and a one-line help; the Pipelines strip has a foot (below).
3. **Analytics grid** (`data-overview-analytics`) — one panel per chart: its visible heading names
   the chart (#620), then an optional description and period, an info button for the chart's
   explanation, the chart, a footer line and the chart's visually hidden table twin
   (`data-chart-table`, §3.7).
4. **Work grid** (`data-overview-work`) — **Continue your work** (`data-overview-recent`): the task
   rows, then **Recently updated** (the family's recent sections, unchanged: heading, note, rows
   with badge and time, the empty note `data-overview-recent-empty`, the unavailable line), then the
   guide's `related` links ("Build on your results"); and **Quick start**
   (`data-overview-quickstart`): the guide's `quickstart` (a headline, one sentence, the numbered
   steps), with **Prepare a starter prompt** and **How it works**.
5. The guide's `extra` fragment, full width (the API home's access and MCP cards).
6. **The dialog** — ONE native `<dialog data-overview-dialog>` per page, with a kicker (the family),
   a title, a close button and server-rendered sections keyed `definitions`, `releases`,
   `activity`, `prompt` and `howitworks`. `overview.mjs` shows the section the opener's `data-open`
   names, takes the title from its `data-title`, opens the dialog modal, and returns focus to the
   opener on close (Escape, the close button or a backdrop click). The `::backdrop` reuses
   `.u-backdrop`'s scrim (owner ruling 2026-10-20). A section with no content on a page is absent,
   and so is its opener.

Task icons use the closed tone set `draft` (warning tint), `released` (success tint) and `danger` (danger tint), each with token-backed background and ink.

An unguided overview shows the heading, the strip, the panels and Continue your work — no Quick
start, no prompt, no placeholder prose.

**The summary strip.** A metric is a label, a value and a help line, read from the same value as
the charts. The five lifecycle families (Pipelines; Templates, Dashboards, Visualizations and Parameter
Sets since #599) show the same four metrics and foot, each in its family's nouns ("Total dashboards",
"Retired parameter sets have no page."); Datasources, Schedules and API show their own counts.
Pipelines shows:

- **Total pipelines** (objects; "Across this workspace");
- **Live** (released only; "Released, with no open changes");
- **In development** (draft only; "Awaiting their first release");
- **Actively developed** (a separate measure; "Draft edited in the last N days", the configured
  window).

The strip's foot holds **Live with changes** (released with draft changes), **Retired** (discarded
only; unlinked, titled "Retired pipelines have no page.") and **How these are counted** (the
definitions dialog). The four lifecycle groups — Live, In development, Live with changes, Retired —
are exclusive and sum to Total. Actively developed overlaps them, and the definitions dialog says so.
The dialog also names each group in the catalog's words (Draft only / Released only / Released with
draft changes / Discarded only; #626).

**Lens words, per metric (#621):** the strip's heading reads "Admitted inventory", and the heading's
sentence names the lens, only when EVERY metric passed the lens. Otherwise each lensed metric carries
"Admitted inventory: only what your promotion view shows." under its value ("(admitted)" in the foot). Every metric of a lifecycle home passes its family's
lens (#599), so a promoter's strip there is headed "Admitted inventory". The API home's keys metric,
which the lens does not narrow, takes `lensed = false` (#639, §4.18a), so a promoter's API strip
is headed "Inventory".

**View all's badge** counts what the catalog and the resource browser's header count: the three live
groups, objects minus discarded only (#593). Every lifecycle home's badge is the same `objects −
discardedOnly` (#599), and a lensed page's is the admitted count. An unavailable inventory renders View all
with no badge, and so does a family whose panel names no such count (Datasources, Schedules, API).

**The counting model**, restated once (the record's §8; #553's `FamilyInventory`): one SQL snapshot
places each object of the workspace in exactly one of the four buckets by its versions. Objects is
their sum; With drafts is draft only plus released with draft changes; With releases is released only
plus released with draft changes, so those two overlap by design and say so. Every number on the page —
counts, bars, bar labels and the table — is read from that one value, so the chart cannot disagree with
its totals. A bar's width is its value relative to the tallest, 0–100, written as the SVG `rect`'s
`width` attribute (the CSP admits no style); a zero is a zero-width bar beside its "0", never a
visible minimum. Bars, parts and legend swatches are painted by four lifecycle tones — draft the warning series,
released the success series, released with draft changes the info series (`--chart-series-1`; #627 —
never the brand), discarded the muted series — and three semantic tones (#642): success, warning and
danger, `--accent-success` / `--accent-warning` / `--accent-danger` through `--overview-*`, for an ops
status — health, condition, a run's outcome — never for a lifecycle fact. The API's serving-draft
warning describes its serving posture, not a lifecycle group. Neither vocabulary relies on colour alone:
every part carries its words and count. Two measured gaps are listed in the sweep, not hidden: on professional the info series sits
0.073 from the link (#633); minimal paints the three series one black (#631).
A visually hidden table repeats the four rows for assistive technology.

**The Pipelines charts (#598a, re-cut by #638).** The same snapshot, extended by each
admitted pipeline's count of RELEASED versions, feeds the strip, its foot and both panels from ONE
statement. That statement is `ChartInventorySource`, built on `InventorySource`'s own `admitted` CTE
and its four lifecycle columns. Since #599 the four object families read the same statement over
their own object and versions tables (`TemplateRepository.charts`, `ArtifactRepository.charts` for
dashboards and for visualizations, `ParameterSetRepository.charts`), so everything below about a
pipeline holds for a template, dashboard, visualization or parameter set, in that family's words.

**Release history** has five bars, one per bin: No releases, 1 release, 2–3, 4–9 and 10+. Each bar
is the number of pipelines with that many releases, and the tallest is full width. No releases takes
the draft tone; the rest take the released tone. A bar reads name · track · value, the track a `rect`
whose `width` attribute is the share. Every bar chart on a page sits at ONE row pitch (`--space-10`),
with the chart at least the 192px floor (#620).

The note under the bars states the bins' sums in two sentences, worded for none, one, some and all
("All 4 unreleased pipelines have a draft." / "3 of the 10 released pipelines have open changes.";
`OverviewStacked.releaseNote`). The table keeps the Draft open / No draft / Total columns. The info
button opens **Understanding release history**: one bin per pipeline, the bars compare pipelines and
not executions, then the note.

A release since discarded is not counted (only RELEASED rows are), so a retired pipeline has 0.
Every pipeline sits in exactly one bin, so the bins total the strip's Total. `ChartInventory` refuses
a value whose bins disagree with the lifecycle total, and the page then reads unavailable. The
chart's words are its own: the catalog filter keeps `CatalogLifecycle.label`'s. Datasources, Schedules
and API keep their own bars and tables (§4.5d, §4.20a, §4.18a).

The definitions dialog says why Total can exceed the resource browser's header count: "The resource
browser and View all count the three groups that are not retired: N. Retired pipelines have no
page." The header counts draft only, released only and released with draft changes
(`FamilyRecentE2eTest`), and Retired is the discarded-only bucket (#593).

**The activity chart (#598b, re-cut by #638).** The same statement also counts
**Development activity**. It is counted against the request's one clock reading (`:now`) and the
configured window.

The chart is twelve columns, one per UTC calendar week (Monday 00:00Z to Sunday), bucketed on
`AT TIME ZONE 'UTC'` so the database session's time zone never moves a boundary. The columns read
oldest first, each named by its Monday ("6 Oct"); on the phone band only every third name shows.

Each column stacks two parts from the baseline:

- **Released** at the bottom: admitted RELEASED versions by `released_at`. A release since discarded
  is not counted, the release-history rule.
- **Created** on top: admitted pipelines by `created_at`, in the draft tone, since a new pipeline is
  a draft.

Each part is a `rect` whose `y` and `height` attributes tile the column in tenths, with no gap or
overlap however the shares round. The busiest week is full height, and a quiet quarter is all
zero-height parts at the baseline. Each column's `<title>` is its table row's sentence ("Week of 6
Oct: 3 created, 2 released, this week so far"). The axis labels the busiest count, its half and 0.

The headline is the quarter's events ("16 events"). The legend carries the twelve-week sums,
unlinked: the catalog has no week filter. The footer reads "No events this week" or "N events this
week so far". **View data** opens the activity dialog, which says "Events, not distinct pipelines. A
pipeline created and released in the same week contributes to both counts. Weeks begin Monday
(UTC); discarded releases are excluded." The panel's table twin MOVES into the dialog while it is
open and back when it closes, so the page holds one copy. The table's columns are Week of, Created,
Released, Events.

**Actively developed** is the strip's fourth metric: admitted pipelines holding an admitted DRAFT
last written (`COALESCE(updated_at, created_at)`) at or after `:now` minus
`datapipelines.ui.actively-developed-days` days (configuration §3.10, default 14, inclusive at the
edge). A draft keeps only its last write, so this is a count today, not a history. Per-week draft
edits are not shown (the owner's ruling on #598). Through a promoter's lens no DRAFT is admitted, so
it reads 0. `ChartInventory` refuses an actively developed count above the pipelines holding a
draft, or more created in the weeks than pipelines in total, and the page then reads unavailable.

**Links.** On the four-count card (the Pipelines card's until #598a), With drafts, With releases and
the Draft only, Released only and Released with draft changes bars link the catalog filtered to that
bucket (`/<family>?lifecycle=…`, §4.3; on the frame since #638, the strip's metrics and the
lifecycle chart's bars);
Objects links nothing (View all is the unfiltered catalog) and Discarded only stays unlinked, since a
discarded-only pipeline has no workspace page. An unguided family links no count or bar until its
catalog offers the filter; Templates offers it since #579 and links that way. The Datasources
overview links differently, as its model allows: each By-dialect bar links `/datasources?dialect=`
(the catalog's existing filter) and the health bars stay unlinked (§4.5d). On the Pipelines home
(#638), the strip's Live and In development and the foot's Live with changes link
`/pipelines?lifecycle=released`, `draft` and `released-with-draft`. Total, Actively developed and
Retired stay unlinked, as do the release-history bars and the activity legend. The task rows link
`?lifecycle=draft` and `?lifecycle=released`. The four object homes (#599) link the same metrics
and rows to their own catalogs (`/templates`, `/dashboards`, `/visualizations`, `/parameter-sets`).

**The prompt dialog (Pipelines; every guide with a `prompt`).** Create with your agent and
Prepare a starter prompt open it. The dialog holds:

- the question field ("What should this pipeline do?");
- the starter prompt in an editable textarea, with the workspace name the menu shows substituted
  server-side and the bracketed parts left for the reader;
- **Copy prompt** and **Reset**;
- the note.

Typing in the question field substitutes the prompt's first `[question]` by index and slice, as text,
never as markup, and only while the textarea still holds the text the field last wrote. An edited
prompt is never overwritten by typing. Reset restores the server's text and clears the question. The
prompt names no key, credential, datasource configuration or hidden resource.

**Copy prompt** puts the textarea's current text, edits included, on the clipboard. On success the
button reads "Copied" and returns. When the clipboard refuses or is absent, the text is selected,
the button reads "Select and copy", and a status line says how to finish by hand. Copying sends no
request and grants nothing. The note says so and, for a role that does not author pipelines,
suggests copying it for a collaborator.

**How it works** is the guide's longer steps, the chain to templates, visualizations and dashboards,
and the MCP server reference.

**Continue your work (Pipelines).** Two task rows come first:

- **Review your drafts**: the In development count, worded for 0, 1 and many, linking
  `?lifecycle=draft`;
- **Explore live pipelines**: linking `?lifecycle=released`.

A family whose catalog offers no such filter gets no row for it. The four object homes (#599) carry
the same two rows in their nouns ("1 dashboard is working toward its first release.", "Explore live
visualizations"). Then comes **Recently updated**:
up to five pipelines that hold a draft or a release, newest change first. Each is a link to its
workspace, with a draft badge when it has one and a relative time (the absolute time as its title).
The time is the latest of the pipeline's own update and its versions' draft write, release and
discard (`RecentSource`). It is the workspace's modification data, not anyone's history (#557 owns
Recent and Pinned).

**Through a promoter's lens** every surface reads the same admitted set: the admitted names' RELEASED
versions only. The strip, its foot, the counts and bars, the recent list (ordered by release time alone, so a hidden draft's
write moves nothing) and the filtered catalog all omit a hidden draft (`PromoterLensSweepTest`); the
Pipelines charts count the admitted names' RELEASED versions alone, so a hidden draft moves no metric
and no releases bin (the sweep reads every metric and table cell), and the activity chart counts only admitted
releases and admitted pipelines, with nobody actively developed (#598b). The
narrowed inventory admits an object with ANY released version, while the lensed catalogs keep their
current-pointer rule; the two can differ for an object whose current pointer is not a released version
(#563). Neither rule changes here.

**Each object home reads its own arm of the lens (#599).** `FamilyInventory.chartsFor` picks the same
member of the promoter's view that `forFamily` picks, so a family's charts are never narrowed by
another family's arm. Templates and Parameter Sets are narrowed as Pipelines are: a RELEASED current
version newer than the target's entry (absent there, or a different body at a higher version). The
Dashboards and Visualizations arms are **derived** (#330, `PromotableView.dashboardArms`, computed once
on the first read of either): a dashboard is admitted when its current version is RELEASED, EVERY
pipeline it sources is admitted by the pipeline arm, and it is newer than the target's entry; a
visualization is admitted when an admitted dashboard pins it and it is RELEASED (#563). So a draft
dashboard, a dashboard the target already holds, or a visualization pinned only by that dashboard
moves no number on either home (`PromoterLensSweepTest` seeds each).

**States.** An inventory that cannot be read says "The inventory is unavailable." with a reason in
the strip's place. The page then shows no number at all: no panel, no badge, no worded count (the
drafts row reads "Pipelines working toward their first release."). An unknown count never reads as
zero. Recently updated has its own unavailable
line and, in an empty workspace, a note pointing at the starter prompt. A real zero is a "0".

**Storage:** none — no browser key, cookie or server preference.

Guards:

- `OverviewRenderTest`: one inventory feeds counts, bars and table; widths; unavailable without a
  digit; links; escaping; unguided families bare. For Pipelines (#638): the strip sums to
  Total; Retired unlinked; the badge = the three live groups; the drafts words; twelve columns,
  oldest first, tiling, each title its table row; the release note = the bins; the dialog's five
  sections; per-metric lens words. For all five lifecycle families (#599, `OverviewRenderSupport`):
  the strip sums to Total and links the family's own catalog in its own words, an empty workspace
  draws all-zero panels, and a family without a guide renders the frame bare; the Dashboards,
  Visualizations and Parameter Sets render tests add their own words.
- `ChartInventorySourceTest` and `ChartInventorySourceIntegrationTest`: the statement's text and
  binds; counts against a Kotlin oracle, unlensed and narrowed; each family's statement reads its own
  object and versions tables only (#599).
- `FamilyInventoryTest` (#599): each family's charts read its own repository with its own lens arm,
  once; a failed read or an unavailable view is unavailable, never zeros; a failed lazy dashboard
  derivation makes only the two artifact homes unavailable, and the other families never derive it.
- `OverviewStackedTest`: random inventories. The strip sums to Total, the badge counts all but
  Retired, every column tiles its own height, the bars are shares of the tallest, and the note is
  worded for none, one, some and all.
- `ShellRenderTest`: the family paths and the Datasources overview light their item, crumb and
  panel.
- `FamilyRecentE2eTest`.
- `OverviewE2eTest`: each catalog lifecycle count equals the inventory's, unlensed and narrowed;
  each family's chart group is its own seeded lifecycle, weeks and actively developed (#599). Each
  `Overview<Family>E2eTest` (#599): the strip's four metrics sum to Total in the family's words.
- `PromoterLensSweepTest`: every metric and table cell through the lens; since #599 every family's
  home, the promoter's numbers against the viewer's over one seed with hidden drafts and a derived
  dashboard and visualization arm.
- `overview.test.mjs`: fill, compose, no overwrite, Reset, open by key, title as text, focus
  return, the borrowed table, backdrop versus inside, zero requests.
- `OverviewBrowserTest`: the landing, with geometry at 1512 (four equal strip cells, twelve columns
  at one pitch, the plot 192px), no horizontal overflow at 390/768/1024/1280, and screenshots; the
  definitions dialog with Escape and focus return; View data leaves one table; the question composes
  and markup stays words; copy resolves and refuses with zero requests; a workspace switch.
- `OverviewDashboardsBrowserTest`, `OverviewVisualizationsBrowserTest`,
  `OverviewParameterSetsBrowserTest` (#599): the strip and foot, the twelve columns and the bins as
  their tables state them, the definitions dialog with Escape and focus return, Review your drafts
  landing on the catalog filtered to `draft`, screenshots at 1512 and 390 in both themes.
- `OverviewToneBrowserTest`: every tone site reads one colour, and the columns' and bars' tones are
  distinct from the track, the panel and the text. On Minimal, two more ΔE-0 rows (In
  development/text, Live/text) are recorded under #631 on the owner's ruling of 2026-10-20.
  #642 adds semantic dot/bar equality and success/warning, success/danger, warning/danger; each semantic
  tone against track, panel and text; warning/live; and success and danger against Live with changes
  and Retired. New Minimal gaps remain tracked under #631; other themes retain the same floor.

**Families as built.** Each family's row lands with the lane that builds its home page; a family without a row is
served (inventory only) and its menu item still links the catalog.

| Family | Menu item links the overview since | Guide | Recently updated | Catalog `lifecycle` filter |
|---|---|---|---|---|
| Pipelines | #556 | `overview/guide-pipelines.html` — subtitle, Quick start, the prompt and How it works dialogs, related: Visualizations, Dashboards, MCP server reference | `PipelineRepository.recent` | `/pipelines?lifecycle=` — every bucket but `discarded` (§4.3); since #638 the summary strip and its foot, Development activity (#598b) and Release history (#598a), all from `PipelineRepository.charts`, one statement |
| Templates | #579 | `overview/guide-templates.html` — since #599 subtitle, Quick start, the prompt and How it works dialogs, related: Pipelines, MCP server reference | `TemplateRepository.recent`, the same `RecentSource` rule; each entry links `/templates/<name>`, every segment URL-encoded (the tree's form) | `/templates?lifecycle=` — every bucket but `discarded` (§4.6); a bucket read places an object by its latest version (`TemplateRepository.listWhere`); `OverviewTemplatesE2eTest` pins each count equal to the inventory's, unlensed and through a narrowed lens; since #599 the summary strip and its foot, Development activity and Release history, all from `TemplateRepository.charts`, one statement |
| Dashboards | #580 | `overview/guide-dashboards.html` — since #599 subtitle, Quick start, the prompt and How it works dialogs, related: Pipelines, Visualizations, Parameter Sets, MCP server reference | `DashboardRepository.recent`, the same `RecentSource` rule | `/dashboards?lifecycle=` — every bucket but `discarded` (§4.21); `OverviewDashboardsE2eTest` pins each count equal to the inventory's, unlensed and through the promoter's real lens; `OverviewDashboardsRenderTest`, `OverviewDashboardsBrowserTest`; since #599 the summary strip and its foot, Development activity and Release history, all from `ArtifactRepository.charts` (the dashboard kind), one statement |
| Visualizations | #581 | `overview/guide-visualizations.html` — since #599 subtitle, Quick start, the prompt and How it works dialogs, related: Pipelines, Dashboards, MCP server reference | `VisualizationRepository.recent`, the same `RecentSource` rule; through a narrowed lens, admitted RELEASED versions and their release time alone | `/visualizations?lifecycle=` — every bucket but `discarded` (§4.24); `OverviewVisualizationsE2eTest` pins each count equal to the inventory's, unlensed and through a narrowed lens; since #599 the summary strip and its foot, Development activity and Release history, all from `ArtifactRepository.charts` (the visualization kind), one statement |
| Parameter Sets | #582 | `overview/guide-parameter-sets.html` — since #599 subtitle, Quick start, the prompt (drafts a set and checks it by evaluating) and How it works dialogs, related: Templates, Dashboards, MCP server reference | `ParameterSetRepository.recent`, the same `RecentSource` rule; each entry links `/parameter-sets/<id>` | `/parameter-sets?lifecycle=` — every bucket but `discarded` (§4.23); guards `OverviewParameterSetsRenderTest`, `OverviewParameterSetsE2eTest`, `OverviewParameterSetsBrowserTest`; since #599 the summary strip and its foot, Development activity and Release history, all from `ParameterSetRepository.charts`, one statement |
| Datasources (#583) | #583 | `overview/guide-datasources.html` — what datasources are for, a register-first starter prompt; related: Pipelines, Templates, Dashboards, MCP server reference | `DatasourceRepository.recentGrants` — GRANTS, not modifications: `granted_at` newest first, name ASC tie-break, each entry linking `/datasources/<name>`, no lifecycle badge | no `?lifecycle=` filter — the By-dialect bars link the catalog's `?dialect=` filter and the health bars stay unlinked; NOT a browser family (the browser never opens); guards `DatasourceOverviewControllerTest`, `OverviewDatasourcesRenderTest`, `DatasourceRepositoryIntegrationTest`, `WorkspaceSurfacesE2eTest`, `OverviewDatasourcesBrowserTest` |
| Schedules (#584) | #584 | `overview/guide-schedules.html` — what schedules are for, How to create one (a person's steps, New schedule for an authoring role); NO agent section or starter prompt; related: Pipelines, Executions (a role that reads them), REST reference | `ScheduleService.summary` — Up next (enabled and unblocked, soonest first) and Recently changed (`updated_at`, newest first), each linking `/schedules?id=<id>`, no lifecycle badge | no `?lifecycle=` filter — a schedule has no lifecycle; the By-condition and Last-run bars stay unlinked; NOT a browser family; guards `OverviewSchedulesControllerTest`, `OverviewSchedulesRenderTest`, `ScheduleSummaryIntegrationTest`, `OverviewSchedulesE2eTest`, `OverviewSchedulesBrowserTest` (§4.20a) |
| API (#585) | #585 | `overview/guide-api-console.html` — what the API is for, How a program gets access (publish / key / call, who does each), a publish starter prompt that never handles a key, the shared MCP card; related: Pipelines, Dashboards, REST/MCP/Claude Code references | two sections, not `RecentSource`: Recently published (`EndpointPublishService.list`, lensed, valid rows only, links `/api-console`) and Recently created keys (the Keys page's narrowing, by name, links `/api-keys`) | no `?lifecycle=` filter — the state bars link the console, the kind bars `/api-keys`; NOT a browser family; guards `OverviewApiInventoryTest`, `OverviewApiControllerTest`, `OverviewApiRenderTest`, `OverviewApiE2eTest`, `OverviewApiBrowserTest` (§4.18a) |

## 5. htmx Usage Pattern

Standard pattern for list/filter/pagination. The page (`GET /pipelines`) renders the shell **and** the initial fragment; every subsequent refresh hits the partial endpoint (`GET /partials/pipelines`) and swaps the fragment only.

**One BrowseModel per list screen (normative, 097).** A list screen is one `<Entity>BrowseModel` + a page controller + a partial controller. The page controller renders the shell AND the initial fragment **through the model**; the partial controller renders every subsequent fragment **through the same model**; neither filters, pages nor projects on its own. The model owns the query, the search, the paging and every attribute the fragment reads; the controllers own only their route's extra chrome.

This is a rule because the alternative was tried on three screens:

- **Datasources** had the two controllers and no model. Each grew its own `filter()`, and they diverged — the page searched 3 fields where the partial searched 10, so `GET /datasources?q=postgres` (a reload, a shared link, a boosted navigation) came back EMPTY while typing the same word into the same box returned rows.
- **Executions** had a page controller that projected nothing: it rendered a spinner and let `hx-trigger="load"` fetch the rows, so the screen's first paint was an empty frame waiting on a request the browser had not made yet — and the page ignored the filters in its own URL.
- **Admin users** had no projection layer in either direction: the rows were built as HTML strings in Kotlin, where no template audit could see them.

The convention is drift-tested by `BrowseModelConventionTest`, which asserts both halves of every pair inject the same model AND that every `*PartialController` is either half of a pair or carries a written exemption (the dashboard's, the execution detail's, the pipeline editor's node-SQL seam, the API console's).

**First paint is the same everywhere, with ONE exception.** Every list screen renders shell + initial fragment. The **dashboard** is the deliberate exception (`dashboard.html`): it is four independent panels — stats, recent executions, recent pipelines, health — that load in parallel behind skeletons, so there is no single list to project and no reason to make the slowest panel decide when the page appears. It is also the only screen where a skeleton is the right first frame, because the alternative is a page that arrives late rather than a page that fills in.

Filter values are carried by **form fields plus `hx-include`** — never by string-interpolating a query parameter into an `hx-get` URL. Interpolation is evaluated once, server-side, at page render; it would freeze whatever the search box contained at that moment (usually empty) and every later request would silently drop the filter.

```html
<!-- Filter controls. Each control issues the request; hx-include re-sends the whole group,
     so the search term, the datasource filter and the offset always travel together. -->
<div id="pipeline-filters">
    <input type="search" name="q" class="ds-input"
           placeholder="Search pipelines…"
           hx-get="/partials/pipelines"
           hx-trigger="keyup changed delay:300ms, search"
           hx-include="#pipeline-filters"
           hx-target="#pipeline-results"
           hx-swap="outerHTML"
           hx-indicator="#pipeline-loading">

    <select name="datasource" class="ds-select"
            hx-get="/partials/pipelines"
            hx-trigger="change"
            hx-include="#pipeline-filters"
            hx-target="#pipeline-results"
            hx-swap="outerHTML"
            hx-indicator="#pipeline-loading">
        <option value="">All datasources</option>
        <option th:each="d : ${datasources}" th:value="${d.name}" th:text="${d.displayName}">pg-main</option>
    </select>

    <span id="pipeline-loading" class="ds-spinner htmx-indicator" aria-hidden="true"></span>
</div>

<!-- The swapped fragment: table + pager, returned whole by /partials/pipelines -->
<div id="pipeline-results">
    <!-- every table is the data table (§3.7): frame › viewport › table -->
    <div class="dt-frame" data-dt-paged>
      <div class="dt-viewport" tabindex="-1">
        <table class="ds-table">
            <tr th:each="p : ${pipelines}">
                <td><a th:href="@{'/pipelines/' + ${p.id} + '/editor'}" th:text="${p.displayName}">Name</a></td>
                <td th:text="${p.description}">Desc</td>
                <td><span class="ds-badge ds-badge--neutral" th:text="'v' + ${p.currentVersion}">v1</span></td>
            </tr>
        </table>
      </div>
    </div>

    <!-- Pager: the next offset is a server-rendered value, not a client-side expression -->
    <button th:if="${hasMore}" class="ds-button ds-button--secondary"
            hx-get="/partials/pipelines"
            hx-include="#pipeline-filters"
            th:attr="hx-vals=|{&quot;offset&quot;: ${nextOffset}}|"
            hx-target="#pipeline-results"
            hx-swap="outerHTML"
            hx-indicator="#pipeline-loading">
        Load more
    </button>
</div>
```

Server returns a partial HTML fragment (the `#pipeline-results` div), htmx swaps it in. No full page reload. No client-side rendering. No JSON parsing.

Two rules this example encodes, applicable to every list screen:

1. **Parameters come from named form fields**, gathered with `hx-include`. Values the user never edits (the next `offset`) are server-rendered into `hx-vals` with `th:attr`.
2. **The swap target is a wrapper that contains everything that changes** — table *and* pager — so one `outerHTML` swap keeps them consistent. Swapping only the `<table>` leaves a stale pager behind. On the screens that swap `outerHTML`, the wrapper is the list **fragment's own root element** (`#pipeline-list-wrapper`, `#template-list-wrapper`, `#datasource-list-wrapper`): the id must live on the fragment root, never on the page's host div — `th:replace` removes the host, so an id written there never reaches the DOM and every swap targets nothing.

**The shared pager.** Every list screen renders the one fragment `partials/pager :: pager(targetId, prevUrl, nextUrl, offset, hasMore, shown, total)`. The fragment never builds URLs: Thymeleaf link expressions take literal parameter names, so a per-screen filter set cannot be splatted into `@{...}` — each caller builds `prevUrl`/`nextUrl` with its own `th:with` (keeping that screen's filters in the query string) and passes the finished strings. `total` is nullable: a screen that computes no count renders `Showing N` rather than `Showing N of M`. Previous is disabled at `offset == 0`, Next when `hasMore` is false. The executions screen is the deliberate exception — its `#execution-table` / `innerHTML` contract is documented in §4.8.

**The URL describes the list (#591, normative).** Every catalog that offers `lifecycle` — Pipelines, Templates, Dashboards, Visualizations, Parameter Sets — answers each swap of its list with `HX-Replace-Url`, so the address bar always names the list on screen: a copied link shares it, a reload renders it (the page route binds the same parameters), and the select, the search, the templates' dialect and type, the pager and the empty state's Clear buttons all keep it current without a line of client code. ONE helper, `CatalogUrl`, builds it from the values the partial handler already bound and validated — `q` trimmed, the parsed bucket's wire value, the offset past the first page, the templates' parsed dialect and type — form-encoded on the catalog's own constant path, never from the raw query string; a refused `lifecycle` is the house 400 before the header is written, and an unfiltered first page stands at the bare path. It is a REPLACE, never a push: the search fires per keystroke after its 300 ms debounce, and a push would make each keystroke a history entry Back steps through one by one, so Back leaves the catalog for the page before it. Each catalog's filter block also carries `hx-history="false"` (owner ruling 2026-10-21): an htmx snapshot serialises the controls' rendered attributes, not their live values, so a restore put the filtered list under an empty search box and the first-rendered select; without a snapshot, Back to a catalog fetches the page at its URL and the server renders list and controls from the same parameters — one page render instead of a cache restore, and the list's scroll position is not kept. Back or Forward onto a catalog is therefore an htmx history MISS, whose response swaps in when it arrives; a reader who navigates again before it lands can see it overwrite the next page (htmx does not check the address; #660 tracks the guard). It also means a filter change never runs §3.2's `beforeHistorySave` cleanups on the live page (`shell.js` header note 9). Guards: `CatalogUrlTest` (the URL), each family's partial-controller test (the header, and none on a 400), and `CatalogUrlBrowserTest`, parameterised over the five families (reload after the select and after the search, one request per change, Back after replaces, Back from an object, no history save).

### 5.1 Standard States

Every list, panel and form on these screens implements the same three states. They are layout-shell concerns, specified once here rather than per screen.

**Empty state.** When a collection legitimately has zero rows, the partial returns a `.ds-empty` block — `.ds-empty-title` naming what is missing, `.ds-empty-description` with the follow-up, and `.ds-empty-actions` for the action if the user has scope for it ("No pipelines yet — Create pipeline"). These are the only empty-state classes; `.ds-empty-state` is **not a class** — no stylesheet defines it (four templates once used it and every pixel came from the inline styles beside it). Distinguish the two empties: *nothing exists* gets the create action; *nothing matched the filter* gets "No pipelines match "…" — Clear filters". **The clear control empties what it names (#432).** The filter controls live on the page, outside the list it re-fetches, so a bare re-fetch alone leaves the stale query in the box. The button therefore carries `data-dp-clear-filters` — the comma-separated `#id` selectors of the controls it resets (search box first; a select returns to its `value=""` "All …" option); `shell.js`'s one delegated click handler empties them and focuses the box, never firing their `input`/`search` triggers, so the click stays one request. That request describes the controls left standing (`hx-include` of the kept filters — Templates keeps dialect and type) and carries `hx-sync="#<box>:replace"`, so a search still in flight from the box is aborted rather than landing after the clear. **Which of the two empties is shown reads ANY standing filter, not the query alone (#510).** The model stamps one `filtered` flag — any of `q`, dialect or type standing — and the fragment picks the empty on it: a filter with no matches and an empty box is a *filter miss*, never "nothing exists". When only a select stands (the box is already empty), Templates' control is a **Clear filters** sibling that names all three (`#template-filter-q, #template-filter-dialect, #template-filter-type`) and carries no `hx-include` — its own request is the unfiltered first page; it keeps the `hx-sync` on the box. The resource browser's own Clear search (§3.4) is a different control. An empty table with only a header row is not an acceptable empty state. **In a data table (§3.7, 282) the empty state is a ROW of the same table** — `.dt-state-row` › `.dt-state`, the title in `<b>` and the follow-up beside it — so the header stays and the columns do not disappear with the rows (the executions list, recent executions; the keys and admin-users tables already carried theirs as a spanning row); a screen whose empty case has no table to keep (a dialog saying "Granted to nobody", a list screen before its first item) keeps `.ds-empty`.

**Search.** A screen's server-side search covers every column that screen renders; where a column is derived, the search matches the rendered text (a dialect enum's wire value, an unbound workspace's `global` literal, a `readonly` restriction badge). A search that silently ignores a visible column reads as "no results" to the user.

**Loading state.** Every htmx request gets a signal at three levels (085 §D — previously the top bar was boosted-navigation only, and partials leaned on their own spinners alone):
- **The 2px bar under the nav (`#app-progress`) shows for EVERY request**, boosted or partial. `shell.js` keeps an in-flight COUNT — shown on 0→1, hidden on →0 — so a tree expand and a detail load running together cannot hide it early. The counting pair is `htmx:beforeRequest` / `htmx:afterRequest`, because afterRequest is the one terminal event htmx 2.0.10 fires on every outcome: `htmx:afterSettle` never fires for an aborted request, a network error or a timeout, and a bar that can stick on is worse than none.
- **The originating control goes busy.** `shell.js` marks the requesting element with the shell's own `.app-busy` class for the flight — deliberately NOT htmx's `.htmx-request`, which htmx 2.0.10 applies to the `hx-indicator` TARGET instead whenever the element carries `hx-indicator` (the tree leaves do), leaving the control itself unmarked. A `<button>` in that state is non-interactive (`pointer-events: none` — `shell.js` adds `aria-disabled="true"` for the duration, never the `disabled` property, which would drop focus mid-flight; htmx already guards re-triggering) and draws an `::after` spinner ring in the `.ds-spinner` idiom, absolutely positioned so the control's box never changes (the no-layout-shift rule below stands). A folder `<summary>` is the deliberate exception: it gets the class but stays operable while its level loads (collapse/re-expand mid-fetch is pinned behaviour) and its busy signal is the chevron spinning in place. Mutating buttons (`Save`, `Generate key`, `Test connection`) still additionally set `hx-disabled-elt="this"` against double-submit, and elements carrying their own `hx-indicator` spinner keep it — the busy state is additive, never a replacement.
- **A slow swap target gets a delayed skeleton.** A request still in flight 150ms after `htmx:beforeRequest` marks its target `aria-busy="true"` and gains ONE skeleton row (`.ds-skeleton .ds-skeleton-table-row .app-target-skeleton`); a faster swap never flashes either. Timers and skeletons pair per target — concurrent requests into different panes are normal — and the terminal event removes both. The detail panes (`#template-detail`, `#pipeline-detail`) are the primary beneficiaries; the mechanism is generic. **The target is resolved once, up front** (`swapTargetFor`): a boosted navigation's htmx target is `<body>` until the beforeSwap retarget, and a skeleton on body sits outside the 100dvh shell — the one place that can grow the document — so a boosted request's skeleton goes into `#app-main`. **The history snapshot carries no transient chrome**: htmx snapshots the page during the swap, before the terminal event, so `htmx:beforeHistorySave` strips every live skeleton and busy mark first, and `htmx:historyRestore` / `pageshow` purge any orphan a cache written earlier still holds. (2026-09-13: the owner's `/dashboard` carried a document scrollbar — a body-level skeleton row restored from the history cache after a slow boosted navigation away and a Back.)
- **The click itself is acknowledged before any of the above** (103 §3.5): a boosted navigation dims and `aria-disabled`s the clicked link in the same frame as the press, and a status pill joins it after 150ms. That layer is specified in §3.5 — it is a property of the SHELL, not of a request, and it is cleared on a different union of events than the bar is.
- **A data table's first-paint placeholder is skeleton ROWS of the same table** (§3.7, 282): the dashboard's recent-executions panel renders the partial's own header (`recent-executions :: head`) over three `.dt-skeleton-row`s, so the header is in place before the rows arrive. The delayed skeleton above is unchanged (shell.js's, for any slow swap).
- Reduced motion keeps every state and drops every motion: the bar's slide, the control ring's spin (it goes dashed-static, the `.ds-spinner` precedent), the chevron's spin (a static accent chevron instead) and the skeleton's shimmer are all covered by their own `prefers-reduced-motion` blocks.

**Error rendering.** A partial request that fails returns the **standard REST error envelope** ([REST §4.2](/docs/rest-api.md#42-error-envelope)) rendered into an HTML fragment — the same `code` / `message` / `user_message` / `correlation_id`, not a bespoke error format. No htmx extension is loaded: htmx never swaps 4xx/5xx responses on its own (`responseHandling` maps `[45]..` to `{swap: false}`), so a refusal that should surface as a toast keeps its real 4xx status, carries the `partials/toast-oob` fragment as its body, and sets the `HX-Retarget: #toast` + `HX-Reswap: beforeend` response headers; `toast.js`'s `bridgeErrors` listener flips `shouldSwap` on `htmx:beforeSwap` — only when the server asked for `#toast` by header, so an ordinary error behaves exactly as before (Shape C under **Notifications**).

- The retarget leaves the success target untouched — a failed refresh never blanks the panel it was going to replace.
- The server renders the envelope into a `.ds-toast-danger` fragment: `user_message` as the headline, `code` and `correlation_id` in small text (the correlation id is what a user quotes in a support request), and `doc_url` as a link when the envelope carries one.
- **Field-level validation errors** (`400` with per-field `details`) are the exception: they render inline next to the offending inputs, because a toast that vanishes is the wrong place for "this field is required".
- **Modal-scoped errors** stay in the modal: a screen whose error must not dismiss its context (the §4.5 register modal) keeps its own `htmx:responseError` path and does NOT retarget to the stack.
- **A malformed typed value** (#408) — a `UUID`, `Int`, `Long` or `Boolean` path or query value that does not convert (`/dashboards/not-a-uuid`, `?version=abc`) — is the caller's error, never the 500. A malformed **path** value names nothing, so it answers exactly as a well-formed absent id does: the §6 404 page, or the 404 toast with the absent id's code. A caller cannot tell the two apart. A malformed **query or form** value is a `400` with the REST answer to the same value (`pipeline.execution.invalid_parameter_type`, "Parameter '<name>' is missing or not of the expected type."), which names the parameter and never echoes the value. The split follows the parameter's binding (path or query), never the value's shape; the line is DEBUG, never ERROR. A parameter type no converter can read is our defect, whatever the caller sent, and stays the 500.
- **`401`** is not a toast — the partial responds with `HX-Redirect: /login?expired=true`, which sends the browser to the login page (§6). Rendering a login form inside a swapped fragment would nest a page inside a panel.
- **`403`** renders a toast and, where the affordance should not have been visible at all, the swap also removes it — a scope-gated action becoming visible is a UI bug, and the toast copy says "you don't have permission", never "something went wrong".

**Notifications.** Success and failure alike are reported as **toasts**: the layout carries a single `#toast` stack (`.ds-toast-stack`, `aria-live="polite"`, top-right below the header), and a partial that has something to report returns the server-rendered `partials/toast` fragment (`.ds-toast` + one of the design-system variants `success` / `danger` / `warning` / `info`, title + body + close button) — the panel, table, or form that fired the request is NOT re-rendered, so a notification can never break layout. `static/js/toast.js` is loaded once by the layout and owns the whole lifecycle: a `MutationObserver` arms each appended toast with an auto-dismiss timer (6s) and its close button; exit is the design system's own `.exiting` animation. Markup is built client-side in exactly one place, `DpToast.show`, for events that carry no HTTP response; everything else is server-rendered (the 025 theme-swap rule: fragments are rendered by Thymeleaf).

**The hard rule.** A toast auto-dismisses after 6s, so it NEVER carries anything the user must keep. One-time secrets (`partials/api-key-created.html`, the admin one-time-password notice) stay persistent inline; a toast may POINT at them ("shown below, copy it now") and nothing more. Field-level validation stays inline at the form.

**Delivery shapes.** htmx 2.0.10 swaps the CHILDREN of an out-of-band element for any swap style other than `outerHTML` ("we use the content of the node, not the node itself" — `oobSwap` in `htmx.js`), so a toast bound for the stack is always wrapped: `partials/toast-oob :: oob(variant, title, message)` renders the `hx-swap-oob="beforeend:#toast"` wrapper with the `.ds-toast` as its child. Putting `hx-swap-oob` on the `.ds-toast` itself appends the close button, title and body BARE into the stack — no `.ds-toast` node, no arming, no auto-dismiss, and no error anywhere (`ToastOobFragmentRenderTest` pins the nesting).

- **Shape A — content + toast.** The response is the normal swap content with the `toast-oob` fragment spliced in; the triggering control keeps its `hx-target`/`hx-swap`.
- **Shape B — toast only.** The control sets `hx-swap="none"`; the response body is the `toast-oob` fragment alone. No response headers.
- **Shape C — refusal as toast.** The response keeps its real 4xx status, its body is the `toast-oob` fragment, and it sets `HX-Retarget: #toast` + `HX-Reswap: beforeend`; `bridgeErrors` (see **Error rendering**) is what lets htmx swap it.
- **Shape D — client-originated (the exception, not a convenience).** `DpToast.show(variant, title, message)` in `toast.js` builds the one toast shape and appends it to `#toast`, for events that arrive with no HTTP response to attach an OOB swap to — the pipeline editor's SSE terminal events (`pipeline_completed`, `execution_aborted`, `pipeline_failed`) — and for the one screen whose every outcome is a REST JSON answer by design, the Schedules page (§4.20; the scheduler design revision §6 forbids it a partial route, so no response of its carries a fragment). It is built with `createElement` + `textContent`, never `innerHTML`: titles and bodies carry abort reasons, node ids and error text, none of which is trusted markup. Any outcome that arrives on an HTTP response uses A, B or C — nothing else in the codebase gets a second toast builder. Both markup definitions — `partials/toast.html` and `show` — assert ONE contract: root classes `ds-toast ds-toast-{variant}`, `role="status"`, exactly three children in the order close / title / body, pinned by `ToastMarkupParityTest` (server) and `toast.test.mjs` (client).

**Typed confirm (102) — the irreversible actions ask the user to TYPE the thing that will be destroyed.** A destructive verb that cannot be undone (the version purge, the entity purge) does not arm its button on a click alone: the dialog renders a field naming exactly what to type — `v4` for a version, the entity's NAME for a purge — and the `ds-button-danger` stays disabled until the typed value matches. It is the second use of the convention after the CLI's `--clean` (same reason: the action deletes rows, not state), and it is enforced TWICE: client-side for the button state, and server-side — the `confirm` form field is checked before the lifecycle service runs, and a mismatch is `400 pipeline.version.confirm_mismatch` / `template.version.confirm_mismatch` (§5.1 Shape C), so a dialog forged or scripted around the field still cannot purge anything. Reversible verbs (Release, Discard, Restore, Switch) do not type-confirm — their one click names one version, and the undo is a sibling verb.

---

## 6. Error Pages

These are **full-page** errors — the result of a browser navigation to a page URL. Errors raised by an htmx partial swap never navigate here; they follow the toast/inline rule in §5.1. The single bridge between the two is `401` on a partial, which returns `HX-Redirect: /login?expired=true` and lands on the first row below.

| Page | URL | Content |
|---|---|---|
| 401 (Unauthorized) | `GET /login?expired=true` | Login page with "Your session has expired" message |
| 400 (Bad Request) | rendered in place (`error/400`) | "That request could not be read" + link to dashboard. The sentence under it is the form's ("Something in the form that was sent is missing or malformed") by default; a malformed value in a GET's address (#408, §5.1) says "The address names a value this page cannot read. Check the link and try again." Neither names the value |
| 403 (Forbidden) | `GET /error?status=403` | "You don't have permission to access this page" + link back to dashboard |
| 404 (Not Found) | `GET /error?status=404` | "Page not found" + link to dashboard. A malformed id answers as an absent one (#408, §5.1) |
| 500 (Server Error) | `GET /error?status=500` | "Something went wrong" + correlation_id for support |
| Login errors | `GET /login?error={code}` | Login page with error message (domain_not_allowed, inactive, oidc_error, identity_mismatch) |

All error pages use the design system's `.ds-card` with appropriate `.ds-text--danger` or `.ds-text--warning` classes.

**The no-workspace page is NOT one of them (114).** `workspaces/none` — reached when a principal
has zero ACTIVE memberships — renders in the app shell, says what happened ("You're not a member of
any active workspace"), says who to ask, and offers the create form to a super admin. Nothing
failed: their credential is fine and their membership is absent, which `error/403` would describe
wrongly. To that person a deactivated workspace and one that never existed look the same
([Auth §11A.3](/docs/auth.md#11a3-deactivation)), so the page names neither. See §4.13 for the
reachability gap this round left open and the one-line fix it needs.

---

## 7. Future Screens (Not in v1)

| Screen | When |
|---|---|
| Pipeline create wizard (guided) | v2 — drag-and-drop graph editor |
| Template library browser | v2 — browse/import community library templates |
| Execution comparison | v2 — compare two executions side by side |
| Webhook management | v2 — configure webhook subscriptions for execution events |
| Audit log viewer | v1.1 — searchable audit log UI for admins |
| System metrics dashboard | v1.1 — Micrometer metrics visualization |

---

## Appendix A: Change Log

| Date | Version | Author | Change |
|---|---|---|---|
| 2026-11-02 | v1.223 | #784 — the browser tree's guides read at the floor | **§3.4, §3.6:** the indent per level 8→12px (`--space-3`), the guides' tick 2→6px (#739's guide geometry otherwise unchanged); band defaults 300→320px (1101–1599) and 320→340px (≥1600), 270px stays; the browser's minimum 240→280px — Home, `--app-browser-min`, and `rail.js`'s pre-paint gate, so a stored value from the old range (e.g. 256) is removed on sight, not applied, and under the clamp the floor wins over the band default (280px at a 900px viewport; `BrowserMaximumBrowserTest` pins it, including the removal). Guards re-measured off the real page: `BrowserResourceRowBrowserTest` (indent 12, tick 6, badge containment at 280/320/480, both families and themes), `TreeIndentAndVersionMenuBrowserTest` (the 12px step), `PipelineSidebarTreeBrowserTest` and `TemplateSidebarTreeBrowserTest` (band defaults, the 280 floor, tablet band), `ReusableRestTreeBrowserTest`, `DashboardFiltersBreakpointBrowserTest`, `DashboardGridRowUnitBrowserTest`, `PersistentChartNavigationBrowserTest`; node guard `browser-resize.test.mjs` (the 280 floor and parse bounds). Code: `static/css/app.css` (the indent token, band defaults, floor), `static/js/browser-resize.mjs` (`BROWSER_MIN`), `static/js/rail.js` (the pre-paint gate). |
| 2026-10-27 | v1.222 | #725 — the Schedules explorer's last-run filter | **§4.20:** `?outcome=succeeded\|failed\|in-progress\|did-not-run\|never-ran` and a second select filter the explorer through REST §20.1's new `outcome` (each schedule's newest run, the home's Last run buckets), composing with the condition and the search; Clear filters clears both; no client-side prune. **§4.20a:** the Last run bars link the filtered explorer and the condition note says so. |
| 2026-10-26 | v1.221 | #726 — task danger tone | **§4.25, §4.20a:** name the closed task-tone set (draft, released, danger); the Schedules Unblock task icon uses danger. |
| 2026-10-26 | v1.220 | #739 merge follow-up | **§3.6:** reconcile the resource-row baseline and resource-name role with §3.4’s approved spacing, family glyphs, guides and one-line tooltip-backed truncation. No behavior change. |
| 2026-10-26 | v1.219 | #739 — browser family glyphs and guides | **§3.4:** 8px per level, parent-centre → last-child-centre guides and 2px ticks, continuous across the 4px rhythm; no guides at root or in search; leaves use the menu family's glyph. One-line labels remain compact at every width. After the 240px probe showed a 26px overrun, the owner approved reduced horizontal spacing: row insets 8px, row gaps 4px, badge gaps and sides 2px; vertical spacing and typography stay. A deep three-badge row may use the trailing inset, inside the rail. Code: `static/js/tree/render.mjs` (allowlisted `leafGlyph`, search marker), `static/js/tree/sidebar.mjs` (option), `templates/layouts/default.html` (five fixed panel attributes), `static/css/app.css` (browser geometry). Guards: `reusable-tree.test.mjs`, `ShellRenderTest`, `BrowserResourceRowBrowserTest`, `TreeIndentAndVersionMenuBrowserTest`; this inventory's row and icon paragraphs corrected. |
| 2026-10-25 | v1.218 | #641 — renumbered at merge after #714’s v1.217 — the Schedules explorer's condition filter | **§4.20:** `?condition=enabled\|paused\|blocked` and the select beside the search filter the explorer through REST §20.1's new `condition` (a flat list; the filtered empty state with Clear filters; a row that stops matching after pause, resume or unblock leaves the list; any other value a 400). **§4.20a:** the condition tiles and bars link the filtered explorer and the note says so; the Schedules tile and Last run bars stay unlinked (no outcome filter); Continue your work opens on **Unblock N schedules** while any schedule is blocked. |
| 2026-10-25 | v1.217 | #714 — visualization version links follow the code | **§4.24:** the Versions tab cell and the workspace htmx row now say what the markup has done since `1d7b8457a`: every link that names a version is a boosted, rail-preserving navigation onto `?version=` — prepared main-content navigation; the server resolves the version and declares the ONE compatible bundle the loader mounts (§4.21's rule) — not a full navigation. The catalog's reconciled prose already carried the rule; the two stale clauses now agree with it and with §4.21's htmx row. `VisualizationUiRenderTest` renders both version-link surfaces (the `nav.dp-versions` selector and the choose-a-version state's anchors, two versions each) and pins `hx-boost="true"` and the version URLs. No template, route or permission change. |
| 2026-10-25 | v1.216 | #701 — renumbered at merge after #442’s v1.214/v1.215 — the resource row is one line (owner ruling 2026-10-24) and compact at every width (owner ruling 2026-10-25, amended the same day) | **§3.3, §3.4:** the browser's resource row is ONE line (option a of the 2026-10-24 ruling; record amended in 4cf4f91d): the 12px title truncates with an ellipsis, the control's and label's `title` carry the full canonical name; version/draft/needs-review (and the optional pin) share the line after it, `flex: none` — the label yields width to the badges, never the reverse, so no indicator is pushed out of the rail and the panel never scrolls sideways for a name (#541 kept); a badge-less row's metadata box leaves the layout; a folder's glyph is `folder` closed / `folder-open` expanded in the old chevron's indent slot, a leaf keeps `file`, both centred on the line and never rotating — the generic chevron-rotation rule is deleted (its rotate(90deg) namesakes in data-table sort glyphs, the template-tree explorer and the marketing site are untouched). Round two (option a of the 2026-10-25 ruling, amended the same day: its ~320px threshold dropped, since a threshold made widening the browser hide deep names): the browser's rows are COMPACT at every width — the indent per level 16→4px and a badge's side padding 8→4px (`--space-1`); at the 240px minimum a leaf deeper than ~7 levels may show no name, the tooltip carries it, and a three-badge one may reach into the row's right padding, inside the rail. Code: `static/js/tree/render.mjs` (the row's title, metadata region and glyphs), `static/css/app.css` (the one-line row, the folder glyphs, the compact block). Guard: `BrowserResourceRowBrowserTest` measured off the real page (one-line heights, centred glyph, same-line badge bounds, ellipsis plus truncation, glyph references, `transform: none`; at 240px and 300px a depth-three and a depth-ten row with `v13`, `draft` and `needs review`, both themes; still compact at the 480px maximum); the node guard `reusable-tree.test.mjs` pins the href swap following `aria-expanded`. |
| 2026-10-25 | v1.215 | 442c (#442) | §4.20: add backlog_full to the existing Resend refusal toast codes. |
| 2026-10-24 | v1.214 | 442b (#442) | **§4.20:** the form's mail note is now the deployment's computed state — shown only while delivery is `off`, worded per reason (`disabled` / `mail_unconfigured` / `no_allowlist`), and the details' suffix follows it; the run dialog gains **Notifications** (§20.11 `deliveries`: notice, recipient for `schedule.update` holders only, state badge never “delivered”, attempts, last error) with **Resend** on `failed`/`uncertain` rows (§20.13, `may_duplicate` warning toast); “Not in this slice” drops notification delivery. |
| 2026-10-25 | v1.213 | #670 round 2 — renumbered at merge after #632's v1.212 (owner ruling 2026-10-24, option (a)) | **§4.25:** the family facts enum is now the top-level `OverviewFamily` (its own file), no longer nested in `OverviewPage` as `Family`; every reference was renamed mechanically and the nested page models stay nested. The three rendered homes (lifecycle, Schedules, API) are byte-identical before and after. Code organisation only; no route, permission, template or wording change. |
| 2026-10-25 | v1.212 | #632 template version navigation transport | **§4.7 htmx row and State and roles:** the version selector and Versions-tab Open are boosted, rail-preserving navigations, matching `hx-boost="true"` since `1d7b8457a`. R5's read-only selected version resolves server-side independently of transport. The catalog links have no explicit boost attribute. `TemplateHtmxRenderAuditTest` renders two versions and pins both selector and Versions-tab Open attributes and version URLs. No template, route or permission change. |
| 2026-10-24 | v1.211 | #668 — renumbered at merge after #684's v1.210 — the ui-screens transport vocabulary guard | New guard `UiScreensTransportVocabularyTest` scans this document alone: a line outside the Change Log matching `sidebar uses` (#654's F1 grep) or a sidebar-then-`REST tree`/`tree/search` shape (both case-insensitive) fails the suite unless a dated-quote keep entry with its reason admits it, and a keep entry that admits no line fails as stale. The change-log skip is docs-audit's heading flip, copied verbatim, and the scan asserts its own honesty (at least 3,000 lines read; the flip seen). The keep list seeds empty at 5e1326773 — the sweep found zero matching sentences outside the Change Log, the #466 v1.140 row being inside it. The self-check carries the six pre-#654 transport sentences from aedfaa6bb verbatim (the five `sidebar uses` lines plus the §4.4 picker note, which matches the second shape only). Documentation only; no route, permission, template or script change. |
| 2026-10-23 | v1.210 | #684 one keyboard ring on template workspace tabs | **§4.7:** `.tw-tab` resets the vendored global focus shadow, retaining its own outline as the only ring. `TemplateWorkspaceBrowserTest` reads both computed properties after keyboard focus. |
| 2026-10-23 | v1.209 | #623 merge follow-up | §4.1 names `auth.css` as the ceremony geometry stylesheet, completing the outstanding filename clarification. |
| 2026-10-22 | v1.208 | #681 — renumbered at merge after #623's v1.207 — the provider button's label is the configured display-name, unprefixed | **§4.1:** the login template renders `th:text="${provider.displayName}"` — the "Continue with " prefix is gone, so a deployment whose display-names already read "Sign in with Google" / "Sign in with GitHub" show exactly that, not "Continue with Sign in with …"; the configured text is the whole label and no yml value changes. Guard: `AuthLayoutRenderTest` pins `shouldNotContain "Continue with"` (red at the base, the assertion that can go red on a regression). No route, authorization, template-attribute or config change. |
| 2026-10-22 | v1.207 | #623 shared auth layout and typography | **§2 principle 4:** the ceremony layout has a header, content main and footer; login uses its story/preview and sign-in composition, while the forced-password-change gate keeps the centred card. The no-shell/no-boost contract remains. **§3.3 item 6:** the illustrative miniature names its `.app-login-*` classes in `auth.css`; the fallback ratios are explicitly 090's historical measurements, not a Noto Sans calibration. **§3.5 item 7:** login uses the shared `.app-eyebrow`, and the faces follow §3.3 (#551 supersedes the 2026-09-08 Inter-only ruling). Documentation reconciliation only; no behavior, route or authorization change. |
| 2026-10-22 | v1.206 | #652 the shell advices run for shell pages only — renumbered at merge after #660's v1.205 | **§3, §3.4:** the four `@ControllerAdvice`s (`UiWorkspaceAdvice`, `AppShellAdvice`, `ReleaseFlashAdvice`, `SiteOriginAdvice`) select on `assignableTypes = [ShellPage]` — the marker every shell-page controller implements and no `@RestController` does — so a JSON API request pays none of the shell's reads (the old `annotations = [Controller]` selector matched REST through its meta-annotation: two `users` reads and a `workspaces` list per JSON request). §3's intro names the partition; §3.4 carries the normative paragraph with the four named exceptions (`SkillController`, `SitemapController`, `LlmsTxtController` — text-only; `VisualizationPreviewController` — session-less capability page that self-supplies `activeTheme`). No route, template or attribute value changes; every shell page renders exactly as before. Guards: `ShellPageGuardTest`, `ShellAdviceSelectionTest`. |
| 2026-10-21 | v1.205 | #660 a late history miss never lands — renumbered at merge after #644's v1.204 | **§3.2:** `shell.js` aborts a pending history-miss xhr when another navigation supersedes it. Guards: `shell.test.mjs` and the held-catalog-response case in `PipelineWorkspaceHistoryBrowserTest`. No route or authorization change. |
| 2026-10-22 | v1.204 | #644 one keyboard ring per tree row — renumbered at merge after #642's v1.203 | **§3.4:** the first-party tree stylesheet resets the vendored global focus shadow on tree items, leaving #516's outline on the row line as the only ring. Both tree browser cases read the item's computed box-shadow as well as the line outline. |
| 2026-10-21 | v1.203 | #642 semantic overview tones | **§4.25:** success, warning and danger bridge the design-system fills for ops status, alongside the four lifecycle tones. **§4.5d, §4.18a, §4.20:** datasource health, API serving-draft posture, schedule conditions and last-run outcomes use their semantic words. Guards: `OverviewToneCssTest`, the frame and three homes' render tests, token audits and `OverviewToneBrowserTest`'s semantic equality and distance pairs. |
| 2026-10-21 | v1.202 | #654 the catalog transport rows name the resource browser, not the sidebar — renumbered at merge after #591's v1.201 | **§4.3, §4.6:** the two htmx rows' and the tree-transport bullets' sentence names the resource browser as the caller of the REST tree/search routes (§3.4, rest-api §24) — #554 moved the trees there and #588 named the control; **§4.23:** the parameter-sets tree paragraph does the same; **§4.4:** the template-reference picker note reuses the resource browser's REST tree component; **§3.2:** the page-script bullet's loader sentence says the resource browser's trees and the REST tree host; **§4.6:** the row-link bullet says the preserved shell. Dated ruling quotes (§4.3, §4.6) and the change log keep their words. Wording only: no code, route, permission or test change; the docs audit is the guard. |
| 2026-10-21 | v1.201 | #591 a catalog's address bar describes its list — renumbered at merge after #640's v1.198, #589's v1.199 and #659's v1.200 | **§5:** new normative paragraph "The URL describes the list" — the five `lifecycle` catalogs' partials answer `HX-Replace-Url` built by `CatalogUrl` from the bound, validated `q` / `lifecycle` / `offset` (and the templates' dialect and type); replace, not push; the filter blocks carry `hx-history="false"` so Back to a catalog refetches the page at its URL instead of restoring a snapshot with stale controls (owner ruling 2026-10-21). Guards: `CatalogUrlTest`, the five partial-controller tests, `CatalogUrlBrowserTest`. Three browser cases that Backed or Forwarded onto a catalog as a cache HIT now expect its miss (`PipelineWorkspaceHistoryBrowserTest` ×2 — the workspace legs still assert no miss; `ModalBackBrowserTest` waits for the restore; `TemplateWorkspaceBrowserTest` — the template leg still hits, the catalog leg is the one restore fetch); follow-up #660 (a late miss response). |
| 2026-10-21 | v1.200 | #659 shell path segment boundary — renumbered at merge after #589's v1.199 | **§3.4:** one path-ownership rule for menu items, crumbs and browser families. The layout consumes `AppNav` once and compares by equality; Home loses its special case and off-rail crumbs retain longest-key selection at segment boundaries. The family overview references (§4.5d, §4.21, §4.25) follow the same rule. Guards: `AppNavTest`, `ShellRenderTest`, `shell.test.mjs`, `sidebar-state.test.mjs`. No route or authorization change. |
| 2026-10-21 | v1.199 | #589 the console's MCP note says where a key comes from today — renumbered at merge after #640's v1.198 | **§4.18:** the read-only paragraph no longer points at the top bar's chip — keys v2 (A15) retired the chip and the login mint, so the sentence says the `mcp` key is created on the Keys page (§4.19) like every kind, and names the Manage link and header action's real holders (workspace admin and super admin) where it named `api_key.read`. `api/console.html`'s header comment and MCP note say the same (the note is the shared card's per-page sentence), `api/keys.html`'s header comment and sub drop the retired "in the top bar" pointer, and every comment and test name that said `MANAGE_API_KEYS` — not a row of the matrix — names the rule that actually guards the thing it describes (`MCP_KEY_OWN` for the Keys page and its REST twin, `API_KEY_BIND` for association, the `RoleModel.shell().apiKeys` workspace rule for the avatar link). `ApiConsoleRenderTest`'s new case pins the note: it names `/api-keys` and never "top bar", on the admin and the viewer render. |
| 2026-10-21 | v1.198 | #640 the script-node rename (script-node-rename lane, V50, owner ruling 2026-10-20) | **The pipeline editor's TRANSFORM card and the template editor's transform face are the SCRIPT card and the script face** (§4.3 the editor card, its accent `--type-script` and the legend's **Script**; §4.7 the face's Save draft / Run suite and its routes `/partials/templates/script-face/…`; the template workspace's script rules). A rename only: no behaviour, route, permission or key change beyond the names, and no alias (the ruling). The visualization block's `transform` key, `transform.template` / `transform.inputs` and the CSS/SVG sense keep their word. |
| 2026-10-20 | v1.197 | #574 the browser header's count has one source — renumbered at merge after #595's v1.196 | **§3.4 Counts:** the 60-second TTL sentence and the "no general metadata cache" paragraph give way to the freshness rule — `NavCounts` reads the five counts on every shell render, so the header and the tree cannot disagree past one navigation after a create, release, discard or delete (UI or MCP), and the read is deferred to the first count a render reads, so a JSON API request pays none; the lens rule and the never-zero rule are unchanged. Guards: `PipelineSidebarTreeStateBrowserTest`'s create-then-navigate case (header = the catalog route's total, no clock advance) and `NavCountsTest`'s every-render and nothing-read-until-a-value cases. |
| 2026-10-20 | v1.196 | #595 §4.25 names five guided families — renumbered at merge after #588's v1.195 | **§4.25:** the stale selection sentence is replaced by the as-built rule — every tree family's menu item links its overview (`/overview/<family>`) since #556/#579–#582, the Datasources item `/overview/datasources` since #583, the Schedules and API items their homes (`/overview/schedules`, `/overview/api-console`) since #584/#585. `ShellRenderTest`'s href case is named for what it asserts (every family item links its overview home) and its #556 KDoc states the as-built links; assertions unchanged. |
| 2026-10-20 | v1.195 | #588 the five family catalogs' intro sentence names the resource browser — renumbered at merge after #541's v1.194 | **§4.3, §4.6, §4.21, §4.23, §4.24:** the four family catalogs' visible intro sentence (pipelines, templates, dashboards, visualizations) says "Browse the folders in the resource browser's <Family> tree" — the Parameter Sets sentence's one wording since #554 moved the trees out of the rail; each page's comment says the tree lives in the resource browser's panel. **§4.3, §4.6:** the Purpose rows' "folder TREE" and the two owner-ruling paragraphs' last sentence (dates and meaning kept, the control's name changed) and §4.6's filter bullet name the resource browser; **§4.23:** the catalog's two tree reads do; **§3.4:** the icons paragraph's REST-tree sentence does. Guards: `CatalogIntroRenderTest` renders each of the five pages and pins the visible text — no "sidebar", the resource-browser sentence present. |
| 2026-10-20 | v1.194 | #541 status badges stay in view | **§3.4, §4.6a:** wrapping badges remain inside the browser regardless of name length, without horizontal scrolling. `BrowserResourceRowBrowserTest` measures version/draft boxes and overflow at depths two and three in both themes; the Implements browser case measures the needs-review marker's box without scrolling it into view. |
| 2026-10-20 | v1.193 | #634 SQL highlight inks; answers #635 — renumbered at merge after #622's v1.191 and its follow-up v1.192 | **§3.5:** SQL status hues read the existing status inks. The three editor sheets drop literal colour fallbacks; `EditorCssTokenAuditTest` guards literals and declared tokens, the status source audits see aliases, and `SqlHighlightContrastBrowserTest` measures both panes on every theme. |
| 2026-10-20 | v1.192 | #622 merge follow-up | **§4.18a:** the API home's MCP card paragraph and its JS row said the card has no Copy button and the page loads no script but `overview.mjs`; since #622 the card's fragment loads `mcp-connection.mjs` and its Copy config button copies the placeholder config (§4.18). Found by lane 622b. |
| 2026-10-20 | v1.191 | #622 MCP config Copy button — renumbered at merge after #639's v1.188, then after #599's v1.190 | **§4.18:** the shared card copies its placeholder config on the console and API home, announces “Config copied to the clipboard.”, sends no request and grants no access; a refused or absent clipboard selects the config for manual copying. One shared text copier also serves Copy prompt. |
| 2026-10-20 | v1.190 | #599 the Templates, Dashboards, Visualizations and Parameter Sets homes on the v2 frame (issue not yet updated: GitHub writes held) — renumbered at merge after #604's v1.189 | **§4.25:** the four object families are lifecycle homes: the strip of four metrics and its foot, Development activity and Release history, each from its own repository's `charts` (one `ChartInventorySource` statement over its own tables), in its own nouns; every lifecycle home's badge is `objects − discardedOnly`; the task rows and the links reach each family's own catalog; a new paragraph says each home reads its own lens arm and what the derived Dashboards and Visualizations arms admit (`PromotableView.dashboardArms`); the #621 sentence now records home-ops' keys metric; the Guards gain `FamilyInventoryTest`, the per-family render, E2E, sweep and browser cases; the four **Families as built** rows take the Pipelines row's shape (guide fragments, then the charts' source). **§4.6, §4.21, §4.23:** each catalog's Purpose names the strip's linked metrics and Review your drafts in place of the old counts and bars. |
| 2026-10-20 | v1.189 | #604 the browser's rows on the record's resource row — renumbered at merge after #605's v1.177, then after #639's v1.188 | **§3.4, §3.3:** the browser column sits on the overlay surface and a row is the record's card — the activate paints 12px padding and the 8px radius with the hover and selection tints (selection is primary ink over `--surface-selected`, not the link blue), the name is its own wrapping 12px `.dp-tree-title` at the resource-name role, the badges live in a trailing always-present `.dp-tree-meta` 8px under the title (no margin when nothing shows), and rows sit 4px apart; the row's DOM contract (control first on the line, `title` = full path, every `aria` attribute) is kept and the #516 ring stays on the row's line, which takes the card's radius while focused. The renderer's regions and `BrowserResourceRowBrowserTest`'s measurements carry the change. |
| 2026-10-20 | v1.188 | #639 the Datasources, Schedules and API homes on the v2 frame's regions (landed under the provisional id home-ops; the number replaced it after filing); answers #621 | **§4.5d / §4.18a / §4.20a:** each Frame paragraph is rewritten for the v2 regions — the summary strip's metrics (help as the note; a dot only where it equals its chart's bar), View all's badge, the two charts as bars panels with a description and an info dialog stating the section's counting rule, Continue your work's task rows (Browse datasources; See what runs next; Open the console and, only when the keys read answered, Manage keys) and its related row, Quick start's three short steps with the prompt (text unchanged) and How it works as dialog sections. **#621:** §4.18a's lens sentence now says a promoter's three endpoint metrics each carry the lens note and the keys metric none, so the strip is headed "Inventory". Schedules has no "Unblock" row: the explorer has no condition filter for it. The API's empty endpoints note reads "Quick start says how." (the steps no longer sit above it). Guide and guard sentences updated; every counting rule is unchanged. No route, permission, migration or config key. |
| 2026-10-20 | v1.187 | #638 the home pages' v2 frame and the Pipelines home (landed under the provisional id home-frame; the number replaced it after filing) — renumbered at merge after #610's v1.186 | **§4.25:** the Hierarchy is rewritten as six regions — heading (icon eyebrow, subtitle, View all with its badge, the primary action), summary strip and foot, analytics grid, work grid (Continue your work with task rows, Recently updated and the related links; Quick start), the guide's `extra`, and ONE native `<dialog>` with five server-rendered sections (definitions, releases, activity, prompt, howitworks). New paragraphs give the metric rule, the per-metric lens words (#621: the mechanism; the API keys tiles wait for home-ops), and View all's badge = objects − discarded only (#593). The Pipelines charts are re-worded as columns (activity: twelve weeks, tiled in tenths, the headline the quarter's events, View data moving the table twin into the dialog) and bars (release history at one pitch, #620; the note's sentences = the bins), with every #598a/#598b invariant kept; Actively developed moves to the strip. The prompt dialog: the question field substitutes `[question]` as text, never overwriting an edit; Reset. Guards are updated; OverviewToneBrowserTest records two more Minimal rows under #631 (owner ruling 2026-10-20). **§4.5d / §4.18a / §4.20a:** one Frame sentence each. No route, permission, migration or config key. |
| 2026-10-20 | v1.186 | #610 the phone top bar drops the breadcrumb — renumbered at merge after #618's v1.185 | **§3.6:** the ≤760px row's topbar cell loses "crumbs (leaf only)" — below the phone band the bar displays NO breadcrumb (the frozen prototype's rule): `.app-crumbs` is `display:none` in app.css's phone block, the element stays in the DOM for shell.js's boosted-swap writes, and `#mode-toggle` inherits the crumbs' `margin-right: auto` right-edge pin. **§3.4:** the breadcrumb bullet records the band and the return at 761. The 761–1100 leaf-only rule (110) and the ≥1101 group are unchanged. `MainFrameBrowserTest`'s four cases pin the hide at 320/390, the band guard at 761/1101 and the anonymous bar at 320. No route, permission, SQL or script change. |
| 2026-10-20 | v1.185 | #618 success and warning text inks — renumbered at merge after #627's v1.184 | **§3.5:** the #509 sentence becomes three roles. Success and warning text read `--app-success-ink` / `--app-warning-ink`, each built like `--app-danger-ink`: `light-dark(oklch(from var(--text-<role>) calc(l - 0.03) c h), var(--text-<role>))`. Twenty-four text sites over six sheets move (app.css, pipeline-editor.css, template-workspace.css, auth.css, parameter-sets.css, dashboards.css), and nine literal fallbacks go. Borders, fills, dots and tints keep the accents. Ocean and saas success text takes the teal text role. New guards: `StatusInkContrastBrowserTest`, 270 pairs over ten passes, worst healthcare warning 4.82:1, and `StatusInkSourceAuditTest`. |
| 2026-10-20 | v1.184 | #627 overview mixed tone — renumbered at merge after #625's v1.183 | **§4.25:** bars, stacked parts and legend swatches share four lifecycle tones; mixed reads the info series (`--chart-series-1`) rather than the brand. `OverviewToneBrowserTest` measures all nine themes and both auto schemes at the unchanged OKLab distance floor of 0.08, with measured, stale-checking palette gaps for professional (#633) and minimal (#631). |
| 2026-10-20 | v1.183 | #625 template version selector selection — renumbered at merge after #598b's v1.182 | **§4.7:** the version selector's viewed row takes the menu's selection expression (§3.1): `--brand-soft` tint, `--text-primary` ink (Minimal `--text-inverted`), the 3px `--border-focus` bar from `--app-nav-indicator-w` instead of the 2px `--focus-ring-offset`, reserved transparent on every row; the viewed row's `· current` suffix inherits the label's ink (owner, 2026-10-20 — muted on the tint measured 2.96–3.83:1). `TemplateVersionSelectorBrowserTest` measures bar, ink, contrast and geometry on nine themes plus auto's dark half. |
| 2026-10-20 | v1.182 | #598b Pipelines activity chart | **§4.25:** the Pipelines card's third chart, **Pipelines activity, last 12 weeks**, from the same statement: twelve UTC weeks (Monday-first, oldest first, the newest "this week so far"), each stacking Released (admitted RELEASED versions by `released_at`) and Created (admitted pipelines by `created_at`) into a bar of events, the busiest week full width; the headline "N pipelines actively developed" (an admitted draft last written within `datapipelines.ui.actively-developed-days`, default 14, inclusive; a count today, no per-week draft history; 0 through a promoter's lens); the note; the hidden table's Week, Released, Created, Events; rows at half the inventory pitch (`--space-6`). The lens paragraph, the guards and the Pipelines row of Families as built name it. |
| 2026-10-19 | v1.181 | #598a merge follow-up: §4.25 records #593's count sentence | **§4.25:** the Pipelines charts paragraph quotes the card's note on why its total can exceed the resource browser's header count (the header counts the three live parts; Retired is discarded only, with no page) — #593's acceptance asked the counting model to say what the page says; the Links paragraph's four-count sentence now names the Pipelines card's as until #598a. |
| 2026-10-19 | v1.180 | #617 screen-reader-only headers — renumbered at merge after #609's v1.179 | **§3.7:** sort controls require visible header text; screen-reader-only labels and resize handles do not qualify. Explicit `data-sort="off"` remains. Node decision tests and the template Versions/dashboard Keys browser witnesses pin the rule. |
| 2026-10-19 | v1.179 | #609 shared-component edge roles — renumbered at merge after #598a's v1.178 | **§3.1 item 5:** the shared components' edges take #606's three roles. Seven boundaries move from `--border-subtle` to `--border-default`: the dashboard filters pane and dock, the parameter card, the editor's minimap, view controls and dock top, and the version-row menu. The release dialog's switch and check rows read `--border-subtle` directly instead of the `--border-faint` alias (same colour). Seven edges stay separators. `ComponentBoundariesBrowserTest` is the sweep. Its four vendored-pair exceptions are tracked by #628. **§4.4:** the dock's top border is named a boundary. |
| 2026-10-19 | v1.178 | #598a the Pipelines charts — renumbered at merge after #605's v1.177 | **§4.25:** the Pipelines card's four counts and four bars give way to a total line and two stacked charts from ONE statement — Pipelines by lifecycle (In development, Live, Live, changes in progress, Retired; the three live parts link the filtered catalog) and Pipelines by number of releases (0, 1, 2–3, 4–9, 10+, each split No draft / Draft open; only RELEASED rows count); both total the same, each with its hidden table; the promoter's lens; the guards; the Pipelines row of **Families as built**. The other families' cards are unchanged. |
| 2026-10-19 | v1.177 | #605 menu typography | **§3.4/§3.3:** recorded menu rows, captions, wordmark, switcher and top-bar type roles; shared 650 emphasis weight; dedicated normative 3px indicator; selected primary/accent inks with accessible inverted ink for Minimal. Browser measurements and all-theme selected-ink contrast. |
| 2026-10-19 | v1.176 | #619 login companion design — renumbered at merge after #509's v1.172, then after #585's v1.175 | **§4.1:** the record §13 two-column login, 360px form, bundled Noto Sans, dark/light and form-first phone layout; the DAG ceremony retired; `auth.css` new; `login.js` progressively enhances password visibility and inline validation; `eye`/`eye-off` vendored (46 glyphs). Authentication, CSRF and redirects preserved. |
| 2026-10-19 | v1.175 | #585 the API home — renumbered at merge after #509's v1.172, then after #584's v1.174 | **§4.18a (new):** `GET /overview/api-console` — Endpoints, Serving a release, Serving a draft and Your keys / Workspace keys tiles; Endpoints by state and Keys by kind (live only) charts; Recently published and Recently created keys; How a program gets access; a publish starter prompt that stops at publishing; the shared MCP card. Each of the two reads fails on its own. **§4.18:** the console is the home's View all; its MCP card is one shared fragment. **§4.25:** the API row of the **Families as built** table. |
| 2026-10-19 | v1.174 | #584 the Schedules home — renumbered at merge after #509's v1.172, then after #613's v1.173 | **§4.20a (new):** `GET /overview/schedules` on the §4.25 frame — four tiles (Schedules, Enabled, Paused, Blocked), By condition and Last run (five buckets, each schedule once by its newest run), Up next and Recently changed linking the explorer's leaf, a person's How to create one with no agent section; every number from one `ScheduleService.summary` over §20.1's own list through the reader's lens, an over-cap workspace unavailable rather than short. **§4.20:** the menu's Schedules item lands on the home; the explorer is its View all. **§4.25 Families as built:** the Schedules row. |
| 2026-10-19 | v1.173 | #613 the phone top bar pads `--app-main-pad`; the wide-window heading-edge sentence (owner rulings 2026-10-19) — renumbered at merge after #509's v1.172 | **§3.6:** the ≤ 760 px row's Topbar cell names the bar's side padding — `--app-main-pad`, 20px, the bar and `<main>` share a left edge (#613) — and the Main gutter cell now reads "the top bar pads the same token (#613)" where it named the bar's own `--gap-md` before; one sentence under the table records ruling 1's second half: the desktop bar's 24px `--gap-lg` is NOT banded, measured identical at the 1512 and 1600 bands while `<main>` moves 32 → 40. **§3.1 item 1:** the record-only wide-window sentence — on a window wider than the frame-bind width (≈1702px, menu expanded, browser hidden) a canvas page's heading and a framed page's heading no longer share a left edge, by design (record §4); the tests measure 1512, where they match. **v1.166's row annotated** where it named the phone bar's own `--gap-md` (banded since this version). `MainFrameBrowserTest` +3 cases — the bar at 390 (one content left edge with `<main>`, first child on the edge), the bar above 760 (24 at both bands), the anonymous bar at 390 (the error-page shell) — red 2/12 on the old code (bar.padLeft 16, bar.contentLeft − main.contentLeft −4, anon.padLeft 16), green 12/0 at the tip. |
| 2026-10-19 | v1.172 | #509 danger text token — renumbered at merge after #583's v1.167, then after #608's v1.171 | **§3.5:** danger TEXT reads one app token, `--app-danger-ink`: the design system's `--text-danger` in dark (the #502 chip's 3.52:1 becomes 8.94:1), and in light that red at 3% lower oklch lightness (owner ruling 2026-10-19). With the unadjusted red, light and saas miss 4.5:1 on the inset and hover surfaces and ocean misses on its chip tint too; worst light case is now ocean at 4.65. Twenty text sites move, seven of them in `pipeline-editor.css` (fallback-spelled reads the inventory grep missed); borders and fills keep `--accent-danger`, and the vendored white `--accent-danger-text` is untouched. `DangerInkContrastBrowserTest` and `DangerInkSourceAuditTest` (new). No route, permission or role changed. |
| 2026-10-19 | v1.171 | #608 the six undefined `u-*` utility classes resolve, and the existence guard (renumbered at merge after #614's v1.170) | **§3.1 item 3:** the guard sentence — `UtilityClassExistenceAuditTest` sweeps every app template's `class`/`th:class`/`th:classappend` value and every first-party script's string literal for `u-*` names no served stylesheet defines, and fails the build naming the file and line (the sweep's floors: ≥ 100 sites, the used and defined counts printed, an empty known-debt allowlist). The thirteen sites that named the six undefined classes now resolve: the hidden-text helper is one rule with two names (`.u-sr-only, .app-visually-hidden`), the template-versions header renames its lone `u-visually-hidden` to the canonical `u-sr-only`, and the margin block gains `.u-mt-0`/`.u-mb-0` and `.u-mr-md`/`.u-ml-xs` on the gap tokens. Browser witnesses in `TemplateWorkspaceBrowserTest` and `DashboardWorkspaceBrowserTest` measure the sr-only headers 1×1 with nothing of the span under the pointer. |
| 2026-10-18 | v1.170 | #614 status pill on the frame's content edge; #611 the stale §4.4 width bullet (#573 follow-ups) — renumbered at merge after #583's v1.167, then after #612's v1.169 | **§3.6 gutter paragraph:** the status pill's desktop and anonymous offsets also add `--app-frame-inset` — a new shell geometry role beside `--app-frame-max`, the 1500px frame's centring offset in its track: `max(0px, (100vw − menu − browser column − 1500px) / 2)`, zeroed by `:root:has(.app-main > .app-canvas)` and, in the anonymous shell, taken over the whole window. Before it the pill sat on the TRACK's content edge on every page, so on a framed page wider than 1500 it floated left of the content (at 2200 on Settings: pill 242, content 491 expanded; 104 vs 422 compact; signed out on an error page 40 vs 390). Now it equals `<main>`'s left plus its padding on framed pages, and is unchanged on a canvas (the editor at 2200: 562 = 202 + 320 + 40) and wherever the frame does not bind (234 at 1512). The phone offset stands. `MainFrameBrowserTest` gains four cases (framed at 2200 expanded and compact, the editor at 2200, signed out at 2200, Settings at 1512), reading the pill raised by the shell's own `showStatusPill`. **§4.4:** the "Full-bleed pages (065)" bullet described mechanisms deleted in 076 (`fullBleed`, `app-main-bleed`, the `--app-content-max` cap on `<main>`) as live and said `EditorLayoutRenderTest` pinned "both directions"; it becomes **Page width (065 → 076 → #573)**: the editor renders the shared `.app-container.app-main` and opts out of the frame only by `app-canvas` on `.pe-root`; the test pins the deleted pair's absence and the shared `<main>` on the editor and the pipelines list. **§3.1 item 1:** "`--app-content-max` … stay deleted" corrected — the name is live since #502, as `.pe-doc`'s measure, and no longer caps `<main>`. No route, permission, role or markup changed. |
| 2026-10-18 | v1.169 | #612 dashboard board fills workspace (renumbered at merge after #615's v1.168) | **§4.21:** the Board tab’s flex chain shrinks its panes into the full-height workspace, keeping the activity dock visible without scrolling `<main>`; other tabs retain ordinary flow and the filters drawer keeps its 820px board-region threshold. |
| 2026-10-19 | v1.168 | #615 editor canvas on a short window (#607/#573 merge follow-up, owner ruling 2026-10-19) | **§4.4** the dock paragraph: the canvas floor is enforced against the dock (180px, from 160 — the view controls' 159px column), the dock's pane row gives way on a short window, and the dock starts collapsed when the canvas with it open would be under 320px unless the person remembered a height; a start-up collapse opens on a run's first results. Found by the local browser suite on batch 20: at 1280×720 the canvas was ~100px and the Fit control sat under the heading. |
| 2026-10-18 | v1.167 | #583 the Datasources overview — renumbered at merge after #565's v1.163, then after #573's v1.166 | **§4.5d (new):** the Datasources overview — one visible-set tile, Connection health (ok/failed/never tested, unlinked bars), By dialect (bars linking `/datasources?dialect=`), Recently added to this workspace (grants, newest first, no lifecycle badge); a guided page (register-first starter prompt); NOT a browser family — the browser and its opener stay hidden and the rail item lights through the layout's two-prefix rule. **§4.5:** the Test button and Auth cell name `datasource.test` and the Edit action `datasource.manage` (the §7.6 scope tokens, replacing the stale `author` wording). **§4.25:** the URL and Auth rows admit the sixth route and `datasource.read`; the shell path-rules paragraph and rule (1) record the deliberate non-family exception; the guided-families sentence and the Families-as-built table gain the Datasources row (grants-ordered Recent, no lifecycle filter); `ShellRenderTest`'s guard wording covers the overview. |
| 2026-10-18 | v1.166 | #573 main padding by band and the 1500px frame (#554 follow-up, record §4) — renumbered at merge after #607's v1.165 | **§3.1 item 1 rewritten:** `<main>` pads all four sides through one role token, `--app-main-pad` — 40px from 1600, 32 from 1101 to 1599, 24 from 761 to 1100, 20 at 760 and below (`--space-10/8/6/5`; it was one `--gap-lg` gutter, 24, and 16 on a phone) — and is a frame of at most 1500px outer width, padding included (`--app-frame-max`), centred in its track; at the 1512 reference it is 1010px wide and 946 inside its padding, so it binds only on wider windows and non-family pages (2200 on Settings: 1998 → 1500, left 451 expanded, 382 compact). The one opt-out is `app-canvas` on a page's root (`.app-main:has(> .app-canvas)`), carried by the pipeline editor, the template workspace, the schedules explorer, the dashboard board and the execution result page; catalogs, overviews, settings, admin, keys, the API page and the parameter-set and visualization workspaces stay framed. **§3.6:** the Main gutter column names the token and its four values (the Topbar column was unchanged at v1.166 — the phone bar then kept `--gap-md`, read the band token since #613's v1.173), and a rule under the table names every geometry that reads the token — the five full-height calcs and the status pill's three offsets — and the one `--gap-lg` that only looks like it (`.tplx-splitter`, the explorer's column gap). **The lander's residue (2026-10-17):** the 390px Known note re-measured (0px on all six screens, before and after) and rewritten; "sidebar tree" → the resource browser in §4.3 (two), §4.3e's read row, §4.7 and §5.1; the §4.21 Tabs row's "rail resizing" → resizing the resource browser. `MainFrameBrowserTest` measures the bands, the reference, the frame, the opt-outs, each canvas's fill of `<main>` and the six phone screens. |
| 2026-10-18 | v1.165 | #607 pipeline workspace heading (#558 slice 1) — renumbered at merge after #606's v1.164 | **§4.4:** a **Heading composition** bullet — the pipeline workspace's top area is the shared resource heading (context line at the Support role, the ONE wrapping 24px `h1.ds-headline` page title, the version/status line, the actions in `.app-page-actions`, then the tabs), the draft state said once (the selector's viewed row drops its suffix), the measured alignment with `/overview/pipelines` and the exemplar sentence for the other workspaces; the **Phone widths** bullet rewritten to the owner's ruling (at 760px and below the band is the page: `.pe-root` is not laid out; rotate behaviour; the template workspace keeps 110 until its slice). **§3.3:** the Heading row no longer names a workspace's top-bar name as a 14px use. |
| 2026-10-18 | v1.164 | #606 shell surfaces | **§2.8, §3.4, §3.5:** plain page ground, page-coloured top bar, three edge roles; the backdrop and redundant brand/avatar outlines removed, required boundaries retained at 3:1, separators at 2:1, decorative rules at the approved prototype’s weight. Browser measurements cover layout bands and every vendored theme. |
| 2026-10-18 | v1.163 | #565 compact scale residue (#555 follow-up) | **§3.3:** a **House exceptions (ruled)** table after the component baseline, its one entry the phone dialog (at 760px and below `.app-modal` is full width with 16px padding — owner 2026-10-18; the Dialog row's 560px / 90vw is the desktop value); the Tab row names the editor dock's `.pe-dock-tab`, whose strip is now 44px through `--pe-dock-tabs-h: var(--app-tab-h)` (was 38px); the Support row names `.u-text-sm` as the one support utility and the Meta row its two ways in, `.ds-caption` (uppercase captions) and the new sentence-case `.u-text-meta` (owner 2026-10-18) — `.u-text-xs`, a second 12px name, is retired (its 49 uses classified: 25 support, 13 meta, 11 removed where the measured size comes from elsewhere); item 7 names the new readings (the dock's tab height, the phone dialog's width and padding). |
| 2026-10-18 | v1.162 | #582 the Parameter Sets overview — renumbered at merge after #581's v1.161 | **§4.25:** the Parameter Sets menu item now links `/overview/parameter-sets`, which carries its guide (introduction, Create with your agent, related families and help) and Recently updated, with Review drafts opening the catalog filtered to With drafts; the Parameter Sets row of the **Families as built** table (in #580's columns). **§4.23:** the catalog's `?lifecycle=` filter (the five offered values, the 400 for `discarded` and unknown values, the promoter's released-only reading) and the Purpose row's menu destination. |
| 2026-10-18 | v1.161 | #581 Visualizations overview — renumbered at merge after #580's v1.160 | **§4.24:** menu leads to `/overview/visualizations`; the catalog adds the inventory lifecycle filter, composed with search and paging. **§4.25:** Visualizations guide, editable starter prompt, Recently updated and Review drafts; the Visualizations row of the **Families as built** table (#580's columns). |
| 2026-10-18 | v1.160 | #580 the Dashboards overview — renumbered at merge after #579's v1.159 | **§4.25:** the Dashboards row of the **Families as built** table (the table's columns are #580's; #579's Templates row is restated in them). **§4.21:** the menu's Dashboards item links `/overview/dashboards`; the catalog's `?lifecycle=` filter (every bucket but discarded, the house 400 otherwise), its control, its empty state and its equality with the overview's counts under every lens. |
| 2026-10-18 | v1.159 | #579 the Templates overview | **§4.25:** Templates is guided (`overview/guide-templates.html` — What templates are for, Create with your agent, Recently updated, related help); **Families as built** table; the catalog's `?lifecycle=` filter on `/templates`, which the overview's View all, bars, counts and Review drafts link — the same five offered buckets and 400 contract, bucket reads placing an object by its latest version. **§3.4, §4.6a:** the Templates menu item links `/overview/templates`. || 2026-10-17 | v1.158 | #556 the family overview pages | **§4.25, new:** `GET /overview/<family>` for the five families from one template: the inventory (four counts, four bars and a hidden table from one `FamilyInventory` read, the unavailable state without a number) and, for Pipelines, the introduction, Create with your agent (Copy prompt, clipboard only), Recently updated and related help. **§3.4:** the four path rules treat an overview as its family's; selection rule 1 — the Pipelines menu item now leads to its overview, the other four keep their catalog until #579–#582. **§4.3:** the `?lifecycle=` filter, its 400 and its equality with the overview's counts. **§4.6, §4.21, §4.23, §4.24:** each names its served overview and the issue that moves the menu. |
| 2026-10-17 | v1.157 | #578 browser reveal | **§3.4:** desktop chevron-right at the browser edge; phone keeps the folder glyph. A desktop family menu click clears Hide and shows the browser, including the active family; non-family destinations preserve Hide. Storage table and **§3.6** updated. |
| 2026-10-17 | v1.156 | #554 the shell becomes three sibling columns: a stable primary menu, a separate resource browser, the workspace | **Principle 4, §3, §3.4, §3.6, §4.3, §4.6, §4.6a/b, §4.21, §4.24:** the in-rail accordion (#460/#478) and the rail resize (#465/#503) retire. The menu is 202px expanded / 64px compact, with Explore (Dashboards, Visualizations, Parameter Sets) as a new group between Build and Operate; compact mode has named tooltips and no group labels. The five REST trees move into `#app-browser` beside it, outside the boosted main: header with title, admitted count (moved from the menu badges, all five families) and Hide; search; tree; View all + width reset. The browser is visible only on a family page, mounts each family lazily and keeps it in-document, and is resizable 240–480px on its boundary, clamped to leave main 320px. §3.6 is re-cut to the record's four bands (≥1600 / 1101–1599 / 761–1100 / ≤760): two phone drawers, one open at a time, the rest of the shell `inert`; the menu drawer keeps its 232px (`--app-menu-drawer`) so its close control clears the wordmark. A storage table: `dp-rail` kept; `dp-browser-width:<ws>` and `dp-browser-hidden:<ws>` new; `dp-rail-width:<ws>`, `dp-tree-open:<ws>` and `dp-tree-panel:*` removed on sight, never reinterpreted. The record's eight selection rules are stated as built (rule 1's overview destination is #556's). The #555 phone rows follow the band edge (760px and below). The "document never scrolls" and "`<main>` is the one scroll container" sentences are unchanged. |
| 2026-10-16 | v1.155 | #536 small chart sizing | Dashboard grid: Plotly bodies keep a 64 px token minimum; undersized slots scroll the entire bordered card instead of failing during axis scaling. |
| 2026-10-16 | v1.154 | #536 renderer diagnostics | Dashboard workspace: renderer rejections write bounded browser-console diagnostics; the visible error sentence is unchanged. |
| 2026-10-16 | v1.153 | #551 merge follow-up: §3.3's font-face count | **§3.3:** the vendoring paragraph counted four `@font-face` blocks and one preloaded sans; since #551 there are six (Noto Sans latin and latin-ext, upright and italic; JetBrains Mono 400 and 500) and both upright sans subsets are preloaded with the regular mono. Prose only. |
| 2026-10-16 | v1.152 | #551 the app's sans face is bundled Noto Sans; Inter retired — renumbered at merge after #555's v1.151 | **§3.3:** `--font-sans` now leads with **Noto Sans** — the prototype's face, vendored the established way (record 2026-10-16 §5, owner decision also on #547): the pinned `@fontsource-variable/noto-sans` 5.3.0 package (upstream v42, OFL-1.1, tarball sha512-checked then sha256-recorded), latin + latin-ext subsets, normal + italic (the editors' muted/empty/stale states inherit the app sans), both normal subsets preloaded in `layouts/default` and `layouts/auth` — under `optional` an unready subset is dropped for the whole load, so ext-glyph names would render in the system face. `font-display: optional` unchanged (090 §B). The Inter files, faces, preloads, manifest entry and audit-test rows are removed; the six vendored themes' `--_font-sans` naming Inter is vendor text owned by the design-system project. No route, permission, token or template change; type sizes untouched (#555 owns the scale). |
| 2026-10-16 | v1.151 | #555 the compact type and density scale (#547 slice 1c) — renumbered at merge after #552's v1.150 | **§3.3** rewritten as the compact scale: item 1's title amended (24px semibold −0.025em 1.35, 21px on a phone; `.ds-title` the 12px card title, 14px in a dialog); new items 4 (the role tokens, px and rem), 5 (the component baseline), 6 (the preserve-list of content typography) and 7 (the measurement, `CompactScaleBrowserTest`). **§3.5** item 6: cards are drawn, not raised — the hover lift stays only on link cards, a dialog floats; item 7: the title's weight and leading, 12px card titles, the eyebrow on the 10px caption role. |
| 2026-10-16 | v1.150 | #552: design system 0.4.0, dark palette and contrast refinements — renumbered at merge after #553's v1.149 | Dark and auto-dark: page `#13161b`, default `#171b21`, overlay `#1c2027`, raised `#232831`, hover/active `#2a303b`, selected `#303953`; primary/secondary/muted text `#e9ecf2`/`#a6afbf`/`#8993a5`, link `#b1baff`, boundary `#687386`, selection indicator `#7e90ee`. Separator `#616772` retains at least 2:1 through the selected surface; the prototype decorative line cannot serve as a required separator. Danger tokens and white primary buttons are preserved; #509 remains an owner ruling. Generated vendor files come from the upstream build through `scripts/sync-design-system.sh`. |
| 2026-10-16 | v1.149 | #553 inventory model, consumed by #556 | `FamilyInventory` supplies one SQL snapshot of distinct objects per family under the workspace and caller's lens: draft only, released only, released with draft changes, discarded only. Unrestricted inventories include discarded-only objects although the live catalog has no drill-down for them; narrowed lenses count admitted releases only. Empty lenses make no query; lens/read failures return unavailable. No route or page is added. |
| 2026-10-16 | v1.148 | #544 a stale dashboard card's border is the warning accent — lane 536b, after #536's v1.147 | **§4.21** Tabs row: a stale card's border is the warning accent, not gray (owner ruling 2026-10-16: #536's gray read as the resting border on the running board; dashboards.md §6.6 v0.54). |
| 2026-10-16 | v1.147 | #536 the dashboard card's border carries every state — re-cut by lane 536b after #535's v1.146 | **§4.21** Tabs row: the Board tab's chart cards show no badge in ANY state (the status chip is visually hidden, its `role=status` text and `data-dp-state` kept); the border is the state — animated while loading, green for three seconds on success, gray when stale, red on error or abort, dashed for no data (dashboards.md §6.6 v0.53). The visualization test preview (§4.22) mounts the same cards and follows. |
| 2026-10-16 | v1.146 | #535 splitter.js stops wiring the withdrawn settings sidebar | **§4.3:** the withdrawal bullet (#349) gains the module half — `static/js/splitter.js`'s `wireEditorSidebar()` branch, its `--pe-sidebar-w` restore, its resize pull-back row and `SIDE_MIN`/`SIDE_MAX_SHARE` are deleted; the module wires the editor dock and the explorer tree only, and its comments name exactly those two panes. No template, stylesheet or visible change; `PipelineWorkspaceLayoutBrowserTest`'s absence assertions are untouched. |
| 2026-10-16 | v1.145 | #533 the sidebar's template leaves carry the needs-review marker again | **§4.6a:** a Templates leaf whose listed version reads `needs_review` ([REST §24](/docs/rest-api.md) v2.96) carries the catalog row's **needs review** marker (`ds-badge ds-badge-warning`, `data-needs-review`, the same title) after its version and draft badges, on exactly the flagged leaves — gone since #466 removed the server-rendered level that carried it. A drift changes a fact, not the template, so the cached level shows it on the panel's Refresh. **§4.6b:** the marker row's guards re-aimed — `TemplateTreeRenderTest` and `TemplateWorkspaceControllerTest` never asserted it; the row, leaf, chip and end-to-end guards are named. No route, permission, template or CSS change. |
| 2026-10-16 | v1.144 | #537 draft-only dashboard opens directly | **§4.21:** the default selects the served release, else the admitted draft labelled, else #409’s choose-a-version state. A default draft writes its number on the board version attribute and boots in place, keeping the URL; no served release and no admitted draft means no runtime read. Model, controller and browser guards pin selection, the attribute channel, release precedence and the narrowing lens. |
| 2026-10-15 | v1.143 | #433 the templates UI's retired-markup leftovers — renumbered at merge after #528's v1.142 | **§3.7** data-table sweep: the `templates/editor.html` (§4.7) row is gone — it named a page #398 deleted. **§4.7**: every in-app link to a template — the header search's template hits, the pipeline editor's Details link and SQL reference, the Parameter Sets inspector's template pin (which keeps its `?version=`) — targets the canonical `/templates/{name}` instead of the `/templates/editor?name=` compatibility 302, which stays for old bookmarks. The editor rail's dead splitter (`.te-page`/`.te-body`/`.te-rail`/`.te-side-splitter`, `--te-side-w`, `splitter.js`'s template-editor-side branch) and `lifecycle.js`'s `versionSelect` fallback are deleted: no template rendered them; the viewed version is the workspace root's `data-viewed-version` alone. |
| 2026-10-15 | v1.142 | #528 template tabs adopt shared history — renumbered at merge after #471's v1.141 | §3.2 and §4.7: a user tab switch pushes a tab-only `templates` entry through the shared history helper; first paint and Back/Forward replay write no URL. The helper keeps htmx's cache path current, so Back after a boosted leave restores the selected pane from cache with a working strip. Replays use the live root and one document listener; version links keep the current tab. |
| 2026-10-15 | v1.141 | #471 the entity-purge dialog previews the inbound pins the POST would refuse | **§4.3d** "Purge entity": for a pinned sole draft the dialog names the exact parent pipeline versions pinning it and every stored dashboard version pinning any of its versions (the SAME ports `PipelineService.refuseIfPinned(entityPurge = true)` reads — the dialog is a preview, the POST is the control) and renders NO destructive form; unpinned, the exclusive-template offer, the #335 kept list and the typed confirm are unchanged. |
| 2026-10-15 | v1.140 | #466 (lane 466b) the sidebar's server-rendered tree removed | **§4.3, §4.6** (and the dashboards, visualizations and parameter-sets catalogs): each catalog partial serves its flat list alone — the `scope=nav` presentation, the `prefix` tree levels, the `DP-Nav-Stamp` header and the five `*-tree-level` fragments are gone (owner ruling 2026-10-10, option A); `GET /partials/visualizations/tree` and `GET /partials/dashboards/tree` are removed, `GET /partials/parameter-sets/tree` keeps its path for the catalog. A stale `scope` or `prefix` is ignored and answers the catalog list. The sidebar has read the REST tree/search routes since #460. **§4.7**: the workspace's foreign-response guard is the REST payload's `view_token`, not the stamp. The two catalog htmx rows stop naming a `scope=page` the pages never sent; the template picker note points at the REST tree component. **§3.4 icons**: the `file-code` glyph's only users were the deleted pipeline and template levels, so it leaves the sprite and the manifest's subset (`IconSpriteAuditTest` keeps the subset exact). |
| 2026-10-14 | v1.139 | #510 a standing filter that matches nothing is never the "nothing exists" state — renumbered at merge after #514's v1.138 | **§5.1** Empty state: which of the two empties a list shows is chosen by a model-stamped `filtered` flag — ANY standing filter (`q`, dialect, type) — never the query alone. **§4.6** Templates: a dialect or type with no matches and an empty box shows the no-match state with **Clear filters** (it resets all three and fetches the unfiltered first page); a `q` miss keeps #432's Clear search. **§4.5** Datasources: a dialect-only miss shows the no-match state, not "No datasources yet". |
| 2026-10-14 | v1.138 | #514 template workspace scripts use the page loader | §3.2 and §4.7: the six bare template-workspace scripts join lifecycle dialogs in one ordered `on-mount` catalog; each source executes once per document, and the last entry, `workspace.js`, exposes `TemplateWorkspaceMount` for tabs on boosted arrivals and cached restores, also synchronizing Render context mode with the arriving page. Only the parser-blocking editor splitter remains in the per-arrival exception set. |
| 2026-10-14 | v1.137 | #516 the tree's focus ring sits on the focused row, keyboard only — renumbered at merge after #513's v1.136 | §3.4: a clicked tree row shows the selected-row tint alone — the outline that boxed a folder AND its whole expanded subtree is gone; the ring (`--focus-ring-width` × `--focus-ring`, the design system's band) is drawn on the focused row's line via `:focus-visible` with a negative offset, so it stays inside the row box and appears only for keyboard focus. Navigation, roving focus and selection unchanged (`PipelineSidebarTreeBrowserTest` pins the click and keyboard cases). |
| 2026-10-14 | v1.136 | #513 the sidebar search boxes carry the server's search bound | **§3.4** The five sidebar search boxes take their `maxLength` from the layout's `data-dp-search-max-length` attribute, rendered once from `SearchNeedle.MAX_LENGTH` — no second literal in the scripts; a box on a host that renders no bound stays usable, unbounded. A `request.query_too_long` refusal that still reaches the page names the limit in the status row (built client-side from the envelope's `details.limit`, never the server's message string) instead of the generic "Could not load". `ShellRenderTest` pins the attribute; `PipelineSidebarTreeBrowserTest` walks the bound, the typing clamp and the refusal. |
| 2026-10-14 | v1.135 | #524 (#512 merge follow-up) the dashboards catalog states no cap | **§4.21** The catalog and the sidebar branch's search render no "reads at most 200 dashboards" note: since #512 both page the lensed SQL search and `total` is the true match count, so every match is reachable through the pager ("Showing N of M"); the note described the retired in-memory first-200 read. `DashboardUiRenderTest` pins the absence at `total = 201` on both scopes. |
| 2026-10-13 | v1.134 | #512 the dashboards catalog searches in SQL | **§4.21** The catalog: `q` pages the same lensed SQL search the visualizations catalog uses — name, display name or description, case-insensitive, LIKE metacharacters literal — so every dashboard in the lens is searchable and the pager's total is the match count; the in-memory name filter over the first 200 rows (a match past the 200th was never shown, the total was the truncated set's, display name and description were not matched) is gone, and a narrowing lens with no `q` pages and counts through the same lensed reads. Sidebar branch search is the same flat list. |
| 2026-10-11 | v1.133 | #494 one page-script loader — renumbered at merge after #478's v1.132 | **§3.2** Scripts: every family page's scripts load through ONE loader, `page-assets.mjs` — inert `<template data-page-assets>` catalogs (a neutral name for the attribute that called them chart assets: since #465 they carry the lifecycle dialogs too), the `/js`+`/vendor` allow-list, once per document per source, one mount per catalog node; `data-page-assets="on-mount"` catalogs are fetched during preparation and executed once the page is in place. The pipeline editor's and the parameter workspace's runtimes stop loading scripts and become their catalogs' last entry and mount; neither page carries a bare bootstrap tag. **§4.21** (dashboards JS row, the catalog's name) and **§4.23** (the parameter workspace's JS row) follow. Measured: across editor → dashboard → editor → template → visualization → parameter set → Back/Forward every catalog source evaluates once (`PersistentChartNavigationBrowserTest`). |
| 2026-10-11 | v1.132 | #478 single-open sidebar accordion | §3.4: one open artifact branch per workspace across disclosure, keyboard, Browse folders and restoration; legacy visibility preferences migrate once. Hidden mounted trees retain context without toggle fetches. The inset themed panel contains a scoped search/clear/refresh toolbar and divider. |
| 2026-10-11 | v1.131 | #432 a catalog's Clear search empties the box it names | **§5.1** Empty state: the clear control's contract — `data-dp-clear-filters` names the controls it resets, `shell.js`'s delegated handler empties them and focuses the box, its one request includes only the kept filters, and `hx-sync` on the box aborts a search still in flight. **§4.3/§4.6:** Clear search empties the box; Templates keeps dialect and type. **§4.5:** Clear filters resets search and dialect. Same six buttons (dashboards, parameter sets, pipelines, templates, visualizations, datasources), one handler. |
| 2026-10-10 | v1.130 | #503 rail maximum reserves main content — renumbered at merge after #502's v1.129 | **§3.4/§3.6:** where the rail is a grid column (768px and up), its maximum is the viewport less the geometry token `--app-main-min-width` (320px), with the rail's 232px floor. End, pointer dragging and `aria-valuemax` use that maximum; saved preferences survive the temporary cap. The phone drawer and gutter are unchanged. |
| 2026-10-10 | v1.129 | #502 undefined CSS custom properties defined or migrated | §2.3: every custom property a first-party sheet reads now exists — the fifteen undefined names are gone. `--accent-danger-bg` becomes an app token (a `color-mix` tint of `--accent-danger` on `--surface-default`; 4% is the strongest tint keeping `--accent-danger` text at 4.5:1 in light, #fef6f6; dark tops out at 3.6:1 — the accent itself is 3.67:1 on the dark surface, reported on #502); `--surface-secondary` migrates to `--surface-inset` (the read-only pane) and `--surface-default` (the context-row inputs, `.ds-input`'s fill); `--accent` → `--accent-primary`, `--accent-soft` → `--surface-selected` (which gains §2.8's accent bar, the house selected pattern), `--text` → `--text-primary`, `--border` → `--border-subtle`, `--gap-2xs` → `--space-0-5`; new app lengths at today's values: `--focus-ring-width: 2px` (mirrors the design system's ring), `--app-content-max: 1600px`, `--measure: 44rem`, `--measure-wide: 60rem`; `--te-side-w`/`--tplx-tree-w` are splitter's runtime-written defaults and keep their fallbacks — the existence audit now reads the splitter's `prop:` declarations as definitions. `CssTokenExistenceAuditTest`'s two ledgers end empty. No visible change except the danger chips (which painted no background before) and the version link's new bar; no route, permission or role changed. |
| 2026-10-10 | v1.128 | #473 merge follow-up: the parameter-set workspace's frame log keeps attempt frames only | **§4.23:** #473 made the runtime's `onStreamEvent` witness carry the board's `scope: "dashboard"` vocabulary too, so the workspace (a parameters-only `initParameters` host, no board) logged the deduped `parameters.applied` notification after the terminal frame and `ParameterWorkspaceStreamBrowserTest` scenarios 3, 6 and 7b read it as the attempt's last frame (CI on `next`). The workspace now ignores `scope: "dashboard"` entries before its log and graph; no visible change, no route, permission or role changed. |
| 2026-10-09 | v1.127 | #492 filters drawer by board-region width | **§4.21:** the board page is the filters presentation's CSS container; at 820 px or narrower it shows the drawer, otherwise the column. Rail resizing clears an open drawer on the return to a column. `DashboardFiltersBreakpointBrowserTest` pins the rail crossings, focus and overflow. |
| 2026-10-09 | v1.126 | #491 nav-tree orphan removal — renumbered at merge after #473's v1.125 | §3.4 note: the old client (`nav-tree.js` + its node test) deleted with its CSS surface — the `rail-tree-open`/`is-measuring` blocks, the `--app-rail-tree-*` tokens, the nav-scope tree-region and `.tpl-*` scoped rules; the five search partials no longer render a nav-scope clear button (the tree engine has its own); §3.4's guard list drops `nav-tree.test.mjs`. |
| 2026-10-09 | v1.125 | #473 dashboard presentation — rebased in recovery onto #460 and #412 | **§4.21's Board tab**: the filters panel LEFT of the board (the parameters pane mounted outside the chart container; below 1100 px a drawer behind the toolbar's Filters button, closed by a widening back past it; no panel and no button while no parameter is visible), every chart a bordered titled card whose border carries its state with no visible ready badge, and the bottom activity dock (Events, Errors, History — the refreshes partial moved from the right-hand pane); the glue's document and window listeners leave with each in-shell mount. |
| 2026-10-09 | v1.124 | #465 REST tree client polish — renumbered at merge after #412's v1.123 | §3.4: staged refresh that keeps rows and reloads visible open levels; the ten-page level ceiling with Load more; the progress bar during navigation preparation. |
| 2026-10-09 | v1.123 | #412 the dashboard breakpoint follows board width, default 640 — renumbered in recovery after #460's v1.122 | **§4.21, Grid rows:** the threshold measures the board host; the 767 px viewport's 719 px board keeps the grid, a configured 700 px threshold stacks the 694 px board at 1280, and widening the user-resizable navigation rail narrows the default board below 640. A 0-wide board holds its stored grid until reveal. |
| 2026-10-06 | v1.122 | #460 reusable REST navigation — renumbered at merge after #462's v1.121 | §3.4/§3.6: reusable REST trees, complete expanded name search and clear reset; user rail width up to the viewport; prepared artifact/version navigation and chart history. |
| 2026-10-06 | v1.121 | #462 draft purge pins | §4.3d purge dialog: escaped named parent/dashboard blockers, sole/nonsole scope and no destructive form while pinned; direct POST rechecks. |
| 2026-10-06 | v1.120 | 459 merge follow-up | **§4.21's dashboard workspace, Overview tab**: the sources show their pinned pipeline versions and statuses (not "releases"), and a DRAFT pin is previewable in a draft and released before publication (#459). The R1 "release hint" wording is withdrawn; #459 superseded #369's R1. |
| 2026-10-04 | v1.119 | #416 the Release dialogs post the hash they read | **§4.3d** gains the paragraph "The Release form posts the hash the dialog read": all four families' dialogs carry one hidden `bodyHash` (`body_hash` for visualizations) from the draft read the dialog already shows, the POST requires it (400 at binding when missing) and releases AT it, so a draft changed after the dialog opened is `*.version.conflict` (409) instead of being released silently; the draft is still read only for the no-draft refusal. The visualizations lifecycle-dialogs row (§4.24) names the field. No route, scope or matrix row changes. |
| 2026-10-03 | v1.118 | 442a (#442) — renumbered at merge after 398b's v1.117 | Saved schedule notification settings: recipients and five event choices, validated and shown by role; omitted PUT settings preserved; mail remains off pending part b. |
| 2026-10-03 | v1.117 | #398 the Templates workspace — the sidebar tree and the tabbed, version-explicit workspace (#396's first reuse of the pipeline pattern) — renumbered at merge after 392's v1.116 | **§3.4**: Build's Templates item joins Pipelines and Dashboards as a **navigating-tree branch** — item link + separate toggle + a panel (`#nav-tree-templates`) whose search re-fetches `GET /partials/templates?scope=nav` into `#template-nav-root`; a leaf is a full-document link into `/templates/{name}` (release-first; a draft is an explicit choice); every Templates answer carries `DP-Nav-Stamp`. **§4.6 rewritten** ("Templates — the catalog page and the sidebar tree"): `/templates` is the flat, paged CATALOG with the dialect/type filters and the create modal (Q1(b), owner ruling 2026-10-02 — the browser keeps authoring templates, so Create/Edit/Save stay); `GET /partials/templates` gains `scope` (`nav`; any other value is the catalog — same route, permission and lens) and the stamp; the two-pane explorer body, its divider and its detail pane retire (`GET /partials/templates/versions`, `partials/template-detail.html` and the `partials/templates.html` dispatcher went with their Surfaces cells; `template-tree.css`'s orphaned `.tplx-excerpt` went with them — the rest of the tplx- chrome stays, schedules and the pipelines Overview share it). **§4.7 rewritten** ("The Template Workspace"): `GET /templates/{*name}?version=&tab=` is the canonical page (`TemplateWorkspaceRouteTest` pins the capture routing), `/templates/editor` is a 302 into it; six tabs — Source (default; the column fragments and the transform face reused, Edit kept), Overview, Render (authors, non-transform), Runs (lazy), Used by, Versions (house table, per-row verbs, no Switch); the resolution order is the pipelines one (release first, explicit-admitted-or-404, draft-only and choose-a-version states); lifecycle dialogs are `from`-aware and redirect onto `?tab=versions&ok=…` (the catalog for a purge that removes the template) with a layout flash — the explorer's Shape A leg is gone with the pane; tab switches are in-page (`replaceState`, no pushed entry — #402's contract), version switches are full navigations. §4.6b carries the capability inventory (every old-explorer row → new home → guard). No new permission; no role cell changed; `template.read`'s Surfaces cell gains the canonical route and the editor-as-redirect and loses the retired detail route. Guards: `TemplateSidebarTreeBrowserTest`, `TemplateWorkspaceBrowserTest`, `TemplateWorkspaceControllerTest`, `TemplateWorkspaceModelTest`, `TemplateWorkspaceRouteTest`, `ShellRenderTest`, the re-aimed template suites, `RoleWalkE2eTest`, the falsifications in the handback. **Rework (398b):** `template-editor.css` and `template-workspace.css` load from the layout head, not from inside `#app-main` — the first round shipped them in the `content` fragment, §3.0's defect on a boosted arrival; `ViewerEditorRenderTest` pins both sheets before `</head>` and none inside `<main>`. |
| 2026-10-03 | v1.116 | #392 the Overview's Last run follows the caller's Runs-tab visibility | **§4.4**: the Overview's Last run is the latest run visible to its caller, through the same #275 read as the Runs tab; a promoter sees only their own. |
| 2026-10-03 | v1.115 | #426 the visualizations workspace's tab switches take the shared history helper — renumbered at merge after 420's v1.114 | **§4.24:** the JS cell — `workspace/panes.js` takes a `family` (the visualizations glue passes `visualizations`) and pushes a tab-only entry per user switch through `workspace/history.js` (#402) instead of `replaceState`, re-selects on Back/Forward in page through the helper's one window listener (the per-root `popstate` listener is gone; it survives only on the no-family/no-helper path), guards the wiring with the `__dpWsWired` expando so a restored root — whose cloned markup carries `data-dp-ws-wired="1"` — wires again, and re-writes `data-active-tab` on every apply so the restore shows the tab it left; `history.js` loads between `tabs.js` and `panes.js`. The htmx cell keeps "the tab strip never navigates". No action, route or permission changes (no §7.6 row). The parameter-set workspace is NOT moved onto the helper: its sections are full-document links (`hx-boost="false"`, `?version=&tab=history`), there is no in-page switch to make navigable. The templates workspace adopts the helper after #398's rework lands (follow-up to #426). |
| 2026-10-03 | v1.114 | #420 the pipeline editor re-adopts the shared workspace tab core — renumbered at merge after 408's v1.113 | **§4.21**: the clause "the pipeline editor keeps its own `pipeline-editor/tabs.js`" is withdrawn; §4.4's workspace six-tab machine (Flow, Overview, Parameters, Runs, Usage, Versions) is no longer a private copy — `pipeline-editor/tabs.js` delegates admission and transition to `workspace/tabs.js` (loaded first by the editor's runtime catalog), and its 18 named getters read `this` so the CSP build's reactive proxy tracks them (#400's first adapter closed over the raw object and was reverted). No tab, pane, route or role changes. |
| 2026-10-03 | v1.113 | #408 a malformed typed path or query value — renumbered at merge after 422's v1.112 | **§5.1** gains the malformed-typed-value rule: a path value that does not convert answers as a well-formed absent id (the 404 page or toast, indistinguishable), and a query or form value is a `400` with REST's `pipeline.execution.invalid_parameter_type` naming the parameter, never the value. It is never the shell-wide 500, and a parameter type no converter reads stays the 500. **§6** gains the `400` row that has existed since 098 (the form sentence by default, the address sentence for #408), and the 404 row says a malformed id answers as an absent one. |
| 2026-10-03 | v1.112 | #422 the dashboards and visualizations Versions tables take the keys page's time shape | **§4.21, §4.24:** the Versions tab's created and released cells are RELATIVE (`3 days ago`) with the absolute UTC stamp (`2026-10-01 09:00 UTC`) on `title` — the keys rule (§4.19: relative in the cell, absolute UTC on hover), `RelativeTime.since`/`absolute` computed in the model's fill (`fillVersions(…, now)`), never the template and never the JVM's zone; an unreleased row keeps the em dash. The dashboards partial's root also carries `dp-versions-pane` (`.dp-versions-pane > * { min-width: 0 }`, the visualizations pane's `.viz-versions` fix) so its framed table scrolls inside its own viewport at 390 px instead of pushing `<main>` sideways. |
| 2026-10-03 | v1.111 | #402 Back/Forward across the workspaces' in-page switches (spec A12) — renumbered at merge after 401's v1.110 | **§3.2**: the workspaces' own history entries sit beside the region-scoped cache — `{dpWorkspace: {family, version, tab}}`, ignored by htmx's popstate, replayed by `workspace/history.js`'s one window listener, never cached; htmx's current-path record kept on the live URL so a boosted leave after a switch is a cache HIT; an entry that pops under another screen is handed to htmx's restore. **§4.4**: tab and version switches PUSH (no longer replaceState); Back/Forward replay on the live instance; the workspace block and the root's tab attribute stay truthful for a restore (a snapshot taken after a switch used to restore with an EMPTY selector). **§4.21** (the dashboards workspace's JS row): tab switches push through the helper; a restored root re-wires once (its guard is an expando — the cloned `data-dp-ws-wired` attribute had left every restored strip dead) and re-paints the tab it left. |
| 2026-10-03 | v1.110 | #401/#407 the pipelines explorer residue — the pane and the dialogs' explorer legs removed; the workspace's release flash names the cascaded templates | **§4.3b**: the detail pane's fragment (`partials/pipeline-detail.html`), its route `GET /partials/pipelines/detail` (off the `pipeline.read` §7.6 Surfaces cell in the same commit) and `PipelineBrowseModel.fillDetail` with its region fills are REMOVED — no page has rendered the pane since #350; the section is the record. **§4.3d**: the pipeline dialogs' `#px-dialog` shapes are gone (`hx-target` ternaries collapsed to the editor form, the POSTs' `from` legs with them — every pipeline verb answers `HX-Redirect`); Shape A is the templates explorer's. `lifecycle-dialog.js` drops `px-dialog` and the `pipeline-detail` close; `explorer-detail.js` drops the pipeline half (the `leafId` branch, the drawer check); `pipeline-versions.html` defaults to the workspace's `#pe-dialog`/`from=editor`. **§4.3d, the Release row**: the reload's flash NAMES the cascade — "Released. Also released: `<template@version>`, …" — server-derived (#407): the release POST holds the list once in the actor's session (`ReleaseFlash`), the workspace GET consumes it; a crafted `?ok=`, another page, another actor or a minute gone by renders the generic sentence only. No permission, role or scope row changed (one route retired with its handler, same commit); no new route. Guards: `MatrixRowReachabilityTest` (red on the row before the handler), `RoleWalkE2eTest`, `ReleaseFlashTest`, `PipelineLifecycleDialogControllerTest` (the held-names assert), `PipelineExplorerRenderTest` (30→21 tests), `LifecycleDialogRenderTest`, `ReleaseCascadeBrowserTest` (the names in the toast + the consumed/crafted replay), `lifecycle.test.mjs`, `explorer-detail.test.mjs`. |
| 2026-10-03 | v1.109 | #399 the Visualizations workspace (#396) — renumbered at merge after 415's v1.108 | **§3.4**: Build gains **Visualizations** between Dashboards and Parameter Sets, the navigating-tree pattern's fourth use (lucide `chart-line`; link → the catalog, lazy tree WITH the search box, leaf → the workspace; no engine change). **New §4.24**: the flat catalog `GET /visualizations` (`q` over name, display name and description, `ILIKE … ESCAPE`), the sidebar branch `GET /partials/visualizations/tree`, and the version-explicit workspace `GET /visualizations/{id}?version=&tab=` with five tabs — Preview (test fixtures, the default: one case at a time in the runtime's fixture mode, the configuration built by §4.22's builder, the empty state verbatim), Overview, Evidence (states its 100-run cap; the screenshot or "no screenshot"), Used by (the pinning dashboards through the dashboard lens) and Versions (the seven lifecycle dialogs with the one `release_pinned_templates` consent and the refusal list, and the Export link). DISCARDED and lens-hidden versions are the family's 404; a malformed version is a non-echoing 400. **§4.22**: the signed-in twin paragraph. No permission, row or role changed; the routes are in auth.md §7.6's visualization Surfaces cells. Guards: the render/controller/model tests, `VisualizationPreviewBlockTest`, `ReadFloorTest`, `ScriptBlockUtextAuditTest`, the two browser suites. |
| 2026-10-03 | v1.108 | #415 the Parameter Sets name search (follow-up to #374) — renumbered at merge after 383's v1.107 | **§3.4**: the Parameter Sets panel gains the search box in the Pipelines branch's exact markup — the third branch the shared engine serves with zero JS lines changed; a non-empty `q` swaps the flat results into the panel's root (`#params-tree-nav`), clearing returns the tree, and a set the lens hides is never a hit (the service filters it, never the template). **§4.23**: `GET /parameter-sets` gains `?q=` and the SPA search control — a case-insensitive substring of name, display name or description through the new `ParameterSetService.search` (everything lens → SQL `ILIKE … ESCAPE '\'` with the term's `%`/`_`/`\` literal; a narrowing lens → in memory over the admitted sets); `partials/parameter-set-list` is retired for the `partials/parameter-sets` dispatcher + `partials/parameter-set-search` (the dashboards' shape, one family over). No new route, permission, scope row or role changed: the same two routes on `parameter_set.read` gained a query parameter. Guards: `ParameterSetServiceIntegrationTest`, `ParameterSetsBrowseModelTest`, `ParameterSetsPartialControllerTest` (new), `ParameterSetsUiControllerTest`, `ParameterSetsRenderTest`, `ShellRenderTest`, `ParameterSetPagesBrowserTest`, the falsifications in the handback. |
| 2026-10-03 | v1.107 | #383 the workspace's observed evaluation — the client half (workspace spec §4.2/§6.2/§6.3, #357 S2b) — renumbered at merge after 376's v1.106 | **§4.23**: the live form now STREAMS `POST /api/v1/parameter-sets/{id}/evaluations` (rest-api §21.5 — the observed evaluation; the version, the whole selection set, a fresh v4 `evaluation_id` per attempt, the CSRF header) through the same `initParameters` entry; the graph is the evaluation's LIVE STATE (waiting → admitted → running → resolved, failed with the frame's catalogued code, D3's unfinished sweep from `evaluation_failed`, the terminal response's reset marks), the live region announces one sentence per frame, a supersede CLOSES the prior attempt's stream (the server's grace aborts it), another attempt's frames are dropped before any state or DOM change, a stream ending without a terminal frame takes dashboards §6.6 (lock released by the deadline, never earlier), and the page keeps an in-memory frame log (`window.PSWorkspace.frames`) the guards read. No route, permission, scope row or role changed; no new page markup (the log is data, not a pane). Guards: `parameter-set-stream.test.mjs`, `parameter-workspace-graph-state.test.mjs`, `ParameterWorkspaceStreamBrowserTest` (scenarios 3/6/7/10 + the promoter arm), `ParameterSetFormBrowserTest`'s captures tightened to the exact `/evaluations` path. |
| 2026-10-03 | v1.106 | #376 the Parameter Sets History tab (workspace spec §6.4, #357 S3) — renumbered at merge after 400's v1.105 | **§4.23** gains **History**: the `Workspace` / `History` section links (one canonical URL each, `?tab=`), the History arm with no live form, graph or body block (so the tab evaluates nothing), the house table of the set's evaluation records — every caller's, 25 a page, newest first — its pager partial and the record detail partial (header, per-parameter outcomes, the statement attempts to the millisecond); `parameter_set.read`, the lens server-side (the page's own 404 on both partials; a narrowing lens lists only the versions it admits). |
| 2026-10-02 | v1.105 | #400 the Dashboards workspace — the searchable sidebar tree, the flat catalog, the tabbed version-explicit workspace (#409 closes with it) | **§3.4**: the Dashboards panel gains the search box in the Pipelines branch's exact markup (the shared engine serves it with no JS change; a non-empty `q` swaps the flat results into the panel's root, clearing returns the tree). **§4.21 rewritten**: `GET /dashboards` is the flat CATALOG in §4.3's shape (owner ruling 2026-10-02 — the L3b tree page retired; folders are the sidebar's axis), each row a full-document link into the workspace, a draft-only dashboard's row saying so; `GET /dashboards/{id}?version=&tab=` is the tabbed, version-explicit WORKSPACE — Board (today's board page exactly: the same bundle block, the unchanged `dashboards-page.js`, the events pane beside the board), Overview (the definition, read-only, with each pin's status and the R1 release hint), Refreshes, Versions (the history and the seven lifecycle dialogs into `#dp-dialog`, `HX-Redirect` back with a flash) and Keys (`dashboard.key.bind`, read-only, linking the Keys page); #409: a dashboard with no served release opens the choose-a-version state — the heading NAMES it, the draft is offered, the runtime config never called, no `not_found`; the preview route answers 303 onto the workspace (the deep links survive); every version switch a full navigation (the one-bundle rule), tab switches in-page over the SHARED tab core (`static/js/workspace/tabs.js`; the pipeline editor's adoption was reverted at merge — #420), the hidden Board re-fitting through the runtime's own `resize()`. **§4.21's preview gap closed**: the #369 draft preview is documented here for the first time, as the workspace's named-version view (the chip succeeded the banner). `dashboards-page.js` unchanged; no new permission; no REST route changed. Guards: the falsifications in the handback. |
| 2026-10-02 | v1.104 | #374 the Parameter Sets screens (workspace spec §6.2/§6.3, #357 S1) — renumbered at merge after 386's v1.103 | **§3.4**: Build gains **Parameter Sets**, the navigating-tree pattern's third use (lucide `sliders-horizontal`; link → the catalog, lazy tree beside it with no search box, leaf → the workspace). **New §4.23**: the flat paged catalog `GET /parameter-sets` and the canonical workspace `GET /parameter-sets/{id}?version=N&tab=` — the version-explicit resolve order (explicit admitted version, never clamped → the lens-visible current → an accessible draft, labelled → choose-a-version), the house 404/400, a static Cytoscape graph + inspector and the live parameter form through the dashboard runtime's new parameters-only `initParameters` entry against the EXISTING evaluate route (role-hidden: a promoter's page has no control and no request). The demo family seeds `nyc/parameters/geo_filters`. No permission, scope row or role changed; the routes are listed in auth.md §7.6's `parameter_set.read` Surfaces cell. Guards: the render/controller/model tests, `ReadFloorTest`, `SampleDataParameterSetsContentTest`, `ParameterSetPagesBrowserTest`, the falsifications in the handback. |
| 2026-10-02 | v1.103 | #386/#387 the board's small slots and its breakpoint | **§4.21's Grid rows row** gains the collapse below `breakpoint_px` (768 px default; every item full width in grid order, the row unit kept, a crossing re-places and resizes) and the 2-row chart's plot-area floor (compact margins + `automargin`, dashboards.md §6.3). |
| 2026-10-02 | v1.102 | #364 the history shape guard names the `<main>` element | **§3.2** — the #358 shape guard decides "body-shaped" by the serialised OPENING TAG `<main … id="app-main"` (attribute order tolerated), not by the substring `id="app-main"`. The substring test was fooled by user text — innerHTML serialisation escapes `<`, `>` and `&` in a text node but not the double quote — so a page whose text carried the literal (a pipeline description) read as body-shaped and reloaded on every Back/Forward. The test is one module-scope function in `shell.js` (`historyEntryIsBodyShaped`, exposed for `node --test`); `PipelineWorkspaceHistoryBrowserTest` gains the cache-hit-with-no-reload case for a description carrying the literal. No route, scope, template or policy change: a genuinely body-shaped entry is still purged and vetoed. |
| 2026-10-02 | v1.101 | #350 the pipeline tree in the expanding global sidebar (workspace spec §5, D7/D8) | **§3.4**: Build's Pipelines and Dashboards items are **navigating-tree branches** — one normative pattern (item link + separate toggle + a panel with the lazy server-backed tree in a bounded scroll region), the leaf a full-document link (pipelines → the canonical workspace, current-first), the explorers' keyboard engine in its NAV context, `aria-current` on the viewed page's leaf; the rail FITS an open tree's visible rows between `--app-rail-tree-min` 320px and `--app-rail-tree-max` 400px and the region scrolls sideways past the max with full labels; an icon collapse wins by selector; visible failure + Retry; the generation + `DP-Nav-Stamp` (workspace\|lens) admission guard; per-workspace+family state (paths and offsets only) restored incrementally, `rail.js` painting the remembered width before the first frame. The Dashboards branch adopts the pattern (its `<details>` disclosure retired — one tree engine in the rail). **§3.2**: `template-explorer.js` loads once from the layout; a re-executed page tag re-inits. **§3.6**: the tree-open widths per band (desktop; tablet only when expanded; the phone drawer keeps 232px). **§4.3 rewritten**: `/pipelines` is the flat, paged CATALOG (owner ruling 2026-10-02) — no tree, no detail pane, every row a full-document link into the workspace, `?q=` kept as the deep link; `GET /partials/pipelines` gains `scope` (`nav`; any other value, `page` included, is the catalog — same route, permission and lens). §4.3a/§4.3c now describe the templates explorer only; §4.3b is the record of what moved (its fragment and the explorer lifecycle legs have no page — #401). **§4.3d/§4.3e (#395)**: the workspace's Switch/Discard/Restore never sent their request (their dialogs targeted the explorer's `#pipeline-detail`); they are `from`-aware now and redirect onto the Versions tab with a flash, and the workspace header keeps the one-destructive rule (Purge pipeline in the `{D}` shape). **§4.21**: the sidebar's Dashboards branch is the pattern. No permission or role changed; no new route. Guards: `PipelineSidebarTreeBrowserTest`, `PipelineSidebarTreeStateBrowserTest`, `nav-tree.test.mjs`, `template-explorer.test.mjs`, the controller/render tests, the falsifications in the handback. |
| 2026-10-02 | v1.100 | #371 the dashboard grid row unit | **§4.21's board table** gains the **Grid rows** row: every row of the board's grid is one `--dashboard-row-unit` (app.css), so a Plotly figure has its slot's height on the board page (it measured 34 px for a 4-row slot before). The preview-scoped copy of the rule (#353, §4.22) is retired — one unscoped rule serves both pages. Routes, permissions, roles: none changed. |
| 2026-10-01 | v1.99 | #349 the unified pipeline workspace — six tabs, the bottom Node Details dock, house tables, view/run ownership | **§3.7**: four surfaces join the house table — the Parameters tab's declared schema (`dt-fit`, client-rendered rows the observer upgrades), and the versions/runs/usage fragments (one shape for the explorer pane and the workspace; the ⋯ verb menus and `data-version-row` ride the rows; runs keep the `data-href` keyboard contract; the parent links name the canonical workspace). **§4.4**: the composition — six tabs (Flow the default; Runs omitted with NO lazy fetch for a caller without the execution read; Runs/Usage lazy-once; Overview client-rendered from the lens-filtered workspace block; the sidebar withdrawn, its content in Overview and Parameters), the IN-PAGE version switch (selector and Versions-tab Open; URL via replaceState; one generation/token discipline over node SQL, checks, runs and usage; per-version run-input drafts, nothing persisted), and run ownership (the identity strip with the effective SUBMITTED parameters and View run's version; graph paint, node states and the Details pane's run rows gate on run version = viewed version). The dock keeps Node Details beside Results/Errors/Events; the execution tabs render only with the execution read. No route, permission or role changed. Guards: `PipelineWorkspaceTabsBrowserTest`, `PipelineWorkspaceRunOwnershipBrowserTest`, the promoter tab-omission arm in `PipelineWorkspacePromoterAdmittedBrowserTest`, `PipelineWorkspaceLayoutBrowserTest` (which replaces the 141 sidebar-resize suite — the pane it resized is withdrawn by the same ruling), `tabs.test.mjs`, `workspace-view.test.mjs`, the falsifications in the handback. **Corrected at merge (orchestrator, review F1/F2):** §4.4's 141 sidebar-resize bullet now records the withdrawal (the pane is gone; `PipelineWorkspaceLayoutBrowserTest` pins it) and §4.3b's "compact rows, never a table" now states the shared house-table fragment. |
| 2026-10-01 | v1.98 | L4b (#353) the visualization test preview | **New §4.22** — the first session-less, capability-authenticated page: `GET /visualizations/{id}/preview?session=`, its standalone head (no layout, no CSRF token, no cookie), the per-case cards mounted by the vendored runtime in its fixture mode, the one byte-identical 404 state for every refusal, and its guards. |
| 2026-10-01 | v1.97 | #358 the history cache is region-scoped, and its restores are single-init (lane 348-c) — renumbered at merge after L3b's v1.94 | **§3.2**: `#app-main` carries `hx-history-elt` — the cache snapshots the workspace region, never the body, so footer scripts are never cached and never re-executed on a Back; a one-time purge plus a restore-time shape guard (drop + full fetch — htmx's own `refreshOnHistoryMiss`, since a cancelled `htmx:historyCacheHit` in 2.0.10 simply dies) refuse any body-shaped entry saved by a pre-scoped build, keyed on the one honest tell (`id="app-main"` as a CHILD). **§3.2's scripts bullet rewritten:** the pipeline editor's restore is single-init through its runtime (inert catalog, `x-ignore`, one `mutateDom` activation — the 080 rescue is gone); a run outliving a navigation is detached, never cancelled, and the restored page re-attaches through the replay stream (terminal event exactly once). **The shell's transient chrome** (`is-pending`/`app-busy`/their `aria-disabled`) comes off before every snapshot and is purged from restored pages — a restored link no longer looks clicked (the #358 browser suite caught it as a dead `.pe-back` on the restored page). **§3.7's data-table paragraph**: "the history element is `document.body`" corrected to `#app-main`. Routes, permissions, roles: none changed. |
| 2026-09-30 | v1.96 | 348-b (#348) the workspace's version-context corrections — renumbered at merge after L3b's v1.94 | **Correction of the record first:** v1.92's sentence "the version rows' Open links carry their row's version" described the intended end state — the code still targeted the unqualified `/editor` URL at that tip, so every row opened the default. **§4.3:** the version rows' Open now really enters `/pipelines/{id}?version=<row>` (full document), and the detail header's Open is the canonical `GET /pipelines/{id}` with the old "in editor" wording gone (tree/search rows keep the redirect-covered URLs). **§4.4:** the page's serialized projection carries only the LENS-VISIBLE current pointer — a development-posture current draft's number can no longer leak into `current_version` of the script JSON for a promoter (the model's `currentVisible`, not the raw index value; PipelineResponses' REST shape untouched). The client's version context has ONE validated initialization path (`workspace.js`: bounded positive integer, body presence, pipeline identity) shared by full load, boost and cached-history restore — the restore re-reads the block through it (clearing stale refusal flags) — and the workspace's node-SQL preview REFUSES visibly with zero requests when no valid pin exists (the legacy working-version default stays with callers that omit the parameter). The cached-restore leg's own residual defects (duplicated subtrees, stacked bindings, Cytoscape CSP re-init) are shell-owned: #358. |
| 2026-09-30 | v1.95 | 348 (#348) the version-explicit pipeline workspace — renumbered at merge after L3b's v1.94 | **§2.1**: `/pipelines/{id}` is a UI page route. **§4.3**: the explorer's Open action (detail header) and every historical `/pipelines/{id}/editor` link enter the canonical read workspace; the version rows' Open links carry their row's version (they targeted the editor with none — every row's link was the same URL, the design record's second migration trap). **§4.4**: the route statement — `GET /pipelines/{id}?version=N&tab=…` at the `pipeline.read` floor, explicit-version-or-404, current-pointer-first default, choose-a-version/empty states, the header's read-only version selector, and the old editor URL as a compatibility redirect (preserving a valid version/tab; the execute floor of 122 is gone with the page it guarded). Execute pins the VIEWED version on the wire (released included); the node-SQL GET carries an optional `version` the workspace always sends; the page's JSON blocks are `#pipeline-data` + `#pipeline-workspace` (the lifecycle draft-pin block and `draft.js` are gone). Floor/auth wording lives in auth.md v3.26; the page's layout itself is #349's. |
| 2026-10-01 | v1.94 | L3b (#10) the first-party dashboard pages — row added at merge (the lane's commits carried the sections without one) | **§3.4**: the landing item is **Home** (D58; the route `/dashboard` and its exact-match section unchanged) and the Build group gains **Dashboards** — the link to the tree page with a lazy disclosure beside it that expands ONE level of the tree in place (`/partials/dashboards/tree?scope=nav`, the same fragment the page renders). **New §4.21** Dashboards: the tree page `/dashboards` (one level per request; a leaf opens the board as a FULL navigation, `hx-boost="false"`) and the board page `/dashboards/{id}` (the server-declared ONE Plotly bundle on its script tag, the glue as a file, the in-page refusal state for a board that cannot run, the events pane's bounded poll). |
| 2026-09-30 | v1.93 | L1c-c (#10) transfer limits and evidence | **§4.17**: the `body_invalid` flash renders its toast (it was unmapped, hence silent — a refused batch said nothing on the screen that issued it), the toast text points at the REST answer's bound/count/key, and the two shipped branches whose `&#39;` entities sat inside fragment-expression literals (`missing_datasources`, `key_invalid`) — a latent render-500 on those flashes — now use typographic apostrophes. Guard: `PromotionErrorFlashRenderTest` (the body_invalid toast renders; the unmapped-key closure unchanged; red-first with the branch removed). No route, permission or role changed. |
| 2026-09-29 | v1.92 | L1c (#10 L1c) the transfer families on the promotion page — renumbered at merge after 340's v1.91 | **§4.17**: the two family tables in the parameter-set mould's shape inside the same form (`name="visualization"` / `name="dashboard"`, the Send column and role guard), the read-only arm, the four-list empty state and form condition, the flash's `visualizations=N dashboards=N`, and the action's six-argument `promote`. The plan lists are the lens (the dashboard's newer-than-target arm stated). Guards named in the section. No route, permission or role changed. |
| 2026-09-30 | v1.91 | 340 (#340, #335) lens residue + the purge dialog's kept list | **§4.3b, the Usage tab:** the parents half applies both halves of the promoter lens — the parent NAME, and under a narrowing view RELEASED parent versions only (the live-parents query returns DRAFT ones; a draft's number never reaches a promoter, 178b) — and the badge counts what the tab lists. **§4.6, the template screen:** the per-version count's pipeline arm honours the lens too (admitted pipelines, RELEASED working versions, re-derived from rows like the set and visualization arms; the whole view keeps the aggregate). **§4.3d, the purge-entity dialog:** names the draft templates the offer keeps and the sets/visualizations that hold them (#335), from the same port the REST `kept_draft_templates` reads. No route, permission or role changed. Guards: `PipelineBrowseModelUsageTest`, `TemplateUsageTest`, `PipelineLifecycleDialogModelTest`, `PipelineLifecycleDialogRenderTest`, each falsified. |
| 2026-09-30 | v1.90 | lane 336-b (#336) | **§4.20**: the refresh answer owns its selection — both completion arms are guarded by the page generation and the originating schedule, so a late reply for a superseded selection (or a page since left) marks nothing, toasts nothing and arms no poll; the outage flag lives on the selection's own state, so the first failure of a NEW selection toasts even though the previous one's did. Correction of the 336 review's F1; the v1.89 stale+toast contract is unchanged. No route, permission or role changed. |
| 2026-09-30 | v1.89 | lane 336 (#336 D8) | **§4.20**: a failed runs refresh keeps the runs shown and MARKS them stale — a `data-slot="runs-stale"` note plus one toast per outage (never one per poll) — until a refresh answers; the poll keeps its existing cadence. A malformed 2xx body rejects like an error (the same stale+toast path). No route, permission or role changed. |
| 2026-09-30 | v1.88 | lane 336 (#336 D4) | **§4.13 gains the degraded reads**: a failed members listing or key-owner read renders "Members could not be loaded" / "Key owners could not be loaded" (`ds-empty` notices, `data-members-degraded` / `data-key-owners-degraded`, fixed text — the failure's class + SQLState live in the controller's log line); a catalogued refusal still renders the empty state it always did. No route, permission or role changed. |
| 2026-09-30 | v1.87 | 320 (#320) dependency guards | **§4.6 the template screen:** the "Used by" card lists the parameter sets and the visualizations that pin the template beside the pipelines (its header counts each kind), and the per-version count's unit is "uses" — the guard refuses on all three, so the card and the refusal name the same things. **§4.3d/§4.6 the dialogs:** the template Purge, Discard and Purge-entity dialogs pre-read the SAME composed evidence the release service refuses with (the sole-draft purge asks the entity rule) and list sets and visualizations beside pipelines, buttonless; the pipeline Discard dialog and the **Usage tab** list the dashboards whose sources pin the version (lensed by the dashboard lens; a dashboard counts in the tab's badge — it is refusal evidence). |
| 2026-09-29 | v1.86 | 313 (#313, with #312) the promotion page offers parameter sets | **§4.17 Promotion**: the plan's parameter-set rows render as their own table — the pipelines table's frame, Send column and `canPromote` role guard, heading "Parameter sets", checkboxes `name="parameter_set"` — inside the form for a promoter and as a second read-only table for a reader; the "Nothing to promote" empty state fires only when BOTH lists are empty, and the success flash carries `parameter_sets=N` into the toast's sentence. The action reads the second field and calls the sender's four-argument `promote` (§8.3's order: templates → sets → pipelines); a set-only selection is a valid submit. Guards: `PromotionSetsRenderTest` (the sets table inside the same role guard; red with the table removed), `PromotionUiControllerTest` (the four-argument call, the counts in the flash), `PromotionTwoDeploymentE2eTest` Order 55 (the form post promotes a set to uat), `PromotionSetsBrowserTest` (light/dark screens of the page with a set candidate, against a stub target — the browser harness boots one app). No route, permission or role changed. |
| 2026-09-28 | v1.85 | 301 (#301, #273, #149) UI residue — the 287 security pass's observations 2–9, the discard dialog's schedules, the facts link | **§4.3d, the Discard dialog**: it now lists **the schedules that run the pipeline** (273) — the SAME by-target read the Usage tab makes, lensed for the caller, one row per schedule with its condition and stored next occurrence, under a note that says the consequence (the discard succeeds; each schedule then blocks at its next run — the §5.2 `pointer_null` shape — until repointed or deleted). **§3.7, keyboard viewports**: the `tabindex="0"` tab stop lands only on a viewport that actually scrolls (301) — the enhancer checks the computed overflow and the content, re-checked in `measure()`; a fitting page-flow frame (`overflow: clip`) keeps its inert `-1`. The history snapshot cleanups run on the **live page** an instant before it is snapshotted (301 — htmx clones only after the event), wording corrected in the shell and the enhancer; the app.css modal comment matches the class toggle the scripts actually do; the token test's duration rule reads the longhand properties too; the paged-sort rule's every-row-new decision is a node-tested seam (`allRowsNew`) and the dock's ancestor walk is linear in the batch (a Set). **The datasource facts' source-pipeline link is a full document load** (149) — it carried the one boosting `<a>` into the editor, which cannot initialise on a swap. Guards: `DataTableBrowserTest` (a non-scrolling frame keeps `-1`; the evidence names in the observer KDoc corrected to the numbers), `DataTableCssTokenTest` (longhand), `data-table.test.mjs` (`allRowsNew`), `PipelineLifecycleDialogRenderTest`/`RoleVisibilityRenderTest` (the schedules evidence, the promoter contract), `DiscardDialogSchedulesBrowserTest` (two schedules; a promoter sees no Discard verb), `DatasourceFactsLinkBrowserTest` (the full load, red on the boosting tree at the issue's own symptom). No route, permission or role changed — the dialog's read rides `pipeline.version.manage` as before. |
| 2026-09-28 | v1.84 | 298 (#293) every surface answers as `visibleTo` | §4.2 and the role-guard table: the dashboard's stat tiles count a promoter's OWN runs (`listVisibleTo`'s third arm, in SQL) instead of zero — the explorers' Runs tabs and the search palette already did; the Recent executions panel stays `execution.read` and undrawn for a promoter. The API console's Legacy paths row withholds Unpublish when its echoed path was cut (a database-written path past the grammar's 200 characters — an unpublish of the echo would address another path) and says so on the row. Default ruling by the orchestrator, recorded as a decision the owner may overturn. |
| 2026-09-28 | v1.83 | 194d (#194) parameter sets — numbered at merge after 287's v1.82 | **§4.17 Promotion**: the plan carries parameter-set rows through `Plan.promotableParameterSets` (the record's §8.3 — pushed after templates, before pipelines); the table markup stays 282's. **§4.6/§4.7 the template explorer/detail**: the per-version in-use count and the used-by list now cover PARAMETER SETS too (`application`'s `TemplateUsage` composition) — a set pinning a template version is one user, counted beside the pipelines, and the `template.in_use` delete refusal names `referencing_parameter_sets`. The lens covers sets on every read (the same `LensedView`). |
| 2026-09-28 | v1.82 | 287 (#287, #288) the modals' Back path + the data table's five follow-ups — renumbered at merge after 286's v1.81 | **§3.7 amended.** The **history snapshot carries no style a script wrote through ONE seam** (287): shell.js's one `htmx:beforeHistorySave` listener runs the `window.__dpHistoryStyleCleanups` registry — data-table's write set (frame, table, cols) and the version menu's placement (closing open menus first, the shell's no-transient-chrome rule) registered there; data-table's own listener is gone. The three hand-rolled modals (`#register-modal`, `#key-modal`, `#create-template-modal`) show and hide through the existing `u-backdrop-hidden` **class**, never an inline `display`, so no `style` attribute exists to serialise and Back carries 0 CSP violations (`ModalBackBrowserTest` walks all four writers: open → close/placed → boosted away → Back). **The paged sort's state is never silent** (288 #1): a page whose rows are NEW to the table (the htmx pagers' whole-frame swap; any keyed-by-content re-render) clears the client sort — the server's order shows, `aria-sort` back to `none` everywhere; a held sort NAMES itself in the button title ("Sorting this page by X — …"), which is the truth for the dock's cursor paging, whose index-keyed in-place re-render keeps the held sort by element identity. A row edit and an append keep the sort. **The observer pays one discovery query per top-level root** (288 #2): a batch's added nodes with no ancestor in the batch carry the whole subtree, and a root inside an upgraded table is one of its rows — refresh's target walk owns it. **Durations are tokens** (288 #3): the skeleton's shimmer takes `--duration-slower` (the ds-spin precedent); the frame's length custom properties stay — they are the component's own contracts, read by name from the script, the §3.7-sanctioned mechanism. **The viewport is keyboard-scrollable** (288 #4): the enhancer flips the server's `tabindex="-1"` to `0`, arrows scroll, `.dt-viewport:focus-visible` draws the ring inside the box. The stale `#app-main` history comment corrected (288 #5). Guards: `ModalBackBrowserTest` (new), `DataTableBrowserTest` extended (the htmx page swap, the dock's held-sort title, the keyboard viewport, the discovery count), the token test's duration and focus-ring rules, `data-table.test.mjs` +2 (`topLevelRoots`, `activeSortTitle`). No route, permission or role changed. |
| 2026-09-28 | v1.81 | 286 (#275) R3 on the Runs tabs and the palette — numbered after 282's v1.80 (renumber at merge if 287 lands first, keep both) | **§4.3b**: the pipeline explorer's Runs tab (and, by the same read, the template explorer's Runs tab and the search palette's executions group) follows §4.8's R3 — own runs plus every SCHEDULED run for a viewer or author; they read own-only until #275. A promoter reaches these `pipeline.read` / `template.read` panes without `execution.read` and sees her own runs only. No route, permission or role changed. |
| 2026-09-27 | v1.80 | 282 (#282) the data table — numbered after 280's v1.79 (in flight; renumber at merge if 280 has not landed, keep both) | **New §3.7 (normative) — the house data table**: `.dt-frame` › `.dt-viewport` › `table.ds-table`, one rule set (`data-table.css`, after app.css) and one enhancer (`data-table.js`, after shell.js), ported from the owner-approved preview: the header sticky in its viewport over opaque rows, the frame's `::after` CAP over the scrollbar track beside the header (measured `--dt-sbw` / `--dt-head-h`, 0 on overlay scrollbars), client sort over the rendered rows (`aria-sort`; nulls last both ways; "Sort this page by …" on a paged list), resize on a `<colgroup>` with `table-layout: fixed` (a drag changes one column), a frozen first column (`dt-freeze`), keyboard rows (Enter through shell.js's `data-href` handler), states as rows of the same table, re-init on htmx swaps and on non-htmx row producers (a MutationObserver), the history snapshot stripped of CSSOM styles; CSP-clean (no `style` attribute anywhere; CSSOM only). The 19 templates' 22 tables swept, each decided FIXED or PAGE-FLOW (the §3.7 table); PAGE-FLOW's header sticks to `<main>` under the top bar — measured: Chromium stops a sticky cell at the scroller's content edge, so the script sets `--dt-flow-top` to minus the scroller's padding. **§2 principle 9**, **§3.3 rule 2**, the primitives rows, §4.4's result grid, the §5 example, **§5.1** (empty and first-paint loading as rows of the table). Legacy classes: app.css's boxed `.ds-table` block folded, `.app-card-table` reduced to one clip rule, `.u-scroll-x` and `.tplx-fit-table` retired, `.pe-result-table-container` reduced to the dock's inset; `.facts-table` / `.mini-table` are the marketing site's, untouched. Guards: `DataTableCssTokenTest`, `data-table.test.mjs`, `DataTableBrowserTest`, `AppCssTokenAuditTest`'s card rule moved to the viewport. No route, permission or role changed. |
| 2026-09-27 | v1.79 | 280 (#280) the form says what is wrong | **§4.20, the form**: every field-placed §20 refusal renders the code's OWN user message, not the family line — nine per-code `ApiErrorCatalog` rows (the name grammar with no leading slash, name-taken, the pattern, the minimum interval, the zone, the pipeline's `target_not_found` / `target_not_released` / `payload_invalid` / `executor_unknown`); `ApiErrorCatalogUserMessageTest` holds the set to `model.js`'s `FIELD_BY_CODE`. A draft-only pipeline is named at PICK time — the release-first sentence at the pipeline field, cleared when a schedulable pipeline is picked; the suggestions stay complete (a list entry's DRAFT status also covers a released pipeline with a draft in flight). The name help says a leading slash is not part of a name. The form's browser suite asserts the catalogue's words and covers the draft-only flow (red on the base at each step, logged). |
| 2026-09-27 | v1.78 | 254 (#254, #255, #256) three small UI defects, the switcher and the rail honest | §3.4: the switcher's options are full-paint facts — every page-route mutation that changes the option list navigates in FULL; the three lifecycle verbs join the create form (#256): deactivate → the option absent without a reload, reactivate → present, delete → absent (`WorkspacesCreateBrowserTest` extends #170's no-reload assertion to each verb's before/after list; on the base the boosted swap left the rail every option rendered before the mutation). §4.7: the panel head yields before the rail scrolls (#255) — `.te-panel-head` wraps and its h2 carries `min-width: 0`, so at the splitter's 220px floor the `Key/Value` / `JSON` tabs drop below the `Render Context` heading instead of holding the rail open at `scrollWidth 228 > clientWidth 220`; the walk pins `scrollWidth == clientWidth` light and dark at 1100/1440/1920 and at the floor (`TemplateEditorContextRowBrowserTest`). |
| 2026-09-27 | v1.77 | 250 (#250, #259) R3 on the UI + the Usage tab's schedules | **§4.8 / §4.2**: the executions screen and the dashboard's recent executions and run figures read `findVisible` — a viewer or author sees their OWN runs plus every SCHEDULED run of the workspace (#250, rest-api §10.1's R3), a scheduled run's row stating its origin (`SCHEDULE`) in the via column. **§4.3b**: the pipelines explorer's Usage tab gains a third heading — the live schedules whose target names the pipeline (#259), name + link to `/schedules?id=<id>` + enabled/paused/blocked, read through the lensed `ScheduleService.listByTarget`; a schedule is not refusal evidence, so the tab badge does not count it and the empty state now names all three absences. `ExecutionHistoryBrowserTest` holds a viewer's list to scheduled-visible / other's-own-absent; `ExplorerDetailBrowserTest` reads the third heading off the tab. |
| 2026-09-26 | v1.76 | scheduler lane 3 (#9, slice 3) — numbered after origin/main's v1.75 (242b/230 may take it; renumber at merge, keep both) | **§4.20, the form**: a `DATE` parameter's field gains the **binding source** selector — Fixed value (default) / Today / Yesterday; a preset hides the fixed-value input by class and travels in `payload.parameter_bindings`, resolved per run on the schedule's frozen reference; the selection round-trips on edit; a literal binding shows its value and keeps its envelope while untouched. **§4.20, the run dialog**: beside the frozen parameters, **resolved parameters** (`prepared.resolved_parameters`) — what the run executed with. No new route, verb or role; the browser suites gain the bindings cases (`SchedulesBindingsBrowserTest`). |
| 2026-09-26 | v1.75 | scheduler lane 2 (#9, slice 2) — numbered after origin/main's v1.74 (243) | **§4.20 Schedules, new**: `GET /schedules` (`schedule.read`) — a shell whose every read and write is a call to REST §20 (the scheduler design revision §6: no partial route, reads included): the folder explorer (§20.1 by prefix, a level's folders derived from the names, search over every rendered column), the detail (blocked reason with Unblock beside it; overview; the next five from §20.9 with their offsets; parameters; runs with due / started / took and the execution link), a run's dialog with the MERGED Messages (the run's trail + the execution's durable events, one time order, labelled by source; an unknown run's recovery named), and the one form for create and edit (presets that write the pattern, the timezone, a live §20.3 preview, the current version's declared parameters, field-level refusals, idempotency per attempt, the stale-revision 409 in the form with a reload). §3 the rail's Operate group gains **Schedules** after Executions, for every role. §4.3e gains the page's verb rows (`canAuthor` = the five write rows) and the execution half's `canReadExecutions`. §5.1 Shape D names the page as the second client-originated toast case. |
| 2026-09-26 | v1.74 | 243 (#243, #170, #159) three small UI defects, measured and pinned | §4.7: a Render Context row fits its rail (#243) — the rows' inputs carry `min-width: 0` and the remove control never yields width, so the row's key, value and × end inside the rail at 1100/1440/1920 and at the splitter's 220px floor, static row and Add-Row clone alike (`TemplateEditorContextRowBrowserTest` measures the edges; on the base the row was 476px in a 320px rail). §4.13: the create form navigates in FULL — `hx-boost="false"` (#170) — because a boosted swap replaces only `#app-main` and the rail's switcher (filled on every full render) kept the pre-create options until a manual reload (`WorkspacesCreateBrowserTest` asserts the row lands in BOTH the table and the switcher). §3.4/§3.6: the 768–1099px band has a search entry point (#159) — a topbar icon button lit only in that band reveals the topbar copy in place (no third copy of the control) and ⌘K drives it, where previously neither copy was displayed and the chord did nothing (`MobileShellBrowserTest`'s band arm, red on the base; plus the phone-width drawer arm the issue named, and HeaderSearchBrowserTest's desktop arm pinning the button absent at ≥1100px). |
| 2026-09-25 | v1.73 | 233 landing (#233, #248) the chip's retirement recorded | §3.4: the top bar's inventory no longer lists the MCP-key chip; the chip bullet becomes its retirement record (keys v2 A15 — every key created on the Keys page, plaintext once at creation; `GET /partials/mcp-key/secret` survives for the V37-migrated sealed copies with no caller, #248). §4.10: `/settings/api-keys` points at the Keys page, not the top bar. The two chip browser tests, the shell's dead copy handler and the screenshot tool's chip wait left with it. |
| 2026-09-25 | v1.72 | 7e (#7) the semantic link | §4.3d **the Release dialog warns on a `needs_review` pin**: one row per pinned template version citing a retired learned fact ("cites a retired fact: … — superseded by …"), above the confirm, never blocking it — the same port read the REST release's `warnings` come from. §4.6: 7d's `needs_review` marker is now computed (the tree/search rows' own flag, the detail's working version, the editor's displayed version, the node card's pin read). No new verb, no new route. |
| 2026-09-25 | v1.71 | 7d (#7) the transform UI | **§4.7a (new): the transform face** — four textarea panes over a `jsonata`/`javascript` template (losslessly pretty-printed blocks), editable only on the working draft for an author; **Save draft** (`POST /partials/templates/transform-face/save`, `template.update` — 7b's deserializer, validator and draft service under the draft's hash; refusals name their pane) and **Run suite** (`…/run-suite`, `template.evaluate` — the panes as typed, owner ruling 2026-09-25: the gate's verdict plus a per-case list with the first difference's path, both sides and every invariant); `GET /partials/templates/transform-face` for the version select. §4.3e gains the face's two verbs. **§4.6:** the four types in the filter and the create modal, the modal's Contract/Invariants/Tests field prefilled with the record's example, the transform detail's Mode and Inputs, the `needs_review` marker behind 7e's flag. **§4.4:** the TRANSFORM node card (its accent and glyph, the language resolved from the pin, inputs → output, rejects + strict) and its Details rows. The `readOnly` rule's home is `TemplateSourceModel` (moved from `fillSource`, unchanged). |
| 2026-09-24 | v1.70 | 215c (#215) after the key model | **§4.19 describes the Keys page and dialog as slice (b) built them**: the role column is fixed by the kind (`api caller` / `promotion receiver`), with nothing left for a slice to offer, since each kind carries exactly one role (record §3.1, A1); the Acts-as cell renders "<key name> (API key)"; the New API key dialog is one card per kind, each naming its fixed role and its create permission (`api_key.create` for workspace admins and super admins, `server_key.create` for super admins only), with no role field and no admin role. The record's §6(c) is marked delivered. No screen, route or verb changed. |
| 2026-09-24 | v1.69 | 215b (#215) key identities | **§4.19: the Keys table gains Role and Acts as** — the key's role (`api caller` / `promotion receiver`, fixed by the kind until slice (c)) and the key's own identity, which its runs and received versions are attributed to; Created by is `api_keys.created_by`. **§4.11: the API card's Scopes row is a Role row** (the session's role in the active workspace). §4.12: the users list shows people only (`kind = 'human'`). §3.4's role badge no longer narrows for a key (no key renders a screen, B2); §4.18's refusal note and §3.3's column table follow. |
| 2026-09-24 | v1.68 | 215a (#215) the permission catalog | **§4.3e's last column is the §7.6 catalog permission** a verb answers to (`pipeline.release`, `datasource.test`, `workspace.members.manage`, …) instead of the retired operation, and every other section names permissions the same way; the booleans and every template are unchanged. §4.19: **a `server` key's Delete renders for a super admin only** (owner ruling 2026-09-24, `server_key.revoke`) — a workspace admin sees the row without the verb. §4.5's "Rendered for" paragraph corrected: Test renders by `canExecute` (`datasource.test` follows execute since 2026-09-20 — the template already did; the prose still said `canAdminWorkspace`). |
| 2026-09-22 | v1.67 | #208 own row + super admin row | §4.13: no verbs on the caller's own member row (`409 workspace.self_membership` behind them); a super admin member reads "super admin" with no role dropdown. |
| 2026-09-21 | v1.66 | 200 (#200) membership-bound keys | §4.13 members row: the member's key state ("has a key" / "no key" — never an id or prefix) and a **Revoke key** verb, drawn only when a live key exists, inside the same `canAdminWorkspace` guard; posts `POST /workspaces/{name}/members/{userId}/key/revoke` through the ONE `WorkspaceService.revokeMemberKey` the REST twin calls, and toasts `member_key_revoked` (the member stays; the next sign-in mints fresh). §4.3e's members row lists the verb. |
| 2026-09-23 | v1.66 | 213 (#213) show-once MCP key | **§3.4: the MCP-key chip is copyable ONCE** — the first Copy's fetch destroys the server-held copy in the same statement that serves it, the chip re-renders without a reload (`GET /partials/mcp-key/chip`, swapped in by the copy handler) with no Copy button and a "Copied already — delete it and sign in again to get a new key you can copy" title (the pre-V31 wording is gone; V32 cleared every such key), and a reload changes nothing. The chip's three states (copyable / copied / none) are spelled out; §4.10's pointer says "copy it once". `McpKeyShowOnceBrowserTest` pins click → clipboard value → no-Copy → rotate; `RoleVisibilityRenderTest` pins the states. |
| 2026-09-21 | v1.65 | 191 (#187) — OIDC identity | Login page gains `?error=identity_mismatch` (the email is already linked to a different sign-in identity; nothing changed, ask an administrator). §4.12 gains the **Reset identity** verb — its own literal route and `USER_IDENTITY_RESET` row, rendering the shared row-swap + toast. |
| 2026-09-21 | v1.64 | 188 (#188) security headers + CSP | Doc-only for the screens (no visible behaviour changes). §3.4: the rail's first-paint script is `/js/rail.js`, not an inline block — the enforced CSP (`script-src 'self'; style-src 'self'`, no `'unsafe-inline'`) forbids every inline script, `on*=` handler and `style=` attribute in a template; the five page scripts and twenty-three handlers moved into `/js` (`api-keys.js`, `datasources.js`, `template-create-modal.js`, `password-card.js`, `rail.js`; the rest into `shell.js` and `template-editor/lifecycle.js` as `data-action` controls), the executions rows navigate by `data-href`, and the type-coloured editor elements carry `data-type` instead of a style attribute. The pipeline editor's route alone allows `'unsafe-eval'` for Alpine (#195). Guards: `InlineScriptAuditTest`, the browser suite's zero-violation rule, `SecurityHeadersTest`. |
| 2026-09-21 | v1.63 | 180 (#180) roles R4 — deactivation | Doc-only. §4.11: `?error=inactive` is also where a session deactivated mid-life lands (cookie cleared by the filter, redirect by the entry point). §4.12: the deactivation window sentence names the knob and the new code `auth.principal_deactivated`; nothing is revoked, Activate restores. |
| 2026-09-21 | v1.62 | 185 (#185) script-block escaping | §4.4: the editor's two JSON blobs are written through `ScriptSafeJson.forScriptBlock`, so a closing-tag sequence inside any free-text field the pipeline carries cannot close the `<script type="application/json">` block the blob is inserted into (stored XSS, #185). One shared escaper with the docs' and FAQ's JSON-LD writers; guarded by `PipelineEditorJsonRenderTest` (jsoup) and `ScriptBlockUtextAuditTest`'s closed `th:utext` allowlist. |
| 2026-09-21 | v1.61 | 179 (#179) roles R3 — keys | **§4.3e / §3.4: the top bar gains the MCP-key chip** (D16, ruling 7) — prefix, Copy (fetches the secret from `GET /partials/mcp-key/secret`; never in the page) and delete-to-rotate for every role on their own key. **§4.18 read-only again**: the keys card and its modal left the console for **§4.19 `/api-keys`** (new; `MANAGE_API_KEYS` — ws_admin + super admin), which carries create (Kind → Name → Expiry → Associations), delete and per-key association; the console's endpoints table gains the associated-keys-by-name column with an admins-only Manage link, and keeps the MCP card. **§4.10 repointed**: the settings screen points at the top bar (your MCP key) and §4.19 (the workspace's). The avatar menu's "API keys" link leads to `/api-keys` for the roles that hold `MANAGE_API_KEYS` and is absent otherwise. `ApiKeysPageRenderTest` pins the page; `ApiConsoleRenderTest` pins the console's new shape. |
| 2026-09-21 | v1.60 | 178 (#178) roles R2 | **The promoter lens on every screen** ([Auth §11A.1](/docs/auth.md#11a1-the-404-rule)): the pipeline and template explorers, detail panes, the editors' read-only view, the header search, the dashboard's Pipelines tile and the rail badges show a promoter the released-and-newer set and nothing else (§4.2, §4.3e's new row, §4.7); a hidden object's URL is the not-found page. New fragment `partials/lens-unavailable` — the promotion page's "Could not read the target" sentence, rendered by the list fragments (`pipeline-tree-level`, `pipeline-search`, `template-tree-level`, `template-search`, `search`) in place of their empty state when a lensed principal's list is empty because the target could not be read (fail closed). §4.17: the plan is the lens's own computation, cached per workspace. |
| 2026-09-20 | v1.59 | 177 (#177) roles R1 | §4.13 the workspaces page is a **workspace admin's** (D13: `WORKSPACES_READ` → `ws_admin`; the rail draws the item for admins and for a principal with no workspace; the switcher stays every member's under its own `WORKSPACE_SWITCH` row); the members table is **one role dropdown per row** (D22) with a Save through the one htmx partial (`POST /partials/workspaces/{name}/members/{userId}/role`, row swap + toast), the add form takes the same dropdown, the checkbox script is gone, the last-admin toast names the workspace admin role. §4.12's create form takes the dropdown too. §4.3e rewritten to the ratified rows: Release and Switch are `canAuthor` (D8), datasource **Test** is `canExecute` (follows execute — ratified), the promotion page is `canReadPromotion` (rule 13), the Executions rail item and the dashboard's runs are `canReadExecutions` (D11 — the promoter has neither), the lifecycle and datasource dialogs guard their verbs in the markup (the route-guarded exemption list is empty). §3.4 the badge prints the one role (`workspace admin`, not `admin`). §4.4/§4.7 the promoter's editors are read-only with no Release. Vocabulary: role and permission; the word "flags" leaves this document (D21). |
| 2026-09-19 | v1.58 | 161 (#161) the login page renders the one brand mark | §3.4 brand mark: `login.html`'s card brand and `layouts/auth.html`'s brand link had kept the retired filled-tile SVG inline (163's sweep rendered the rail, the top bar and the site only); both now include `partials/brand-mark.html` exactly as `layouts/default.html` does — no CSS change, the tile and mark are sized by class. `BrandMarkParityRenderTest` renders the login page and the forced-password gate as two more surfaces (each divergence named) and sweeps every template source for the retired rect. |
| 2026-09-19 | v1.57 | 172 (#172) | The `/api-console` route rationale reworded for the re-rooted published-endpoint tree (R-EP5); no screen, route or verb changed. |
| 2026-09-17 | v1.55 | 159 / #148 Start runs on a human press; Cancel from the marker | §4.4: the Start disc now starts a run from a HAND's click — the live "Start does nothing" was Cytoscape's mousedown re-rendering the disc under a held button (no click ever dispatched); the press is stopped before Cytoscape. While the run is active the disc is the **Cancel** control (`Cancel execution`, the word Cancel, square glyph, danger fill, never `aria-disabled`, the toolbar's own `cancelExecution()`), Start again on any terminal state; a viewer who may not execute keeps the plain marker; focus survives the disc's re-render. ([Pipeline Editor §5.3b](/docs/pipeline-editor.md); `PipelineEditorStartMarkerBrowserTest` presses the disc the way a hand does.) |
| 2026-09-17 | v1.54 | 151 addendum / #144 boundary redesign | §4.4: Start and End are **shapes** (a disc and a rounded square, the word under them), not pills; **Start runs the pipeline** for a viewer who may execute (`role="button"`, Enter/Space, the toolbar's own `canExecute` stamped as `data-can-execute`, the same `executePipeline()`), `aria-disabled` + pulse while Running…; End's fill is the outcome, with the run clock's elapsed; `#cy-canvas` is `role="group"`. Corrects v1.51's "never executable" reading: the markers are still not executable NODES, but Start is a run TRIGGER. In a browser session viewers may execute (D-R3), so they get the button too. |
| 2026-09-17 | v1.53 | 151 / #127 arrows vs. writes | §4.4: a `depends_on` edge is an ORDERING (`dependency` kind; `active` still / `satisfied` / `unmet`), never a transfer — no copied row count on any edge, no motion along one (the rAF flow retired); the write is the producer's **output port** row (destination, kind word, measured state from the 149 reducer: waiting amber and still, writing the only flow, committed/not observed/rolled back honest words, CTAS `one statement`); stream loss freezes without inventing; the recovery poll settles End and closes open operations unobserved; legend `Depends on` / `Output write`; Details `Depends on` / `Required by`; arrow tap announces its meaning. |
| 2026-09-17 | v1.52 | 155 / #112 empty rail reclaims its width | §4.7: a template-editor rail with NOTHING to show (a reader — viewer or pure promoter — on a template with no imports: the Render Context panel is author-only, the Imports table follows the template) is not rendered, its splitter handle goes with it, and `.te-body` collapses to one column so the read-only source takes the width. Author context, the Imports read, remembered `--te-side-w` and the narrow-viewport clamp are untouched (TemplateEditorRailBrowserTest pins all four). |
| 2026-09-17 | v1.51 | 150 / #126 execution boundaries + #135 abort pulse | §4.4: the canvas derives view-only **Start** and **End** markers — Start to every root, every leaf to End — dashed-border pills whose End word is the authoritative execution outcome (Finished/Failed/Stopped), never a node event; boundary connectors are a distinct element kind for #127, never row-transfer labelled; eligible roots show no flow until `node_started`. A cancelled-or-never-started node ends Aborted beside them (#135: the terminal `node_progress` sample or the `pipeline_failed` sweep stops the running pulse; minimap and a11y agree). |
| 2026-09-17 | v1.50 | 149 correction / #125 review | §4.9: the Node operations table's commit column says `not observed` for a terminal operation without commit evidence, keeps `committed` on a failed node whose commit was confirmed. |
| 2026-09-16 | v1.49 | 149 / #125 measured node operations | §4.4: the editor card footer shows the measured operation while a node runs, the Details pane its Operation/Progress/Rows/Time in/Commit rows, the a11y list the same as a description. §4.9: the execution detail page gains the **Node operations** table derived from the durable `node_progress` record — destination, last state, counts, commit badge, time share; never a commit that was not observed. |
| 2026-09-15 | v1.48 | 143 viewer editor + navigation (T315) | **Readers open templates read-only; the rail's Admin follows authority.** §4.7/§4.3e: `GET /templates/editor` floors at `read` (the explorer's Open links led every reader to a 403); `readOnly` = author capability ∧ version rule on the page AND the source partial, so a viewer or a pure promoter sees every version — the working one included — in the read-only pane with the version select and Imports kept; the editable textarea, Preview and the context rail are `canAuthor`; every version-row Open carries `version=`. §3.4: the Admin item renders only where it leads — `/admin/users` for a super admin (judged without the workspace, key-narrowed), `/workspaces#workspace-members` (a full navigation) for a workspace admin — and is absent for viewers, authors and pure promoters; `UiWorkspaceAdvice` stamps `navAdminUsers`/`navAdminMembers` from `RoleModel.shell` on every render. §4.13: the members section is `#workspace-members`. §4.17: a reader's plan is a plain table, no form/Send/checkboxes. `AdminUsersController` and the matrix are untouched. **Found on the way (JarSmokeE2eTest):** a super admin's `read` key rendered as an author — the auth predicates short-circuit on `superAdmin` before their key conjunct — which the editor's old floor had hidden; `RoleModel` now states the scope conjunct on `canAuthor`/`canPromote` too, so the key gets the reader's page (the predicate itself is unchanged; the interceptor already refused the writes). Evidence: `ViewerEditorRenderTest`, `AdminNavRenderTest`, `ViewerAccessBrowserTest`, `ViewerAccessE2eTest`, `RoleModelTest`. |
| 2026-09-15 | v1.47 | 142 release cascade UI | §4.3d/§4.4 **the Release dialog cascades.** A DRAFT template pin is no longer a dead end: the dialog (both explorers' and both editors' one partial) renders a consent group — "Also release these N draft templates with the pipeline", checkbox `releasePinnedTemplates` checked by default, one row per pinned version with "also pinned by K other draft pipelines" from the used-by service — and Release is enabled; unchecking withholds Release (`lifecycle-dialog.js`'s consent arm, the third of the typed-confirm / min-chars family, folded into ONE evaluator with the 140 min-chars gate so both can withhold the same button) and restores the pre-142 refused wording by CSS `:has()`. The POST posts the flag; the toast lists the templates released with the pipeline; the editor leg's flash is `released_with_templates`. DISCARDED / MISSING pins keep the refused branch with no button. `ReleaseCascadeBrowserTest` covers the checked group, the shared line, both toggles and the audited landing. |
| 2026-09-14 | v1.46 | 140 release checks UI | §4.3b/§4.3d **the release checks are on screen.** New `partials/pipeline-checks` (definitions + latest runs, read-only GET; the same fragment re-rendered from the FRESH outcomes of `POST …/checks/run`, `via = ui`, session-only on both) behind `PipelineChecksPartialsController`. The explorer detail's Overview gains a Checks sub-section when the working body declares checks (lazy `hx-get` + a Run checks button at the execute floor); the editor's Details pane shows the count read off the loaded body and lazy-loads the same partial. The Release dialog on a checked draft runs the checks as it opens: the submit starts disabled and the run's fragment decides the footer OOB — all pass enables Release; anything short of PASS withholds it and offers the Override disclosure (required ≥ 10-char reason armed by `lifecycle-dialog.js`'s min-chars pair, which re-arms on swaps inside an open dialog; the overridden ids named, the reason audited on `pipeline.version.released`). |
| 2026-09-14 | v1.45 | 137 mail notices | §4.12 admin users: with mail configured the create/reset notice shows "Emailed to \<address\>" with the send's outcome (sending → polled `/partials/admin/users/{id}/mail/{kind}?act=` → sent / failed with the error) and never the one-time password; mail off is unchanged. |
| 2026-09-13 | v1.44 | 127 beta feedback setup | §3.4 **the avatar menu gains "Report a problem"** — the GitHub bug form, `target="_blank" rel="noopener"`, the fourth `menuitem` between API keys and Log out, its URL read off the new `SitePages.REPORT_PROBLEM_URL` constant through `SiteOriginAdvice` (no template types it); the v1.42 close-on-choice rule covers it, and `AppShellBrowserTest` pins the item count, the arrow-key order, the new tab and the app tab not navigating. §4.16's signed-in docs index header carries the same link from the same constant. The lucide sprite gains the `bug` glyph (manifest subset + sha recorded). The public side is §4.15-adjacent: the beta line renders from `SitePages.RELEASE_STAGE` on the hero and in the site footer, `/roadmap` gains the known-limitations section (docs/ROADMAP.md §2.2 read the other way), and `/faq` gains the "Support and feedback" group the footer links as `#support-and-feedback`. |
| 2026-09-13 | v1.43 | owner testing round, day 3 — the scrollbar | §5.1 **the skeleton never lands on body, and never in the history snapshot.** The owner's `/dashboard` document scrollbar (R11): `document.body`'s last child was a `.app-target-skeleton` row, static, 64 px past the viewport. Root cause: a boosted navigation's htmx target is `<body>` at `beforeRequest` (shell.js retargets at `beforeSwap`), so a navigation slower than the 150 ms arm appended the row to body — outside the `100dvh` shell; htmx snapshotted the page for history with the row in it (the snapshot is taken during the swap, before `afterRequest` removes the live one), and Back restored it as markup no tracker owned. `AppShellBrowserTest`'s guard could not see it: it measures plain loads, never a boosted-then-Back path. Fix in shell.js: `swapTargetFor` resolves a boosted request to `#app-main` at BOTH ends; `htmx:beforeHistorySave` strips live skeletons and busy marks; `htmx:historyRestore` and `pageshow` purge orphans (heals caches poisoned before the fix). Pinned: four `shell.test.mjs` cases through the real listeners; `ShellBusyBrowserTest` holds a boosted GET past the arm, asserts the row is inside main, settles, goes Back, and asserts no skeleton, no body busy mark and a zero-overflow document. |
| 2026-09-13 | v1.42 | owner testing round, day 3 | §3.4 **the avatar menu closes on a choice.** It closed only on Escape and an outside click, so choosing a mode left it hanging over the page and Settings / API keys — boosted swaps of `#app-main` alone — carried it open onto the next screen (the settle path closes only the phone drawer). shell.js: any `[role="menuitem"]`, `[role="menuitemradio"]` or `.app-swatch` inside `#app-user-menu` closes the menu and returns focus to the avatar (`MENU_SELECTION`, unit-tested through the real body listener; `AppShellBrowserTest` chooses a mode and Settings and waits for `[hidden]`). The document-scroll guard now also walks both editors and a 1636×1850 window (owner report of a document scrollbar, 2026-09-13, not reproduced in the harness — see the ledger). |
| 2026-09-13 | v1.41 | 124 site facts derived | **§4.15 — the site's numbers are derived, not typed** (owner ruling 2026-09-13: eight engines, not six — H2 is a legitimate engine, LAKE is dp-lake, tempdb implicit and never counted). New `SiteFacts` (engine count/list from `Dialect.entries`, tool counts from `McpToolCatalog`, calculator kinds, API-key kinds, learned-fact kinds/scopes, demo engine counts from the manifests) exposed to every template by `PublicPage.render`; `SitePages`/`SiteFaqs` titles, descriptions and answers are string templates over it, one `numberWord()` helper for prose. H2 and dp-lake gain `/mcp-server/{engine}` pages; `SiteEngineFactsGuardTest` tightened from containment to EQUALITY with `Dialect.entries`; new `SiteHandTypedCountsGuardTest` fails the build on a hand-typed count in any site template or `ui/site/` Kotlin file (red at base on 40 hits; green only when every count renders from `${facts…}`). |
| 2026-09-13 | v1.40 | 122 viewer executes (owner testing round, day 2) | **The route caught up with the record.** D-R3 and §4.3e already gave the viewer a read-only pipeline editor with Execute; the route refused them at the door — `GET /pipelines/{id}/editor` was floored at `MUTATE_PIPELINES_TEMPLATES`, so the explorer's Open link (the only door to Execute) answered 403 `auth.scope.insufficient`. The floor is now `EXECUTE_PIPELINE` — the operation the screen exists to perform for its lowest role — so an `execute` key and a viewer session reach the page while a `read` key stays out; the template editor keeps the author floor (§4.7 note corrected to match). §4.3e states the Open route and the 114 read-only line; RoleVisibilityBrowserTest walks a viewer explorer → Open → Execute (zero ≥400 responses, terminal state reached) and JarSmokeE2eTest pins the key boundary (`execute` 200 / `read` 403) in one test. |
| 2026-09-12 | v1.39 | owner testing round, day 2 | Two defects the owner saw everywhere a tree or a version list shows. §3.4 icons: **a leaf row keeps the chevron slot** (`template-tree.css .tpl-leaf` pads it; the guide's tick runs on to the file glyph), so a leaf's glyph and label sit level with a sibling folder's and one indent right of its parent's — before, the glyph sat IN the chevron slot and every leaf's label landed at its parent's x. §4.3b: **the ⋯ menu is a top-layer popover** (`popover="manual"` on `.tplx-vmenu-list`, placed by `lifecycle-dialog.js`'s pure `menuPlacement` — under the ⋯, above it when the viewport has no room below, clamped inside it) — it was `position: absolute` inside `.tplx-tabpanel`, which scrolls, so on a one-row list the open menu fell into the panel's scrollable overflow and never showed. `TreeIndentAndVersionMenuBrowserTest` pins both in both explorers (label offsets to the px; the menu's centre hit-tests to the menu). |
| 2026-09-11 | v1.38 | 119 open-source signals | **§4.15 — the site reads as free and open source on the first screen** (owner: the fold did not answer "is this something I buy"). Hero CTA is the install itself — a copyable `./app.sh --start` via the standard `.code-block`/`.copy-btn` pair (zero new clipboard JS), labelled *Run it on your machine — free, one command, no account*; the strip gains the **Price · $0 · AGPL-3.0 · self-hosted · no phone-home** cell (cites deployment.md §10, §4.3, §11); nav gains **Pricing** plus the ★GitHub star badge and the licence chip, and its last item is host-aware (**Try the live demo** on the public origin, **Sign in** elsewhere) via `SiteOriginAdvice`; `/pricing` page with `SiteFaqs.PRICING` and the dated no-paid-tier promise (freshness guard extended to it); `CONTACT_EMAIL` renders from one constant in the footer, /pricing and a new /security report-a-vulnerability section; "free" now in the home FAQ cost answer and the open-source section; `SiteOpenSourceSignalsTest` (red at birth: the fold carried neither "free" nor the licence on the first screen). |
| 2026-09-11 | v1.37 | 119 site batch 4 | **§4.15 — one vertical rhythm for every public page (measured, not felt).** New `--site-gap-head` token (20px phone / 24px desktop) applied by ONE selector per structure (`.section .container > h2 + *`), ledes keeping 16px and passing the gap on; card padding/gap 24 from 64rem; the card's internal rhythm set once on `.card`; `align-items: start` in feature groups + the 90-word card body budget (`SiteCardBudgetTest`, red at birth on 11 cards, surplus moved to cited-docs links); `.group-title` takes the kicker treatment; `.hero-shot` clamped to `--site-hero-max` so the proof strip starts in the fold (phone order: copy → numbers → picture); the home dateline folded into its pill; `.shot img` framed while the pre-re-shoot light captures ship; long machine tokens in prose break instead of widening the page. All pinned by `SiteRhythmBrowserTest` (real viewports, 1440/390). |
| 2026-09-11 | v1.34 | 116 demo-data page | **§4.15 — the `/demo-data` public page**: the three published sample-data families documented table by table, with the rule that the family tables are GENERATED from the manifests vendored at `resources/site/demo/` and pinned to the deploy versions (`SiteDemoDataGuardsTest`: pin agreement, row completeness, derived-and-bound row counts, non-vacuity). `SiteDemoData` parses the three manifests once at startup, fail-fast; a family with no licence stamp renders "not yet verified" and claims no licence. FAQ group `SiteFaqs.DEMO_DATA` (5) joins `ALL`; linked from the footer, the home `#demo` lede, `/how-it-works`'s where-to-next and `/dp-lake`; `PublicPaths` + §8.3 row (37); lighthouse script gains the page. |
| 2026-09-10 | v1.33 | 115 site batch 3 | **§4.15 — the home page speaks to the buyer; the engineering moved to `/how-it-works`.** The homepage sells the outcome in a fixed fold (hero → before/after → the artifact) held to buyer vocabulary by the new `SiteBuyerLanguageTest` (no pipeline/DAG/federated/Iceberg/DuckDB/Parquet/MCP/JDBC/staging from `<main` to the end of `#artifact`; `/how-it-works` must carry ≥6 of those words — the mechanism moved, nothing hidden), with the H1 retargeted to "no data team required" (the pillar keeps the SQL-MCP-server keyword). New sections: verified-by-you, what-you-get (today/next-month badges), who-it's-for, a halved demo; the home FAQ became `SiteFaqs.HOME`'s eight buyer questions, which open `/faq`; the home page's next-month promise carries the roadmap pages' dated `status` markup, so the freshness guard covers it. The engine strip, the agent loop, "What's in the box" and the security teaser moved verbatim to the new `/how-it-works` engineering page (`SiteFaqs.APIS_AND_OPERATIONS`; nav's first item; full nine-edit mechanics incl. the `PublicPaths`/§8.3 row). `WebsiteFactsGuardTest`'s rendered tool-count leg and demo-name sweep followed the moved sections. |
| 2026-09-11 | v1.36 | 118 learned semantic layer | New **§4.5b** (datasource learned facts): a read-only Facts dialog on every datasources-list row (`data-read`, every member) and the same table inline on the LAKE detail — trust badges, drift message, conflict and provenance badges, the D-S9 pipeline link. §4.5's row gains the Facts button. |
| 2026-09-11 | v1.35 | owner testing round, day 1 | §3.4: **the shell is the viewport** — `<main>` scrolls, the document never does (`AppShellBrowserTest`); default theme `dark`. Fixes from the walk: an anchor styled as a button kept the base `a:hover` link colour and vanished on the accent ground (login's "Continue with Google") → `a.ds-button*` hover rules in app.css, pinned on the computed style after a real hover; version rows (§4.3/4.6) are one line on a pane ≥ 560px (container query) and two lines below it; the editor's result table (§4.4) carries its frame on the scroll viewport (right edge and corners always visible) and its header row is sticky while the body scrolls — the same frame rule for `.u-scroll-x > .ds-table` (execution result partial). |
| 2026-09-10 | v1.34 | 114 RBAC screens | §4.3e (role visibility — the whole verb inventory and the flag that renders each), §3.4 (the role badge beside the switcher), §4.5a (datasource grants), §4.13 rewritten (members with three checkboxes, deactivation, the active-workspace rule), §6 (the no-workspace page), and a "Rendered for" note on §4.3/§4.4/§4.5/§4.6/§4.7/§4.10/§4.17. |
| 2026-09-09 | v1.31 | 102 lifecycle dialogs | **§4.3d (new) — every lifecycle verb 101 shipped is reachable from a confirm dialog**, one partial per verb, in both explorers (`#px-dialog` / `#tx-dialog`) and both editors (`#pe-dialog` / `#te-dialog`); the template twins are addressed by name in the query. The 094 datasource-delete shape throughout: refusal branches render NO button, the POST re-runs the guard, refusals are §5.1 Shape C with the real 4xx, success is Shape A (re-rendered detail + toast + `HX-Trigger: lifecycle-changed` for the tree badge) — entity purges and editor verbs answer `HX-Redirect` with a flash toast. **Typed confirm (§5.1)** on the two irreversible verbs — type `v4`, type the entity's NAME — enforced client-side for the button and server-side as `400 *.confirm_mismatch` before the service runs; second use after the CLI's `--clean`. §4.3b's header carries at most ONE destructive verb (owner rule) and the version rows move their verbs into a per-row ⋯ menu where a §3.5-refused verb is absent, not disabled. §4.4/§4.7: the editors' native `window.confirm`/`window.alert` sites are gone (a static test pins zero), including the pre-101 discard text that had become false. Two new codes: `pipeline.version.confirm_mismatch` / `template.version.confirm_mismatch` (§13.13/§13.9, landed with constants and catalog rows in the same commit). |
| 2026-09-09 | v1.30 | 097 the hybrid boundary made uniform | **§2.1 gains the FOURTH shape it never described** — a "Page-route mutation (PRG)" row plus its response contract (a `redirect:` back to the page, `?ok=`/`?error=` KEYS the layout maps to copy, and the §5.1 toast the `#toast-flash` bin turns them into — 076 §B had already retired the banners the round brief expected to find) and the two reasons a mutation may live there: its success changes the SHELL, or it must work without JS. The eight handlers are enumerated and justified one by one in `MutatingHandlerScopeFloorTest.PAGE_ROUTE_MUTATIONS`, whose new arm fails on any mutating handler outside `/partials` and `/api` that is not on that list — so the scheduler cannot invent a third idiom. **§5 gains the BrowseModel rule** (one `<Entity>BrowseModel` + a page controller + a partial controller; neither filters, pages or projects on its own), drift-tested by `BrowseModelConventionTest` over five pairs, and states the first-paint rule with the dashboard as its ONE exception. The rule is written because three screens had already paid for its absence: datasources' two hand-written filters had diverged (`GET /datasources?q=postgres` returned nothing while the typed search returned rows), executions painted a spinner and fetched its rows on a load trigger while ignoring the filters in its own URL, and admin users built its `<tr>`s as strings in Kotlin. All three now project through a model; the admin table and four other Kotlin-built markup sites became Thymeleaf fragments, and `InlineWidthAuditTest` widened from templates to every module's `main/kotlin` with an empty allowlist. **§4.7** — the template editor's ~135-line inline script left the template for `static/js/template-editor/lifecycle.js`: one CSRF reader (`static/js/csrf.js`), `htmx.ajax` for the `/partials` render (§2.1 as written), an in-page confirmation and toasts in place of `window.confirm`/`alert`, and nodes in place of `innerHTML`. |
| 2026-09-08 | v1.26 | 099 draft-first (D55/D56) | **§4.3** — a new pipeline appears as `v1 draft`: the leaf and search rows name the WORKING version (the draft's number when one exists), because `p.currentVersion` alone rendered `vnull` once creation stopped releasing. The Release action stays the editor's and renders for a v1 draft exactly as for any later one. **§4.8/§4.9** — the executions list and detail label a **DRAFT** run beside the version (`data-draft-run`), from versioning §8's derivation; a v1 run is routinely a draft run now, so the number alone no longer says whether a result came from reviewed content. |
| 2026-09-08 | v1.29 | 091 keys — one page for three kinds | **§4.10 reduced to a LINK; §4.18 card 2 became the key management screen.** One table, one form, one row model: kind, prefix, reach (scopes \| bindings \| the promotion route family), created, expires and last used — each relative in the cell and absolute UTC on hover — plus revoke. Revoked AND expired keys keep their row and lose the affordance, because §7.3 refuses both. The form's order is the owner's ruling — **Kind → Scope → Name → Expiry → Bindings** — with scope and bindings conditional on the kind and their inputs DISABLED rather than merely hidden (a disabled input is not submitted, and a scope on a scopeless kind is refused, not dropped). Kind renders as radio cards with a sentence each; `user` is labelled "Agent / API key" (one kind, two surfaces) and `server` (091's third kind) is admin-only. Scope lists the CAPABILITIES with their meanings, never HTTP verbs. Expiry is a select resolved server-side from the same table the page renders from, with `400 auth.api_key.expiry_invalid` for anything unusable — never a silent fallback to "never". The binding picker offers only LITERAL published prefixes, because the authorizer walks the concrete request path's ancestors and a `{variable}` node would authorise nothing. The Kotlin row builder and its parity test are deleted: the page, the post-create OOB refresh and the post-revoke rows now render one fragment. |
| 2026-09-07 | v1.28 | UI round 3 — the boosted first frame, the fonts, and a login page that ignores your session (090) | **§3.0 (new, normative): no page template carries its own `<link rel="stylesheet">`.** The explorers' sheet lived inside the `content` fragment — inside `#app-main`, the boosted-swap target — so htmx replaced the region and the browser only then discovered the link; an inserted stylesheet does not block an already-painted document. Measured at the first animation frame after `htmx:afterSwap`: at 1920 the tree painted 1640px wide with the detail pane's left edge EQUAL to its own (the panes stacked), 2280px at 2560 expanded, 2452px collapsed — settling to 480px and a 24px gap a frame later. That unstyled frame is BOTH of the round's reports: "the detail section is very close with the side menu" and "css is applied after data load". `template-tree.css` moved to the layout head. **The reported cause did not reproduce:** the pane's 260px floor was never beaten — six settled measurements (1440/1920/2560 × rail expanded/collapsed) gave 432 or 480px with `min-width` computing 260px, no persisted width existed, and no flex override was in the cascade. **CLS was the wrong instrument** and is recorded as such: `PerformanceObserver('layout-shift')` scored the broken navigation 0.0000, because the panes were inserted wrong rather than moved — `ExplorerPaneGeometryBrowserTest` asserts first-frame geometry at 3 widths × 2 rail states × 2 explorers, and keeps the < 0.05 budget for the font rule. **§3.3 amended: `font-display: optional`** replaces `swap`. With every font response held 1.2s, the `/dashboard` heading went 243.5 → 251.7px (+8.2px) and `/templates` 611.7 → 618.2px on arrival, at CLS 0 and 0.0075 — a horizontal reflow inside a block that does not move, which is text changing after it has been read. `optional` makes post-paint reflow structurally impossible; re-measured, first-frame and settled widths now agree exactly. `fallback` is the recorded alternative, with the size-adjust ratios already measured (Inter/system-ui 102.59%, JetBrains Mono/ui-monospace 109.48%). **§2 principle 4 rewritten, §4.1 and §4.14 amended: two layouts.** `login.html` decorated with `layouts/default`, which renders the shell whenever `authenticated` is true, so a signed-in visitor who opened `/login` in a second tab got the sign-in form inside a working app (owner's walk). New `layouts/auth.html` — brand, card, theme, htmx and the toast stack, no rail, no top bar, no `#app-main`, no `hx-boost`; `GET /login` answers `302 /dashboard` for a live session (on the PRINCIPAL, not on the cookie, so an expired session still gets the form); `OidcSignedInBounceFilter` closes `/oauth2/authorization/*` ahead of Spring Security's redirect filter. The forced-change gate became its own view (`settings/password-forced`) over one shared card partial, because a conditional decorator via `__${...}__` preprocessing is resolved at PARSE time and cached — the first request through would freeze the layout for all of them. **§B, second half:** `[x-cloak]{display:none}` moved from the page-scoped `pipeline-editor.css` to `app.css`, so the attribute is not inert everywhere else, and the editor's Alpine ROOT is cloaked (its six panes already were). New guards: `ExplorerPaneGeometryBrowserTest`, `AuthLayoutRenderTest`, `AlpineCloakAuditTest` (with two non-vacuity floors), `OidcSignedInBounceFilterTest`, four `LoginGoldenPathBrowserTest` cases. Still page-scoped and carrying §3.0's defect, named rather than left to be rediscovered: `pipeline-editor.css`, `template-editor.css`, `docs.css`. |
| 2026-09-07 | v1.27 | shell polish — a signal for every server trip (085 §D) | §5.1's Loading state rewritten: the 2px `#app-progress` bar was boosted-navigation only; the owner asked for "some kind of an indicator" on every trip, so the bar now shows for EVERY htmx request as an in-flight COUNT between `htmx:beforeRequest` and `htmx:afterRequest` (afterRequest is the one terminal event htmx 2.0.10 fires on success, error status, network error, abort AND timeout — afterSettle never fires for an aborted request, which is why the old settle/error listener set could have stranded the bar on). The originating control goes busy under the shell's OWN `.app-busy` marker — htmx 2.0.10's `.htmx-request` lands on the `hx-indicator` target instead when the element carries `hx-indicator` (the tree leaves do), so it cannot mark the control itself. A busy `<button>` gets `pointer-events: none` plus an absolutely-positioned `::after` spinner ring (the `.ds-spinner` idiom, no layout shift; a tree row's trailing badges go `visibility:hidden` for the flight to make room), and `shell.js` sets `aria-disabled="true"` — never the `disabled` property, which would drop focus mid-flight. Folder summaries are the exception: they stay operable mid-fetch (the §C hammer pins collapse/re-expand while a level loads) and their signal is the chevron spinning in the row's own icon slot. A request still in flight after 150ms marks its swap target `aria-busy="true"` and appends ONE `.ds-skeleton` row (`.app-target-skeleton`), per-target paired, removed by the terminal event before the swap — slow swaps show a skeleton, fast swaps never flash one. Reduced motion keeps every state and drops every motion (dashed-static ring, static accent chevron, the vendored skeleton's own static surface). §3.2's boosted-bar passage now points at §5.1. Pinned by four new `shell.test.mjs` cases (counter interleave, `.app-busy`/aria-disabled set and cleared, skeleton only after the delay, per-target pairing) and the new `ShellBusyBrowserTest` (bar active during a throttled expand and gone after settle, the in-flight control non-interactive, the skeleton present past 150ms and absent on a fast swap, and the bar provably not stuck after an hx-sync replace abort). |
| 2026-09-07 | v1.26 | shell polish — tree guides + one icon set (085) | **§A — the explorer trees draw real guide geometry** (owner: "connecting lines are not accurate"): the vertical guide was a `border-left` on the level CONTAINER that neither met the parent chevron nor stopped at the last child; it is now `::before`/`::after` on each row's `<li>` — the vertical starts at the parent row's chevron centre and stops at the LAST row's own centre even when that row is an expanded folder (the li wraps the subtree, so a bottom-anchored line would run down inside it), and every row carries a horizontal tick into its chevron/file icon. All lengths derive from tokens (`--tpl-indent`/`--tpl-row-h`/`--tpl-guide-x` on `.tplx-tree`); the unicode ▸ disclosure marker is the sprite's chevron-right rotated on `details[open]` (reduced-motion keeps the state, drops the transition), folder rows gain folder/folder-open (two icons, CSS picks one) and leaves file-code; the per-row border-bottom hairline is gone — the mock draws rows with whitespace and a hover tint, and no component boundary loses its line. Search results stay a flat, guide-free list. **§B — one icon source for the whole app** (owner: "probably we need different icons which can look more professional"): the sprite grows 12 → 40 glyphs, exactly the referenced set, per-icon SHA-256 in `vendor-manifest.json`, the ISC text vendored as `LICENSE.lucide`; the sixteen ad-hoc inline shell SVGs, the eight `&times;` close glyphs and the five `&larr;`/`&rarr;`/`→` arrows are all sprite references now (toast's client builder kept in parity — `ToastMarkupParityTest` + `toast.test.mjs`), and `graph.js`'s CALCULATOR draws `calculator` instead of the `file` stand-in. The size mapping (sm = chrome/actions, md = canvas, xs = tight inline) is recorded in §3.4; `IconSpriteAuditTest` enforces referenced = vendored = manifest-subset in both directions. |
| 2026-09-06 | v1.25 | the §3.3 faces, applied (082 §D) | §3.3's decision table had been prose for three rounds while the MARKUP disagreed with it in six cells. Swept mechanically, not eyeballed, and fixed: **§4.9 node stats** renders its node id in mono (it was in the body face) and its Context `key → value` under `.u-mono` rather than `.num` — `.num` is the NUMERIC column treatment (right-aligned, one size down, mono), and only the mono third of it was ever right for an identifier; **§4.5 datasources** sets its JDBC URL and username in mono; **§4.13 workspaces** sets the workspace name in mono; **§4.17 promotion** gives the pipeline path the same `.app-path` treatment the execution lists use. The **Started** column reported as monospace is not: verified against 079's own screenshots and the stylesheets, dates render in Inter with `tabular-nums`, which is §3.3.2 exactly — the wide, even figures are tabular numerals, not a mono face, and no change was made. New guard `TableTypeFaceAuditTest`: the date rule swept over every app template (no timestamp may sit in a mono/`.num` cell) plus §3.3's mono list transcribed as data. Explicitly NOT changed: the admin-users ID column and the execution-family UUID links, which §3.3 does not list as mono columns. |
| 2026-09-08 | v1.22 | feedback and atmosphere (103) | §3 gains **§3.5** — the click-time pending state (`is-pending` / `is-pending-scope`, the 150ms `#app-status-pill`, the three-ending clear), the `app-enter` entrance inside the 0.05 CLS budget, the `.app-backdrop` ground (dot grid + brand glow, tokens only), card elevation and the interactive-card hover lift, heading weight (`--text-2xl`, Inter only) and `.app-eyebrow`, and the control transitions. §5.1's loading state gains a pointer to it. Text contrast over the backdrop is measured on every vendored theme by `ShellFeelBrowserTest`. No architecture change: no router, no view-transitions dependency, no new library. |
| 2026-09-04 | v1.21 | structural contrast floors (064) | §2 gains principle 8 — the WCAG 1.4.11 non-text floors: `border-default`/`hover`/`focus` at 3:1, `border-subtle` at 2:1, `surface-selected` at 1.5:1 with a `border-focus` accent bar; pane/card boundaries are `border-default` lines, never tints. Text floors unchanged. Enforced upstream by the design-system audit and on the vendored CSS by `VendoredNonTextContrastTest`. The explorer's pane split and the tree's selected-row bar follow the floors (§4.6). |
| 2026-09-02 | v1.17 | template tree UI (047) | §4.6 rewritten as the template **tree**: the one-route/two-shape fragment contract (`/partials/templates` with and without `prefix`, plus `/partials/templates/versions`), levels as server-side prefix queries with no client-side tree assembly at any size, `<details>`-driven lazy expansion with no JS of our own, per-level paging through the shared §5 pager against each level's own derived id (the ROOT level's id stays `#template-list-wrapper`, so the existing swap contract carries over unchanged), and the decided **browse-vs-search** rule — a non-empty `q` is a FLAT list of full paths, not a pruned tree. The four §9.1 absences are recorded as absences and guarded by render assertions: no folder CRUD, no empty-folder state, no `type` control on the edit form, no rename affordance anywhere. The list screen gains a `type` filter (046's column) and a real Create modal (§5.1 Shape A) whose name `pattern`/`maxlength` are RENDERED FROM the server's grammar rather than retyped beside it, and whose `dialect` is disabled-and-absent for `type=html`. §4.7 records `type`/`dialect` as read-only values on the editor. §4.4 records the pipeline editor's read-only template reference (one-line truncation with the full path on `title`, at both call sites and in the `template-missing` state) **and the rule that a future template picker reuses §4.6's prefix fragment rather than building its own client-side tree** — written on the screen the picker would be built on. |
| 2026-09-03 | v1.20 | graph node cards + explorer geometry (059) | §4.4 gains the card row: the pipeline editor's nodes are cards with the facts INSIDE (name/type/datasource·dialect/template@v/run line, HTML overlay, status dot, ports, fit controls) — the 2026-09-02 operator reversal of the 031 label-below look; full contract in Pipeline Editor §5.3 (v1.6). §4.6's layout paragraph records the two CSS corrections seen in 058's own 1920px screenshot: the explorer grid is the VIEWPORT's width (the centered app-canvas cap does not apply to this screen — the owner asked three times for the real estate), and the selected template TOP-ALIGNS with the tree's first row (`#template-detail` keeps the empty state's centered-flex classes for its whole life; the partial's root now stretches, cancelling both centres — the 300px inter-pane gap and the header at y=550 were this missing). The quiet empty/not-found states still centre. |
| 2026-09-02 | v1.19 | template explorer layout (058) | §4.6 rewritten as the template **explorer**: two panes, full height below the page header (the 041 layout math) — tree LEFT (~30%, natively resizable, 260px floor) and the SELECTED template RIGHT (header with full path/badges/Open-in-editor above the versions table; quiet `Select a template` empty state). The owner's spec was Windows file explorer, not a refinement of 047's full-width accordion. **A selection swaps `#template-detail`'s innerHTML and nothing else** — pinned at the fragment-contract level by the new `TemplateExplorerRenderTest` (the detail fragment contains no tree markup, no tree swap target, no OOB). `/partials/templates/versions` now answers `partials/template-detail` (header from `findLatest` + 047's versions table unchanged; a dead name renders a quiet not-found). Search renders IN THE LEFT PANE — full-path `role=listbox` rows that select exactly like tree leaves (047's browse-vs-search rule and the §4.4 future-picker note both survive). Keyboard per the spec: up/down moves selection, right/left expands/collapses, Enter opens the editor — `static/js/template-explorer.js` owns selection, `aria-selected`/`aria-expanded` and the roving tabindex (root level is `role=tree`, nested levels `role=group`); leaves carry `hx-sync="#template-detail:replace"` so a keyboard sweep cannot race a stale detail load. Everything underneath 047 is unchanged: server-side prefix levels one request per expansion, virtual folders, the four absences, per-level paging, filters, the create modal. |
| 2026-09-02 | v1.18 | version selection in the template editor (054) | §4.7: the version dropdown was inert (it blanked the query string and reloaded the same version). Selecting a version now LOADS it read-only — body in the preview surface as a `<pre>` (never a disabled textarea), version number, RELEASED/DRAFT badge, `released_at`/`released_by` — swapping the new `#template-source` root via `GET /partials/templates/editor/source` (§5's idiom: the page paints the same fragment the swap returns). The editable textarea carries the working version and nothing else, so no selection can make a RELEASED row the write target. **Edit** (`POST /partials/templates/editor/edit`) decides nothing itself: a draft exists ⇒ that draft opens and NOTHING is written; otherwise the selected version is copied into a new draft through the same `TemplateDraftService` the REST write uses, based on the CURRENT RELEASE's hash. The §4.7 Save bullet now states the honest position: no save affordance is on the screen, and none is rendered while a RELEASED version is displayed. |
| 2026-09-04 | v1.20 | SEO (073) | §4.15 gains the thirteen intent-cluster pages the public site now serves (pillar, six engine pages over one template, client setup, AI data pipeline, text-to-SQL agent, two comparisons, federated query), the shared `site/_layout.html` chrome, and the guard list; new §4.15a records `/robots.txt` and the generated `/sitemap.xml`. §4.16 **Documentation is now public**: both routes are `permitAll` (the viewer renders packaged Markdown, reaches no principal, workspace or datastore, and the same content is public on GitHub), with an anonymous public chrome + SEO head and the application chrome unchanged for signed-in readers. Page titles, descriptions and canonicals moved out of the templates into the `SitePages` registry, which `/sitemap.xml` and the SEO guards read — the homepage `<h1>` is untouched. |
| 2026-09-02 | v1.19 | promotion (055) | New **§4.17 promotion screen** — the ONLY way to promote (Versioning §10.1 D8: no MCP tool, no schedule; the round's fence excluded `modules/mcp-server` to make that mechanical). Lists exactly §10.2's set, computed in the sender service rather than in the template so the screen cannot drift from the rule. Three distinguishable states — no target configured, in sync, target unreachable/refusing — because the operator's next step differs in each; the refusal CODE is shown in place, not on a generic error page. The Promote action is session-only and re-runs every §10.3 guard against a fresh inventory server-side. The nav gains a Promotion link. |
| 2026-08-31 | v1.16 | recurrence defect round (034) | §4.5: the OOB whole-table rule recorded beside the swap-root rule — a table partial travels as a whole `<table>` on any out-of-band path; a `<tbody>` carrying `hx-swap-oob` nested in a `<div>` is silently discarded by the browser's fragment parser (030 F-1, previously known only from the §4.10 changelog note). Doc-only; a mechanical guard was judged disproportionate (the shape is only visible to a real HTML parser — a regex over templates cannot tell an OOB `<tbody>` from a legitimate one inside a `<table>`). |
| 2026-08-31 | v1.15 | website + docs in-app (033) | §4.2 Dashboard moved from `/` to `GET /dashboard` — `/` is now the public marketing site (new §4.15, app-served, cache-defended per OPEN-ITEMS T46, no rate limiter); new §4.16 Documentation — the packaged spec set rendered in-product at `/docs` (session-only), with the §A link-rewrite rule (packaged slug or canonical GitHub URL, never a dead relative href) and `th:utext` doc-body insertion. Navbar gains the Docs entry; error pages and login/workspace-switch redirects point at `/dashboard`. The root `README.md` website pointer and the `website/` directory are gone (the app's vendored design system is the single copy). |
| 2026-08-05 | v1.0 | initial draft | UI screens inventory: 12 screens (login, dashboard, pipeline list/editor, datasource list, template list/editor, execution history/detail, API keys, user settings, admin users), htmx patterns, error pages |
| 2026-08-07 | v1.1 | consistency campaign | Per [SPEC-REVIEW-2026-08.md](https://github.com/msabiransari/datapipelines/blob/main/docs/SPEC-REVIEW-2026-08.md) §2.12: route convention §2.1 (pages / `/partials/**` / `/api/v1/**`, htmx never calls the JSON API) and all `hx-*` endpoints re-pointed at `/partials/**` incl. §4.10 API keys [1]; template-editor context form replaced with free-form key-value/JSON input — templates no longer declare variables [1b, D3]; §5 htmx example fixed (`hx-include` + `th:attr` `hx-vals` instead of `${q}` interpolation) [2]; §4 scope column declared a view of the authoritative [Auth §7.6](/docs/auth.md#76-operation-matrix--the-permission-catalog-authoritative) matrix, datasource test corrected to `author`, key scopes ⊆ creator's scopes [3, D15]; §4.11 theme preference persisted on the `users` row via `PATCH /partials/profile/theme`, not session state [4]; §4.11 provider badge renders the configured provider `display-name` [5]; §4.9 result panel rebuilt on the uniform cursor with the TTL-expired state and `format`-parameter downloads [6, D9]; new §5.1 standard states (empty / loading via `hx-indicator` / errors via the `response-targets` extension into `#toast`) [7]; CSRF via `dp_csrf` cookie + `DP-CSRF-Token` header wired in the layout [D10] |
| 2026-08-28 | v1.9 | workspaces surfaces slice | New **§4.13 Workspaces** screen (create per mode, open-join, owned-workspace member management, switch) + navbar **workspace switcher** (§3: POST /workspace/switch re-stamps the session claim; hx-headers carries DP-Workspace for partials). §4.5 datasource list re-grounded: workspace-scoped listing, workspace/readonly columns, register modal with the D8-gated `global` (admin-only, visible-disabled) and `readonly` checkboxes, Register hidden for gated-off members. |
| 2026-08-30 | v1.11 | datasources SPA table + toasts | §4.5: search/dialect/pager re-fetch only the list fragment into the stable `#datasource-list-wrapper` swap root (the id moved onto the fragment root — it previously died with the page's placeholder div); the connection test result is a §5.1 toast, ending the row-swap/"Back to list" contract that broke the table layout; the dead View button (REST JSON via hx-get) removed. New §5.1 **Notifications** state: `#toast` stack, server-rendered `partials/toast`, layout-global `toast.js` lifecycle. |
| 2026-08-30 | v1.10 | local password auth | §4.1 Login: local form + divider + provider buttons, only enabled methods render; `credentials`/`locked` banners join the `?error=` idiom. §4.12 admin users: create local user (one-time password shown once), reset, disable local, unlock; `Local` column. New §4.14 Change password — the §5A.4 forced-change screen. |
| 2026-08-31 | v1.12 | table component rollout + live list controls | §4.3/§4.6: the pipelines and templates swap roots moved onto the fragment roots (`#pipeline-list-wrapper` / `#template-list-wrapper`) — the ids previously died with the page's `th:replace`d host div, so both pagers had never worked — and the search inputs gained their `hx-*` wiring (they were inert). §4.3's unimplementable datasource filter was deleted (deferred: `PipelineRecord` carries no datasource; serving it needs a join through the pipeline definition). New §5 **shared pager** fragment (`partials/pager`, caller-built URLs, nullable `total`). New §5.1 **Search** rule: a screen's search covers every rendered column — datasources search now matches URL, username, dialect wire value and workspace; template search matches the dialect column (repository-level ILIKE). `.ds-table`/`.ds-badge`/`.ds-empty` adopted on the three list partials, API keys, admin users (rows are Kotlin-built), workspaces and the template editor's imports table; the undefined `.ds-empty-state` class is gone. §4.8 keeps its `#execution-table`/`innerHTML` contract; its pager's `hx-vals` offsets are now server-rendered via `th:attr` (they previously reached the browser as an unprocessed `[[...]]` literal). Two new mechanical guards: no unquoted literal inside a `th:attr` assignation, and every `hx-target` id a rendered list page references must exist in that page. |
| 2026-08-31 | v1.13 | toast application rollout | Every mutation now reports its outcome through the §5.1 toast — successes AND refusals. §5.1 rewritten: the `response-targets` prescription is gone (the extension was never loaded; the layout's `hx-target-error` was dead config and is removed) and replaced by the four delivery shapes — A (content + OOB toast), B (toast-only, `hx-swap="none"`), C (refusal: real 4xx + `HX-Retarget: #toast` + `HX-Reswap: beforeend`, admitted by `toast.js`'s new `bridgeErrors`, the twelve-line `htmx:beforeSwap` bridge that exists because htmx never swaps 4xx/5xx on its own), and D (the ONE client-side builder `DpToast.show`, for stream-borne events with no HTTP response; `createElement` + `textContent` only). Toasts bound for the stack travel WRAPPED in the new `partials/toast-oob` fragment, because htmx swaps a non-`outerHTML` OOB element's children, not the element. The hard rule is carved into §5.1: a 6s toast never carries anything the user must keep — one-time secrets stay in their persistent inline panels and toasts only point at them. Previously INVISIBLE refusals are now delivered: admin user-create 400/409 (§4.12, Shape C), the unknown-theme 400 (§4.11, Shape C), and the password-change failures (§4.14 — field-level validation, so they stay inline, delivered by the screen's own `htmx:responseError` listener, the one screen where Shape B and inline errors coexist). §4.5 register drops `HX-Redirect`: success closes the modal, refreshes the list and toasts without a page reload, while the modal refusal stays inline (the screen keeps its own error path — no `HX-Retarget`). §4.10: creating a key now refreshes the key table out-of-band (the table markup is one extracted fragment, `settings/api-keys :: keysTable` — swapped at TABLE level, because a `tbody` OOB element nested in the response is destroyed by the browser's fragment parser: table-only tags outside table context are dropped tokens) and the dead `HX-Trigger: keyRevoked` header is gone. §4.9 cancel toasts; its `ResponseStatusException` refusals stay full error pages (recorded gap: `UiExceptionHandler` has no htmx-aware branch). §4.13's redirect flash renders as a server-side toast in the layout's `#toast` stack instead of the banner, copy verbatim. New guards: `ToastOobFragmentRenderTest` (nesting), `ToastMarkupParityTest` + `toast.test.mjs` (one markup contract, server and client), and the `bridgeErrors`/`show` JS tests under `editorJsTest`. |
| 2026-08-31 | v1.14 | execute page redesign (032) | §4.4 Pipeline Editor expanded from the bare pointer into the rows that touch this document's shared contracts: the new `READ_RESOURCES` node-SQL read partial (`GET /partials/pipelines/{id}/nodes/{nodeId}/sql`, spec §8.3) with the deliberately-NOT-a-toast copy confirmation (live region + 1.5s label swap); the result grid moved onto the shared `.ds-table` (bespoke `.pe-result-table` styles deleted; paging stays the client-side cursor contract, restyled to the shared pager's look); and the SSE terminal events land on §5.1's Shape D — `pipeline_completed`/`execution_aborted` toast via `DpToast.show` (stream-borne, no HTTP response), `pipeline_failed` keeps the error modal — with all three announcing on the live region. No §5.1 amendment was needed: Shape D already existed (v1.13) and the copy button does not use it. |
| 2026-09-03 | v1.20 | datasource credentials (061/T84) | §4.5 gains the **Last test** column: an `ok`/`failed`/`never tested` badge with the timestamp and the driver's message on hover, from the datasource row's stored outcome ([Datasources §8.1B](/docs/datasources.md#81b-the-last-tests-outcome-is-stored-and-listed)). It exists because listing never connects — on 2026-09-02 this screen showed a datasource as fine while every execution failed at CONNECT. The §5.1 Search rule follows the new column (`ok` / `failed` / `never tested` all match). No polling is added. §4.5's delete note now points at the any-version in-use guard (T79). |
| 2026-09-05 | v1.22 | pipeline folders (067) | §4.3 becomes the **Pipelines Explorer**: pipeline names are folder paths, so the flat table is a tree LEFT + selected pipeline RIGHT — the §4.6 shape, reusing `template-tree.css` and `template-explorer.js` (which now finds its pane by a `data-explorer-pane` marker rather than a hard-coded id, so one file serves both screens). One level per request; a non-empty `q` is a flat list of full paths; the detail pane is read-only and carries settings, parameters, versions and Open in editor. **T108:** the permanently-`disabled` "Create Pipeline" button and its "Phase 2 other worktree" tooltip are deleted — an affordance the server does not have, advertised with an internal note — replaced by a sentence naming MCP as the authoring path and the roadmap as the plan. |
| 2026-09-06 | v1.24 | shell v2 + the API section (079) | **The shell became a rail + top bar** (new **§3.4**, normative): 232px collapsible rail grouped Build/Operate/Organisation with Dashboard above, workspace switcher as a card, Pipelines/Templates counts behind a 60s `NavCounts` TTL (no general metadata cache existed to reuse), collapsed state on `<html>` written by the layout's ONE inline script before first paint; a top bar with a breadcrumb derived from `AppNav` (and re-derived client-side from the active link after a boosted swap — `ShellRenderTest` asserts the table and the markup agree), a search field that is a `<div>` wired to nothing, a light/dark toggle and an avatar menu (OIDC `picture` when stored, initials otherwise; Escape/outside-click/arrow keys; `aria-expanded` + `role="menu"`). The 076 boost contract is unchanged. The active item gains a 3px `--border-focus` bar the mock does not have, because §2.8 says a tint alone cannot carry selection. **One theme preference, five controls**: the design system ships one stylesheet per look, so light/dark/auto and the six palettes are nine values of `users.theme_preference` — there is no mode column and none was invented. **New §4.18, the API section** at `/api-console` (`read`): published endpoints (released version, exact-node binding count, and NO calls column — nothing in the system records endpoint serves, stated rather than sampled), a read-only key list showing an endpoint key's BINDINGS instead of its absent scopes, and the MCP card with `McpToolCatalog.NAMES.size` and a `base-url`-derived URL (never request-derived). **§4.11 Settings** is a card grid (owner ruling, option (a)) with scopes as chips, a Mode row, a disabled Compact density and no `.app-reading`. **§3.1 amended**: Settings/Admin/Workspaces leave the reading column. **§3.3 amended**: the headline is `--text-xl` in the app shell, and **§3.3 gains the faces** — Inter and JetBrains Mono vendored under `static/vendor/fonts` (OFL-1.1, owner-confirmed AGPL-compatible), which closes the gap that `light`, `dark` and `minimal` named neither face at all. **§4.17 promotion** flashes become toasts, with `toast.js` draining any `[data-toast-flash]` bin. `InlineWidthAuditTest` widened from "no inline width" to **no inline `style=` and no literal colour on any app template**, allowlist empty and asserted empty; the 330 attributes became the `u-*` utility layer plus semantic `app-*` classes, all token-only. |
| 2026-09-05 | v1.23 | app shell + coherence (076) | One width policy (new **§3.1**, normative): every screen full-bleed with one gutter — `--app-content-max`, `.app-main-bleed` and the editor's `fullBleed` opt-in deleted (the opt-in became the rule); reading content in `.app-reading` (90ch, left); inline `max-width`/`grid-template-columns` out of every app template (`InlineWidthAuditTest`). **Boosted navigation** (new **§3.2**): `hx-boost` on nav + `<main id="app-main">` with the swap policy in `shell.js` (NOT inherited attributes — that would hijack partial swaps), `#app-progress` as the loading signal, client-mirrored active-section state, the five full-navigation routes marked `hx-boost="false"` (`ShellRenderTest`), editor teardown/re-bind semantics, and server redirect flashes rendered into a hidden `#toast-flash` bin inside the swapped region (`WorkspacesUiControllerTest` re-pinned). **Bootstrap out**: the 5.3.8 webjar, its layout `<link>` and every lockfile/verification reference deleted — no app screen used a Bootstrap class; what its reboot silently provided (`<code>`, `<pre>`, `<small>`, `<strong>`) is restated on tokens in app.css (`LayoutStylesheetOrderTest` rewritten, `SiteAssetAuditTest` sweeps for the reference). One type/density scale (new **§3.3**): `.ds-headline` titles everywhere (`TypeScaleAuditTest`), tabular tables, dates always proportional, mono for identifiers only with the per-column decision table. **T114**: execution lists show the pipeline display name (machine path on hover) via ONE web-side batch query per page — no dag/pipeline-contract change, no request per row. §4.9's node-stats table gains the Context column (`context_key → value` for CALCULATOR nodes — the claim `pipeline-contract.md`/`dag-executor.md` always made, now true). |
| 2026-09-05 | v1.24 | mandatory folders (077) | Both explorers' ROOT level holds **folders only** ([Template Hierarchy §4.1](https://github.com/msabiransari/datapipelines/blob/main/docs/template-hierarchy-design.md#41-grammar)): a name carries a folder, so neither browse model queries the root's leaves and both level fragments lose the `prefix.isEmpty() ? name : name.substring(…)` label branch. §4.6's create modal gains the helper line `folder/name — e.g. test/scratch`; the templates empty state says to author one under a folder. A pre-077 flat PIPELINE can still exist (no migration gate on that side) and stays reachable by search, by `pipelines_list` and by its own URL — it is simply not a row the root level draws. |
| 2026-09-09 | v1.28 | resizable panes (104) | **Panes the user can size, one mechanism for two of them** (`static/js/splitter.js`: a pure clamp/step core under `node --test` plus a `role="separator"` DOM adapter — pointer capture, touch, arrows ±16px / Shift ±64px / Home / End, double-click reset, `localStorage` per pane key). **§4.4** the editor dock's 232px fixed height becomes a drag on its top edge (floor 120px, ceiling = stage − 160px), remembered as `dp.pane.editor-dock`, restored before first paint, and the Cytoscape canvas follows it: graph.js's stage `ResizeObserver` now routes through `handleStageResize` — `cy.resize()` always, re-fit only if the view was still the fit, which also fixes the rail-collapse and window-resize cases that silently had the same defect. **New §4.3a** (shared by §4.3 and §4.6): the tree pane's native `resize` corner is replaced by a handle ON the divider, floor 260px, ceiling 40vw, one key for both explorers so the width follows the user. Sizes travel as ONE CSS custom property on `<html>` (`--pe-dock-pane-h`, `--tplx-tree-w`) with the shipped default as the `var()` fallback, so reset is "remove the property"; `InlineWidthAuditTest`'s KDoc names that shape as the allowed one. §5.1 unchanged; no agent-facing surface changes, so the Skill is untouched. |
| 2026-09-08 | v1.25 | 094 datasource lifecycle | **§4.5** gains three things. A collapsed **"Connection pool — defaults"** section in the register modal AND in the new **edit dialog**: the eight tunable HikariCP keys ([Datasources §5](/docs/datasources.md#5-connection-pool-configuration)) with unit, meaning and a help line naming the LAYER each prefilled default came from; `readOnly` mirrored disabled (it is §5.6-refused, so a free field would reject every value); refused keys never rendered; a field left at its default not persisted; the dialect select re-fetches the section because the default is the dialect's to decide. An **Edit** row action — name and dialect disabled (immutable, and re-pointing a live datasource would take every pipeline with it), a blank secret keeps the stored credential, `global` admin-only with a hidden companion field so an UNCHECKED box is still a deliberate write. A **Delete** row action whose dialog asks the usage question FIRST and renders the referencing nodes with their versions; the in-use branch has no button at all, the unused branch's confirm names the datasource. Both dialogs are fetched as whole backdrops into one empty `#ds-dialog` (nothing opens them; closing empties the container) and close on a `data-ds-saved` marker a refusal never carries. |
| 2026-09-09 | v1.32 | the shell fits a phone (110) | **New §3.6 (normative) — the shell at three widths**: ≥1100px as today; 768–1099px the rail STARTS collapsed by a pure-CSS default (`rail-expanded`, stamped pre-paint by the layout's one inline script, is a stored-"0" user's opt-out; the collapse toggle acts on the VISUAL state, so its first click there expands); <768px the SAME `<aside class="app-rail">` becomes an off-canvas drawer — `rail-open` on `<html>` set/cleared by `shell.js`, never persisted, opened by the topbar's `#rail-open` (the sprite's new `menu` glyph; hidden ≥768 by CSS), closed by `#rail-close`, Escape, the `.app-rail-backdrop` scrim, or any BOOSTED navigation (the same one-shot `applyBoostSwap` arms for the §3.5 entrance decides the close, so a background partial settling cannot slam it shut). Opening moves focus to the first nav link; closing returns it; body scroll locks via the same class. The search MOVES into the drawer's head (a second inert copy of the §3.4 placeholder — hidden is not moved). §B: crumbs truncate to the leaf with the full path on `title` below 1100; every `.ds-table` scrolls inside its own box at every width (`display:block` + `overflow-x:auto` — one rule, wrapper-less tables included, oob fragments untouched); `.app-main` gets `min-width: 0` (the bare-`1fr` track was letting any page's min-content widen the document); `.app-modal` collapses to one full-width rule below 768 with a sticky close; the toast stack sits at the BOTTOM below 768; touch targets are `--field-height-lg` in the drawer and topbar. §4.4/§4.7: the editors are desktop-first BY DECISION — below 768 a `.app-wide-screen-note` band names the entity, its lifecycle badge and the explorer link, editor untouched underneath. Guards: the §D walk now covers 390 and 768 (`AppShellBrowserTest`), and `MobileShellBrowserTest` drives the drawer's open/close/focus/motion contract end to end. |

| 2026-09-19 | v1.57 | 173 (#173) SEO and AI-readiness coverage before indexing | §4.15: the `/compare/dagster-vs-airflow` editorial page; every measured search phrase pinned to a page and surface by `SiteKeywordCoverageTest` (heading renames on eleven pages, retired with reasons in the outline fixture; `/for/analysts` retargeted to "AI data analyst", owner ruling); engine `searchAliases` ("PostgreSQL", "MSSQL"); the homepage `Organization` block and the docs' `TechArticle` + `BreadcrumbList`. §4.15a: `/llms.txt`, `/llms-full.txt`, `/docs/{slug}.md` (+ `Accept: text/markdown`), and the sitemap row corrected — `lastmod` has been absent since 145. §4.16: the `.md` twin. |
| 2026-09-18 | v1.56 | the header search becomes real (161) | **§3.4 amended — the placeholder is gone (#155)**. The shell's search is a real `<input role="combobox">` (and the same control in the drawer's head), opening a palette on focus/click or ⌘K / Ctrl+K and fetching `GET /partials/search?q=…` over htmx (`input changed delay:200ms`; a BLANK query never reaches the server — `shell.js` cancels it). `SearchController` / `SearchBrowseModel` answer Pipelines / Templates / Executions, ≤ 8 each, workspace- and role-scoped through the screens' own queries (`PipelineService.list`, `TemplateRepository.list`, the execution-history fork), every hit a link (editor / template editor / execution detail; executions show status + started-at) and a "more…" row per group to the FILTERED list page. Combobox keyboard: ↑/↓ move the active row (`aria-activedescendant`; focus stays in the input), Enter opens it, Esc / outside click / any boosted navigation close. §4.3e gains the search's read-only inventory row. Guards: `ShellRenderTest` (real input, no `aria-hidden` search), `SearchControllerTest` / `SearchPartialRenderTest`, the palette half of `shell.test.mjs`, `RoleVisibilityRenderTest`'s viewer arm, and `HeaderSearchBrowserTest` (⌘K → type → arrow → Enter → editor, screenshot). |
