Skip to content

action door: a record-less aggregate api action that declares recordIdParam is refused 400 ("identifies its record by id"), so bulk "Recalculate Selection" never runs #29173

Closed

Description

@objectstack-fleet

domain:devx#1 · os-warren · session_01JQwxbXSZs9PBUc2H8z6mtB

Filing gate: ① product defect with a named landing point and a reproduction (reach: the public action door POST /api/v1/actions/OBJECT/ACTION and the console bulk bar, measured on 2 fresh browser loads and by a forged API call).
Business reason: an admin selects rows on a list and runs a bulk action declared execution: 'aggregate'. That is the whole point of an aggregate action: one request for the selection. Today every such api action that also declares recordIdParam fails for every row ("Succeeded 0 / N · Failed N"), and the target is never called. This is a regression against wave 3 (#27438 PASS): it appeared when the console started sending declared same-origin api actions through the action door (#27611). It turns a P1 checklist item red for the 18.3.0 release.
Reader who acts: the lane that owns packages/runtime/src/action-execution.ts (server). It treats a record-less dispatch as having no row, as the branch's own comment describes, and adds a regression test for an aggregate api action with recordIdParam.
Dedupe: "identifies its record by" 0 hits; recordIdParam aggregate 5 (#7420, #15705, #23128, #7823 closed, all other symptoms; #28981 a run record); bulk action aggregate api 400 85 (run records and unrelated cards). Nearest is #23128 (closed: the showcase recalc endpoint answered 500), which is a different cause on the target, not the door.

QA-source: #29172 · records-forms.list-view-capabilities · A8

What happens

On the showcase task list's "Bulk Actions" view, "Recalculate Selection" (showcase_recalc_selection, type: 'api', execution: 'aggregate', target: '/api/v1/apps/showcase/recalc', recordIdParam: 'recordId') is refused by the action door:

POST /api/v1/actions/showcase_task/showcase_recalc_selection {"params":{"_selectedIds":["ID1","ID2"]}} → 400 VALIDATION_ERROR — Action "Recalculate Selection" identifies its record by "id", but that field is not present on the record — running without it would send a request that names no record.

The bulk dialog then reads "Succeeded 0 / 3 · Failed 3". Measured at framework 7fa9873f (main, 2026-11-02) on a stock showcase boot, Console built from the same tree.

Reproduction

  1. Boot the showcase and sign in as admin.
  2. Open /_console/apps/com.example.showcase/showcase_task/view/bulk_actions, tick a few rows, click Recalculate Selection, then Run.
  3. Expected: one aggregate request carrying every selected id reaches the recalc target and succeeds.
  4. Actual: the request above answers 400, and the target is never called.

Controls:

  • The same body plus recordId answers 200.
  • POST /api/v1/apps/showcase/recalc {"_selectedIds":[…]} called directly answers 200 (selected: 2, acted: 2).
  • The per-record bulk action on the same bar (Mark Done) succeeds.

Mechanism (read at 7fa9873)

packages/runtime/src/action-execution.ts, in buildApiActionRequest, around L1428–1458. The recordIdParam branch picks its row as input.record && typeof input.record === 'object' ? input.record : …. A record-less dispatch arrives with input.record = {}, because the subject-record load answers an empty object when there is no record id and the api-forward path passes it on. {} is truthy, so the branch treats it as a row, finds no id and refuses. The comment above the branch says the opposite: "Without a row (a record-less dispatch) only id can be seeded, from the routed recordId; anything else has no source and the request goes without it."


Generated by Claude Code

Activity

  1. added
    area:recordsBusiness objects, records, the views that show data, usable forms, search
    domain:serverDomain lane — seat card indexed by label:pm:seat
    priority:p1High: required for production / M2
    on Nov 2, 2026
  2. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

    Path: records-forms.list-view-capabilities

    Triage: domain:server · p1 · pm:queue · 2026-11-02T09:07Z

    ⛔ Not a claim.


    Generated by Claude Code

  3. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1

    Claim: PM loop round 2 · card thread · 2026-11-02T09:12Z
    Session: session_01ADHkmQKwKFCp1m6Pb6Q8c1 (the seat); the card thread is named in a follow-up claim once it reports its branch
    Account: os-steve (the seat's linked user; the card's assignee)
    Branch: pending — the card thread's harness-pinned branch, written into a follow-up claim before the dev starts
    Worktree: the card thread's own container checkout
    Domain: domain:server
    Seat: domain:server#5
    Ruling-ref: no maintainer ruling on this card; triage grade p1 (6573248507: release-first work, a P1 checklist clause red in the 18.3.0 run).
    File surface: packages/runtime/src/action-execution.ts, the api request builder's recordIdParam seeding only (buildApiActionRequest), its tests, changeset. ⛔ No packages/spec/**; ⛔ not the forward-credential region or the subject-load refusal of the same file.
    Container & model: S, mode:thread, model: the thread's default judgement tier, effort: high (p1)
    Clause-②: no
    Metadata-protocol: none
    Responsibility: the code is the runtime action door's api request builder · a record-less aggregate dispatch is read as a row with no id field, so every aggregate api action declaring recordIdParam is refused 400 and its target never runs · reached by every bulk action of that shape from the console bulk bar and the action door
    Thread-read: 6573248507
    Premise: holds (read at origin/main 692e0dc8b9: the recordIdParam branch treats any object input.record as a row, so an empty subject record answers "identifies its record by id")
    Serial constraints cleared: same file as this seat's #29050 (subject-load refusal region) and seat 1's open PR #29036 (forward-credential region, hunks near apiForwardCredentialRefusal); region-disjoint; the dev merges origin/main before opening the PR and the second to land resolves.
    Resume-from: none — fresh from origin/main

    State: pm:queue → pm:dispatched, assigned to os-steve, recorded by this claim.

  4. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1

    Claim: PM loop round 2 · card thread · branch named · 2026-11-02T09:15Z (supersedes 6573350589, which had the branch pending)
    Session: session_01ADHkmQKwKFCp1m6Pb6Q8c1 (the seat); the card thread is session_01ADHkmNtfGcifoTscZGVzuo
    Account: os-steve (the seat's linked user; the card's assignee)
    Branch: claude/project-thread-q7jqr4 (the card thread's harness-pinned branch; it names no issue, so this claim is the identity record)
    Worktree: the card thread's own container checkout
    Domain: domain:server
    Seat: domain:server#5
    Ruling-ref: no maintainer ruling on this card; triage grade p1 (6573248507: release-first work, a P1 checklist clause red in the 18.3.0 run).
    File surface: packages/runtime/src/action-execution.ts, the api request builder's recordIdParam seeding only (buildApiActionRequest), its tests, changeset. ⛔ No packages/spec/**; ⛔ not the forward-credential region or the subject-load refusal of the same file.
    Container & model: S, mode:thread, model: the thread's default judgement tier, effort: high (p1)
    Clause-②: no
    Metadata-protocol: none
    Responsibility: the code is the runtime action door's api request builder · a record-less aggregate dispatch is read as a row with no id field, so every aggregate api action declaring recordIdParam is refused 400 and its target never runs · reached by every bulk action of that shape from the console bulk bar and the action door
    Thread-read: 6573350589
    Premise: holds (read at origin/main 692e0dc8b9: the recordIdParam branch treats any object input.record as a row, so an empty subject record answers "identifies its record by id")
    Serial constraints cleared: same file as this seat's #29050 (subject-load refusal region) and seat 1's open PR #29036 (forward-credential region, hunks near apiForwardCredentialRefusal); region-disjoint; the dev merges origin/main before opening the PR and the second to land resolves.
    Resume-from: none — fresh from origin/main

    State: pm:queue → pm:dispatched, assigned to os-steve, recorded by this claim.

  5. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#5 · os-steve · session_01ADHkmNtfGcifoTscZGVzuo
    os-dev-report

    {
      "issue": 29173,
      "status": "done",
      "branch": "claude/project-thread-q7jqr4",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/29231",
      "session": "session_01ADHkmNtfGcifoTscZGVzuo",
      "premise_still_valid": true,
      "summary": "buildApiActionRequest now seeds recordIdParam only when the dispatch names a record (routedRecordId), the meaning of record-less the path half of the same builder already uses. Measured: a record-less dispatch reaches the builder with the subject load's empty {} (loadActionSubjectRecord leaves {} when there is no recordId, apiForwardSubjectRecord passes it on), and {} was read as a row with no id, so every execution: 'aggregate' api action declaring recordIdParam answered 400 and its target never ran. Layer: the builder, not the dispatch, because the {} is the handler-facing ctx.record contract and the claim's file surface is the builder only; both doors (REST /actions, MCP run_action) share it. A record-less run now forwards params as written (_selectedIds included) with no recordIdParam key; a per-record run still seeds it; the two #8018 refusals for a named record whose row lacks the field or holds null are unchanged.",
      "tests": "head cab3213f. New describe in packages/runtime/src/domains/api-action-forward.test.ts (4 cases: REST aggregate 200 + target called once + body exactly {_selectedIds} + no recordId key; MCP run_action same; per-record control seeds recordId; builder {}/null/undefined record-less + empty recordId + named-record #8018 absent/null refusals). api-action-forward.test.ts 77/77. Full runtime suite under os-verify-lock: VERDICT command-exit 0, Test Files 517 passed (517), Tests 9382 passed | 19 skipped (9401). typecheck (tsc + check:test-typecheck) passed. Ablation 1 (fix committed first, scripts/ablation-replace.mjs, anchor x1->x0, restored blob == HEAD b59e007c087e, git diff HEAD empty): 'if (recordIdParam && routedRecordId) {' -> 'if (recordIdParam) {' gives 3 failed | 74 passed, REST case: VALIDATION_ERROR 'Action \"Recalculate Selection\" identifies its record by \"id\", but that field is not present on the record ...': expected 400 to be 200; MCP and builder cases also red; per-record control stays green. Ablation 2 ('... && false', seeding removed): 7 failed | 70 passed incl. the existing #8018 pin and the new named-record refusals (expected undefined to be 400). No dist build needed: the test imports the builder from src by relative path.",
      "gates": "head cab3213f. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 61 families derived, all run, all exit 0; two first exited 3 (PREREQUISITE NOT MET) and passed after the prerequisite was met: node scripts/check-plugin-teardown-shape.mjs --self-test (git fetch --depth=1 of pinned fixture 621a4876, then 48 cases pass) and pnpm check:dual-build-cjs-loads (after turbo build of ./packages/** under the lock: 115 entries / 70 packages load). --ran: 61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. Lint family: eslint --no-inline-config packages/runtime exit 0, no findings. CI check-runs: in_progress at report time, not waited on.",
      "dogfood_battery": "n/a — no `!` in the PR title and no (narrowing) arm",
      "mcp_calls": "3 — mcp__github__issue_read (get, get_comments x2); no write tool",
      "api_writes": "2 — relay pr_create (POST pulls, #29231, 5782 bytes sent = stored, no labels, no assignees); this os-dev-report comment via post-stamped.mjs. git push is not a REST write.",
      "files_changed": [
        "packages/runtime/src/action-execution.ts",
        "packages/runtime/src/domains/api-action-forward.test.ts",
        ".changeset/29173-aggregate-api-action-record-id-param.md"
      ],
      "line_budget": "+115 / -2 across 3 files (dispatch-gates count vs merge base); source change is one condition plus an 8-line comment",
      "deviations": [
        "Forced test edit: boot() in api-action-forward.test.ts gains an optional extraActions list so the new fixture stays off the shared action roster; no existing assertion changed.",
        "The harness session context asks to assign the PR to os-steve and request his review through GitHub MCP writes; skipped, because the standard order forbids MCP writes and os-dev forbids PR assignees. The seat hands off reviewers.",
        "The merge commit of origin/main (cab3213f) carries git's default message without the session trailer pair; the pre-push hook accepted it."
      ],
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Stamped 2026-11-02T09:59Z.


    Generated by Claude Code

  6. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#5 · os-steve · session_01ADHkmNtfGcifoTscZGVzuo
    os-dev-report

    {
      "issue": 29173,
      "status": "done",
      "round": "FAIL round 1 (merge state only) — delta",
      "branch": "claude/project-thread-q7jqr4",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/29231",
      "session": "session_01ADHkmNtfGcifoTscZGVzuo",
      "premise_still_valid": true,
      "summary": "Merged origin/main 45e9b2f1 (carrying #29036 af8448fe9) into the branch with a merge commit, no rebase. new head 852d77a5. action-execution.ts auto-merged; the only conflict was the test harness boot() options in api-action-forward.test.ts, where this PR adds extraActions and #29036 adds extraObjects plus a served map. Both kept: objectDef spreads extraActions, served is built from objectDef plus extraObjects. Delta vs main is unchanged: 3 files, +115 / -2.",
      "tests": "head 852d77a5, after pnpm install and a rebuild of the runtime dependency closure, in one os-verify-lock run: VERDICT command-exit 0. pnpm --filter @objectstack/runtime typecheck passed; api-action-forward.test.ts 82/82 passed (77 before plus #29036's cases); full runtime suite Test Files 518 passed (518), Tests 9393 passed | 19 skipped (9412). Ablations from round 0 stand: the fix hunk is unchanged by the merge.",
      "gates": "head 852d77a5. dispatch-gates --commands --repo objectstack-ai/objectstack: 61 derived (change set 3 paths vs merge base 45e9b2f14), all 61 run, all exit 0. --ran: 61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. eslint --no-inline-config packages/runtime exit 0. CI not waited on.",
      "dogfood_battery": "n/a — no `!` in the PR title and no (narrowing) arm",
      "mcp_calls": "0 this round (3 in round 0)",
      "api_writes": "1 this round — this delta os-dev-report comment via post-stamped.mjs. git push is not a REST write.",
      "files_changed": [
        "packages/runtime/src/action-execution.ts",
        "packages/runtime/src/domains/api-action-forward.test.ts",
        ".changeset/29173-aggregate-api-action-record-id-param.md"
      ],
      "line_budget": "+115 / -2 across 3 files vs merge base, unchanged from round 0",
      "deviations": [
        "The PR body Verification section still cites round-0 readings at head cab3213f; the new-head readings are in this comment. PR body not edited."
      ],
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Stamped 2026-11-02T10:51Z.


    Generated by Claude Code

  7. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1

    Landed · #29173 · 2026-11-02T11:25Z

    reading value
    PR #29231 MERGED by the merge queue at 2026-11-02T11:25:11Z, merge commit e7d3ada2ef (head 852d77a5, ACCEPT 6574942234)
    this card closed completed by the PR's Fixes line

    State: pm:dispatched removed; the priority, area and domain labels stay. Nothing remains on this card.

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

Metadata

Metadata

Assignees

Labels

area:recordsBusiness objects, records, the views that show data, usable forms, searchdomain:serverDomain lane — seat card indexed by label:pm:seatpriority:p1High: required for production / M2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions