From f8d8c7a996ac3c4dd68464e7504d83c0e2c717c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 04:43:08 +0000 Subject: [PATCH 1/5] wip(runtime): type the packages-domain protocol service handle Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- packages/runtime/src/domains/packages.ts | 173 ++++++++++++++++++----- 1 file changed, 138 insertions(+), 35 deletions(-) diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index e7e7bf8503..4bd8a401d0 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -40,10 +40,17 @@ import { OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES } from '@objectstack/metada import { isWritablePackage } from '@objectstack/metadata-protocol'; // [#9960] The uninstall seam's DECLARED shapes, from the same producer and for // the same reason as the predicate above: this door reached `deletePackage` -// through `(protocol as any)` and routinely sent two keys — `organizationId` +// through `protocol` and routinely sent two keys — `organizationId` // and `keepData` — that the sibling REST door's own option type could not even // express. One statement of the contract, imported by both doors. import type { DeletePackageRequest, DeletePackageResponse } from '@objectstack/metadata-protocol'; +// [#13598] The DECLARED protocol contracts this domain's request literals are +// compiled against. Imported, never restated: a second hand-written +// `saveMetaItem(…)` signature here would silently drift from the one the spec +// declares and `ObjectStackProtocolImplementation` states it `implements` — +// which is the whole reason `PackagesDomainProtocol` below is `Pick`ed rather +// than written out. Same move `domains/mcp.ts` makes for its merged-read seam. +import type { MetadataProtocol, PackageProtocol } from '@objectstack/spec/api'; // [#8443] ADR-0112's disclosure rule (#8086 / #8136 / #8333), and the DECLARED // 422 that keeps the one quotable population quotable. Both imported from the // producer for the reason the line above is: this door's seed-apply fallback is @@ -58,6 +65,95 @@ import { setPackageDisabled } from '../package-state-store.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; +/** + * [#13598] The `protocol` service slot **as this domain reaches it** — one + * statement of the handle, `Pick`ed from the DECLARED contracts, replacing + * twelve independent `protocol` seams in this file. + * + * ## What was wrong with the seam + * + * `deps.resolveService(context, 'protocol')` answers `any`. That is not an + * oversight — {@link DomainHandlerDeps.resolveService} types its return from + * `ServiceSlotContracts`, and `protocol` is deliberately left unmapped there + * ("real services with no written contract, so they keep today's `any` rather + * than being given a shape here that nothing verifies"). The `any` is honest + * about the SLOT. What it also did, silently, was hand every request literal + * downstream of it an unchecked call target: the #11006 series' end state — + * "an undeclared key in a request literal is a compile error" — stopped one + * seam short here, so a misspelt or undeclared key in these literals compiled. + * + * ## Why the type is here and not on the slot + * + * Mapping `'protocol'` in `ServiceSlotContracts` would type every consumer at + * once, but it is a `packages/spec` change that would have to answer for the + * whole slot — including the seven verbs below that no contract declares at + * all — and it would state that a filled slot IS a `MetadataProtocol`, whose + * members are mostly REQUIRED. That is the shape the guards exist to deny (see + * next paragraph). So the narrowing happens at the consumer, once, exactly as + * `domains/mcp.ts` narrows the same slot to `Pick` for its merged read. + * + * ## ⛔ Every member is OPTIONAL, and the runtime guards STAY + * + * A host may occupy this slot with a partial object — that is the documented + * reason the `typeof protocol. === 'function'` probes exist, and every + * one of them survives this change unchanged in meaning. `Partial<…>` is what + * makes the type agree with them instead of contradicting them: tightening the + * type and then deleting a probe would trade a compile-time improvement for a + * runtime crash. The type answers "is this key declared?"; the probe answers + * "did THIS host bring the verb?". Two different questions, both still asked. + * + * ## Where the ledger honestly ends + * + * The first two groups name shapes someone DECLARES: the spec's + * `MetadataProtocol` / `PackageProtocol`, and — for `deletePackage` — the + * producer's own exported request type, already imported here since #9960 for + * exactly this reason. The last group has no declared request shape anywhere: + * `@objectstack/metadata-protocol` types those seven verbs inline on the + * implementation class and exports nothing for them. Writing a structural type + * for them HERE would be a private restatement that nothing verifies — the + * thing #9846 retired one file over. So their request keeps `any` and the gap + * stays visible and greppable: declaring them is producer-side work, not this + * consumer's to invent. What the entries still buy is the verb name itself — + * `protocol.rollbackToPackageCommmit` is now a compile error where the `any` + * handle took any spelling at all. + */ +type PackagesDomainProtocol = + Partial> + & Partial> + & { + /** Declared by the producer (`@objectstack/metadata-protocol`), #9960. */ + deletePackage?(request: DeletePackageRequest): Promise; + /** ⚠️ Undeclared request shapes — see "Where the ledger honestly ends". */ + publishPackageDrafts?(request: any): Promise; + discardPackageDrafts?(request: any): Promise; + listCommits?(request: any): Promise; + revertCommit?(request: any): Promise; + rollbackToPackageCommit?(request: any): Promise; + reassignOrphanedMetadata?(request: any): Promise; + duplicatePackage?(request: any): Promise; + updatePackage?(request: any): Promise; + }; + +/** + * [#13598] Resolve the `protocol` slot as {@link PackagesDomainProtocol}. + * + * THE one narrowing point for this file. `resolveService` answers `any` for + * this name, so the widening happens here and nowhere else — every call site + * downstream holds a typed handle, and a thirteenth call site added next month + * gets the type by construction rather than by remembering to write one. + * + * ⛔ Not a guard and not a replacement for one: it neither probes for verbs nor + * rejects a partial host. `undefined` still means "no protocol service", and + * each caller still asks its own `typeof …=== 'function'` capability question. + */ +async function resolveProtocol( + deps: DomainHandlerDeps, + context: HttpProtocolContext, +): Promise { + return await deps.resolveService(context, 'protocol'); +} + export function createPackagesDomain(deps: DomainHandlerDeps): DomainRoute { return { prefix: '/packages', @@ -401,7 +497,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin }; } let pkg: any; - const protocolSvc: any = await deps.resolveService(_context, 'protocol').catch(() => null); + const protocolSvc = await resolveProtocol(deps, _context).catch(() => null); if (protocolSvc && typeof protocolSvc.installPackage === 'function') { const out = await protocolSvc.installPackage({ manifest, settings: body.settings }); pkg = out?.package ?? out; @@ -466,11 +562,11 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin if (parts.length === 2 && parts[1] === 'publish-drafts' && m === 'POST') { const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); - const protocol = await deps.resolveService(_context, 'protocol'); - if (protocol && typeof (protocol as any).publishPackageDrafts === 'function') { + const protocol = await resolveProtocol(deps, _context); + if (protocol && typeof protocol.publishPackageDrafts === 'function') { try { const organizationId = await deps.resolveActiveOrganizationId(_context); - const result = await (protocol as any).publishPackageDrafts({ + const result = await protocol.publishPackageDrafts({ packageId: id, ...(organizationId ? { organizationId } : {}), ...(body?.actor ? { actor: body.actor } : {}), @@ -613,10 +709,10 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin const flipOrganizationId = organizationIdForMetaWrite('app', organizationId); try { if ( - typeof (protocol as any).getMetaItems === 'function' && - typeof (protocol as any).saveMetaItem === 'function' + typeof protocol.getMetaItems === 'function' && + typeof protocol.saveMetaItem === 'function' ) { - const appsRes = await (protocol as any).getMetaItems({ + const appsRes = await protocol.getMetaItems({ type: 'app', packageId: id, ...(organizationId ? { organizationId } : {}), @@ -626,7 +722,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin : Array.isArray((appsRes as any)?.items) ? (appsRes as any).items : []; for (const app of apps) { if (app && typeof app === 'object' && app._unpublished === true && typeof app.name === 'string') { - await (protocol as any).saveMetaItem({ + await protocol.saveMetaItem({ type: 'app', name: app.name, // `false`, not a delete: ADR-0045 §3 makes @@ -792,11 +888,11 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin if (parts.length === 2 && parts[1] === 'discard-drafts' && m === 'POST') { const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); - const protocol = await deps.resolveService(_context, 'protocol'); - if (protocol && typeof (protocol as any).discardPackageDrafts === 'function') { + const protocol = await resolveProtocol(deps, _context); + if (protocol && typeof protocol.discardPackageDrafts === 'function') { try { const organizationId = await deps.resolveActiveOrganizationId(_context); - const result = await (protocol as any).discardPackageDrafts({ + const result = await protocol.discardPackageDrafts({ packageId: id, ...(organizationId ? { organizationId } : {}), ...(body?.actor ? { actor: body.actor } : {}), @@ -815,11 +911,11 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin if (parts.length === 2 && parts[1] === 'commits' && m === 'GET') { const denied = requireReadCapability(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); - const protocol = await deps.resolveService(_context, 'protocol'); - if (protocol && typeof (protocol as any).listCommits === 'function') { + const protocol = await resolveProtocol(deps, _context); + if (protocol && typeof protocol.listCommits === 'function') { try { const organizationId = await deps.resolveActiveOrganizationId(_context); - const commits = await (protocol as any).listCommits({ + const commits = await protocol.listCommits({ packageId: id, ...(organizationId ? { organizationId } : {}), }); @@ -837,11 +933,11 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin if (parts.length === 4 && parts[1] === 'commits' && parts[3] === 'revert' && m === 'POST') { const denied = requireManageMetadata(deps, _context); if (denied) return denied; const commitId = decodeURIComponent(parts[2]); - const protocol = await deps.resolveService(_context, 'protocol'); - if (protocol && typeof (protocol as any).revertCommit === 'function') { + const protocol = await resolveProtocol(deps, _context); + if (protocol && typeof protocol.revertCommit === 'function') { try { const organizationId = await deps.resolveActiveOrganizationId(_context); - const result = await (protocol as any).revertCommit({ + const result = await protocol.revertCommit({ commitId, ...(organizationId ? { organizationId } : {}), ...(body?.actor ? { actor: body.actor } : {}), @@ -858,14 +954,14 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // back THROUGH every commit newer than `commitId` (ADR-0067). if (parts.length === 2 && parts[1] === 'rollback' && m === 'POST') { const denied = requireManageMetadata(deps, _context); if (denied) return denied; - const protocol = await deps.resolveService(_context, 'protocol'); - if (protocol && typeof (protocol as any).rollbackToPackageCommit === 'function') { + const protocol = await resolveProtocol(deps, _context); + if (protocol && typeof protocol.rollbackToPackageCommit === 'function') { if (!body?.commitId) { return { handled: true, response: deps.error('Body { commitId } is required', 400) }; } try { const organizationId = await deps.resolveActiveOrganizationId(_context); - const result = await (protocol as any).rollbackToPackageCommit({ + const result = await protocol.rollbackToPackageCommit({ commitId: String(body.commitId), ...(organizationId ? { organizationId } : {}), ...(body?.actor ? { actor: body.actor } : {}), @@ -908,13 +1004,13 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin if (parts.length === 2 && parts[1] === 'adopt-orphans' && m === 'POST') { const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); - const protocol = await deps.resolveService(_context, 'protocol'); - if (!protocol || typeof (protocol as any).reassignOrphanedMetadata !== 'function') { + const protocol = await resolveProtocol(deps, _context); + if (!protocol || typeof protocol.reassignOrphanedMetadata !== 'function') { return { handled: true, response: deps.error('Orphan adoption not supported', 501) }; } try { const organizationId = await deps.resolveActiveOrganizationId(_context); - const result = await (protocol as any).reassignOrphanedMetadata({ + const result = await protocol.reassignOrphanedMetadata({ targetPackageId: id, ...(organizationId ? { organizationId } : {}), ...(body?.actor ? { actor: body.actor } : {}), @@ -931,8 +1027,8 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin if (parts.length === 2 && parts[1] === 'duplicate' && m === 'POST') { const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); - const protocol = await deps.resolveService(_context, 'protocol'); - if (!protocol || typeof (protocol as any).duplicatePackage !== 'function') { + const protocol = await resolveProtocol(deps, _context); + if (!protocol || typeof protocol.duplicatePackage !== 'function') { return { handled: true, response: deps.error('Package duplication not supported', 501) }; } const targetPackageId = typeof body?.targetPackageId === 'string' ? body.targetPackageId.trim() : ''; @@ -941,7 +1037,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin } try { const organizationId = await deps.resolveActiveOrganizationId(_context); - const result = await (protocol as any).duplicatePackage({ + const result = await protocol.duplicatePackage({ sourcePackageId: id, targetPackageId, ...(typeof body?.targetName === 'string' ? { targetName: body.targetName } : {}), @@ -993,10 +1089,10 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin return { handled: true, response: deps.error('Body { name?, description?, version? } — nothing to update', 400) }; } - const protocol = await deps.resolveService(_context, 'protocol'); - if (protocol && typeof (protocol as any).updatePackage === 'function') { + const protocol = await resolveProtocol(deps, _context); + if (protocol && typeof protocol.updatePackage === 'function') { try { - const updated = await (protocol as any).updatePackage({ packageId: id, patch }); + const updated = await protocol.updatePackage({ packageId: id, patch }); return { handled: true, response: deps.success((updated as any)?.package ?? updated) }; } catch (e: any) { return { handled: true, response: deps.errorFromThrown(e, 500) }; @@ -1034,16 +1130,23 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // named: `organizationId` (the key that decides an uninstall's blast radius) // and `keepData` are exactly the two the sibling REST door's option type // could not express, and nothing compared the two doors' requests. Narrowed - // HERE to the producer's declared verb, so what this door sends is checked + // to the producer's declared verb, so what this door sends is checked // against the contract the implementation states. // + // [#13598] That narrowing used to be written INLINE right here, as this + // door's own one-off `{ deletePackage?(…) }` annotation, because it was the + // only typed seam in a file of eleven untyped ones. It is now the + // `deletePackage` member of {@link PackagesDomainProtocol} — the same + // producer-declared request type, stated once for the whole file instead of + // once at the one door that happened to need it first. The rule is + // unchanged; only its address is. + // // The `typeof … === 'function'` probe STAYS and the member stays optional: // the verb is absent from the spec's `PackageProtocol` (every member of // which is optional anyway), the slot takes whatever a host registers under // the name, and registrants carrying no `deletePackage` are real in-tree. // A capability question, asked as a capability probe — not a cast. - const protocol: { deletePackage?(request: DeletePackageRequest): Promise } | undefined = - await deps.resolveService(_context, 'protocol'); + const protocol = await resolveProtocol(deps, _context); if (protocol && typeof protocol.deletePackage === 'function') { try { const organizationId = await deps.resolveActiveOrganizationId(_context); @@ -1136,7 +1239,7 @@ packageId: string, registry: any, context: HttpProtocolContext, ): Promise | null> { - const protocol = await deps.resolveService(context, 'protocol'); + const protocol = await resolveProtocol(deps, context); if (!protocol || typeof protocol.getMetaItems !== 'function') return null; const organizationId = await deps.resolveActiveOrganizationId(context); @@ -1233,7 +1336,7 @@ _context: HttpProtocolContext, // [#4127] `protocol` keeps its `any` — no written contract, so this is where // the ledger honestly ends. `metadata` and `ql` are both evidenced now, // `objectql` as of batch 3: it is the same instance the `data` slot holds. - const protocol: any = await deps.resolveService(_context, 'protocol'); + const protocol = await resolveProtocol(deps, _context); const metadata = await deps.getService(_context, CoreServiceName.enum.metadata); const ql = await deps.resolveService(_context, 'objectql'); if (!protocol || typeof protocol.getMetaItem !== 'function' || !ql || !metadata) { From b9ee384592a2ced5de88369d44ceafd59c328a28 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 05:08:44 +0000 Subject: [PATCH 2/5] wip(runtime): add the packages-domain protocol handle typing pin Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- .../packages-protocol-handle-typing.test.ts | 193 ++++++++++++++++++ packages/runtime/src/domains/packages.ts | 2 +- 2 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 packages/runtime/src/domains/packages-protocol-handle-typing.test.ts diff --git a/packages/runtime/src/domains/packages-protocol-handle-typing.test.ts b/packages/runtime/src/domains/packages-protocol-handle-typing.test.ts new file mode 100644 index 0000000000..0cf071966a --- /dev/null +++ b/packages/runtime/src/domains/packages-protocol-handle-typing.test.ts @@ -0,0 +1,193 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #13598 — the packages domain reaches the `protocol` service through a TYPED + * handle, and the runtime capability probes survive that typing. + * + * Two halves, because the card has two halves that pull in opposite directions + * and either one alone is a regression: + * + * 1. **Compile-time** (section 1). An undeclared key in one of this domain's + * request literals must be a COMPILE ERROR. That is the #11006 series' end + * state, and it stopped one seam short here. + * 2. **Runtime** (section 2). ⛔ A host may occupy the `protocol` slot with a + * PARTIAL object. Tightening the type and then deleting a + * `typeof … === 'function'` probe would trade the compile-time improvement + * for a runtime crash, so section 2 drives a real dispatcher whose protocol + * brings none of the verbs and pins the documented 501s. + * + * ## The defect, measured on the base tree with the same instrument + * + * `deps.resolveService(context, 'protocol')` answers `any` — `protocol` is + * deliberately unmapped in `ServiceSlotContracts`. Downstream of that seam + * nothing compiled against a contract at all. Measured at `25a59bd`, injecting + * one undeclared key (`bogusUndeclaredKey: true`) into the `saveMetaItem` + * literal of the ADR-0045 visibility flip: + * + * tsc --noEmit -p packages/runtime/tsconfig.json -> exit 0, ZERO diagnostics + * + * The same injection into the same literal after this change: + * + * ... -> 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?: … }' + * + * Section 1 is that measurement made DURABLE. Each `@ts-expect-error` below is + * itself checked: if the seam ever goes back to `any` the directive stops + * matching an error and tsc reports TS2578 (unused directive) — so this file + * cannot rot into a green no-op the way an assertion-only pin could. + * + * ⚠️ These directives are NOT phantom checks: `packages/runtime`'s BUILD + * tsconfig excludes every `.test.ts` under `src`, but the sibling + * `tsconfig.test.json` + * compiles this layer and `package.json`'s `typecheck` script names it via + * `check:test-typecheck`. This file carries no entry in + * `test-typecheck-debt.json`, so any error it gains beyond the expected ones is + * red on arrival. + * + * ## Reverse verification — direction predicted BEFORE running + * + * Reverting `domains/packages.ts` to the base tree makes section 1 red as + * TS2578 x4 (every directive becomes unused, because the `any` handle accepts + * everything) — the reversal shape, not a plain "assertion failed", which is + * why the directives are the pin and not `expectTypeOf` assertions. Section 2 + * is GREEN IN BOTH DIRECTIONS by construction: the probes it exercises are + * unchanged by this card, so it is the control that says the 501s were never + * bought with a behaviour change. + */ +import { describe, expect, it } from 'vitest'; +import { HttpDispatcher } from '../http-dispatcher.js'; +import type { PackagesDomainProtocol } from './packages.js'; + +// --------------------------------------------------------------------------- +// Section 1 — compile-time pins (never executed; the checker is the assertion) +// --------------------------------------------------------------------------- + +/** + * The literals this domain actually sends, spelled exactly as the handlers + * spell them. A positive control for the four `@ts-expect-error`s below: if + * this body ever stopped compiling, those directives could be "satisfied" by a + * type that rejects everything, which pins nothing. + */ +function declaredKeysCompile(protocol: PackagesDomainProtocol) { + return [ + // ADR-0045 visibility flip — `GET` half. + protocol.getMetaItems?.({ type: 'app', packageId: 'crm', organizationId: 'org_1' }), + // ADR-0045 visibility flip — `SAVE` half. `packageId` is declared + // `nullable().optional()`, `actor` optional; both are load-bearing here. + protocol.saveMetaItem?.({ + type: 'app', + name: 'crm_console', + item: { _unpublished: false }, + packageId: 'crm', + organizationId: 'org_1', + actor: 'u_publisher', + }), + // `applyPublishedSeeds`' seed body read-back, both attempts. + protocol.getMetaItem?.({ type: 'seed', name: 'crm_seed', organizationId: 'org_1' }), + protocol.getMetaItem?.({ type: 'seed', name: 'crm_seed' }), + // The manifest-export read. + protocol.getMetaItems?.({ type: 'view', packageId: 'crm', organizationId: undefined }), + ]; +} + +/** + * ⛔ THE PIN. Each directive must match a real diagnostic; an unused one is + * TS2578 and fails `check:test-typecheck`. + */ +function undeclaredKeysAreCompileErrors(protocol: PackagesDomainProtocol) { + return [ + protocol.saveMetaItem?.({ + type: 'app', + name: 'crm_console', + item: {}, + // @ts-expect-error [#13598] `packagId` is a misspelling of the + // declared `packageId`. Through the pre-change `any` handle this + // compiled, and the write silently landed unbound to the package. + packagId: 'crm', + }), + protocol.getMetaItems?.({ + type: 'app', + // @ts-expect-error [#13598] not a member of `GetMetaItemsRequest` — + // the read has no `packageIds` plural. + packageIds: ['crm'], + }), + // A misspelt VERB, which is what the untyped handle could never catch: + // any property access on `any` is a property access on `any`. + // @ts-expect-error [#13598] `rollbackToPackageCommit` has three `m`s in + // neither of the two places this one puts them. + protocol.rollbackToPackageCommmit?.({ commitId: 'c1' }), + // ⛔ Every member is OPTIONAL and STAYS optional: a filled slot is not a + // promise that the verb is there. This directive is what would go + // unused if someone "simplified" the handle to a non-partial + // `MetadataProtocol` — which is exactly the change that deletes the + // reason the runtime probes in section 2 exist. + // @ts-expect-error [#13598] possibly `undefined` — call it behind the probe. + protocol.getMetaItems({ type: 'app' }), + ]; +} + +// --------------------------------------------------------------------------- +// Section 2 — runtime control: the capability probes SURVIVE the typing +// --------------------------------------------------------------------------- + +/** `/packages` state changes demand `manage_metadata` (#7033 / #7023). */ +const PKG_ADMIN = () => ({ + request: {}, + executionContext: { + userId: 'u_pkg_admin', + systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], + }, +}) as any; + +/** + * A host that OCCUPIES the `protocol` slot with an object carrying none of the + * verbs — the documented reason every call site probes rather than calls. Not + * an empty slot: an empty slot would take the `!protocol` arm of each guard and + * prove nothing about the `typeof … === 'function'` half. + */ +function partialProtocolDoor() { + const kernel: any = { + getService: (name: string) => { + if (name === 'protocol') return Promise.resolve({ someUnrelatedVerb: () => undefined }); + if (name === 'objectql') { + return Promise.resolve({ + registry: { getAllPackages: () => [], getPackage: () => undefined }, + }); + } + return null; + }, + context: { getService: () => null }, + }; + return new HttpDispatcher(kernel); +} + +describe('#13598 · 1 · the compile-time pins are type-level only', () => { + it('neither pin function is invoked — tsc is the assertion', () => { + expect(typeof declaredKeysCompile).toBe('function'); + expect(typeof undeclaredKeysAreCompileErrors).toBe('function'); + }); +}); + +describe('#13598 · 2 · a PARTIAL protocol host is still answered, never crashed', () => { + const cases: Array<[string, string, string, string]> = [ + ['publish-drafts', '/crm/publish-drafts', 'POST', 'Draft publishing not supported'], + ['discard-drafts', '/crm/discard-drafts', 'POST', 'Draft discarding not supported'], + ['commits', '/crm/commits', 'GET', 'Commit history not supported'], + ['commit revert', '/crm/commits/c1/revert', 'POST', 'Commit revert not supported'], + ['rollback', '/crm/rollback', 'POST', 'Commit rollback not supported'], + ['adopt-orphans', '/crm/adopt-orphans', 'POST', 'Orphan adoption not supported'], + ['duplicate', '/crm/duplicate', 'POST', 'Package duplication not supported'], + ]; + + for (const [label, path, method, message] of cases) { + it(`${label} answers 501 from the capability probe`, async () => { + const result = await partialProtocolDoor().handlePackages( + path, method, { commitId: 'c1', targetPackageId: 'crm_copy' }, {}, PKG_ADMIN(), + ); + expect(result.response?.status).toBe(501); + expect(JSON.stringify(result.response?.body)).toContain(message); + }); + } +}); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 4bd8a401d0..1c0349a734 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -118,7 +118,7 @@ import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry. * `protocol.rollbackToPackageCommmit` is now a compile error where the `any` * handle took any spelling at all. */ -type PackagesDomainProtocol = +export type PackagesDomainProtocol = Partial> & Partial> & { From 5433cb30a7855a6ba812a095f5662bbcda829707 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 05:12:04 +0000 Subject: [PATCH 3/5] chore(changeset): patch note for the packages-domain protocol handle typing Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- .../packages-domain-protocol-handle-typed.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .changeset/packages-domain-protocol-handle-typed.md diff --git a/.changeset/packages-domain-protocol-handle-typed.md b/.changeset/packages-domain-protocol-handle-typed.md new file mode 100644 index 0000000000..1ee601cf42 --- /dev/null +++ b/.changeset/packages-domain-protocol-handle-typed.md @@ -0,0 +1,26 @@ +--- +"@objectstack/runtime": patch +--- + +fix(runtime): the packages domain reaches the `protocol` service through a typed handle (#13598) + +`deps.resolveService(context, 'protocol')` answers `any` — `protocol` is +deliberately left unmapped in `ServiceSlotContracts` — so every request literal +downstream of that seam compiled against nothing. Twelve sites in +`domains/packages.ts` held that `any` (two of them on the variable declaration +rather than the call), and an undeclared or misspelt key in the ADR-0045 +publish-visibility flip's `getMetaItems` / `saveMetaItem` literals compiled +silently. Measured on the base tree: injecting `bogusUndeclaredKey: true` into +the `saveMetaItem` literal gave `tsc --noEmit` exit 0 and zero diagnostics. + +The slot is now narrowed once, at one helper, to a handle `Pick`ed from the +DECLARED contracts — `MetadataProtocol` / `PackageProtocol` from +`@objectstack/spec`, plus the producer's own exported `DeletePackageRequest` — +so the same injection is now `error TS2353`. Every member is OPTIONAL and every +`typeof protocol. === 'function'` capability probe is unchanged: a host +may occupy the slot with a partial object, and the type answers "is this key +declared?" while the probe still answers "did this host bring the verb?". + +Compile-layer signal only — no request is newly accepted or refused, no +response shape moves, and the eight verbs no contract declares keep an explicit +`any` request rather than a private restatement nothing verifies. From 1cac4df1bfe0714b57539711afe0f2ae7ef40ad2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 05:49:53 +0000 Subject: [PATCH 4/5] docs(permissions): re-anchor the system-context census rows moved by the typing block Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- content/docs/permissions/system-context.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 9dd7b595f1..ff14dc705f 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -160,10 +160,10 @@ The largest single consumer — **17 of the 106 sites**. | 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` | | 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4789`, `:6203`, `:6451`, `:6882`, `:7075` | | 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` | -| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:326`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | +| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:422`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | | 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` | | 54 | Package REST route capability gate bypassed | rest | Get: package read/write over REST without `manage_metadata` / `studio.access` / `setup.access` | `package-routes.ts:102` | -| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:145`, `:178` | +| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:241`, `:274` | | 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `activation-gate.ts:139`, `:190` | | 57 | Automation run-state read, flow-authoring write and unrelated-screen read all pass | runtime | Get: run state, flow writes and screen reads with no grant | `domains/automation.ts:254`, `:545`, `:635` | | 58 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `suggested-audience-bindings.ts:703` | From 34a1740a63cacc9cb146c78be9a251595502eced Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 06:13:39 +0000 Subject: [PATCH 5/5] docs(permissions): regenerate the system-context census from the merged tree Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- content/docs/permissions/system-context.mdx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index ff14dc705f..3a56196487 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -64,7 +64,7 @@ not on any flag. ## How the flag is set `isSystem` is **server-constructed and never client-supplied**. Inbound HTTP -cannot set it (`packages/rest/src/rest-server.ts:1520`, `:1549`), and neither +cannot set it (`packages/rest/src/rest-server.ts:1524`, `:1553`), and neither can an action body (`packages/runtime/src/domains/actions.ts:404`). It is written by internal callers only, as an option on the engine call: @@ -103,7 +103,7 @@ that silently does not happen. | 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` | | 15 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `read-audit.ts:556` | | 16 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel. Lose: field-visibility redaction over approval payloads | `payload-redaction-middleware.ts:115` | -| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1552` | +| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1556` | ### 2. Write pipeline and data integrity @@ -158,13 +158,13 @@ The largest single consumer — **17 of the 106 sites**. |:--|:---|:---|:---|:---| | 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:138` | | 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` | -| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4789`, `:6203`, `:6451`, `:6882`, `:7075` | +| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4888`, `:6302`, `:6550`, `:6981`, `:7174` | | 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` | | 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:422`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | | 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` | | 54 | Package REST route capability gate bypassed | rest | Get: package read/write over REST without `manage_metadata` / `studio.access` / `setup.access` | `package-routes.ts:102` | | 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:241`, `:274` | -| 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `activation-gate.ts:139`, `:190` | +| 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `activation-gate.ts:138`, `:189` | | 57 | Automation run-state read, flow-authoring write and unrelated-screen read all pass | runtime | Get: run state, flow writes and screen reads with no grant | `domains/automation.ts:254`, `:545`, `:635` | | 58 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `suggested-audience-bindings.ts:703` | | 59 | Email-template / webhook provenance stamps skipped | plugin-email, plugin-webhooks | Lose: the row is not marked as an admin customization | `email-template-provenance.ts:59`, `webhook-provenance.ts:50` | @@ -199,7 +199,7 @@ assuming `isSystem` covers it is a documented source of bugs. | "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1580` (#3493 / #6640) | | "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` | | "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` | -| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1520`, `:1549`; `domains/actions.ts:404` | +| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1524`, `:1553`; `domains/actions.ts:404` | --- @@ -253,7 +253,7 @@ should recognise it instead of re-deriving it. rule-materialised grant that the next reconcile silently restores. 5. **`applySystemFields` does not read this flag.** It is named as if it did. - `packages/objectql/src/registry.ts:464` is **schema-side column + `packages/objectql/src/registry.ts:475` is **schema-side column provisioning** — which columns an object carries — and consumes `ExecutionContext.isSystem` zero times. The write-time ownership behaviour people attribute to it is row 2, in `plugin-security`.