Skip to content

fix(data-objectstack): read the flat REST error envelope through one reader (objectui#9594) - #9812

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-9594-export-error-envelope-reader
Sep 18, 2026
Merged

os-sales merged 2 commits into
mainfrom
claude/issue-9594-export-error-envelope-reader

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes #9594

Clause-②: no

A console operator clicking "export to CSV" without the allowExport grant was shown one
word: Forbidden. The server had answered a sentence naming the object, the user and the
axis. This routes six hand-copied failure ladders in ObjectStackAdapter through one
internal envelope reader that understands every dialect on the wire.

Per the seat ruling on the card (option 2 carrying option 1's behaviour), the rung order is
error.message then message then a bare-string error then statusText.

What changed

One new internal module, packages/data-objectstack/src/error-envelope.ts, exporting
readErrorEnvelope(body) which answers a message and a code. Six sites now call it:

site shape before what it gets now
searchAll full ladder to statusText reader, plus the server code
rawFindWithPopulate full ladder, already read code reader; code precedence preserved exactly
exportDownload full ladder to statusText reader, plus the server code
fetchObjectSchemaFresh full ladder to statusText reader, plus the server code
uploadFile error.message only, own fallback text dialect reading only
uploadFiles error.message only, own fallback text dialect reading only

The reader is deliberately not re-exported from the package entry point. Verified
mechanically: the export list of src/index.ts is byte-identical to base, 64 export lines
on both sides, so Clause-②: no holds.

Mechanism assumptions, each re-measured on my own base

1. The six sites are one ladder — PARTLY FALSIFIED, and the brief predicted where.
The four res.ok ladders really are one shape, and the card's line numbers were all exact on
my base. The statusText pair is not the same shape: 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. So they share the dialect reading and keep their own envelope — forcing their
last rung and their code through one function would have changed behaviour nobody asked for.
The card's phrase "the identical ladder is copied at five more sites" is accurate for four of
the six and loose for the upload pair.

2. The flat dialect is still on the wire — CONFIRMED. @objectstack/rest is not a
dependency of this repo, so I read the producer source in the sibling checkout, which
declares version 17.4.0 — the same version the card measured. enforceExportPermission
still writes code plus a string error plus object, with no message key.

3. The affected-code count — RE-MEASURED INDEPENDENTLY, and it reproduces. My scan walked
the producer's source tree rather than the shipped bundle the card used, brace-matching every
.json( body and keeping those with code and error and no message: 28 files, 68 matching
bodies, 34 distinct literal codes. Diffed against the card's list: identical, zero
difference either way. EXPORT_NOT_PERMITTED was the known-hit control and it fired. A
different instrument reaching the same population is corroboration rather than a restatement.
Also measured: of those bodies, zero spell error equal to code, so the bare-string rung
always yields a real sentence for that family.

4. The 401-vs-403 control — CONFIRMED, and it turned out to be load-bearing. The 401 body
is { error: 'UNAUTHENTICATED', code: 'UNAUTHENTICATED', message: ... }: flat, and its error
key holds the code word while the sentence lives in message. That is exactly why the
ruled rung order puts message before a string error. Inverting those two rungs would make
every unauthenticated response render the word UNAUTHENTICATED instead of its sentence. The
pair is pinned as a control at both the reader and the call site.

Not a lenient fallback

Reading this dialect is conformance, not tolerance, and the producer's own text says so.
ADR-0112's 2026-07-30 amendment records the flat and wrapped envelopes as the two live,
sanctioned shapes, D5 leaves the flat one's fate an open maintainer question, and
@objectstack/core's guidance beside ANONYMOUS_DENY_BODY states the rule: read the envelope
the seam you called DECLARES — flat from /data and /meta. Every site routed through the
reader calls a /data route. The server envelope is untouched; that belongs to objectstack#9559.

That same guidance names body.error?.code ?? body.error as precisely the chain an envelope
regression hides inside, so the reader takes code from code or a nested error.code and
never promotes a string error into that slot. A sentence is not a code.

Decision on the card's optional extra (Zone 3)

The card floated appending code to the message text so an operator can search for
EXPORT_NOT_PERMITTED. Taken, but as a structured field rather than in the text. The four
res.ok ladders attach the server code to the thrown error. 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 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.

Tests, and proof they are not vacuous

pnpm exec vitest run packages/data-objectstack/ — 67 files, 912 tests, all passing, read
at 9cf9e0c6 (the merge commit, so after the last content change). Refusal cases assert code
and status together, never a bare throw.

Two ablations, in opposite directions, each mutated on disk through an anchor that had to
hit, with the injected and deleted text counted by grep -c on both sides and the restore
proven by git diff HEAD being empty and the blob hash matching HEAD:

  • Delete the bare-string rung (reproduce the old blind spot): 6 tests red, every one a
    flat-dialect case, across all three test files — and the 401 control stayed green.
  • Invert message and the string error: 3 tests red, every one a 401 case, and every
    403 case stayed green.

The two mutations turn disjoint sets red, which is what shows the 401 case is a genuine
control rather than a second copy of the 403 assertion. The call-site tests going red from a
mutation inside the reader module is also what proves the wiring is real at those sites.

No build was needed for either leg and none was run: 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 had not been what ran, no
mutation to it could have changed a verdict.

Gates, exit code captured before any pipe

Derived from this repo's package.json scripts and .github/workflows/, against the real
diff. scripts/pm/dispatch-gates.mjs was not used: it lives in the sibling repo and
derives families from the tree it runs in, so it cannot answer for this one.

gate exit
check:new-line-citations (Line Citation Gate) 0 — "0 new citation(s)"
check:control-bytes 0
check:changeset-presence 0
check:changeset-no-major / -overwrite / -fixed / -claims 0 / 0 / 0 / 0
check:pending-changeset-literals 0
check:unreferenced-sources 0
check:self-import 0
check:test-path-roots 0
check:vi-mock-specifiers / -inherit / -override-shape 0 / 0 / 0
check:readme-exports 0 after a full build
check:dist-completeness --all 0
check:published-dist 0
check:published-tsconfig-exclude 0
check:esm-specifiers 0
check:entry-guard 0
check:side-effects-array 0
type-check (this package) 0
lint (this package) 0
check:node-esm-load 1 — environmental, see below

pnpm build (whole workspace): 43 of 43 tasks successful.

check:readme-exports first answered 1 and that reading was discarded, not reported. Its
own output said the population had collapsed and named the cause — 34 unbuilt packages, all
four findings in plugin-calendar / plugin-gantt / plugin-kanban READMEs, none in this
package. After a full build it is 0 over a real population (58 keys compared, 0 unbuilt). A
prerequisite-not-met exit is not a failed measurement.

check:node-esm-load answers 1 for a reason outside 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 five 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 five are untouched by this diff, and
a clean CI checkout has no sibling worktree to inherit from. I force-rebuilt those five through
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 rebuild
of the workspace, 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.

The repo-wide pnpm lint and pnpm test are CI's runs. The lint narrowing here is declared and
measured: the root eslint.config.js is the only config (this package declares none), the
package's own eslint . — the exact per-package unit turbo run lint invokes — linted 76
files, 0 errors, 446 warnings
by its own --format json count, and type-aware linting is
not enabled (tseslint.configs.recommended, no project or projectService), so nothing
in this diff can move the verdict of a file it does not touch. Every warning is the
pre-existing no-explicit-any population; my appended export-test block adds none, and the
searchAll insert adds exactly one, in that file's established fetchImpl as any idiom.

Acceptance notes

Observed and deliberately not filed:

  • A shared reader across packages. objectui#7959 raised the same option for app-shell,
    and if both land, one reader shared across packages is the honest end state. Out of scope
    here by the card's own fence, and recorded rather than built. Successor: whichever of
    objectui#7959 / objectui#7938 / objectui#7368 lands next in this family.
  • Two more statusText sites that are not ladders and were left alone. The discovery probe
    builds a status plus statusText string and never opens the body at all; 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 — noted so the next reader of this module
    does not mistake the count of statusText mentions for the count of ladders.
  • Turbo's cross-worktree cache provenance, above. Already known and handled by the gate,
    which names it and cites objectui#7276 — a condition of running several agents in one
    checkout, not a defect.

Generated by Claude Code

…reader (objectui#9594)

A denied CSV export reached the operator as the single word `Forbidden` while
the server was answering a sentence naming the object, the user and the axis.
The adapter's failure ladder knew two envelope dialects -- the ADR-0112 nested
`error: { message }` and the flat shape carrying a `message` key -- and the REST
export gate writes a third: flat, sentence in a bare string `error`, no
`message` key at all. Every rung missed and the ladder fell through to
`res.statusText`.

That ladder was hand-copied at four sites (`searchAll`, `rawFindWithPopulate`,
`exportDownload`, `fetchObjectSchemaFresh`), with a narrower variant at the two
upload paths, so all six shared the blind spot. Hoist one internal
`readErrorEnvelope` and route all six through it.

The rung order is load-bearing: `message` is consulted before a string-valued
`error`, because the REST 401 body is flat AND carries both, with the code word
in `error` and the sentence in `message`. The 401/403 pair is pinned as a
control against that inversion.

The four `res.ok` ladders now also attach the server's `code` to the thrown
error, matching what `rawFindWithPopulate` already did. The code is read from
`code` or a nested `error.code` and never promoted out of a string `error`.

The reader is deliberately not exported from the package entry point; the
published face is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xm4WFhEe5mwcgyqHjxR2hn
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3049.6 KB 3104.5 KB
Main entry chunk (gzip) 145.7 KB 350 KB
Entry file index-D7kJ8FiH.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 545.99KB 130.71KB
core (index.js) 8.94KB 3.59KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 216.90KB 60.15KB
fields (index.js) 249.23KB 62.88KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.22KB 2.26KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 32.15KB 10.49KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.83KB 10.95KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.92KB 14.22KB
plugin-charts (index.js) 71.71KB 20.07KB
plugin-chatbot (index.js) 195.34KB 46.51KB
plugin-dashboard (index.js) 131.44KB 34.65KB
plugin-designer (index.js) 215.94KB 44.33KB
plugin-detail (index.js) 253.28KB 65.87KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.71KB 34.16KB
plugin-gantt (index.js) 167.62KB 41.26KB
plugin-grid (index.js) 212.61KB 57.90KB
plugin-kanban (index.js) 48.10KB 14.94KB
plugin-list (index.js) 112.74KB 27.70KB
plugin-map (index.js) 21.48KB 6.99KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.41KB 11.93KB
plugin-timeline (index.js) 30.07KB 8.74KB
plugin-tree (index.js) 10.58KB 3.72KB
plugin-view (index.js) 85.05KB 21.01KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 104.82KB 34.67KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.66KB 2.50KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 14.82KB 4.99KB
types (ai.js) 4.11KB 2.06KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.04KB 5.36KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-sales
os-sales marked this pull request as ready for review September 18, 2026 06:59

Copy link
Copy Markdown
Collaborator Author

Reviewed, green and ready at 2026-09-18T06:59Z — ACCEPT recorded on objectui#9594 (comment 5726280534). CI on head 9cf9e0c6: 33 success · 3 skipped · 0 pending · 0 non-success; the three skips are Test (coverage), dependabot and Test (coverage shard ${{ matrix.shard }}/4) (an uninterpolated matrix placeholder), all expected. mergeable_state: clean, path surface carries no governed file.

⛔ This seat cannot put it in the merge queue, for the same reason and with the same refusal already recorded in full on PR objectui#9804: PUT .../ccr/auto_merge is refused by this environment's auto-mode classifier as [Self-Approval], because the dev pushed under os-sales — the same account this seat runs on — so the PR's author and the hand enabling auto-merge are one GitHub identity.

⇒ This is a pointer, not a second report: the blocker, what this seat will not do about it (⛔ no MCP fallback, ⛔ no direct merge, ⛔ no retry through another door) and the two ways to unblock it are all on objectui#9804 and were reported to the maintainer there. ⛔ No auto-merge attempt was made on this PR: the refusal is identity-based and therefore predictable, and retrying a denied action class unchanged is not a reading, it is repetition.

Nothing else is outstanding on this PR. It stays watched until it is merged or closed.

Posted by the domain:ui seat 3 (session_01Xm4WFhEe5mwcgyqHjxR2hn).


Generated by Claude Code

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

Projects

None yet

2 participants