fix(rest): compile the data doors' protocol requests against the declared contract - #16071
Conversation
…red protocol contract
The DELETE and PATCH data handlers dispatched through `p.deleteData({…} as any)` /
`p.updateData({…} as any)`, which erased TypeScript's check of the assembled request
against `DeleteDataRequest` / `UpdateDataRequest`. A sweep found the same erasure in
two forms across 22 protocol-dispatch sites in this file, not two.
The casts were load-bearing, as filed: `environmentId` and `context` are passed at
these call sites and are members of no data request schema. Neither belongs in one:
`environmentId` is the transport routing key already ruled out of the protocol request
shape (2026-08-18, #9741), and `context` is the SERVER-DERIVED execution context whose
caller-supplied form is a privilege escalation the ingress deletes unconditionally. So
both are declared on a typed envelope beside the ruled `TransportScopedMetaRequest`,
and every other member of every literal is now compiled against the spec contract.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
…eset The `ServerScopedDataRequest` doc-comment names `resolveExecCtx` to say where its `context` member comes from, which moves the census's prose-mention control from 98 to 99. The invocation-site control is UNCHANGED at 77 — no consumer was added, moved or removed — which is the split that census exists to keep visible, and the entry records it in the block's house style. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
…r the line shift Pure line rot, applied with the gate's own `--fix`: the typed-envelope declaration and its imports sit near the top of `rest-server.ts`, so every `isSystem` citation below them moved by a constant +86. No prose, no row and no verdict changed — only the `file:line` anchors. `check-system-context-census` was verified GREEN at the merge base first, so this is a shift this branch caused rather than one it inherited. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 13 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c40c69b05a2be6259dcfc16324c7f674b77cf0c5 && git checkout c40c69b05a2be6259dcfc16324c7f674b77cf0c5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 31403453dda6de58d2bbed91a1448a65e2d0a1a3 29356431077e74df8b6638c01951b260bd47ebe6 && git checkout -B drift-repro 31403453dda6de58d2bbed91a1448a65e2d0a1a3 && git merge --no-ff 29356431077e74df8b6638c01951b260bd47ebe6
node scripts/docs-audit/affected-docs.mjs --json 31403453dda6de58d2bbed91a1448a65e2d0a1a3 |
…otocol-dispatch-casts
… reverted⚠️ The merge with `origin/main` was textually CLEAN and semantically wrong, which on a line-anchor page is the failure mode to expect rather than a surprise: `main` had re-anchored row 39 to `sharing-rule-service.ts:278`/`:503`, the merge resolved that line to this branch's older `:202`/`:427`, and nothing about the merge said so. Only `check-system-context-census` did — it was verified GREEN at `origin/main` (3140345) first, so the four findings were the merge's and not inherited. Re-derived from the merged sources with the gate's own `--fix`; the two anchors now read what `main` set them to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
Seat ruling — clause ② is NO on both limbs, and the seat's own premise was wrongRecording this so a later reader can tell a contract review that was not owed from one that was skipped — and because the reason it is not owed is a correction to this seat, not a formality. The seat's premise, and why it failedThe seat's hypothesis was: if a That premise does not hold. The evidence order matters and is the dev's, not this seat's: the strongest leg is a language guarantee rather than an inference about this codebase — type assertions, annotations and The declaration
One exception the dev named rather than buried, and it is why "no runtime change" is stated precisely here: The dev also scoped the 3161-test run honestly as corroboration, not targeted proof of the
Three things from this round that outlive it
And the answer to "whose are Scope, and what was handed backResidue of the Generated by Claude Code |
Fixes #15866
The DELETE and PATCH data handlers dispatched through
p.deleteData({ … } as any)/p.updateData({ … } as any), which erased TypeScript's check of the assembled request againstDeleteDataRequest/UpdateDataRequest. This restores that check — at 22 dispatch sites, not two.ServerScopedDataRequest of DeleteDataRequest, never the angle-bracket form). The body sanitizer strips short tag-shaped fragments, including inside code fences, so a diagnostic quoted verbatim would arrive with its type name eaten.1. Whose are
environmentIdandcontext? — the question the card said to answer firstBoth the card and triage measured the casts as load-bearing: these call sites pass
environmentIdandcontext, and neither is a member of any data request schema. Confirmed. The answer splits by field, and neither answer is "put it in the schema".environmentIdis transport-level, and this was already ruled.resolveProtocol(environmentId)selects the target kernel before the protocol call;@objectstack/metadata-protocol's data methods never read it off the request;protocol.zod.tsrecords the same exclusion schema-side. That is the 2026-08-18 ruling on #9741, which this file already implements for the meta doors throughTransportScopedMetaRequest.contextis server-derived, and putting it in the schema would re-open a closed hole. UnlikeenvironmentIdit is consumed —findData/getData/createData/updateData/deleteDataall declarecontext?: anyand forward it so the RBAC/RLS middleware can enforce. But the request schema is the catalog's publishedrequestSchema, so declaringcontextthere would make it caller-supplied, and the ingress deletes any inboundcontextunconditionally for exactly that reason:⇒ Both members are server-side, so both are declared on a typed envelope, not in the spec: a new
ServerScopedDataRequestalias sitting beside the ruledTransportScopedMetaRequest, declaringenvironmentIdandcontextand nothing else. Every other member of every literal is now compiled against the spec contract. No file underpackages/spec/srcis touched.2. Clause ② — one sentence per limb, judged from the delivered diff
Content limb —
Clause-②: no. The delivered diff touches no file underpackages/spec/srcand adds, removes or moves no key on any published payload: every door assembles and forwards byte-identically the object it forwarded before, the two(Schema as any)removals leave the same schema running the samesafeParseover the same input, and thetype: req.params.type as anysimplification is a no-op becausereqisany.Conformance limb —
Clause-②: no. No input class is re-selected between two published verdicts on any shipped face: no validation was added, tightened or loosened, no request that was accepted is now refused and none that was refused is now accepted, because the instrument this card restores is a build-time check that no request ever passes through.3. The full residue — every
as anyon a protocol dispatch in this fileTriage counted 15 occurrences of the
} as any)form and explicitly did not judge whether all 15 were protocol dispatch. Classified: 14 of the 15 were, 1 was not. The stronger form-B set is larger than triage's 5.Form A — the argument object is cast (the card's form). 14 of 15; all repaired.
GET /ui/view/:object/:typegetUiViewTransportScopedMetaRequestGET /data/:objectfindDataGET /data/:object/:idgetDataPOST /data/:objectcreateDataPOST /data/:object/queryfindDataPATCH /data/:object/:idupdateDataDELETE /data/:object/:iddeleteDatacreateDatafindDatacreateDataPOST /data/:object/batchbatchDatacreateManyDataupdateManyDatadeleteManyDataThe 15th is not protocol dispatch:
guardedRouteManager.register({ … } as any)is aRouteManager.registercall againstRouteEntry. Out of this card's class; left alone.Form B — the protocol object itself is cast, erasing every method. 9 repaired, the rest load-bearing.
Triage was right that this form is stronger and that the card's argument holds more firmly on it. The axis that decides each one is whether the member is declared:
Repaired (9) — all on REQUIRED
DataProtocolmembers, where the cast was pure erasure and no guard depended on it: import-job persist (createData), import-job progress patch (updateData), import-job cancel (updateData), import-undo delete (deleteData), import-undo restore (updateData), import-undo stamp (updateData), import-job listing (findData), export chunk loop (findData— its request variable was additionally typedany, which had to go too), public picker (findData, also form A).Not repaired, and correctly so — the cast carries member existence, not request shape.
cloneData,searchAll,getObjectSchema,omitInternalWriteFields,getMetaDiagnostics,listDrafts,migrateStoredMetadata,findReferencesToMeta,getMetaItemLayered,rollbackMetaItem,diffMetaItemare server-only extensions thatRestProtocoldeliberately does not declare ("Server-only extensions … are feature-detected via runtime casts and so don't widen this contract"); each is reached behind atypeof … === 'function'guard that answers 501. Removing the cast is TS2339, not a typing improvement.Not repaired, deliberately —
getMetaItems/getMetaTypes/getMetaItem. These are declared members, but the #9805 comment already rules that this exact optional-call spelling survives: a host may occupy the protocol slot with an object that does not implement the whole surface, so retiring the guard turns a tolerated absence into aTypeError. That is a behaviour change, not a typing fix.Residue after this PR:
} as any)is 15 → 1 (the non-dispatchregistercall). Every remaining(p as any)reaches a member that is either undeclared or guard-paired for a documented reason.4. Proof the check is actually restored — predicted first, then observed
A cast removed into a type that accepts anything is indistinguishable from a cast removed. Two mutations, each written down before it was run.
M1 — the card's own scenario: a newly-REQUIRED field
Predicted: adding a required member to
DeleteDataRequestSchematurnstsc --noEmiton@objectstack/restred, naming the file and the line.Applied to the spec source, proven on disk (injected marker count 1, anchor intact), then
pnpm --filter @objectstack/spec build, then proven to have reacheddist/withscripts/ablation-dist-preflight.mjs— that build step is load-bearing, becausepackages/restresolves@objectstack/spec/apithrough the packageexportsmap todist, so an unbuilt mutation would have left this ablation silently green.Observed — RED, at two sites:
M1 control — is it the cast that erased it, or the field that is loud?
With the same mutated spec still in place, the DELETE door alone was reverted to its original
p.deleteData({ … } as any)form (proven on disk: repaired form 0, cast form 1) and tsc re-run.Observed: the error at 8475 disappeared; the still-repaired sibling at 8921 kept reporting. ⇒ the cast is what erases the check, per site. (I had loosely predicted "green"; the run is sharper than that prediction — the control is site-local, and the surviving sibling error is the positive control proving the instrument was still measuring.)
M1 restore — the leg that is usually skipped
The preflight caught that the spec build had also rewritten a checked-in artifact (
packages/spec/authorable-surface/api.json), which the first restore attempt missed, and that restoring source without rebuilding leaves the mutated marker indist/where every later run reads it. Both were repaired: artifact restored from HEAD, spec rebuilt, then verified —git status --porcelainempty, and preflight--absentreporting "marker absent from all 217 built files".M2 — an undeclared member
Predicted: TS2353. Observed, exactly:
Restore proven by whole-tree
git diff HEADempty.One thing measured that changed the repair
{ ...someAnyValue, bogusKey: 1 }assigned to a declared type produces no excess-property error — spreading ananymakes the whole literalany. Measured directly, with a passing control on the same instrument. So at the updateMany / deleteMany doors, typing the const would have restored nothing whileparsed.datawasany; dropping the(Schema as any)on those twosafeParsecalls is what makes the repair real rather than cosmetic. Same defect class, same gate family, no behaviour change.What the restored check does NOT cover, stated so it is not overread
These handlers declare
req: any, so keys sourced from the request bag arrive asany. What is regained is the key set — an undeclared member and a missing required member — not the value types of keys read offreq.5. Handed back, not acted on — filed as #16066
Restoring the check reddened three sites on one slot, and the honest answer there was neither repair this card allows.
FindDataRequest.querydeclares the QueryAST, but the shippedfindDataingress also folds an undeclared wire dialect ($top,$orderby,filter/filters/$filter,$expand) that its own normalizer documents as "the wire-only spellings no schema declares". Three server-built literals speak it (import-job listing, export chunk loop, public picker).Widening
QuerySchemais forbidden by Prime Directive 12 and by this card; a runtimesafeParseis closed by the card's own reasoning. So the erasure went from one call wide to one slot wide, behind a named, greppablewireDialectQueryhelper carrying the reasoning, and the contract question is filed as #16066 rather than settled here. At those three sites the method name, the arity and every other request member are now compiled.6. Verification
Union derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, never from a hand-built diff. Exit codes captured before any pipe.Reconciliation — N famil(ies)line (55 = 55, then 84 = 84, then 84 = 84).Everything below was harvested on the delivered head
29356431077, after mergingorigin/mainin.check:dual-build-cjs-loadsandcheck:type-check-debt— both exit 3, PREREQUISITE NOT MET; each needs every package'sdist/, i.e. the whole-farm build CI runs.check:skill-examplesandcheck:published-readme-exportsfirst blocked the same way; their dependencies were built and both then passed (the former over 257 prose examples).check-partof-closing-keyword.mjsandcheck-single-claim-paths.mjsexit 2, NOT WIRED (no PR context outside the workflow — the former was then re-run with this body supplied asPR_BODYand passed);check:published-readme-exportsexit 3;check:react-declaration-parityexit 1 — identical at the merge base, a missing objectui manifest, so not this diff.check:partof-closing-keywordandcheck:single-claim-pathsresolve to--self-testonly (Two artifact-roster gates report a green that is not PR clearance — the pnpm script names resolve to --self-test only #16030), so their green grades the checker's fixtures and not this diff.packages/rest, on the delivered head29356431077:pnpm typecheckOK (includingcheck:test-typecheck, 0 files / 0 errors),vitest run185 files / 3161 tests, all passing — re-run after the merge, on a rebuilt dependency closure, because the merge changedpackages/spec.pnpm lintnarrowed, and the narrowing measured rather than asserted. ① The population is read from eslint's own config, which lintsts,tsx,mts,cts,js,jsx,mjs,cjsonly — so of this diff's 4 paths, the.mdand.mdxare outside it by configuration, not by my judgement. ②--format jsonreports 2 files linted, matching that lintable subset exactly. ③eslint.config.mjsenables no type-aware linting (noprojectService, noparserOptions.project, notsconfigRootDir), so every verdict is a function of a file's own text plus the shared config, neither of which this diff moves for any file it does not contain — the narrowing therefore excludes nothing. Result: 0 errors, 0 warnings.Not run locally and deliberately left to CI: the repo-level
pnpm lintsweep and the two whole-build gates above.The same gate reddened twice, for two different reasons — the second one is worth reading
First, line rot this branch caused.
check-system-context-censuswent red because the envelope declaration and its imports shifted everyisSystemcitation below them by a constant +86. Verified green at the merge base first, so it was this branch's shift and not an inherited one, then repaired with the gate's own--fix: 4 anchors, no prose, no row, no verdict.Then, a clean merge that was semantically wrong. Merging
origin/mainin reported no conflict, andgit merge-treehad predicted none — but the census went red again with 4 findings inplugin-sharing/src/sharing-rule-service.ts, a file this branch never touched.origin/main(31403453dda) was verified green on its own, so the findings belonged to the merge. Cause: that page is a generated artifact merged through a merge driver rather than a text merge, somain's re-anchoring of row 39 to:278/:503was silently resolved back to this branch's older:202/:427. Nothing about the merge said so; only the gate did. Re-derived from the merged sources with--fix, and the repo's ownos-regenpre-commit hook independently confirmed the artifact was stale and then current. ⇒ On a line-anchor page, a clean merge is not evidence of a correct merge.7. Merge state
origin/mainwas merged in, never rebased — delivered head29356431077, merge base31403453dda. Every number in §6 was harvested after that merge, on a rebuilt dependency closure.8. Not claimed
Whether callers of these protocol methods outside
packages/restcarry the same erasure was not swept — this was scoped to the file the card and triage named.🤖 Generated with Claude Code
https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N