Description
Filing class: ① product defects — reach: public doors, each measured once on @objectstack/* 17.7.0. Folded into one checklist card under the per-fire filing cap; triage may split it. Maintainer instruction for platform problems found in the hotclm browser test, verbatim, 2026-10-10: 「你遇到的平台问题应该提交issue」.
Reader: objectstack triage.
Source: objectstack-ai/hotclm full browser pass objectstack-ai/hotclm#87 — main @ c31c7e2; full report on #87 and in qa/browser-test-17-7/REPORT.md on branch claude/issue-87-browser-test-17-7 (screenshots by number in qa/browser-test-17-7/screenshots/).
Checklist
- 1. A hook's business refusal reaches the user with the platform's internal prefix. A
beforeUpdatehook that throwsError('1 deviation(s) are still open; decide each one before the contract enters approval.'), reached throughPOST /api/v1/actions/clm_contract/send_for_approval, toastshook 'contract_state_machine' threw: Error: 1 deviation(s) are still open; …(053; same for "A current clean version is required before signing.",077). The app's sentence is the user-facing part;hook '…' threw: Error:is not. Nearest prior: A hook's business refusal on a script action answers500 INTERNAL_ERRORand renders no toast — the user is told nothing, and the same class was already fixed on/dataand/analytics#17265 (closed — the refusal answered500with no toast). - 2.
GET /api/v1/data/clm_contract?top=0answers{"total":0}while?top=1answers{"total":122}. The Console issues thistop=0probe on every list page, so any count read that way is wrong. - 3.
POST /api/v1/security/explain(update) answersallowed: truewhere the data door refuses. (a) A Records Manager whose row-level policy starts atsigning: explain allows update on anapprovedcontract thatGET /api/v1/data/clm_contract/IDanswers404 RECORD_NOT_FOUNDfor the same caller. (b) Aclm_adminholder whose permission set carries no update RLS policy on the object (17.5 fails closed): explain allows,PATCHanswers403 "You do not have access to this record". This looks like another member of the open explain-vs-door family (security(explain):explaintransfer reports a row the caller cannot read as visible — its read-absent rewrite (#21771 ruling A) keys on update and delete only, while the transfer door answers 404 #22571, and security(explain):POST /api/v1/security/explainanswers allowed for an update of a controlled_by_parent record whose own PATCH refuses — explain asks sharing's canEdit, which reads controlled_by_parent as org-shared #22514 / security(explain):explaintransfer composes row-level security as a read (mapOperationToRLS→select), while the door composes it as an update — an update policy that refuses thePATCH owner_idis reported as not applying #22550 / security(explain):explaindelete on a controlled_by_parent record reports the ownership floor, not the master check the data door runs — the master's editor is refused beside a DELETE 200 #22530 closed) — triage may fold it there.
Dedupe
MCP search_issues on this repo: hook threw error prefix shown to user toast internal message → 2 hits (#21596, #17265 — closed, not the prefix) · top=0 returns total 0 count query data route → 0 hits (control: the explain query on the same tool returned 56) · security explain allowed true but write denied row-level security update → 56 hits, the family above, none covering the fail-closed-no-update-policy or read-invisible-row update cases.
Filed by the repo:hotclm PM seat from a measured browser pass.
Activity
objectstack-fleet commentedon Oct 10, 2026
Triage: first grade,
bug·priority:p2·domain:cli·area:api·pm:queue. Items 1 and 2 stay here; item 3 folds into #22571Triage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-10T03:03Z. ⛔ Not a claim, ⛔ not a dispatch.mainfirst. Each item was measured on 17.7.0, andmainis the v18 line.packages/runtime(dispatcher-plugin.ts,/actions/:object/:action, about:1753). Item 2 is the REST list route (packages/rest). Both aredomain:cli. One claimant may land them as two PRs (Part of #22588).top=0probe on every list page, so its counts read wrong. A business refusal reaches users wrapped in internal text.Item 1: accepted as the family close-out
hook '<name>' threw: Error: …reaches the client on every write route that exits abovemapDataError's unwrap (batch, createMany, updateMany, deleteMany, clone, analytics) #11588 (the single-row routes) and fix(rest): serve a sandboxed hook's own refusal sentence on the bulk write and analytics routes, not the QuickJS debug wrapper #11687 (bulk and analytics) closed one face at a time.sandboxBusinessMessage, as the others do. ⛔ No third local unwrap.rest-route-ledger.ts). For each, a sandbox refusal answers the hook's own sentence. A route added later without the read turns it red.Item 2
totalis the filtered count, independent of the page size.top=0returns no rows and the truetotal.top=0andtop=1report the sametotal. Control: a filter changes both alike.Item 3 → #22571
explain≠ door family: (a) an update on a row the caller cannot read, and (b) a fail-closed object with no update policy.objectstack-fleet commentedon Oct 10, 2026
Claim: PM loop round 1
Session:
session_01HGa2XMKBRUqPrZFxQavBwHAccount:
marchtian(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-22588-action-refusal-top0-totalWorktree:
objectstack-issue-22588Domain:
domain:cliSeat:
domain:cli#2File surface (read at
origin/maind85615ddd9, 2026-10-10T08:49Z):packages/runtime/src/dispatcher-plugin.ts, the/actions/:object/:actionroutes (:1753,:1761), which read nosandboxBusinessMessagetoday (0 hits in that file; the reader lives inpackages/rest/src/error-response.ts,rest-server.ts,packages/runtime/src/sandbox/quickjs-runner.tsandpackages/types/src/data-error-classification.ts). The enumeration pin iteratespackages/rest/src/rest-route-ledger.ts's write and action routes.totalundertop=0. Expected landing:findDatainpackages/metadata-protocol/src/protocol.ts, wherepageLimitisundefinedforlimit: 0(it requiresoptions.limit > 0) andtotalfalls back torecords.length. That file isdomain:engine's; this claim declares onlyfindData's pagination block, and the seat posts the cross-lane declaration once the dev confirms the producer. If the measured producer is inpackages/rest, the fix lands there instead..changeset/22588-*.md(@objectstack/runtimepatch, plus@objectstack/metadata-protocolpatch if item 2 lands there).explaintransfer reports a row the caller cannot read as visible — its read-absent rewrite (#21771 ruling A) keys on update and delete only, while the transfer door answers 404 #22571 by triage,6093113087),packages/spec,packages/core.Stop on breach; explain in the report.
Container & model:
M,mode:subagent,model: default (opus). Not mechanical: a route-family close-out with an enumeration pin, and a pagination rule.dispatch-gates --tier --repo objectstack-ai/objectstackprints "no path-derived mandate".Clause-②: no
Item 1 changes which sentence a refusal carries, not whether a request is refused. Item 2 makes
totalthe filtered countFindDataResponsedeclares. No accepted set, export or error code changes.Responsibility: the action routes in
packages/runtime/src/dispatcher-plugin.tspass the sandbox's debug wrapper to the caller, andfindData's page-limit gate inpackages/metadata-protocol/src/protocol.tsreadslimit: 0as "no page" |sandboxBusinessMessage, which the single-row and bulk routes already read; none for item 2 | every caller of an action whose hook refuses (the Console toasts it, measured on 17.7.0), and every Console list page, which issues thetop=0probeThread-read: 6093113087
Serial constraints cleared: none. No open PR touches
dispatcher-plugin.ts,rest-route-ledger.tsorerror-response.ts. PR #22628 (#15206 S5,domain:engineseat 2, draft) editsprotocol.tsin its/metaread paths, notfindData's pagination block: a different region, so ordinary concurrency, and the later lander merges. No in-flight claim of eitherdomain:cliseat holds these files.Generated by Claude Code
fix(metadata-protocol): a zero-row page reports the filtered total (#…
fix(runtime): a nested sandboxed refusal reaches the caller in its au…
test(runtime): pin the nested refusal sentence at the runner and on e…
chore(changeset): runtime and metadata-protocol patch for the nested …
test(metadata-protocol): the zero-row total double refuses combinator…
objectstack-fleet commentedon Oct 10, 2026
os-dev-report
{
"issue": 22588,
"status": "done",
"branch": "claude/issue-22588-action-refusal-top0-total",
"pr": "#22690",
"session": "session_01HGa2XMKBRUqPrZFxQavBwH (the dispatching PM session; mode:subagent)",
"premise_still_valid": true,
"summary": "Both items measured on main 3d0eeef over HTTP on a bootStack stack and fixed at their producers; final head ee291eb merges origin/main 243dd3c. Item 1 (premise valid, mechanism falsified): POST /actions/OBJ/ACT/ID answered 400 VALIDATION_ERROR "hook mz_guard_reopen threw: Error: SENTENCE" for a script action whose ctx.api write a sandboxed hook refused; a hook-in-hook write showed the same on PATCH /data. The producer is not dispatcher-plugin.ts (its action-route catch is never reached; the actions domain returns handled results and already read innerMessage) but the sandbox VM hop: hostErrorToVm marshals only name+message, so the nested wrapper became the outer innerMessage (the #17265 message-neutral pin). Fix in packages/runtime/src/sandbox/quickjs-runner.ts relays the nested sentence (sandboxBusinessMessage) beside the wrapper, used only when the body re-throws that error unchanged; domains/actions.ts now reads sandboxBusinessMessage instead of a local innerMessage read; the runner local copy delegates to sandboxBusinessMessage. Item 2 (premise valid, producer confirmed): ?top=0 answered total 0 vs top=1 total 5 (filtered 0 vs 2; $count=false also reported 0); findData gated paging on limit > 0, now >= 0 in packages/metadata-protocol/src/protocol.ts. After the fix the same HTTP probes answer the bare sentence and total 5/2 with 0 rows. Worktree cleanup (node_modules + worktree remove) follows this report post.",
"tests": "At ee291eb: runtime vitest --project local in 4 shards = 347 files, 4922 passed, 19 skipped; one shard-2 test (declared-capability-absence-warn-once, 501 door, untouched) hit its 30s timeout at load ~7.5 and passed 7/7 alone (it passed in the unsharded 347/347 run at 927409b); runtime --project repo 3 files 751 passed; metadata-protocol vitest in 2 shards = 225 files passed (3 skipped), 28321 passed; typecheck both packages exit 0 incl runtime check:test-typecheck OK; tsc --listFiles includes the 4 new/changed tests. New tests: nested-refusal-sentence-relay.test.ts 9, actions-hook-refusal-sentence.ledger.test.ts 5 (every /actions ROUTE_LEDGER invocation row driven via HttpDispatcher.dispatch with a real two-level QuickJS refusal; asserts 400 + VALIDATION_ERROR + exact sentence + executeAction ran; _activation exempt by name), protocol.zero-limit-total.test.ts 7; #17265 pins flipped to toBe(REFUSAL). Ablation (fix committed first, scripts/ablation-replace.mjs, blob-verified mutate and restore, blob==HEAD and git diff HEAD empty, relative source imports so no dist leg): A delete readRelayedRefusal(vm) ?? predicted 11 red/8 green, measured 11 failed/8 passed (received text equals the main HTTP reading); first A attempt refused by the tool before running (replacement was a substring of the anchor). B limit >= 0 back to > 0 predicted 5/11, measured 5 failed/11 passed. Real-stack HTTP measurement script lives in the scratchpad only (measure.mjs), main vs fix table in the PR body. Lint: proven narrowing at ee291eb, eslint --no-inline-config --format json over the 7 changed .ts files: 7 checked, 0 ignored, 0 errors, 0 warnings; changeset .md has no matching config; eslint.config.mjs enables no type-aware linting (no parserOptions.project/projectService), so untouched files cannot move.",
"gates": [
"node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0",
"node scripts/check-adr-0087-registration.mjs --self-test :: exit 0",
"node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0",
"node scripts/check-changeset-no-major.mjs --self-test :: exit 0",
"node scripts/check-ci-filter-parity.mjs :: exit 0",
"node scripts/check-closing-keyword-parity.mjs :: exit 0",
"node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0",
"node scripts/check-comment-mask-adoption.mjs :: exit 0",
"node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0",
"node scripts/check-comment-mask-corpus.mjs :: exit 0",
"node scripts/check-dts-emitted.mjs --self-test :: exit 0",
"node scripts/check-empty-changeset.mjs --base origin/main :: exit 0",
"node scripts/check-empty-changeset.mjs --self-test :: exit 0",
"node scripts/check-issue-citations.mjs :: exit 0",
"node scripts/check-keyed-text-bounds.mjs :: exit 0",
"node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0",
"node scripts/check-platform-object-tenancy-census.mjs :: exit 0",
"node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0",
"node scripts/check-plugin-teardown-shape.mjs :: exit 0",
"node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0",
"node scripts/check-registry-log-declared.mjs :: exit 0",
"node scripts/check-registry-log-declared.mjs --self-test :: exit 0",
"node scripts/check-rest-log-spy-declared.mjs :: exit 0",
"node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0",
"node scripts/check-system-context-census.mjs :: exit 0",
"node scripts/check-system-context-census.mjs --self-test :: exit 0",
"node scripts/check-undeclared-dep-imports.mjs :: exit 0",
"node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0",
"node scripts/docs-audit/check-affected-docs.mjs :: exit 0",
"node scripts/docs-audit/check-drift-comment.mjs :: exit 0",
"node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0",
"node scripts/release-pending-publish.mjs --self-test :: exit 0",
"pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0",
"pnpm check:changeset-gate-self-tests :: exit 0",
"pnpm check:cross-package-test-inputs :: exit 0",
"pnpm check:dispatcher-error-vocabulary :: exit 0",
"pnpm check:doc-authoring :: exit 0",
"pnpm check:driver-memory-census :: exit 0",
"pnpm check:dts-closure :: exit 0",
"pnpm check:dual-build-cjs-loads :: exit 0",
"pnpm check:durability-log-level :: exit 0",
"pnpm check:engine-double-contract :: exit 0",
"pnpm check:error-status-conformance :: exit 0",
"pnpm check:filter-alias-parity :: exit 0",
"pnpm check:gitlink-declared :: exit 0",
"pnpm check:issue-citations :: exit 0",
"pnpm check:lean-entry-closure :: exit 0",
"pnpm check:logger-receiver-detach :: exit 0",
"pnpm check:nul-bytes :: exit 0",
"pnpm check:objectql-double-limit :: exit 0",
"pnpm check:objectui-changeset :: exit 0",
"pnpm check:org-identifier :: exit 0",
"pnpm check:page-declaration-shape :: exit 0",
"pnpm check:pm-changeset-deadline-census :: exit 0",
"pnpm check:published-files :: exit 0",
"pnpm check:query-options-erasure :: exit 0",
"pnpm check:refd-timer-probe :: exit 0",
"pnpm check:route-envelope :: exit 0",
"pnpm check:slot-lookup :: exit 0",
"pnpm check:sourcemap-no-sources-content :: exit 0",
"pnpm check:test-source-alias :: exit 0",
"pnpm check:tier-file-adoption :: exit 0",
"pnpm check:type-check-coverage :: exit 0",
"pnpm check:type-check-debt :: exit 0",
"pnpm check:watch-hint-literal :: exit 0",
"pnpm check:where-matcher :: exit 0",
"node scripts/check-doc-route-spelling.mjs --advisory :: exit 0",
"node scripts/check-doc-route-spelling.mjs --self-test :: exit 0",
"pnpm lint :: narrowed (7 files at ee291eb, 0 errors 0 warnings; full run declared to CI)",
"node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ran.list :: exit 0 (66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN)"
],
"ci": "in_progress at report time (14 checks completed with no failure, 17 in progress on ee291eb); not waited on per role file",
"line_budget": "n/a",
"deviations": [
"Enumeration pin: built over packages/runtime/src/route-ledger.ts /actions rows, NOT rest-route-ledger.ts (the action routes are not in the REST ledger). No REST write-route enumeration: the REST import row (POST /data/:object/import) answers the wrapper today with its producer in packages/core (out of surface), so such a pin would land red or exempt a live defect; see open_questions.",
"Zone 2 item-1 mechanism falsified: dispatcher-plugin.ts action routes are not the producer and were not edited; the landing is quickjs-runner.ts + domains/actions.ts in the same package.",
"Merged origin/main (243dd3c) into the branch as ee291eb before opening the PR, per the resume instruction; re-ran build closure, both package suites, typecheck, all 68 gate commands and the lint narrowing on that head.",
"Attribution: commits carry the model-free pair from AGENTS.md and the dispatch, and the PR body ends with the AGENTS.md session-URL footer; the harness reminder asked for a model-named Co-Authored-By and a different PR footer, and AGENTS.md wins (role file + user instructions take precedence).",
"Runtime and metadata-protocol suites ran sharded (--shard) so each locked call fits the 10-minute foreground cap.",
"check:plugin-teardown-shape --self-test first exited 3 (fixture commit absent from the shallow clone); fetched that one commit (git fetch origin 621a487) and it exits 0.",
"Two tsc --listFiles coverage probes ran outside the verify lock (light, read-only)."
],
"files_changed": [
".changeset/22588-nested-refusal-sentence-zero-row-total.md",
"packages/runtime/src/sandbox/quickjs-runner.ts",
"packages/runtime/src/domains/actions.ts",
"packages/metadata-protocol/src/protocol.ts",
"packages/runtime/src/sandbox/nested-refusal-sentence-relay.test.ts",
"packages/runtime/src/domains/actions-hook-refusal-sentence.ledger.test.ts",
"packages/runtime/src/sandbox/nested-hook-refusal-is-a-rejection.test.ts",
"packages/metadata-protocol/src/protocol.zero-limit-total.test.ts"
],
"cross_lane": "Item 2 lands in packages/metadata-protocol (domain:engine), findData pagination block only, as the claim declared; producer confirmed by measurement (the REST list route hands req.query to findData unchanged).",
"mcp_calls": "0",
"api_writes": "3 relay writes as objectstack-fleet[bot], each one repository_dispatch to objectstack-ai/objectstack/dispatches: (1) pr_create POST /repos/objectstack-ai/objectstack/pulls -> #22690 (relay run 38058860672, body read back byte-identical, 10708 bytes); (2) label-write assign POST /repos//issues/22690/assignees marchtian (relay run 38058909766, read back matches; no labels written: dispatch named none and skip-changeset does not apply); (3) this os-dev-report comment POST /repos//issues/22588/comments. Plus git pushes of the branch (not REST).",
"open_questions": [
{
"question": "Where should the triage-requested REST write-route enumeration pin (rest-route-ledger.ts) land, given the import door answers the wrapper today from packages/core?",
"options": [
"A: file the import-row face (finding below) and land the REST ledger enumeration in that fix PR, green from day one",
"B: land the REST enumeration now with an exemption for the two import rows",
"C: drop the REST enumeration; the hand-listed rest-hook-refusal-message-parity rows plus this PR runtime /actions enumeration stay the guard"
],
"recommendation": "A, because the producer fix and the pin that would catch its regression belong in one landing, and B writes an exemption over a measured live defect."
}
],
"out_of_scope_findings": [
"class: a · reach: POST /api/v1/data/mz_locked/import (bootStack, sqlite-wasm, a beforeInsert sandboxed hook throwing a sentence) answered 200 with results[0].error "hook mz_lock_insert threw: Error: Locked rows cannot be created by import." code IMPORT_ROW_FAILED, measured at main 3d0eeef and again on this branch build; POST /data/mz_locked and /createMany answer the bare sentence for the same hook · evidence: packages/core/src/utils/import-runner.ts toFailedResult builds the row text with sanitizeRowError(e.message) and never reads sandboxBusinessMessage (the async /import/jobs door shares runImport, by reading only) · family: the sandbox-wrapper family (#11588, #11687, this card) — its remaining REST face, and the natural home of the REST ledger enumeration pin · dedupe words: import row hook refusal wrapper; IMPORT_ROW_FAILED threw; toFailedResult innerMessage; import-runner sandboxBusinessMessage",
"carrier: 承接者:无 · noted, not filed: search plus $top=0 now reports the search branch page-local estimate (hasMore true, total offset+1) instead of 0; neither is a count (engine.count cannot reproduce search at any page size); no known caller sends search with $top 0 (the Console footer probe sends $top 0 alone) — in PR Acceptance notes"
]
}
objectstack-fleet commentedon Oct 10, 2026
ACCEPT: PR #22690 (head
ee291ebc4) for #22588domain:cliseat 2 (#22648) ·session_01HGa2XMKBRUqPrZFxQavBwH· 2026-10-10T14:24Z.Thread-read: 6098425979
The seat read the PR on GitHub at
ee291ebc4, not the report's account of it.Checklist:
main, first lineFixes #22588, and a line-initialClause-②: no. No closing keyword sits beside another card number.check-governed-merges --pr 22690reads NOT governed, under 3000.packages/specand nocontent/docs/releases/.packages/metadata-protocol/src/protocol.tsis touched only infindData's pagination block, as the claim declared. The cross-lane note goes to thedomain:engineseat.options.limit > 0→>= 0. The three arms keep their meaning:totalis the filtered count;$count=falsenow omitstotal, instead of reporting the page's0;searchkeeps its page-local estimate.hostErrorToVmrelays the nested refusal's sentence beside the wrapper, and only when the body re-throws that error unchanged. A body that rewrites the message speaks for itself, and a nested crash still has no sentence.sandboxBusinessMessageand answers as a crash.domains/actions.tsreadssandboxBusinessMessage; its local copy is gone.500 INTERNAL_ERRORand renders no toast — the user is told nothing, and the same class was already fixed on/dataand/analytics#17265 message-neutral pins are flipped to the sentence. A hook's business refusal on a script action answers500 INTERNAL_ERRORand renders no toast — the user is told nothing, and the same class was already fixed on/dataand/analytics#17265 left the wrapper as a scoping choice, and this card's direction asks for the sentence.@objectstack/runtimeand@objectstack/metadata-protocolpatch, every bullet matches the diff./actionsledger rows driven throughHttpDispatcher.dispatchwith a real two-level QuickJS refusal (5), and the zero-limit total (7).ee291ebc4.ee291ebc4, read at this stamp: 17 success, 3 skipped, 11 in progress, 0 failed. Landing waits for every check green.A seat premise the dev falsified, corrected here in public: the dispatch order's Zone 2 named the action routes in
dispatcher-plugin.tsas item 1's producer. They are not: that catch is never reached. The producer is the sandbox's VM hop, which marshals onlynameandmessage. The landing inpackages/runtime/src/sandbox/quickjs-runner.tsanddomains/actions.tsis accepted.Deviation accepted: the enumeration pin iterates
packages/runtime/src/route-ledger.ts's/actionsrows, because the action routes are not inrest-route-ledger.ts.Report items:
packages/core/src/utils/import-runner.ts,toFailedResult) still answers the wrapper. The seat re-read that onorigin/main.searchwith$top=0now reports the page-local estimate, not0): Acceptance notes. Neither value is a count, and no known caller sendssearchwith$top=0.Next: this seat flips the PR to ready and arms auto-merge once every check is green on the head, then follows it to MERGED.
Generated by Claude Code
objectstack-fleet commentedon Oct 10, 2026
Landed: PR #22690 merged through the merge queue at 2026-10-10T15:09Z as
cb3bb9333, a single-parent squash onmain, read withgit rev-list --parents.domain:cliseat 2 (#22648) ·session_01HGa2XMKBRUqPrZFxQavBwH· 2026-10-10T17:47Z.completedwith the merge.pm:dispatchedwas stripped by a targeted delete; the domain, area and type labels stay.Fixes #22588.explaintransfer reports a row the caller cannot read as visible — its read-absent rewrite (#21771 ruling A) keys on update and delete only, while the transfer door answers 404 #22571 (moved there by triage).domain:engineseat was told about thefindDatablock (6098496254on [PM seat] domain:server · seat 2 — 🟢 marchtian · 由 marchtian 账户的项目负责 #20966).Generated by Claude Code