A dashboard widget's OWN filter keys and options.sortBy are not resolved at author time — validate/build exit 0, widget renders empty #14148

Description

@os-warren

Measured on @objectstack/cli / @objectstack/spec17.2.0 while building a manager dashboard in the duly app (objectstack-ai/duly#10). Each mutation was applied to a real, shipping dashboard, confirmed on disk, and both gates re-run.

The interesting part is how narrow the remaining gap is: almost everything on this surface is already checked, including the sibling of one of the two misses.

What IS caught (no action needed — recorded so the gap below is precise)

mutated referencevalidatebuildrule
widgets[].datasetduly_stagnatoin11widget-dataset-unknown (+ "did you mean")
widgets[].dimensions[]business_unitt11widget-dimension-unknown
widgets[].values[]untouched_over_1411widget-measure-unknown
{token} inside widgets[].filter{14_days_hence}11filter-token-unknown, path-precise at dashboards[0].widgets[4].filter.due_date.$lte
dashboard-level dateRange.fieldnonexistent_column11dashboard-filter-field-unknown (#3365 / #3382)
nav dashboardNameduly_ghost12defineStack cross-reference validation

A — widgets[].filter keys are not resolved

// widget bound to dataset `duly_workload`, base object `duly_task`filter: {due_daet: {$gte: '{today}',$lte: '{14_days_from_now}'}},// ^^^^^^^^ no such column on duly_task

validate0. build0. The condition reaches the engine, matches nothing, and the widget renders empty.

Two things make this sharper than a generic missing check:

  1. On the same node, the TOKEN is checked and the COLUMN is not.filter-token-unknown fires with the exact path …widgets[4].filter.due_date.$lte, so the traversal already walks into the filter tree and already knows the widget's dataset. Only the key resolution is missing.

  2. The identical resolution already exists one key over.dashboard-filter-field-unknown (Validate dashboard filter field-existence at build time (extend ADR-0021) #3365, shipped in feat(lint): validate dashboard filter field-existence at build time (#3365) #3382) resolves a dashboard-level filter's field against each widget's dataset base object, and its message even prints that object's field list:

    dashboard "duly_duty_health" › widget "not_moving_14d": inherits dashboard filter dateRange(nonexistent_column), but object duly_task (dataset "duly_stagnation") has no field nonexistent_column. … Object fields: subject, duty, owner, business_unit, …

    So dataset → base object → field set is already computed per widget at that point. widgets[].filter is the same field-existence invariant on the filter an author is more likely to write by hand, and it was simply not fed through the same rule.

B — options.sortBy is not checked against what the widget selects

dimensions: ['business_unit'],values: ['untouched_over_14d'],options: {sortBy: 'not_selected',sortOrder: 'asc'},

validate0. build0. The spec states the contract in its own words — "Order rows by this dimension or measure name — must be one this widget actually selects" (DashboardWidgetOptionsSchema.sortBy) — and DashboardWidgetOptions is one of the four keys deliberately declared because they reach the analytics query (framework#3588). The runtime falls back to ordering by the selected dimensions, so the authored order silently does not happen.

Why it matters beyond tidiness: for us the authored order is a product rule. A per-unit chart must be ordered by the unit dimension and never by the count, because ordering units by a count turns a workload chart into a league table. Today nothing but a hand-written repo test keeps that from silently degrading into "whatever the runtime picked".

Why silent-empty is the expensive failure here

The dashboard this came from leads with a "not moving" tile — open work untouched >14 days. An empty tile is indistinguishable from a healthy team: a missing number reads as zero, and zero is the answer the manager is hoping for. Every other class of authoring mistake on this surface already fails loudly at build; these two are the ones that ship.

Suggested fix

Both are one pass at the same site that already emits widget-dataset-unknown / widget-dimension-unknown / dashboard-filter-field-unknown:

  • A — for each widget with a filter, walk the condition's field KEYS (descending $and / $or / $not, skipping $-prefixed operator keys) and resolve each against the widget's dataset base object plus the platform system columns. Suggested rule id widget-filter-field-unknown, message shaped like dashboard-filter-field-unknown so the two read alike. A dotted path through a declared include should resolve or be explicitly out of scope, not silently pass.
  • B — error unless options.sortBy is in dimensions[] ∪ values[] for that widget. Suggested widget-sortby-unselected, listing what the widget does select.

Acceptance: both fail validate and build naming dashboard, widget, key and object; fixture coverage for the failing and the clean shape.

Happy to take either one if useful — the shape is mechanical and mirrors #3382.

Repo-local stopgap meanwhile: test/dashboard.test.ts in objectstack-ai/duly resolves both against the datasets barrel, and is written to be deleted when this lands.


Generated by Claude Code

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

    , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
     blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
    }
    } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
    })();
    (function(){
    try {
    var __m = "github.com";
    var __re = new RegExp('^' + "github\\.com" + '
    
    Skip to content

    A dashboard widget's OWN filter keys and options.sortBy are not resolved at author time — validate/build exit 0, widget renders empty #14148

    Description

    @os-warren

    Measured on @objectstack/cli / @objectstack/spec17.2.0 while building a manager dashboard in the duly app (objectstack-ai/duly#10). Each mutation was applied to a real, shipping dashboard, confirmed on disk, and both gates re-run.

    The interesting part is how narrow the remaining gap is: almost everything on this surface is already checked, including the sibling of one of the two misses.

    What IS caught (no action needed — recorded so the gap below is precise)

    mutated referencevalidatebuildrule
    widgets[].datasetduly_stagnatoin11widget-dataset-unknown (+ "did you mean")
    widgets[].dimensions[]business_unitt11widget-dimension-unknown
    widgets[].values[]untouched_over_1411widget-measure-unknown
    {token} inside widgets[].filter{14_days_hence}11filter-token-unknown, path-precise at dashboards[0].widgets[4].filter.due_date.$lte
    dashboard-level dateRange.fieldnonexistent_column11dashboard-filter-field-unknown (#3365 / #3382)
    nav dashboardNameduly_ghost12defineStack cross-reference validation

    A — widgets[].filter keys are not resolved

    // widget bound to dataset `duly_workload`, base object `duly_task`filter: {due_daet: {$gte: '{today}',$lte: '{14_days_from_now}'}},// ^^^^^^^^ no such column on duly_task

    validate0. build0. The condition reaches the engine, matches nothing, and the widget renders empty.

    Two things make this sharper than a generic missing check:

    1. On the same node, the TOKEN is checked and the COLUMN is not.filter-token-unknown fires with the exact path …widgets[4].filter.due_date.$lte, so the traversal already walks into the filter tree and already knows the widget's dataset. Only the key resolution is missing.

    2. The identical resolution already exists one key over.dashboard-filter-field-unknown (Validate dashboard filter field-existence at build time (extend ADR-0021) #3365, shipped in feat(lint): validate dashboard filter field-existence at build time (#3365) #3382) resolves a dashboard-level filter's field against each widget's dataset base object, and its message even prints that object's field list:

      dashboard "duly_duty_health" › widget "not_moving_14d": inherits dashboard filter dateRange(nonexistent_column), but object duly_task (dataset "duly_stagnation") has no field nonexistent_column. … Object fields: subject, duty, owner, business_unit, …

      So dataset → base object → field set is already computed per widget at that point. widgets[].filter is the same field-existence invariant on the filter an author is more likely to write by hand, and it was simply not fed through the same rule.

    B — options.sortBy is not checked against what the widget selects

    dimensions: ['business_unit'],values: ['untouched_over_14d'],options: {sortBy: 'not_selected',sortOrder: 'asc'},

    validate0. build0. The spec states the contract in its own words — "Order rows by this dimension or measure name — must be one this widget actually selects" (DashboardWidgetOptionsSchema.sortBy) — and DashboardWidgetOptions is one of the four keys deliberately declared because they reach the analytics query (framework#3588). The runtime falls back to ordering by the selected dimensions, so the authored order silently does not happen.

    Why it matters beyond tidiness: for us the authored order is a product rule. A per-unit chart must be ordered by the unit dimension and never by the count, because ordering units by a count turns a workload chart into a league table. Today nothing but a hand-written repo test keeps that from silently degrading into "whatever the runtime picked".

    Why silent-empty is the expensive failure here

    The dashboard this came from leads with a "not moving" tile — open work untouched >14 days. An empty tile is indistinguishable from a healthy team: a missing number reads as zero, and zero is the answer the manager is hoping for. Every other class of authoring mistake on this surface already fails loudly at build; these two are the ones that ship.

    Suggested fix

    Both are one pass at the same site that already emits widget-dataset-unknown / widget-dimension-unknown / dashboard-filter-field-unknown:

    • A — for each widget with a filter, walk the condition's field KEYS (descending $and / $or / $not, skipping $-prefixed operator keys) and resolve each against the widget's dataset base object plus the platform system columns. Suggested rule id widget-filter-field-unknown, message shaped like dashboard-filter-field-unknown so the two read alike. A dotted path through a declared include should resolve or be explicitly out of scope, not silently pass.
    • B — error unless options.sortBy is in dimensions[] ∪ values[] for that widget. Suggested widget-sortby-unselected, listing what the widget does select.

    Acceptance: both fail validate and build naming dashboard, widget, key and object; fixture coverage for the failing and the clean shape.

    Happy to take either one if useful — the shape is mechanical and mirrors #3382.

    Repo-local stopgap meanwhile: test/dashboard.test.ts in objectstack-ai/duly resolves both against the datasets barrel, and is written to be deleted when this lands.


    Generated by Claude Code

    Metadata

    Metadata

    Assignees

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
      Skip to content

      A dashboard widget's OWN filter keys and options.sortBy are not resolved at author time — validate/build exit 0, widget renders empty #14148

      Description

      @os-warren

      Measured on @objectstack/cli / @objectstack/spec17.2.0 while building a manager dashboard in the duly app (objectstack-ai/duly#10). Each mutation was applied to a real, shipping dashboard, confirmed on disk, and both gates re-run.

      The interesting part is how narrow the remaining gap is: almost everything on this surface is already checked, including the sibling of one of the two misses.

      What IS caught (no action needed — recorded so the gap below is precise)

      mutated referencevalidatebuildrule
      widgets[].datasetduly_stagnatoin11widget-dataset-unknown (+ "did you mean")
      widgets[].dimensions[]business_unitt11widget-dimension-unknown
      widgets[].values[]untouched_over_1411widget-measure-unknown
      {token} inside widgets[].filter{14_days_hence}11filter-token-unknown, path-precise at dashboards[0].widgets[4].filter.due_date.$lte
      dashboard-level dateRange.fieldnonexistent_column11dashboard-filter-field-unknown (#3365 / #3382)
      nav dashboardNameduly_ghost12defineStack cross-reference validation

      A — widgets[].filter keys are not resolved

      // widget bound to dataset `duly_workload`, base object `duly_task`filter: {due_daet: {$gte: '{today}',$lte: '{14_days_from_now}'}},// ^^^^^^^^ no such column on duly_task

      validate0. build0. The condition reaches the engine, matches nothing, and the widget renders empty.

      Two things make this sharper than a generic missing check:

      1. On the same node, the TOKEN is checked and the COLUMN is not.filter-token-unknown fires with the exact path …widgets[4].filter.due_date.$lte, so the traversal already walks into the filter tree and already knows the widget's dataset. Only the key resolution is missing.

      2. The identical resolution already exists one key over.dashboard-filter-field-unknown (Validate dashboard filter field-existence at build time (extend ADR-0021) #3365, shipped in feat(lint): validate dashboard filter field-existence at build time (#3365) #3382) resolves a dashboard-level filter's field against each widget's dataset base object, and its message even prints that object's field list:

        dashboard "duly_duty_health" › widget "not_moving_14d": inherits dashboard filter dateRange(nonexistent_column), but object duly_task (dataset "duly_stagnation") has no field nonexistent_column. … Object fields: subject, duty, owner, business_unit, …

        So dataset → base object → field set is already computed per widget at that point. widgets[].filter is the same field-existence invariant on the filter an author is more likely to write by hand, and it was simply not fed through the same rule.

      B — options.sortBy is not checked against what the widget selects

      dimensions: ['business_unit'],values: ['untouched_over_14d'],options: {sortBy: 'not_selected',sortOrder: 'asc'},

      validate0. build0. The spec states the contract in its own words — "Order rows by this dimension or measure name — must be one this widget actually selects" (DashboardWidgetOptionsSchema.sortBy) — and DashboardWidgetOptions is one of the four keys deliberately declared because they reach the analytics query (framework#3588). The runtime falls back to ordering by the selected dimensions, so the authored order silently does not happen.

      Why it matters beyond tidiness: for us the authored order is a product rule. A per-unit chart must be ordered by the unit dimension and never by the count, because ordering units by a count turns a workload chart into a league table. Today nothing but a hand-written repo test keeps that from silently degrading into "whatever the runtime picked".

      Why silent-empty is the expensive failure here

      The dashboard this came from leads with a "not moving" tile — open work untouched >14 days. An empty tile is indistinguishable from a healthy team: a missing number reads as zero, and zero is the answer the manager is hoping for. Every other class of authoring mistake on this surface already fails loudly at build; these two are the ones that ship.

      Suggested fix

      Both are one pass at the same site that already emits widget-dataset-unknown / widget-dimension-unknown / dashboard-filter-field-unknown:

      • A — for each widget with a filter, walk the condition's field KEYS (descending $and / $or / $not, skipping $-prefixed operator keys) and resolve each against the widget's dataset base object plus the platform system columns. Suggested rule id widget-filter-field-unknown, message shaped like dashboard-filter-field-unknown so the two read alike. A dotted path through a declared include should resolve or be explicitly out of scope, not silently pass.
      • B — error unless options.sortBy is in dimensions[] ∪ values[] for that widget. Suggested widget-sortby-unselected, listing what the widget does select.

      Acceptance: both fail validate and build naming dashboard, widget, key and object; fixture coverage for the failing and the clean shape.

      Happy to take either one if useful — the shape is mechanical and mirrors #3382.

      Repo-local stopgap meanwhile: test/dashboard.test.ts in objectstack-ai/duly resolves both against the datasets barrel, and is written to be deleted when this lands.


      Generated by Claude Code

      Metadata

      Metadata

      Assignees

      Type

      Projects

      No projects

        Milestone

        No milestone

        Relationships

        None yet

        Development

        No branches or pull requests

        Issue actions

        , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
        Skip to content

        A dashboard widget's OWN filter keys and options.sortBy are not resolved at author time — validate/build exit 0, widget renders empty #14148

        Description

        @os-warren

        Measured on @objectstack/cli / @objectstack/spec17.2.0 while building a manager dashboard in the duly app (objectstack-ai/duly#10). Each mutation was applied to a real, shipping dashboard, confirmed on disk, and both gates re-run.

        The interesting part is how narrow the remaining gap is: almost everything on this surface is already checked, including the sibling of one of the two misses.

        What IS caught (no action needed — recorded so the gap below is precise)

        mutated referencevalidatebuildrule
        widgets[].datasetduly_stagnatoin11widget-dataset-unknown (+ "did you mean")
        widgets[].dimensions[]business_unitt11widget-dimension-unknown
        widgets[].values[]untouched_over_1411widget-measure-unknown
        {token} inside widgets[].filter{14_days_hence}11filter-token-unknown, path-precise at dashboards[0].widgets[4].filter.due_date.$lte
        dashboard-level dateRange.fieldnonexistent_column11dashboard-filter-field-unknown (#3365 / #3382)
        nav dashboardNameduly_ghost12defineStack cross-reference validation

        A — widgets[].filter keys are not resolved

        // widget bound to dataset `duly_workload`, base object `duly_task`filter: {due_daet: {$gte: '{today}',$lte: '{14_days_from_now}'}},// ^^^^^^^^ no such column on duly_task

        validate0. build0. The condition reaches the engine, matches nothing, and the widget renders empty.

        Two things make this sharper than a generic missing check:

        1. On the same node, the TOKEN is checked and the COLUMN is not.filter-token-unknown fires with the exact path …widgets[4].filter.due_date.$lte, so the traversal already walks into the filter tree and already knows the widget's dataset. Only the key resolution is missing.

        2. The identical resolution already exists one key over.dashboard-filter-field-unknown (Validate dashboard filter field-existence at build time (extend ADR-0021) #3365, shipped in feat(lint): validate dashboard filter field-existence at build time (#3365) #3382) resolves a dashboard-level filter's field against each widget's dataset base object, and its message even prints that object's field list:

          dashboard "duly_duty_health" › widget "not_moving_14d": inherits dashboard filter dateRange(nonexistent_column), but object duly_task (dataset "duly_stagnation") has no field nonexistent_column. … Object fields: subject, duty, owner, business_unit, …

          So dataset → base object → field set is already computed per widget at that point. widgets[].filter is the same field-existence invariant on the filter an author is more likely to write by hand, and it was simply not fed through the same rule.

        B — options.sortBy is not checked against what the widget selects

        dimensions: ['business_unit'],values: ['untouched_over_14d'],options: {sortBy: 'not_selected',sortOrder: 'asc'},

        validate0. build0. The spec states the contract in its own words — "Order rows by this dimension or measure name — must be one this widget actually selects" (DashboardWidgetOptionsSchema.sortBy) — and DashboardWidgetOptions is one of the four keys deliberately declared because they reach the analytics query (framework#3588). The runtime falls back to ordering by the selected dimensions, so the authored order silently does not happen.

        Why it matters beyond tidiness: for us the authored order is a product rule. A per-unit chart must be ordered by the unit dimension and never by the count, because ordering units by a count turns a workload chart into a league table. Today nothing but a hand-written repo test keeps that from silently degrading into "whatever the runtime picked".

        Why silent-empty is the expensive failure here

        The dashboard this came from leads with a "not moving" tile — open work untouched >14 days. An empty tile is indistinguishable from a healthy team: a missing number reads as zero, and zero is the answer the manager is hoping for. Every other class of authoring mistake on this surface already fails loudly at build; these two are the ones that ship.

        Suggested fix

        Both are one pass at the same site that already emits widget-dataset-unknown / widget-dimension-unknown / dashboard-filter-field-unknown:

        • A — for each widget with a filter, walk the condition's field KEYS (descending $and / $or / $not, skipping $-prefixed operator keys) and resolve each against the widget's dataset base object plus the platform system columns. Suggested rule id widget-filter-field-unknown, message shaped like dashboard-filter-field-unknown so the two read alike. A dotted path through a declared include should resolve or be explicitly out of scope, not silently pass.
        • B — error unless options.sortBy is in dimensions[] ∪ values[] for that widget. Suggested widget-sortby-unselected, listing what the widget does select.

        Acceptance: both fail validate and build naming dashboard, widget, key and object; fixture coverage for the failing and the clean shape.

        Happy to take either one if useful — the shape is mechanical and mirrors #3382.

        Repo-local stopgap meanwhile: test/dashboard.test.ts in objectstack-ai/duly resolves both against the datasets barrel, and is written to be deleted when this lands.


        Generated by Claude Code

        Metadata

        Metadata

        Assignees

        Type

        Projects

        No projects

          Milestone

          No milestone

          Relationships

          None yet

          Development

          No branches or pull requests

          Issue actions

          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
          Skip to content

          A dashboard widget's OWN filter keys and options.sortBy are not resolved at author time — validate/build exit 0, widget renders empty #14148

          Description

          @os-warren

          Measured on @objectstack/cli / @objectstack/spec17.2.0 while building a manager dashboard in the duly app (objectstack-ai/duly#10). Each mutation was applied to a real, shipping dashboard, confirmed on disk, and both gates re-run.

          The interesting part is how narrow the remaining gap is: almost everything on this surface is already checked, including the sibling of one of the two misses.

          What IS caught (no action needed — recorded so the gap below is precise)

          mutated referencevalidatebuildrule
          widgets[].datasetduly_stagnatoin11widget-dataset-unknown (+ "did you mean")
          widgets[].dimensions[]business_unitt11widget-dimension-unknown
          widgets[].values[]untouched_over_1411widget-measure-unknown
          {token} inside widgets[].filter{14_days_hence}11filter-token-unknown, path-precise at dashboards[0].widgets[4].filter.due_date.$lte
          dashboard-level dateRange.fieldnonexistent_column11dashboard-filter-field-unknown (#3365 / #3382)
          nav dashboardNameduly_ghost12defineStack cross-reference validation

          A — widgets[].filter keys are not resolved

          // widget bound to dataset `duly_workload`, base object `duly_task`filter: {due_daet: {$gte: '{today}',$lte: '{14_days_from_now}'}},// ^^^^^^^^ no such column on duly_task

          validate0. build0. The condition reaches the engine, matches nothing, and the widget renders empty.

          Two things make this sharper than a generic missing check:

          1. On the same node, the TOKEN is checked and the COLUMN is not.filter-token-unknown fires with the exact path …widgets[4].filter.due_date.$lte, so the traversal already walks into the filter tree and already knows the widget's dataset. Only the key resolution is missing.

          2. The identical resolution already exists one key over.dashboard-filter-field-unknown (Validate dashboard filter field-existence at build time (extend ADR-0021) #3365, shipped in feat(lint): validate dashboard filter field-existence at build time (#3365) #3382) resolves a dashboard-level filter's field against each widget's dataset base object, and its message even prints that object's field list:

            dashboard "duly_duty_health" › widget "not_moving_14d": inherits dashboard filter dateRange(nonexistent_column), but object duly_task (dataset "duly_stagnation") has no field nonexistent_column. … Object fields: subject, duty, owner, business_unit, …

            So dataset → base object → field set is already computed per widget at that point. widgets[].filter is the same field-existence invariant on the filter an author is more likely to write by hand, and it was simply not fed through the same rule.

          B — options.sortBy is not checked against what the widget selects

          dimensions: ['business_unit'],values: ['untouched_over_14d'],options: {sortBy: 'not_selected',sortOrder: 'asc'},

          validate0. build0. The spec states the contract in its own words — "Order rows by this dimension or measure name — must be one this widget actually selects" (DashboardWidgetOptionsSchema.sortBy) — and DashboardWidgetOptions is one of the four keys deliberately declared because they reach the analytics query (framework#3588). The runtime falls back to ordering by the selected dimensions, so the authored order silently does not happen.

          Why it matters beyond tidiness: for us the authored order is a product rule. A per-unit chart must be ordered by the unit dimension and never by the count, because ordering units by a count turns a workload chart into a league table. Today nothing but a hand-written repo test keeps that from silently degrading into "whatever the runtime picked".

          Why silent-empty is the expensive failure here

          The dashboard this came from leads with a "not moving" tile — open work untouched >14 days. An empty tile is indistinguishable from a healthy team: a missing number reads as zero, and zero is the answer the manager is hoping for. Every other class of authoring mistake on this surface already fails loudly at build; these two are the ones that ship.

          Suggested fix

          Both are one pass at the same site that already emits widget-dataset-unknown / widget-dimension-unknown / dashboard-filter-field-unknown:

          • A — for each widget with a filter, walk the condition's field KEYS (descending $and / $or / $not, skipping $-prefixed operator keys) and resolve each against the widget's dataset base object plus the platform system columns. Suggested rule id widget-filter-field-unknown, message shaped like dashboard-filter-field-unknown so the two read alike. A dotted path through a declared include should resolve or be explicitly out of scope, not silently pass.
          • B — error unless options.sortBy is in dimensions[] ∪ values[] for that widget. Suggested widget-sortby-unselected, listing what the widget does select.

          Acceptance: both fail validate and build naming dashboard, widget, key and object; fixture coverage for the failing and the clean shape.

          Happy to take either one if useful — the shape is mechanical and mirrors #3382.

          Repo-local stopgap meanwhile: test/dashboard.test.ts in objectstack-ai/duly resolves both against the datasets barrel, and is written to be deleted when this lands.


          Generated by Claude Code

          Metadata

          Metadata

          Assignees

          Type

          Projects

          No projects

            Milestone

            No milestone

            Relationships

            None yet

            Development

            No branches or pull requests

            Issue actions

            , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
            Skip to content

            A dashboard widget's OWN filter keys and options.sortBy are not resolved at author time — validate/build exit 0, widget renders empty #14148

            Description

            @os-warren

            Measured on @objectstack/cli / @objectstack/spec17.2.0 while building a manager dashboard in the duly app (objectstack-ai/duly#10). Each mutation was applied to a real, shipping dashboard, confirmed on disk, and both gates re-run.

            The interesting part is how narrow the remaining gap is: almost everything on this surface is already checked, including the sibling of one of the two misses.

            What IS caught (no action needed — recorded so the gap below is precise)

            mutated referencevalidatebuildrule
            widgets[].datasetduly_stagnatoin11widget-dataset-unknown (+ "did you mean")
            widgets[].dimensions[]business_unitt11widget-dimension-unknown
            widgets[].values[]untouched_over_1411widget-measure-unknown
            {token} inside widgets[].filter{14_days_hence}11filter-token-unknown, path-precise at dashboards[0].widgets[4].filter.due_date.$lte
            dashboard-level dateRange.fieldnonexistent_column11dashboard-filter-field-unknown (#3365 / #3382)
            nav dashboardNameduly_ghost12defineStack cross-reference validation

            A — widgets[].filter keys are not resolved

            // widget bound to dataset `duly_workload`, base object `duly_task`filter: {due_daet: {$gte: '{today}',$lte: '{14_days_from_now}'}},// ^^^^^^^^ no such column on duly_task

            validate0. build0. The condition reaches the engine, matches nothing, and the widget renders empty.

            Two things make this sharper than a generic missing check:

            1. On the same node, the TOKEN is checked and the COLUMN is not.filter-token-unknown fires with the exact path …widgets[4].filter.due_date.$lte, so the traversal already walks into the filter tree and already knows the widget's dataset. Only the key resolution is missing.

            2. The identical resolution already exists one key over.dashboard-filter-field-unknown (Validate dashboard filter field-existence at build time (extend ADR-0021) #3365, shipped in feat(lint): validate dashboard filter field-existence at build time (#3365) #3382) resolves a dashboard-level filter's field against each widget's dataset base object, and its message even prints that object's field list:

              dashboard "duly_duty_health" › widget "not_moving_14d": inherits dashboard filter dateRange(nonexistent_column), but object duly_task (dataset "duly_stagnation") has no field nonexistent_column. … Object fields: subject, duty, owner, business_unit, …

              So dataset → base object → field set is already computed per widget at that point. widgets[].filter is the same field-existence invariant on the filter an author is more likely to write by hand, and it was simply not fed through the same rule.

            B — options.sortBy is not checked against what the widget selects

            dimensions: ['business_unit'],values: ['untouched_over_14d'],options: {sortBy: 'not_selected',sortOrder: 'asc'},

            validate0. build0. The spec states the contract in its own words — "Order rows by this dimension or measure name — must be one this widget actually selects" (DashboardWidgetOptionsSchema.sortBy) — and DashboardWidgetOptions is one of the four keys deliberately declared because they reach the analytics query (framework#3588). The runtime falls back to ordering by the selected dimensions, so the authored order silently does not happen.

            Why it matters beyond tidiness: for us the authored order is a product rule. A per-unit chart must be ordered by the unit dimension and never by the count, because ordering units by a count turns a workload chart into a league table. Today nothing but a hand-written repo test keeps that from silently degrading into "whatever the runtime picked".

            Why silent-empty is the expensive failure here

            The dashboard this came from leads with a "not moving" tile — open work untouched >14 days. An empty tile is indistinguishable from a healthy team: a missing number reads as zero, and zero is the answer the manager is hoping for. Every other class of authoring mistake on this surface already fails loudly at build; these two are the ones that ship.

            Suggested fix

            Both are one pass at the same site that already emits widget-dataset-unknown / widget-dimension-unknown / dashboard-filter-field-unknown:

            • A — for each widget with a filter, walk the condition's field KEYS (descending $and / $or / $not, skipping $-prefixed operator keys) and resolve each against the widget's dataset base object plus the platform system columns. Suggested rule id widget-filter-field-unknown, message shaped like dashboard-filter-field-unknown so the two read alike. A dotted path through a declared include should resolve or be explicitly out of scope, not silently pass.
            • B — error unless options.sortBy is in dimensions[] ∪ values[] for that widget. Suggested widget-sortby-unselected, listing what the widget does select.

            Acceptance: both fail validate and build naming dashboard, widget, key and object; fixture coverage for the failing and the clean shape.

            Happy to take either one if useful — the shape is mechanical and mirrors #3382.

            Repo-local stopgap meanwhile: test/dashboard.test.ts in objectstack-ai/duly resolves both against the datasets barrel, and is written to be deleted when this lands.


            Generated by Claude Code

            Metadata

            Metadata

            Assignees

            Type

            Projects

            No projects

              Milestone

              No milestone

              Relationships

              None yet

              Development

              No branches or pull requests

              Issue actions

              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
              Skip to content

              A dashboard widget's OWN filter keys and options.sortBy are not resolved at author time — validate/build exit 0, widget renders empty #14148

              Description

              @os-warren

              Measured on @objectstack/cli / @objectstack/spec17.2.0 while building a manager dashboard in the duly app (objectstack-ai/duly#10). Each mutation was applied to a real, shipping dashboard, confirmed on disk, and both gates re-run.

              The interesting part is how narrow the remaining gap is: almost everything on this surface is already checked, including the sibling of one of the two misses.

              What IS caught (no action needed — recorded so the gap below is precise)

              mutated referencevalidatebuildrule
              widgets[].datasetduly_stagnatoin11widget-dataset-unknown (+ "did you mean")
              widgets[].dimensions[]business_unitt11widget-dimension-unknown
              widgets[].values[]untouched_over_1411widget-measure-unknown
              {token} inside widgets[].filter{14_days_hence}11filter-token-unknown, path-precise at dashboards[0].widgets[4].filter.due_date.$lte
              dashboard-level dateRange.fieldnonexistent_column11dashboard-filter-field-unknown (#3365 / #3382)
              nav dashboardNameduly_ghost12defineStack cross-reference validation

              A — widgets[].filter keys are not resolved

              // widget bound to dataset `duly_workload`, base object `duly_task`filter: {due_daet: {$gte: '{today}',$lte: '{14_days_from_now}'}},// ^^^^^^^^ no such column on duly_task

              validate0. build0. The condition reaches the engine, matches nothing, and the widget renders empty.

              Two things make this sharper than a generic missing check:

              1. On the same node, the TOKEN is checked and the COLUMN is not.filter-token-unknown fires with the exact path …widgets[4].filter.due_date.$lte, so the traversal already walks into the filter tree and already knows the widget's dataset. Only the key resolution is missing.

              2. The identical resolution already exists one key over.dashboard-filter-field-unknown (Validate dashboard filter field-existence at build time (extend ADR-0021) #3365, shipped in feat(lint): validate dashboard filter field-existence at build time (#3365) #3382) resolves a dashboard-level filter's field against each widget's dataset base object, and its message even prints that object's field list:

                dashboard "duly_duty_health" › widget "not_moving_14d": inherits dashboard filter dateRange(nonexistent_column), but object duly_task (dataset "duly_stagnation") has no field nonexistent_column. … Object fields: subject, duty, owner, business_unit, …

                So dataset → base object → field set is already computed per widget at that point. widgets[].filter is the same field-existence invariant on the filter an author is more likely to write by hand, and it was simply not fed through the same rule.

              B — options.sortBy is not checked against what the widget selects

              dimensions: ['business_unit'],values: ['untouched_over_14d'],options: {sortBy: 'not_selected',sortOrder: 'asc'},

              validate0. build0. The spec states the contract in its own words — "Order rows by this dimension or measure name — must be one this widget actually selects" (DashboardWidgetOptionsSchema.sortBy) — and DashboardWidgetOptions is one of the four keys deliberately declared because they reach the analytics query (framework#3588). The runtime falls back to ordering by the selected dimensions, so the authored order silently does not happen.

              Why it matters beyond tidiness: for us the authored order is a product rule. A per-unit chart must be ordered by the unit dimension and never by the count, because ordering units by a count turns a workload chart into a league table. Today nothing but a hand-written repo test keeps that from silently degrading into "whatever the runtime picked".

              Why silent-empty is the expensive failure here

              The dashboard this came from leads with a "not moving" tile — open work untouched >14 days. An empty tile is indistinguishable from a healthy team: a missing number reads as zero, and zero is the answer the manager is hoping for. Every other class of authoring mistake on this surface already fails loudly at build; these two are the ones that ship.

              Suggested fix

              Both are one pass at the same site that already emits widget-dataset-unknown / widget-dimension-unknown / dashboard-filter-field-unknown:

              • A — for each widget with a filter, walk the condition's field KEYS (descending $and / $or / $not, skipping $-prefixed operator keys) and resolve each against the widget's dataset base object plus the platform system columns. Suggested rule id widget-filter-field-unknown, message shaped like dashboard-filter-field-unknown so the two read alike. A dotted path through a declared include should resolve or be explicitly out of scope, not silently pass.
              • B — error unless options.sortBy is in dimensions[] ∪ values[] for that widget. Suggested widget-sortby-unselected, listing what the widget does select.

              Acceptance: both fail validate and build naming dashboard, widget, key and object; fixture coverage for the failing and the clean shape.

              Happy to take either one if useful — the shape is mechanical and mirrors #3382.

              Repo-local stopgap meanwhile: test/dashboard.test.ts in objectstack-ai/duly resolves both against the datasets barrel, and is written to be deleted when this lands.


              Generated by Claude Code

              Metadata

              Metadata

              Assignees

              Type

              Projects

              No projects

                Milestone

                No milestone

                Relationships

                None yet

                Development

                No branches or pull requests

                Issue actions

                , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
                Skip to content

                A dashboard widget's OWN filter keys and options.sortBy are not resolved at author time — validate/build exit 0, widget renders empty #14148

                Description

                @os-warren

                Measured on @objectstack/cli / @objectstack/spec17.2.0 while building a manager dashboard in the duly app (objectstack-ai/duly#10). Each mutation was applied to a real, shipping dashboard, confirmed on disk, and both gates re-run.

                The interesting part is how narrow the remaining gap is: almost everything on this surface is already checked, including the sibling of one of the two misses.

                What IS caught (no action needed — recorded so the gap below is precise)

                mutated referencevalidatebuildrule
                widgets[].datasetduly_stagnatoin11widget-dataset-unknown (+ "did you mean")
                widgets[].dimensions[]business_unitt11widget-dimension-unknown
                widgets[].values[]untouched_over_1411widget-measure-unknown
                {token} inside widgets[].filter{14_days_hence}11filter-token-unknown, path-precise at dashboards[0].widgets[4].filter.due_date.$lte
                dashboard-level dateRange.fieldnonexistent_column11dashboard-filter-field-unknown (#3365 / #3382)
                nav dashboardNameduly_ghost12defineStack cross-reference validation

                A — widgets[].filter keys are not resolved

                // widget bound to dataset `duly_workload`, base object `duly_task`filter: {due_daet: {$gte: '{today}',$lte: '{14_days_from_now}'}},// ^^^^^^^^ no such column on duly_task

                validate0. build0. The condition reaches the engine, matches nothing, and the widget renders empty.

                Two things make this sharper than a generic missing check:

                1. On the same node, the TOKEN is checked and the COLUMN is not.filter-token-unknown fires with the exact path …widgets[4].filter.due_date.$lte, so the traversal already walks into the filter tree and already knows the widget's dataset. Only the key resolution is missing.

                2. The identical resolution already exists one key over.dashboard-filter-field-unknown (Validate dashboard filter field-existence at build time (extend ADR-0021) #3365, shipped in feat(lint): validate dashboard filter field-existence at build time (#3365) #3382) resolves a dashboard-level filter's field against each widget's dataset base object, and its message even prints that object's field list:

                  dashboard "duly_duty_health" › widget "not_moving_14d": inherits dashboard filter dateRange(nonexistent_column), but object duly_task (dataset "duly_stagnation") has no field nonexistent_column. … Object fields: subject, duty, owner, business_unit, …

                  So dataset → base object → field set is already computed per widget at that point. widgets[].filter is the same field-existence invariant on the filter an author is more likely to write by hand, and it was simply not fed through the same rule.

                B — options.sortBy is not checked against what the widget selects

                dimensions: ['business_unit'],values: ['untouched_over_14d'],options: {sortBy: 'not_selected',sortOrder: 'asc'},

                validate0. build0. The spec states the contract in its own words — "Order rows by this dimension or measure name — must be one this widget actually selects" (DashboardWidgetOptionsSchema.sortBy) — and DashboardWidgetOptions is one of the four keys deliberately declared because they reach the analytics query (framework#3588). The runtime falls back to ordering by the selected dimensions, so the authored order silently does not happen.

                Why it matters beyond tidiness: for us the authored order is a product rule. A per-unit chart must be ordered by the unit dimension and never by the count, because ordering units by a count turns a workload chart into a league table. Today nothing but a hand-written repo test keeps that from silently degrading into "whatever the runtime picked".

                Why silent-empty is the expensive failure here

                The dashboard this came from leads with a "not moving" tile — open work untouched >14 days. An empty tile is indistinguishable from a healthy team: a missing number reads as zero, and zero is the answer the manager is hoping for. Every other class of authoring mistake on this surface already fails loudly at build; these two are the ones that ship.

                Suggested fix

                Both are one pass at the same site that already emits widget-dataset-unknown / widget-dimension-unknown / dashboard-filter-field-unknown:

                • A — for each widget with a filter, walk the condition's field KEYS (descending $and / $or / $not, skipping $-prefixed operator keys) and resolve each against the widget's dataset base object plus the platform system columns. Suggested rule id widget-filter-field-unknown, message shaped like dashboard-filter-field-unknown so the two read alike. A dotted path through a declared include should resolve or be explicitly out of scope, not silently pass.
                • B — error unless options.sortBy is in dimensions[] ∪ values[] for that widget. Suggested widget-sortby-unselected, listing what the widget does select.

                Acceptance: both fail validate and build naming dashboard, widget, key and object; fixture coverage for the failing and the clean shape.

                Happy to take either one if useful — the shape is mechanical and mirrors #3382.

                Repo-local stopgap meanwhile: test/dashboard.test.ts in objectstack-ai/duly resolves both against the datasets barrel, and is written to be deleted when this lands.


                Generated by Claude Code

                Metadata

                Metadata

                Assignees

                Type

                Projects

                No projects

                  Milestone

                  No milestone

                  Relationships

                  None yet

                  Development

                  No branches or pull requests

                  Issue actions