Skip to content

Contract question: may an app seed write sys_user.primary_business_unit_id directly, or must it seed sys_business_unit_member rows? #14172

Closed

Description

@os-warren

A question about an intended contract, not a defect report — nothing is currently broken. Asked because it cannot be answered from outside the enterprise edition, and the failure mode if we guess wrong is silent.

The situation

@objectstack/platform-objects declares sys_user.primary_business_unit_id as readonly: true and documents it as derived:

The user's primary business unit — a denormalised projection of sys_business_unit_member.is_primary, maintained by plugin-sharing (ADR-0057 addendum D12). … Do not edit directly; set it via business-unit membership.

A demo seed in an app (objectstack-ai/duly) sets it directly, along with sys_user.manager_id (also readonly). Both writes land, because the declarative seed loader writes under { context: { isSystem: true } }, which exempts them from the readonly strip. Measured on a real objectstack dev boot with SharingServicePlugin loaded: all twelve seeded people come back with their unit and manager resolved, and the app's unit rollups work.

sys_business_unit_member is never populated.

The question

Is { isSystem: true } a sanctioned way to write a documented projection column, or does it merely fail to stop it?

Concretely, either answer is actionable and they lead to different code:

  1. If plugin-sharing ever recomputes primary_business_unit_id — on a membership edit, a backfill, a boot-time reconciliation, a future version — then it would recompute from an empty junction and blank all twelve rows. Nothing would error. The demo's unit rollups and any own_and_reports / unit_and_below scope would just go quietly empty. In that case an app seed must write sys_business_unit_member rows with is_primary and let the projection derive, and the doc comment should probably say so in the imperative it already uses.

  2. If the projection is only ever written forward (set when membership changes, never reconciled), then writing it directly is a legitimate shortcut for a seed, and the readonly flag is about the API surface rather than about ownership of the value.

The same question applies to sys_user.manager_id.

Why we cannot answer it ourselves

Neither primary_business_unit_id nor business_unit_member appears anywhere in the installed @objectstack/* tree — the recompute path, if it exists, is in the sharing/enterprise package, which an open-source app does not install. So the risk would first surface in an enterprise deployment: precisely the one where the hierarchy scopes that consume the projection are also the ones doing real work.

If the answer is (1), a note in the seed-loader docs would be worth more than the fix in any one app — writing directory data is the ordinary thing a demo or onboarding seed does, and { isSystem: true } makes every projection column writable without saying that it should not be written.

Recorded app-side as objectstack-ai/duly#74.

Activity

  1. huangyiirene commented on Sep 1, 2026

    @huangyiirene
    Collaborator

    裁决:(1) —— 契约按文档字面成立:投影列一律经 membership 派生,直写是「未被拦住」不是「被批准」(维护者 2026-09-01,总监批 #25)

    项目总监席 · session session_01KGtaLpkW1mycWgkbSb3H6t · 维护者对本批逐字:「同意」。

    1. 契约裁定:sys_user.primary_business_unit_id 与 sys_user.manager_id 是派生投影,唯一正当写法 = 种 sys_business_unit_member 行(带 is_primary)/ 走 membership 编辑,让投影派生;{ isSystem: true } 使写入落地是 readonly 剥除的豁免副作用,⛔ 不构成对直写的批准;
    2. 交付(本仓):seed-loader 文档加一段注记 —— projection 列不得被 seed 直写,点名这两列并给正确种法示例;落 packages/metadata-protocol 的 seed-loader 文档面;
    3. 应用侧:duly 的 seed 改种 junction 行,记 duly#74,归应用车道自理,⛔ 本仓不代修;
    4. ⛔ 不建机器可读的 projection 标记 / 加载器告警机制 —— 一条文档注记先行,第二个实例出现时该机制才有分母,届时回箱;
    5. 裁决依据入册:ADR-0057 addendum D12 的派生语义 + 列文档自己的祈使句(「set it via business-unit membership」)—— declared = enforced,不造第二条真理。

    状态转移(同笔)

    needs-user-decision → pm:queue;priority:p2 domain:services 不动(交付面在 metadata-protocol 文档,由接手席位按车道表核锚)。


    Generated by Claude Code

  2. added
    pm:queueReady for the PM dispatch loop
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Sep 1, 2026
  3. os-warren commented on Sep 1, 2026

    @os-warren
    CollaboratorAuthor

    Closing this — the answer already exists in this repo as a passing test, and my framing was wrong

    I asked this as a contract question and claimed it "cannot be answered from outside the enterprise edition." That was wrong, and the maintainer was right to push back: sys_business_unit_member is a community platform object — it is in PLATFORM_OBJECT_NAMES (packages/spec/src/system/constants/platform-object-names.ts:52), and the table exists in the reporting app's own community-edition database.

    The answer is (1), and packages/qa/dogfood/test/primary-bu-projection.dogfood.test.ts already proves it

    That test boots the showcase stack — community, not enterprise — and asserts exactly the behaviour I was asking about:

    it('sets primary_business_unit_id when a primary member is inserted', …)
    it('follows the primary flag when it moves to another business unit', …)

    So plugin-sharing's hooks do maintain the projection, and they do recompute it as sys_business_unit_member.is_primary moves. The doc comment on the column — "a denormalised projection … Do not edit directly; set it via business-unit membership" — is enforced behaviour, not aspiration.

    Why I got it wrong, since the method is the reusable part

    I grepped node_modules/@objectstack/ in the consuming app for primary_business_unit_id and business_unit_member, got nothing, and inferred "the recompute path must be in a package the open-source app does not install." Two errors compounded:

    1. A published-package tree is not the source tree, and absence from a node_modules grep is not absence from the platform. I should have read this repo — which I had access to — instead of inferring from the consumer's node_modules.
    2. I attached it to the wrong signal. The app's boot prints a hierarchy-security capability warning, and I let that stand in for "anything business-unit-shaped is enterprise." ADR-0057 hierarchy depth scopes are the enterprise capability; the D12 projection is not, and conflating them is what made a checkable fact look unknowable.

    Nothing here needs a platform change. Closing as answered.

    The one thing I would still offer, if a maintainer thinks it is worth a separate card rather than a closing note: { context: { isSystem: true } } exempts a write from the readonly strip, so a seed can write a documented projection column directly and land it — quietly, with no error and no drift until the junction is next touched. That is how the reporting app got into this state. A note in the seed-loader docs saying that a readonly projection is still owned by its maintainer under isSystem would have saved this round-trip. Happy to open that if wanted; not reopening this for it.

    Fixed app-side in objectstack-ai/duly#74.


    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

No one assigned

    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