Skip to content

feat(metrics): a bound widget names one series with dims: (abilityai/trinity-enterprise#730) - #3294

Merged
vybe merged 23 commits into
devfrom
AndriiPasternak31/ent730
Oct 7, 2026
Merged

vybe merged 23 commits into
devfrom
AndriiPasternak31/ent730

Conversation

@AndriiPasternak31

@AndriiPasternak31 AndriiPasternak31 commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Description

A dashboard widget bound to a declared metric (metric: ad_spend) could not name one series of a dimensioned metric. Every tile bound to the metric got the same cross-series fold, so three per-channel spend tiles all showed the same number and nothing said which number it was.

A bound metric / status / progress widget may now carry dims: {channel: google}:

  • One series, filled once. bind_dashboard_widgets picks a source (the series the selector names, or today's fold when there is no selector) and one fill block writes value, last_point_at, stale, freshness, colour and history from it, so the number and the sparkline always describe the same series. Matching is by canonical_dims (key order does not matter), exact match only. dims: {} / null mean "no selector".
  • A selector that cannot match never shows the fold. It is one of three named refusals with no number and no sparkline: metric_dimension_invalid (with a type hint, e.g. "quote it"), metric_dimension_undeclared (lists the declared keys), metric_series_not_found (a neutral grey footer with a deterministic hint built from binding_detail facts). The selector is validated by the write path's own leaf (metric_points_service.validate_dims, made public), through metric_read_service.parse_dims_selector.
  • _latest_entry's fold is untouched, so a tile without dims: and the objective join still show the same number (the ent#479 parity tests pass unchanged).
  • The tile says what its number is. Every bound widget carries bound_series facts and BoundMetricMark captions them: channel=meta, sum of 3 channel values, newest of 3: channel=meta.
  • Threshold verdict on bound metric tiles. The backend sends a typed threshold_verdict (critical / warning / ok) from the one threshold rule that also drives color; the tile renders it as a BaseBadge whose footprint is reserved on every judgeable tile, so crossing a threshold on a background poll swaps the badge in place instead of shifting the layout.
  • Trend colours follow the declared direction on bound tiles (rising down_good is red; neutral is a grey arrow over a blue sparkline). Unbound widgets are unchanged.
  • Compat X-009 (SOFT, static): a dims: selector that can never match the dimensions its metric declares, a dims: with no metric:, and a metric: that is not text, reported with the tile's own code and sentence, so an agent sees its own mistake through get_agent_compatibility_report (the dashboard route is # mcp: none). It judges each widget as the tile receives it over the agent server's JSON response. Catalog 91 → 92.
  • Two fixes in the same loop: a store outage no longer leaves an author's value: 0 placeholder on screen (it read as a real 0), and a non-text metric: (a YAML list or mapping) refuses that one widget (metric_name_invalid) instead of failing the whole dashboard read. A successful bind also drops an author-typed trend / trend_value so it cannot contradict the computed sparkline.

Related Issue

Fixes abilityai/trinity-enterprise#730

  • OSS-core. This fixes behaviour inside the OSS declared-metrics binding; no entitlement.
  • Follow-up (deliberate): a dims selector on GET /api/agents/{name}/metrics and MCP get_metrics is not in this PR (follow-up: abilityai/trinity-enterprise#828).
  • Known limit, documented: a selected series is found among the metric's 200 newest points and 50 newest series. A channel that reports rarely beside a busy one can fall outside that window; the tile says so (binding_detail.window_points / series_cap). Follow-up: A bound dashboard widget's dims: selector can miss a series that reports rarely beside a busy one #3293 (a selector-specific latest-point lookup).

Journey Impact

Journey Impact: extends: J12

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Visible change for existing dashboards, with no author action: a bound tile over several series gains a caption, a breached bound metric tile gains a verdict badge, bound trend arrows and sparklines follow the metric's direction (neutral is no longer green/red), and the declared-metric tiles' chart: label joins dimensions with , instead of ,.

Testing

  • I have tested this locally (targeted, see below; CI runs the full suites)

  • New tests added (if applicable)

  • All existing tests pass (targeted set)

  • Every new test executes the changed path

  • Backend: new tests/unit/test_ent730_bound_widget_dims.py (incl. a Hypothesis property over arbitrary dims values: binds or refuses by name, never raises) and tests/unit/test_ent730_dims_compat.py; test_compatibility_checks.py catalog 91 → 92 plus a metric:+dims: widget with no value still passes D-003. The 22 test files that cover the touched modules (ent#477/478/479/666/729 metrics, compatibility, hardened YAML): 1099 passed on each of 3 random seeds (1, 12345, 99999); lint_sys_modules.py and lint_root_test_placement.py clean.

  • Frontend: metricFormat.spec.js (caption, hint, verdict helpers) and mounted dashboardPanelBoundWidget.spec.js (caption, not-found footer, verdict badge incl. in-place swap, direction colours); npm run test:unit 280 files / 4905 tests passed; npm run build green; raw-colour ratchet: DashboardPanel.vue exactly at its baseline, BoundMetricMark.vue zero non-gray.

  • UI: rendered the real DashboardPanel in a throwaway component harness (fixture API responses, no stack) in light and dark: caption variants, a long dims label wrapping at 240px, verdict badges, a live ok → warning → critical transition at 240px with nothing in the tile moving, direction colours, the not-found footer. The badge stacks both verdict words in one grid cell so Critical, Warning and the reserved ok slot are one width in any font (measured equal in the system font, Verdana and Courier New). Text contrast ≥ 4.5:1 in both themes.

Mutation: the outage fix is pinned by test_T19_an_outage_drops_the_authors_placeholder_value / test_T19_the_cached_dashboard_path_drops_it_too; the non-text metric: fix by test_a_non_text_metric_refuses_that_widget_only (on dev a list/mapping raised TypeError out of the bind loop). Both go red with the fix reverted.

Pre-existing, not fixed here

  • The metric-name chip and the "Stale" chip in BoundMetricMark are hand-rolled pills (ent#479); the new not-found row reuses the same chip so the two stay identical, and the new verdict is a BaseBadge.
  • trendClasses uses status-*-600 trend text in light mode and the sparkline colours are literal hexes (shared with the declared-metric tiles).
  • A refused metric tile shows - while status / progress show —; a refused or zero-point progress tile draws an empty bar.
  • An author history / color still survives the outage refusal arm (only the placeholder value is dropped, as decided).
  • A metric tile's value and unit can wrap onto two lines when the number grows, which changes the tile height (existing markup).
  • AC5 is "same series", not equal endpoints.

Checklist

  • My code follows the project's style guidelines
  • I have updated the documentation (requirements §49.6/§49.7, architecture/backend.md, architecture/frontend.md, both feature flows + index, the user doc, agent-validation-spec.md)
  • I have not committed any sensitive data (API keys, credentials, etc.)
  • I have added appropriate logging for new functionality (no new log lines: refusals are carried on the widget, X-009 on the compat report)

…ontract (ent#730)

Requirements §49.6/§49.7, the backend and frontend area files, the dashboard
and custom-metrics feature flows, and the user doc (a copyable per-channel
recipe, the selector rules and what each refusal means) for a dashboard.yaml
widget naming one series of a dimensioned metric with dims:.

Refs Abilityai/trinity-enterprise#730
…_dims

The read path (a dashboard.yaml widget's dims: selector) reuses it, so a
selector is valid exactly when a recorded point's dims would be. No alias,
no behaviour change; its one caller is updated.

Refs Abilityai/trinity-enterprise#730
A dashboard.yaml widget bound to a dimensioned metric could only show the
cross-series fold, so per-channel tiles all showed the same number with no
label. bind_dashboard_widgets now picks a source (the one series a dims:
selector names, or today's fold) and fills the widget once, so value,
freshness, colour and sparkline come from the same place on both paths.

- parse_dims_selector (public, pure, total) validates the selector with the
  write path's validate_dims and maps its codes; {} and null are no selector.
- Matching is canonical_dims, exact match only.
- Three named refusals that never fall back to the fold:
  metric_dimension_invalid, metric_dimension_undeclared,
  metric_series_not_found (facts in binding_detail, never 'does not exist').
- Every bound widget carries bound_series facts saying what its number is.
- _latest_entry, fold, _chart and the route are untouched.

Refs Abilityai/trinity-enterprise#730
…declared direction

_threshold_verdict is now the one threshold rule; _threshold_color is a
two-line mapping of it, so a tile's colour and its verdict cannot disagree.
A successful bind writes threshold_verdict ({level: critical|warning|ok,
threshold}) whenever the metric is judgeable (not status, up_good/down_good,
at least one threshold) and the value is a number, so the field's presence
never changes between polls. Every successful bind writes the registry's
direction (neutral when none is declared). Refusals drop both.

Refs Abilityai/trinity-enterprise#730
… value on a bound tile

The user doc recommends a value: 0 placeholder for agents on an older base
image. The metric_store_unavailable arm left it in place, so an outage showed
a believable 0 under the refusal. The arm now drops value; the cached
dashboard path re-binds through the same function and gets it too.

Refs Abilityai/trinity-enterprise#730
DashboardPanel prefers an author-typed trend/trend_value over the computed
history.trend, so on an unselected bound tile an author arrow could
contradict the sparkline beside it (with direction-aware colours, a green
arrow over a red line). Every successful bind now drops them; the selected
path already did.

Refs Abilityai/trinity-enterprise#730
…g the dashboard read

A YAML metric: [ad_spend] or metric: {a: b} is truthy, so the widget is
bound, and by_name.get(<list>) raised TypeError out of the per-widget loop,
which runs outside the store try: one bad line took the whole dashboard read
down. That widget now gets a named refusal (metric_name_invalid, naming the
YAML kind, never the value) and the rest of the dashboard renders.

Refs Abilityai/trinity-enterprise#730
…ter and hints

BoundMetricMark captions every bound tile whose number needs qualifying, from
the backend's bound_series facts: channel=meta for a selected series, 'sum of
3 channel values' (with '· N stale') for a fold, 'newest of 3: channel=meta'
for a last metric over several series. A metric_series_not_found refusal is a
neutral footer row (chip, selector, 'no recent data') with a deterministic
hint built from binding_detail; every dims: refusal links the docs. The copy
lives in metricFormat.js (formatDims, boundSeriesNote, refusalHint), and
chartBasisNote now shares formatDims (multi-key labels join with ', ').

Refs Abilityai/trinity-enterprise#730
…ollow the declared direction

A bound metric tile never rendered its threshold colour, so a breaching
channel showed no red. BoundMetricMark now renders the backend's
threshold_verdict as a BaseBadge (Critical / Warning, the threshold in its
title) on bound metric tiles only; on ok the badge's footprint is reserved
invisibly, so a poll crossing a threshold swaps it in place.

DashboardPanel colours a bound widget's trend arrow and sparkline through
metricFormat.trendClasses / sparklineColor with the widget's direction, so
rising down_good spend is red and neutral is grey, matching the declared
metric tile beside it. Gated on a truthy metric AND bound === true; unbound
widgets keep the legacy colours. No class string added or removed in
DashboardPanel.vue (raw-colour counts unchanged: 8 / 101 / 0).

Refs Abilityai/trinity-enterprise#730
A dims: refusal renders only in the browser and the dashboard route is
mcp: none, so an agent never saw its own selector mistakes. New SOFT static
check X-009 reports them in the compatibility report, with the tile's own
code and sentence: it validates each selector against the dimensions its
metric declares in template.yaml through the binding's parse_dims_selector
(which wraps validate_dims), and also reports a dims: with no metric: and a
metric: that is not text (invalid_metric_name, now shared with the binding).
It never consults points, skips undeclared/malformed metrics (D-009's) and
partial selectors, fails closed with the type name only, and clips every
echoed string. D-003 is untouched; a metric:+dims: widget still passes it.
Catalog 91 -> 92.

Refs Abilityai/trinity-enterprise#730
…c: (ent#730)

The three refusal rows said only "a YAML list or mapping". The binding
refuses any truthy non-text value: a number, boolean, list or mapping.
Only a list or mapping used to fail the whole dashboard read; a scalar
such as metric: 5 or metric: true read metric_undeclared and now reads
metric_name_invalid. A falsy value stays unbound, as before. The
backend.md catalog entry made the same overbroad "instead of raising"
claim and is corrected the same way.

Refs Abilityai/trinity-enterprise#730
…ses and the catalog count match the code (ent#730)

- metric_name_invalid drops the same full key set as the dims refusals; a
  resolved bind also pops binding_detail; the index row says only
  metric_series_not_found carries binding_detail facts.
- metric_dimension_invalid also covers over-long and control-character values.
- A bound neutral metric shows a grey arrow over a blue sparkline, not grey.
- bound_series.dims on a non-numeric sum/avg is the newest series' dims.
- backend.md states that a falsy metric: stays unbound (ROUND-3).
- X-009 also skips an invalid dashboard.yaml and reports a truthy non-text
  metric: only; the compatibility flow states 92 checks.
- The user-doc recipe shows a Critical badge on the Meta tile only.
…t (ent#730)

The reserved slot was an invisible "Critical" over a 76px min-width, on the
claim that Critical is the widest label. In macOS system-ui "Warning" renders
77.34px, so crossing a threshold resized the badge by 1.34px; with the system
font stack any fixed floor only moves that boundary.

Both verdict words now share one grid cell inside the BaseBadge and only the
current one is visible (the other is invisible + aria-hidden), so critical,
warning and the reserved ok slot are as wide as the wider word in whatever
font renders it. Measured in a live render: 77.34px in all three states in
system-ui, 85.55 in Verdana, 86.14 in Courier New (where Critical is the wider
word); an ok -> warning -> critical poll moves none of the tile's 24 elements.
The words come from VERDICT_WORDS, derived from VERDICTS, so each is spelled
once.
@AndriiPasternak31 AndriiPasternak31 added the ui PR touches the frontend UI — triggers Playwright e2e tests label Oct 6, 2026
@AndriiPasternak31
AndriiPasternak31 marked this pull request as ready for review October 7, 2026 00:59
@dolho

dolho commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

/review Report

Branch: AndriiPasternak31/ent730 → dev (diffed against merge-base a72271d9c)
Files Changed: 23 (+2990/-47)
Scope: DRIFT DETECTED (informational; documented in the PR body)
Plan Completion (abilityai/trinity-enterprise#730 acceptance criteria): 7 done / 0 partial / 0 not done / 1 changed / 0 unverifiable

AC Status Evidence
metric/status/progress widget may carry dims:; value, last_point_at, stale, freshness, history from that series DONE metric_read_service.py _select_series + the single fill block in bind_dashboard_widgets
Canonical (key-order-independent) matching via canonical_dims DONE _select_series: key = canonical_dims(clean) compared against each row's canonical_dims(r.get("dims"))
No match → named refusal, never a fallback to the fold DONE metric_series_not_found with binding_detail facts; no branch with a non-empty selector reaches _fold_source
No dims: → unchanged fold, and a multi-series fold says so DONE _fold_source + bound_series → boundSeriesNote caption ("sum of 3 channel values", "newest of 3: …")
Number and sparkline describe the same series DONE both come from one source (source["value"] / source["buckets"])
Dim keys validated against declared dimensions DONE parse_dims_selector → the write path's validate_dims, named refusals metric_dimension_undeclared / metric_dimension_invalid
Frontend renders the series identity DONE BoundMetricMark.vue seriesNote, utils/metricFormat.js::boundSeriesNote/formatDims
Thresholds/status colours applied to the resolved value CHANGED colour still comes from _status_color / _threshold_color, now fed the selected value; _threshold_color was refactored onto a new _threshold_verdict so the colour and the new verdict badge share one rule

The adjacent route-level dims selector (GET /api/agents/{name}/metrics, MCP get_metrics) is explicitly deferred to abilityai/trinity-enterprise#828, and the issue framed it as "the cheap other half", not an AC.

Execution coverage (Step 2.5)

changed symbol / test file executed by live consumer verdict
metric_read_service.parse_dims_selector test_ent730_bound_widget_dims.py (incl. a Hypothesis totality property), test_ent730_dims_compat.py _select_series → bind_dashboard_widgets; static_checks.c_x009 ✅ executed
_select_series / _fold_source / _refuse_widget test_ent730_bound_widget_dims.py bind_dashboard_widgets ← agent_service/dashboard.py:296,347 ✅ executed
_threshold_verdict / _judgeable / threshold_verdict field T20–T22 in test_ent730_bound_widget_dims.py; mounted panel spec BoundMetricMark.vue via verdictBadge ✅ executed
invalid_metric_name test_a_non_text_metric_refuses_that_widget_only bind_dashboard_widgets, c_x009 ✅ executed
metric_points_service.validate_dims (renamed from private) existing ent#478 validation tests + new tests validate_batch, parse_dims_selector ✅ executed
static_checks.c_x009 / _as_wire test_ent730_dims_compat.py, test_compatibility_checks.py (catalog 91 → 92) STATIC_CHECKS["X-009"] ✅ executed
metricFormat.js (formatDims, boundSeriesNote, refusalHint, verdictBadge, VERDICT_WORDS) metricFormat.spec.js BoundMetricMark.vue, DashboardPanel.vue ✅ executed
BoundMetricMark.vue / DashboardPanel.vue (getTrendColor, getSparklineColor, isBoundToMetric) dashboardPanelBoundWidget.spec.js (@vitest-environment jsdom, mounts the real DashboardPanel) rendered dashboard ✅ executed
test_the_user_doc_recipe_shows_three_different_numbers (read_text() over the user doc) — the recipe lines are asserted present in the doc AND the same YAML is run through the real agent-server validate_widget and bind_dashboard_widgets 🛡 guard + executed

Fix mutation (the two fixes bundled with the feature), reproduced locally:

  • Removed widget.pop("value", None) from the store-outage arm → test_T19_an_outage_drops_the_authors_placeholder_value and test_T19_the_cached_dashboard_path_drops_it_too go red.
  • Disabled the invalid_metric_name refusal → all 4 cases of test_a_non_text_metric_refuses_that_widget_only go red.
  • Feature mutation: made an unmatched selector fall back to the fold (the exact ent#730 harm) → 12 tests go red.

Mutations were applied to the committed file and reverted with git checkout --; the branch is unchanged.

Local runs

  • Backend: the ent#730 suites, test_compatibility_checks.py, and the touched-module neighbours (ent#477 template metrics, ent#478 validation, ent#479 binding/route, ent#666 objective join, ent#727, ent#729, test_1965_agent_server_safe_yaml.py): 681 passed, 1 skipped (random order). The ent#730 suites alone: 122 passed, 0 skipped. The agent-server validate_widget harness was importable, so the recipe test was not skipped.
  • Frontend: npx vitest run → 280 files / 4905 tests passed, including the raw-colour, loading-gate and source-text ratchets.
  • Open-core check: the enterprise-docs-guard pattern matches no added line in docs/, and no customer or instance names from the issue report appear anywhere in the diff.

Critical Findings (block merge)

None.

Informational Findings (review required)

[I1] Scope: the verdict badge and direction-driven trend colours go beyond ent#730 (Confidence: 8/10)
Files: metric_read_service.py (_threshold_verdict, _judgeable, widget["direction"] = entry.get("direction") or "neutral"), DashboardPanel.vue (getTrendColor(trend, widget) → trendClasses(trend, widget.direction)), BoundMetricMark.vue (BaseBadge verdict).
Issue: The issue asks for series selection plus correct colours fed by the resolved value. The typed threshold_verdict badge, reserved badge footprint, and per-direction trend/sparkline colours are new UX that changes every existing bound dashboard without author action. The PR body states this openly, and it is well tested, so this is not a defect. It does widen the blast radius of a P1 bug fix, though.
Suggestion: No change required. Make sure whoever merges is fine shipping the visible change alongside the fix. Splitting it out would have been the more minimal shape.

[I2] Behaviour: a successful bind now drops the author's trend / trend_value on the fold path too (Confidence: 7/10)
File: metric_read_service.py, bind_dashboard_widgets: widget.pop("trend", None) / widget.pop("trend_value", None) run on every successful bind, not only the dims: path.
Issue: The user doc says an author trend: "is replaced by the computed trend once the binding resolves". But when a bound metric has no chart buckets (source["buckets"] is None), no history is written, so the author's arrow is removed and nothing replaces it. That is consistent with "a successful bind owns the trend", but it is a small, quiet change on the fold path, which AC4 describes as unchanged.
Suggestion: Keep the behaviour, and change the doc sentence to "is ignored on a bound tile" so it also covers the no-history case.

[I3] Known limit: a selected series is looked up only inside the bounded latest-points read (Confidence: 9/10)
File: metric_read_service.py, _select_series. The lookup runs over latest_by_series / series, which come from LATEST_POINTS_PER_METRIC = 200 points and MAX_SERIES_PER_METRIC = 50.
Issue: A channel that reports rarely beside a busy one can show metric_series_not_found even though it has data. The refusal is honest about it (it says "no recent data", and binding_detail.window_points / series_cap feed the hint), and the PR links follow-up #3293.
Suggestion: None for this PR. Make sure #3293 is prioritised with ent#730's P1 in mind, because the bug report's own scenario (per-channel spend tiles) is the shape most likely to hit this.

[I4] Design system: the new not-found row reuses the hand-rolled gray pill (Confidence: 6/10)
File: BoundMetricMark.vue, the notFound template block (rounded-full bg-gray-100 … text-gray-600 dark:bg-gray-700 dark:text-gray-300).
Issue: design-system-contract.md prefers primitives over hand-rolled lookalikes. The ratchet passes because gray is exempt. The PR lists this as pre-existing (it matches the ent#479 chip), which is a reasonable choice for visual consistency.
Suggestion: Consider a follow-up that moves both chips onto BaseBadge together.

Clean Categories

  • SQL & data safety: no SQL, schema or migration changes. The binding is read-only over read_agent_metrics.
  • Race conditions: the cached path binds a copy.deepcopy of the cache (agent_service/dashboard.py), and the live path binds a freshly fetched config, so refusal arms cannot leave stale keys on a shared object.
  • Auth boundaries: no new endpoints. X-009 surfaces through the existing compatibility report.
  • Credential exposure: none. X-009's failure detail records only type(e).__name__ (the D-009 convention), and echoed keys go through _clip / _safe_echo.
  • Totality of the per-widget loop (it runs outside the store try): non-text metric: is refused before by_name.get, and parse_dims_selector is total over arbitrary input (Hypothesis property). The previous whole-dashboard TypeError is fixed and pinned by a mutation-verified test.
  • Enterprise disclosure in public docs: guard pattern clean. The PR is OSS-core with no entitlement involved.
  • Enum completeness: the new binding_error_code values are handled in DIMS_REFUSAL_CODES, BoundMetricMark (not-found arm, then the generic binding_error arm), and documented in requirements and the user doc. X-009 is registered in both spec.py and STATIC_CHECKS, and the catalog count is updated.
  • Documentation staleness: requirements (lifecycle-observability), architecture/backend.md, architecture/frontend.md, both feature flows, the index, the user doc and agent-validation-spec.md are all updated in the diff.

Low confidence (appendix)

  • DIMS_DOCS_URL points at …/guides/dynamic-dashboards#one-series-per-tile-dims. The anchor assumes the docs site slugs the heading One series per tile: `dims:` to one-series-per-tile-dims. I could not verify that from the repo. A wrong slug only lands on the page top. (Confidence: 4/10)

Summary

  • Critical: 0 (none found)
  • Informational: 4 (review recommended, none blocking)
  • Scope: drift (verdict badge and direction colours), disclosed in the PR body

🤖 Generated with Claude Code

@dolho dolho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — /review found no blocking findings; see review comment above.

@vybe

vybe commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

merge-train (2026-10-07): changed the body's Refs to Fixes — validation found every acceptance criterion met (follow-ups are already filed separately), so the issue should close/promote on merge. Mechanical; nothing pushed to the branch.

@vybe
vybe merged commit f60008b into dev Oct 7, 2026
25 checks passed
vybe added a commit that referenced this pull request Oct 7, 2026
Four user-docs files conflicted with this train's siblings (#3294, #3297,
#3299, #3303), which documented their own changes on the same lines. Each
hunk keeps both sides' facts once: dev's new text, plus this sync's
additions (wired-boundaries list, receipt contract, new FAQ entries,
bound-widget field rules).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vybe

vybe commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

merge-train (2026-10-07): merged as part of train #3329 (green). Before the squash, dev was merged into this branch because an earlier sibling had appended to the same files (tests/registry.json and/or docs/memory/feature-flows*.md). Both sides were kept, nothing else was changed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ui PR touches the frontend UI — triggers Playwright e2e tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants