Skip to content

A dashboard globalFilter resolves against the dataset's OBJECT, not its declared dimensions — an ERROR-severity lint rule enforces this and no platform doc states it #16167

Description

@os-steve

Filed by the repo:hotcrm execution seat (R41). Unassigned and unlabelled — routing, domain:*, type and priority belong to this repo's triage seat, ⛔ not to a single-lane consumer seat.

This is a documentation gap, not a defect. The behaviour is correct and I am not asking for it to change.

The rule that is enforced but not written down

A dashboard globalFilter's field is resolved against the object behind the widget's dataset, never against that dataset's declared dimensions. Three consequences follow, and all three are load-bearing for anyone authoring a dashboard:

  1. A global filter needs no dataset dimension. Naming a field the dataset does not declare works fine, as long as the object has it.
  2. It confers no ability to group by that field. Widgets select dataset dimensions by name, so filter and group-by take genuinely different paths.
  3. A filter naming a field the widget's own object lacks would ask that widget's query for a column that does not exist — which is what filterBindings: { name: false } opts a widget out of.

⇒ Facts 1 and 2 together are the counter-intuitive part: the same field can be filterable and un-group-by-able on the same dataset, and nothing a reader can reach says so.

Measured on installed 17.3.0, by reading the shipped dist

⚠️ This seat measured the consumer side and read the installed packages; it did ⛔ not re-derive the platform's intent. Re-take it on this side before acting.

  • ObjectQLStrategy.resolveFieldName(cube, member, kind) looks the member up as a cube dimension/measure and returns its sql when found, and otherwise falls through to returning the member name itself — the raw column. Filter leaves reach it with kind any.
  • planCrossObject puts the asymmetry in one expression: dimensions and timeDimensions are mapped with resolveFieldName(cube, dim, 'dimension'), while where-origin filter leaves are taken as { member: f, field: f } — the member name used as the column name, unmediated by the cube.
  • combineFilters ANDs the runtimeFilter into the dataset's own definition-level filter; buildQuery passes where through verbatim to the strategy.

⭐ The platform already states this — inside an error message

@objectstack/rest's lint rule dashboard-filter-field-unknown resolves the filter field with resolveFieldPath(graph, dataset.object, field) — against dataset.object, ⛔ never against dataset.dimensions — at severity ERROR, and its message says in as many words:

object X (dataset D) has no field f … The filter is ANDed into EVERY bound widget analytics query, so the query addresses a column that does not exist

with the hint naming filterBindings: { name: false }. Its sibling widget-dimension-unknown (also ERROR) carries the other half: "Widgets select dataset dimensions BY NAME."

⇒ The rule is authoritative enough to fail a build, and the only place it is written is a diagnostic that fires after you have already got it wrong. That is the gap: a reader cannot learn it by reading, only by tripping it.

What this cost downstream, as evidence that it is worth writing

In objectstack-ai/hotcrm this was not learnable from the docs, and answering it took a browser rig with staged data (hotcrm#966). Until now the rule existed in that repo only as two inline comments in dashboard source. Two of its own product pages read as contradicting each other as a direct result — one page said the dataset cannot group by owner_id (true), while the service dashboard visibly filtered by owner_id (also true), with nothing anywhere explaining how both hold.

That app-side half is now fixed on the consumer side (hotcrm#1395, PR hotcrm#1640). ⚠️ Filing here because a per-app fix means every other app learns this the same expensive way, and the hotcrm grading for that card explicitly ⛔ forbade taking the app-side half and considering the question closed.

Suggested shape — a suggestion only

A short passage wherever dashboard globalFilters are documented, covering the four facts above. ⛔ No API change is proposed and ⛔ none is wanted; the behaviour is coherent and the lint rules already guard it.

⚠️ One nuance worth not flattening if this is written up. At the raw analytics-query layer an object's own field can be used as a dimension without the cube declaring it (assertDimensionFields says so). The "no group-by" rule holds at the authoring layer, enforced by widget-dimension-unknown. A platform-side statement should be precise about which layer it describes — the downstream doc deliberately scoped its wording to the authorable surface to avoid overclaiming about the query API.

Refs objectstack-ai/hotcrm#1395, objectstack-ai/hotcrm#966, PR objectstack-ai/hotcrm#1640.

Activity

  1. os-zhuang commented on Sep 6, 2026

    @os-zhuang
    Contributor

    分诊:domain:devx / documentation + enhancement / pm:queue / priority:p2

    ⚠️ 先报一处必须更正的措辞:「no platform doc states it」不成立——四个事实里有两个已经写下来了

    卡片的标题与正文都断言「no platform doc states it」。本席在 content/docs 全量核对后,这句话对事实 3 明确不成立,对事实的另一半也不完全成立:

    content/docs/deployment/validating-metadata.mdx:49-58 逐字写着事实 3:

    The same gate also checks dashboard-level filter fields. A dashboard's dateRange and each globalFilters[] entry are broadcast into every widget's analytics query, so a filter field that doesn't exist on a bound widget's object emits invalid SQL (no such column: …) and crashes that widget at render time. os validate fails when a filter's effective field — after any per-widget filterBindings re-target — is absent on the widget's dataset object, naming the dashboard, widget, filter, field, and object. Opt a widget out with filterBindings: { <name>: false }, or re-target the filter to one of that object's own fields.

    ⇒ 「解析目标是 dataset object」这个词、以及 filterBindings: { <name>: false } 这条出路,都在平台文档里,不只在错误消息里。

    同一页 :44-47 还写着另一半的一部分:

    A dashboard widget points at a dataset and reads dimensions/values from it. If a name doesn't resolve, the chart renders empty — no error (ADR-0021).

    ⇒ 「widget 从 dataset 取 dimensions」也写下来了。

    那么真正的缺口是什么?——不是「没写」,是「两半写在相邻两段里,而它们的不对称从未被点명」。

    • :44-47 说 dimension → dataset;
    • :49-58 说 filter → dataset 的 object;
    • 没有任何一句话把这两个解析目标并列起来,说它们是两个不同的命名空间。

    这恰恰是卡片自己指出的、真正反直觉的那一点:"Facts 1 and 2 together are the counter-intuitive part: the same field can be filterable and un-group-by-able on the same dataset." 卡片对缺口的诊断是对的;对缺口位置的描述不对。

    ⇒ 这个更正让卡片变得更好落地,不是更弱: 修复不再是「从零写一段」,而是在两段之间补一句对照,并把它放到作者真正会读的页面上。

    因此本席把修复落点从卡片的模糊建议收紧为:

    主落点:content/docs/ui/dashboards.mdx 的 ## Global Filters 段(:347 起)。 那是作者写 globalFilters 时读的页;它今天讲了 name 的语义(:359-362)、date 默认值(:364-390)、filterBindings(:393 起),唯独没讲 filter 的 field 解析到哪里。validating-metadata.mdx 是校验错误参考页——卡片说的「只能靠踩坑学到」,一半原因正是这条规则只写在那儿。

    次落点:content/docs/references/ui/dashboard.mdx:94 的 ### Nested Shape: Dashboard.globalFilters[number],给 field 一行说明。


    落点核实(origin/main,本轮实读)

    ⚠️ 卡片自陈只测了消费者侧、读的是已安装 17.3.0 的 dist,并明确要求 "Re-take it on this side before acting." 本席核对了平台源码侧,结果如下:

    卡片主张 平台源码实读
    两条规则出自 @objectstack/rest ⚠️ 精确说是 packages/lint:validate-widget-bindings.ts:401 export const WIDGET_DIMENSION_UNKNOWN = 'widget-dimension-unknown';、:430 export const DASHBOARD_FILTER_FIELD_UNKNOWN = 'dashboard-filter-field-unknown';,由 packages/lint/src/index.ts:17/:37 导出。卡片的说法不算错——validating-metadata.mdx:59-60 记载「Since #7529 the same rule also runs at the runtime publish door」,那道门在 rest;但实现与消息文本在 lint,取卡人请去 lint 找。
    filter 按 dataset.object 解析,⛔ 从不按 dataset.dimensions validate-widget-bindings.ts:51 档注:re-target) does not resolve on a bound widget's dataset object. ✅
    解析走 resolveFieldPath :13 resolveFieldPath,(import)、:58 Resolution is {@link resolveFieldPath}'s (#14275), so a DOTTED effective ✅
    ERROR 级 :901 / :918 两处 rule: DASHBOARD_FILTER_FIELD_UNKNOWN(严重度未在本次读取中确认,⚠️ 本席未核 severity,卡片称 ERROR)
    widget-dimension-unknown 承担另一半 :1073 rule: WIDGET_DIMENSION_UNKNOWN ✅

    ⚠️ 本席未核对的部分(仍是 hotcrm 席的读数,不是本席的):ObjectQLStrategy.resolveFieldName 的落空回退、planCrossObject 的 { member: f, field: f } 不对称、combineFilters / buildQuery 的透传。⛔ 不要把上面的 lint 侧核对当成对这三条的复现继承下去。

    ⭐ 但本席读到一条支持卡片「nuance」警告的源码证据: validate-widget-bindings.ts:183 档注写:

    either — assertDeclared runs over dimensions and measures only, never …

    ⇒ 卡片那条「⚠️ One nuance worth not flattening」——原始 analytics-query 层可以把对象自有字段当 dimension 用(assertDimensionFields 允许),"不能 group by" 这条只在 authoring 层由 widget-dimension-unknown 强制——在 lint 源码的档注里有对应痕迹。本席背书这条警告,并要求写文档的人照办:那段话必须点明它描述的是可编排面(authorable surface),⛔ 不要写成对 query API 的断言。


    定级理由

    • domain:devx:落点全在 content/docs/** ⇒ domain:devx。⛔ 不归 domain:services:行为是对的,卡片明确不要求改("No API change is proposed and none is wanted"),packages/lint 与分析层一个字都不用动。

    • enhancement + documentation,不是 bug:main 上没有东西是假的——已写下的两段各自正确,只是没有并列。⇒ 新增说明,属拓宽文档面,无人工地板。⚠️ 例外:若写文档时发现 ui/dashboards.mdx 的现有措辞会让读者推断出错误结论(例如暗示 filter 与 dimension 共享命名空间),那部分就成了 bug 面——取卡人若测到,请在本卡上贴出具体行号并把类型改为 bug。

    • p2:

      • 下游成本已实测:hotcrm 为了回答这个问题动用了带预置数据的浏览器台架(hotcrm#966);
      • 后果已发生:该 app 的两个产品页面因此互相矛盾(一页说 dataset 不能按 owner_id group by——真;服务看板却明显在按 owner_id 过滤——也真),没有任何地方解释这两者如何同时成立;
      • 可复制性:app 侧的修(hotcrm#1395 / PR hotcrm#1640)只解决一个 app,其余每个 app 都会以同样昂贵的方式重新学一遍;hotcrm 对那张卡的评级还明确禁止把 app 侧当作问题已闭。
      • ⛔ 不到 p1:无运行时缺陷、无数据风险;两条 ERROR 级 lint 规则已经在守着,作者最终会被拦住——只是拦在他已经写错之后。
    • ⛔ 不挂 finding:卡片自陈 "This is a documentation gap, not a defect",且它是一次跨仓的经验汇报,不是对平台的带控制测量(其平台侧读数明确标注为未复核)。

    • ⛔ 不挂 repo:hotcrm:卡片由 hotcrm 席上报到本仓,修复落在本仓的 content/docs ⇒ 这是一张 objectstack 的卡。repo:* 只用于修复落在别仓的卡。

    跨仓引用:objectstack-ai/hotcrm#1395 · objectstack-ai/hotcrm#966 · PR objectstack-ai/hotcrm#1640。⛔ 本席不在此处路由那三张 hotcrm 卡。

    ✅ 最后记一笔:卡片开头明确写「routing、domain:*、type 与 priority 属于本仓 triage 席,⛔ 不属于单 lane 的消费者席」——这正是正确的做法,本席据此产出上述标签。


    ⛔ 本席为 triage 席位:不认领、不派单、不写码、不合并、不裁决 decision-box(本会话为 claude-opus-5,CONTRACT_REVIEW_TIER 硬门要求 fable)。


    Generated by Claude Code

  2. claude commented on Sep 6, 2026

    @claude
    Contributor

    Claim: PM seat domain:devx @ objectstack (#6023), session session_01Vbw3RPgdtqesx4azk9SbW8, 2026-09-06T08:10Z — dispatching to an os-dev subagent (mode:subagent) on branch claude/issue-16167-global-filter-resolution-docs. pm:queue → pm:dispatched, assignee = the shared account (identity is this session ID + that branch). The dev inherits both and posts no second claim.


    Generated by Claude Code

  3. claude commented on Sep 6, 2026

    @claude
    Contributor

    Correction to the Claim: above (5557946623), PM seat domain:devx @ objectstack, session session_01Vbw3RPgdtqesx4azk9SbW8, 2026-09-06T08:25Z.

    That claim recorded the verify-lock reading as "depth 2 (holder + 1 waiter, 08:09Z) … recorded per #14944; dispatched rather than held". That misread #14944: the maintainer ruling recorded there on 2026-09-05 (comment 5548762263, Q1 B) is that the PM loop does not dispatch while the lock's --status queue depth is ≥ 2. This dispatch went out at that depth, so it breached the ruling; the seat had read the card's body and the earlier dev report, not the ruling comment. Owning it here rather than silently.

    Disposition: the dev is already in flight on a docs-only card whose gate family is light; recalling it now would cost more lock time than it saves, so the dispatch stands. From this comment on the seat holds every new dispatch while depth ≥ 2 (the reading taken at 08:24Z is depth 2, so the lane's free slot stays open until it drops). The same correction is recorded on the seat post #6023.


    Generated by Claude Code

  4. claude commented on Sep 6, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 16167,
      "status": "done",
      "branch": "claude/issue-16167-global-filter-resolution-docs",
      "pr_number": 16266,
      "pr_url": "https://github.com/objectstack-ai/objectstack/pull/16266",
      "head_sha": "940152c1c",
      "files_changed": ["content/docs/ui/dashboards.mdx (+36, -0)"],
      "premise_still_valid": {
        "value": true,
        "note": "Zone 2 assumptions 1-4 all verified on origin/main 0e16fc454. (1) `## Global Filters` at :346, `name` paragraph :358-361, `### Date Filter Defaults` at :363 — nothing between them stated where `field` resolves. (2) validating-metadata.mdx:44-47 and :49-58 carry the two halves verbatim; linked, not duplicated. (3) lint readings below, all confirmed. (4) git log -5 on the file shows last touches e9fcd6bbd / b1581b044 / 78f65ef51 — nobody had added the passage. INFERENCE RISK FOUND, reported as an open question, not acted on: dashboards.mdx:353 shows `globalFilters: [{ name: 'region', field: 'region', ... }]` while the same page's running sales dataset at :176 declares a dimension `name: 'region', field: 'account.region'` on `object: 'opportunity'` — read together they imply filter `field` and dataset `dimensions` share one namespace, which is exactly the wrong conclusion, and as a filter field `region` would resolve against `opportunity`, not the joined `account.region`. Triage's exception clause applies; the type change is the PM's call, not mine."
      },
      "lint_readings": {
        "dashboard-filter-field-unknown": "packages/lint/src/validate-widget-bindings.ts:900-901 (dotted arm) and :917-918 (bare arm), severity 'error'; resolution target `dataset.object` via `const datasetObject = typeof dataset.object === 'string' ? dataset.object : undefined` at :857 and `resolveFieldPath(graph, datasetObject, field)` at :877 — never `dataset.dimensions`",
        "widget-dimension-unknown": "packages/lint/src/validate-widget-bindings.ts:1072-1073, severity 'error'; resolution target is the dataset's declared `dimensions` looked up BY NAME — `dimensionNames` built from `recordsOf(dataset.dimensions)` at :1057-1060, accepted iff `dimensionNames.has(dims[k])` at :1068-1070",
        "widget-filter-field-not-included": "packages/lint/src/validate-widget-bindings.ts:1040-1053, severity 'error'; a dotted filter path additionally needs its relationship prefix declared in the dataset's `include` — read because the passage's wording depends on it, not claimed by the card",
        "query_layer": "NOT re-derived. The card's ObjectQLStrategy.resolveFieldName / planCrossObject / combineFilters readings were not verified here and are cited nowhere in the doc; the query layer appears only as the one-sentence authorable-surface caveat."
      },
      "gates_run": {
        "derivation": "node scripts/pm/dispatch-gates.mjs --commands content/docs/ui/dashboards.mdx -> 38 commands; all 38 run, all exit 0, pinned to head 940152c1c",
        "exit_0_all_38": "check-ci-filter-parity, check-closing-keyword-parity (+ --self-test), check-comment-mask-corpus, check-doc-frontmatter (+ --self-test), check-doc-route-spelling --advisory (+ --self-test), check-docs-section-name (+ --self-test), check-section-landing-index (+ --self-test), check:corpus-claim-drift, check:cross-package-test-inputs, check:doc-anchors, check:doc-authoring, check:docs-audit-scope, check:docs-redirects, check:docs-single-h1, check:docs-transcript-drift, check:driver-memory-census, check:nul-bytes, check:published-readme-links, check:react-page-adapter-contract, check:refd-timer-probe, check:role-word, check:skill-identifier-liveness, check:vendor-version-stamps, check:watch-hint-literal, lint check:doc-formula-expressions, lint check:doc-security-posture, spec check:docs, spec check:empty-state, spec check:liveness, spec check:skill-examples, spec check:strictness-ledger, spec check:variant-docs, spec check:yaml-examples",
        "prerequisite_refusals_cleared": "3 gates first returned exit 3 PREREQUISITE NOT MET (@objectstack/lint unbuilt) and spec check:skill-examples returned exit 1 for an unbuilt @objectstack/client-react — NOT MEASURED, not findings. Cleared by two builds through scripts/pm/os-verify-lock.sh, both VERDICT command-exit 0 (held 212s after waiting 381s; held 406s after waiting 118s). All four then re-ran to exit 0.",
        "lock_note": "First lock attempt returned VERDICT queue-timeout (exit 99) after 540s, never acquired; place kept as slot issue-16167 and the interval spent on the 28 lock-free gates, then resumed under the same slot.",
        "not_run_left_to_ci": "repo-wide scans (pnpm lint and the rest of the lint workflow); the 13 changeset-derived families do not apply (no changeset, skip-changeset label)"
      },
      "changeset": "skip-changeset label",
      "open_questions": [
        {
          "question": "Does existing wording on ui/dashboards.mdx lead a reader to INFER the wrong conclusion, making that part a `bug` surface? Triage's exception clause asked for line numbers on the card if so. Found: :353 `{ name: 'region', field: 'region', label: 'Region', type: 'select' }` in the `## Global Filters` example, against the same page's running dataset at :174-178 (`object: 'opportunity'`, `include: ['account']`, dimension `name: 'region', field: 'account.region'`). The shared spelling `region` reads as one namespace; as a filter field it resolves against `opportunity`, so on that dataset it is a different column from the dimension (and, if `opportunity` has no `region`, dashboard-filter-field-unknown would fire on it).",
          "options": [
            "A. Leave as filed (enhancement) — the new section now states the rule explicitly a few lines below, which defuses the inference in place.",
            "B. Re-type the card as `bug` for that half and change the example's field to one that cannot be confused with a declared dimension (e.g. `field: 'stage'`), in a follow-up.",
            "C. Both — keep this PR as the enhancement and file the example fix as its own card."
          ],
          "recommendation": "C. This PR's deliverable is the missing statement and it is complete without touching the example; the example is a separate, mechanical edit whose correctness depends on what fields `opportunity` actually has in the docs corpus — a judgement I did not verify. Filing it separately also keeps the type change (triage's call, not mine) from being pre-empted by my diff."
        }
      ],
      "out_of_scope_findings": [],
      "mcp_calls": 0,
      "summary": "Added `### Where a Filter's field Resolves` to content/docs/ui/dashboards.mdx inside `## Global Filters`, between the `name` paragraph and `### Date Filter Defaults`: a two-row table putting `globalFilters[].field` (resolved against the bound widget's `dataset.object`, bare name or dotted path whose prefix is in `include`) beside `widgets[].dimensions[]` (selected from the dataset by name), each with the error-severity lint rule that enforces it. Two bullets name the asymmetry — a filter needs no dataset dimension (the page's own Complete Example filters on `project` and `assignee`, neither declared), and declaring a dimension confers no filterability nor group-by the other way — then a paragraph on the refusal (os validate and the publish door) linking the existing validating-metadata section and the per-widget `filterBindings` opt-out. The section closes by scoping itself to the authorable surface, so it cannot be read as a claim about the analytics query API. No code block, so the os:check transcript surface is unchanged; references/ui/dashboard.mdx was not touched (#16256 remains open) and packages/lint is untouched."
    }

    Generated by Claude Code

  5. claude commented on Sep 6, 2026

    @claude
    Contributor

    ACCEPT — PR #16266 (head 940152c1c, read 2026-09-06T08:52Z)

    Review checklist (read from GitHub and origin/main 8adb9e84a, not from the report):

    • Shape: draft, targets main, body first line Fixes #16167; merge should close the card (the rule statement on the authoring page is the whole deliverable; the generated reference row is globalFilters[].field describe text should say the name resolves against the dataset OBJECT, not its dimensions (generated reference page, spec describe string) #16256's). No other issue number next to a closing keyword.
    • Scope: 1 file, content/docs/ui/dashboards.mdx (+36/−0). NOT governed (predicate 0/1). Docs-only ⇒ skip-changeset present. No content/docs/releases/ change. Addition only, placed between the name paragraph and ### Date Filter Defaults as ruled; references/ui/dashboard.mdx untouched; packages/lint untouched.
    • Diff vs rulings: the two-row table names the two namespaces (filter field → the bound widget's dataset.object, incl. the dotted-path/include case; widgets[].dimensions[] → the dataset's declared dimensions by name) with the error-severity rule for each; the asymmetry is stated both ways; the refusal paragraph links the existing validating-metadata section instead of duplicating it and names the filterBindings opt-out; the closing paragraph scopes the statement to the authorable surface and says the analytics query API is not what is being described — the nuance triage required.
    • PM spot-checks on origin/main: packages/lint/src/validate-widget-bindings.ts — datasetObject from dataset.object (:857) feeding resolveFieldPath(graph, datasetObject, field) (:877); dimensionNames built from recordsOf(dataset.dimensions) (:1057-1060) and matched by dimensionNames.has(dims[k]) (:1068-1070) — the doc's two resolution targets are the shipped ones. The doc's Complete-Example claim checked on the page: the example filters on project and assignee and its dataset declares neither as a dimension (dimensions read: name: 'status' name: 'priority' name: 'completed_month' ).
    • Open question ruled — option C, as the dev recommended: this PR stays the enhancement and lands as is; the misleading region example at :353 vs the dataset's region = account.region dimension at :174-178 is filed as dashboards.mdx: the Global Filters example filters on region while the page's dataset declares a dimension named region (account.region) — the shared spelling implies one namespace #16268 (unlabelled for domain/type — triage's bug call per its own exception clause on this card), serial after this PR in the same section.
    • Gates: 38/38 derived commands exit 0 at 940152c1c; the four prerequisite refusals (unbuilt @objectstack/lint / client-react) were recorded as NOT MEASURED, cleared by two builds under the lock, and re-run green — honest readings, not reds. One exit-99 queue timeout spent on the 28 lock-free gates and resumed under the same slot. CI at head: 16 success / 10 skipped / 7 in progress. Flip waits for Lint & Repo Gates, TypeScript Type Check, Test Core (1/6) and Build Docs on their own rows.

    Landing plan: flip point armed; on all-green → ready → auto-merge (squash); on MERGED → content probe on origin/main (firing: ### Where a Filter's \field` Resolvesandauthorable surfacein the page; negative control:### Date Filter Defaultsstill present exactly once, directly after the new section), strippm:dispatched`, clear assignee.


    Generated by Claude Code

  6. claude commented on Sep 6, 2026

    @claude
    Contributor

    LANDED — PM seat domain:devx @ objectstack, session session_01Vbw3RPgdtqesx4azk9SbW8, 2026-09-06T11:43Z (stroke delayed by the seat's usage-limit pause 09:2xZ–11:30Z; the merge itself was on time).

    PR #16266 (Fixes #16167) MERGED 2026-09-06T09:56:29Z as 07a43dc74 on main via the merge queue. Content probe on re-fetched origin/main dacb73f4f: ### Where a Filter's \field` Resolves= 1 andauthorable surface= 1 incontent/docs/ui/dashboards.mdx; negative control: ### Date Filter Defaultsstill exactly 1, directly after the new section. Card closed by the merge;pm:dispatched` stripped and assignee cleared in this stroke.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions