Repository navigation
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>
…e-delivery rejections (objectstack-ai#15276) * fix(runtime): type the packages-domain `protocol` service handle so undeclared request keys are compile errors (objectstack-ai#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> * fix(objectql): publish the record's organization on every DataEvent (objectstack-ai#15220) * fix(objectql): publish the record's organization on every DataEvent (objectstack-ai#14970) `DataEventSchema.organizationId` was declared and published by the spec half but populated by nothing, so every `data.record.*` event went out with the key absent — which the contract requires a consumer to read as "this record is behind no organization wall". `publishDataEvent` now resolves it from the row itself: the written record on `created`, the post-state on `updated`, and the by-id branch's already-read pre-image on `deleted`, so no per-event read is bought. The record's organization, never `ExecutionContext.tenantId` — that is the caller's active org, and the two diverge on exactly the system/unscoped write this key most needs to label correctly. Absence keeps one spelling: the key is omitted, never `''` (which the schema refuses outright, dropping the whole event) and never an explicit `undefined` (which survives `parse` as a present key). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * docs(permissions): re-anchor the system-context census after the engine line shift Mechanical repair by `node scripts/check-system-context-census.mjs --fix`, the only correct writer for this table. Pure line rot: the `eventOrganizationId` helper and its threading shifted every later line in `packages/objectql/src/engine.ts`, so 14 anchors (15 citation sites — one source line is cited twice) pointed at the wrong lines. No population and no classification change: still 106 elevation read sites in 20 packages across 45 files, all anchored; 140 anchors resolve, 27 declared non-read — the same figures as before the shift. `--fix` did not refuse, and the diff is digits and nothing else (12 lines added, 12 removed, identical once digits are stripped). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --------- Co-authored-by: Claude <noreply@anthropic.com> * ci(reaper): arm the merged-branch reaper for scheduled deletion of claude/ branches (objectstack-ai#15224) * ci(reaper): arm the merged-branch reaper for scheduled deletion of claude/ branches Flips .github/workflows/merged-branch-reaper.yml from report-only to the scheduled weekly deletion the maintainer ruled on 2026-09-04 (issue objectstack-ai#12771, decision batch objectstack-ai#30), reaffirming the 2026-08-31 ruling under the base-ref guard PR objectstack-ai#15144 landed. Deletion is a SEPARATE job (`reap`), because `permissions:` is scoped per job. `sweep` keeps `contents: read` + `pull-requests: read` and remains structurally incapable of deleting a ref; `reap` holds the only `contents: write` in the file, consumes the `reapable` list `sweep` publishes as a job output, and computes no classification of its own. Fences: - `reap` never runs on `pull_request` — the self-exercising run stays a dry run — and its `if:` is an allowlist of `schedule` plus a `workflow_dispatch` on which the operator explicitly set `dry_run: false`. - the new `dry_run` workflow_dispatch input defaults to true, so the manual path is fail-closed. - the base-ref guard, `PREFIX`, `BASE_REF`, the grace window, the schedule and the `is-ancestor` prohibition are all untouched. - the whole deletion list is printed to the run log before the first delete. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk * test(reaper): pin the deletion hand-off and fence the delete job structurally The contract harness drives the `sweep` classifier and can say nothing about the job that deletes — deletion deliberately lives outside the extracted script, so what the harness judges stays a classification rather than an action. Two additions close that gap. 1. The hand-off. `sweep` now publishes `reapable_branches`, the machine-readable half of the list it prints, and `reap` consumes that and nothing else. Scenarios G1/G2/R1 pin that the list EQUALS the reapable bucket — same members, same order — over a population carrying one branch in every bucket, and mutations M13/M14 drive both directions red (held branches leaking in; the list not published at all). 2. The fence. `reapFenceFailures()` parses the shipped YAML and asserts the delete job's structure: its `if:` excludes `pull_request` and gates `workflow_dispatch` on `inputs.dry_run == false`; it declares `contents: write` and is the ONLY job in the file that does; the top-level grant stays `contents: read`; it still `needs: sweep`. New self-test battery 6 drives six mutations of the workflow text to red, each asserting its anchor was present first. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk * ci(reaper): put every excluded bucket on the run-log audit line The maintainer's ruling names the run log as the audit trail, and the notice line named three of the seven buckets — reapable, mergedElsewhere, noPr. The other four (open, closedUnmerged, grace, protectedBranch) lived only in the step summary and the uploaded artifact, so the log alone could not answer "what did it hold back, and why". Also retires two strings that stopped being true when the reaper was armed: the summary heading said "DRY RUN. Nothing was deleted." of a run that may now delete in a later job, and the notice said "Nothing was deleted" of the whole run rather than of this job. Both now speak for the `sweep` job only, which is the thing they were ever really asserting — its token grant is `contents: read` and that has not changed. No classification changed: the buckets, the guard, the grace window and the step outputs are byte-identical. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk --------- Co-authored-by: Claude <noreply@anthropic.com> * fix(service-automation): compile the test layer with tsc, and repair the TS2341 x3 it hid (objectstack-ai#15152) * wip: onboard service-automation typecheck, fix TS2341 residue * wip: onboarding gate registry entry + changeset * fix(scripts): re-measure this entry's provenance totals on the merged tree The `service-knowledge` onboarding landed on `main` between this entry's first reading and this merge, so every absolute in its provenance block (programs, pairs, packages, clean count) was a number about a tree that no longer exists. Re-taken with `--list` on the merge commit itself, all four rows plus the before/after pair, by varying only what the `typecheck` script names: no `typecheck` script absent 120 programs / 293 pairs names tsconfig.json absent 120 programs / 293 pairs names tsconfig.test PRESENT 121 programs / 302 pairs names both (the card) PRESENT 121 programs / 302 pairs before 59 of 78 packages, 120 programs, 293 pairs, 19 clean after 60 of 78 packages, 121 programs, 302 pairs, 18 clean The deltas this block actually claims (+1 package, +1 program, +9 pairs, one per dep) are unchanged; only the absolutes moved, and the block now says which merge moved them. The sibling entries' own blocks keep their own historical readings untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --------- Co-authored-by: Claude <noreply@anthropic.com> * docs(platform-objects): widen sys_email.error description to cover pre-delivery rejections `sys_email.error` was declared as "Transport error message when status=failed", but since objectstack-ai#14371 EmailService.recordRejectedMessage also writes status=failed rows for messages rejected by normalizeMessage before they reach a transport (prefixed "rejected before delivery: ..."). The declared field help was narrower than what the column actually holds. Widen the description (wording settled in triage, issue comment 5504375428) and regenerate the platform-objects i18n bundle with its own tooling (node scripts/check-i18n-bundles.mjs --write) rather than hand-editing the generated file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * chore(platform-objects): add changeset for sys_email.error description widening Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --------- Co-authored-by: Litant Ying <litant.dev@proton.me> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: os-sales <sales@objectstack.ai>
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.