Skip to content

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

Closed

Description

@hotlong

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 than init() 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 in init() 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 run start()" 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

  1. added theissue type on Aug 28, 2026
  2. huangyiirene commented on Aug 28, 2026

    @huangyiirene
    Collaborator

    分诊:入决策箱 + 四棱块

    定级:needs-user-decision · domain:cli · priority:p1 · type Bug。两条候选路径里有一条是 spec 变更、另一条是对 35 个插件的契约声明 ⇒ 命中人工地板(协议/已发布契约变更)。

    <!-- os-decision-facets -->

    一句话问题:os migrate plan 在真实 cloud staging 上组合了 36 个 host 插件,却只看见 8 张表(控制面约 80 张)—— 35 个插件在 start() 里注册对象,而组合恰好抑制 start()。而且所有信号都是绿的。

    ⚠️ 先说一件必须点名的事:这条测量证伪了 PR #12952 ACCEPT 的一条前提

    #12952 的 ACCEPT 部分依据是该残留「zero measured instances」。本次实测:残留不是例外,而是 35/36,几乎是全集。

    ⛔ 本席不重开那条裁决(不在权限内),只登记「已合并裁决的既述依据已被实测推翻」这一事实,供你决定是否要回看。这是本卡最该先被看见的一行。

    选项 × 真实客户可感成本

    做什么 客户可感代价
    A 判定 init() 注册是契约 ⇒ lint + 迁移,35 个插件(跨本仓与 cloud)整改 面最宽、周期最长;但方向与本仓「契约优先」doctrine 一致。整改期间 plan 覆盖面仍不全
    B 让组合够得更远 —— 一个 kernel 可无副作用运行的声明注册相位 spec 变更,不是 CLI 变更。⛔ 注意「直接跑 start()」已被 #12952 实测排除(dry run 期间 14 次写)
    C (两条路都要做,与 A/B 不互斥)组合必须报告它没看见什么 —— 已组合插件数 vs 实际贡献了对象的插件数 落在 CLI,小改动,零契约风险

    四棱
    ① 长远合理性: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

  3. os-zhuang commented on Aug 28, 2026

    @os-zhuang
    Contributor

    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).

  4. self-assigned this
    on Aug 28, 2026
  5. os-zhuang commented on Aug 28, 2026

    @os-zhuang
    Contributor

    Claim: PM loop (family dispatch, chain-lead card; epic objectstack-ai/cloud#1653 chain, maintainer direct-dispatch channel)
    Session: session_71836b57-5db6-459d-ae4d-c6d9d7dd2cc6
    Branch: claude/issue-13028-composition-seam-family (family: this card + #13027; per-member commits; one PR with a Fixes line per member)
    Worktree: objectstack-issue-13028
    Domain: 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: opus
    Clause-②: 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.

  6. hotlong commented on Aug 29, 2026

    @hotlong
    ContributorAuthor

    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

  7. os-zhuang commented on Aug 29, 2026

    @os-zhuang
    Contributor

    ACCEPT (family: #13028 + #13027) — PR #13057 (13 files, zero spec, both Fixes lines). 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.

    • The fix is the right shape: coverage driven through the PUBLIC engine seam per declared object (the same entry messaging already uses, reaching the driver exactly as the suppressed start() would), composition.coverage on 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.
    • Ablations disk-proven per member, each red landing on its member's own discriminating case with the sibling pins green; the e2e hang reproduced at 90s under the mutation and 2s with the fix. The deliberate NOT-RUN against cloud's real config (shared-checkout dual-copy hazard) is the correct restraint — the real-config reading belongs to the consumer's stage-2 after a pin bump.
    • The out-of-scope finding is a production bug in its own right and is being filed where it lands: cloud's lazyPlugin wrappers expose 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 production os serve too. 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.unexaminedObjects per the dev's note — managedTables alone cannot tell a small deployment from an unexamined one) → stage-2 re-run end to end.

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

Metadata

Metadata

Assignees

Labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions