Repository navigation
The composed-host-stack plan sees 8 of ~80 control-plane tables: objectstack#12952's start()-registration residue is the dominant case, not a zero-instance one #13028
Description
Follow-up to objectstack#12938 / PR objectstack#12952, found while running the
consumer acceptance that PR named (objectstack-ai/cloud#1710).
objectstack#12952 states its residue plainly:
a host plugin that registers its objects in
start()rather thaninit()is
invisible to this composition — its tables stay out of the plan.
and its ACCEPT adjudicated Option A partly on the ground that the residue had
"zero measured instances". Measured against the deployment the fix was written
for, the residue is not the exception — it is almost the whole set.
Measurement
ObjectStack Cloud's migrate-control-db.yml, plane=staging, apply=false, against
the real Neon staging control plane, framework pinned at 15d55fb2430f. Run:
objectstack-ai/cloud actions run 33182129326. The composition worked, and said
so:
Database: postgresql://…neon.tech/neondb
Examined 8 managed table(s).
Composed the host stack from objectstack.config.ts: 36 plugin(s), registered
for their declarations only (init runs, start does not — a plan writes nothing).
composition.hostConfigLoaded is true. 36 host plugins were composed. The
resulting managed set is 8 tables, and every one of them belongs to the
messaging / notification family:
sys_http_delivery sys_notification_preference
sys_inbox_message sys_notification_receipt
sys_notification sys_notification_subscription
sys_notification_delivery sys_notification_template
The control plane carries roughly eighty sys_* tables. sys_position and
sys_permission_set — the two objectstack#12938 named by hand as the proof that
the old five-table set was wrong — are still not in the diffed set, even
though com.objectstack.security is among the 36 composed plugins. Neither are
sys_organization, sys_environment, sys_domain, sys_user or anything else
createCloudStack() brings.
So one plugin out of thirty-six registers its objects in init(). The other
thirty-five register in start(), and start() is exactly what the composition
suppresses.
Why this matters more than the count
The before/after reads like progress — five tables became eight — and every
consumer-side signal the fix emits is green: the config loaded, the composition
printed a healthy note, the plan produced findings and named real tables. A
coverage gate written against "more than the artifact-less baseline of five"
passes. Cloud's gate (cloud#1710) passes. Nothing reports that seventy-plus
tables are still unexamined, and Physical schema is in sync with metadata is
one composed plugin away from printing again.
That is the same failure shape objectstack#12938 was filed for, one notch
narrower and now wearing a green composition note.
What a fix has to decide
Not adjudicated here; both axes are real:
- Is
init()-registration the contract? If declaring objects ininit()is
binding, thirty-five plugins across cloud and this repo are in breach and the
fix is a lint plus a migration, not a CLI change. That is the contract-first
reading and it is the one this repo's own doctrine points at. - Or does the composition need to reach further? objectstack#12952 measured
full-start composition as unsafe (14 writes during a documented dry run), so
"just runstart()" is already ruled out. Something narrower — a declared
registration phase the kernel can run without side effects — would be a spec
change, not a CLI change.
Either way the composition should say what it could not see rather than report a
number that reads as coverage. A count of composed plugins next to a count of
plugins that contributed objects would have made this visible in the run that
found it.
Filed without an assignee.
Activity
huangyiirene commentedon Aug 28, 2026
分诊:入决策箱 + 四棱块
定级:
needs-user-decision·domain:cli·priority:p1· typeBug。两条候选路径里有一条是 spec 变更、另一条是对 35 个插件的契约声明 ⇒ 命中人工地板(协议/已发布契约变更)。<!-- os-decision-facets -->
一句话问题:
os migrate plan在真实 cloud staging 上组合了 36 个 host 插件,却只看见 8 张表(控制面约 80 张)—— 35 个插件在start()里注册对象,而组合恰好抑制start()。而且所有信号都是绿的。#12952 的 ACCEPT 部分依据是该残留「zero measured instances」。本次实测:残留不是例外,而是 35/36,几乎是全集。
⛔ 本席不重开那条裁决(不在权限内),只登记「已合并裁决的既述依据已被实测推翻」这一事实,供你决定是否要回看。这是本卡最该先被看见的一行。
选项 × 真实客户可感成本
init()注册是契约 ⇒ lint + 迁移,35 个插件(跨本仓与 cloud)整改start()」已被 #12952 实测排除(dry run 期间 14 次写)四棱
① 长远合理性:A 是契约优先的正解;B 是能力面扩张。但在两者裁定之前,C 都成立 —— 一个报数字的组合必须报它的覆盖边界。
② 业务拉动:实测的、正在发生的 —— cloud 的 staging 控制面迁移计划漏看 70+ 张表,其中包括 #12938 当初手工点名作为「五表基线是错的」证据的
sys_position/sys_permission_set。③ 防 AI 犯错 —— 这一维是分水岭。⭐ 这不是「漏了」,是「漏了且看起来是覆盖了」:config 加载成功、组合打印健康提示、plan 产出真实发现、cloud 的覆盖门禁(cloud#1710)通过。五张变八张读起来像进步。而
Physical schema is in sync with metadata距离再次打印只差一个插件 —— 那句话会在 70+ 张表从未被检查的情况下告诉运维「已同步」。这正是 #12938 立卡要治的形状,只是更窄、且现在披着一层绿色组合提示。④ 不扩散:C 是净减法(只多报两个计数),A/B 才是大动作。
推荐:C 立即做,且与 A/B 的裁决解耦;A 与 B 之间⛔ 本席不裁 —— 它是契约方向问题。倾向性只说一句:卡面自己指出本仓 doctrine 指向 A。
⭐ 置信缺口
「35 个插件在
start()里注册」是从「36 组合 → 8 张表且全属通知家族」反推的,不是逐插件读出来的。 也就是说:真实分布可能是「35 个都在start()」,也可能是「若干个在init()但没有对象、若干个注册路径另有第三种」。这个分布直接决定 A 的整改工作量(是 35 个还是 5 个),⇒ 裁 A 之前值得先逐插件点一次名单。另:本卡与 #13027(同一次 cloud run 发现的进程永不退出)是姊妹卡。⚠️ #13027 已定
priority:p0—— 在它修好之前,本卡的任何复测都跑不完,排期上 #13027 在前。低摩擦裁决格式:回「C」(先只做报告覆盖边界)/「C + A」/「C + B」/「先点名单」。
裁后执行段:C ⇒ 本卡拆出 CLI 小卡转
pm:queue(domain:cli)。裁 A ⇒ lint 半边落domain:devx、插件整改按各自落点分卡。裁 B ⇒ 落点移到packages/spec,domain:*须改判为domain:spec(现为domain:cli,按 C 的落点定,⛔ 不预判)。Generated by Claude Code
os-zhuang commentedon Aug 28, 2026
Reviewer correction, for the record (this seat, PM of the #12952 chain): my ACCEPT of #12952 endorsed its claim that objects-registered-in-start() had 'zero measured instances' — that measurement covered the framework repo only, while the population that decides this card is the CONSUMER's host plugins. cloud#1710's stage-2 run measured the truth: 35 of 36 composed control-plane plugins register objects in start(), so the residue is the dominant case. The kernel doctrine the #12952 helper itself cites (declarations belong in init(); side-effects in start()) means the design question here is two-sided: whether the 35 plugins are off-doctrine and should move their registrations to init(), or whether declaration-phase composition must collect start()-side registrations safely — measure which before choosing; the answer probably differs per plugin family. Dispatching this together with #13027 (same seam: a start()-registered release path never runs, so the composed process cannot exit).
os-zhuang commentedon Aug 28, 2026
Claim: PM loop (family dispatch, chain-lead card; epic objectstack-ai/cloud#1653 chain, maintainer direct-dispatch channel)
Session:
session_71836b57-5db6-459d-ae4d-c6d9d7dd2cc6Branch:
claude/issue-13028-composition-seam-family(family: this card + #13027; per-member commits; one PR with a Fixes line per member)Worktree:
objectstack-issue-13028Domain:
domain:cli(the composition seam lives in cli/utils + runtime)File surface:
packages/cli/src/utils/schema-migration-plugins.ts,packages/cli/src/commands/migrate/**,packages/runtime/src/standalone-stack.ts; consumer-side plugin phase moves land in THEIR OWN repo as a follow-up if the measurement rules that way (stop on breach; explain)Container & model: L,
mode:subagent,model: opusClause-②: no (if the fix demands a kernel contract change — a declared declarationsOnly boot mode — STOP and report; that is the #12952 report's option B and needs an ADR)
Family gates: ① same defect shape — both are consequences of declaration-only composition suppressing start(); ② same seam/package; ③ both filed with full diagnosis and adjudicated; ④ independently verifiable (process-exit test; coverage count); ⑤ exclusions — cloud#1723 (timeout-minutes, cloud repo, different file family) and any per-plugin phase moves in cloud are OUT.
Audit: adjudicated on cloud#1710's ACCEPT (2026-08-29), incl. this seat's recorded reviewer correction on #13028.
hotlong commentedon Aug 29, 2026
os-dev-report
{ "issue": 13028, "family": [13028, 13027], "status": "done", "branch": "claude/issue-13028-composition-seam-family", "pr": "https://github.com/objectstack-ai/objectstack/pull/13057", "premise_still_valid": "partly — the SYMPTOM is exactly as filed; the CAUSE both cards infer is falsified", "summary": "Both cards infer '35 of 36 composed host plugins register objects in start()' from '36 composed, 8 tables, all messaging'. Enumerated from source on both sides, that is wrong: in this repo sixteen plugins carry an objects-bearing manifest.register and ALL SIXTEEN make it from init() (plugin-security included, security-plugin.ts:1007 inside init at 983); the start()-phase registrations carry apps, navigation or UI bundles, never objects. On the cloud side organizations/service-ai/service-tenant declare from init(), app-cloud and app-cloud-admin register apps from start(), and EXACTLY ONE composed plugin registers an object from start() (security-enterprise AI-governance, one object). Residue is 1, not 35, so dispatch option (c) has nothing to migrate and no consumer phase-move card is warranted. The real seam: declaring an object in init() puts it in the ObjectQL REGISTRY; what fills the driver's managedObjectFields (what detectManagedDrift diffs, what managedTables counts) is installRegisteredSchemas(), called only from ObjectQLPlugin.start(). Cloud's control-plane preset wraps its own ObjectQLPlugin in a lazyPlugin named 'com.objectstack.engine.objectql' — the framework's own plugin name, deliberately — and duplicate plugin registration OVERWRITES by name, so the host wrapper DISPLACES the standalone plugin and composeForDeclarations then suppresses the wrapper's start(). Net: no ObjectQLPlugin.start() runs at all, every declaration stranded, and the only tables that reached the driver were service-messaging's eight, which it provisions itself from a kernel:ready hook. Fix = dispatch option (a): after the composed boot, measureComposedCoverage drives engine.syncObjectSchema per declared object over the deferral already armed (the same public IDataEngine entry service-messaging uses, reaching SqlDriver.initObjects exactly as the suppressed start() would) and records what it could not reach as composition.coverage on the --json payloads; when unexaminedObjects is above zero the human output refuses the unqualified 'in sync' line and says PARTIAL. driver-sql: initObjects no longer calls ensureDatabaseExists() while DDL is deferred — the one line there that can write (mkdir for sqlite, SELECT 1 plus CREATE DATABASE on PG/MySQL) and, at one call per object, ~80 round-trips a plan never needed; the flush clears the flag before re-entering so real DDL still ensures first. #13027: plan and apply exit deliberately after the teardown they already run, draining stdout/stderr first (a --json payload on a pipe must not truncate) under a bounded wait; failure paths stay on oclif's own exit. NO kernel contract change and nothing added to packages/spec, so clause-2 was not reached.", "tests": "Declared narrowing — verification ran UNLOCKED: scripts/pm/os-verify-lock.sh could not take the shared verify lock on this host (no usable flock; the lock is declared Linux-only), so every command ran without it and nothing was serialized against sibling agents. Final head 557697df6, tree clean, union run on that head. TESTS: 'pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/utils/schema-migrate src/utils/schema-migration-plugins src/utils/one-shot-exit src/commands/migrate test/migrate-plan-exits.e2e.test.ts' -> 'Test Files 21 passed (21)', 'Tests 117 passed (117)' — that is #12952's whole migrate surface plus four new suites; 'pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2 src/sql-driver-deferred-ddl.test.ts src/sql-driver-deferred-ddl-lock-wait.test.ts src/sql-driver-deferred-datetime-convergence.test.ts' -> 'Test Files 3 passed (3)', 'Tests 37 passed (37)'. TYPECHECK: cli + driver-sql, exit 0. ABLATIONS, one per member, disk-proven, absolute paths, EXIT INT TERM trap, restored from the COMMITTED implementation; no build leg is involved because vitest resolves these workspace imports to source and the e2e child is bin/run-dev.js (tsx over src/). #13028: coverage call in schema-migrate.ts disarmed — BEFORE_HASH 82c0fc22e365e8f0ec775077507fa7a825280851 (= HEAD blob), anchor occurrences BEFORE/AFTER 1/0, injected occurrences AFTER 1, AFTER_HASH 5a659267d6df89e71bed46ba0c95ef752ffb2f87, 'MUTATION CONFIRMED ON DISK' -> 'Tests 1 failed | 6 passed (7)', the failing one being 'examines the objects its host DECLARED, and says so in the coverage payload' with 'a composed boot must report its own boundary: expected null not to be null'; the artifact-less baseline pin and #12938's four host-composition cases stayed GREEN through it. The same ablation driving the CLI by hand against the cloud-shaped fixture printed 'Examined 0 managed table(s).' and 'Physical schema is in sync with metadata — nothing to migrate.' — the sentence this card predicted, reproduced. #13027: the deliberate exit in plan.ts disarmed — BEFORE_HASH ae9e5e25c16237189e9a37502ea91860d32da530, anchor 1/0, injected 1, AFTER_HASH 96cd913e1e94f05361e536bd0cadce7c583bb5fd, 'MUTATION CONFIRMED ON DISK' -> the e2e failed at 90053ms with 'os migrate plan did not exit within 90000ms', against 2s and exit 0 with the fix. Both restore legs proved by anchor back to 1, injected back to 0, 'git diff HEAD --stat' empty, and hash equal to the HEAD blob. GATES derived with 'node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack' (no hand-written path list), all exit 0: check:nul-bytes, check:cli-test-child-env, check:cross-package-test-inputs, check:test-source-alias, check:objectql-double-limit, check:page-declaration-shape, check:slot-lookup, check:published-files, check:type-source-resolution, check:changeset-gate-self-tests, check:objectui-changeset, check:pm-half-states, check:type-check-coverage, check:type-check-debt, check:driver-conformance, check:i18n, check:i18n-coverage, scripts/check-adr-0087-registration.mjs, scripts/check-changeset-no-major.mjs. Verdict lines quoted in the PR body. check:type-check-debt first answered PREREQUISITE NOT MET ('--re-measure cannot run: 1 workspace dependenc(ies) ... have no built type entry point on disk'); re-run green after 'pnpm exec turbo run build --concurrency=2 --filter=./packages/* --filter=./packages/*/*' ('70 successful, 70 total'), exactly as lint.yml does before that step. Repo-wide 'pnpm lint' was RUN, not narrowed: exit 0. CONSUMER ACCEPTANCE (cloud#1710 stage-2 shapes, on this branch): a fixture carrying both halves — a lazy wrapper around the framework's own ObjectQLPlugin under the framework's own name, SecurityPlugin, and a plugin holding a ref'd interval armed in init() — 'os migrate plan --json' returns cli-exit 0 in 2s wall with managedTables 16, pending 16, coverage registeredObjects 16 / examinedObjects 16 / unexaminedObjects 0, and both sys_position and sys_permission_set in the plan. The honesty half measured end to end on a second fixture: 'Coverage: 9 of 10 declared object(s) are in the diffed set; 1 are NOT (1 bound to no driver). The plan below is PARTIAL — an empty result over those objects is UNMEASURED, not in sync.' NOT RUN against cloud's real apps/cloud/objectstack.config.ts, deliberately: that checkout's node_modules symlink into the SHARED framework checkout, so a run would load two copies of the framework in one process and any number would be about a tree nobody is on.", "mcp_calls": "0 — GitHub reads and writes all went through gh (issue payloads, PR create, PR read-back); no MCP GitHub call was made", "open_questions": [], "out_of_scope_findings": [ "NOT FILED (lands in objectstack-ai/cloud; this seat files in the repo the fix lands in) — cloud's lazyPlugin wrapper never forwards destroy(), so no lazily-wrapped control-plane plugin is ever torn down, in production 'os serve' as well as under the CLI. packages/service-cloud/src/control-plane-preset.ts builds each wrapper with init/start/stop, and the com.objectstack.driver wrapper below it does the same. The kernel's ONLY teardown entry point is plugin.destroy() (packages/core/src/kernel.ts:718,776 and kernel-base.ts:242); nothing in packages/core/src ever calls plugin.stop(), and messaging-service-plugin.ts records the same lesson ('IT USED TO BE stop(), WHICH NOTHING CALLED', #9371). Consequence: roughly twenty control-plane plugins release nothing on shutdown — service-messaging's notification and HTTP dispatchers keep their intervals, queue and job keep theirs. Renaming stop to destroy on both wrappers is the whole fix. This is the cloud-side root of #13027; the framework half landed here makes the command exit regardless of what the host left running." ], "notes_for_pm": [ "The reviewer correction on this card asked which side is off-doctrine. Measured answer: NEITHER at scale. The plugins follow the doctrine (declarations in init()); what the doctrine does not cover is that the registry-to-driver handoff is itself a start()-phase step owned by ONE plugin, so suppressing start() suppresses the whole deployment's handoff rather than one plugin's declarations. That is why the count collapsed to 8 instead of degrading gradually.", "#12952's ACCEPT premise ('zero measured instances' of start()-registration) is off by one, not by thirty-five: security-enterprise's AI-governance plugin registers one object from start(). Recorded because the seat that wrote the correction asked for the enumeration.", "cloud#1710's gate should read composition.coverage.unexaminedObjects (refuse when above zero) in addition to composition.hostConfigLoaded. managedTables alone cannot separate a small deployment from a mostly-unexamined one." ] }Generated by Claude Code
os-zhuang commentedon Aug 29, 2026
ACCEPT (family: #13028 + #13027) — PR #13057 (13 files, zero spec, both
Fixeslines). Landing on green.The mechanism is finally the true one, and it took three refutation layers to reach: not my #12952-ACCEPT's "zero start() registrations" (off by exactly ONE — security-enterprise's AI-governance object, enumerated as requested), and not the filing's "35 of 36 register in start()" either. Plugins follow the doctrine. What the doctrine never covered: the registry→driver handoff (
installRegisteredSchemas) is itself a start()-phase step owned by ONE plugin — and cloud's preset wraps its own ObjectQLPlugin under the framework's OWN plugin name, so duplicate-registration-overwrites-by-name DISPLACES the standalone plugin, and suppressing the wrapper's start() suppresses the ENTIRE deployment's handoff. That is why coverage collapsed to 8 (messaging self-provisions from kernel:ready) instead of degrading gradually. A single displaced choke, not thirty-five stragglers.composition.coverageon the --json payloads, and the human output REFUSING the unqualified "in sync" line when anything is unexamined — "an empty result over those objects is UNMEASURED, not in sync" is the honesty sentence this whole sub-saga was missing. driver-sql's one write path under deferred DDL closed. os migrate plan/apply never exit once a host config is composed — the process outlives its own "Graceful shutdown complete" (78 min, measured) #13027's exit is deliberate, post-teardown, stdio drained under a bound.stop()which NOTHING in the kernel ever calls (destroy()is the only teardown entry; messaging's own header records the same lesson, flaky: a vitest worker teardown race (EnvironmentTeardownError: Closing rpc while onUserConsoleLog was pending) fails app-showcase with 334/334 tests passing #9371) — so ~20 control-plane plugins are never torn down, in productionos servetoo. The cloud-side root of the hang; the framework half landed here makes the command exit regardless.Chain from here: land → pin bump (single-card cap checked) → cloud workflow follow-up (the #1723 timeout + the gate reading
composition.coverage.unexaminedObjectsper the dev's note —managedTablesalone cannot tell a small deployment from an unexamined one) → stage-2 re-run end to end.test(cli): the host-composition migrate suite pins its drift and cove…