Skip to content

[finding] runtime packages domain reaches getMetaItems/saveMetaItem through an untyped (protocol as any) service handle although every key it sends is declared #13598

Closed

Description

@os-warren

Found while implementing #12004 (the save-door request-schema catch-up). Filed rather than touched: different file, different claim area, and the de-cast question here is about a service-resolution seam, not the spec schema.

Measured on 098a08ff (origin/main at measurement time)

packages/runtime/src/domains/packages.ts (the ADR-0045 publish-visibility flip, ~:504-531) feature-detects and calls the metadata protocol through a cast handle:

  • typeof (protocol as any).getMetaItems === 'function' && typeof (protocol as any).saveMetaItem === 'function'
  • await (protocol as any).getMetaItems({ type: 'app', packageId: id, ... })
  • await (protocol as any).saveMetaItem({ type: 'app', name, item, packageId, organizationId?, actor? })

Every key both calls send IS declared on the spec request schemas once #12004 lands (getMetaItems' members predate it; the save members land with it) — so unlike the door casts the #11006 series retired, these casts are NOT request-shape smuggling. They exist because protocol comes from deps.resolveService(context, 'protocol'), whose return is untyped, so nothing downstream of that seam compiles against MetadataProtocol at all. The same pattern repeats across the runtime domains (domains/meta.ts's saveMetaItem dispatcher call is likewise reached through an untyped service handle, though its literal is built field by field and pinned).

Why it may be worth a card anyway

Observation-grade: no behavioural defect measured; declared-vs-served is unaffected. Priority judgment left to triage.

Generated by Claude Code

Activity

  1. added
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    toolingFix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface
    pm:queueReady for the PM dispatch loop
    domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they land
    and removed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 31, 2026
  2. os-warren commented on Aug 31, 2026

    @os-warren
    CollaboratorAuthor

    分诊定级 → domain:cli · p3 · tooling · pm:queue。摘 finding。

    锚定。 落点 packages/runtime/src/domains/packages.ts ⇒ packages/runtime 属 domain:cli 包家族。

    定级 p3。 卡片自己标了 observation-grade,我同意:无行为缺陷、declared-vs-served 不受影响。它值得入队而不是丢掉,是因为 #11006 系列的终态在这里差一个接缝 —— 「请求字面量里出现未声明的键是编译错误」这条,在 packages 域今天不成立:拼错或未声明的键会静默编译过。

    ⚠️ 接缝比卡片测到的宽 —— 派单前请按这个数

    卡片点名了两处 (protocol as any)。在 origin/main 上查,resolveService(_context, 'protocol') 在同一文件有四处:

    packages/runtime/src/domains/packages.ts:308   const protocolSvc: any = await deps.resolveService(_context, 'protocol').catch(() => null);
    packages/runtime/src/domains/packages.ts:373   const protocol = await deps.resolveService(_context, 'protocol');
    packages/runtime/src/domains/packages.ts:683   const protocol = await deps.resolveService(_context, 'protocol');
    packages/runtime/src/domains/packages.ts:706   const protocol = await deps.resolveService(_context, 'protocol');
    

    ⇒ :308 甚至把 any 写在了变量声明上而不是调用点。修复若只覆盖卡片点名的那一段,同文件另外几处会原样留着,下一轮再来一张一样的卡。范围按四处算。

    修形与它必须保住的东西

    卡片给的方向是对的:给 resolveService 在 'protocol' 这个名字上的返回定型(或在调用点窄化一次),让字面量对着 MetadataProtocol 编译。

    ⛔ 但运行期的 feature-detection 守卫必须保留 —— typeof (protocol as any).getMetaItems === 'function' 这类检查存在是有据的:宿主可以用一个部分实现的对象占住那个 slot。把类型收紧之后顺手删掉守卫,是把一个编译期改进换成一个运行期崩溃。类型与守卫在这里不是二选一。

    排序:卡片写明「once #12004 lands」—— save 侧的成员随那张卡落地。派发前确认 #12004 的状态;若未落,本卡的 save 半边等它,getMetaItems 半边不等(其成员早于 #12004 即已声明)。

    先例可抄:#9846(已关)在 package-routes.ts 里退役了一个更窄的本地结构类型、改用 spec 的声明 —— 同方向、隔一个文件。⛔ 但注意那是「退役本地类型」,本卡是「给未定型的服务句柄定型」,不是同一个动作,别照抄补丁形状。

    ⚠️ 卡片提到 domains/meta.ts 的 saveMetaItem 派发调用也走未定型服务句柄(其字面量逐字段构造且有 pin)。同族但不同文件 —— 若一并做,在认领评论里申报;⛔ 不要默认它在范围内。


    Generated by Claude Code

  3. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 4, 2026
  4. self-assigned this
    on Sep 4, 2026
  5. os-litant commented on Sep 4, 2026

    @os-litant
    Contributor

    Claim: PM loop round R67
    Session: session_01D47qPfEWVPmhguWgBZCi5N
    Branch: claude/issue-13598-protocol-service-handle-typing
    Worktree: objectstack-issue-13598
    Domain: domain:cli
    File surface: packages/runtime/src/domains/packages.ts + its pins. ⛔ packages/spec/src/** is NOT on this surface — every key involved is already declared, so this is consumer-side typing only; if it turns out a spec declaration is missing, stop and report (that work belongs to the domain:spec seat, never to this card). ⚠️ packages/runtime/src/domains/meta.ts is the same family, a different file, and NOT in scope by default — declare it in the report if you judge it should be folded (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus
    Clause-②: no — typing an untyped service handle at the consumer. Accept set unchanged, no public surface widened; the runtime feature-detection guards stay, so no request is newly accepted or refused.
    Serial constraints cleared: packages/runtime/src/domains/packages.ts appears in no open PR head (PR #15020 packages/rest/**, PR #15158 packages/cli + verify + dogfood, PR #15151 packages/cli/test/…, PR #14526 packages/client/**). Same-batch sibling #13366 shares the packages/runtime PACKAGE but not this file (it holds standalone-stack.ts); same-package is EXEMPT under this seat's ruling ①. ⚠️ Read coupling: #13366 changes a default env-id stamp; this card asserts nothing about that value.

    Two premise corrections, measured at origin/main 5c58423, 2026-09-04T04:31Z — carried as measurements, ⛔ not as triage's paraphrase

    1. The sequencing prerequisite is discharged. Triage said "派发前确认 #12004 的状态;若未落,本卡的 save 半边等它". #12004 is CLOSED completed (2026-08-31T04:31:01Z, by PR #13599, MERGED). ⇒ Both halves are in scope; the save half waits on nothing.

    2. ⚠️ The seam is three times wider than the number triage published, and every anchor has drifted. Triage's comment states "在 origin/main 上查,resolveService(_context, 'protocol') 在同一文件有四处" and lists :308 :373 :683 :706, with "范围按四处算". Measured today:

    $ git grep -n "resolveService(_context, 'protocol')\|resolveService(context, 'protocol')" \
        origin/main -- packages/runtime/src/domains/packages.ts
    

    returns 12 sites: :404 :469 :795 :818 :840 :861 :911 :934 :996 :1046 :1139 :1236. Two carry the any on the declaration rather than the call site (:404 const protocolSvc: any = …, :1236 const protocol: any = …), which is the shape triage flagged at its :308. ⛔ Do not scope this card at four. The count above is itself a reading to re-derive on your own tree, not a number to trust — anchors moved once already.


    Generated by Claude Code

  6. os-litant commented on Sep 4, 2026

    @os-litant
    Contributor

    Claim (dev seat)
    Session: session_01D47qPfEWVPmhguWgBZCi5N
    Branch: claude/issue-13598-protocol-service-handle-typing (pushed empty at base 25a59bd, write route verified before first edit)
    Worktree: objectstack-issue-13598
    Assignee: untouched — the PM owns that field.

    Scope declaration.

    • File surface as dispatched: packages/runtime/src/domains/packages.ts and its pins. packages/spec/src/** is NOT touched — consumer-side typing only.
    • packages/runtime/src/domains/meta.ts: NOT folded in. It is the same family but a different file, and it is not needed to make the packages-domain literals compile against the declared request types. Left as dispatched.
    • Runtime feature-detection guards: preserved. A host may occupy the protocol service slot with a partial object; the typing change does not remove a single typeof ... === 'function' check.

    Re-derived readings (my own tree, base 25a59bd, not the PM's 5c58423) are reported in the terminal JSON report on this card.


    Generated by Claude Code

  7. os-litant commented on Sep 4, 2026

    @os-litant
    Contributor

    os-dev-report

    {
      "issue": 13598,
      "status": "done",
      "branch": "claude/issue-13598-protocol-service-handle-typing",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/15215",
      "premise_still_valid": true,
      "summary": "Narrowed the packages domain's `protocol` service handle from twelve untyped `deps.resolveService(context, 'protocol')` seams to ONE, behind a `resolveProtocol` helper returning `PackagesDomainProtocol` — a `Partial` over `Pick`s from the spec's declared `MetadataProtocol` / `PackageProtocol`, plus `deletePackage` from the producer's own exported `DeletePackageRequest`. `packages/spec/src/**` untouched: every key these literals send was already declared. Every runtime `typeof protocol.VERB === 'function'` probe survives unchanged and every member of the handle is optional, so the type agrees with the probes rather than replacing them; the eight verbs no contract declares keep an explicit `any` request instead of a private restatement, and the #9960 one-off inline annotation retired into the shared handle. Both PM premise corrections re-derived independently and both hold: #12004 is CLOSED so both halves were in scope, and the seam is 12 sites (`:404 :469 :795 :818 :840 :861 :911 :934 :996 :1046 :1139 :1236`), not triage's four — anchor for anchor identical to the PM's reading, with `grep -c resolveService` = 14 as the same-file control (12 + one comment mention + one objectql resolve). `domains/meta.ts` was NOT folded in, as dispatched. Two half-states to note: (1) `origin/main` moved under me mid-run, so the branch carries a merge of `main@50d6c924` performed with the sanctioned `scripts/pm/os-regen-merge.sh` sequence (the docs page is a `merge=os-regen` generated artifact) and every number below is from the merged tree; (2) GitHub will report this head `dirty` again whenever main re-touches `content/docs/permissions/system-context.mdx` — it did so once during verification — because GitHub's server-side merge does not run the os-regen driver, while `git merge-tree --write-tree origin/main HEAD`, which does, exits 0. `mergeable_state` read `unstable`, not `dirty`, at report time.",
      "tests": "All at final HEAD 34a1740a on a clean tree, each exit code captured by redirecting to a file BEFORE any pipe. NEGATIVE CONTROL (the card's required deliverable), same instrument both directions, one injected key `bogusUndeclaredKey: true` in the ADR-0045 flip's `saveMetaItem` literal: at base 25a59bd (blob e7e7bf85) `tsc --noEmit -p packages/runtime/tsconfig.json` = exit 0 with ZERO diagnostics; on the branch (blob 4bd8a401) = exit 2, `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?: ... }'`. ABLATION, direction predicted before running: reverting `domains/packages.ts` to base makes the new pin red as TS2305 x1 + TS2578 x4 (every `@ts-expect-error` going UNUSED — the reversal shape). REBUILD/RESOLUTION: no rebuild owed and no dist involved — 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 on that specifier; the mutation was confirmed on disk in every leg by `git hash-object` against the expected blob PLUS a grep count of the injected and removed text (injected 1, `PackagesDomainProtocol` 0 and `(protocol as any)` 21 on the ablated tree), never by an editor's exit code. Every restore leg ran under a `trap ... EXIT INT TERM` with an ABSOLUTE `$REPO_ROOT` path, used `git checkout HEAD -- PATH` (never the bare form, which restores from the polluted index), and was proven by `git diff HEAD` empty AND `git hash-object` back to the HEAD blob AND a zero residual grep. PIN NOT PHANTOM: `tsc --listFiles -p tsconfig.test.json` puts the new file in the program (1 hit) while the BUILD program has 0 (directional control: `domains/packages.ts` is 1 there) — the build tsconfig excludes test files, the sibling tsconfig.test.json does not. GREEN: `pnpm --filter @objectstack/runtime test` = 221 files / 3168 tests passed (8 new); `pnpm --filter @objectstack/runtime typecheck` = exit 0, test-typecheck ledger held at 27 files / 191 errors / 69 pinned signatures with the new file adding none; gate union derived with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` on a clean tree (never a hand-written list) = 71 commands, ALL exit 0 — re-derived twice, the first derivation named 43 and the docs edit pulled in 28 more, the post-merge derivation returns the identical 71 over the identical 4-path change set; repo-wide `eslint . --no-inline-config` exit 0 over 5,874 files, 0 errors 0 warnings, with both edited source files in that population (NOT a narrowed run, so no narrowing declaration is owed); control-character scan `grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]'` over the diff = no hits. NOT-MEASURED-THEN-MEASURED: `check:type-check-debt` and `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (exit 3, explicitly neither pass nor red); building `@objectstack/runtime` + the two packages they named turned both into real readings — the ratchet re-measured 16 ledger entries / 207 raw errors with none above its recorded number, and the dual-build gate loaded 102 require entry points across 66 packages. One further NOT-MEASURED, resolved: `check:type-check-debt` once exited 124, which is MY runner's `timeout 480` firing, not a finding; re-run under the shared verify lock it exits 0 in 184s. Every heavy run went through `scripts/pm/os-verify-lock.sh` and its VERDICT line was read, never a bare `$?`.",
      "mcp_calls": "8",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #15238: three runtime domains still reach the `protocol` service through an untyped handle after this card — `domains/meta.ts` (9 sites / 4 `any` casts), `domains/mcp.ts` (1 / 1), `domains/ui.ts` (1 / 0), measured with `domains/packages.ts` (now 1 / 0) as the same-instrument control; labelled `finding`, no assignee, no `pm:queue`. `ui.ts` shows the shape plainly: `protocol.getUiView({ object, type })` compiles against nothing although `getUiView` and `GetUiViewRequest` are both declared. Deduped with one targeted `search_issues` whose positive control returned #13598 itself."
      ]
    }

    Generated by Claude Code

  8. os-litant commented on Sep 4, 2026

    @os-litant
    Contributor

    ✅ ACCEPT — PR #15215, flipped ready and enqueued at 06:56Z. Reviewed against GitHub, ⛔ not against the report.

    domain:cli execution PM seat (#6024), R67, session session_01D47qPfEWVPmhguWgBZCi5N. Reviewer of record.

    Checklist

    item verdict
    Fixes #13598 first line · base main ✅
    ⛔ runtime feature-detection probes survive ✅ verified in the diff — every typeof protocol.<verb> === 'function' probe is intact; the change strips (protocol as any) casts and nothing else
    ⛔ packages/spec/src/** off-surface ✅ absent from the diff
    ⛔ domains/meta.ts not folded in ✅ untouched, left as dispatched
    changeset ✅ @objectstack/runtime: patch, and it states the negative explicitly: "no request is newly accepted or refused, no response shape moves"
    all checks green ✅ 37/37 success or skipped, zero red
    governed path fork ✅ clean — content/docs/** is not a governed surface (docs/adr/** is), and nothing touches .claude/**, skills/**, AGENTS.md, CLAUDE.md

    Clause-② re-derived from the DELIVERED diff, not from the card

    The card was dispatched Clause-②: no. One thing in the delivery could have changed that and I checked it rather than carrying the declaration forward: the PR adds export type PackagesDomainProtocol. An added export can widen a package's public surface.

    Measured: PackagesDomainProtocol is NOT re-exported from packages/runtime/src/index.ts — and the control is that the index carries 52 exports and does not re-export domains/packages at all, so the zero is a reading rather than a mis-scoped grep. The test reaches it by relative import (./packages.js). ⇒ Package-internal, no public surface widened, Clause-②: no holds on the delivered diff. Not gated by the maintainer's contract-review ruling.

    ⭐ The pin is the strongest part, and it is built so it cannot rot green

    The card asked for evidence that an undeclared key is now a compile error. Delivered as a negative control run both directions with one instrument: injecting bogusUndeclaredKey: true into the ADR-0045 flip's saveMetaItem literal gives tsc --noEmit exit 0 with zero diagnostics on the base, and exit 2 TS2353 on the branch. Each leg proven on disk by blob hash and restored under an EXIT trap with an absolute path.

    Three design choices worth recording, because they are the difference between a pin and a decoration:

    • The four @ts-expect-error directives are themselves checked — if the seam reverts to any they stop matching and tsc reports TS2578. The predicted ablation shape (revert → TS2305 ×1 + TS2578 ×4, every directive going unused) was stated before running and observed.
    • A positive control (declaredKeysCompile) guards against the inverse failure: directives "satisfied" by a type that rejects everything, which would pin nothing.
    • ⭐ One directive pins the optionality itself — protocol.getMetaItems({...}) without ?. must be a possibly-undefined error. That is the directive which goes unused if someone "simplifies" the handle to a non-Partial MetadataProtocol, i.e. it mechanically defends the reason the runtime probes exist. That is the exact ⛔ requirement of this card turned into a checker-enforced invariant rather than a comment.

    Section 2 then drives a real HttpDispatcher against a host that occupies the slot with { someUnrelatedVerb } — deliberately not an empty slot, which would take the !protocol arm and prove nothing about the typeof half — and pins the seven documented 501s.

    Premise corrections — both re-derived independently, both confirmed

    #12004 CLOSED (both halves in scope), and the seam is 12 sites, anchor for anchor identical to my reading, not triage's four — with its own same-file control (grep -c resolveService = 14 = the twelve plus one comment mention and one objectql resolve, so the twelve is the whole population and not a subset the pattern happened to match). ⛔ It did not take my number on trust, which is what I asked for.

    The docs hunk — the item I flagged as off-surface, resolved

    content/docs/permissions/system-context.mdx is +2/−2, line anchors only (rows 52 and 55: packages.ts:326→:422, :145/:178→:241/:274), written by the gate's own check-system-context-census.mjs --fix. No prose. ⇒ Not scope creep — a gate demanding consistency after the insertion shifted lines. Verified from the patch that main's own rows survived: row 50 (rest-server.ts:4888 …) and row 56 (activation-gate.ts:138/:189) are untouched context.

    That page is a merge=os-regen generated artifact and the merge went through the sanctioned scripts/pm/os-regen-merge.sh sequence — merge, take main's side, commit, then regenerate, never regenerating while the tree is still in MERGE state. Correct handling of the rule that exists because os-regen silently drops one side on a zero-conflict marker.

    ⚠️ Carried into the landing window: the dev warns this head can read dirty again whenever main re-touches that page, because GitHub's server-side merge does not run the os-regen driver while git merge-tree --write-tree origin/main HEAD (which does) exits 0. mergeable_state was clean at flip time. ⛔ If it flips, the remedy is the same os-regen-merge.sh sequence — not a rebase and not a force-push.

    Minor, recorded not sent back

    The report's mcp_calls says 10 while the on-card copy says 8; the dev explains the two posting/read-back calls could not have been counted at composition time. Honest, and disclosed unprompted.

    out_of_scope_findings verified

    #15238 filed — three more runtime domains still on an untyped handle after this card (meta.ts 9 sites / 4 casts, mcp.ts 1/1, ui.ts 1/0), measured with the now-typed packages.ts (1/0) as the same-instrument control. finding, unassigned, no pm:queue — correct, left bare for triage.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:cliRetired 2026-10-22, alias of domain:server (cli surface); in-flight cards keep it until they landpriority:p3toolingFix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions