Reference

UI Screens Inventory

Status: v1.223 Owner: datapipelines.co core Depends on: Pipeline Editor, Design System, REST API, Auth & Security, Templates, Configuration Reference 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). 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); 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.
  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 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) 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). 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 @ControllerAdvices 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). 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) — 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).
  • 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.
<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).

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 @ControllerAdvices — 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. AppShellBrowserTestpins it: no app screen makes the document taller than the viewport at 1440×900 or 1920×1080, and/docsproves 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.

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, 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.

  • 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)
  1. 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.

  1. 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
  1. 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.

  2. 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:

<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 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-rows 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 — 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): 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), 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, §5.3), 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); credentials (unknown email or wrong password — deliberately identical, Auth §5A.5), 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).

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), 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); 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) 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) — 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): 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 — 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) 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. 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. 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). 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). 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; 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). 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): 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) 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), "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 precedent.

The Last test column (061/T84). Each row shows the outcome of the LAST connection test against that datasource (Datasources §8.1B): 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). 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) — 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). 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):

  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 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 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)
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)
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 §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 — 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 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)
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); 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
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); 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. 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)
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 ResponseStatusExceptions 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) — 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).
  • 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). 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)
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).

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)
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).
  • 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); 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). 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).
    • 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), 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)

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) — create local users, reset passwords, disable local access, and clear lockouts.

  • Partials delegate to REST §16.3 (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) — 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 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). 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). 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, Auth §5A.8) 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). Nothing is revoked: Activate restores the same sessions and keys.
  • There are no scopes (Auth §7.5): 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): 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, Auth §7.6). 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 — 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, 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) — "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). 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). 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).

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 and Deployment §3.5, 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)
Endpoints called The target's REST API §18 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, Versioning §10.2): 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 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'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: 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) 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 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), 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) — 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

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), 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), 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, 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 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, #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). 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, Dashboards §5.7). 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); 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): 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'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 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.

<!-- 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-disableds 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-rows, 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) 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), 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 @ControllerAdvices (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.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 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): 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; 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 §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 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:replaced 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). 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): 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) 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). |

This is the documentation packaged with the running version. The same files live in the repository on GitHub.