Skip to content

Seeded contracts are ownerless at the platform level (owner_id null) — nobody, admin included, can edit one #622

Description

@os-zhuang

Found while browser-verifying #602 on a fresh dev stack (objectstack dev --seed-admin, @objectstack/* 17.0.0-rc.1). Pre-existing and unrelated to that change; filed unassigned per Prime Directive #10.

What happens

crm_contract is the only object in the app whose seeded rows carry no platform owner:

crm_account:      9/9   rows have owner_id
crm_contact:      9/9
crm_opportunity: 22/22
crm_quote:        5/5
crm_case:        38/38
crm_task:         7/7
crm_lead:        21/21
crm_contract:     0/4   ← none

The app-level owner lookup is populated on those contracts (it holds the dev admin's user id), so demo_bootstrap did run and believes it claimed them. The platform ownership column it did not set is the one the sharing service reads.

Consequence, as the seeded admin:

PATCH /api/v1/data/crm_account/3H58wnu6PbAnlWkK    → 200
PATCH /api/v1/data/crm_quote/8Nq3pHYJSkbMWWOG      → 200
PATCH /api/v1/data/crm_contract/58hCASGfz7fCn_Zi   → 403
  {"error":"FORBIDDEN: insufficient privileges to update crm_contract 58hCASGfz7fCn_Zi","code":"FORBIDDEN"}

Every seeded contract is read-only for every user. crm_contract is sharingModel: 'private', so an ownerless row admits nobody, and a share can only widen from an owner that isn't there.

Second-order effect (how it surfaced)

With #602 enabling attachments on contracts, the Attachments panel on a demo contract answers 403 on upload, because the platform gates attaching on canEdit(parent):

POST /api/v1/data/sys_attachment {parent_object: "crm_contract", parent_id: "<seeded id>"}
  → 403 ATTACHMENT_PARENT_ACCESS
     "Cannot attach to crm_contract/…: the parent record does not exist or you cannot edit it"

The same call against account / contact / opportunity / quote / case returns 201. So the attachment surface is correct and it is the contract's ownership that is broken — but on demo data the visible symptom will read as "attachments don't work on contracts".

Worth a look while fixing

POST /api/v1/security/explain for {object: crm_contract, operation: update} on that record answers allowed: true, via:

owd_baseline  narrows  record: excluded — "Private baseline admits only the owner"
sharing       widens   record: excluded — "No ownership and no edit/full share grants write on this record"
vama_bypass   widens   "View/Modify All Data bypass held via [admin_full_access] — ownership and sharing checks are skipped"

…while the write path refuses. Whichever of the two is right, they disagree, and explain is the tool an admin would use to debug exactly this. That half may belong upstream — worth confirming before assuming it is ours.

Suggested direction (not a decision)

src/flows/demo-bootstrap.flow.ts stamps fields: { owner: '{firstUser.id}' }. Find out why that lands the platform ownership column on the other six objects but not on crm_contract — the beforeUpdate guard in src/objects/contract.hook.ts (contract_dates_and_terms, which throws on term/date mismatch) is the obvious suspect for a claim pass that partially failed and was swallowed by onError: 'log'. A test that asserts every claimed object comes out of bootstrap with a real owner would keep this closed.

Activity

  1. added
    bugSomething isn't working
    backendServer-side behaviour — hooks, flows, actions
    metadataDeclarative metadata — schema, security posture, UI surfaces
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    pm:queueReady for the PM dispatch loop
    and removed
    backendServer-side behaviour — hooks, flows, actions
    on Aug 2, 2026
  2. self-assigned this
    on Aug 2, 2026
  3. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    认领:PM 循环第 4 轮
    会话:session_019SS7C5SXpniKeCApxgARyf
    分支:claude/issue-622-contract-seed-ownership
    Worktree:hotcrm-issue-622

    本件被提到本轮首位、顶掉了原定的 #613。理由:每一条种子合同对所有人(含管理员)只读,预览时"打开合同改一个字段"直接 403;而 #602 刚开的附件面板在合同上会回 ATTACHMENT_PARENT_ACCESS,症状看起来像"附件坏了",实际是所有权坏了——双重误导,对客户预览的伤害是即时可见的。#613 自述"不紧急、有自愈窗口",让位。

    issue 里的 security/explain 与写入路径互相矛盾那一段(explain 答 allowed: true,写入 403),请先判定它属于本仓库还是平台侧:若是平台侧,另开 upstream issue,不要在本 PR 里改平台;若是本仓库的元数据导致,一并修。

    contract_dates_and_terms 那个 beforeUpdate 守卫是 issue 点名的嫌疑对象,但那只是线索不是结论——请以实测为准找出真正原因。issue 建议的"断言每个被 bootstrap 认领的对象都带真实 owner"的测试请务必补上,否则同类问题下次照样静默。

    同批的另外两件:#614(src/dashboards/sales.dashboard.ts + 分析数据集)、#598(lead.object.ts / 线索 hook / lead-conversion.flow.ts)。请勿改 src/data/index.ts 之外的共享文件时不打招呼;另注意 content/docs/guides/index*.mdx 与 meta*.json 是三件共用的桶文件,只有真的新增文档页才碰。


    Generated by Claude Code

  4. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    PM 复核:通过,已入队合并 — #632

    核验依据(以 GitHub 为准):9 项检查全绿(含 Playwright 的冷库 e2e);5 个文件全部在范围内(changeset、demo-bootstrap.flow.ts、src/data/index.ts 仅加注释、新 runtime 用例、runtime-coverage.test.ts 把 demo_bootstrap 移出 PENDING_FLOWS 的三行——该 ratchet 只减不增,方向正确)。

    我在认领里写的"contract_dates_and_terms 只是嫌疑不是结论、请以实测为准"得到了兑现,而且结论是它无罪:那个守卫在种子行上从不抛错,而且认领失败的话应用层 owner 也会是空的,与观察到的现象不符。真正的缺陷更深:demo_bootstrap 只盖应用自建的 owner,从不盖共享服务唯一读取的平台列 owner_id。

    三点判断我认为都站得住:

    1. 为什么记录会那样进库——seed 写入跑在 { isSystem: true } 下,按 seeder 自己写明的契约这会关闭 owner_id 自动注入("seeds either declare those fields explicitly per record"),而本仓库的 seed 声明不了它:cel\os.user.id`在 seed 阶段不解析,启动日志对这些字段正好打Unknown variable: os`(即 Every owner field's celos.user.id`` default fails to evaluate — 127 warnings on one boot, and the default never applies #620)。所以平台层所有权本来就是这个 flow 的职责,没有别人的。
    2. 为什么这是终态——扫描自身的过滤条件是 owner != null,一条"人看着已认领、访问控制眼里无主"的记录再也不会被扫第二眼,产品内没有任何恢复路径。修法是每个对象扫两遍(缺 owner 一遍、缺 owner_id 一遍),已经卡住的组织下次扫描自愈,不需要重置数据库。这比只改种子数据正确得多——后者只能救新库。
    3. 用两个单字段过滤而不是一个 $or,理由是 { field: null } 是这些 sweep 对真实 driver 用过的唯一过滤形状。在"能跑通"和"用已验证过的形状"之间选后者,是对的。

    证据链完整:反向验证(改回单列认领,3 条平台所有权断言如期失败)、真机复现再修复(PATCH → 403 变 200,附件 POST 从 403 ATTACHMENT_PARENT_ACCESS 变成 400 File is required——已过 canEdit(parent) 门禁,剩下的只是没带文件体)、八个被认领对象全量核对(合同 4/4)。

    security/explain 与写入路径矛盾那一半,按我的要求判定为平台侧并开了 objectstack#4647,没有从这边改平台代码。判定理由成立:该逻辑完全在 plugin-security 的 explain 与引擎写入路径之间,没有任何本仓库元数据参与。

    关于代理主动提出、留给我定的一件事——Unknown variable: os 不再单独开第二个上游 issue,我同意:#620 已经在跟踪它,重复开等于制造噪音。我会把这里查明的机制补充到 #620 上。


    Generated by Claude Code

  5. added
    priority:p0Critical: blocker, must ship before MVP
    and removed on Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingmetadataDeclarative metadata — schema, security posture, UI surfacespm:dispatchedDispatched to a dev agent by /pm-dispatchpm:queueReady for the PM dispatch looppriority:p0Critical: blocker, must ship before MVP

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions