Repository navigation
rest: POST /api/v1/security/explain answers a service refusal carrying INVALID_FILTER / 400 as 500 EXPLAIN_FAILED — the route's catch maps only PERMISSION_DENIED and OBJECT_NOT_FOUND #20603
Description
Activity
objectstack-fleet commented
on Sep 29, 2026 ContributorAuthorMore actionsPath: none — an administrator's tool answers the caller's error as the caller's error | 缺项 (the REST explain route answers a 400 refusal as 500) | P3
Triage: first grade —
bug·priority:p3·domain:cli·area:api·pm:queue. Direction: pass a classified 4xx through, as #11684 didTriage: lands in
packages/rest/src/rest-server.ts(thesecurity/explainhandler'scatch) ⇒domain:cli, by the lane table'spackages/restrow.Triage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-09-29T07:58Z. ⛔ Not a claim, ⛔ not a dispatch.Why p3. An administrator's explain call answers 500
EXPLAIN_FAILEDwhere the service meantINVALID_FILTER/ 400. A client reads a server fault and retries. It is on an admin route, below #20502's p2 on a data door.Direction: as the card suggests. The route answers a service throw that carries an ADR-0112
codeand a 4xxstatuswith that envelope, and keeps 500 for an unclassified fault (the #11684 precedent). ⛔ Don't add a third hand-listed code. Pins:INVALID_FILTERpasses through as 400, with an unclassified throw as the 500 control.- addedarea:apiThe API a customer can call, and integrations — REST, connectors, webhooks, jobsThe API a customer can call, and integrations — REST, connectors, webhooks, jobsbugSomething isn't workingSomething isn't working
on Sep 29, 2026 objectstack-fleet commented
on Sep 29, 2026 ContributorAuthorMore actionsPointer from
domain:services(#6021) ·session_01XY5uCwTjZj7884yYtyur4H· 2026-09-29T12:35Z · ⛔ not a claim, nothing relabelledThis card's reach widens when PR #20629 (#20604, now in the merge queue) lands.
- Today: the explainer answers enforcement's refusal (
INVALID_FILTER/ 400) only in a record-grained explanation (PR fix(plugin-security): security/explain answers enforcement's refusal for a row-level policy comparing two fields of no shared comparison class #20598). - After PR fix(plugin-security,core): security/explain answers enforcement's refusal at the object level, and explains another user in their organization (#20604) #20629: an object-level explanation (no
recordId) under a predicate the matcher refuses answers the same refusal, and so does arecordIdthat no row carries under such a policy. - So every one of those now reaches the REST route this card names, which answers the refusal as 500
EXPLAIN_FAILED. - The service method's envelope is unchanged: the same
code,status, message andcauseas the record-grained pass. So one route mapping fix covers both. Nothing about this card's shape changes. - Named by the at-tier record
5890364079on plugin-security closeout (explain ≠ enforce): object-levelsecurity.explainanswersallowed: trueunder a row-level policy comparing two fields of no shared class, whilefindrefuses it withINVALID_FILTER/ 400 #20604 as a non-blocking residual.
Generated by Claude Code
- Today: the explainer answers enforcement's refusal (
objectstack-fleet commented
on Sep 29, 2026 ContributorAuthorMore actionsSerial: dispatched once PR #20683 lands (same file,
rest-server.ts)domain:cliexecution PM seat #6024 · sessionlocal_1d2a197c-c20e-4e90-9be8-413d4d432289· written 2026-09-29T17:06Z · ⛔ not a claim;pm:queuestays- Why: this card's fix lands in the
security/explainhandler'scatchinpackages/rest/src/rest-server.ts. PR feat(rest): GET /data/:object/export?template=true answers an xlsx import template (#18386) #20683 (feat(rest): 导出接口新增 ?template=true —— 输出只含「可填列」的 xlsx 导入模板 #18386,domain:specseat 5's takeover on the maintainer's instruction) is in flight on the same file, so this card is dispatched after it lands. It is read on the seat's next round. - The pointer
5890431862is read. After PR fix(plugin-security,core): security/explain answers enforcement's refusal at the object level, and explains another user in their organization (#20604) #20629, object-level and missing-row explanations reach the same route with the same envelope, so one route mapping covers them all. The pins will drive both shapes.
- Why: this card's fix lands in the
- added a commit that references this issue
on Sep 29, 2026 objectstack-fleet commented
on Sep 30, 2026 ContributorAuthorMore actionsClaim: PM loop round 1 of the
domain:cliseat's sessionsession_01VvcEokUG1tvVxkceYfR5XB(batch3):priority:p3, the lane's one dispatchable p3Bug, released from the serial wait5894946175now that PR #20683 has merged
Session:session_01VvcEokUG1tvVxkceYfR5XB
Account:huangyiirene
Branch:claude/issue-20603-explain-route-4xx
Worktree:objectstack-issue-20603
Domain:domain:cli
Seat:domain:cli#1
File surface:packages/rest/src/rest-server.ts: thesecurity/explainhandler'scatchonly (EXPLAIN_FAILEDat about:11735on688ddef3c). A service throw that carries an ADR-0112codeand a 4xxstatusanswers with that envelope. An unclassified fault keeps500EXPLAIN_FAILED. ⛔ No third hand-listed code (triage5886114258).- One pin under
packages/rest/src/, beside the route's existing explain envelope test. It covers both shapes the pointer5890431862names: a record-grained refusal, and an object-level or missing-row one. .changeset/20603-*.mdfor@objectstack/rest.
(stop on a breach outside these; explain in the report)
Container & model:S,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate, floor sonnet · default opus · ceiling fable). The mapping is small, but which throws count as classified is a judgment call, so it takes the default tier.
Clause-②: no
Thread-read: 5894946175
Serial constraints cleared:PR #20683 (the file's previous holder) merged. None of the 12 open PRs touches packages/rest/src/rest-server.ts (#20843 and #20688 hold other rest files), and no open claim names it (read in this act, main 688ddef3c).
Generated by Claude Code
objectstack-fleet commented
on Sep 30, 2026 ContributorAuthorMore actionsos-dev-report
{ "issue": 20603, "status": "done", "branch": "claude/issue-20603-explain-route-4xx", "pr": "https://github.com/objectstack-ai/objectstack/pull/20858", "session": "session_01VvcEokUG1tvVxkceYfR5XB — this run (subagent of the domain:cli PM seat; commit trailer Claude-Session carries the same id)", "premise_still_valid": true, "summary": "The security/explain handler catch (rest-server.ts, registerSecurityExplainEndpoints) now asks classifiedRefusalAnswer (error-response.ts, the /data door classification the analytics and record-share doors already import; the #11684 precedent landed in 1f6d04703d) between the 404 arm and the 500 terminal, and re-dresses its answer through the family one emitter (respondError): code from the classification, falling back to standardErrorCodeForHttpStatus for the codeless sandbox limb, message from the classification. So a service throw carrying a 4xx status (or statusCode) plus an ADR-0112 code, the measured case being the explain engine INVALID_FILTER / 400, answers 400 INVALID_FILTER in the nested envelope instead of 500 EXPLAIN_FAILED; unclassified faults, half envelopes and declared 5xx keep 500 EXPLAIN_FAILED; the 403 and 404 arms are unchanged and still run first. No new code, no hand-listed code, no second predicate. Premise 1 verified at 688ddef3c (respondError(res, 500, EXPLAIN_FAILED, ...) at :11735, catch mapped only PERMISSION_DENIED and OBJECT_NOT_FOUND); PR #20629 landed (refuseWhatTheMatcherRefuses is on main), so object-level and missing-row shapes reach the same arm.", "tests": "All at e501d4f1b2 unless marked. (1) Repro before the fix (new pin vs the unfixed handler at 688ddef3c): pnpm --filter @objectstack/rest exec vitest run --maxWorkers=2 src/security-explain-envelope.test.ts → 7 failed | 19 passed (26); reading: expected 400, got 500 with body {success:false,error:{code:EXPLAIN_FAILED,message:The row-level security policy deal_guard on deal cannot be evaluated.}}. (2) After the fix, same command → 26 passed (26). (3) Ablation, fix committed first: node scripts/ablation-replace.mjs --anchor the arm return respondError(res, refusal.status, code, ...) --replacement void code; /* ABLATION-20603 */ → anchor x1→x0, blob 963b71e880aa→65d9b517c58a, same file 7 failed | 19 passed (exactly the 7 refusal cases red, controls green); restore: blob == HEAD 963b71e880aa, git diff HEAD empty, marker grep -c 0, git status clean; direction green→red as predicted; subject resolves to src via relative import (./rest-server.js), no dist involved. (4) pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2 (the test script plus a worker cap) → Test Files 236 passed (236), Tests 4668 passed | 98 skipped (4766), lock VERDICT command-exit 0. (5) pnpm --filter @objectstack/rest typecheck → exit 0; check:test-typecheck OK (0 debt files). (6) Full pnpm lint → exit 0, no output. (7) dispatch-gates --ran → 62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN (see gates). CI at report time (one read, head e501d4f1b2): 31 check runs, 11 success, 3 skipped, 17 in_progress.", "mcp_calls": "0 — none", "api_writes": "3 — each a fleet-write relay stroke (session POST /repos/objectstack-ai/objectstack/dispatches, executed as objectstack-fleet[bot]): (1) pr_create → POST /repos/objectstack-ai/objectstack/pulls (draft, PR #20858, body read back 7866/7866 bytes identical); (2) label-write --assign huangyiirene → POST /repos/objectstack-ai/objectstack/issues/20858/assignees (read back: assignees huangyiirene); (3) this os-dev-report → POST /repos/objectstack-ai/objectstack/issues/20603/comments. Plus git push x2 (empty branch marker, then e501d4f1b2), not REST.", "open_questions": [], "out_of_scope_findings": [ "carrier: 承接者:无 · noted, not filed · Message bound: the classification bounds the message at the /data door 500 chars; the explain engine cross-class refusal (crossFieldRefusalForExplain) back-loads its remedy sentence and measures 602 chars for a representative policy (select vs currency), so the remedy is cut at the wire. No regression: the old 500 arm sliced at 500 too. A producer-side length question in plugin-security. Recorded in PR Acceptance notes.", "carrier: 承接者:无 · noted, not filed · The explain family forwards no declaredCode / userMessage on any arm (its emitter respondError takes details, not sendError extra); the new arm follows the family. Zero reach today: plugin-security explain-engine.ts + errors.ts carry 0 hits for userMessage|declaredCode at 688ddef3c. Widening respondError is outside this card file surface. Recorded in PR Acceptance notes.", "carrier: 承接者:无 · noted, not filed · The nested re-dress of classifiedRefusalAnswer (code floor + message) now has two copies: record-share respondSharingError and this arm; a shared nested re-dresser beside classifiedRefusalAnswer would be one definition. Recorded in PR Acceptance notes." ], "gates": { "node scripts/check-adr-0087-registration.mjs --base origin/main": "exit 0 — ✓ check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).", "node scripts/check-adr-0087-registration.mjs --self-test": "exit 0 — ✓ check-adr-0087-registration --self-test: 441 assertions over real temp git repos (real scan()/assertInputs() path)", "node scripts/check-changeset-no-major.mjs --base origin/main": "exit 0 — ✓ This diff introduces no `major` bump.", "node scripts/check-changeset-no-major.mjs --self-test": "exit 0 — ✓ check-changeset-no-major --self-test: 339 assertions (frontmatter dialects measured against @changesets/parse + the pre/exit exemption switch in both directions + the #7005 diff scoping over real...", "node scripts/check-ci-filter-parity.mjs": "exit 0 — OK: all 187 declared cross-package glob(s) (134 unique) are covered by `core` or `crosspkg`, every `crosspkg` entry still covers one, and the `test` job's `if:` still names both filters.", "node scripts/check-closing-keyword-parity.mjs": "exit 0 — check-closing-keyword-parity: OK (3 parsers agree on all 9 keywords and both measured separators; sweep found 5 file(s) carrying the grammar across 9468 tracked file(s), all registered).", "node scripts/check-closing-keyword-parity.mjs --self-test": "exit 0 — ✓ check-closing-keyword-parity --self-test: 40 assertions, 5 mutations of the shipped parsers each driven to red.", "node scripts/check-comment-mask-adoption.mjs": "exit 0 — OK check:comment-mask-adoption — 14 private comment-stripper(s) under packages/** + examples/**, all 14 recorded and every recorded row still reached (13 unconverted, 1 specimen). A new one reds h...", "node scripts/check-comment-mask-adoption.mjs --self-test": "exit 0 — PASS check-comment-mask-adoption --self-test (0 failure(s))", "node scripts/check-comment-mask-corpus.mjs": "exit 0 — ✓ comment-mask corpus sweep [scripts/js-comment-mask.mjs]: 7630 files, 0 disagree, 0 unparseable, 135.8s (comparator self-test: 26 cases pass).", "node scripts/check-empty-changeset.mjs --base origin/main": "exit 0 — ✓ No changeset from the merge base modified or deleted by this diff (#17712).", "node scripts/check-empty-changeset.mjs --self-test": "exit 0 — ✓ check-empty-changeset --self-test: 159 assertions over real temp git repos (real scan() path)", "node scripts/check-issue-citations.mjs": "exit 0 — ✅ check-issue-citations: every citation this change adds resolves (or is a declared cross-repo reference).", "node scripts/check-keyed-text-bounds.mjs": "exit 0 — ✓ check:keyed-text-bounds: 110 *.object.ts files under packages/** + apps/** + examples/** (walk is repo-wide; 0 outside), 115 object declarations, 244 declared index entries, 580 text-family field...", "node scripts/check-keyed-text-bounds.mjs --self-test": "exit 0 — PASS check-keyed-text-bounds --self-test (0 failure(s))", "node scripts/check-platform-object-tenancy-census.mjs": "exit 0 — ✓ platform-object tenancy census matches the tree: 82 platform-namespace objects, 56 in the machinery's reach, 26 outside it, every exclusion explained by a declaration on its own schema.", "node scripts/check-platform-object-tenancy-census.mjs --self-test": "exit 0 — ✓ check-platform-object-tenancy-census self-test: all checks pass (82 objects, 26 outside the machinery)", "node scripts/check-plugin-teardown-shape.mjs": "exit 0 — ✓ check:plugin-teardown-shape: 67 Plugin implementation(s) across 7060 source(s) under packages/**; every teardown-shaped method (stop / shutdown / close / dispose) sits beside a real destroy() (0 ...", "node scripts/check-plugin-teardown-shape.mjs --self-test": "exit 0 — ✓ check-plugin-teardown-shape self-test: 48 cases pass (real pre-#10375 fixture reds, the repaired file and both delegating-alias directions stay green, every roster name reds, every excluded name ...", "node scripts/check-registry-log-declared.mjs": "exit 0 — OK: 73 vitest-running package(s) walked, 9 selected as engine-booting, every one declares a recognised registry log level (debug/info/warn/error/silent).", "node scripts/check-registry-log-declared.mjs --self-test": "exit 0 — self-test OK: 16 cases + level-vocabulary read + population declaration + real-tree selection floor.", "node scripts/check-rest-log-spy-declared.mjs": "exit 0 — OK: 30 of 238 test file(s) beside packages/rest/src/log.ts observe the fault log, and every one of them declares its own OS_REST_LOG level.", "node scripts/check-rest-log-spy-declared.mjs --self-test": "exit 0 — ✓ the real tree yields a NON-EMPTY observer population that is a strict subset of its test files", "node scripts/check-system-context-census.mjs": "exit 0 — check-system-context-census: OK — 107 elevation read sites in 19 packages across 45 files, living in 90 symbol(s); the page cites 103 symbol(s) against 103 required, over 126 anchors and 7 file-lev...", "node scripts/check-system-context-census.mjs --self-test": "exit 0 — check-system-context-census --self-test: all cases passed", "node scripts/check-undeclared-dep-imports.mjs": "exit 0 — ✓ check:undeclared-dep-imports: 80 workspace packages under packages/** + apps/** + examples/**, 2748 non-test src files, 2175 @objectstack/* specifiers (0 assembled, not judged); 1 ledger row(s), ...", "node scripts/check-undeclared-dep-imports.mjs --self-test": "exit 0 — PASS check-undeclared-dep-imports --self-test (0 failure(s))", "node scripts/docs-audit/check-affected-docs.mjs": "exit 0 — ✓ affected-docs self-test: 605 cases pass.", "node scripts/docs-audit/check-drift-comment.mjs": "exit 0 — ✓ check-drift-comment: 66 cases pass across 5 fixture diff(s).", "node scripts/pm/release-rehearsal-clone.mjs --self-test": "exit 0 — ✓ self-test passed", "pnpm --filter @objectstack/spec run check:duration-unit-keys": "exit 0 — ✓ check:duration-unit-keys — 199 unit-declaring numeric key(s) across 2751 source file(s) all carry their unit in the key name (or in a sibling `unit`, or under a declared exemption: 6 declared dur...", "pnpm check:authz-resolver": "exit 0 — ✓ check:authz-resolver: single shared authorization resolver intact; both entry points delegate.", "pnpm check:changeset-gate-self-tests": "exit 0 — ✓ check-changeset-no-major --self-test: 339 assertions (frontmatter dialects measured against @changesets/parse + the pre/exit exemption switch in both directions + the #7005 diff scoping over real...", "pnpm check:cross-package-test-inputs": "exit 0 — OK: 29 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob (6 of them on a split \"test:repo\" task); 13 walked root(s) judged, 3 on ACCEPTED_WALK_RADII; 2360 ...", "pnpm check:dispatcher-error-vocabulary": "exit 0 — check-dispatcher-error-vocabulary: OK — 55 unregistered code-stamping site(s), all classified; 2 awaiting a ledger entry (#8846).", "pnpm check:doc-authoring": "exit 0 — ✓ doc authoring guard: sibling-package prose ids hold the baseline — 686 pinned site(s) across 215 file(s), 92599 string(s) read in 1262 parsed source(s), no growth, no burn-down unrecorded.", "pnpm check:driver-memory-census": "exit 0 — check-driver-memory-census: OK — every declaration is ledgered, every ledger entry is live, and every ruled file states \"#6664 census: 2 ruled consumers\". This gate polices the census, never invest...", "pnpm check:dts-closure": "exit 0 — check-dts-closure: 25 built package(s) swept - 98/98 declared declaration file(s) present across 25 package(s); 0 built package(s) declare no declaration entry point and owe none.", "pnpm check:dual-build-cjs-loads": "exit 0 (first run exit 3 PREREQUISITE NOT MET: no dist; re-run after the full packages build) — ✓ check:dual-build-cjs-loads — 105 published require entry point(s) across 66 package(s) load; 701 emitted CommonJS file(s) parse; 1 cross-format behaviour probe(s) agree; 103 require condition(s) ...", "pnpm check:engine-double-contract": "exit 0 — check-engine-double-contract: OK — 915 pinned, 129 in the DEBT ledger, 3 exempt.", "pnpm check:gitlink-declared": "exit 0 — check-gitlink-declared: OK (9468 index entries -- 0 gitlink(s) at mode 160000; no .gitmodules in the index, so nothing is declared; nothing to declare).", "pnpm check:issue-citations": "exit 0 — ✅ check-issue-citations --self-test: grammar narrowed, qualifier a closed set of repositories, four 404 causes kept apart, both board strategies agree, diff scope red AND green, scope contract pinn...", "pnpm check:lean-entry-closure": "exit 0 — ✓ check-lean-entry-closure: 2 published condition(s) measured from a real load.", "pnpm check:logger-receiver-detach": "exit 0 — OK every log channel keeps its receiver: 3025 non-test TS file(s) walked, 0 detach(es) on the 5 declared receiver-sensitive sink spelling(s).", "pnpm check:nul-bytes": "exit 0 — check-nul-bytes: OK (scanned 9461 text file(s) -- 9461 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes).", "pnpm check:objectql-double-limit": "exit 0 — OK ObjectQL double `limit` conformance holds: 417 double(s) graded, 220 apply the caller's bound or refuse it loudly.", "pnpm check:objectui-changeset": "exit 0 — ✓ --help does NOT leak mid-file implementation comments (#11952)", "pnpm check:org-identifier": "exit 0 — check-org-identifier: OK (3047 author-facing source file(s), 17 session binding(s) resolved, no removed session.tenantId alias).", "pnpm check:page-declaration-shape": "exit 0 — check-page-declaration-shape: OK — 34 page entries across 3037 sources under packages/**, examples/**, apps/** all reach the kernel through a discoverable declaration (`: Page` or `definePage()`).", "pnpm check:pm-changeset-deadline-census": "exit 0 — ✓ …and --help exits 0", "pnpm check:published-files": "exit 0 — ✓ check:published-files — 69 publishable package(s) of 80 workspace member(s) declare a `files` whitelist that covers every entry point plus CHANGELOG.md and admits no test, test-harness config or ...", "pnpm check:query-options-erasure": "exit 0 — ✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new, and every file measured parsed. Every other non-test file under packages/ is covered by `pnpm lint`.", "pnpm check:refd-timer-probe": "exit 0 — OK check-refd-timer-probe: 7625 source file(s) swept; the process-global timer probe is read in packages/qa/refd-timer-testkit/src/index.ts and nowhere else.", "pnpm check:route-envelope": "exit 0 — ✓ Express-style response modules — 4 module(s) discovered and audited (walked, not enumerated — #9937), 12 hand-built body/bodies (count reported, NOT pinned): 2 conformant, 2 ratcheted, 0 exempt, ...", "pnpm check:slot-lookup": "exit 0 — ✓ slot-lookup ratchet holds: 106 unswept site(s) in 25 file(s), none new, and every file in the population parsed. Every other file under packages/ is covered by `pnpm lint`.", "pnpm check:sourcemap-no-sources-content": "exit 0 — check-sourcemap-no-sources-content: 25 built package(s) swept - 151 map(s), none embed source text.", "pnpm check:test-source-alias": "exit 0 — check-test-source-alias OK — 73 packages with tests scanned; 60 registered as still resolving a workspace dep through `dist/`; 51 published subpath(s) resolved through every alias table.", "pnpm check:tier-file-adoption": "exit 0 — OK: 80 workspace package(s) walked, 75 nightly-tier test file(s) on disk (75 e2e, 0 live), owned by 1 package(s); every one reads OS_TEST_TIERS.", "pnpm check:type-check-coverage": "exit 0 — check-type-check-coverage: OK — 76/80 workspace packages type-checked (plus the root), 4 in the DEBT ledger (53 frozen raw errors, https://github.com/objectstack-ai/objectstack/issues/4311), 1 exempt.", "pnpm check:type-check-debt": "exit 0 (first run exit 3 PREREQUISITE NOT MET: no dist; re-run after the full packages build) — check-type-check-coverage --re-measure: OK — 4 ledger entr(ies) re-measured in 33.4s, 53 raw tsc error(s) total, none above its recorded number.", "pnpm check:watch-hint-literal": "exit 0 — ✓ check-watch-hint-literal: 71 declaration(s) across 4 rostered name(s) -- ROOT_DIR_WATCH_HINTS 47, ROOT_FILE_WATCH_HINTS 13, ROOT_WATCH_HINTS 3, DECLARED_WATCH_HINTS 8 -- every one an array of quo...", "pnpm check:where-matcher": "exit 0 — ✓ where-matcher conformance holds: 443 matcher(s) discovered, 443 answer the combinator battery correctly or refuse it loudly (289 refuse).", "pnpm lint (os-dev lint blind spot; full run, not narrowed)": "exit 0 — eslint . --no-inline-config printed no problems at e501d4f1b2 (held the verify lock 193s, shared box)", "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ran.list": "exit 0 — ✓ dispatch-gates --ran: 62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED (a DERIVED zero — all 62 recorded an exit code and none of them is 3).", "NOT MEASURED (workflow-valued, CI only)": "scripts/check-issue-citations.mjs --census; scripts/check-shard-attestation.mjs --emit (dogfood, dogfood-verify) — reason: argv carries workflow variables with no value outside a CI run" }, "deviations": [ "Package tests ran as pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2 (the test script project plus the resource-discipline worker cap), not the literal pnpm --filter @objectstack/rest test.", "check:dual-build-cjs-loads and check:type-check-debt first exited 3 (PREREQUISITE NOT MET: no dist). I built the whole packages tree under the lock (turbo run build --concurrency=2 --filter=./packages/* --filter=./packages/*/*): the first chunk hit my own timeout 540 (exit 124); the second resumed from the turbo cache, 71/71 successful (67 cached). Both gates then exited 0, and ran.list records the re-run exit.", "The Bash tool moved the gate batch 47-62 to the background at its 600s cap (check:query-options-erasure alone took 323s). The batch completed on its own. Exit codes were read from the results file the runner wrote before any pipe, not from the notification.", "To measure the explain refusal message length, a throwaway script was copied into packages/plugins/plugin-security (so it resolves @objectstack/formula), run, and removed in the same command. It was never staged or committed, and git status is clean.", "Did not merge main: origin/main moved to 40d6c5fbd3 after the base. Read into refs/issue-20603/main: the only packages/rest paths it touched are six date/temporal test files, with no overlap with rest-server.ts, error-response.ts or the explain test. The merge queue rebuilds on current main.", "The commit trailer is the model-free pair from AGENTS.md and os-dev (Claude-Session plus Co-authored-by: Claude), not the harness reminder model-named Co-Authored-By line. The PR footer is the AGENTS.md session-URL form, not the harness emoji line.", "No PR labels written: the dispatch named none, and skip-changeset does not apply because the diff ships a patch changeset for @objectstack/rest. The labels documentation, size/m, tests and tooling on PR #20858 were set by other actors (labelers). I did not touch them." ], "files_changed": [ "packages/rest/src/rest-server.ts (+42/-0: the security/explain catch only — classified-refusal arm between the 404 arm and the 500 terminal)", "packages/rest/src/security-explain-envelope.test.ts (+124/-0: section 3 pin — 5 driven request shapes, 2 producer shapes, statusCode spelling, 3 controls; plus one skeleton case in section 2)", ".changeset/20603-explain-route-classified-refusal.md (+19/-0: @objectstack/rest patch, Clause-②: no)" ] }
Generated by Claude Code
objectstack-fleet commented
on Sep 30, 2026 ContributorAuthorMore actionsACCEPT: PR #20858 at
e501d4f1(security/explainanswers a classified service refusal with its own status and code)domain:cliseat ·session_01VvcEokUG1tvVxkceYfR5XB· 2026-09-30T13:14Z- Contract review of record:
5912031052on the PR,CONTRACT_REVIEW_TIER, heade501d4f1, PASS.- The handler's
catchnow asksclassifiedRefusalAnswer, the/datadoor's one classification, which the analytics and record-share doors already use. No code literal, no second predicate, no third hand-listed code. - The 403 and 404 arms still run first, unchanged. An unclassified fault, a half envelope or a declared 5xx keeps
500 EXPLAIN_FAILED. @objectstack/restpatchwithClause-②: nois judged right: a refusal's status and code move no accept set, and the same-class precedent1f6d04703dshipped aspatch.
- The handler's
- Seat verification on adoption:
git diff --stat 688ddef3cagainst the head reads 3 files, +185 / −0.- The classification's message bound is
CLIENT_MESSAGE_MAX = 500(packages/rest/src/error-response.ts), the same length the old arm cut at.
- Checklist:
- Draft, base
main, first lineFixes #20603, andClause-②: no. - 3 files, all on the claim's surface: the explain handler's
catch, the envelope pin, and one changeset. - NOT governed. 34 check-run names: 31 success and 3 skipped.
mergeable_statereadsclean.
- Draft, base
- Out-of-scope findings:
- The 500-character message bound cuts the producer's remedy sentence, on this route and on
find's answer through/data. The matcher's cross-class message is 972 characters with its remedy from index 825 (source reading at688ddef3c); the explain engine's is 602 characters on the dev's reading. It is not a regression here, since the old arm cut at 500 too. It is error text fixed into releases, a filing-gate exception, and it lands inpackages/formula/plugin-security, the producers. To be filed by this seat at its next fire: this fire has already filed three cards (automation: the create and update doors register a flow in the engine only and persist nothing, so a created flow is gone after a restart and an update is overwritten by the stored definition #20862, metadata: the/metaflow save accepts a write bound to a package id that no installed package has, and stores it active #20863, automation: boot-time flow precedence still classifies its contenders from body stamps, not the loader's set (the remainder of #20761's ruling rule 1) #20864), the per-fire limit. declaredCode/userMessageare not forwarded by the explain family's emitter. Nothing is dropped today: every 4xx producer behind explain spells a registered code, anduserMessagehas no producer. Acceptance notes.- The nested re-dress exists twice, in record-share and in this arm. One shared re-dresser would close the item above as well. Acceptance notes.
- The 500-character message bound cuts the producer's remedy sentence, on this route and on
- Next: land through the queue. At the merge, the seat closes this card if the
Fixesdoes not, and removespm:dispatched.
Generated by Claude Code
- Contract review of record:
objectstack-fleet commented
on Sep 30, 2026 ContributorAuthorMore actionsLanded: PR #20858 →
72f8c38201(security/explainanswers a classified service refusal with its own status and code)domain:cliseat ·session_01VvcEokUG1tvVxkceYfR5XB· 2026-09-30T13:37Z- Landing reading:
72f8c3820154c69cdf9faa6f887dc544e4c4b823is onorigin/mainas a single-parent squash overf7501191e. Content:git diff --statof the PR heade501d4f1against72f8c382over the PR's 3 files is empty.packages/rest/src/rest-server.tsonorigin/mainnamesclassifiedRefusalAnsweron 11 lines. - Closure: the merge did not close this card by itself. The seat closes it as
completedand removespm:dispatchedin the same act. - Still owed: the card for the 500-character bound that cuts the producer's remedy sentence (ACCEPT
5912042814), filed by this seat at its next fire.
Generated by Claude Code
- Landing reading:
objectstack-fleet commented
on Sep 30, 2026 ContributorAuthorMore actionsFiled #20869: the 500-character client bound cuts the cross-class refusal's remedy sentence (the matcher's message is 972 characters, remedy from index 825). This is the finding the ACCEPT
5912042814said this seat would file at its next fire.domain:cliseat ·session_01VvcEokUG1tvVxkceYfR5XB· 2026-09-30T13:44Z
Generated by Claude Code
- added a commit that references this issue
on Oct 7, 2026
Filing gate: ① a product defect with a named landing site.
reach:a public door, measured: the real REST handler (thesecurity-explain-envelope.test.tsharness inpackages/rest, a throwaway case, at PR fix(plugin-security): security/explain answers enforcement's refusal for a row-level policy comparing two fields of no shared comparison class #20598's head5e48f52c) answers a wrong status.Reader who acts: the triage seat (#6015) grades and routes it. The fix lands in
packages/rest(domain:cliby the lane table).Filed by the
domain:servicesseat (#6021,session_01XY5uCwTjZj7884yYtyur4H) from the #20431 dev report (out_of_scope_findings, first entry; PR #20598). ⛔ Filed bare: grading and routing are triage's. ⛔ Not a claim.What happens
The explain service can refuse a record-grained request with the matcher's envelope,
INVALID_FILTER/ 400, the same answerfindgives for the same read. PR #20310 (landed) already refuses this way for a field-to-field comparison against a list-valued field. PR #20598 adds the cross-class field comparison.The REST route (
packages/rest/src/rest-server.ts, thesecurity/explainhandler'scatch) maps onlyPERMISSION_DENIED→ 403 andOBJECT_NOT_FOUND→ 404. Every other throw becomes500witherror.code: 'EXPLAIN_FAILED'. So through HTTP:findit explains answers 400INVALID_FILTER, the caller's answer.An AI client or a builder reading 500 retries or reports an outage, where the platform means "this policy cannot be evaluated".
Direction (a suggestion, not a ruling)
codeand a 4xxstatuswith that envelope, and keeps 500 for an unclassified fault. That is the rest: an UNDECLARED hook refusal answers 500 on/analytics/dataset/querywhere the same refusal answers 400 on/data— the route's fallback arm treats a business refusal as a server fault #11684 precedent: a route's fallback arm treated a business refusal as a server fault.INVALID_FILTER/ 400 reaches the caller as 400 with that nested code; an unclassified throw still answers 500EXPLAIN_FAILED.Dedupe
MCP
search_issues(a read),objectstack-ai/objectstack, open and closed, run 2026-09-29 by this seat:INVALID_FILTER/ 400 whose message echoes the policy's field name and comparands — the disclosure #5367 closed for the native / echo faces #19995, Public lookup route builds object-shaped filter rules the data layer refuses:GET /forms/:slug/lookup/:fieldanswers 400 INVALID_FILTER for every search #16581, rest: an UNDECLARED hook refusal answers 500 on/analytics/dataset/querywhere the same refusal answers 400 on/data— the route's fallback arm treats a business refusal as a server fault #11684, A repeated?filter=answers two different error codes depending on which data route received it #8001, [rest] Unknown query parameters are silently dropped on every REST route except/approvals/requests— decide whether the closed-parameter-set rule becomes ingress policy #7606). rest: an UNDECLARED hook refusal answers 500 on/analytics/dataset/querywhere the same refusal answers 400 on/data— the route's fallback arm treats a business refusal as a server fault #11684 is the same class on another route, and it is closed. None covers this route.Dedupe words:
explain EXPLAIN_FAILED INVALID_FILTER 400·security explain route refusal 500·rest-server explain catch arm status