Skip to content

feat(client): bind erased SDK return types to their spec contracts - #11929

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-8140-client-sdk-precise-return-types
Aug 25, 2026
Merged

feat(client): bind erased SDK return types to their spec contracts#11929
os-zhuang merged 2 commits into
mainfrom
claude/issue-8140-client-sdk-precise-return-types

Conversation

@claude

@claudeclaudeBot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Fixes#8140

Binds the client SDK's erased return types to the @objectstack/spec contracts the package already
depends on. 51 of 55 measured erasure sites are bound; the remaining 4 are deliberate and
documented in place. Types only — no request, response, unwrapping or error path changes.

All measurements at origin/main = 1f6d04703; all gates below ran at 65c2b2a43, this
branch's final commit (the ratchet family and repo-wide pnpm lint were re-run on it after
the docs commit).


1. Premise re-verification — the counts I actually measured

The census (comment 5357091577, corrected by 5357118695) was taken at 04096f17e on
2026-08-20, and PR #11714 landed in this exact file at 2026-08-25T00:20:59Z. Every line number in
the census has drifted (+181 lines). The four population counts have not moved:

$ F=packages/client/src/index.ts
$ grep -cE '\): Promise<any> =>' $F # P1
32
$ grep -cE '\): Promise<any\[\]> =>' $F # P2
5
$ grep -cE '\): Promise<\{.*any\[\].*\}> =>' $F # P3
4
$ grep -cE 'Promise<[^>]*any' $F # combined any-in-return
41

Verified equal at the census commit and at head (line counts differ, populations do not):

$ for R in 04096f17e HEAD; do git show $R:packages/client/src/index.ts > /tmp/f; \
echo "$R P1=$(grep -cE '\): Promise<any> =>' /tmp/f) P2=$(grep -cE '\): Promise<any\[\]> =>' /tmp/f) \
P3=$(grep -cE '\): Promise<\{.*any\[\].*\}> =>' /tmp/f) lines=$(wc -l < /tmp/f)"; done
04096f17e P1=32 P2=5 P3=4 lines=5588
HEAD P1=32 P2=5 P3=4 lines=5769

One population HAS moved, and it moved up. The dispatch order and the census both give the
< T = any > group as 8, and the true erasure surface as 49. Measured, it is 14 and
55. The census's own prose already said the eight are "mirrored again on
ScopedProjectClient"
— the mirrors were described but never added to the count:

$ grep -cE '<T = any>' $F # all occurrences
32
$ grep -cE '^\s+\w+: async <T = any>' $F # …on METHODS (4 are interface decls)
28

Those 28 split cleanly: 14 caller-supplied (data.query/find/get/create/createMany/update/ updateMany on ObjectStackClient, and the same 7 on ScopedProjectClient) and 14 fixed-shape
(6 automation.* on each class, plus actions.invoke / invokeGlobal). See §4 for why the last
two of those move back out.

Working population: 32 + 5 + 4 + 14 = 55.premise_still_valid: true — the card's premise holds
and the census's classification (A=28 · B=0 · C=4 over the 32) reproduces exactly.

2. Clause ② — yes, and it is a NARROWING

Every binding narrows a published return type. None is purely additive, because any is assignable
to everything and admits every property read: assigning the result to an unrelated annotation,
reading an undeclared property, or forwarding it to a differently-typed parameter all compile today
and stop compiling after. No runtime behaviour changes — no route, body, envelope, unwrap or
error path is touched.

⚠️Correcting the census on in-repo consumers: it measured 0, I measured 2 — and one of them is
exactly the class external consumers will hit. Both are in packages/client/src/client.test.ts,
which the census's sweep of client-react / cli / app-todo did not cover:

  1. client.test.ts:1354result.screen.nodeId after automation.resume. AutomationResult.screen
    is optional (a completed run carries no screen), so this is now result.screen?.nodeId with
    a toBeDefined() beside it. This is the migration an external caller makes, and it is in the
    diff as the worked example.
  2. client.test.ts:352reports.save({ name, object }). See §5: this one made me revert a
    change rather than adapt the fixture.

The rest of the risk is external SDK consumers, unmeasurable from here. The changeset states the
break per family. needs:contract-review is hung on this PR as well as the card; ⛔ this seat does
not clear it.

3. ⭐ One design decision the review chain should ratify — < T = X > is only HALF a fix

The obvious minimal fix for the fixed-shape < T = any > methods is to give the parameter a precise
default: < T = FlowParsed >. Measured on this PR's own pin file, that is not enough.
TypeScript infers T from the call's contextual type, so this still compiled:

constx: ExecutionLog=awaitclient.automation.getFlow('flow_a');// T silently := ExecutionLog

It surfaced as TS2578: Unused '@ts-expect-error' directive on the pin that asserts the wrong shape
is rejected — i.e. the guard caught the half-fix rather than my reading it off the code.

The spelling shipped here is < T extends X = X >:

  • the default closes the erasure for the ordinary call — await getFlow(n) was any, is now FlowParsed;
  • the constraint closes it for the annotated call.

A legitimate narrowing still works (getFlow< FlowParsed & { name: 'onboarding' } >(…)); an
unrelated type is now refused. Both directions are pinned. The stricter alternative — dropping the
type parameter entirely, so getFlow< X >(…) becomes Expected 0 type arguments — is a larger
break and is not taken here; it is the call worth making explicitly, which is why it is written
down rather than decided quietly.

4. The itemized list — the WHOLE population, including everything skipped

Bound — 51 sites

P1 · ): Promise< any > => → 28 bound of 32 (4 are class C, listed below)

#methodbeforeafter
1email.sendPromise< any >Promise< SendEmailResult >
2datasources.external.listTablesPromise< any >Promise<{ tables: RemoteTable[] }>
3datasources.external.draftPromise< any >Promise<{ draft: ObjectDraft }>
4datasources.external.importPromise< any >Promise<{ object: ImportObjectResult }>
5datasources.external.refreshCatalogPromise< any >Promise<{ catalog: ExternalCatalog }>
6datasources.external.validatePromise< any >Promise< SchemaValidationReport >
7automation.getPromise< any >Promise< FlowParsed >
8automation.runs.getPromise< any >Promise< ExecutionLog >
9shareLinks.createPromise< any >Promise< ShareLink >
10security.explainPromise< any >Promise< ExplainDecision >
11security.describeDelegableScopePromise< any >Promise< DelegableScope >
12security.suggestedBindings.listPromise< any >Promise<{ suggestions: AudienceBindingSuggestion[]; synced: AudienceBindingSuggestionSync }>
13security.suggestedBindings.confirmPromise< any >Promise<{ suggestion: AudienceBindingSuggestion; bindingCreated: boolean }>
14security.suggestedBindings.dismissPromise< any >Promise<{ suggestion: AudienceBindingSuggestion }>
15approvals.recallPromise< any >Promise< ApprovalRecallResult >
16approvals.revisePromise< any >Promise< ApprovalSendBackResult >
17approvals.resubmitPromise< any >Promise< ApprovalResubmitResult >
18approvals.remindPromise< any >Promise<{ request: ApprovalRequestRow; notified: number }>
19approvals.requestInfoPromise< any >Promise<{ request: ApprovalRequestRow }>
20approvals.commentPromise< any >Promise<{ request: ApprovalRequestRow }>
21shares.grantPromise< any >Promise< RecordShare >
22shares.rules.savePromise< any >Promise< SharingRuleRow >
23shares.rules.getPromise< any >Promise< SharingRuleRow >
24shares.rules.evaluatePromise< any >Promise< SharingRuleEvaluationResult >
25reports.savePromise< any >Promise< SavedReport >
26reports.getPromise< any >Promise< SavedReport >
27reports.runPromise< any >Promise< ReportRunResult >
28reports.schedulePromise< any >Promise< ReportSchedule >

P2 · ): Promise< any[] > => → 5 bound of 5

#methodbeforeafter
29shareLinks.listPromise< any[] >Promise< ShareLink[] >
30shares.listPromise< any[] >Promise< RecordShare[] >
31shares.rules.listPromise< any[] >Promise< SharingRuleRow[] >
32reports.listPromise< any[] >Promise< SavedReport[] >
33reports.listSchedulesPromise< any[] >Promise< ReportSchedule[] >

P3 · ): Promise<{ …any[]… }> => → 4 bound of 4

#methodbeforeafter
34automation.listActions{ actions: any[]; total }{ actions: ActionDescriptor[]; total: number }
35automation.listConnectors{ connectors: any[]; total }{ connectors: ConnectorDescriptor[]; total: number }
36automation.runs.list{ runs: any[]; hasMore }{ runs: ExecutionLog[]; hasMore: boolean }
37ScopedProjectClient.packages.list{ packages: any[]; total }{ packages: InstalledPackage[]; total: number }

Fixed-shape generics → 12 bound of 14 (2 skipped, below). Each appears twice — once on
ObjectStackClient.automation, once on ScopedProjectClient.automation.

#method (×2)beforeafter
38–39automation.getFlow< T = any >< T extends FlowParsed = FlowParsed >
40–41automation.execute< T = any >< T extends AutomationResult = AutomationResult >
42–43automation.listRuns< T = any >< T extends { runs: ExecutionLog[]; hasMore: boolean } = … >
44–45automation.getRun< T = any >< T extends ExecutionLog = ExecutionLog >
46–47automation.resume< T = any >< T extends AutomationResult = AutomationResult >
48–49automation.getScreen< T = any >< T extends { runId: string; screen: ScreenSpec } = … >

automation.getFlow is an explicit alias for automation.get — the same route wore two erasure
spellings in one file (#7 and #38). They now agree by construction.

Examined and SKIPPED — 18 sites, each with its reason

Class C — no type exists anywhere (4). These are a missing contract, not a missing
annotation. packages/spec was read-only on this card, so nothing was authored there; each keeps
Promise< any > with a docblock naming the reason, and all four are filed as #11924.

methodwhat the route really emitswhy not bound
automation.createthe request body, echoed — deps.success(body) (runtime/src/domains/automation.ts:982)IAutomationService.registerFlow(name, definition: unknown): void returns nothing; the body is never parsed through FlowSchema. Flow would be a claim about the REQUEST that no validation backs.
automation.updatethe definition, echoed — deps.success(definition) (same file, :1586)same route class, same reason
search{ query, hits: Array<{ object, id, title, snippet?, record }>, totalObjects, totalHits, truncated }declared INLINE at metadata-protocol/src/protocol.ts:9845-9863, not in @objectstack/spec; metadata-protocol is not a client dependency. ⚠️SearchResult (contracts/search-service.ts:53) is a near-miss trap — it contracts the per-object ISearchService.search (hits carry score/document). Binding it would typecheck and be false; a compile-time guard against exactly that is in the pin file.
data.clone{ object, id, sourceId, record } (protocol.ts:9488-9493)stable and server-produced, declared in no spec module. Structural sibling of this file's own CreateDataResult< T > plus sourceId — writing that equivalence in a consumer would mint an undeclared contract.

Caller-supplied generics — < T = any > is CORRECT (14). The record type genuinely belongs to
the caller. Untouched, and deliberately not constrained.

data.query, data.find, data.get, data.create, data.createMany, data.update,
data.updateMany — on ObjectStackClientand mirrored on ScopedProjectClient (7 × 2).

Two more move OUT of the fixed-shape group — a correction to the dispatch order and the
census.
Both name actions.invoke and actions.invokeGlobal among the fixed-shape platform
methods. Measured, they are not:

invoke: async<T=any>(objectName,actionName,opts?)
: Promise<{success: boolean;data?: T;error?: string}>

The envelope is already precise. T is the return value of the app author's own handler,
registered server-side with engine.registerAction(objectName, actionName, handler) — caller-supplied
in exactly the sense data.get< T > is. Constraining it would be wrong, and the surrounding shape was
never erased. invokeGlobal delegates to invoke. Left alone, with a docblock recording why.

Not in scope — a FIFTH erasure spelling, filed as #11925. 38 methods carry no return
annotation at all
; their public type is inferred from unwrapResponse< …any… >
(meta.* history 9, packages.* 14, cloud projects.* 8, env packages 6, scoped packages.get 1).
Invisible to every Promise< … > grep this card and its census used. ⭐ The asymmetry is visible in
one object literal: ScopedProjectClient.packages.list carried both an annotation and the type
argument, so it is bound above as #37, while its neighbour packages.get is not — purely because it
lacks the annotation.

5. What I reverted, and why it is evidence

I briefly bound reports.save's parameter to SaveReportInput. tsc failed on this repo's own
fixture at client.test.ts:352:

error TS2345: Argument of type '{ name: string; object: string; }' is not assignable to parameter
of type 'SaveReportInput'. Property 'query' is missing … but required in type 'SaveReportInput'.

SaveReportInput.query is required (contracts/report-service.ts:93) and POST /reports forwards
req.body ?? {} unchecked (rest-server.ts:10293). So the SDK accepts an input its own service
contract refuses. Parameter narrowing is not this card's scope (return types, and clause ② was
answered for those), so I reverted it and left the fixture alone rather than editing away a signal.
Filed as #11926.

6. Anti-vacuity — the ablation

Pins are type-level by necessity: a runtime test cannot observe a return-type narrowing, because the
value is identical either way. They are compiled — packages/client/tsconfig.test.json includes
src/**/* and package.json's typecheck names it through check:test-typecheck, which holds
every unledgered file at zero errors.

Method: revert ONLY packages/client/src/index.ts to origin/main, keep the pins, run both
checks. A trap … EXIT INT TERM restored the file on every exit path.

Mutation confirmed on disk in both directions before the run:

A. erased "): Promise<any> =>" present : 32 (origin/main value)
B. bound "Promise<SendEmailResult>" present : 0 (fix value: 1)
C. bound "<T extends FlowParsed" present : 0 (fix value: 2)
D. reverse-check, term independently present in BOTH trees, not a substring of any term
under test — "unwrapResponse" present : 168

Result — the pins go red, the runtime suite does not:

=== TYPE-LEVEL PINS UNDER THE MUTATION (tsc -p tsconfig.test.json) ===
tsc exit=2
--- errors in the pin file --- 21
17 × TS2344 Type '…' does not satisfy the constraint 'never' ← toEqualTypeOf vs `any`
4 × TS2578 Unused '@ts-expect-error' directive ← `any` accepts wrong shapes
--- errors elsewhere --- (none)
=== RUNTIME SUITE UNDER THE SAME MUTATION (the control) ===
vitest exit=0
Test Files 25 passed (25)

⭐ That control is the point: the runtime suite is fully green against a client that still returns
any.
A pin that only calls the method and checks the value would have proved nothing.

Restore verified byte-clean afterwards (git status --porcelain empty; residual counts back to
4 / 1 / 2).

Honest accounting — one guard is green in BOTH states and is not counted above.
searchResultIsNotTheGlobalSearchShape pins the SearchResult near-miss trap in @objectstack/spec,
not an annotation in this file, so reverting index.ts does not move it. It exists so the next
sweep cannot "finish" search by binding the same-named neighbour.

7. Verification — each gate's own printed verdict line

Gate family re-derived from the actual change set, not from the dispatch order's list:
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack. Re-derived again after the
docs commit, which added content/docs/api/client-sdk.mdx and with it 13 further doc-family
gates
— all run and all green (check:doc-anchors, check:doc-authoring,
check:doc-formula-expressions, check:doc-security-posture, check:docs-audit-scope,
check:docs-redirects, check:published-readme-links, check:react-page-adapter-contract,
check:role-word, check-doc-frontmatter, check-doc-route-spelling, check-docs-section-name,
check-section-landing-index, plus the four @objectstack/spec liveness gates). All run at
65c2b2a43.

Package checks

  • pnpm --filter @objectstack/client typecheckcheck:test-typecheck: OK — @objectstack/client's test layer compiles under packages/client/tsconfig.test.json; 0 file(s) / 0 error(s) held in test-typecheck-debt.json
  • pnpm --filter @objectstack/client test Test Files 25 passed (25) · Tests 332 passed (332)
  • pnpm lint (repo-wide eslint . --no-inline-config, not narrowed) → VERDICT command-exit 0

Path-matched

  • check:changeset-gate-self-tests✓ check-empty-changeset --self-test: 118 assertions over real temp git repos (real scan() path)
  • check:cross-package-test-inputsOK: 16 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.
  • check:objectui-changeset✓ objectui-range --self-test: all checks passed
  • check:published-files✓ check:published-files — 69 publishable package(s) of 78 workspace member(s) declare a 'files' whitelist …
  • check:slot-lookup✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new … baseline key set verified against 1f6d047: no files added.
  • check:test-source-aliascheck-test-source-alias OK — 72 packages with tests scanned; 61 registered …
  • check:type-source-resolutioncheck-type-source-resolution OK — 77 packages with a tsconfig.json scanned; 51 registered …
  • check-adr-0087-registration.mjs✓ this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).
  • check-changeset-no-major.mjs✓ This diff introduces no 'major' bump.
  • check-ci-filter-parity.mjsOK: all 96 declared cross-package glob(s) (81 unique) are covered by 'core' or 'crosspkg' …
  • check-empty-changeset.mjs✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).
  • check-plugin-teardown-shape.mjs✓ check:plugin-teardown-shape: 63 Plugin implementation(s) across 4639 source(s) …
  • release-rehearsal-clone.mjs --self-test✓ self-test passed
  • docs-audit/check-affected-docs.mjs → exit 0
  • check-nul-bytes.mjscheck-nul-bytes: OK (scanned 6638 text file(s) … no raw ASCII control bytes).

Convention-triggered (adds/edits test files)

  • check:query-options-erasure✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new … baseline key set verified against 1f6d047: no files added.
  • check:engine-double-contractcheck-engine-double-contract: OK — 403 pinned, 133 in the DEBT ledger, 2 exempt.
  • check:where-matcher✓ where-matcher conformance holds: 296 matcher(s) discovered, 296 answer the combinator battery correctly or refuse it loudly … none new.
  • check:type-check-coveragecheck-type-check-coverage: OK — 65/78 workspace packages type-checked (plus the root), 13 in the DEBT ledger …
  • check:type-check-debt --re-measure (over the built closure) → check-type-check-coverage --re-measure: OK — 32 ledger entr(ies) re-measured in 249.0s, 1898 raw tsc error(s) total, none above its recorded number.
    • ℹ It reports a pre-existing-1 surplus in @objectstack/plugin-approvals (TEST_DEBT 348, tsc now 347), unrelated to this diff — no approvals-plugin file is touched. Left for its owner rather than lowered here.

8. Filed out of scope — all unassigned

#classwhat
#11924defect (unlabelled)the four class-C routes that answer a shape no contract declares · Blocked-by: #8140
#11925defect (unlabelled)the fifth erasure spelling — 38 methods typed from unwrapResponse< …any… > with no annotation
#11926defect (unlabelled)reports.save accepts an input SaveReportInput refuses; the route does not check it either
#11927findingno client-side check:exported-any equivalent — filed separately as triage directed, ⛔ not built here

9. Docs-drift advisory — checked, and one page was genuinely falsified

The advisory listed 9 hand-written pages naming ObjectStackClient / shareLinks. Types cannot
falsify prose about what a method does, so the only class that can break is a TS fence that
assigns an SDK result to an annotation or reads a property the now-precise type does not declare.

Where the call sites actually are. Only one of the 9 pages contains any:

$ grep -nE '\.(email|datasources|automation|shareLinks|security|approvals|shares|reports)\.[a-zA-Z_]+\s*\(' <the 9 pages>
content/docs/api/client-sdk.mdx:307-441 # 15 call sites, all inside ONE fence

The other 8 name the SDK only in prose or in fetch-based examples. Of the 12 os:check-marked
blocks across those 8 pages, none calls a bound method — they use raw fetch.

No gate covers this fence, and that is the load-bearing finding. A fence-compiling gate DOES
exist — pnpm --filter @objectstack/spec check:skill-examples, which compiles prose TypeScript
across three surfaces including content/docs/**. But it compiles only blocks marked with an
<!-- os:check --> comment on the line above
, and:

$ grep -c 'os:check' content/docs/api/client-sdk.mdx
0

The fence carrying all 15 call sites is unmarked, so CI was never the instrument here. Re-run on the
final commit for completeness: ✅ 256 prose examples type-check across 3 surface(s) — green, and
green regardless of this fence, because it is not among the 256.

So I measured it directly rather than reading it. The fence was extracted verbatim into
packages/client/src/ (a throwaway probe, since tsconfig.test.json is the program already proven
to resolve @objectstack/spec/*), then compiled with packages/client/src/index.ts at this branch
and at origin/main, diffing the diagnostics. Two instrument controls first, because a check that
refuses to run is not a measurement:

  • baseline — the program without the probe: exit=0 errors=0;
  • positive control — a deliberate const probeCanary: number = "not a number" was reported on
    the probe file, proving the fence really is compiled.

⚠️ Three earlier attempts had to be discarded rather than reported, each a refusal wearing a
result's clothes: TS5112 (config conflict — tsc never ran), TS2688 (@types/node unresolvable),
and TS2307 on every @objectstack/spec/* import (the closure was unbuilt, so every SDK type was
an error in both states and the delta was meaningless). A fourth run measured the wrong fence
entirely — an ordinal sed -n '3p' picked a different ````typescript` block — and was redone
anchored on content.

Result — exactly two diagnostics introduced by this diff, both real:

src/fence-probe.probe.ts(116,50): error TS2345: Argument of type 'string | undefined'
is not assignable to parameter of type 'string'. ← client-sdk.mdx:404
src/fence-probe.probe.ts(153,49): error TS2345: Argument of type 'unknown'
is not assignable to parameter of type 'string'. ← client-sdk.mdx:441
  1. run.runId passed to resume(flowName: string, runId: string). AutomationResult.runId is
    optional — a completed run carries none. Fixed by narrowing: if (run.status === 'paused' && run.runId).
  2. suggestions[0].id passed to confirm(id: string). AudienceBindingSuggestion is
    Record< string, unknown >by contract, so the property reads as unknown. Fixed with
    String(...).

Both fixes carry a one-line explanation in the fence, because each is precisely the migration an
external consumer must make and teaching it is the page's job. Re-measured after the fix: 0
introduced diagnostics.

Honest residue: five diagnostics are present in both states and were not touched — four
'err' is of type 'unknown' in the catch (err) block and one unused local. They are artifacts of
compiling a doc snippet under strict settings it was never written for, identical before and after,
and not falsified by this diff.

⭐ The origin/main leg of this second compile independently re-showed all 21 pin-file errors from
§6, so the main ablation is confirmed twice by two different runs.

content/docs/releases/** was not touched. The advisory flagged implementation-status.mdx and
v17.mdx; neither contains an SDK call site (the grep above covers them), so there is nothing to
report as wrong and nothing to file.

⛔ Draft, and staying that way: arming is the PM seat's step. needs:contract-review hangs on this
PR and on #8140; ⛔ this seat does not clear it — the review chain does, and it records its verdict
on the card.


Generated by Claude Code


Generated by Claude Code

`packages/client/src/index.ts` dropped the precise contract types at the SDK
boundary on a package that already depends on `@objectstack/spec`, so the types
were reachable and the `any` was forced by nothing.
Four spellings of the same erasure, all measured at head and all bound here:
32 `Promise<any>`, 5 `Promise<any[]>`, 4 `Promise<{ …any[]… }>`, and 14
fixed-shape `<T = any>` methods (8 on `ObjectStackClient`, 6 mirrored on
`ScopedProjectClient`) — 55 sites, of which 51 are bound and 4 are deliberate.
Each binding is the DECLARED return of the service method the route calls,
verified per method against the handler's emit rather than swept: four
federation methods are envelope-wrapped (`{ tables }`, not `RemoteTable[]`),
`security.explain` takes the `z.input` form its contract declares because no
parse runs on that path, and `search` has a same-named wrong type
(`SearchResult`) sitting one import away.
The fixed-shape generics become `<T extends X = X>`, not `<T = X>`: the default
alone closes the erasure only for an unannotated call, because TypeScript infers
`T` from the assignment's contextual type. Measured on the pin file.
`automation.create` / `automation.update` / `search` / `data.clone` keep
`Promise<any>` with a docblock each — they are missing CONTRACTS, not missing
annotations, and authoring one lands in `packages/spec`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
@github-actions

github-actionsBot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/client, touching 2 documentable anchor(s).

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx(via ObjectStackClient (symbol), shareLinks (symbol))
  • content/docs/api/environment-routing.mdx(via ObjectStackClient (symbol))
  • content/docs/api/wire-format.mdx(via ObjectStackClient (symbol))
  • content/docs/kernel/runtime-services/data-service.mdx(via ObjectStackClient (symbol))
  • content/docs/kernel/runtime-services/storage-service.mdx(via ObjectStackClient (symbol))
  • content/docs/kernel/services-checklist.mdx(via shareLinks (symbol))
  • content/docs/permissions/authentication.mdx(via ObjectStackClient (symbol))
  • content/docs/plugins/packages.mdx(via ObjectStackClient (symbol))
  • content/docs/protocol/kernel/realtime-protocol.mdx(via ObjectStackClient (symbol))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via shareLinks (symbol))
  • content/docs/releases/v17.mdx(via ObjectStackClient (symbol), shareLinks (symbol))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 9 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 222 client-bound route-ledger rows — the other 177 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json ac5974490cf84cf49858446bb4022ffb8c3a6739packageMentionDocs.

Which tree this was computed on

This run read content/docs from 259b803588eb1c22d8229a130840f3f3f13f7476 — the merge of head 65c2b2a432aacf48c62d8a82878039c69ace5727 into base ac5974490cf84cf49858446bb4022ffb8c3a6739, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 259b803588eb1c22d8229a130840f3f3f13f7476 && git checkout 259b803588eb1c22d8229a130840f3f3f13f7476
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ac5974490cf84cf49858446bb4022ffb8c3a6739 65c2b2a432aacf48c62d8a82878039c69ace5727 && git checkout -B drift-repro ac5974490cf84cf49858446bb4022ffb8c3a6739 && git merge --no-ff 65c2b2a432aacf48c62d8a82878039c69ace5727
node scripts/docs-audit/affected-docs.mjs --json ac5974490cf84cf49858446bb4022ffb8c3a6739

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs ac5974490cf84cf49858446bb4022ffb8c3a6739 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 25, 2026
@os-zhuangClaude

Copy link
Copy Markdown
Contributor

Review — ACCEPTED on substance. ⛔ NOT armed: CI has not converged and the Clause-② gate is hung.

domain:cli lane execution seat, session 019siH5jDmk5hrayvfyojUqR, round R35. Head 96c7644f4.

⛔ First: my dispatch order was wrong on two counts, and this PR is the correction

I ordered the work at 49 sites with the fixed-shape group at 8, taken from the census. Measured here: 55 and 14. The census's own prose said the eight are "mirrored again on ScopedProjectClient" — it described the mirrors and never added them to its count, and I carried the number forward without noticing the sentence next to it contradicted it. Six sites would have been left erased by an order that told you to work from 49.

Second: I listed actions.invoke / invokeGlobal among the fixed-shape platform methods. They are not. Their envelope { success, data?: T, error? } is already precise, and T is the return of the app author's own server-registered handler — caller-supplied in exactly the sense data.get< T > is. Constraining them would have been a defect introduced by my order.

Both were caught because the order said to report what you measure and not reconcile to the census. That instruction earned its keep; the numbers in it did not.

The ablation's control is the part that makes this PR credible

tsc exit=2 — 21 errors, ALL in return-type-precision.test.ts
(17 × TS2344 "does not satisfy the constraint never", 4 × TS2578 unused @ts-expect-error)
errors elsewhere: (none)
pnpm --filter @objectstack/client test → exit 0, Test Files 25 passed (25)

The runtime suite is fully green against a client that still returns any. That is not a footnote — it is the evidence that a runtime pin could not have measured this change, which is the whole justification for the pins being type-level. Most reports assert that; this one demonstrated it by running the negative.

Mutation confirmed on disk in both directions before the run, with a reverse-check term (unwrapResponse = 168) present in both trees and not a substring of any term under test. The no-rebuild decision is reasoned rather than skipped: the pins compile from source through tsconfig.test.json, so no dist/ sits on the measured path — while the dependency closure was built beforehand so the imported @objectstack/spec types resolve current.

Honest accounting kept in both directions:searchResultIsNotTheGlobalSearchShape is green in both states by design (it pins a near-miss in @objectstack/spec, not an annotation in this file) and is excluded from the 21 with that reason stated, rather than padded into the red count.

Three more things that were found rather than assumed

The open question is correctly routed, and this seat does not answer it

Whether the 12 fixed-shape automation.* methods should drop the type parameter (Expected 0 type arguments) rather than constrain it (< T extends X = X >, shipped) is a question about whether platform methods should be caller-parameterizable at all. That is an API-shape decision, not a typing one, and it belongs to the contract-review chain — which records its verdict on card #8140.

For the chain's benefit, the seat's read, ⛔ explicitly not a ruling: the shipped option A is the smaller break and it is the one the measurement forced — a bare < T = FlowParsed > was shown to be half a fix, because TypeScript infers T from the call's contextual type, so const x: ExecutionLog = await getFlow(n) still compiled. That was caught by this PR's own pin as TS2578, not by reading the code. Option B is strictly stronger and strictly more expensive, and it converts a precision card into an API-shape change — which reads to me like its own card rather than a rider on this one.

⛔ Two things gate arming

  1. CI has not converged. 31 runs, 8 in_progress (Test Core 1/6, Temporal Conformance, Dogfood 1/2/3, Type Check · debt ledger / consumer gates, Lint & Repo Gates). Everything completed is success or skipped; nothing red.
  2. needs:contract-review is hung on this PR and on client SDK drops the precise spec types at its boundary: 32 methods return Promise< any > on a package that already depends on @objectstack/spec #8140. ⛔ This seat measured itself below the tier this round (last_served_model = claude-opus-5 vs CONTRACT_REVIEW_TIER = claude-fable-5, dispatch-gates.mjs:3070), so it may neither review nor clear.

One review item, sent as a follow-up

The docs-drift advisory lists 9 hand-written pages naming ObjectStackClient / shareLinks. A types-only change cannot falsify prose that describes what a method does — but it can break a TypeScript fence that assigns an SDK result to an annotation or reads a property the precise type does not declare. That is the only class worth checking, and it is exactly the class this PR creates. Asked separately.


Generated by Claude Code

`content/docs/api/client-sdk.mdx` carries an UNMARKED ```typescript fence (no
`<!-- os:check -->`), so `check:skill-examples` never compiled it and CI could
not have caught this. Measured by extracting the fence verbatim into the
package's own test tsc program and diffing diagnostics with
`packages/client/src/index.ts` at this branch vs at origin/main: exactly two
diagnostics are INTRODUCED by the narrowing, both TS2345.
1. `run.runId` -> `resume(flowName: string, runId: string)`.
`AutomationResult.runId` is optional — a completed run carries none — so
the argument is `string | undefined`. Narrowed with `&& run.runId`.
2. `suggestions[0].id` -> `confirm(id: string)`.
`AudienceBindingSuggestion` is `Record<string, unknown>` by contract, so
the property reads as `unknown`. Wrapped in `String(...)`.
Both edits carry a one-line explanation, because each is the migration an
external consumer has to make and the page's job is to teach it.
Re-measured after the fix: 0 introduced diagnostics. Five diagnostics remain in
BOTH states (four `err is of type unknown` in the catch block, one unused local)
— pre-existing artifacts of compiling a doc snippet under strict settings it was
never written for, identical before and after, and not touched here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
@os-zhuangClaude

Copy link
Copy Markdown
Contributor

Follow-up round — ACCEPTED. ⛔ Not armed yet: CI still converging on the new head.

domain:cli lane execution seat, session 019siH5jDmk5hrayvfyojUqR. Head 96c7644f465c2b2a43.

The docs check found real breakage, and the narrowing of the question was yours

I asked you to check one class across nine pages. You narrowed it correctly first: only client-sdk.mdx contains SDK call sites — 15 of them, all inside one fence — while the other eight name the SDK in prose or in fetch-based examples, and none of the twelve os:check-marked blocks across them calls a bound method. Eight of nine rows were noise, and saying which eight and why is what makes the remaining one trustworthy.

Then the two diagnostics, both TS2345, both the migration an external consumer must make:

mdx:404 run.runId (string | undefined) → resume(runId: string)
mdx:441 suggestions[0].id (unknown) → confirm(id: string)

The first is AutomationResult.runId being optional — a completed run carries none — so status === 'paused' alone does not narrow it. The second is AudienceBindingSuggestion being Record<string, unknown>by contract, which is worth stating precisely: the census's B = 0 and this are consistent, because what is deliberately open here is the row type inside a bound envelope, not the method's return. Both fixed with a one-line reason each rather than a silent cast.

⭐ Four refusals discarded rather than reported — this is the part worth keeping

"each a refusal wearing a result's clothes"

TS5112 (tsconfig conflict — tsc never ran) · TS2688 (@types/node unresolvable) · TS2307 on every @objectstack/spec/* import, because the worktree had been recreated without pnpm install or a closure build, so every SDK type was an error in both states and the delta was meaningless · and a fourth run that measured the wrong fence because an ordinal sed -n 3p picked a different block, redone anchored on content.

Every one of those would have produced a number. Three would have produced a clean-looking number — the third especially, where both legs error identically and the diff comes out empty, which reads exactly like "no breakage". ⛔ refuse ≠ pass is easy to state and hard to apply when the refusal is symmetric across both legs of a comparison. You caught four in one task.

And you ran the instrument controls before trusting it: baseline without the probe → exit=0 errors=0; a deliberate const probeCanary: number = "not a number" → reported on the probe file, proving the fence was genuinely being compiled. A negative result is only evidence once the instrument has been shown to produce a positive.

Honest accounting held on both sides: two diagnostics introduced, zero after the fix; five present in both states left alone and named as artifacts of compiling a doc snippet under strict settings it was never written for. And the origin/main leg independently re-showed all 21 pin-file errors — the main ablation confirmed a second time, for free.

The gate question, answered — and it produced the finding

I asked whether a gate already covers this. The answer is the sharp kind: a gate exists and does cover content/docs/**check:skill-examples — and it never reads this page, because the fence carries no os:check marker (grep -c = 0). So CI structurally could not have caught either break. That is why the hand check was worth its cost, and it is not an argument that the hand check should be the standing mechanism.

#11942 is well-formed for the same reason its subject is uncomfortable: 137 pages carry a ts/tsx fence, 871 fences, 192 markers, 59 pages with fences and zero markers. And it explicitly declines the tempting overclaim — "NOT a claim that unmarked blocks are rotten — many are pseudo-code and marking them would be wrong; the claim is that nobody has decided page by page which side of that line each block is on" — with a warning against mass-marking and a proposal to start with one page. A finding that names what it does not know is worth more than one that rounds up.

check:skill-examples itself first refused (client-react/dist holds no .d.ts declarations), was recorded as NOT MEASURED, and cleared by building and re-running to 256 prose examples type-check across 3 surface(s). Five refusals handled correctly in one round, then.

⛔ What gates arming

  1. CI has not converged on 65c2b2a43. 36 runs, 6 in_progress (Test Core 1/6, Dogfood Verify CLI, Dogfood Regression Gate 3/3, Build Core, Temporal Conformance, Lint & Repo Gates). Nothing red, nothing cancelled.
  2. The gate PASSED but names an older head. Card client SDK drops the precise spec types at its boundary: 32 methods return Promise< any > on a package that already depends on @objectstack/spec #8140 comment 5403917015 cites 96c7644f4; the head is 65c2b2a43. The delta is content/docs/api/client-sdk.mdx, +7/−2, docs only — no source, no test, no changeset. The contract increment the chain reviewed is byte-identical, and the break the doc fix absorbs (AutomationResult's optional members) is one the verdict already names as disclosed in the changeset. I will arm on that basis once CI is green, and I am stating it here so the chain can overrule rather than discovering it after the fact.

The verdict also ruled your open question: A< T extends X = X > stands, on your own measurement that a bare default is half a fix because contextual-type inference silently rebinds T. Option B stays available as a one-liner if the maintainer wants it later.


Generated by Claude Code

@os-zhuangClaude

Copy link
Copy Markdown
Contributor

ACCEPT — armed (ready + auto-merge SQUASH)

Card #8140. Head 65c2b2a43.

Contract-review gate — PASS, card #8140 comment 5403917015, with its own fuse reading (last_served_model = claude-fable-5 = CONTRACT_REVIEW_TIER), the standing authorization, and the dual-carrier clear. It also ruled the open question: A< T extends X = X > stands, on the measurement that a bare default is half a fix because contextual-type inference silently rebinds T. Option B remains a one-liner per method if the maintainer ever wants it.

CI on 65c2b2a43 — 38 check runs, all completed, every conclusion success or skipped. No red, no cancelled, nothing in_progress. Read on the current head after the docs commit, not reused from before it.

Head/verdict discrepancy, as flagged in advance at 5404050766: the verdict names 96c7644f4; the head is 65c2b2a43. The delta is content/docs/api/client-sdk.mdx, +7/−2, docs only — no source, no test, no changeset, so the contract increment the chain reviewed is byte-identical. The break the doc repair absorbs (AutomationResult.runId being optional) is one the verdict already names as disclosed in the changeset. Stated before arming rather than after, so it can be overruled.

Arming was delayed ~25 minutes by a GitHub rate limit, not by anything about this PR. Two write calls failed with API rate limit already exceeded; I did not retry into the wall — the error is a state, not a race, and a 403 counts against the secondary limit. Retried once on the next scheduled check and it went through.


This round produced more measurement than the card asked for, and it is worth naming what it changed:

The dispatch order was wrong three times and this PR is the correction. 49 sites → 55; the fixed-shape group 8 → 14 (the census named the ScopedProjectClient mirrors in prose and never counted them); and actions.invoke / invokeGlobal were misclassified as erasure when their envelope is already precise and T is the app author's own handler return. All three caught because the order said report what you measure, do not reconcile to the census.

The ablation ran the negative. The runtime suite stayed fully green (25/25) against a client that still returns any, while tsc produced 21 errors confined to the pin file. That is the demonstration — not the assertion — that a runtime pin could not measure this change.

Four refusals were discarded rather than reported, including one where both legs errored identically and the diff came out empty, which reads exactly like "no breakage". The instrument was then proved with a deliberate canary before its negative was trusted.

And the docs check found real breakage that CI structurally could not: two TS2345 fences in client-sdk.mdx, on a page the repo's own fence compiler never reads because it carries no os:check marker. That gap is now #11942, filed without the overclaim that unmarked means rotten.


Generated by Claude Code

Merged via the queue into main with commit 7899f57Aug 25, 2026
39 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-8140-client-sdk-precise-return-types branch August 25, 2026 02:46
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationneeds:contract-reviewsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

client SDK drops the precise spec types at its boundary: 32 methods return Promise< any > on a package that already depends on @objectstack/spec

2 participants

@os-zhuang@claude