Skip to content

ObjectStackAdapter.aggregate() posts an un-lowered FilterArray as the analytics where — every array-form element:number / object-metric filter answers 400 on the runtime route; lower through parseFilterAST before the wire (moved from objectstack#15828, ruled 2026-09-05) #7752

Description

@os-zhuang

Provenance. Rebuilt here from objectstack-ai/objectstack#15828 (transfer is unavailable to the filing seat) after the maintainer's ruling of 2026-09-05, verbatim 「15828 按前端缺陷处理」 — recorded on that card by the director seat (objectstack-ai/objectstack#12708, summon #15). The objectstack card stays open, narrowed to the spec-side documentation half (D3 entry wording + one .describe() sentence). Measurements below are the source card's and triage's, re-read against objectui origin/main 81a2eb1f and the spec docblock on objectstack origin/main 6c439f2.

Symptom (user-visible today)

An element:number (array-only filter since objectstack#12039) or an object-metric authored with an array filter plus aggregate calls ObjectStackAdapter.aggregate() → analytics branch → POST /analytics/query with where = the array. The runtime route parses the body with AnalyticsQueryRequestSchema first (where: FilterConditionSchema) and answers 400 Invalid AnalyticsQuery body: where: … instead of the aggregate. Only the MongoDB-style record survives — the exact authoring form objectui#6206-B retired. Measured with safeParse on the spec source: record ACCEPT; AST tuple array / single tuple / rule array all REFUSE.

Why this is the adapter's defect and not the protocol's

@objectstack/spec data/filter.zod.ts, FilterArray docblock (objectstack#5158 ruling C), verbatim: "A FilterArray is not a storage shape and not a protocol shape. It is lowered to a FilterCondition at the single sink parseFilterAST (@objectstack/spec/data) the moment it arrives, and only the lowered FilterCondition travels any further. where on a query is a FilterCondition and stays one … no driver, no transport, no stored row — ever has to understand two filter dialects." Widening the wire was that ruling's rejected option A. The declared doors are React block props, FilterBuilder, and the REST $filter query string; a POST body's where is transport.

packages/data-objectstack/src/index.ts aggregate() (analytics branch, ~line 4990): payload.where = Array.isArray(params.filter) ? translateFilterArray(params.filter) : params.filter. translateFilterArray only normalises rule objects into AST tuples — the result is still a FilterArray. The protocol's lowering step is missing. The same file already imports isFilterAST from @objectstack/spec/data (line 21); parseFilterAST is exported beside it. The branch comment mistook service-analytics' in-process lowerAnalyticsWhere door (objectstack#5334) for the wire's door; objectui#6302's gate measured that function, not the route.

The fix

payload.where = Array.isArray(params.filter)
  ? parseFilterAST(translateFilterArray(params.filter))   // the protocol's single lowering sink
  : params.filter;                                        // record path unchanged
  • parseFilterAST returns undefined for an empty array — decide and pin: an empty authored filter posts no where (matches lowerAnalyticsWhere's "[] is no filter").
  • The find() path is untouched: it sends $filter on the query string, which is a declared door.
  • The spec-shape branch's UnloweredAggregateWhereError (objectui#6825, ruling 2026-08-30 option A) is a different branch and a different ruling; do not touch it.

Tests this must carry

  • packages/data-objectstack/src/aggregate-filter-lowering.test.ts re-pointed: the posted where for an array filter is a FilterCondition object (e.g. ['stage','=','won'] → { stage: 'won' } or whatever parseFilterAST yields — assert against parseFilterAST's own output, not a hand-written literal).
  • The route-hop pin both triage comments asked for: the built payload passes AnalyticsQueryRequestSchema.safeParse from @objectstack/spec/api — the exact gate the runtime runs — for a tuple, a logical group, a bare list, and the record form; and an infix join ([condA, 'or', condB]) is refused before the wire with the adapter's INVALID_FILTER envelope, not silently dropped.
  • Existing record-path tests stay green unchanged.

Out of scope

Widening AnalyticsQuerySchema.where; touching translateFilterArray's tuple normalisation; the dashboard-widget record producer (groupToCondition, objectstack#15829 family); any @objectstack/spec change (the documentation half lives on objectstack#15828).

Refs: objectstack#15828 (source, now the spec-docs half) · objectstack#5158 (FilterArray ruling C) · objectstack#5334 (analytics in-process door) · objectstack#12039 · #6302 / PR #6828 (the change that introduced the un-lowered post) · #6206 (ruling B) · #6825 · objectstack#15829 · #7711 · #7712 (same family per triage).

Activity

  1. self-assigned this
    on Sep 5, 2026
  2. added
    bugSomething isn't working
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Sep 5, 2026
  3. added theissue type on Sep 5, 2026
  4. os-zhuang commented on Sep 5, 2026

    @os-zhuang
    ContributorAuthor

    Claim: PM loop round — director seat summon #15, maintainer direct dispatch (verbatim 「15828 按前端缺陷处理」 replying to the seat's proposal 「按直派派发 objectui 修复与 spec 文档卡」, 2026-09-05, director chat); the objectui domain:ui seat #6025 is vacant
    Session: session_01TezFG8ZMrNH6n5VTNpPpdH
    Branch: claude/issue-7752-aggregate-where-lowering
    Worktree: objectui-issue-7752
    Domain: domain:ui
    File surface: packages/data-objectstack/src/index.ts (the aggregate() analytics branch only) · packages/data-objectstack/src/aggregate-filter-lowering.test.ts (re-point) · one new or extended test asserting the posted payload against AnalyticsQueryRequestSchema · one .changeset/*.md (@object-ui/data-objectstack patch). ⛔ translateFilterArray body, the spec-shape branch and UnloweredAggregateWhereError, packages/types, packages/core, governed surfaces untouched (stop on breach; explain in the report)
    Container & model: S code change with contract-facing tests, handled as M; mode:subagent; model: opus — --tier (objectstack dispatch-gates) has no objectui path mandate; default tier because the tests must read the spec's own schema and the change sits on a published wire path
    Clause-②: no — the adapter starts emitting what the protocol already declares (where = FilterCondition); no accept/reject behaviour or public surface of any published package changes; the spec is untouched
    Serial constraints cleared: open objectui PRs at 14:5xZ (#7749 hooks, #7748 core DataScopeManager, #7743 types kanban, #7739 types chatbot, #7737 types chart, #7736 types layout docs, #7685 lockfile, three dependabot, #5400 release) — none touches packages/data-objectstack/**; sibling cards #7711 / #7712 (same census family) are pm:queue, unclaimed, and land in plugin-calendar / plugin-kanban; none.


    Generated by Claude Code

  5. os-zhuang commented on Sep 5, 2026

    @os-zhuang
    ContributorAuthor

    Claim (os-dev, mode:subagent) — dispatched by the PM seat for this card only.

    Session: session_01TezFG8ZMrNH6n5VTNpPpdH
    Branch: claude/issue-7752-aggregate-where-lowering (pushed empty at claim time; base origin/main 81a2eb1f)
    Worktree: /home/user/objectui-issue-7752
    Domain: domain:ui

    Read before claiming: this card's body and its one comment; objectstack#15828 body and all four comments (two triage comments, the maintainer ruling 5552626017 verbatim 「15828 按前端缺陷处理」, and the spec-half claim). The ruling is executed, not re-decided: the adapter is wrong, the protocol is right; ⛔ no widening of AnalyticsQuerySchema.where, ⛔ no @objectstack/spec change from this branch.

    File surface I intend to touch: packages/data-objectstack/src/index.ts (the aggregate() analytics branch only, plus its stale comment about lowerAnalyticsWhere) · packages/data-objectstack/src/aggregate-filter-lowering.test.ts (re-pointed assertions plus the route-hop AnalyticsQueryRequestSchema.safeParse pin) · one .changeset/*.md (@object-ui/data-objectstack patch). ⛔ Untouched: translateFilterArray's body, the spec-shape branch and UnloweredAggregateWhereError / MalformedFilterError meanings, find()'s $filter door, packages/types, packages/core, and every governed surface (AGENTS.md, CLAUDE.md, .claude/**, docs/adr/**, root skills/**).


    Generated by Claude Code

  6. os-zhuang commented on Sep 5, 2026

    @os-zhuang
    ContributorAuthor

    os-dev-report

    {
      "issue": 7752,
      "status": "done",
      "branch": "claude/issue-7752-aggregate-where-lowering",
      "pr": "https://github.com/objectstack-ai/objectui/pull/7754",
      "premise_still_valid": true,
      "head_sha": "00acc4a9",
      "base": "origin/main 81a2eb1f",
      "summary": "aggregate()'s ANALYTICS branch now lowers an array filter with parseFilterAST(translateFilterArray(filter)) before the payload is built, so the posted `where` is the FilterCondition AnalyticsQuerySchema declares; a record filter passes through unchanged. Empty case pinned as ruled: parseFilterAST([]) is undefined, so an empty authored filter posts NO `where` key (it used to post a literal []). An array the sink cannot lower (infix join [condA,'or',condB]; also a truncated tuple like ['stage','='] that isFilterAST accepts but the sink throws on) is refused with a new UnlowerableAnalyticsFilterError carrying the same INVALID_FILTER/400 envelope as MalformedFilterError and UnloweredAggregateWhereError, recognised by isMalformedFilterError() — never posted, never dropped. Neither sibling class's meaning was widened. The lowering runs BEFORE the analytics try block: raised inside it, a refusal would be classified `unknown` by classifyAnalyticsFailure and answered by the client-side fallback with plausible numbers. The branch comment no longer calls lowerAnalyticsWhere the wire's door and names the route hop instead. Verbatim premise check on origin/main confirmed every claim in the card: the un-lowered assignment at the named line, isFilterAST already imported from @objectstack/spec/data, parseFilterAST exported beside it, and the four safeParse verdicts (record ACCEPT; tuple/group/bare-list REFUSE un-lowered, ACCEPT lowered).",
      "files_changed": [
        "packages/data-objectstack/src/index.ts",
        "packages/data-objectstack/src/aggregate-filter-lowering.test.ts",
        "packages/data-objectstack/src/aggregate-spec-shape-where.test.ts",
        ".changeset/7752-analytics-where-lowering.md"
      ],
      "pm_mechanism_assumptions": {
        "1_repoint_lowering_test": "CONFIRMED and done. The file asserted the posted `where` equals translateFilterArray's AST. Re-pointed to parseFilterAST's own output on the same input (never a literal); the cross-path block now asserts aggregate()'s `where` equals parseFilterAST applied to the AST find() puts on filter=.",
        "2_route_hop_pin": "CONFIRMED absent, now added. 8 accept rows parse the REAL posted body with AnalyticsQueryRequestSchema from @objectstack/spec/api (tuple, rule array, and-group, bare list, both record forms, empty array, no filter), plus a negative control asserting the array this branch used to post is REFUSED by that same schema. The infix join is proven refused before any request: analytics body list empty AND no /data fallback call.",
        "3_find_untouched": "CONFIRMED. find()'s $filter path unchanged; its independent row and the 7 cross-path rows are green.",
        "4_spec_shape_branch_untouched": "PARTIALLY FALSIFIED, declared. The branch itself and UnloweredAggregateWhereError are untouched, but aggregate-spec-shape-where.test.ts carried ONE row pinning the analytics branch's old un-lowered value ('legacy analytics params go to POST /analytics/query'). It went red on the first run; re-pointed to parseFilterAST's output the same way. That file's own refusal assertions are unaffected."
      },
      "gates": [
        { "command": "pnpm exec vitest run packages/data-objectstack/", "exit": 0, "verdict": "Test Files  56 passed (56) / Tests  748 passed (748)" },
        { "command": "pnpm exec vitest run packages/data-objectstack/ packages/plugin-charts/ packages/plugin-dashboard/ packages/components/ packages/core/", "exit": 0, "verdict": "Test Files  544 passed (544) / Tests  6649 passed (6649) — every in-repo aggregate() caller's package; run under the shared verify lock, VERDICT command-exit 0, held 581s" },
        { "command": "pnpm --filter @object-ui/data-objectstack type-check", "exit": 0, "verdict": "tsc --noEmit, no diagnostics; --listFiles confirms BOTH edited test files are in the program (not excluded)" },
        { "command": "pnpm --filter @object-ui/data-objectstack lint", "exit": 0, "verdict": "427 problems (0 errors, 427 warnings) — warnings all pre-existing no-explicit-any" },
        { "command": "pnpm --filter '@object-ui/data-objectstack^...' build", "exit": 0, "verdict": "VERDICT command-exit 0 (shared verify lock); run first so type-check reads fresh .d.ts" },
        { "command": "node scripts/check-changeset-presence.mjs", "exit": 0, "verdict": "3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)" },
        { "command": "node scripts/check-changeset-no-major.mjs", "exit": 0, "verdict": "No changeset declares a major bump." },
        { "command": "pnpm check:control-bytes", "exit": 0, "verdict": "OK (scanned 6315 tracked text file(s); skipped 85 binary)" },
        { "command": "pnpm check:spec-symbols", "exit": 0, "verdict": "spec member citations: no comment cites a key its spec symbol does not declare." },
        { "command": "pnpm check:phantom-deps", "exit": 0, "verdict": "Every in-scope import is declared by the package that publishes it." },
        { "command": "pnpm check:vi-mock-specifiers", "exit": 0, "verdict": "OK (4298 tracked source file(s))" },
        { "command": "pnpm check:self-import", "exit": 0, "verdict": "No package names itself inside its own src/." },
        { "command": "pnpm check:unreferenced-sources", "exit": 0, "verdict": "OK" },
        { "command": "pnpm check:readme-exports", "exit": 1, "verdict": "the population COLLAPSED — packagesRead: found 4, floor is 25. NOT MEASURED, not red: the gate needs every package built and its workflow builds first. With @object-ui/data-objectstack built it reports ZERO findings for this package, and this change only ADDS an export (UnlowerableAnalyticsFilterError, confirmed in dist/index.d.ts), which cannot make a README name stop resolving. Repo-wide run declared to CI." },
        { "command": "grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]' on the 4 changed files", "exit": 1, "verdict": "no matches — no control bytes; and 0 CJK characters in any line this diff ADDS (rule #-1)" }
      ],
      "ablation": "Run on the COMMITTED tree (00acc4a9), source-level (tests import ./index, not dist/, so no rebuild leg applies). Mutation: `lowered = parseFilterAST(ast)` to `lowered = ast`. On-disk proof BEFORE reading any result — HEAD blob 78acecd0 == pre-mutation hash; anchor counts flipped (parseFilterAST(ast) call 1 to 0, ABLATED marker 0 to 1); post-mutation blob c8d20a3b, asserted different from HEAD blob and non-empty. Result: ABLATED_TEST_EXIT=1, Tests 17 failed | 14 passed (31). Direction as predicted (red), and it reddens the RIGHT rows: all four AnalyticsQueryRequestSchema accept rows fail with `expected false to be true`, i.e. the new route-hop pin can fail at the hop it exists to measure. Restore leg: `git checkout HEAD -- <abs path>` (never bare checkout), plus a trap '<restore>' EXIT INT TERM using an absolute REPO_ROOT. Restore PROVEN by state, not by exit code: post-restore blob 78acecd0 == HEAD blob, `git diff HEAD` empty, ABLATED marker count back to 0. Re-ran the two touched test files after restore: exit 0, 53 passed.",
      "line_budget": "n/a — no skills/** file touched (no root skills/, no .claude/**). Governed surfaces (AGENTS.md, CLAUDE.md, .claude/**, docs/adr/**, skills/**) all untouched; verified by the diff's 4-file file list.",
      "deviations": [
        "FILE SURFACE +1: aggregate-spec-shape-where.test.ts was edited (one assertion row + its comment + one import). It is outside the file surface the claim listed, but it pinned the analytics branch's old un-lowered `where` and went red on the first run. Re-pointed to parseFilterAST's output; that file's own #6825 refusal assertions are untouched.",
        "NEW EXPORTED SYMBOL: UnlowerableAnalyticsFilterError is added to the package's public surface. Additive only, ordered by the card ('refused with the adapter's existing INVALID_FILTER / 400 envelope shape — reuse the pattern, do not widen those classes' meaning'); a new class was the only way to obey the do-not-widen half.",
        "LOWERING HOISTED above the analytics `try`, beyond the card's literal one-line patch. Necessary: classifyAnalyticsFailure maps an INVALID_FILTER-coded error to `unknown`, and aggregate()'s catch answers `unknown` with aggregateViaFind — so a refusal raised inside the try would have been answered with client-side numbers. Side effect, strictly better: MalformedFilterError now reaches the caller directly instead of only surviving because aggregateViaFind happened to re-throw it. Same observable outcome for the existing test (same error class, no HTTP call).",
        "GATE NARROWED, declared: check:readme-exports (see gates). No other gate was narrowed.",
        "CHANNEL SWITCH, declared: the container's REST proxy refuses /search/issues ('sessions are bound to their configured repositories. Use repository-scoped endpoints') even though repo-scoped REST reads answer 200. Dedup for the finding below therefore used ONE targeted MCP search_issues call. It returned 2 hits (both closed, neither a duplicate), so the reading is non-empty and needs no control word.",
        "NEAR-MISS, self-caught and repaired: the changeset was first written to .changeset/lucky-donkeys-shave.md, a name that ALREADY EXISTS on origin/main (a #7642 census changeset) — the write clobbered it. Caught because check-changeset-presence.mjs counts only ADDED changesets and stayed red at 0. Restored with `git checkout origin/main -- <path>` and PROVEN identical by blob hash (0eb92d73 both sides), then rewritten as .changeset/7752-analytics-where-lowering.md. Nothing of that file reached any commit; the diff vs origin/main touches 4 files and that is not one of them.",
        "SCRIPT NAME: this package spells its typecheck script `type-check` (hyphen), not `typecheck`; the run echoed the script name."
      ],
      "mcp_calls": "10 — 4 issue_read (7752 body+comments, 15828 body+comments), 2 add_issue_comment (claim, this report), 1 create_pull_request, 1 pull_request_read (full body read-back for sanitizer), 1 search_issues (the declared dedup shot), 1 issue_write (the finding below).",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #7755: classifyAnalyticsFailure returns kind 'unknown' for a coded 400 that is not VALIDATION_FAILED (INVALID_FILTER, say), and aggregate()'s catch answers 'unknown' with aggregateViaFind — so a body the analytics route refuses can be re-read through find()'s different filter door and rendered as a confident number, the exact misdirection the 'rejected' branch's own comment cites framework#3878 to stop. Unassigned, unlabelled, left for triage; NOT fixed in PR #7754."
      ]
    }

    Verification artifacts: PR #7754 body carries the same gate table, the ablation transcript, and the verbatim objectstack#5158 ruling C docblock the change rests on. The PR is draft, base main, auto-merge not armed, Fixes #7752 on its first line.


    Generated by Claude Code

  7. added a commit that references this issue on Sep 5, 2026
  8. os-zhuang commented on Sep 5, 2026

    @os-zhuang
    ContributorAuthor

    os-dev-report addendum

    Patch round, one item as dispatched: the changeset's bump was patch while the PR adds a public export (UnlowerableAnalyticsFilterError, present in dist/index.d.ts). Scored minor instead. Frontmatter only — the changeset text is byte-unchanged, and no other file moved.

    Gates re-run locally on d4557b01, exit codes captured by redirect and the verdict lines quoted from each gate's own output:

    Command Exit Verdict line
    node scripts/check-changeset-presence.mjs 0 ✅ 3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/7752-analytics-where-lowering.md.
    node scripts/check-changeset-no-major.mjs 0 ✅ No changeset declares a major bump.
    node scripts/check-changeset-fixed.mjs 0 ✅ privatePackages declared: version=true, tag=false.

    The third row is this repo's fixed-group bump-policy gate (changeset-guard.yml runs it beside no-major). One gate beyond the three asked for, run because it is the other half of that workflow and it bears on the earlier reported near-miss: node scripts/check-changeset-overwrite.mjs exits 0 with ✅ No pre-existing changeset was modified or deleted. — independent confirmation that the clobbered .changeset/lucky-donkeys-shave.md was fully restored and that nothing this branch carries touches a changeset it does not own.

    Nothing else was touched: no source, no test, no governed surface, and the PR was not flipped out of draft.


    Generated by Claude Code

  9. os-zhuang commented on Sep 5, 2026

    @os-zhuang
    ContributorAuthor

    Review — ACCEPT (director seat, objectstack#12708 summon #15, session_01TezFG8ZMrNH6n5VTNpPpdH, 2026-09-05T15:3xZ) · PR #7754, head d4557b01 (patch round on 00acc4a9)

    Checked on GitHub against references/review-checklist.md:

    item reading
    PR shape draft ✓ · base main ✓ · first line Fixes #7752 ✓; body cites objectstack#15828's ruling and #5158 ruling C; no closing keyword near another open card
    Scope (get_files, 4 files) packages/data-objectstack/src/index.ts (the analytics branch: new lowerAnalyticsFilterForWire, parseFilterAST import beside the existing isFilterAST, new UnlowerableAnalyticsFilterError, hoisted before the try), aggregate-filter-lowering.test.ts re-pointed, aggregate-spec-shape-where.test.ts one row re-pointed (declared +1: it pinned the old un-lowered value), one changeset. translateFilterArray, the spec-shape branch, UnloweredAggregateWhereError, find()'s $filter door, governed surfaces untouched ✓
    Protocol conformance (the ruling) posted where is parseFilterAST's output, asserted against the spec's own function, never a literal ✓; the route-hop pin both triage comments asked for is present: 8 accept rows parse the real posted body with AnalyticsQueryRequestSchema from @objectstack/spec/api, plus a negative control proving the old array is refused by that same gate ✓; [] posts no where (matches lowerAnalyticsWhere's reading) ✓; un-lowerable arrays (infix join; truncated tuple) refused with the INVALID_FILTER / 400 envelope before any request and without the find() fallback ✓
    Hoisting above the try justified and measured: inside it, classifyAnalyticsFailure returns unknown and the catch answers with aggregateViaFind — a refused filter would have become a confident number. Accepted as in-scope; the residual (a route 400 still re-read through find()) is filed as #7755 for triage ✓
    Semver new public export ⇒ minor (patch round, frontmatter only, d4557b01); check-changeset-fixed / -no-major / -overwrite / -presence all exit 0 on the new head ✓
    Evidence real commands: 748 tests in the package, 6,649 across every aggregate() caller's package, type-check, lint; ablation with lowered = ast turns exactly the four schema-accept rows red, restore proven by blob hash ✓; check:readme-exports declared NOT MEASURED locally (population floor), the repo-wide CI job is the reading ✓
    Changeset overwrite near-miss self-caught (a random changeset name collided with an existing file on main), restored and proven by blob hash; check-changeset-overwrite.mjs exit 0 on the branch ✓
    CI on 00acc4a9 all suites completed, none failed (15:27Z); CI on d4557b01 read in the same stroke as this comment — ready-flip waits for it

    Governed-surface path test: none of the four paths is governed ⇒ ordinary landing (ready → auto-merge; objectui lands through its merge queue). Implemented-by: branch claude/issue-7752-aggregate-where-lowering (mode:subagent) · Reviewed-by: this session (same session — PM review of a subagent's diff; Clause-②: no, spec untouched, no independent contract review owed).

    Not this PR's, recorded: objectstack#15828 (spec-side D3 wording) is a separate dev; #7755 (analytics-400 → find() fallback misdirection) is triage's.


    Generated by Claude Code

  10. os-zhuang commented on Sep 5, 2026

    @os-zhuang
    ContributorAuthor

    MERGED confirmed — landing closed out. PR #7754 merged 2026-09-05T15:58:46Z (squash 53ded82b, now objectui origin/main tip); this card auto-closed by Fixes #7752 at 15:58:48Z. Read on origin/main packages/data-objectstack/src/index.ts: UnlowerableAnalyticsFilterError (:297), lowerAnalyticsFilterForWire (:509) and its call in aggregate() (:5085) are present — the lowering the maintainer's 2026-09-05 ruling on objectstack#15828 (「15828 按前端缺陷处理」) required is live. pm:dispatched removed in the same stroke; ACCEPT was 5552826184. The spec-side documentation half is objectstack PR #15941 (ready, landing). Director seat, session_01TezFG8ZMrNH6n5VTNpPpdH, ledger objectstack#12708.


    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

Labels

bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpriority:p1

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions