feat(core): narrow Plugin.type to the closed PluginType set the spec declares (#13925) - #14608
Conversation
…declares `Plugin.type` was `string` on a published surface while the authority — `PluginSchema.type` in @objectstack/spec, `z.enum(['standard', ...CORE_PLUGIN_TYPES])` — was a closed set enforced only at parse; the TSDoc beside it carried the enumeration as prose, and prose drifted. Maintainer ruling 2026-09-01: core aligns to the declared contract. - `PluginType = 'standard' | (typeof CORE_PLUGIN_TYPES)[number]`, derived from the spec's constant over the published `@objectstack/spec/kernel` subpath (already a dependency; zero new edges), exported from core. - `Plugin.type?: PluginType`; the docblock points at the type instead of re-listing members. - Runtime parity pin in core: the Zod enum's options equal the union's members in declared order; members parse, non-members are refused with `invalid_value` at `type`. - Compile-time pin in packages/rest (its test-typecheck program reads core's built .d.ts; core is a type-check DEBT package, so a `@ts-expect-error` there would be a phantom): non-member literal, `string`-typed value and PluginMetadata each refused; every member and type-level equality with the spec-derived shape as positive controls. - Changeset: @objectstack/core minor with the BREAKING banner and the ADR-0087 no-migration-prescription disposition. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…l so they satisfy PluginType The workspace census under the narrowing (tsc per package, TS2416 "Property 'type' in type 'X' is not assignable to the same property in base type 'Plugin'") names seventeen `implements Plugin` classes whose `type = '<member>'` initializer widened to `string`. Each becomes `type = '<member>' as const` — the spelling three sibling services already use — and the downstream TS2345s at their `kernel.use()` sites clear with it. The docs copy of the interface in content/docs/plugins/anatomy.mdx follows the type. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
… by the build and test-typecheck programs Second census pass (turbo build --continue=always over ./packages/*, then tsc per package on the src and tsconfig.test.json programs): twenty-three more `implements Plugin` classes — plugins, triggers, services, the http-conformance node adapter, and four test fixtures compiled by their package's `check:test-typecheck` ratchet — each TS2416 at its `type = '<member>'` initializer. Same correction: `as const`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
… member they are objectql's test-typecheck ratchet (EXACT, per-file) named src/plugin.integration.test.ts: TS2322 "Type '"metadata"' is not assignable to type 'PluginType | undefined'" (x2) and the same for '"test"'. The three kernel.use() mocks register a `metadata` service and nothing reads their `type`; neither spelling was ever a plugin type (the Zod gate refuses both), so the member they are is `standard`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…ugin-type-closed-set
📓 Docs Drift CheckThis PR changes 28 package(s): 27 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 71 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 fb3d05ee3c91ada8f9d80e1194c078005f02c333 && git checkout fb3d05ee3c91ada8f9d80e1194c078005f02c333
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d15ecd85505c1d1d4af8eb53f5210a520d7d29d6 96291402924c629f675e720b078b2af2c005f445 && git checkout -B drift-repro d15ecd85505c1d1d4af8eb53f5210a520d7d29d6 && git merge --no-ff 96291402924c629f675e720b078b2af2c005f445
node scripts/docs-audit/affected-docs.mjs --json d15ecd85505c1d1d4af8eb53f5210a520d7d29d6
|
|
Landing provenance (engine execution seat, session
Generated by Claude Code |
Fixes #13925
Plugin.typeon the published@objectstack/coresurface wasstringwhile the authority —PluginSchema.typein@objectstack/spec, declaredz.enum(['standard', ...CORE_PLUGIN_TYPES])— was a closed set enforced only at parse; the TSDoc beside it carried the enumeration as prose, and prose drifted (#13762). Maintainer ruling 2026-09-01 (director batch #25, comment 5494598699, correction 5494610258): core aligns to the declared contract, the in-repo census is corrected in the same PR, no warning window, Clause-② YES.What changed
packages/core/src/types.ts— new exportedPluginType = 'standard' | (typeof CORE_PLUGIN_TYPES)[number], derived from the spec's constant over the published@objectstack/spec/kernelsubpath (already aworkspace:*dependency of core; zero new dependency edges).Plugin.type?: PluginType; the docblock points at the type instead of re-listing members.packages/core/src/plugin-type-closed-set.test.ts(new) — RUNTIME parity pin: the Zod enum's options, read by walkingPluginSchema.shape.type's wrapper chain (optional, default, enum), equal the union's members in declared order; every member parses; five non-members (bogus,ui-plugin,plugin,module,Standard) are refused withinvalid_valueat pathtype.packages/rest/src/plugin-type-closed-set.pin.test.ts(new) — COMPILE-TIME pin on the PUBLISHED.d.ts: a non-member literal, astring-typed value and aPluginMetadataliteral each carry a@ts-expect-error; positive controls: all eight members compile with no directive, and the published union is type-level equal to the spec-derived shape in both directions. Placement rationale under Deviations..changeset/core-plugin-type-closed-set.md—@objectstack/core: minorwith the**BREAKING**banner (launch-window convention: a breaking change ships asminor; the banner and the ADR-0087 disposition are the carriers) andadr-0087: not-required (no-migration-prescription)— a TypeScript narrowing of a runtime interface; no metadata key, spec symbol or stored representation moves, soobjectstack migrate metahas nothing to rewrite; the compiler at the assignment is the channel (PR feat(spec): refuse a null comparand in the ordering positions — $gt / $gte / $lt / $lte (#14080) #14425 / feat(spec): declare the not-found arm on IDataDriver.update() and un-mask driver-memory's published update/upsert types #14434 precedent).as constinitializers, one test literal), listed below.content/docs/plugins/anatomy.mdx— the copiedPlugininterface'stype?: string;line and docblock follow the type (bounded in-place fix; see Deviations).Measurements (PM assumptions, Zone 2)
packages/spec/package.jsonexports["./kernel"]maps import/require (and browser) todist/kernel/index.{mjs,js}with.d.mts/.d.tstypes. Built at BASE9c7d9d4b3, all four carryCORE_PLUGIN_TYPES(.d.mts1,.d.ts1,.mjs4,.js4 occurrences); frompackages/core,import { CORE_PLUGIN_TYPES } from '@objectstack/spec/kernel'resolves in ESM and CJS to the seven-member array. The root@objectstack/specdoes NOT export it (ESM import fails: "does not provide an export named 'CORE_PLUGIN_TYPES'"), so the subpath is the only value path; core already spells@objectstack/spec/kernelat 15 import sites. Both animport type(types.ts, for the derived union) and a value import (the parity test) are used.PluginSchema.safeParse({ type: 'bogus' })and{ type: 'ui-plugin' }are REFUSED withinvalid_valueat path["type"]("expected one of standard|ui|driver|server|app|theme|agent|objectql");standardandobjectqlare accepted; an absenttypeis accepted (the.default('standard').optional()wrapper).ManifestSchema.type(manifest.zod.ts:245, spreads the same constant) refusesbogusANDstandard— its set isplugin+ CORE +module/gateway/adapter, a different (package-manifest) vocabulary that does not overlap the plugin one at the edges. No.catch,.or(z.string()),.passthrough()or refinement exists on the key (wrapper chain measured: optional, default, enum). Re-measured identically on the rebuilt dist after theorigin/mainmerge.turbo run build --continue=alwaysover./packages/*,./packages/*/*,./examples/*^...: 71/71 green at the final tree) and the workspace typecheck CI runs (turbo run typecheckover./packages/*,./packages/*/*,./apps/*, pluspnpm --filter './examples/*' run typecheck). Every hit is ONE shape: animplements Pluginclass whosetype = 'MEMBER'property initializer widens tostring(TS2416 "Property 'type' in type 'X' is not assignable to the same property in base type 'Plugin'"), with TS2345 at that class'skernel.use()call sites as a consequence — plus three object-literal mocks in one objectql test (row 41). Zero hits on computed strings,as stringcasts or third-party manifests; zero production hits whose value is a non-member. The correction in every class case isas conston the initializer — the spelling three sibling services (service-datasource,service-messaging,service-automation) already used — so no annotation is added and no other line in any of those files changes.962914029:check-adr-0087-registration— "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition" (exit 0);check-changeset-no-major— "This diff introduces nomajorbump" (exit 0);check-empty-changeset— "No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added)" (exit 0). The ADR-0087 body carries no arrow rewrite, FROM/TO label or rewrite table, so theno-migration-prescriptioncategory is claimed on a body the detector reads as prescription-free.check:api-surface— not moved. It ispackages/spec's script (tsx scripts/build-api-surface.ts --check) and ledgers spec's exports;packages/specis untouched and core's exports are not ledgered by it. The published-surface pin for core lives in the rest test program instead.Census (file · compile error that named it · correction)
Instrument column: dts = tsup's dts emit during the workspace build (
turbo run build --continue=always); tsc =tsc --noEmit --pretty falseon the package'stsconfig.json; test-tsc = the package'stsconfig.test.jsonprogram (check:test-typecheck, EXACT per-file ratchet). Correction column:as const= thetype = 'MEMBER'initializer becomestype = 'MEMBER' as const; nothing else in the file changes. The trailing TS2345s ("Argument of type 'X' is not assignable to parameter of type 'Plugin'") at the same class'skernel.use()sites clear with the same edit and are listed for completeness, not edited.packages/services/service-i18n/src/i18n-service-plugin.ts(75,3) TS2416: Property 'type' in type 'I18nServicePlugin' is not assignable to the same property in base type 'Plugin'as constpackages/metadata/src/plugin.ts(265,5) TS2416 … 'MetadataPlugin'; consequencesrc/plugin-shutdown-releases-repository.test.ts(61,16) TS2345as constpackages/objectql/src/plugin.ts(206,3) TS2416 … 'ObjectQLPlugin'; consequencesrc/kernel-factory.ts(38,20) TS2345as constpackages/runtime/src/app-plugin.ts(64,5) TS2416 … 'AppPlugin'as constpackages/runtime/src/driver-plugin.ts(21,5) TS2416 … 'DriverPlugin'as constpackages/runtime/src/external-validation-plugin.ts(207,3) TS2416 … 'ExternalValidationPlugin'as constpackages/runtime/src/observability/observability-service-plugin.ts(74,5) TS2416 … 'ObservabilityServicePlugin'as constpackages/runtime/src/app-plugin-shutdown-emits-unregistered.test.tstypecheckexcludes tests); pre-scan, identical shape at line 56as const(declared under Deviations 3)packages/runtime/src/external-validation-shutdown-clears-timers.test.tsas const(Deviations 3)packages/services/service-cluster/src/authz-cluster-bridge-plugin.ts(53,5) TS2416 … 'AuthzClusterBridgePlugin'as constpackages/services/service-cluster/src/cluster-service-plugin.ts(50,5) TS2416 … 'ClusterServicePlugin'as constpackages/services/service-cluster/src/metadata-cluster-bridge-plugin.ts(63,5) TS2416 … 'MetadataClusterBridgePlugin'as constpackages/services/service-job/src/job-service-plugin.ts(72,3) TS2416 … 'JobServicePlugin'as constpackages/services/service-cache/src/cache-service-plugin.ts(70,3) TS2416 … 'CacheServicePlugin'as constpackages/services/service-knowledge/src/knowledge-service-plugin.ts(62,3) TS2416 … 'KnowledgeServicePlugin'; consequencesrc/__tests__/plugin-shutdown-unsubscribes.test.ts(65,14) TS2345as constpackages/services/service-knowledge/src/__tests__/plugin-shutdown-unsubscribes.test.ts(45,3) TS2416 … 'FakeRealtimePlugin'; consequence(63,14) TS2345as constpackages/services/service-queue/src/queue-service-plugin.ts(52,3) TS2416 … 'QueueServicePlugin'as constpackages/triggers/trigger-api/src/plugin.ts(50,5) TS2416 … 'ApiTriggerPlugin'as constpackages/plugins/plugin-hono-server/src/hono-plugin.ts(229,5) TS2416 … 'HonoServerPlugin'as constpackages/services/service-realtime/src/realtime-service-plugin.ts(54,3) TS2416 … 'RealtimeServicePlugin'as constpackages/plugins/plugin-pinyin-search/src/pinyin-search-plugin.ts(44,3) TS2416 … 'PinyinSearchPlugin'as constpackages/services/service-storage/src/storage-service-plugin.ts(168,3) TS2416 … 'StorageServicePlugin'as constpackages/plugins/plugin-audit/src/audit-plugin.ts(66,3) TS2416 … 'AuditPlugin'as constpackages/plugins/plugin-reports/src/reports-plugin.ts(48,3) TS2416 … 'ReportsServicePlugin'as constpackages/plugins/plugin-sharing/src/sharing-plugin.ts(318,3) TS2416 … 'SharingServicePlugin'as constpackages/plugins/plugin-security/src/security-plugin.ts(756,3) TS2416 … 'SecurityPlugin'as constpackages/plugins/plugin-email/src/email-plugin.ts(267,3) TS2416 … 'EmailServicePlugin'as constpackages/triggers/trigger-record-change/src/plugin.ts(34,5) TS2416 … 'RecordChangeTriggerPlugin'as constpackages/triggers/trigger-schedule/src/time-relative-plugin.ts(37,5) TS2416 … 'TimeRelativeTriggerPlugin'as constpackages/plugins/plugin-auth/src/auth-plugin.ts(255,3) TS2416 … 'AuthPlugin'as constpackages/plugins/plugin-approvals/src/approvals-plugin.ts(95,3) TS2416 … 'ApprovalsServicePlugin'as constpackages/plugins/knowledge-memory/src/index.ts(210,3) TS2416 … 'KnowledgeMemoryPlugin'as constpackages/plugins/knowledge-ragflow/src/index.ts(280,3) TS2416 … 'KnowledgeRagflowPlugin'as constpackages/plugins/plugin-dev/src/dev-plugin.ts(396,3) TS2416 … 'DevPlugin'as constpackages/qa/http-conformance/src/node-plugin.ts(27,5) TS2416 … 'NodeServerPlugin'as constpackages/triggers/trigger-schedule/src/plugin.ts(38,5) TS2416 … 'ScheduleTriggerPlugin'as constpackages/plugins/plugin-approvals/src/plugin-shutdown-cancels-escalation-job.test.ts(64,3) TS2416 … 'FakeJobServicePlugin'; consequence(81,20) TS2345as constpackages/plugins/plugin-email/src/plugin-shutdown-detaches-template-bridge.test.ts(134,5) TS2416 … 'FixturePlugin'; consequence(154,16) TS2345as constpackages/plugins/plugin-reports/src/plugin-shutdown-releases-dispatcher.test.ts(101,3) TS2416 … 'FakeJobServicePlugin'; consequence(215,48),(230,48),(244,48) TS2345as constpackages/plugins/plugin-webhooks/src/plugin-shutdown-stops-auto-enqueuer.test.ts(88,5) TS2416 … 'FixturePlugin'; consequence(108,16) TS2345as constpackages/objectql/src/plugin.integration.test.ts@objectstack/objectql#typecheck):TS2322: Type '"metadata"' is not assignable to type 'PluginType | undefined'x2 (lines 323, 371) andType '"test"' …x1 (line 137)kernel.use()mocks register ametadataservice and nothing reads theirtype; neither spelling was ever a plugin type (the Zod gate refuses both), so the literal becomes the member they are:'standard'. NOT a rejection fixture, so no cast.Non-hits worth stating:
packages/runtime/src/migration-recovery-plugin.ts:60(readonly type = 'standard'— readonly keeps the literal type) and the three services already spellingas constcompile unchanged. The threeimplements Pluginfilesorigin/maintouched between BASE and the merge (plugin-email,runtime/app-plugin,service-analytics/plugin.ts) carry no widened initializer on the merged tree (re-scan at962914029: zero matches).Clause-② self-reading
Yes. The diff narrows the accept set of a published type:
Plugin.type(andPluginMetadata.typethroughextends) goes fromstringto the eight-memberPluginType. Any consumer assigning a computed orstring-typed value, or a literal outside the set, stops compiling. Runtime accept/reject behaviour is unchanged (the Zod gate refused such values already).needs:contract-reviewis hung on this PR at creation; the PR stays draft for the in-seat contract review.Verification
Head for every reading below:
962914029(theorigin/mainmerge atf60ab90ae, taken after the last content commit6a98b2118); every heavy run went throughscripts/pm/os-verify-lock.shand its VERDICT line is quoted, never a bare exit code; exit codes were captured after redirects. Wall-clock figures are shared-box seconds.pnpm install --frozen-lockfile;pnpm --filter @objectstack/spec buildthencheck:generated("All 15 generated artifacts are up to date", exit 0);turbo run build --filter=./packages/* --filter=./packages/*/* --filter=./examples/*^... --continue=always --concurrency=2— "Tasks: 71 successful, 71 total", VERDICT command-exit 0, zeroerror TSlines.turbo run typecheckover the filters CI uses — a superset of the prefix filter...@objectstack/core, i.e. every downstream consumer of core):./packages/*— "Tasks: 73 successful, 73 total", VERDICT command-exit 0;./packages/*/*— "Tasks: 108 successful, 108 total", VERDICT command-exit 0;./apps/*— "Tasks: 2 successful, 2 total" (TURBO_APPS_EXIT=0);pnpm --filter ./examples/* run typecheck— EXAMPLES_EXIT=0. Zeroerror TSlines in any slice. The same three slices were green on the pre-merge tree at6a98b2118after the corrections (the objectql ratchet red atd1422d081is what named row 41).pnpm --filter @objectstack/core exec vitest run --maxWorkers=2— "Test Files 48 passed (48) · Tests 1159 passed (1159)", VERDICT command-exit 0 (the new parity file: 3/3).pnpm --filter @objectstack/rest typecheck(inside the./packages/*slice) — green; itscheck:test-typecheckratchet reports the pin file at zero errors (rest's ledger is at zero, so an error arriving in any test file there is red).pnpm lint(whole repo,eslint . --no-inline-config) — VERDICT command-exit 0, held 125s; no narrowing needed.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(no path list; the tool's stderr namesobjectstack-ai/objectstackat962914029and flags the tree as 7 commits behind a movingorigin/main— the shared-ref effect, not a stale checkout): 68 commands, all run — 66 exit 0 with each verdict line kept in a per-gate log,pnpm check:type-check-debtVERDICT command-exit 0 through the lock ("68/78 workspace packages type-checked, 10 in the DEBT ledger, 305 frozen raw errors" — no entry moved), and two barenode scripts/...invocations returned exit 3 (NOT MEASURED in the gates' own words):node scripts/check-test-completeness.mjs("PREREQUISITE NOT MET — this gate grades a savedturbo run testlog, and no log was named"; only CI produces that log, so it is CI's reading), andnode scripts/pm/check-half-states.mjs("repo-scoped reads are refused — this container cannot make one repo-scoped request";GET /repos/objectstack-ai/objectstackanswers 403 from this container, the known channel shape — its self-test formpnpm check:pm-half-statesis Add unified query DSL with MongoDB-style operators #52 in the union and passed). Neither is read as green or red.pnpm check:nul-bytes("OK, scanned 7964 text files, no raw ASCII control bytes"),pnpm check:error-status-conformance("every derivable runtime status is documented, and every documented status is reachable"),pnpm check:type-check-coverageexit 0 (the rest pin sits inside a program atypecheckscript runs; no phantom directive). Every family in the PM's seed is inside the 68; the derivation added the docs and spec-docs families theanatomy.mdxedit pulls in.Ablation (reverse verification)
Script:
ablation.sh(trap-restored, absolute paths, run through the verify lock; VERDICT command-exit 0, held 82s). Precondition proven: committed tree at962914029,git diff HEADempty, HEAD blob ofpackages/core/src/types.ts=7687b550a….Predicted direction, stated before the run: reverting only the narrowing line turns the three
@ts-expect-errordirectives inpackages/rest/src/plugin-type-closed-set.pin.test.tsinto TS2578 "Unused '@ts-expect-error' directive" — exactly 3, all TS2578, all in that file; the positive controls stay green; the core RUNTIME parity test stays green under the mutation (it reads the Zod enum, not the TS field) and is not this leg's subject.type?: PluginType;totype?: string;— on disk by anchored counts (PluginType-line 1 to 0, string-line 0 to 1) and by blob (f9454880b…, differs from HEAD). Core rebuilt (exit 0);node scripts/ablation-dist-preflight.mjs @objectstack/core 'type?: PluginType;' --absent— "marker absent from all 12 built files". resttsc --noEmit -p tsconfig.test.json: exit 2, 3 errors, all in the pin file, all TS2578 at lines 42, 58 and 70 — the observed direction equals the predicted one.git checkout HEAD -- REPO_ROOT/packages/core/src/types.ts(absolute path, HEAD named); proven by blob equality with HEAD (7687b550a…both), bygit diff HEADempty and by whole-treegit status --porcelainempty; core rebuilt (exit 0); preflight present — "marker present in 2 built files (index.d.ts, index.d.cts) … working tree clean against HEAD"; rest tsc: exit 0, 0 errors.Deviations from the claim, declared
packages/rest, notpackages/core. The claim asked for@ts-expect-errorcases "in the package's test typecheck program"; measured,@objectstack/corehas notypecheckscript at all (scripts: build, test, test:watch) — it is a type-check DEBT ledger entry (98 errors) andcheck:type-check-coveragerefuses a@ts-expect-errorin a package notypecheckscript compiles (line 2036). The precedentpackages/rest/src/plugin-metadata-retired-fields.pin.test.tsis the sanctioned placement: rest'stsconfig.test.jsonprogram is run by itstypecheckscript (EXACT per-file ratchet at zero) and resolves@objectstack/coreto the BUILTdist/index.d.ts, so the pin guards the published contract. Amendment posted on the card (comment 5507815954).content/docs/plugins/anatomy.mdx— one code-block line plus its docblock, the copiedPlugininterface. Bounded in-place fix under the four conditions (same defect class as the card — the exact block [finding]Plugin.type's TSDoc in packages/core/src/types.ts under-enumerates the value set — omitsobjectql, which ObjectQLPlugin actually declares #13762's PR docs(core): list objectql in Plugin.type TSDoc enumeration #13923 last edited; mechanical with the form pinned by the ruling; no other claim on the file on this lane; docs-audit gates already in the dispatched family). Declared in the same card comment.packages/runtime/src/app-plugin-shutdown-emits-unregistered.test.ts:56andpackages/runtime/src/external-validation-shutdown-clears-timers.test.ts:58.@objectstack/runtime'stypecheckistsc --noEmiton a config that excludes tests and the package is a TEST_DEBT ledger entry (206), so no program the census runs reads them; the pre-scan (implements Pluginclasses with a widenedtypeinitializer) found them in the identical shape, andas constis a no-op at runtime. Declared here rather than silently left to red the moment runtime's test layer is onboarded.Out of scope, filed
Plugin.typeasstringwith the set in a comment — the published field is now the closedPluginType#14589 —skills/objectstack-platform/SKILL.md:798andskills/objectstack-platform/rules/plugin-lifecycle.md:48still spelltype?: string; // standard|ui|...in the published skill'sPlugininterface copy.skills/**is a governed surface (Prime Directive feat: Comprehensive CRM example demonstrating all ObjectStack protocol features #14) and outside this claim; not touched here.Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Generated by Claude Code