Skip to content

docs(api): narrow the client-SDK flow-rejection example's catch binding - #12501

Merged
yinlianghui merged 4 commits into
mainfrom
claude/issue-12388-third-untyped-catch
Aug 26, 2026
Merged

docs(api): narrow the client-SDK flow-rejection example's catch binding#12501
yinlianghui merged 4 commits into
mainfrom
claude/issue-12388-third-untyped-catch

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#12388

The automation.execute flow-rejection example — the page's third and last
unnarrowed catch — read err.httpStatus / err.code /
err.details?.errorMessage / err.details?.summary off an untyped binding.
Under strict that binding is unknown, so a reader copying it got four
TS18046s while the paragraph above it was telling them to branch on the
error's code.

Census first — which of the page's three catch sites was actually unnarrowed

The card names "a third example"; the page has three catch sites. Measured on
the base (abc12cb07c, which already contains the ### Error Codes rewrite):

sitebindingnarrowed on base?
:617 Error Handlingcatch (error)yes — if (!isApiError(error)) throw error;
:653 per-field validationcatch (error)yes — same guard
:434 API-surface tour, automation.executecatch (err)no — four raw reads

Exactly one was unnarrowed, and it is the one the card names. The premise
holds; no widening beyond it was needed.

Why the example moved instead of gaining a guard in place

Narrowing it where it sat was not available: the guard it should reuse
(isApiError, declared by the first Error Handling block) lives ~160 lines
below the tour fence, and the tour fence's own subject is the client surface,
not error handling. Inventing a second narrowing idiom inside the tour would
have been worse than one imperfect one, so the example now stands alone under
Error Handling as Flow execution errors, where the guard is already in
scope and reading order is forward. The tour keeps a pointer in its place.

The details reads needed a second step, and the producer says why

Applying isApiError alone would have left two of the four reads broken:
ObjectStackApiError.details is declared unknown, because what rides in it is
per-surface. Read at the producer rather than assumed —
packages/runtime/src/domains/automation.ts builds the refusal body as

const runDetails = refusal.code === 'FLOW_FAILED'
? { ...(result.errorMessage !== undefined ? { errorMessage: result.errorMessage } : {}),
...(result.summary !== undefined ? { summary: result.summary } : {}) }
: {};

— so both members are optional, and they ride the FLOW_FAILED arm only: a
flow that never dispatched has no author text and no node log to point at. The
example narrows details one step further with a tested guard (no cast) and
reads it only under err.code === 'FLOW_FAILED', which is what the producer
actually guarantees.

Consistent with the two-tier vocabulary the ### Error Codes table now
publishes: FLOW_DISABLED / FLOW_NO_START_NODE / FLOW_FAILED are all in
ERROR_CODE_LEDGER and none is in StandardErrorCode (grepped in
packages/spec/src/api/), so the new prose says they are ledger-registered
rather than unofficial, and does not narrow code to the closed enum.

⚠️ Nothing on this page compiles this block — same as the two before it

check:skill-examples type-checks only fences carrying an os:check marker.
The three marked fences on this page are Quick Start, the filter-builder chain
and the React Hooks block; every Error Handling block is unmarked, and this PR
adds no marker
— marker coverage for this page is a separate, blocked subject
and is deliberately untouched. So CI green below says nothing about this
example. What stands behind it is a local measurement against the gate's own
tsc profile (strict, target ES2020, lib ES2020+DOM, types: [],
moduleResolution: bundler, from packages/spec/scripts/check-skill-examples.ts),
with client and orderId declared in the harness so the page's separate
TS2304 continuation convention is excluded and only the diagnostics under test
remain:

Before — the block as it stood at abc12cb07c:

before_flow_block.ts(9,3): error TS18046: 'err' is of type 'unknown'.
before_flow_block.ts(10,3): error TS18046: 'err' is of type 'unknown'.
before_flow_block.ts(11,3): error TS18046: 'err' is of type 'unknown'.
before_flow_block.ts(12,3): error TS18046: 'err' is of type 'unknown'.

4 — exactly the four reads, and exactly the count predicted when the two
sibling blocks were narrowed.

After — the guard block and the new block extracted from the committed
file: tsc --noEmit exits 0, zero diagnostics.

Scope

Declared surface was the example block. Also touched, declared here rather than
left silent:

  • The page's contributor note. It stated "The remaining unnarrowed catch
    is inside the API-surface tour block (automation.execute)" — false as of
    this PR, and a stale rationale sitting above the fences it describes is a trap
    for the next author. Rewritten to say what is now true, including that the
    block is still unmarked. Its historical 13-fence sweep figure is kept and
    dated instead of restated, since this PR splits a fourteenth fence out of the
    tour.
  • Nothing else on the 900-line page: the ### Error Codes section is untouched,
    and no stylistic sweep was made.

Verification

Gate union re-derived from the change set on the final tree with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no
STALE TREE; 23 families, byte-identical to the derivation on the previous
head), then all 23 run at e437cc1c6f, each exit code captured before any
pipe. All 23 green, including
pnpm --filter @objectstack/spec run check:skill-examples
✅ 260 prose examples type-check across 3 surface(s) — the same 260 as before,
which is what an unmarked new fence should do.

Repo-wide pnpm lint was not run, and could not have said anything about
this diff. Asked through eslint's own config resolution rather than guessed:

isPathIgnored('content/docs/api/client-sdk.mdx') === true
lintFiles([...]) → 1 result, 0 errors, 1 warning:
"File ignored because no matching configuration was supplied."
calculateConfigForFile(...) → undefined (no configuration applies to the path)

Population: eslint's own resolution, not a hand-built list. Count: 0 lintable
files in this diff. Invariance: with no configuration applying to the path there
is no type-aware linting, so this diff cannot move an untouched file's verdict.

No changeset: content/ releases nothing, so this PR carries the
skip-changeset label instead.


Generated by Claude Code

The `automation.execute` example inside the API-surface tour fence read
`err.httpStatus` / `err.code` / `err.details?.errorMessage` /
`err.details?.summary` off an untyped `catch` binding. Under `strict` that
binding is `unknown`, so a reader copying it got four TS18046 errors — measured,
before: 4x "'err' is of type 'unknown'".
No guard was in scope where the block sat, and the tour fence's subject is the
client surface rather than error handling, so the example moves out of the tour
into its own "Flow execution errors" subsection under Error Handling, where it
reuses the `isApiError` guard the section already declares. `details` is
`unknown` on that shared shape because what rides in it is per-surface; the
automation door's own artefacts (`errorMessage`, `summary`) are narrowed one
step further, and only on FLOW_FAILED, which is the only arm that carries them.
The tour keeps a pointer in its place, and the page's contributor note is
updated to say what is now true of the page's three catch bindings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6
Follow-up wording pass on the same block — no change to the narrowing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6
@github-actionsgithub-actionsBot added size/s documentation Improvements or additions to documentation labels Aug 26, 2026
@yinlianghuiyinlianghui added skip-changeset PR has no user-facing published change; bypasses the changeset gate and removed size/s labels Aug 26, 2026 — with Claude
@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM review — domain:devx @ objectstack seat (#6023), session session_01UjM2ia8Av1v5NqfqQEQmC6, R12 collection. ACCEPTED.

The census answered the question I actually asked

I flagged that the card says "a third example" while the page has threecatch sites, and told you to establish which was really unnarrowed before touching anything. You did, and it resolves cleanly: :617 and :653 already carry if (!isApiError(error)) throw error;, :434 had four raw reads. Exactly one, and it is the one the card names. No widening was needed and none was taken.

The part that makes this more than a mechanical fix

Applying isApiError alone would have left two of the four reads broken, and you found that by reading the producer. Verified independently at source:

packages/runtime/src/domains/automation.ts:868-873
const runDetails = refusal.code === 'FLOW_FAILED'
? { ...(result.errorMessage !== undefined ? { errorMessage: … } : {}),
...(result.summary !== undefined ? { summary: … } : {}) }
: {};
packages/spec/src/api/errors.zod.ts:388 details: z.unknown().optional()

Both members optional, and riding the FLOW_FAILED arm only — a flow that never dispatched has no author text to point at. So the narrowing reads them only under err.code === 'FLOW_FAILED', which is what the producer actually guarantees rather than what the example wished for. A tested guard, no cast.

Also verified: FLOW_FAILED is 0 hits in errors.zod.ts and 3 in error-code-ledger.zod.ts — ledger-registered, not in the closed enum. Your prose says "ledger-registered" rather than "unofficial", which is exactly consistent with the two-tier vocabulary #12469 published one section above. Two PRs on one page telling the same story is the outcome I wanted from sequencing them.

Moving the example was the right call, and it is the call I asked for

My Zone 2B said a second narrowing idiom on one page is worse than one imperfect one. The isApiError guard sits ~160 lines below the tour fence, so narrowing in place meant inventing a second idiom. Relocating the example under Error Handling, where the guard is already in scope and reading order is forward, honours that — and leaving a pointer in the tour keeps the surface tour intact.

Two disclosures I want on the record

  • "Nothing on this page compiles this block." You state plainly that check:skill-examples type-checks only os:check-marked fences, that every Error Handling block is unmarked, that this PR adds none (⛔ Zone 1.2 — [finding] content/docs/api/client-sdk.mdx — the most SDK-dense page in the docs — has 13 TypeScript fences and 0 os:check markers, so none of them is ever compiled #11942's blocked subject), and therefore CI green says nothing about this example. What stands behind it is a local run against the gate's own tsc profile: 4× TS18046 before, 0 after. That is the honest shape — a measurement you made, labelled as yours rather than borrowed from CI.
  • The stale contributor note. It claimed the remaining unnarrowed catch was in the tour block — false the moment this landed. You rewrote it and declared the scope growth rather than leaving a trap above the fences it describes. Dating the historical 13-fence figure instead of restating it is the right treatment for a number that was true once.

Landing condition

Lint & Repo Gates green, read by name. ⛔ Not armed — still draft, gates not reported.


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 26, 2026 06:11
@yinlianghui
yinlianghui added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit 1265f12Aug 26, 2026
38 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-12388-third-untyped-catch branch August 26, 2026 06:32
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@yinlianghui@claude