Skip to content

ADR-0130 跨包规则矩阵补全:9 类命名对象的元数据、navigationContributions[].group 语义、权限集的模块归属、dashboard/report → dataset 跨包绑定(hotcrm 分拆方案挖出的 4 个上游缺口) #14454

Description

@hotlong

Part of #14122。来源:hotcrm 分拆方案 objectstack-ai/hotcrm#1448 / PR hotcrm#1449,docs/architecture/module-split-plan.md §「上游缺口 / Upstream gaps」。方案严格只用 #14122 §4 已实测的四条跨包规则判边(73 条边:54 允许、19 拒绝),凡四条规则没覆盖的都没有给判定,而是记在这里。四项都是平台侧的事实缺口或决定,不挡本次发版(发版清单见 #14122),但挡 hotcrm 的代码分拆设计定稿。

1. ACCEPT / REFUSE 矩阵只测了 2 类命名对象的元数据,分拆需要 9 类(测量工作)

#14122 §4 实测了 hooks[].object 与 app.navigation[].objectName。没测的:action 的 objectName、view 的 data.object、page 的记录对象与 related-list 对象、dataset 的 object、sharing rule 的 object、permission set 的 objects、seed dataset 的 object、import mapping 的 targetObject、flow 节点的 object。HotCRM 里有 19 个 action、9 个 flow、5 个 page、6 个权限集、3 个 dashboard 会一边在某模块、另一边点名别的模块的对象。

要做:按 §4 同样的方法(真实 installPackage 两包、共享命名空间、逐类一条 pin),把这 9 类的接受 / 拒绝各测一遍,结论写回 #14122 §4 的矩阵与 ADR-0130 的 Consequences(ADR 改动 docs-only 另开卡给维护者)。

2. navigationContributions[].group 语义未钉(测量 + pin)

方案把 17 个 R3 拒绝的导航节点全部转成 owning module 的 navigationContributions,依赖三件事:group 按目标 app 声明的 group 节点 id 解析;贡献进一个不存在的 group 要可见地失败而不是静默消失;多个包贡献进同一 group 的顺序由 priority 决定而非注册顺序。要做:三条 pin。任何一条不成立,转换就不能保信息架构不变。

3. 权限集没有模块归属 —— ADR-0130 §1.3(a) 点名的痛点分拆后没有被消掉(需维护者决定)

ADR-0130 §1.3(a) 把 30 行 × 9 列 × 6 套的权限矩阵列为无边界的三个可量化后果之一,并把包边界作为 Studio 一直缺的分组。但权限集天然跨域授权——HotCRM 6 套里 4 套横跨 5–6 个模块——它不能住在某个模块里,分拆后矩阵依旧是平的。

待决定:A. 允许权限集按包组合(模块把自己对象的授权贡献进 app 拥有的角色,类似 navigationContributions);B. 明确记录"权限矩阵不在 ADR-0130 创造的边界范围内",Studio 的分组收益不含它。两者都要写回 ADR-0130(docs-only 卡)。

4. dashboard / report → dataset 的跨包绑定未测(测量)

3 个 dashboard 与 1 个 report 绑定的 dataset 会住在别的模块。这是第 1 项在分析面的特例,它决定"一个模块能不能自带自己的 dashboard"。要做:一条 pin,接受或拒绝都行,但要有结论。

边界

⛔ 不在 hotcrm 侧绕(AGENTS.md 规定平台缺口等平台修)。⛔ 不改 ADR 正文(另开 docs-only 卡)。第 1、2、4 项是测量与 pin,可以直接派 dev;第 3 项先等维护者拍板再派。

Activity

  1. added
    pm:queueReady for the PM dispatch loop
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Sep 2, 2026
  2. added
    domain:specDomain lane — seat card indexed by label:pm:seat
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Sep 2, 2026
  3. added theissue type on Sep 2, 2026
  4. huangyiirene commented on Sep 2, 2026

    @huangyiirene
    Collaborator

    Triage: priority:p2 · pm:queue · domain:spec · type Task — and the third item is split out to #14457.

    🔴 Why the split was forced. This card carried pm:queue and needs-user-decision together. The six pm:* states are a ONE-OF: a card holding both is simultaneously in the dispatchable inventory and in the maintainer's decision inbox. One of two things then happens, and both are bad — a dev agent picks it up and implements a limb nobody ruled, or a card that was never a decision sits in the inbox forever. ⛔ Not a labelling nit.

    But the dual label was honest — the card genuinely is mixed, and it says so: 「第 1、2、4 项是测量与 pin,可以直接派 dev;第 3 项先等维护者拍板再派」. ⇒ The fix is a split, not a coin-flip:

    ⛔ Nothing was summarised away in the split: #14457 quotes item 3 verbatim and keeps its two options as written.

    domain:spec: every landing point is spec's — the #14122 §4 matrix, ADR-0130's Consequences, and navigationContributions' declared semantics. The measurement runs through installPackage but the conclusions are written into the spec surface.

    p2: the card is explicit that none of this blocks the release (「不挡本次发版」) but it does block hotcrm's split design from being finalised — a real, named consumer waiting, which is what lifts it off p3.

    ⭐ Sequence item 2 first, and it is not arbitrary

    The three navigationContributions[].group pins are the cheapest of the three and they are the evidence #14457 needs: they establish whether "contribute into a container someone else owns" works on this platform at all, which is precisely the shape option A of that decision would reuse. ⇒ Running item 2 first turns a maintainer decision from a judgement call into a measured one, at zero extra cost. Post those three readings on #14457 when they land.

    ⚠️ The card is right that all three of item 2's pins are load-bearing, and the middle one is the one to write carefully: contributing into a group that does not exist must fail visibly. A silent drop there is the failure mode that would make the whole 17-node navigation conversion unsafe, and it is exactly the shape that passes a test asserting only the happy path.

    Fences, verbatim from the card and binding: ⛔ no work-around on the hotcrm side (AGENTS.md: platform gaps are fixed in the platform); ⛔ no edits to the ADR body — that is a separate docs-only card once the readings exist.

    Size/model suggestion: L, sonnet; item 2 is separable and small enough to land first on its own.


    Generated by Claude Code

  5. removed
    domain:specDomain lane — seat card indexed by label:pm:seat
    on Sep 2, 2026
  6. self-assigned this
    on Sep 2, 2026
  7. hotlong commented on Sep 2, 2026

    @hotlong
    ContributorAuthor

    决定 + 认领

    第 3 项(权限集)维护者 2026-09-02 拍板:取 B。 本次发版按"权限集整体留在 app 包,权限矩阵不在包边界收益范围内"写进 ADR-0130 补记(#14487,受管面 draft 交维护者合);A(按包组合授权)立为分拆第二阶段平台卡 #14488,不挡发版。摘 needs-user-decision。

    第 1、2、4 项是测量与 pin,现在派发:


    Generated by Claude Code

  8. hotlong commented on Sep 2, 2026

    @hotlong
    ContributorAuthor

    os-dev-report

    {
      "issue": 14454,
      "status": "done",
      "branch": "claude/issue-14454-cross-package-matrix",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/14549",
      "premise_still_valid": true,
      "summary": "Items 1, 2 and 4 measured and pinned; item 3 untouched (maintainer ruled B, split to #14487/#14488). Two new test files, 22 pins, no source changes. METHOD as #14122 §4: package A and package B installed as same-artifact co-owners of one namespace via the real load path (manifest.register({packages:[...]}) on a booted ObjectKernel, ADR-0130 D1 / #14354); A owns crm_account, B names it from the class under test. KEY STRUCTURAL FINDING: §4's four verdicts do not come from one door, so every row reads TWO — the AUTHORING gate (defineStack -> validateCrossReferences, packages/spec/src/stack.zod.ts:1109, which sees one stack and so cannot tell a co-owner's object from a typo; this is where §4's two REFUSE rows came from, message text verbatim) and the INSTALL gate (registerApp -> installPackage, packages/objectql/src/engine.ts:4797, which validates NO object reference on any of these classes). Install-gate-only would report a uniform ACCEPTED for all nine and be useless; authoring-only would miss that the runtime enforces nothing. MATRIX (item class -> effective verdict -> error -> pin in packages/objectql/src/registry-cross-package-item-classes.test.ts): action objectName -> REFUSED at authoring / ACCEPTED at install -> \"Action 'NAME' references object 'OBJECT' which is not defined in objects.\" no code/status -> :282,:316 | view data.object -> REFUSED/ACCEPTED -> \"View[0].list references object 'OBJECT' ...\" -> :335 | page record object -> ACCEPTED (unenforced) both doors -> none -> :384,:434 | page related-list dataSource.object -> ACCEPTED (unenforced) -> none -> :448 | dataset object -> ACCEPTED (unenforced) -> none -> :392,:434 | sharing rule object -> ACCEPTED (unenforced) -> none -> :410,:434 | permission set objects -> REFUSED/ACCEPTED -> \"Permission 'NAME' grants on object 'OBJECT' ...\" -> :290,:316 | seed dataset object -> REFUSED/ACCEPTED -> \"Seed data references object 'OBJECT' ...\" -> :298,:316 | import mapping targetObject -> REFUSED/ACCEPTED -> \"Mapping 'NAME' targets object 'OBJECT' ...\" -> :306,:316 | flow node START config.objectName -> ACCEPTED (unenforced), config is an open z.record -> none -> :477. Totals 5 REFUSED / 7 ACCEPTED. ITEM 4: dashboard widget dataset (:529) and report dataset (:551) both ACCEPTED at both doors — a module CAN carry its own dashboard bound to another package's dataset. ITEM 2 (packages/objectql/src/registry-nav-contribution-group-semantics.test.ts): proposition 1 HOLDS (:143,:158 — DFS by id AND type==='group', appended into the group, not duplicated); proposition 3 HOLDS (:270,:284 — priority ascending, stable tie-break to arrival, schema default 200 verified between explicit 100 and 300); proposition 2 DOES NOT HOLD AS POSED (:170,:186,:245) — the platform does a THIRD thing: it RELOCATES the contribution to the app's top level. No throw, no code/status, no console.warn and no console.error; the only trace is ONE console.log gated at logLevel info/debug. Shipped default is info so a default boot prints it once, but at warn — what OS_REGISTRY_LOG selects, and what this package's own vitest.config.ts selects (#13517) — the relocation is completely silent while still happening. The authoring door cannot catch it either: navigationContributions appears in no cross-reference rule at all. This is worse than the silent drop the card feared: a relocated entry is present and looks fine, while the information architecture the 17-node conversion exists to preserve has changed. Flagged in the PR body for PM ruling; no runtime behaviour changed here, per the card's fence. SECOND FLAG: all five REFUSED rows are a bare new Error from defineStack — code and status are undefined, so there is no ADR-0112 envelope to assert; the pins assert the message (the only discriminator) and pin the envelope's ABSENCE once at :251. Same shape as #14367 one door over.",
      "tests": "All at final commit 2019fdc8d (gate union re-run on this exact head; exit codes captured before any pipe as `cmd > log 2>&1; EXIT=$?`). (1) Full suite: `pnpm --filter @objectstack/objectql test` -> exit 0, 259 test files / 4479 tests passed (the 22 new pins included; run alone 22/22). (2) Typecheck: `pnpm --filter @objectstack/objectql typecheck` -> exit 0; both new files confirmed INSIDE the program via `tsc -p tsconfig.test.json --listFiles` (2 hits) — a typecheck that excluded them would have said nothing about them. (3) Gate union: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` derived 28 families from the real change set (24 by path + 6 by kind, 2 shared), harvested with --commands; 25 exit 0 on the first pass, 3 returned exit 3 = PREREQUISITE NOT MET (self-declared NOT MEASURED, not red). After `pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'` (70/70 tasks): check:type-check-debt -> exit 0, verdict line \"check-type-check-coverage --re-measure: OK — 23 ledger entr(ies) re-measured in 161.3s, 771 raw tsc error(s) total, none above its recorded number. surplus: none\"; check:dual-build-cjs-loads -> exit 0, 102 require entry points across 66 packages load. check-test-completeness.mjs stays NOT MEASURED — invoked with no argument (as the family names it) it needs a saved `turbo run test` log and prints \"It is not a red, and there is nothing here to fix.\" (4) ABLATIONS, 3 legs, all mutating packages/objectql/src/. Each leg: mutation proven on disk by grep-counting the REMOVED and the INJECTED text (never a bare `git diff --stat`), restore proven by `git hash-object` EQUAL to the HEAD blob plus an empty `git diff HEAD`, `trap '<restore>' EXIT INT TERM` with absolute paths resolved from `git rev-parse --show-toplevel`. (a) drop the priority sort in applyNavContributions -> predicted PROPOSITION 3 red -> observed 1 failed / 7 passed, only PROPOSITION 3. (b) missing-group branch DROPS items instead of relocating -> predicted PROPOSITION 2 red -> observed 3 failed / 5 passed: both PROPOSITION 2 pins plus PROPOSITION 1 (scope), which rides the same path. (c) remove sharingRules from METADATA_ARRAY_KEYS -> predicted the sharing-rule ACCEPTED row red -> observed 1 failed / 13 passed, only that row. NO REBUILD was needed or done for the ablations: both suites reach the mutated code through RELATIVE SOURCE imports (./registry.js, ./engine.js), not through dist — and the ablations going RED is the proof of that resolution path, since a dist-resolved suite would have stayed green (the failure mode that makes an ablation lie). Final state after all three: `git status --porcelain` empty, both blob hashes equal to their HEAD blobs. Clause-② expected NO — no source change, no contract accept/reject behaviour altered, no public surface widened.",
      "mcp_calls": "12 — MCP-only by necessity: `gh` is absent from this container and a repo-scoped REST probe returned 403, so the write channel was declared and switched to MCP (4 reads on #14454/#14122, create_pull_request, pull_request_read, one failed issue_read get_labels since that path is Issue-scoped and will not resolve a PR, 2 search_pull_requests for the label read/read-back, 1 issue_write for the label union, and these 2 report comments).",
      "open_questions": [
        {
          "question": "navigationContributions[].group naming a group that does not exist: today the items are RELOCATED to the app top level, with no throw, no warn, and only an info-gated console.log. Should this refuse instead? (Measurement card — not changed here.)",
          "options": [
            "A — refuse at install: the contribution names a group its target app does not declare, so reject with an ADR-0112 envelope, the way the object-name checks do. Loudest, and catches it before any user sees a moved menu; but it makes install order matter (the target app must be registered first) and would break any package that contributes into an optional group.",
            "B — keep relocating, but upgrade the trace to console.warn (or a real diagnostic): survives OS_REGISTRY_LOG=warn, which is where the signal is lost today. Cheap, no new refusal, no ordering constraint — but a warning in a boot log is still not a gate.",
            "C — leave as measured and make hotcrm's conversion assert its own IA: no platform change; the 17-node conversion carries a test that the merged nav tree matches the pre-split tree."
          ],
          "recommendation": "B, with C alongside for the conversion. Real business need: hotcrm's 17-node conversion is the named, measured consumer, and its requirement is that a mis-aimed contribution be NOTICED — B delivers exactly that at the point of failure. Long-term soundness: A is the contract-first answer in the abstract, but the ordering constraint it introduces is real (registerAppNavContribution deliberately does not require the target app to exist yet, and the merge is a read-time fold precisely so order does not matter) — buying loudness by giving that up is a worse trade than it looks. Making AI-authored metadata hard to get wrong: a typo'd group id is exactly the error an AI author emits, and today it produces a silently different IA; B makes it visible without inventing a new refusal an author must then learn to suppress. Startup scope discipline: B is a one-line change to an existing log call, C is a test in the consumer repo — neither adds declared surface. If the maintainer wants the gate rather than the signal, A is the shape, and it should then also cover the app-does-not-exist case, which is equally unchecked today."
        }
      ],
      "out_of_scope_findings": []
    }

    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

pm:queueReady for the PM dispatch loop

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions