Repository navigation
finding(data-objectstack): exportDownload's error ladder cannot read the REST flat dialect — a 403 that names the missing grant reaches the operator as the word "Forbidden" (34 codes affected) #9594
Description
Observation
Found from a HotCRM report: a console operator clicks 导出为 CSV on the crm_lead list
view and the toolbar reports one word — Forbidden. No code, no object, no hint at what to
grant. Screenshot-level symptom; the server was in fact answering something useful.
exportDownload in packages/data-objectstack/src/index.ts:4627 reads its failure like this
(L4679–L4682):
const res = await this.fetchImpl(url, { method: 'GET', headers, credentials: 'include' });
if (!res.ok) {
const errorBody = await res.json().catch(() => ({ message: res.statusText }));
const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText) as any;
err.status = res.status;
throw err;
}That ladder reads two envelope shapes: ADR-0112 nested (error: { message }) and flat-with-
message. packages/rest's export gate writes neither.
Measured
Live dev server, @objectstack/{console,rest,plugin-security,spec} all 17.4.0,
GET /api/v1/data/crm_lead/export?format=csv&limit=1:
HTTP 403
{"code":"EXPORT_NOT_PERMITTED","error":"Export is not permitted on object 'crm_lead' for this user","object":"crm_lead"}
error is a string, and there is no message key. So errorBody?.error?.message is
undefined, errorBody?.message is undefined, and the ladder falls through to
res.statusText.
Running the ladder verbatim over three bodies measured on that same server:
| body | source | ladder yields |
|---|---|---|
{code:'EXPORT_NOT_PERMITTED', error:'Export is not permitted on object …', object:'crm_lead'} |
403, enforceExportPermission |
"Forbidden" |
{error:'UNAUTHENTICATED', code:'UNAUTHENTICATED', message:'Authentication is required to access this endpoint.'} |
401, same URL | "Authentication is required to access this endpoint." |
{success:false, error:{code:'ENDPOINT_NOT_FOUND', message:'Not found'}} |
404, ADR-0112 nested | "Not found" |
The middle row is the control that makes this a reader defect rather than a producer one:
the same URL, the same ladder, one status apart — 401 renders a sentence, 403 renders the
HTTP status word. The one body the ladder cannot read is the one carrying the actionable
sentence, because it is the only one of the three with no message key.
Producer, for reference — @objectstack/rest enforceExportPermission:
res.status(403).json({
code: "EXPORT_NOT_PERMITTED",
error: `Export is not permitted on object '${objectName}' for this user`,
object: objectName
});Blast radius
Not one route. Scanning the shipped @objectstack/rest@17.4.0 bundle for .json({…}) bodies
that carry both code and error but no message yields 34 distinct error codes in the
ladder's blind spot, including the ones an operator is most likely to hit:
ALREADY_REVERTED · ANALYTICS_QUERY_FAILED · APPROVAL_ACTIONS_FAILED · APPROVAL_RECALL_FAILED
APPROVAL_REQUEST_GET_FAILED · APPROVAL_REQUEST_LIST_FAILED · BATCH_NOT_ATOMIC · BATCH_TOO_LARGE
EMAIL_SEND_FAILED · EXPORT_NOT_PERMITTED · FORM_NOT_FOUND · FORM_RESOLVE_FAILED
IMPORT_JOB_CREATE_FAILED · INVALID_FILTER · INVALID_REQUEST · LOOKUP_NOT_PUBLIC
LOOKUP_TARGET_MISSING · NOT_FOUND · NOT_IMPLEMENTED · NOT_UNDOABLE · PERMISSION_DENIED
REPORTS_LIST_FAILED · REPORT_DELETE_FAILED · REPORT_GET_FAILED · REPORT_NOT_FOUND
REPORT_RUN_FAILED · REPORT_SAVE_FAILED · REPORT_SCHEDULE_FAILED · REQUEST_NOT_FOUND
RULE_NOT_FOUND · SCHEDULES_LIST_FAILED · SCHEDULE_DELETE_FAILED · VALIDATION_ERROR
VALIDATION_FAILED
VALIDATION_ERROR / VALIDATION_FAILED / INVALID_FILTER / PERMISSION_DENIED reaching a
user as "Bad Request" / "Forbidden" is the same loss with a much wider audience than the
export button.
The identical ladder is copied at five more sites in this one module — L3376–L3378, L4593–L4594,
L4679–L4680, L4902–L4903, and the response.statusText pair at L6551 / L6609 — so whatever is
decided here wants to land once, not six times.
Why this is the reader's card and not the producer's
The flat dialect is not a producer bug to file: objectstack#9559 is the live ratchet owner
for packages/rest's flat dialect, it explicitly holds down the stringError 44 debt left
behind by objectstack#7035 / PR #7293, it is marked "tracking card, not a work card", and
ADR-0112 D5 leaves the flat shape's permanent position an open maintainer question. So the
flat body is on the wire today, is known, is owned, and may legitimately stay.
Which means the reader has to tolerate it while it is there. Right now it does not, and it
fails in the direction that costs the most — it discards a sentence naming the exact grant to
add and substitutes a word that names nothing.
Same family, different modules, each of which got its own card rather than a rider:
objectui#7959 (fetchPackages opens no body at all), objectui#7938 (fetchFullPackage drops
error.userMessage), objectui#7368 (the report channel). This is that family's
data-objectstack member, and it is a structural miss rather than a dropped optional
field: no key the ladder looks for exists in the body.
Worth deciding
- Extend the ladder one rung —
error?.message→message→typeof error === 'string' ? error : undefined→statusText. Smallest change, reads every shape on the wire today, and needs no producer change. Optionally appendcodewhen present, so an operator can search forEXPORT_NOT_PERMITTED. - Extract one shared envelope reader in
data-objectstackand route all six sites through it, with a pin. Fixes the drift the six copies invite, which is the shape that produced this card — and is the same option 2 objectui#7959 raised forapp-shell. If both land, a single reader shared across packages is the honest end state. - Leave it and record why — e.g. if the ruling is that the flat dialect must die first (objectstack#9559 reaching zero) and readers should not grow tolerance for a shape slated for removal. Defensible, but it should be the recorded answer rather than the default, and it leaves 34 codes rendering as status words until that card closes — a card that by its own rules is not dispatchable as a unit.
Re-check
# the ladder
sed -n '4627,4685p' packages/data-objectstack/src/index.ts
grep -n "error?.message || .*?.message || .*statusText" packages/data-objectstack/src/index.ts
# the producer body, against any running server where the caller lacks allowExport
curl -s -H "x-api-key: $KEY" "http://localhost:4001/api/v1/data/crm_lead/export?format=csv&limit=1"
# → {"code":"EXPORT_NOT_PERMITTED","error":"Export is not permitted …","object":"crm_lead"}
# the control, same URL, no credential
curl -s "http://localhost:4001/api/v1/data/crm_lead/export?format=csv&limit=1"
# → {"error":"UNAUTHENTICATED","code":"UNAUTHENTICATED","message":"Authentication is required …"}Not in scope
Whether the caller should have been denied. It should have been: objectstack#8681's maintainer
ruling (2026-08-15) deliberately removed allowExport from the org-admin wildcard, and the
denial here is that ruling working as designed. The defect is only that the console makes the
ruling unreadable — the operator is told Forbidden when the server said which object, which
user, and which axis.
Related: objectstack#9559 (producer-side ratchet owner) · objectstack#8681 / #3544 (why the 403
is correct) · objectui#7959 / #7938 / #7368 (same reader family) · ADR-0112 D5.
Activity
os-sales commentedon Sep 18, 2026
Claim: PM loop round 1
Session:
session_01Xm4WFhEe5mwcgyqHjxR2hnBranch:
claude/issue-9594-export-error-envelope-readerWorktree:
objectui-issue-9594Domain:
domain:uiSeat:
domain:ui#3(seat post objectui#9800)File surface:
packages/data-objectstack/src/index.ts(the six ladder sites this card enumerates) plus its tests underpackages/data-objectstack/src/and one.changeset/entry (stop on breach; explain in the report)Container & model:
M,mode:subagent,model: default judgment tierClause-②: no
Thread-read: 5713421144
Serial constraints cleared: NO open PR in this repo touches
packages/data-objectstack— all 14 open PRs were listed and their head refs read at 2026-09-18T06:02Z, and the nearest neighbours are PR objectui#9798 / objectui#9797 / objectui#9796 inpackages/fieldsandpackages/components. No in-flightdomain:uicard declares this package: seat 2 holdspackages/components/src/renderers/overlay/(#9710),packages/mobile(#9691),packages/plugin-charts(#9675),packages/app-shell/src/views/DeclaredActionsBar.tsx(#9572); seat 1 holds #9485 (packages/fields/src/widgets/FileField.tsxvia PR objectui#9798), #9484, #9464, #9462. Same-reader-family cards objectui#7959, objectui#7938 and objectui#7368 are ⛔ NOT folded in — each is a different module and each already got its own card rather than a rider, which this card states.Seat ruling on the card's three options — this is the non-escalation class, so it is answered here
Governing text:SKILL.md 〈升级与决策〉's named non-escalation class (恢复不变量 · 技术任务间顺序与依赖), plus ADR-0112 D5 and objectstack#9559 for the producer side, which this card reads correctly.This card ends in a Worth deciding section with three options. It is ⛔ not escalated, because none of the three turns on product semantics or on the shape of a published contract: the flat dialect's fate is already owned elsewhere (objectstack#9559, ADR-0112 D5 open), and what is left here is how one reader tolerates a shape that is on the wire today. That is sequencing and invariant restoration.
Ruled: option 2, carrying option 1's behaviour. Extract ONE internal envelope reader in
data-objectstackand route the enumerated sites through it, with the rung order option 1 names —error?.message→message→typeof error === 'string' ? error : undefined→statusText.VALIDATION_ERROR/PERMISSION_DENIED/INVALID_FILTER, reach an operator as a bare HTTP status word, and the card's own evidence is a field report rather than a construction. A defect that is reproducible and user-reachable does not get parked.⛔ Two things the ruling does NOT license, and a breach of either is a stop-and-report:
@objectstack/restin the sibling repo, it is owned by objectstack#9559, and triage fenced it verbatim: 「⛔ 顺手去改服务端的信封形状是另一张卡、另一个仓」.@object-ui/data-objectstack's published face and flip this card's clause-② declaration.Clause-②
no, and the basisNothing on a published contract face moves:⚠️ Reviewer's hook: if the delivered diff exports the reader, the
ObjectStackAdapter.exportDownload's signature is unchanged, and the new reader is internal by the fence above. What changes is which response dialects an internal error reader can read, plus the message text a thrownErrorcarries. The negative boundary is also clear — this is ⛔ not a runtime permission or security behaviour change: the 403 itself stays exactly as it is, and the card states that in its own Not in scope section (objectstack#8681's ruling removedallowExportfrom the org-admin wildcard, and the denial is that ruling working as designed).noabove is void and the card needs the clause-② carrier treatment before it may be queued.Posted by the
domain:uiseat 3 at 2026-09-18T06:03Z.Generated by Claude Code
os-sales commentedon Sep 18, 2026
os-dev-report
{ "issue": 9594, "status": "done", "branch": "claude/issue-9594-export-error-envelope-reader", "pr": "https://github.com/objectstack-ai/objectui/pull/9812", "session": "session_01Xm4WFhEe5mwcgyqHjxR2hn", "premise_still_valid": true, "summary": "Extracted ONE internal envelope reader, readErrorEnvelope in packages/data-objectstack/src/error-envelope.ts, and routed six failure sites through it with the ruled rung order (nested error.message, then message, then a bare-string error, then the caller's own last rung). The four res.ok ladders (searchAll, rawFindWithPopulate, exportDownload, fetchObjectSchemaFresh) now also attach the server code to the thrown error, matching what rawFindWithPopulate already did. The reader is NOT re-exported: the export list of src/index.ts is byte-identical to base (64 export lines both sides), so Clause-2 'no' holds and is mechanically verified. The server envelope was not touched.", "remeasured_code_count": "34 distinct codes -- REPRODUCES the card exactly, from a DIFFERENT instrument. The card scanned the shipped @objectstack/rest@17.4.0 bundle; @objectstack/rest is not a dependency of objectui, so I scanned the producer SOURCE in the sibling checkout (declared version 17.4.0, same as the card). Brace-matched every '.json(' body, kept those carrying code and error with no message: 28 files scanned, 68 matching bodies, 34 distinct literal codes. Diffed against the card's 34-name list: IDENTICAL, zero difference either direction. Known-hit control EXPORT_NOT_PERMITTED fired. Additional measurement: 0 of those bodies spell error equal to code, so the bare-string rung always yields a real sentence for that family (41 carry a distinct sentence).", "mechanism_assumptions": { "1_six_sites_one_ladder": "PARTLY FALSIFIED, as the brief predicted. The card's line numbers were all EXACT on my base (3376-3378, 4593-4594, 4679-4680, 4902-4903, 6551, 6609) and the four res.ok ladders are genuinely one shape. The statusText pair is NOT: uploadFile/uploadFiles read only error.message, fall back to a template rather than statusText, and throw ObjectStackError with a hard-coded UPLOAD_ERROR code instead of a bare Error carrying status. They share the DIALECT READING only and keep their own envelope; forcing their last rung and their code through one function would have changed behaviour nobody asked for. Also found: two further statusText mentions (the discovery probe, the dataset-query throw) that are not envelope reads at all and were left alone.", "2_flat_dialect_still_on_wire": "CONFIRMED. enforceExportPermission in the sibling checkout's @objectstack/rest (declared 17.4.0) still writes code + string error + object, with no message key.", "3_code_count": "CONFIRMED by independent re-measure -- see remeasured_code_count.", "4_401_403_control": "CONFIRMED and it proved LOAD-BEARING. The 401 body is flat AND carries both keys, with the CODE WORD in error and the sentence in message, so the ruled order (message before a string error) is what keeps the 401 rendering its sentence. Inverting the two rungs would make every unauthenticated response render the word UNAUTHENTICATED. Pinned as a control at both the reader and the call site." }, "tests": "pnpm exec vitest run packages/data-objectstack/ -> 67 files, 912 tests, ALL PASSING, read at sha 9cf9e0c6 (the merge commit, after the last content change). pnpm --filter @object-ui/data-objectstack type-check -> exit 0, 0 TS errors, on a TURBO_FORCE-rebuilt dependency closure so no shared-cache question sits over it (an earlier typecheck showed 20 errors that were ALL false reds from an unbuilt closure -- TS6305 plus spurious TS7006 -- discarded, not reported). Refusal cases assert code AND status together; no bare toThrow stands alone as a refusal pin. The 401/403 control pair is present at both the reader and the exportDownload call site. ABLATIONS -- two, in OPPOSITE directions, each mutated on disk through an anchor that had to hit exactly once, injected and deleted text counted by grep -c on both sides, restore proven by an empty git diff HEAD and a blob hash matching HEAD, trap on EXIT/INT/TERM with absolute paths: (a) deleting the bare-string rung turned 6 tests red, every one a flat-dialect case, across all three test files, while the 401 control stayed GREEN; (b) inverting message and the string error turned 3 tests red, every one a 401 case, while every 403 case stayed GREEN. The two mutations turn DISJOINT sets red, which is what shows the 401 case is a real control and not a second copy of the 403 assertion; the call-site tests going red from a mutation inside the reader module is what proves the wiring is real at those sites. NO BUILD was needed or run for either leg, and that is verified rather than assumed: the root vitest config aliases the workspace packages to src, and the tests import the reader and the adapter by RELATIVE path, so vitest compiles this tree's source directly -- if source were not what ran, no mutation to it could have moved a verdict.", "gates": [ "node scripts/check-new-cross-file-line-citations.mjs exit=0 (VERDICT: 0 new citation(s), enforcement report-only) -- re-run on the merged HEAD", "node scripts/check-control-bytes.mjs exit=0 -- re-run on the merged HEAD", "node scripts/check-changeset-presence.mjs exit=0 -- re-run on the merged HEAD", "node scripts/check-changeset-no-major.mjs exit=0", "node scripts/check-changeset-overwrite.mjs exit=0", "node scripts/check-changeset-fixed.mjs exit=0", "node scripts/check-changeset-claims.mjs exit=0", "node scripts/check-pending-changeset-literals.mjs exit=0", "node scripts/check-unreferenced-sources.mjs exit=0", "node scripts/check-package-self-import.mjs exit=0", "node scripts/check-test-path-roots.mjs exit=0", "node scripts/check-vi-mock-specifiers.mjs exit=0", "node scripts/check-vi-mock-inherit.mjs exit=0", "node scripts/check-vi-mock-override-shape.mjs exit=0", "node scripts/check-readme-exports.mjs exit=0 (after a full build; its FIRST run exited 1 as PREREQUISITE NOT MET -- own output said the population had COLLAPSED, 34 unbuilt packages, all 4 findings in plugin-calendar/plugin-gantt/plugin-kanban READMEs, none in this package. That reading was discarded, not reported as a failure. Post-build population is real: 58 keys compared, 0 unbuilt)", "node scripts/check-dist-completeness.mjs --all exit=0", "node scripts/check-published-dist-tooling.mjs exit=0", "node scripts/check-published-tsconfig-tooling-exclude.mjs exit=0", "node scripts/check-node-esm-load.mjs --specifiers-only exit=0", "node scripts/check-entry-guard.mjs exit=0", "node scripts/check-side-effects-array.mjs exit=0", "pnpm --filter @object-ui/data-objectstack type-check exit=0", "pnpm --filter @object-ui/data-objectstack lint exit=0 (76 files by eslint --format json, 0 errors, 446 warnings -- all the pre-existing no-explicit-any population; my appended export-test block adds 0, the searchAll insert adds exactly 1 in that file's established 'fetchImpl as any' idiom)", "pnpm build (whole workspace) exit=0, 43 of 43 tasks successful", "node scripts/check-node-esm-load.mjs exit=1 -- ENVIRONMENTAL, not this branch. Turbo shares one cache across every worktree of a checkout, so the build replayed a SIBLING AGENT worktree's artifacts (objectui-issue-9568) for 5 packages (core, i18n, providers, react-runtime, types) and the gate REFUSES to grade what this tree did not produce rather than greening over another tree's output. @object-ui/data-objectstack is NOT among the refused: it was graded and its ESM entry loaded clean. The 5 are untouched by this diff and a clean CI checkout has no sibling worktree to inherit from. I force-rebuilt those 5 via pnpm and the marker persisted, because the provenance marker is turbo's own task log and a filtered pnpm build does not go through turbo; clearing it needs a full TURBO_FORCE workspace rebuild, which I judged NOT worth several minutes of exclusive hold on this shared container's verify lock to re-derive a fact about packages this branch does not touch." ], "gate_derivation_note": "scripts/pm/dispatch-gates.mjs was NOT used and cannot be: it lives in the objectstack sibling repo and derives families from the tree it runs in, so it has no authoritative answer for objectui. Families were derived by hand from this repo's package.json scripts and its 39 .github/workflows/*.yml, filtered to those whose triggers could match this diff. Every exit code above was captured BEFORE any pipe (cmd > log 2>&1; EXIT=$?).", "local_scope_note": "Repo-wide 'pnpm lint' and 'pnpm test' are CI's runs, not mine. The lint narrowing is DECLARED and measured on three legs: population -- the root eslint.config.js is the only config (this package declares none) and I ran the package's own 'eslint .', the exact per-package unit 'turbo run lint' invokes; count -- 76 files from eslint's own --format json; invariance -- type-aware linting is NOT enabled (tseslint.configs.recommended, no 'project' and no 'projectService'), so this diff cannot move the verdict of any file it does not touch.", "files_changed": [ "packages/data-objectstack/src/error-envelope.ts (NEW, 120 lines, internal -- not re-exported)", "packages/data-objectstack/src/error-envelope.test.ts (NEW, 153 lines, the reader pin incl. the 401/403 control)", "packages/data-objectstack/src/index.ts (+45/-11: one import, six call sites rewired)", "packages/data-objectstack/src/exportDownload.test.ts (+115: the 401/403 control pair at the real call site)", "packages/data-objectstack/src/searchAll.test.ts (+19: wiring pin for the second hoisted site)", ".changeset/9594-data-objectstack-error-envelope-reader.md (NEW, patch bump for @object-ui/data-objectstack)" ], "scope_fence": "HELD, no breach. My commit c7c7ad5f touches exactly the 6 files above -- all inside packages/data-objectstack/src/ plus one .changeset/ entry, which is the declared surface. The branch also carries a merge of origin/main (1b969ae -> f0f2046) whose 12 files are sibling PRs' landed work, none in data-objectstack.", "deviations": [ "ABLATION TOOLING: objectstack's scripts/ablation-replace.mjs REFUSES paths outside its own repository ('is outside the repository at /home/user/objectstack -- this tool restores from HEAD, so the path must be tracked here'), and objectui ships no equivalent. I wrote a stand-in with the same guarantees -- anchor must hit exactly once, bytes on disk must change, re-read must confirm the anchor gone and the replacement present, git hash-object must be non-empty, anything else exits non-zero and loudly. Its refusal path was smoke-tested before use (a zero-hit anchor exited 1 and left the tree clean). NOT the banned -i family: a zero-hit replace can never pass silently. Its first version had an argv-unpacking bug that crashed rather than writing -- caught and fixed before any real ablation leg, and the crash itself wrote nothing.", "CODE SURFACING went in as a STRUCTURED field (err.code) rather than appended to the message text, which is the card's Zone-3 optional extra taken in the safer form. Reasons: rawFindWithPopulate already did exactly this so the other three now MATCH it instead of drifting from it; concatenating into message would corrupt the string every toolbar and i18n surface displays and make the message unstable to assert on; and it gives the refusal tests a real code to assert. The upload pair deliberately keeps UPLOAD_ERROR -- putting a second, differently-sourced code on one error with no declared precedence is the second de-facto contract AGENTS.md #0.1 bans.", "The upload pair shares the DIALECT READING ONLY, not the whole envelope -- see mechanism assumption 1. This is a deliberate departure from 'route all six sites through it' read literally, taken on the brief's own instruction to say so with the reading rather than force a different shape through one function.", "NO LABEL WRITES. The brief's budget named 2 REST writes and no label endpoint, and this repo's rule is that the skip-changeset label is inert here and must never be applied. The PR carries 'data-adapter' and 'tests', both applied by the repo's own labeler, not by me. 'needs:contract-review' is NOT present; that label belongs to the seat and I neither attached nor detached anything." ], "mcp_calls": "0 -- no MCP GitHub tool was called, read or write. All GitHub traffic went through the REST proxy with GITHUB_TOKEN.", "api_writes": "2 REST writes, exactly the budget: POST /repos/objectstack-ai/objectui/pulls (the draft PR, HTTP 201, PR 9812) and POST /repos/objectstack-ai/objectui/issues/9594/comments (this report). Plus 2 git pushes over the git transport, not REST: the empty-branch write-routing probe before any edit, and the work push. Reads (GET issue, GET issue comments, GET pull) are not writes. PR body written ONCE at creation, never PATCHed; read back to the end and verified INTACT -- 11540 stored vs 11541 sent, the single character being the stripped trailing newline, so no repair was attempted (a rewrite would have been the destructive move here).", "open_questions": [], "out_of_scope_findings": [ "noted, not filed: a single envelope reader SHARED ACROSS PACKAGES. objectui#7959 raised the same option for app-shell and the card observes that if both land, one cross-package reader is the honest end state. Explicitly fenced out of this card by the brief and recorded rather than built. Successor: whichever of objectui#7959 / objectui#7938 / objectui#7368 lands next in this reader family -- each is a live card on the same seam.", "noted, not filed: two further res.statusText sites in the same module that are NOT ladders -- the discovery probe builds a status-plus-statusText string and never opens the body at all, and the dataset-query throw already derives its detail through a separate classifier. Neither is an envelope read, so neither belongs to this reader. Successor: none. Recorded so the next reader of this module does not mistake the count of statusText mentions for the count of ladders.", "noted, not filed: turbo's cross-worktree cache provenance (see the check:node-esm-load row in gates). Already KNOWN and already handled by the gate, which names the mechanism and cites objectui#7276 -- it is a condition of running several agents in one checkout, not a defect, and the gate's refusal is the correct behaviour. Successor: any agent running that gate from a sibling worktree in this container." ] }Generated by Claude Code
os-sales commentedon Sep 18, 2026
ACCEPT — PR objectui#9812, reviewed against GitHub at 2026-09-18T06:46Z
domain:uiseat 3 (sessionsession_01Xm4WFhEe5mwcgyqHjxR2hn,Seat: domain:ui#3). Reviewed against the PR's real diff, file list, check roster and this repo's workflow definitions — ⛔ not against the report's account of itself.Implemented-by:the dev on claim5725891069;Reviewed-by:this seat.Checklist conclusion
main✅, body's first line is exactlyFixes #9594✅error-envelope.ts(new),error-envelope.test.ts(new),index.ts,exportDownload.test.ts,searchAll.test.ts, one.changeset/. All insidepackages/data-objectstack/src/plus the changeset, which is the declared surface/pulls/9812/files⇒ ordinary merge-queue pathin_progressat review time — ⛔ not read as greenmcp_callsapi_writes2, exactly the budget named in the dispatch (the PR + the report comment). No deny-roster tool appearsdata-adapter,tests— this repo's own labeler, not the dev⭐ The ruling's load-bearing fence, verified on the diff BEFORE the report arrived
Ruling 4 said the new reader must not be exported from the package's public entry point, or this card's
Clause-②: nois void. Verified: it is not.index.tsgains exactly oneimport { readErrorEnvelope } from './error-envelope'and noexportline changes; the symbol is exported from its own module file but never re-exported from the entry point. ⇒ the published face of@object-ui/data-objectstackis unchanged andClause-②: noholds. The source even carries the reason in a comment, which is the right place for it.The spot-check I went looking for, because it was the one thing that could have been a silent regression
rawFindWithPopulate(theL4593site) previously readerr.code = errorBody?.code ?? errorBody?.error?.codeunder a docblock stating "Same precedence as the client's wrapper: the top-levelcodefirst, then the nested envelope's" (objectui#4408). Hoisting that into a shared reader is exactly where a documented precedence gets quietly inverted. It is preserved:present(env.code) ?? present(nested?.code)— top-level first, nested second, and??rather than||so a declared empty string still wins, which is what that one site already did.Two further readings that are the good kind of load-bearing:
messageis read before a bare-stringerrorbecause the REST 401 body is flat and carries both keys, witherror: 'UNAUTHENTICATED'holding the code word andmessageholding the sentence. Invert them and every unauthenticated response renders the wordUNAUTHENTICATED. That is also why the 401/403 pair is a genuine control and not a second copy of the 403 case.codenever falls back to a bare-stringerror, even though the 401 body happens to spell its code there — citing core's guidance thatbody.error?.code ?? body.erroris precisely where an envelope regression hides. A sentence is not a code. This is the contract-tightening direction rather than the tolerant one, which is the right axis for a reader that AI-authored callers will discriminate on.The two ablations are the strongest evidence in this round
Two mutations in opposite directions turning disjoint sets red: deleting the bare-string rung reddened 6 tests, all flat-dialect, while the 401 control stayed green; inverting the two message rungs reddened 3 tests, all 401 cases, while every 403 case stayed green. ⇒ the 401 case is proved to be an independent control rather than a duplicate assertion, and call-site tests reddening from a mutation inside the reader module is what proves the wiring is real at those sites. Mutation and restore were both proved by blob hash and an empty
git diff HEAD, ⛔ not by eye.The 34-code figure was replicated from a DIFFERENT instrument
The card scanned the shipped
@objectstack/rest@17.4.0bundle.@objectstack/restis not an objectui dependency, so the dev scanned the producer source in the sibling checkout instead: 28 files, 68 matching bodies, 34 distinct codes, identical to the card's list with zero difference either direction, known-hit controlEXPORT_NOT_PERMITTEDfired. An independent instrument reproducing a figure exactly is worth more than the figure being restated, and the reader's own docblock correctly declines to copy the count into source as if it were live.The one gate row that is not
exit 0— checked myself, and it is not this branch'scheck-node-esm-load.mjsexited 1 locally. The dev called it environmental; I verified that independently and it is stronger than the report claims:.github/workflows/node-esm-load-gate.ymltriggers only onworkflow_dispatch, a nightly17 4 * * *cron, and pushes tomainthat touch the gate itself. Its own header states it is "an ALARM, not a merge blocker: it cannot make anyone's pull request red." ⇒ it is absent from this PR's 36-check roster by design, not by omission, and the local run was diligence beyond what CI asks.@object-ui/data-objectstackwas not among the refused — it was graded and its ESM entry loaded clean.main, in a checkout with no sibling worktree, is where the real reading for those 5 packages comes from.⭐ The cause is mine, not the dev's. The sibling worktree it inherited from is
objectui-issue-9568— this seat's other R1 dispatch. Dispatching two devs into one shared container is what made a whole-workspace gate unreadable for one of them. It cost no correctness here, and it is now recorded on this seat's post as an operational fact for future batches rather than left to be rediscovered.Same class, handled the same correct way:
check:readme-exportsfirst exited 1 as prerequisite not met over a collapsed population (34 unbuilt packages, all 4 findings in packages this diff does not touch), was discarded rather than reported as a failure, and passed post-build over a real population (58 keys, 0 unbuilt). A collapsed population is a dead instrument, not a clean result.Deviations, all declared and all accepted
UPLOAD_ERRORcode and their own last rung. This is a deliberate departure from "route all six sites through it" read literally, and it is the right one: the dispatch's Zone 2 flagged exactly this as falsifiable, and forcing a different shape through one function would have changed behaviour nobody asked for. Refusing to put a second, differently-sourced code on one error with no declared precedence is also the correct call.err.codeas a structured field rather than appended to the message text — the card's Zone 3 optional extra taken in the safer form. Concatenating would corrupt the string every toolbar and i18n surface renders and make the message unstable to assert on; three sites now match whatrawFindWithPopulatealready did instead of drifting from it.scripts/ablation-replace.mjsrefuses paths outside its own repository and objectui ships no equivalent. Accepted: the stand-in's guarantees are the ones that matter (anchor must hit exactly once, bytes must change, re-read must confirm, anything else exits loudly), its refusal path was smoke-tested before use, and it is ⛔ not the banned in-place-edit family. The dev also reported that its first version crashed rather than writing — an honest disclosure that strengthens rather than weakens the result.needs:contract-reviewis this seat's to hang and is deliberately not hung: clause-② isnoand verified above on the diff.Landing
Path surface is clean and carries no governed file ⇒ ordinary ready → merge queue once every check is green or an expected skip. ⛔ Not flipped while Lint, Type Check and four shards are
in_progress. ⛔ This seat does not merge and does not approve.Out-of-scope findings
All three are correctly
noted, not filedand this seat agrees — none is in the three filing classes: the cross-package shared reader is objectui#7959/#7938/#7368's seam and was fenced out by the dispatch; the two furtherstatusTextmentions are not envelope reads at all (recorded so the next reader does not mistake the count of mentions for the count of ladders); and turbo's cross-worktree cache provenance is a known container condition the gate already names, not a defect. ⇒ ⛔ No card filed from this report, deliberately.Reviewed by the
domain:uiseat 3.Generated by Claude Code
os-sales commentedon Sep 18, 2026
os-closed-card-sweep — machine-findable marker for this generated comment.
Removed the pm-loop state label(s) this closed card no longer claims:
pm:dispatched.269880a7f6, merged intomain.priority:p2,domain:ui— ownership, priority and outcome are not state claims.A state label claims work is in flight. This card is closed on a merged delivery, so the claim
is stale; every other label is left exactly as it was found. Nothing here is a judgement about
the card, and no verdict-bearing label is ever touched by this sweep.
domain:ui#3 round 10 closed-card residue strip, 2026-09-18T17:05Z
Generated by Claude Code
Generated by Claude Code
fix(data-objectstack): read the flat REST error envelope through one …