fix(runtime): type the packages-domain protocol service handle so undeclared request keys are compile errors - #15215
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
…typing Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
…the typing block Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
…otocol-service-handle-typing
…ed tree Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
📓 Docs Drift CheckThis PR changes 1 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 24 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 8417bc388e079187252330af732e47acc7adfb82 && git checkout 8417bc388e079187252330af732e47acc7adfb82
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 50d6c924bb7cacbc934e8386d8523f1be40cc040 34a1740a63cacc9cb146c78be9a251595502eced && git checkout -B drift-repro 50d6c924bb7cacbc934e8386d8523f1be40cc040 && git merge --no-ff 34a1740a63cacc9cb146c78be9a251595502eced
node scripts/docs-audit/affected-docs.mjs --json 50d6c924bb7cacbc934e8386d8523f1be40cc040
|
…ndeclared request keys are compile errors (#15215) * wip(runtime): type the packages-domain protocol service handle Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * wip(runtime): add the packages-domain protocol handle typing pin Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * chore(changeset): patch note for the packages-domain protocol handle typing Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * docs(permissions): re-anchor the system-context census rows moved by the typing block Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * docs(permissions): regenerate the system-context census from the merged tree Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #13598
The packages domain reached
getMetaItems/saveMetaItem— and ten other protocol verbs — through a service handle typedany, so nothing downstream of that seam compiled against a contract. The #11006 series' end state ("an undeclared key in a request literal is a compile error") stopped one seam short here. This narrows the slot once, at one helper, and keeps every runtime capability probe.What was measured, before writing anything
The card's anchors (
~:504-531) and triage's count ("四处",:308 :373 :683 :706) are both stale. Re-derived on this branch's base25a59bdwith triage's own instrument::404 :469 :795 :818 :840 :861 :911 :934 :996 :1046 :1139 :1236— twelve, not four, matching the dispatching PM's own re-measurement anchor for anchor. Two carry theanyon the declaration rather than the call site (:404const protocolSvc: any = …,:1236const protocol: any = …), which a call-site-only fix would have left standing. A broader control on the same file (grep -c resolveService) returns 14: the twelve plus one comment mention and oneobjectqlresolve, so the twelve is the whole population and not a pattern that happened to match a subset.The seam, and why the type is not on the slot
DomainHandlerDeps.resolveServicealready expresses a per-name return type — its first overload is keyed onkeyof ServiceSlotContracts.'protocol'is deliberately absent from that map, and the reason is written at both ends: the contract's own comment ("real services with no written contract, so they keep today'sanyrather than being given a shape here that nothing verifies") and this file's #9960 note. Mapping the slot would be apackages/specchange that has to answer for the whole slot, including the eight verbs no contract declares — and it would assert that a filled slot is aMetadataProtocol, whose members are mostly required, which is exactly what the runtime probes exist to deny.So the narrowing happens at the consumer, once, in the shape
domains/mcp.tsalready uses for the same slot — itsMcpMergedMetadataRead, aPickofMetadataProtocol'sgetMetaItems:PackagesDomainProtocol— aPartialoverPicks from the spec'sMetadataProtocol/PackageProtocol, plusdeletePackagefrom the producer's own exportedDeletePackageRequest(already imported here since [finding]protocol.deletePackagehas no declared spec shape — three hand-rolled types that disagree, and the runtime twin reaches it throughas any#9960).resolveProtocol(deps, context)— THE one widening point. A thirteenth call site added next month gets the type by construction rather than by remembering to write one.The eight verbs with no declared request shape anywhere (
publishPackageDrafts,discardPackageDrafts,listCommits,revertCommit,rollbackToPackageCommit,reassignOrphanedMetadata,duplicatePackage,updatePackage) keep an explicitanyrequest.@objectstack/metadata-protocoltypes them inline on the implementation class and exports nothing for them; writing a structural type here would be a private restatement nothing verifies — the thing #9846 retired one file over. What their entries still buy is the verb name: a misspelt verb is now a compile error where theanyhandle took any spelling at all.The guards STAY
Not one
typeof protocol.VERB === 'function'probe is removed or weakened. A host may occupy this slot with a partial object; thatPartialis what makes the type agree with the probes instead of contradicting them. The type answers "is this key declared?", the probe answers "did this host bring the verb?" — two different questions, both still asked. Section 2 of the new pin drives a real dispatcher whose protocol brings none of the verbs and asserts the seven documented 501s.packages/spec/src/**is untouched: every key these literals send was already declared, so this is consumer-side typing only.Evidence: an undeclared key is now a compile error
Same instrument both directions, one injected key (
bogusUndeclaredKey: true) in the ADR-0045 flip'ssaveMetaItemliteral, each leg proven on disk by blob hash and restored the same way:tsc --noEmit -p packages/runtime/tsconfig.json25a59bd(blobe7e7bf85)4bd8a401)packages.ts(727,41): error TS2353: Object literal may only specify known properties, and 'bogusUndeclaredKey' does not exist in type '{ type: string; name: string; item: unknown; organizationId?: … }'Both legs restored with
git checkout HEAD -- "$REPO_ROOT/PATH"under an EXIT trap, each verified bygit diff HEADempty andgit hash-objectback to the HEAD blob.packages-protocol-handle-typing.test.tsmakes that measurement durable rather than one-shot. Its four@ts-expect-errordirectives are themselves checked — if the seam ever goes back toanythey stop matching an error and tsc reports TS2578 — and they are not phantom checks:--listFilesputs the file intsconfig.test.json's program (1 hit) and not in the build program (0 hits, whiledomains/packages.tsis 1 there), so the probe is directional rather than blind. The file carries no entry intest-typecheck-debt.json, which the ratchet reads as "any error it gains is red on arrival"; the ledger is unchanged at 27 files / 191 errors.Ablation, direction predicted before running. Reverting
domains/packages.tsto base (blob confirmede7e7bf85on disk,PackagesDomainProtocolgrep 0,(protocol as any)grep 21) makes the pin red as TS2305 x1 + TS2578 x4 — the reversal shape, every directive going unused, which is why the directives are the pin and notexpectTypeOfassertions. Restored and re-verified by hash. Nodist/is involved in this ablation and no rebuild is owed: the subject is reached by an intra-package relative import (./packages.js), so tsc and vitest both read it from source, with no vitest alias in play.Verification
Everything below re-run at final HEAD
34a1740a, on a clean tree, AFTER mergingorigin/main(50d6c924) into the branch — every number here is from the merged tree, not from the pre-merge one.pnpm --filter @objectstack/runtime test— 221 files / 3168 tests passed, including the 8 new ones.pnpm --filter @objectstack/runtime typecheck— exit 0 (tsc --noEmitpluscheck:test-typecheck: ledger held at 27 files / 191 errors / 69 pinned signatures).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(never a hand-written path list) — 71 commands, all exit 0, each exit code captured before any pipe. Re-derived twice: once after the docs edit below (which pulled in 28 gates the first derivation did not name) and again after the merge, where it returns the identical 71 over the identical 4-path change set.pnpm lint(repo-wideeslint . --no-inline-config, exactly as CI spells it) — exit 0 over 5,874 files, 0 errors, 0 warnings; both edited source files are in that population. Not a narrowed run.grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]') — no hits.check:type-check-debtandcheck:dual-build-cjs-loadsfirst answered PREREQUISITE NOT MET (exit 3 — not a pass and not a finding). Building@objectstack/runtimeand the two other packages they named turned both into real measurements: the debt ratchet re-measured 16 ledger entries / 207 raw errors with none above its recorded number (17 / 217 before the merge), and the dual-build gate loaded 102 require entry points across 66 packages.The docs hunk
content/docs/permissions/system-context.mdxmoves three line anchors by exactly 96 — the height of the inserted type block — for census rows 52 and 55. Written by the gate's ownnode scripts/check-system-context-census.mjs --fix, which is whatcheck:system-context-censusdemanded; no prose changed.That page is a
merge=os-regengenerated artifact, so the merge went through the sanctioned sequence(
bash scripts/pm/os-regen-merge.sh, self-test green first): merge, take main's side, commit the merge, THEN regenerate— never a regeneration while the tree is still in MERGE state. Verified afterwards that main's own advances on the page
survived: diffed against
origin/main, the only difference is my two rows (52 and 55), with main's row 50(
rest-server.ts:4888…) and row 56 (:138/:189) intact.dirtyagain. That page is re-anchored by every PR that shifts a line in aruntime source file, and GitHub's server-side merge does not run the
os-regendriver, so a textual conflict reappearsthe moment main re-touches it — it did so once while this PR was being verified.
git merge-tree --write-tree origin/main HEAD(which does honour the driver) exits 0. ⛔ Not a reason to force-push or rebase: the remedy at landingtime is the same
scripts/pm/os-regen-merge.shsequence.Out of scope, deliberately
packages/runtime/src/domains/meta.tsreachessaveMetaItemthrough the same untyped handle. Same family, different file, and not folded in: it is not needed to make the packages-domain literals compile against the declared request types, and its literal is built field by field and separately pinned. Left as dispatched.