Skip to content

Commit 9fd9ae7

Browse files
os-zhuangclaude
andauthored
feat(core): enforce the init-service declaration contract with a CI gate (#4471) (#4631)
ADR-0116's ordering contract (dependencies / optionalDependencies / requiresServices / providesServices) was complete but voluntary: a plugin resolving getService('X') during init() while declaring nothing was invisible to every check, failing only under unlucky composition orders — the #4085 and #4420 failure class, the latter at data-consistency cost. - scripts/check-init-service-contract.mjs: AST scan of every plugin unit (classes and object literals) under packages/. Walks the init() call graph transitively (same-class methods, same-file functions — the #4420 call sat in a private helper), skips deferred callbacks (hooks) and start(), and errors on any init-reachable getService of a workspace-provided service not covered by a declaration. 12-case --self-test pins the #4420 pre-fix shape caught, all declared shapes passing, and the start()/hook shapes exempt. - Wired as check:init-service-contract in root package.json and lint.yml. - Declares the 11 previously undeclared init-time consumers the gate found (metadata, rest, two cli serve plugins, analytics, datasource-admin, job, knowledge, queue, settings, storage) via optionalDependencies on their providers — declared tolerance in the plugin, never a checker-side ledger. Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5 Co-authored-by: Claude <noreply@anthropic.com>
1 parent c13350b commit 9fd9ae7

15 files changed

Lines changed: 744 additions & 1 deletion

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/metadata': patch
3+
'@objectstack/rest': patch
4+
'@objectstack/cli': patch
5+
'@objectstack/service-analytics': patch
6+
'@objectstack/service-datasource': patch
7+
'@objectstack/service-job': patch
8+
'@objectstack/service-knowledge': patch
9+
'@objectstack/service-queue': patch
10+
'@objectstack/service-settings': patch
11+
'@objectstack/service-storage': patch
12+
---
13+
14+
Init-time service consumption is now declared everywhere, and the declaration is enforced (#4471, ADR-0116). A new CI gate (`check:init-service-contract`) walks every plugin's `init()` call graph — including private helpers, the shape that shipped #4420 — and errors on any init-reachable `getService('X')` of a workspace-provided service that is not covered by `dependencies`, `optionalDependencies`, or `requiresServices`. Eleven previously undeclared init-time consumers (metadata, rest, cli serve plugins, and seven services) now declare `optionalDependencies` on their providers, so the kernel orders them deterministically instead of by registration luck; each still degrades on purpose when the provider is not composed. Plugin authors: a best-effort init-time `getService` must declare its provider in `optionalDependencies` (declared tolerance) — the checker never exempts it.

.github/workflows/lint.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,21 @@ jobs:
173173
- name: Wildcard fall-through guard
174174
run: pnpm check:wildcard-fallthrough
175175

176+
# Init-service declaration guard (#4471, ADR-0116). The kernel's ordering
177+
# contract (dependencies / optionalDependencies / requiresServices /
178+
# providesServices) was complete but VOLUNTARY: a plugin that resolves
179+
# getService('X') during init() and declares nothing fails only under
180+
# unlucky composition orders, usually inside a best-effort try/catch that
181+
# downgrades the miss to a warn. That silence shipped #4085 and #4420 (the
182+
# latter losing every in-flight approval on restart). This scan walks each
183+
# plugin's init() call graph from the AST — the #4420 call sat in a private
184+
# helper, not init()'s own body — and errors on any init-reachable
185+
# getService of a workspace-provided service that no declaration covers.
186+
# Declared tolerance stays in the plugin (optionalDependencies), never in a
187+
# checker-side ledger. Runs its own --self-test first.
188+
- name: Init-service declaration guard
189+
run: pnpm check:init-service-contract
190+
176191
# Release-notes drift guard: the platform is one version-locked train, so
177192
# every released @objectstack/spec major must have a curated, navigable
178193
# release page at content/docs/releases/v<major>.mdx. Catches the gap that

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"check:route-envelope": "node scripts/check-route-envelope.mjs --self-test && node scripts/check-route-envelope.mjs",
4242
"check:error-code-casing": "node scripts/check-error-code-casing.mjs --self-test && node scripts/check-error-code-casing.mjs",
4343
"check:wildcard-fallthrough": "node scripts/check-wildcard-fallthrough.mjs --self-test && node scripts/check-wildcard-fallthrough.mjs",
44+
"check:init-service-contract": "node scripts/check-init-service-contract.mjs --self-test && node scripts/check-init-service-contract.mjs",
4445
"check:console-sha": "node scripts/check-console-sha.mjs",
4546
"check:release-notes": "node scripts/check-release-notes.mjs",
4647
"check:node-version": "node scripts/check-node-version.mjs",

packages/cli/src/commands/serve.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1270,6 +1270,11 @@ export default class Serve extends Command {
12701270
const guardPlugin: any = {
12711271
name: 'com.objectstack.cli.unknown-hostname-guard',
12721272
version: '1.0.0',
1273+
// init() resolves the `http.server` service the hono server plugin
1274+
// provides — order-if-present so the middleware install is
1275+
// deterministic (ADR-0116, #4471). Soft: without a server plugin the
1276+
// guard degrades on purpose (warn + not installed).
1277+
optionalDependencies: ['com.objectstack.server.hono'],
12731278
init: async (ctx: any) => {
12741279
try {
12751280
const httpServer: any = ctx.getService?.('http.server') ?? ctx.getService?.('http-server');
@@ -2375,6 +2380,11 @@ export default class Serve extends Command {
23752380
const adminRoutePlugin: any = {
23762381
name: 'com.objectstack.cli.datasource-admin-routes',
23772382
version: '1.0.0',
2383+
// init() resolves the `http.server` service the hono server plugin
2384+
// provides — order-if-present so route registration is
2385+
// deterministic (ADR-0116, #4471). Soft: without a server plugin
2386+
// the routes degrade on purpose (warn + not installed).
2387+
optionalDependencies: ['com.objectstack.server.hono'],
23782388
init: async (ctx: any) => {
23792389
try {
23802390
const httpServer: any =

packages/core/src/plugin-order.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@
2727
* Declare only unconditional registrations: a conditional service (e.g.
2828
* one gated behind an option) would indict this plugin for orderings it
2929
* cannot actually satisfy.
30+
*
31+
* Declaring is NOT voluntary (#4471). Everything above can only enforce what
32+
* a plugin declares — a plugin that resolves `getService('X')` during init()
33+
* and declares nothing was invisible to all of it, failing only under
34+
* unlucky composition orders (#4085, and #4420 at data-consistency cost).
35+
* `scripts/check-init-service-contract.mjs` (CI: `check:init-service-contract`)
36+
* closes that gap: it walks every plugin's init() call graph and errors on
37+
* any init-reachable getService of a workspace-provided service that no
38+
* declaration covers. Best-effort tolerance is declared IN the plugin via
39+
* `optionalDependencies`, never exempted in the checker.
3040
*/
3141

3242
/**

packages/metadata/src/plugin.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,14 @@ export class MetadataPlugin implements Plugin {
206206
* initializes.
207207
*/
208208
providesServices = ['metadata'];
209+
/**
210+
* init() registers the metadata system objects through the `manifest`
211+
* service ObjectQLPlugin provides — order-if-present so that
212+
* registration is deterministic instead of "whichever init ran first"
213+
* (ADR-0116, #4471). Soft, not hard: without an engine the plugin
214+
* degrades on purpose (objects are discovered via the legacy fallback).
215+
*/
216+
optionalDependencies = ['com.objectstack.engine.objectql'];
209217

210218
private manager: NodeMetadataManager;
211219
private options: MetadataPluginOptions;

packages/rest/src/rest-api-plugin.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,14 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin {
3232
return {
3333
name: 'com.objectstack.rest.api',
3434
version: '1.0.0',
35-
35+
/**
36+
* init() registers sys_import_job through the `manifest` service
37+
* ObjectQLPlugin provides — order-if-present so the registration is
38+
* deterministic (ADR-0116, #4471). Soft, not hard: on an engine-less
39+
* kernel the plugin degrades on purpose (warn + no import-job object).
40+
*/
41+
optionalDependencies: ['com.objectstack.engine.objectql'],
42+
3643
init: async (ctx: PluginContext) => {
3744
// Register the async-import job object so its state/progress/history
3845
// is queryable in Studio and readable by the import-job routes.

packages/services/service-analytics/src/plugin.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,14 @@ export class AnalyticsServicePlugin implements Plugin {
161161
version = '1.0.0';
162162
type = 'standard' as const;
163163
dependencies: string[] = [];
164+
/**
165+
* init() probes the `data` engine ObjectQLPlugin provides for the
166+
* auto-bridge — order-if-present so the probe verdict is deterministic
167+
* (ADR-0116, #4471). Soft, not hard: without an engine the plugin
168+
* degrades on purpose (per-query lazy resolution / explicit
169+
* `executeAggregate`).
170+
*/
171+
optionalDependencies: string[] = ['com.objectstack.engine.objectql'];
164172

165173
private service?: AnalyticsService;
166174
private readonly options: AnalyticsServicePluginOptions;

packages/services/service-datasource/src/datasource-admin-plugin.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,13 @@ export class DatasourceAdminServicePlugin implements Plugin {
175175
version = '1.0.0';
176176
type = 'standard' as const;
177177
dependencies: string[] = [];
178+
/**
179+
* init() contributes the Setup-app nav entry through the `manifest`
180+
* service ObjectQLPlugin provides — order-if-present so the contribution
181+
* is deterministic (ADR-0116, #4471). Soft, not hard: without an engine
182+
* the plugin degrades on purpose (no nav entry, admin service still up).
183+
*/
184+
optionalDependencies: string[] = ['com.objectstack.engine.objectql'];
178185

179186
private service?: DatasourceAdminService;
180187
private config?: DatasourceAdminServiceConfig;

packages/services/service-job/src/job-service-plugin.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ export class JobServicePlugin implements Plugin {
4848
* kernel name this plugin when a consumer requires one before it inits.
4949
*/
5050
providesServices = ['job'];
51+
/**
52+
* init() registers sys_job/sys_job_run through the `manifest` service
53+
* ObjectQLPlugin provides, and probes the `cluster` service for the cron
54+
* adapter's leader election — order-if-present so both resolutions are
55+
* deterministic (ADR-0116, #4471). Soft, not hard: without either the
56+
* plugin degrades on purpose (in-memory adapter, single-node cron).
57+
*/
58+
optionalDependencies = ['com.objectstack.engine.objectql', 'com.objectstack.service.cluster'];
5159
version = '1.1.0';
5260
type = 'standard';
5361

0 commit comments

Comments
 (0)