From bb70aaf969bcf4275301c021f86de813c59fcb96 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 22:32:38 +0000 Subject: [PATCH 1/3] fix(flows): retire the demo_bootstrap sweep, the platform claims seeded rows once demo_bootstrap was a */10 schedule flow that re-filtered twelve objects for ownerless rows forever. On the 17.6.0 pin the platform re-runs its seed-ownership claim on app:seeded (objectstack#17872): a fresh dev boot with the sweep unable to fire leaves zero ownerless rows on all twelve objects, so the sweep is removed with its registration, its saas-composition exclusion, its exemption-register entry, its test pins and its docs rows. The #702 forecast ordering cases now run the platform's real claimSeedOwnership in both orders instead of the retired flow. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01ER8ntXZhYebyQ66aXWdjfT --- .changeset/1892-demo-bootstrap-run-once.md | 31 ++ content/docs/administration/automation.mdx | 9 +- .../administration/automation.zh-Hans.mdx | 9 +- .../administration/automation.zh-Hant.mdx | 9 +- docs/MAINTENANCE.md | 10 +- docs/architecture/module-split-inventory.json | 16 +- docs/feature-inventory.md | 2 +- e2e/fixtures.ts | 5 +- e2e/opportunity-lifecycle.spec.ts | 3 +- objectstack.composition.ts | 34 +- objectstack.config.ts | 33 +- scripts/demo-staff.ts | 17 +- ...billing-handoff-contract-activated.flow.ts | 2 +- src/sales/data/_shared.ts | 3 +- src/sales/data/activity.seed.ts | 8 +- src/sales/data/forecast.seed.ts | 7 +- src/sales/data/index.ts | 7 +- .../flows/billing-handoff-closed-won.flow.ts | 2 +- src/sales/flows/demo-bootstrap.flow.ts | 292 ---------- src/sales/flows/index.ts | 7 +- src/sales/flows/opportunity-won-alert.flow.ts | 2 +- src/sales/objects/account.hook.ts | 7 +- src/sales/objects/forecast.hook.ts | 7 +- src/sales/objects/lead.hook.ts | 4 +- src/sales/sharing/demo-staffing.ts | 20 +- test/actions-flows-integrity.test.ts | 45 +- test/activity-seed-coverage.test.ts | 23 +- test/automation-docs-coverage.test.ts | 9 - test/flow-scheduled-org-partition.test.ts | 84 +-- test/flow-scheduled.test.ts | 505 ++---------------- test/flow-variable-conditions.test.ts | 22 +- test/forecast-seeds.test.ts | 18 +- test/hooks-runtime-sales.test.ts | 7 +- test/ownership-model.test.ts | 2 +- test/saas-composition.test.ts | 162 +----- 35 files changed, 233 insertions(+), 1190 deletions(-) create mode 100644 .changeset/1892-demo-bootstrap-run-once.md delete mode 100644 src/sales/flows/demo-bootstrap.flow.ts diff --git a/.changeset/1892-demo-bootstrap-run-once.md b/.changeset/1892-demo-bootstrap-run-once.md new file mode 100644 index 000000000..e8db7b5f0 --- /dev/null +++ b/.changeset/1892-demo-bootstrap-run-once.md @@ -0,0 +1,31 @@ +--- +'hotcrm': patch +--- + +Retire the `Demo Bootstrap` flow: seeded records get their owner from the platform, once + +HotCRM shipped a scheduled flow, **Demo Bootstrap** (`demo_bootstrap`), that ran every ten +minutes forever in every tenant. Each run filtered twelve objects for records with no owner +and gave them to the first user. A seed cannot name a user, so seeded demo records arrive +with no owner, and the flow existed to fix that after the fact. On a production tenant it ran +1,776 times in 13 days, took up to 26 minutes, and changed nothing after its first pass. + +From ObjectStack 17.6.0 the platform does this itself, once: when the seed data finishes +loading, it hands every seeded record that has no owner to the first administrator. A fresh +`pnpm dev` boot on this release, with the flow kept from running, leaves no ownerless record +on any of the twelve objects the flow used to cover. So the flow is removed. + +**What changes for you:** + +- A fresh install no longer carries a `flow-schedule:demo_bootstrap` job (`*/10 * * * *`) in + `sys_job`, and **Flow Runs** no longer shows a Demo Bootstrap run every ten minutes. +- HotCRM now ships 30 flows, eight of them scheduled. The admin *Automation* page says so in + all three locales. +- Seeded demo records are still owned by the first administrator, as before. `pnpm demo:staff` + works unchanged. +- The `saas` composition no longer differs from the community app in its flows. It already + left this flow out. + +**Upgrading an existing install:** the platform leaves the old +`flow-schedule:demo_bootstrap` row in `sys_job`, still marked active, when the flow disappears. +Nothing runs it any more. An operator who wants the table clean can delete that one row. diff --git a/content/docs/administration/automation.mdx b/content/docs/administration/automation.mdx index 7053983a7..d14ee8083 100644 --- a/content/docs/administration/automation.mdx +++ b/content/docs/administration/automation.mdx @@ -50,7 +50,7 @@ A flow fires one of three ways, set by its start node: > **Auto-launch needs the `triggers` capability.** Record-change and scheduled flows only fire when the stack's `requires` list includes `triggers` — it installs the record-change + schedule trigger providers (schedule triggers also use the job service). Screen flows are always launched manually. -**Built-in flows in HotCRM** (31). Each row carries the flow's own label — the name listed in **Studio → Automation → Flows**, and the name you pick from in **Studio → Developer → Flow Runs**, so a run you are chasing can be looked up here verbatim: +**Built-in flows in HotCRM** (30). Each row carries the flow's own label — the name listed in **Studio → Automation → Flows**, and the name you pick from in **Studio → Developer → Flow Runs**, so a run you are chasing can be looked up here verbatim: | Flow | Trigger | What it does | | --- | --- | --- | @@ -84,11 +84,10 @@ A flow fires one of three ways, set by its start node: | **Contract Renewal Reminder** | Schedule (daily 8 AM) | Open renewal tasks/opportunities for contracts nearing their `end_date` | | **Case SLA Monitor** | Schedule (hourly) | Flag and escalate open cases past their SLA due date | | **Task Due Reminder** | Schedule (hourly) | Notify owners of tasks whose reminder time has arrived | -| **Demo Bootstrap** | Schedule (every 10 min) | Claim ownerless seeded records for the first user by stamping `owner_id` | Two entries carry an **(on create)** twin. Record-change flows subscribe to one trigger type each — `record-after-create` *or* `record-after-update` — so automation that has to catch both a newly created record and a later edit is authored as a pair of flows with the same condition. They are separate rows here because they are separate runs in **Flow Runs**. -**Demo Bootstrap** is scaffolding, not business automation. Seed writes bypass the security middleware, so seeded rows arrive with no owner — which empties every *My …* view and sends owner-addressed notifications to nobody. This sweep stamps them onto the first user. On an org whose records already have owners it selects nothing and does nothing, every ten minutes. +Seeded demo records get their owner from the platform, not from a flow: when the seed data finishes loading, the platform hands every seeded record that has no owner to the first administrator. HotCRM's former **Demo Bootstrap** flow, which re-checked for ownerless records every ten minutes, has been retired. Notifications inside flows are delivered by the **`notify` node** (inbox + email via the messaging service) — not the legacy `script`/email step, which is a no-op in 7.4. @@ -96,9 +95,9 @@ See [Customization › Extending Objects](/docs/customization/extending-objects) ## Scheduled automation -Time-based automation is implemented as **scheduled flows** — flows whose start node carries a cron schedule. The nine `Schedule` rows above are the complete set; there is no separate scheduled-job metadata to look for. They run via the job service, so the `triggers` capability is paired with `job` (both ship in the default slate). +Time-based automation is implemented as **scheduled flows** — flows whose start node carries a cron schedule. The eight `Schedule` rows above are the complete set; there is no separate scheduled-job metadata to look for. They run via the job service, so the `triggers` capability is paired with `job` (both ship in the default slate). -> **From ObjectStack 17.5.0, scheduled flows are off until the deployment turns them on.** The platform runs package-authored scheduled work only when the deployment sets `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true` (`1`, `on` and `yes` also count). Without it, none of the nine flows above runs — no contract or quote expiry, no SLA monitor, no reminders, no forecast snapshots — and `os doctor` prints the effective value. Under the `isolated` tenancy posture a scheduled flow must also name the organization it acts as; these nine do not, so they are not armed there. +> **From ObjectStack 17.5.0, scheduled flows are off until the deployment turns them on.** The platform runs package-authored scheduled work only when the deployment sets `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true` (`1`, `on` and `yes` also count). Without it, none of the eight flows above runs — no contract or quote expiry, no SLA monitor, no reminders, no forecast snapshots — and `os doctor` prints the effective value. Under the `isolated` tenancy posture a scheduled flow must also name the organization it acts as; these eight do not, so they are not armed there. Date-driven field logic that needs no orchestration — defaulting a quote's expiration date, freezing an expired/accepted quote, deriving a forecast period — lives in lightweight **object hooks** (`beforeInsert` / `beforeUpdate`) rather than a scheduled sweep. diff --git a/content/docs/administration/automation.zh-Hans.mdx b/content/docs/administration/automation.zh-Hans.mdx index bf854e27a..07699cd4d 100644 --- a/content/docs/administration/automation.zh-Hans.mdx +++ b/content/docs/administration/automation.zh-Hans.mdx @@ -50,7 +50,7 @@ description: 验证规则、流程、计划作业与审批 —— 无需你动 > **自动触发需要 `triggers` 能力。** 记录变更与计划类流程,只有当 stack 的 `requires` 列表包含 `triggers` 时才会触发 —— 它会安装记录变更与计划触发器提供方(计划触发器还依赖 job 服务)。屏幕类流程始终为手动启动。 -**HotCRM 中的内置流程**(31 个)。每一行用的都是流程自身的标签 —— 也就是 **Studio → 自动化 → 流程** 里列出、并在 **Studio → 开发者 → 流程运行记录** 里供你挑选的那个名字,因此一次运行可以逐字回到这张表里查: +**HotCRM 中的内置流程**(30 个)。每一行用的都是流程自身的标签 —— 也就是 **Studio → 自动化 → 流程** 里列出、并在 **Studio → 开发者 → 流程运行记录** 里供你挑选的那个名字,因此一次运行可以逐字回到这张表里查: | 流程 | 触发 | 它做什么 | | --- | --- | --- | @@ -84,11 +84,10 @@ description: 验证规则、流程、计划作业与审批 —— 无需你动 | **合同续约提醒** | 计划(每日 8 点) | 为临近 `end_date` 的合同开出续约任务/商机 | | **工单 SLA 监控** | 计划(每小时) | 标记并升级超过 SLA 到期时间的未结工单 | | **任务到期提醒** | 计划(每小时) | 通知负责人:其任务的提醒时间已到 | -| **演示数据引导** | 计划(每 10 分钟) | 为首位用户认领无归属的种子记录,写入其 `owner_id` | 有两组条目带 **(新建时)** 孪生流程。记录变更类流程每条只订阅一种触发类型 —— `record-after-create` *或* `record-after-update` —— 所以「新建与后续修改都要管」的自动化会被写成条件相同的一对流程。它们在这里各占一行,因为在 **Flow Runs** 里它们就是两次独立运行。 -**演示数据引导** 属于脚手架,而非业务自动化。种子写入会绕过安全中间件,因此种子记录落地时没有负责人 —— 这会让所有「我的 …」视图变空,并让所有寄给负责人的通知无人可达。这条清扫把它们写到首位用户名下。在记录本就有负责人的组织里,它每十分钟选中零条记录、什么也不做。 +演示种子记录的负责人由平台指定,而不是由流程指定:种子数据写入完成时,平台会把所有没有负责人的种子记录交给首位管理员。HotCRM 以前每十分钟检查一次无主记录的 **演示数据引导** 流程已经退役。 流程内的通知由 **`notify` 节点** 投递(通过消息服务发送站内信 + 邮件)—— 而非旧的 `script`/邮件步骤(在 7.4 中已是空操作)。 @@ -96,9 +95,9 @@ description: 验证规则、流程、计划作业与审批 —— 无需你动 ## 计划类自动化 -基于时间的自动化以 **计划类流程** 实现 —— 即起始节点带 cron 计划的流程。上表中九个 `计划` 行就是全部;不存在另一套需要另找的「计划作业」元数据。它们通过 job 服务运行,因此 `triggers` 能力需与 `job` 搭配(两者都在默认能力集中)。 +基于时间的自动化以 **计划类流程** 实现 —— 即起始节点带 cron 计划的流程。上表中八个 `计划` 行就是全部;不存在另一套需要另找的「计划作业」元数据。它们通过 job 服务运行,因此 `triggers` 能力需与 `job` 搭配(两者都在默认能力集中)。 -> **自 ObjectStack 17.5.0 起,计划类流程默认关闭,需由部署方开启。** 平台只在部署设置了 `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true`(`1`、`on`、`yes` 同样有效)时,才运行包内编写的计划类工作。不设置时,上表九个计划类流程一个都不会运行 —— 没有合同与报价的自动过期、没有 SLA 监控、没有提醒、没有预测快照;`os doctor` 会打印当前生效的取值。在 `isolated` 租户隔离模式下,计划类流程还必须声明它代表哪个组织运行;这九个流程都没有声明,因此在该模式下不会被挂载。 +> **自 ObjectStack 17.5.0 起,计划类流程默认关闭,需由部署方开启。** 平台只在部署设置了 `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true`(`1`、`on`、`yes` 同样有效)时,才运行包内编写的计划类工作。不设置时,上表八个计划类流程一个都不会运行 —— 没有合同与报价的自动过期、没有 SLA 监控、没有提醒、没有预测快照;`os doctor` 会打印当前生效的取值。在 `isolated` 租户隔离模式下,计划类流程还必须声明它代表哪个组织运行;这八个流程都没有声明,因此在该模式下不会被挂载。 无需编排的日期驱动字段逻辑 —— 例如为报价设置默认过期日、冻结已过期/已接受的报价、推导预测周期 —— 放在轻量的 **对象钩子**(`beforeInsert` / `beforeUpdate`)中,而非计划清扫。 diff --git a/content/docs/administration/automation.zh-Hant.mdx b/content/docs/administration/automation.zh-Hant.mdx index 5cd22817b..e7c8547db 100644 --- a/content/docs/administration/automation.zh-Hant.mdx +++ b/content/docs/administration/automation.zh-Hant.mdx @@ -52,7 +52,7 @@ description: 驗證規則、流程、排程作業與審批 —— 無需你動 > **自動觸發需要 `triggers` 能力。** 記錄變更與排程類流程,只有當 stack 的 `requires` 列表包含 `triggers` 時才會觸發 —— 它會安裝記錄變更與排程觸發器提供方(排程觸發器還依賴 job 服務)。螢幕類流程始終為手動啟動。 -**HotCRM 中的內建流程**(31 個)。每一行用的都是流程自身的標籤 —— 也就是 **Studio → Automation → Flows** 裡列出、並在 **Studio → Developer → Flow Runs** 裡供你挑選的那個名字,因此一次執行可以逐字回到這張表裡查: +**HotCRM 中的內建流程**(30 個)。每一行用的都是流程自身的標籤 —— 也就是 **Studio → Automation → Flows** 裡列出、並在 **Studio → Developer → Flow Runs** 裡供你挑選的那個名字,因此一次執行可以逐字回到這張表裡查: | 流程 | 觸發 | 它做什麼 | | --- | --- | --- | @@ -86,11 +86,10 @@ description: 驗證規則、流程、排程作業與審批 —— 無需你動 | **合約續約提醒** | 排程(每日 8 點) | 為臨近 `end_date` 的合約開出續約任務/商機 | | **工單 SLA 監控** | 排程(每小時) | 標記並升級超過 SLA 到期時間的未結工單 | | **任務到期提醒** | 排程(每小時) | 通知負責人:其任務的提醒時間已到 | -| **展示資料啟動** | 排程(每 10 分鐘) | 為首位使用者認領無歸屬的種子記錄,寫入其 `owner_id` | 有兩組條目帶 **(新建時)** 孿生流程。記錄變更類流程每條只訂閱一種觸發類型 —— `record-after-create` *或* `record-after-update` —— 所以「新建與後續修改都要管」的自動化會被寫成條件相同的一對流程。它們在這裡各占一行,因為在 **Flow Runs** 裡它們就是兩次獨立執行。 -**展示資料啟動** 屬於腳手架,而非業務自動化。種子寫入會繞過安全中介層,因此種子記錄落地時沒有負責人 —— 這會讓所有「我的 …」檢視變空,並讓所有寄給負責人的通知無人可達。這條清掃把它們寫到首位使用者名下。在記錄本就有負責人的組織裡,它每十分鐘選中零條記錄、什麼也不做。 +展示種子記錄的負責人由平台指定,而不是由流程指定:種子資料寫入完成時,平台會把所有沒有負責人的種子記錄交給首位管理員。HotCRM 以前每十分鐘檢查一次無主記錄的 **Demo Bootstrap** 流程已經退役。 流程內的通知由 **`notify` 節點** 投遞(透過訊息服務傳送站內信 + 郵件)—— 而非舊的 `script`/郵件步驟(在 7.4 中已是空操作)。 @@ -98,9 +97,9 @@ description: 驗證規則、流程、排程作業與審批 —— 無需你動 ## 排程類自動化 -基於時間的自動化以 **排程類流程** 實作 —— 即起始節點帶 cron 排程的流程。上表中九個 `排程` 行就是全部;不存在另一套需要另找的「排程作業」中繼資料。它們透過 job 服務執行,因此 `triggers` 能力需與 `job` 搭配(兩者都在預設能力集中)。 +基於時間的自動化以 **排程類流程** 實作 —— 即起始節點帶 cron 排程的流程。上表中八個 `排程` 行就是全部;不存在另一套需要另找的「排程作業」中繼資料。它們透過 job 服務執行,因此 `triggers` 能力需與 `job` 搭配(兩者都在預設能力集中)。 -> **自 ObjectStack 17.5.0 起,排程類流程預設關閉,需由部署方開啟。** 平台只在部署設定了 `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true`(`1`、`on`、`yes` 同樣有效)時,才執行套件內撰寫的排程類工作。未設定時,上表九個排程類流程一個都不會執行 —— 沒有合約與報價的自動到期、沒有 SLA 監控、沒有提醒、沒有預測快照;`os doctor` 會列印目前生效的取值。在 `isolated` 租戶隔離模式下,排程類流程還必須宣告它代表哪個組織執行;這九個流程都沒有宣告,因此在該模式下不會被掛載。 +> **自 ObjectStack 17.5.0 起,排程類流程預設關閉,需由部署方開啟。** 平台只在部署設定了 `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true`(`1`、`on`、`yes` 同樣有效)時,才執行套件內撰寫的排程類工作。未設定時,上表八個排程類流程一個都不會執行 —— 沒有合約與報價的自動到期、沒有 SLA 監控、沒有提醒、沒有預測快照;`os doctor` 會列印目前生效的取值。在 `isolated` 租戶隔離模式下,排程類流程還必須宣告它代表哪個組織執行;這八個流程都沒有宣告,因此在該模式下不會被掛載。 無需編排的日期驅動欄位邏輯 —— 例如為報價設定預設過期日、凍結已過期/已接受的報價、推導預測週期 —— 放在輕量的 **物件鉤子**(`beforeInsert` / `beforeUpdate`)中,而非排程清掃。 diff --git a/docs/MAINTENANCE.md b/docs/MAINTENANCE.md index a49e20dfc..b62c93c6d 100644 --- a/docs/MAINTENANCE.md +++ b/docs/MAINTENANCE.md @@ -286,8 +286,8 @@ After any platform upgrade, or whenever Studio shows validation banners: ### 4.1 Staffing the demo org (`pnpm demo:staff`) A reseeded org has records but no PEOPLE. On a fresh install exactly one user -exists (the dev admin), `demo_bootstrap` claims every seeded record for them, -and `sys_user_position` is empty — so every position-based sharing rule this app +exists (the dev admin), the platform hands every seeded record to them when +the seed settles (its seed-ownership claim; no HotCRM flow is involved), and `sys_user_position` is empty — so every position-based sharing rule this app ships grants nobody anything, and `opportunity_approval`'s `manager_review` node opens with an empty approver slate while `lockRecord` holds the record ([#640]). @@ -333,10 +333,10 @@ Three things worth knowing before changing any of it: - **The reps must not own the ACCOUNTS — but they must own their pipeline.** `crm_account` is `private`, so the OWD baseline already admits a record's owner: a share to the owner demonstrates nothing, and account ownership - therefore stays with `demo_bootstrap`'s first user. The script still exits + therefore stays with the dev admin the platform's claim gave it to. The script still exits non-zero if a demo user turns out to own a seeded account. - Every OTHER routed object is the opposite case ([#1759]). `demo_bootstrap` - claims them all for the dev admin, which an API key never notices — it runs as + Every OTHER routed object is the opposite case ([#1759]). The platform's + seed-ownership claim gives them all to the dev admin, which an API key never notices — it runs as the human, so `viewAllRecords` applies — while an agent connecting over OAuth sees only what its user owns or holds a share on (the ceiling in [objectstack#16549], which `viewAllRecords` deliberately does not lift). So a diff --git a/docs/architecture/module-split-inventory.json b/docs/architecture/module-split-inventory.json index 61cf9ade8..2431973e7 100644 --- a/docs/architecture/module-split-inventory.json +++ b/docs/architecture/module-split-inventory.json @@ -22,6 +22,13 @@ "change": "Re-pointed every files[].path to the ADR-0130 package layout: each authored file moved from src// into src///, where is this row's own module folded onto the four directories the 2026-09-14 rulings settled on (app + core + sales + activity -> sales, cpq -> revenue, service -> service, marketing -> marketing). Six rows name files that were SPLIT rather than moved and now point at the half that kept the row's module: campaign.hook.ts (the two hooks on crm_lead and crm_opportunity left for src/sales/objects/), global.actions.ts (renamed src/sales/actions/activity-actions.ts; the crm_case triple left for src/service/actions/case-activity.actions.ts), billing-handoff.flow.ts (the contract_activated flow left for src/revenue/flows/), and the three *.seed.ts family files (split by object ownership). src/hooks/index.ts was retired outright — its row carries retired_by_1905 rather than a path that no longer resolves. src/docs/*.md did NOT move: `objectstack build` reads package docs from /src/docs and no other path (ADR-0046), so moving them silently drops the artifact's docs[] on an exit-0 build.", "figures_left_as_measured": "Untouched, per do_not_hand_patch: totals, ratchet, edges, modules[] and every files[].authored_tokens are still the one measurement taken at measured_at.commit. The modules[] roster still names the seven-module plan the 2026-09-14 rulings superseded; that is history and is read as history — the current shape is module-split-plan.md 'Decisions recorded (2026-09-14)', items 4-9. Only the path strings moved, because a path is a pointer at a live file and the files moved.", "roster_drift_measured_at_this_date": "files[] held 170 rows before this edit and holds 170 after. Against the tree at this date it is short by 35 files that postdate measured_at.commit and were never added: src/sales/flows/_guarded-iteration.ts, src/marketing/flows/campaign-member-enroll.flow.ts, src/service/flows/case-escalation-stamp.flow.ts, and the 32 per-locale translation modules under src/sales/translations/{en,es-ES,ja-JP,zh-CN}/. They are left out for the same reason the figures are left alone: this file is evidence for one measurement, not an index of the tree." + }, + { + "date": "2026-10-02", + "issue": 1892, + "change": "Removed the files[] row for src/sales/flows/demo-bootstrap.flow.ts (kind 'flow', module app.objectstack.hotcrm, 860 authored tokens). The file existed at measured_at.commit and was deleted by #1892, which retired the demo_bootstrap ownership sweep once the platform's seed-settle ownership claim (objectstack#17872, in the 17.6.0 pin) was measured leaving no seeded owner-scoped row ownerless.", + "figures_left_as_measured": "Untouched, per do_not_hand_patch: totals, ratchet, edges (the demo_bootstrap entries included), modules[] and every other files[].authored_tokens are still the one measurement taken at measured_at.commit. Only the row that pointed at a deleted file was removed, as #1698 did for case-csat-followup.flow.ts.", + "roster_drift_measured_at_this_date": "files[] held 170 rows before this edit and holds 169 after. The 35 files the 2026-09-14 entry lists as postdating measured_at.commit are still left out, for the reason it gives." } ], "measured_at": { @@ -2292,15 +2299,6 @@ "ratcheted": true, "authored_tokens": 1637 }, - { - "path": "src/sales/flows/demo-bootstrap.flow.ts", - "kind": "flow", - "module": "app.objectstack.hotcrm", - "anchor_object": null, - "reason": "demo sweep spanning twelve objects in every module", - "ratcheted": true, - "authored_tokens": 860 - }, { "path": "src/sales/flows/forecast-snapshot.flow.ts", "kind": "flow", diff --git a/docs/feature-inventory.md b/docs/feature-inventory.md index 6b48a1d6e..f4c9d53c2 100644 --- a/docs/feature-inventory.md +++ b/docs/feature-inventory.md @@ -257,7 +257,7 @@ | ADM-008 | 多语言 | en/zh-CN/ja-JP/es-ES 四语言全量翻译:对象/字段/选项/视图/表单分区/动作/仪表板/页面/导航/通用文案 | `src/sales/translations/` | | ADM-009 | 分析语义层 | 9 个 dataset(ADR-0021)承载全部仪表板与报表的维度/度量定义,含派生比率与跨对象维度 | `src/*/datasets/` | | ADM-010 | 种子数据 | 19 个幂等 upsert 数据集(客户 9/线索 21/商机 23/工单 8/事件 27/活动成员 ~52 等),金额自明细派生、区域与流失时钟按演示可观测性设计 | `src/*/data/` | -| ADM-011 | 演示数据认领 | 每 10 分钟扫描:把 12 个对象的无主种子记录认领给首个真实用户(种子无法命名 owner 的补偿机制) | `src/sales/flows/demo-bootstrap.flow.ts` | +| ADM-011 | 演示数据认领 | **已移除 (#1892)**:曾以 `demo_bootstrap` 计划流程每 10 分钟扫描,把 12 个对象的无主种子记录认领给首个真实用户。平台(17.6.0 起)在种子写入完成时自行把无主种子记录交给首位管理员,该流程随之退役,应用内不再有对应功能 | 已删除 | | ADM-012 | AI Live Data 技能 | 每次先读取当前活 schema 再查询——管理员新加字段后 AI 立即可用(平台 `ask` 助手挂载,skills-only 架构) | `src/sales/skills/live-data.skill.ts` | | ADM-013 | 应用内规则手册 | 4 篇随包文档:总览/销售规则(路由、审批阈值、停滞窗口)/服务规则(SLA、升级)/管理手册(岗位、共享、12 个自动化旋钮) | `src/docs/` | | ADM-014 | 字段历史跟踪 | 各对象关键字段 `trackHistory`(owner、状态/阶段/优先级、金额/营收等)驱动记录页 History 审计流 | `src/*/objects/*.object.ts` | diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 6d364b2ec..6c5c8ba0a 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -70,8 +70,9 @@ import { TOKEN_ENV, USER_ID_ENV } from './global-setup'; * minutes. * * Creating the records under test removes the question. A row this account - * inserts carries its `owner_id` from the platform's own stamp, no sweep ever - * selects it (`demo_bootstrap` looks for `owner_id: null`), and `demo:staff` + * inserts carries its `owner_id` from the platform's own stamp, no ownership + * claim ever selects it (the platform's seed-ownership claim looks for + * `owner_id: null`, as the retired `demo_bootstrap` sweep did), and `demo:staff` * re-evaluating every sharing rule cannot take it away. The seeds are backdrop * now: the suite runs against a long-lived dev server, a staffed org, or a cold * CI database identically. diff --git a/e2e/opportunity-lifecycle.spec.ts b/e2e/opportunity-lifecycle.spec.ts index 0c3bbb2ec..519bf20ab 100644 --- a/e2e/opportunity-lifecycle.spec.ts +++ b/e2e/opportunity-lifecycle.spec.ts @@ -24,7 +24,8 @@ import type { APIRequestContext } from '@playwright/test'; * The parent account comes from the `account` fixture, which creates one owned * by this caller. It used to be the first SEEDED account, which made every test * here depend on the demo book still being owned by nobody (#665, #669) — a - * condition `demo_bootstrap` removes within a minute of a `pnpm dev` boot. The + * condition the platform's seed-ownership claim removes within a minute of a + * `pnpm dev` boot (the retired `demo_bootstrap` sweep did before #1892). The * hook under test does not care whose account the deal hangs off, so nothing * about what these assertions prove changed. */ diff --git a/objectstack.composition.ts b/objectstack.composition.ts index f6f183722..61b737cc8 100644 --- a/objectstack.composition.ts +++ b/objectstack.composition.ts @@ -98,7 +98,7 @@ import { campaignHook, campaignMemberHook } from './src/marketing/objects/hooks. // Flows — the registration order the single `allFlows` array used to hold. import { - ContactWelcomeFlow, DemoBootstrapFlow, ForecastSnapshotFlow, LeadAssignmentFlow, + ContactWelcomeFlow, ForecastSnapshotFlow, LeadAssignmentFlow, AccountApprovalFlow, LeadConversionFlow, LeadConversionApprovalFlow, OpportunityApprovalFlow, OpportunityApprovalOnCreateFlow, @@ -221,7 +221,6 @@ export const allFlows = [ LeadConversionFlow, LeadConversionApprovalFlow, ScheduleFollowUpFlow, - DemoBootstrapFlow, OpportunityApprovalFlow, OpportunityApprovalOnCreateFlow, QuoteGenerationFlow, @@ -344,21 +343,24 @@ export { CrmPositions }; * id), and `cel\`os.user.id\`` inside a seed evaluates to nothing. The id does * not exist until first boot. * - * The `demo_bootstrap` scheduled flow (`src/sales/flows/demo-bootstrap.flow.ts`) - * does it at the only moment it can: once the first real user exists, its - * periodic sweep claims every ownerless seeded record for that user. + * The PLATFORM does it, at the only moment it can: when the seed settles, + * `@objectstack/plugin-security` re-runs its seed-ownership claim on + * `app:seeded` and hands every ownerless row of every object carrying + * `owner_id` to the first platform administrator (objectstack#17872, in the + * 17.6.0 pin). No HotCRM flow is involved. The `demo_bootstrap` sweep that used + * to do this every ten minutes was retired once a fresh boot measured that + * claim leaving all twelve seeded owner-scoped objects at zero ownerless rows + * with the sweep disabled (#1892). * - * That sweep owns the app's ONE ownership column, `owner_id` (#548 retired the - * app-authored `owner` lookup that used to sit beside it — the #622 split). - * Seed writes run under `{ isSystem: true }`, which short-circuits the security - * middleware, so its insert-time auto-stamp of `owner_id` never fires — "seeds - * either declare those fields explicitly per record" — and per the paragraph - * above these seeds cannot declare it. So a seeded row reaches the database - * owned by nobody at the PLATFORM level (`owner_id` null), and under - * `sharingModel: 'private'` such a row is editable by no one at all, admin - * included. Nothing here should grow an `owner_id` seed value to paper over - * that: the sweep is the mechanism, and `test/flow-scheduled.test.ts` holds it - * to leaving no claimed object ownerless. + * `owner_id` is the app's ONE ownership column (#548 retired the app-authored + * `owner` lookup that used to sit beside it — the #622 split). Seed writes run + * under `{ isSystem: true }`, which short-circuits the security middleware, so + * its insert-time auto-stamp of `owner_id` never fires — "seeds either declare + * those fields explicitly per record" — and per the paragraph above these seeds + * cannot declare it. So a seeded row reaches the database owned by nobody + * (`owner_id` null) until that claim runs. Nothing here should grow an + * `owner_id` seed value to paper over that: the platform claim is the + * mechanism. */ /** diff --git a/objectstack.config.ts b/objectstack.config.ts index 0984fd9e4..7c3384f44 100644 --- a/objectstack.config.ts +++ b/objectstack.config.ts @@ -15,7 +15,6 @@ import { } from './objectstack.composition.js'; import { SystemAdminProfile, TenantAdminProfile } from './objectstack.composition.js'; import { resolveComposition } from './src/sales/data/index.js'; -import { DemoBootstrapFlow } from './src/sales/flows/index.js'; // ─── Which SHAPE of HotCRM this build assembles (#1361) ─────────────────── // @@ -34,26 +33,17 @@ import { DemoBootstrapFlow } from './src/sales/flows/index.js'; // but it has no per-family or per-tenant selection and none is chartered — so // WHAT gets replayed is decided once, here, for every tenant alike. // -// Three things change, and nothing else. There is no runtime branch anywhere in +// Two things change, and nothing else. There is no runtime branch anywhere in // `src/`, and no enterprise package is imported: an artifact built either way -// runs on the community runtime. +// runs on the community runtime. Both shapes register the same flows: the one +// flow the SaaS shape used to drop, the `demo_bootstrap` ownership sweep, is +// retired from the app (#1892) — the platform claims seeded rows itself. // // 1. `data` — the catalogue family only. See `SaasTenantSeedData`. -// 2. `flows` — `demo_bootstrap` is dropped. It is a DEMO sweep (its own header -// says so) and under the wall it is actively wrong, not merely useless: it -// runs `runAs: 'system'`, and a system context is the one context the -// organization predicate does not apply to. Measured on a real engine under -// `OS_TENANCY_POSTURE=isolated` — the sweep's own shape, a system-context -// select of ownerless rows followed by an owner stamp, sees rows in EVERY -// organization and writes org A's first user onto org B's row. That is an -// identity crossing the wall. `test/saas-composition.test.ts` reproduces it -// rather than asserting it in prose. (It is also redundant in this shape: -// the catalogue's `crm_product` declares no `owner_id`, so a catalog-only -// tenant has nothing ownerless for the sweep to claim.) // `demo-staffing` needs no exclusion — `src/sales/sharing/demo-staffing.ts` is // deliberately not exported from `src/sales/sharing/index.js` and not registered // in any composition (#640, pinned by `test/demo-staffing.test.ts`). -// 3. `permissions` — `system_admin` is replaced by `tenant_admin`, which holds +// 2. `permissions` — `system_admin` is replaced by `tenant_admin`, which holds // org-scoped `manage_org_users` instead of platform-scope `manage_users`. // Read `src/sales/profiles/tenant-admin.profile.ts` for the full audit, including // what `view_all_data` / `modify_all_data` mean under the wall. @@ -61,16 +51,9 @@ const composition = resolveComposition(); const isSaas = composition === 'saas'; /** - * Flows this composition registers. - * - * Filtered by IDENTITY, not by name string: renaming `demo_bootstrap` must not - * silently turn the exclusion into a no-op that ships the sweep to every - * tenant. `test/saas-composition.test.ts` additionally asserts the filter - * removed exactly one flow, so a refactor that makes it match nothing is red. + * Permission sets this composition registers. Filtered by IDENTITY, not by name + * string, so a rename cannot silently turn the substitution into a no-op. */ -const compositionFlows = isSaas ? allFlows.filter((flow) => flow !== DemoBootstrapFlow) : allFlows; - -/** Permission sets this composition registers — same identity discipline. */ const compositionPermissions = isSaas ? [...Object.values(allProfiles).filter((set) => set !== SystemAdminProfile), TenantAdminProfile] : Object.values(allProfiles); @@ -170,7 +153,7 @@ export default defineStack({ // spreadsheet loads without per-column mapping by hand. Templates: // `assets/import-templates/`. mappings: allMappings, - flows: compositionFlows, + flows: allFlows, skills: allSkills, permissions: compositionPermissions, apps: allApps, diff --git a/scripts/demo-staff.ts b/scripts/demo-staff.ts index 9742cac15..c9f35333f 100644 --- a/scripts/demo-staff.ts +++ b/scripts/demo-staff.ts @@ -21,10 +21,10 @@ // insert, which ADR-0092's write guard refuses and which would produce an // un-loginable row anyway); // 2. assign the positions they hold (`sys_user_position`); -// 3. HAND THE DEMO BOOK TO THEM (#1759). `demo_bootstrap` claims every -// ownerless seeded row for the FIRST user — it has to; a seed cannot name -// a user and that flow ships in the artifact — which leaves the whole -// book on the dev admin. Invisible over an API key (it runs as the human, +// 3. HAND THE DEMO BOOK TO THEM (#1759). The platform's seed-ownership claim +// hands every ownerless seeded row to the FIRST administrator — nothing +// else can; a seed cannot name a user and the artifact must not know these +// people — which leaves the whole book on the dev admin. Invisible over an API key (it runs as the human, // so `viewAllRecords` applies) and fatal over OAuth, where the agent // ceiling admits only what the caller OWNS or holds a share on // (objectstack-ai/objectstack#16549). So a demo salesperson asking their @@ -283,15 +283,16 @@ type OwnershipOutcome = { * ### What it claims, and what it deliberately does not * * Only rows sitting on the DEV ADMIN or on nobody. Those two states are the - * seed book: `demo_bootstrap` stamps the first user onto everything it finds - * ownerless, and anything it has not reached yet is still null. A row some LIVE + * seed book: the platform's seed-ownership claim stamps the first administrator + * onto everything it finds ownerless once the seed settles, and anything it has + * not reached yet is still null. A row some LIVE * WORKFLOW has already assigned is left exactly where it is — measured on a * staffed box, the SLA sweep escalates cases and the escalation hook hands the * resulting tasks to the service manager, and re-routing those by territory * would overwrite the app demonstrating itself. * * That also makes the run idempotent and order-independent: correct whether - * `demo_bootstrap` has already claimed the rows or has not run yet, and a + * that claim has already run or has not run yet, and a * second pass over a converged org writes nothing. */ async function handBookToRoster( @@ -487,7 +488,7 @@ async function verify(base: URL, outcomes: StaffOutcome[], adminAccounts: Json[] failures.push( `${member.email} OWNS ${owned.join(', ')} — a share to a record's owner demonstrates ` + `nothing, because the private OWD baseline already admits the owner. Ownership belongs ` + - `to demo_bootstrap's first user; staffing must not move it.`, + `to the seed-ownership claim's first administrator; staffing must not move it.`, ); } if (member.positions.includes('na_sales_team') || member.positions.includes('eu_sales_team')) { diff --git a/src/revenue/flows/billing-handoff-contract-activated.flow.ts b/src/revenue/flows/billing-handoff-contract-activated.flow.ts index 961476fdf..21711c75d 100644 --- a/src/revenue/flows/billing-handoff-contract-activated.flow.ts +++ b/src/revenue/flows/billing-handoff-contract-activated.flow.ts @@ -61,7 +61,7 @@ import { * ⛔ Both start conditions must test the TRANSITION * (`record.x == v && previous.x != v`), never the current value. This is the * idiom `opportunity_won_alert` uses on this very object: without the - * `previous.*` term, every later edit of a won deal — a demo-bootstrap owner + * `previous.*` term, every later edit of a won deal — a seed-ownership * claim, an approval stamp, a description tweak — re-fires the flow. For a * congratulations notification that is noise; for a billing hand-off it is a * duplicate order. `previous.*` is guarded FAIL-CLOSED (`has(previous.x) &&`) diff --git a/src/sales/data/_shared.ts b/src/sales/data/_shared.ts index 869ee0613..b73f61d82 100644 --- a/src/sales/data/_shared.ts +++ b/src/sales/data/_shared.ts @@ -24,7 +24,8 @@ import { cel } from '@objectstack/spec'; * * - `isSystem` bypasses RBAC and DISABLES the security plugin's injection * of `organization_id` / `owner_id` — which is why seeded rows - * land ownerless and `demo_bootstrap` has to claim them. + * land ownerless until the platform's seed-ownership claim + * hands them to the first administrator when the seed settles. * - `skipTriggers` suppresses record-change AUTOMATION (autolaunched flows), * not lifecycle hooks. Seed data is pre-existing end state, * not a stream of user events. diff --git a/src/sales/data/activity.seed.ts b/src/sales/data/activity.seed.ts index 29edcc4bf..4317764d2 100644 --- a/src/sales/data/activity.seed.ts +++ b/src/sales/data/activity.seed.ts @@ -167,8 +167,8 @@ export const tasks = defineSeed(Task, { // two activity-shaped objects that came before already accept the same trade. // No collision exists with the runtime writer's own rows: `log_call` / // `log_meeting` / `schedule_meeting` insert with an explicit `owner_id` -// (`src/actions/global.actions.ts`), so they are never ownerless and -// `demo_bootstrap`'s claim sweep never sees them — the "one producer per +// (`src/actions/global.actions.ts`), so they are never ownerless and the +// platform's seed-ownership claim never touches them — the "one producer per // window" question #702 raised, answered for this object. /** Which `related_to_*` lookup carries each `related_to_type`. */ @@ -527,8 +527,8 @@ export const events = defineSeed(Event, { ...(spec.description ? { description: spec.description } : {}), ...(spec.outcome ? { outcome_notes: spec.outcome } : {}), // No `owner_id`: a seed cannot name a user (see `src/data/index.ts`), so - // these rows land ownerless and `demo_bootstrap` claims them — which is - // why `crm_event` is in that flow's CLAIMED_OBJECTS (#671). + // these rows land ownerless and the platform's seed-ownership claim hands + // them to the first administrator when the seed settles (#671, #1892). }; }), }); diff --git a/src/sales/data/forecast.seed.ts b/src/sales/data/forecast.seed.ts index 44788bc3b..94cbbcf73 100644 --- a/src/sales/data/forecast.seed.ts +++ b/src/sales/data/forecast.seed.ts @@ -15,9 +15,10 @@ import { Forecast } from '../objects/forecast.object'; // ─── Forecasts ──────────────────────────────────────────────────────── // `owner_id` is left unset: a seed cannot name a user and seed writes run -// `isSystem`, so nothing stamps it — ownership is backfilled by -// `demo_bootstrap`, which claims `crm_forecast` alongside the other -// owner-scoped objects (#702). See the note at the foot of `src/data/index.ts`. +// `isSystem`, so nothing stamps it — ownership is backfilled by the platform's +// seed-ownership claim when the seed settles, which covers `crm_forecast` +// alongside every other owner-scoped object (#702, #1892). See the note on +// ownership in `objectstack.composition.ts`. // // ─── ONE PRODUCER PER WINDOW (#702) ─────────────────────────────────── // diff --git a/src/sales/data/index.ts b/src/sales/data/index.ts index 881bc30a0..a3216b3a9 100644 --- a/src/sales/data/index.ts +++ b/src/sales/data/index.ts @@ -34,15 +34,16 @@ export { celDaysAgo, celDaysFromNow, lineTotal, linesTotal, type LineSpec } from * Which SHAPE of this app a build assembles (#1361). * * `default` is the community/single-org app and is what every build produces - * unless something asks otherwise — the demo org, its storytelling data, and - * the `demo_bootstrap` sweep that binds that data to the first user. + * unless something asks otherwise — the demo org and its storytelling data, + * which the platform's seed-settle ownership claim binds to the first platform + * administrator. * * `saas` is the shape a multi-org operator deploys on the enterprise runtime * under a walled tenancy posture (`OS_TENANCY_POSTURE=isolated`). The two * differ ONLY by what the composition registers; no code branches at runtime, * nothing is decided per tenant, and no enterprise package is imported. See * {@link SaasTenantSeedData} for why the seed set shrinks and - * `objectstack.config.ts` for the flow/permission halves. + * `objectstack.config.ts` for the permission half. */ export type HotCrmComposition = 'default' | 'saas'; diff --git a/src/sales/flows/billing-handoff-closed-won.flow.ts b/src/sales/flows/billing-handoff-closed-won.flow.ts index 6acca3666..f8b2c20eb 100644 --- a/src/sales/flows/billing-handoff-closed-won.flow.ts +++ b/src/sales/flows/billing-handoff-closed-won.flow.ts @@ -61,7 +61,7 @@ import { * ⛔ Both start conditions must test the TRANSITION * (`record.x == v && previous.x != v`), never the current value. This is the * idiom `opportunity_won_alert` uses on this very object: without the - * `previous.*` term, every later edit of a won deal — a demo-bootstrap owner + * `previous.*` term, every later edit of a won deal — a seed-ownership * claim, an approval stamp, a description tweak — re-fires the flow. For a * congratulations notification that is noise; for a billing hand-off it is a * duplicate order. `previous.*` is guarded FAIL-CLOSED (`has(previous.x) &&`) diff --git a/src/sales/flows/demo-bootstrap.flow.ts b/src/sales/flows/demo-bootstrap.flow.ts deleted file mode 100644 index 7083e21fa..000000000 --- a/src/sales/flows/demo-bootstrap.flow.ts +++ /dev/null @@ -1,292 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { P } from '@objectstack/spec'; -import type * as Automation from '@objectstack/spec/automation'; -import { guarded } from './_guarded-iteration'; -type Flow = Automation.Flow; - -/** - * Demo-org bootstrap — bind the seeded demo data to the first real user. - * - * A seed cannot name a user. Lookup values resolve against the target's - * externalId and that only works for objects in the app's own graph, so - * `owner_id: 'Dev Admin'` would store the literal string rather than an id - * (verified against 16.1.0). A hook on `sys_user` is rejected at build time — - * cross-reference validation refuses hooks on objects the app doesn't - * declare. The user id only exists after first boot, so this has to be a - * scheduled sweep. - * - * Without it a freshly seeded org comes up with every record ownerless, and - * that quietly disables a tier of the product: "My Leads" / "My Deals" / - * "My Cases" are empty for everyone, and any `notify` addressed to a record's - * owner reaches nobody. - * - * Idempotent: once a record has an owner the query no longer returns it and - * each pass is a no-op. `runAs: 'system'` because a scheduled run has no - * trigger user and these writes must bypass RLS (ADR-0049). - * - * Per-record updates inside a loop, not one filtered mass update: the - * `update_record` node calls `data.update(...)` without `options.multi`, so a - * filter matching more than one row fails with "Update requires an ID or - * options.multi=true". Same shape as `case_sla_monitor`. - * - * Scoped to a demo install by intent — it claims records for whoever the first - * user is. A real deployment assigns ownership through import or territory - * rules instead, and by then nothing is ownerless for this to pick up. - * - * ─── ⛔ This flow must NOT staff anybody ────────────────────────────────── - * - * ⛔ Never add a `create_record` on `sys_user`, or on any identity table, to - * this flow. It ships in the ARTIFACT, so it runs in a customer's org too, and - * the only way to make synthetic users there impossible rather than unlikely is - * for the artifact to contain no mechanism that can create one. - * `test/demo-staffing.test.ts` fails on any flow node that writes an identity - * table. Such a node would not produce usable people anyway: identity tables are - * `managedBy: 'better-auth'` (ADR-0092), and a row inserted around that surface - * has no credential — an account nobody can sign in as. - * - * Staffing lives in `pnpm demo:staff`, which drives a LOCAL dev server through - * the platform's own admin endpoints. It depends on this flow's behaviour - * staying exactly as it is: the demo's whole point is that a rep reads accounts - * they do NOT own (a `private` OWD already admits the owner, so a share to the - * owner proves nothing). The reps are created after the dev admin and appended - * to `sys_user`, so `get_user`'s unordered "first user" is unaffected by - * staffing — and the staffing script re-checks that from the other side, - * failing if any demo user turns out to own a seeded account. - * - * ─── ONE ownership column ──────────────────────────────────────────────── - * - * `owner_id` is the ONLY owner this app has. It is the column ObjectQL injects - * into every user-owned object, and the only one the sharing service reads: - * under `sharingModel: 'private'` the OWD baseline admits the owner of - * `owner_id` and a share can only WIDEN from there. It also drives the "My …" - * views, the owner-addressed `notify` in every sweep, and the owner axis of the - * analytics datasets. - * - * ⛔ Never author a second, app-level owner lookup beside it. A row claimed on - * such a column comes out of a sweep looking claimed everywhere a human would - * check, while still being owned by nobody as far as access control is - * concerned: `PATCH` answers 403 for EVERY user including the admin, and the - * attachment surface, which gates on `canEdit(parent)`, answers 403 - * `ATTACHMENT_PARENT_ACCESS` on upload. A sweep whose filter then reads the - * second column never looks at the row again, so the state is terminal. With one - * column that failure mode is structurally gone rather than guarded against — - * there is no second column left to disagree with the first. - * - * Why rows reach the platform ownerless at all, and the reason this flow exists: - * seed writes run under `{ isSystem: true }`, which short-circuits the security - * middleware entirely, so the insert-time auto-stamp of `owner_id` never fires - * ("seeds either declare those fields explicitly per record"). HotCRM's seeds - * cannot declare it — no seed can name a user. So ownership at the platform - * level is THIS flow's job, and nothing else's. - * - * One pass per object, selecting `{ owner_id: null }`. On a healthy org it - * selects nothing. - */ - -/** The app's one ownership column — the platform anchor. See the note above. */ -const OWNERSHIP_COLUMN = 'owner_id'; - -/** - * One find + loop + stamp-owner pass over an object, selecting the rows that - * are ownerless and stamping the first user onto each. - */ -const claim = (key: string, objectName: string, label: string) => ({ - find: { - id: `find_${key}`, - type: 'get_record' as const, - label: `Find ownerless ${label}`, - config: { - objectName, - filter: { [OWNERSHIP_COLUMN]: null }, - limit: 500, - outputVariable: `${key}List`, - }, - }, - loop: { - id: `loop_${key}`, - type: 'loop' as const, - label: `Claim each ownerless ${label}`, - config: { - collection: `{${key}List}`, - iteratorVariable: `current_${key}`, - body: guarded(key, { - nodes: [ - { - id: `stamp_${key}`, - type: 'update_record' as const, - label: `Set ${OWNERSHIP_COLUMN} on ${label}`, - config: { - objectName, - filter: { id: `{current_${key}.id}` }, - // A foreign owner on an update is an ownership TRANSFER, denied - // without `allowTransfer` — but this flow is `runAs: 'system'`, - // which short-circuits the security middleware before that guard - // The claim is deliberately outside the user gate: there - // is no user to hold the grant when it runs. - fields: { [OWNERSHIP_COLUMN]: '{firstUser.id}' }, - }, - }, - ], - edges: [], - }), - }, - }, -}); - -/** - * The objects whose seeded rows this flow claims. - * - * MEMBERSHIP RULE, computed rather than curated: an object belongs here when it - * is seeded AND declares `owner_id`. `test/flow-scheduled.test.ts` crosses the - * registered objects three ways — seeded × declares `owner_id` × claimed here — - * and fails while the "seeded and owner-scoped but unclaimed" cell is non-empty, - * so the next seeded owner-scoped object cannot be forgotten. ⛔ Do not - * hand-maintain this list against a written roster instead. - * - * An object declaring NO `owner_id` stays OUT — `crm_product` (a shared - * catalogue) and `crm_event_attendee` (`sharingModel: 'controlled_by_parent'`, - * its access derived from the event it hangs off). There is no ownership to - * claim and stamping one would write a column the object does not have; the - * same test asserts that direction too. - * - * ⚠️ OWD does not decide membership, and reading it as if it did is what hides - * an omission. On a `private` (or `controlled_by_parent`) object an ownerless - * row is INVISIBLE and the defect announces itself on the first list view. On a - * `public_read` object the seeded rows read fine for everybody and look - * perfectly healthy — but `public_read` opens the READ baseline only: - * "public_read is read-open but write-owned; only a fully public object is - * write-open" (`@objectstack/plugin-sharing` 17.0.0-rc.2, `buildWriteFilter`). - * A write still needs owner-match, or a share at a write level. - * - * Which turns a GRANTED permission into a permanent 403. `marketing_user` holds - * `crm_campaign` at `allowEdit: true, modifyAllRecords: false`, and - * `service_agent` holds `crm_knowledge_article` the same way. With - * `modifyAllRecords: false` and no `writeScope` the effective write depth is - * `own`, whose filter is `owner_id == caller` — a predicate no null-owner row - * can satisfy. So the permission table says "can edit" while every seeded row - * answers 403 for everyone but `system_admin`. ⛔ Criteria shares are not a - * substitute: `campaign_leadership_*` (`src/sharing/campaign.sharing.ts`) widens - * edit to two marketing POSITIONS and only while a campaign is - * `planning`/`in_progress`, so it covers neither the finished seeded campaigns - * nor any knowledge article, and nobody holding the plain `marketing_user` grant - * is reached by it at all. - * - * ⚠️ Before adding an object, check for a SECOND PRODUCER of the same row. A - * claim is correct only while nothing else writes rows in the window this sweep - * would stamp. `crm_forecast` is claimable because the seeds ship no row in the - * window `forecast_snapshot` owns (`src/data/revenue.seed.ts`); while they did, - * stamping an owner here would have moved the phantom onto the first user rather - * than removing it, and left a permanent duplicate on any boot where the 03:00 - * sweep reached the window first. `crm_event` has no such overlap: `log_call` / - * `log_meeting` / `schedule_meeting` insert their row with an explicit - * `owner_id` (an action body runs `isSystem`, so it stamps ownership itself — - * `src/actions/global.actions.ts`), so a rep's own interactions are never - * ownerless and this sweep never selects them. - * - * Who owns a seeded knowledge article, recorded rather than decided (PM ruling): - * the first user, by the same demo convention every other object here follows. - * Whether a real deployment's article owner means its AUTHOR or its MAINTAINER - * is a product question this claim deliberately does not prejudge — a real - * deployment assigns ownership before this sweep has anything to pick up. - */ -const CLAIMED_OBJECTS: ReadonlyArray<[key: string, objectName: string, label: string]> = [ - ['leads', 'crm_lead', 'Leads'], - ['accounts', 'crm_account', 'Accounts'], - ['contacts', 'crm_contact', 'Contacts'], - ['opportunities', 'crm_opportunity', 'Opportunities'], - ['cases', 'crm_case', 'Cases'], - ['tasks', 'crm_task', 'Tasks'], - ['quotes', 'crm_quote', 'Quotes'], - ['contracts', 'crm_contract', 'Contracts'], - ['forecasts', 'crm_forecast', 'Forecasts'], - ['campaigns', 'crm_campaign', 'Campaigns'], - ['knowledge', 'crm_knowledge_article', 'Knowledge Articles'], - ['events', 'crm_event', 'Events'], -]; - -/** One pass per object. */ -const TARGETS = CLAIMED_OBJECTS.map(([key, objectName, label]) => claim(key, objectName, label)); - -/** - * The whole flow is one straight line: check for a user, then find/loop each - * object in turn. Edges are just consecutive pairs of this list. - */ -const CHAIN = [ - 'start', - 'get_user', - 'bind_first_user', - 'has_user', - ...TARGETS.flatMap((t) => [t.find.id, t.loop.id]), - 'end', -]; - -export const DemoBootstrapFlow: Flow = { - name: 'demo_bootstrap', - label: 'Demo Bootstrap', - description: - 'Claim ownerless seeded demo records for the first user by stamping the platform `owner_id` column — the one that decides the "My …" views, owner-addressed notifications and who may edit.', - type: 'schedule', - status: 'active', - runAs: 'system', - - variables: [], - - nodes: [ - { - id: 'start', type: 'start', label: 'Start (every 10 minutes)', - config: { schedule: '*/10 * * * *' }, - }, - { - // A LIST read, deliberately. ⛔ Never lower this to `limit: 1`: the - // executor routes `limit <= 1` to `findOne`, and 17.0.0-rc.2 refuses a - // `findOne` that names no record — `get_record(sys_user) failed: - // findOne('sys_user') selects no particular record` — which aborts the - // sweep on its second node and leaves every seeded row ownerless, the - // whole failure this flow exists to prevent. - // - // "Any row will genuinely do" is the honest description, and `find` is - // the prescription for that case: the reps are appended after the dev - // admin, and `demo:staff` asserts from the other side that no demo rep - // owns a seeded record. `limit: 2` is the smallest value that reaches - // `find` (this node offers no `orderBy` at all). - id: 'get_user', type: 'get_record', label: 'First Users', - config: { objectName: 'sys_user', limit: 2, outputVariable: 'userList' }, - }, - { - // `{userList.0}` is absent on an empty org, so every downstream read of - // `vars.firstUser` is guarded with `has()` — an unguarded read aborts the - // predicate, and 17.0.0-rc.2 turns an unevaluable condition into a failed - // step rather than a skipped one. - id: 'bind_first_user', type: 'assignment', label: 'Bind First User', - config: { assignments: { firstUser: '{userList.0}' } }, - }, - { - // Branching is on the two out-edges below; a `decision` node's singular - // `config.condition` is never evaluated, so a copy here would be inert - // (17.0.0-rc.2's `flow-inert-node-condition`). - id: 'has_user', type: 'decision', label: 'Any user yet?', - }, - ...TARGETS.flatMap((t) => [t.find, t.loop]), - { id: 'end', type: 'end', label: 'End' }, - ], - - edges: [ - // The straight line, one edge per consecutive pair. - ...CHAIN.slice(0, -1).map((source, i) => ({ - id: `e${i}`, - source, - target: CHAIN[i + 1], - type: 'default' as const, - // The only branch: leaving `has_user` towards the first claim step. - ...(source === 'has_user' - ? { condition: P`has(vars.firstUser) && vars.firstUser != null`, label: 'Yes' } - : {}), - })), - // Nothing to claim before anyone exists — skip the whole chain. - { - id: 'e_nouser', source: 'has_user', target: 'end', type: 'default', - condition: P`!has(vars.firstUser) || vars.firstUser == null`, label: 'No user yet', - }, - ], -}; diff --git a/src/sales/flows/index.ts b/src/sales/flows/index.ts index ad5e41196..e02d2258a 100644 --- a/src/sales/flows/index.ts +++ b/src/sales/flows/index.ts @@ -11,16 +11,13 @@ * independently ordered lists cannot reproduce one interleaved order, so the * assembled list lives in `objectstack.config.ts` where all four are visible. * - * `demo-bootstrap.flow.ts` is here because it has no single object: it is the - * demo ownership sweep across twelve of them, and app-level items are the app - * package's. `_billing-endpoint.ts` and `_guarded-iteration.ts` are the shared - * flow sources every package reads along its edge into sales. + * `_billing-endpoint.ts` and `_guarded-iteration.ts` are the shared flow + * sources every package reads along its edge into sales. */ export { LeadConversionFlow } from './lead-conversion.flow'; export { LeadConversionApprovalFlow } from './lead-conversion-approval.flow'; export { AccountApprovalFlow } from './account-approval.flow'; export { ScheduleFollowUpFlow } from './schedule-followup.flow'; -export { DemoBootstrapFlow } from './demo-bootstrap.flow'; export { OpportunityApprovalFlow, OpportunityApprovalOnCreateFlow } from './opportunity-approval.flow'; export { OpportunityStagnationFlow } from './opportunity-stagnation.flow'; export { ForecastSnapshotFlow } from './forecast-snapshot.flow'; diff --git a/src/sales/flows/opportunity-won-alert.flow.ts b/src/sales/flows/opportunity-won-alert.flow.ts index 34ac79725..49f2eca9e 100644 --- a/src/sales/flows/opportunity-won-alert.flow.ts +++ b/src/sales/flows/opportunity-won-alert.flow.ts @@ -49,7 +49,7 @@ export const OpportunityWonAlertFlow: Flow = { objectName: 'crm_opportunity', triggerType: 'record-after-update', // `previous.stage` guard: fire on the TRANSITION into closed_won only. - // Without it every later edit of a won deal (demo-bootstrap owner + // Without it every later edit of a won deal (seed-ownership // claims, approval-status stamps, description tweaks) re-sent the // congratulations blast. The trigger forwards `previous` into the // condition scope (cf. the engine's record-change context). diff --git a/src/sales/objects/account.hook.ts b/src/sales/objects/account.hook.ts index 9f89ce7f3..ea8bbb6cc 100644 --- a/src/sales/objects/account.hook.ts +++ b/src/sales/objects/account.hook.ts @@ -180,9 +180,10 @@ const accountHook: Hook = { // Stamp last_activity_date when ownership or type changes. // USER writes only (`ctx.user?.id` — this repo's system-write signal, cf. - // opportunity/quote hooks): the demo_bootstrap flow claims ownerless seeded - // accounts as a system write every 10 minutes, and stamping those flattened - // every seeded activity date to "today", emptying the churn report buckets. + // opportunity/quote hooks): the platform's seed-ownership claim re-owns + // ownerless seeded accounts as a system write (the retired `demo_bootstrap` + // sweep did the same every 10 minutes), and stamping those flattened every + // seeded activity date to "today", emptying the churn report buckets. // D3 stand-down on the predicate path (`forecast.hook.ts`): `ownerChanged` // / `typeChanged` are decided against THIS row's `previous`, so the stamp // would spread to every matched row. The `billing_country` / `territory` / diff --git a/src/sales/objects/forecast.hook.ts b/src/sales/objects/forecast.hook.ts index cb8195541..735df21af 100644 --- a/src/sales/objects/forecast.hook.ts +++ b/src/sales/objects/forecast.hook.ts @@ -51,9 +51,10 @@ const forecastDerive: Hook = { // `ctx.previous` on this path is supplied so a guard can REFUSE a write, // never so a rewrite can be aimed. Deriving is per-record work, so it // stands down here and still happens on the per-record path — which is - // every writer this app has: all 19 `update_record` flow nodes, every - // action and hook write through `ctx.api`, and the `demo_bootstrap` claim, - // all of them by id. + // every writer this app has: all 19 `update_record` flow nodes and every + // action and hook write through `ctx.api`, all of them by id. The platform's + // seed-ownership claim is a predicate write, and it writes only `owner_id`, + // which derives nothing. // // ⚠️ The `ctx.event` half is load-bearing, not ceremony: a BATCH INSERT // also reports `dispatch.mode === 'per-row'`, and there each row carries diff --git a/src/sales/objects/lead.hook.ts b/src/sales/objects/lead.hook.ts index 77d763803..fb06a3db3 100644 --- a/src/sales/objects/lead.hook.ts +++ b/src/sales/objects/lead.hook.ts @@ -169,7 +169,7 @@ const leadHook: Hook = { // INSERT-only on `crm_lead`.) // // ⚠️ `!ctx.session?.isSystem` is required, not decoration: a SYSTEM write - // (seed load, backfill, demo bootstrap) also arrives with no user id, and + // (seed load, backfill, seed-ownership claim) also arrives with no user id, and // the strip below would otherwise blank the owner and conversion state of // every system-written lead. The converted-lead lock further down reads // that same absence as the system-write signal, so the two readings have @@ -287,7 +287,7 @@ const leadHook: Hook = { // Converted-lead lock — USER edits only (`ctx.user?.id` is this repo's // system-write signal, cf. opportunity/quote/account hooks): a blanket - // throw also rejected system writes (demo-bootstrap owner claims, flow + // throw also rejected system writes (seed-ownership claims, flow // backfills). Narrative notes and framework-managed columns stay editable; // identity and conversion fields stay locked. // diff --git a/src/sales/sharing/demo-staffing.ts b/src/sales/sharing/demo-staffing.ts index 7155a1c37..311f04001 100644 --- a/src/sales/sharing/demo-staffing.ts +++ b/src/sales/sharing/demo-staffing.ts @@ -73,9 +73,10 @@ import type { Territory } from '../objects/_territory'; * - the two reps make TERRITORY SHARING observable for the first time. They * must be users who do NOT own the accounts: `crm_account` is `private`, so * the OWD baseline already admits a record's owner and a share to the owner - * proves nothing. `demo_bootstrap` claims every seeded record for the first - * user (the dev admin, #622) and staffing deliberately does not touch that — - * the reps stay non-owners, which is the whole point. + * proves nothing. The platform's seed-ownership claim hands every seeded + * record to the first administrator (the dev admin, #622, #1892) and + * staffing deliberately does not touch that — the reps stay non-owners, + * which is the whole point. * - the sales manager makes `opportunity_approval`'s `manager_review` resolve * to a non-empty slate for the first time. * - the service AGENT is the case INTAKE POOL. `case_auto_assign` @@ -137,8 +138,8 @@ import type { Territory } from '../objects/_territory'; * - the ROW SET is decided one layer down and is still the whole ballgame: * `crm_account` is `sharingModel: 'private'`, so the OWD baseline admits * only rows the caller OWNS, and `sys_record_share` can only widen it. The - * reps own nothing (`demo_bootstrap` claimed every seeded record for the - * dev admin), so their row set is exactly the grants their territory rule + * reps own nothing (the seed-ownership claim gave every seeded record to + * the dev admin), so their row set is exactly the grants their territory rule * materialised — 6 for NA, 2 for EU, and the SG account for nobody. * * So `sales_rep` is what makes a rep a READER at all, and the territory rule is @@ -255,10 +256,11 @@ export const DemoOrgStaffing: readonly DemoStaffMember[] = [ * * ### The defect * - * `demo_bootstrap` claims every ownerless seeded row for the FIRST user, the - * dev admin (`src/flows/demo-bootstrap.flow.ts`). That flow has to do it and - * has to do it that way: a seed cannot name a user, and the flow SHIPS IN THE - * ARTIFACT, so it must not know these people — `test/demo-staffing.test.ts` + * The platform's seed-ownership claim (`@objectstack/plugin-security`, re-run + * on `app:seeded`) hands every ownerless seeded row to the FIRST administrator, + * the dev admin; until #1892 the app's own `demo_bootstrap` sweep did the same. + * Nothing in the ARTIFACT may do it any other way: a seed cannot name a user, + * and the artifact must not know these people — `test/demo-staffing.test.ts` * fails the build if any staffing email reaches the manifest. Correct as far * as it goes (an ownerless row under a `private` OWD is editable by nobody at * all), and it leaves the entire demo book on one identity. diff --git a/test/actions-flows-integrity.test.ts b/test/actions-flows-integrity.test.ts index 412c6768c..b9cfaa66a 100644 --- a/test/actions-flows-integrity.test.ts +++ b/test/actions-flows-integrity.test.ts @@ -3,7 +3,7 @@ import { readdirSync, readFileSync } from 'node:fs'; import { describe, it, expect } from 'vitest'; import stack from '../objectstack.config'; -import { nodesUnder, flowNodesDeep } from './helpers/flow-regions'; +import { flowNodesDeep } from './helpers/flow-regions'; import { join } from 'node:path'; import { REPO_ROOT } from './helpers/repo-root'; import { metadataFiles } from './helpers/src-roster'; @@ -233,49 +233,6 @@ describe('lead conversion is discoverable', () => { }); describe('demo data is demo-ready', () => { - /** - * A seed can't name a user (lookups resolve against the target's externalId, - * which only works for objects in the app's own graph), and a hook on - * `sys_user` is rejected at build time. Seed writes are also `isSystem`, so - * the middleware's insert-time `owner_id` stamp never fires for them. So - * ownership is claimed by a scheduled sweep — without which every "My …" - * view is empty and owner-addressed notify reaches nobody (#548). - */ - it('demo_bootstrap claims every owner-scoped object', () => { - const f = flow('demo_bootstrap'); - expect(f, 'demo_bootstrap flow missing').toBeTruthy(); - expect(f!.type).toBe('schedule'); - // System context: a scheduled run has no trigger user, and these writes - // must bypass RLS to touch records nobody owns yet. - expect(f!.runAs).toBe('system'); - - const claimed = (f!.nodes ?? []) - .filter((n: AnyRec) => n.type === 'get_record' && n.config?.filter?.owner_id === null) - .map((n: AnyRec) => n.config.objectName); - // The objects behind My Leads / My Deals / My Cases and the task queue. - for (const objectName of ['crm_lead', 'crm_account', 'crm_opportunity', 'crm_case', 'crm_task']) { - expect(claimed, `demo_bootstrap never claims ${objectName}`).toContain(objectName); - } - }); - - it('every claim runs per-record inside a loop, not as a filtered mass update', () => { - // The update_record node calls data.update() WITHOUT options.multi, so a - // filter matching more than one row fails at runtime with "Update requires - // an ID or options.multi=true" — invisible to build and validate. - const f = flow('demo_bootstrap'); - const loops = (f!.nodes ?? []).filter((n: AnyRec) => n.type === 'loop'); - expect(loops.length).toBeGreaterThanOrEqual(5); - for (const loop of loops) { - // Regions included: the body is one `try_catch` guard since - // `src/sales/flows/_guarded-iteration.ts`, and the stamp is inside its `try`. - const body = nodesUnder(loop); - const update = body.find((n: AnyRec) => n.type === 'update_record'); - expect(update, `loop ${loop.id} has no update_record`).toBeTruthy(); - // Keyed by the iterator's id — the only shape update_record supports. - expect(String(update?.config?.filter?.id ?? '')).toMatch(/^\{current_\w+\.id\}$/); - } - }); - it('open opportunities close in the future and settled ones in the past', () => { // A pipeline that holds open deals with past close dates, or closed deals // scheduled in the future, reads as abandoned. This covers the whole diff --git a/test/activity-seed-coverage.test.ts b/test/activity-seed-coverage.test.ts index e5eabd188..87b8f86e6 100644 --- a/test/activity-seed-coverage.test.ts +++ b/test/activity-seed-coverage.test.ts @@ -152,7 +152,7 @@ describe('the activity seeds resolve against real records (#671)', () => { const authored = [...eventRows, ...attendeeRows].filter((r) => 'owner_id' in r); expect( authored.map((r) => String(r.subject ?? r.crm_event)), - 'ownership is demo_bootstrap\'s job (src/data/index.ts); an authored owner_id would ' + + 'ownership is the platform seed-ownership claim\'s job; an authored owner_id would ' + 'store a literal string, not an id', ).toEqual([]); }); @@ -390,12 +390,13 @@ const datasetByName = new Map(datasetDefs.map((d) => [String(d.name), d])); const objectByName = new Map(objects.map((o) => [String(o.name), o])); /** - * The claim `demo_bootstrap` performs on first boot, modelled as the one thing - * this file needs from it: a seeded event reaches the database ownerless, and - * the sweep stamps the first user onto it (#671 added `crm_event` to - * CLAIMED_OBJECTS — `test/flow-scheduled.test.ts` owns the sweep's behaviour). - * Without it the "Activity by Rep" bar groups every interaction under a null - * owner, which is the defect that claim exists to prevent. + * The ownership claim the platform performs once the seed settles + * (`claimSeedOwnership` on `app:seeded`, `@objectstack/plugin-security`), + * modelled as the one thing this file needs from it: a seeded event reaches + * the database ownerless, and the claim stamps the first administrator onto it. + * Until #1892 the app's own `demo_bootstrap` sweep did this (#671 added + * `crm_event` to it). Without it the "Activity by Rep" bar groups every + * interaction under a null owner, which is the defect that claim prevents. */ const OWNER = 'usr_first'; @@ -483,7 +484,7 @@ describe('every Sales Activity widget returns a number over the shipped seeds (# } for (const e of eventRows) { await api.object('crm_event').insert({ - // The sweep's stamp — see OWNER above. + // The claim's stamp — see OWNER above. owner_id: OWNER, subject: e.subject, type: e.type, @@ -588,15 +589,15 @@ describe('every Sales Activity widget returns a number over the shipped seeds (# } }); - it('lands every interaction under a real owner — the demo_bootstrap claim', async () => { + it('lands every interaction under a real owner — the seed-ownership claim', async () => { const rows = await runWidget( (activityDashboard.widgets as AnyRec[]).find((w) => w.id === 'activity_by_rep')!, ); const ownerless = rows.filter((r) => r.owner == null || r.owner === ''); expect( ownerless, - 'the "Activity by Rep" bar has a null-owner column: a seeded event that demo_bootstrap ' + - 'never claimed (#671 added crm_event to CLAIMED_OBJECTS)', + 'the "Activity by Rep" bar has a null-owner column: a seeded event the ' + + 'seed-ownership claim never reached (#671, #1892)', ).toEqual([]); }); diff --git a/test/automation-docs-coverage.test.ts b/test/automation-docs-coverage.test.ts index e4cb79b7f..0f1fa77c0 100644 --- a/test/automation-docs-coverage.test.ts +++ b/test/automation-docs-coverage.test.ts @@ -79,14 +79,6 @@ import stack from '../objectstack.config'; * to make that decision, and better than a filter written now on a guess about * which answer a future reader wants. * - * `demo_bootstrap` IS in the table for the same reason, and it is not a close - * call: that flow ships in the artifact and runs in a customer's org every ten - * minutes, writing `owner_id` (its own header says so, and - * `test/demo-staffing.test.ts` polices what it may write). An admin who sees - * ownership change on a ten-minute beat needs a row to find. The page labels it - * scaffolding rather than business automation in the prose under the table, - * which is the honest way to say both things at once. - * * ## Reverse verification (four directions, each predicted before it was run) * * | direction | predicted | measured | @@ -257,7 +249,6 @@ const ROW_LABEL: Record> = { contract_renewal: { 'zh-Hans': '合同续约提醒', 'zh-Hant': '合約續約提醒' }, case_sla_monitor: { 'zh-Hans': '工单 SLA 监控', 'zh-Hant': '工單 SLA 監控' }, task_due_reminder: { 'zh-Hans': '任务到期提醒', 'zh-Hant': '任務到期提醒' }, - demo_bootstrap: { 'zh-Hans': '演示数据引导', 'zh-Hant': '展示資料啟動' }, // 计费/計費, not 账单/帳單: the pages call the outbound target 「计费端点」 and the // boundary page is 「计费交接」, so the flow labels follow that one word (#600). billing_handoff_closed_won: { 'zh-Hans': '计费交接:赢单', 'zh-Hant': '計費交接:贏單' }, diff --git a/test/flow-scheduled-org-partition.test.ts b/test/flow-scheduled-org-partition.test.ts index 28cf383bf..466958a51 100644 --- a/test/flow-scheduled-org-partition.test.ts +++ b/test/flow-scheduled-org-partition.test.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { FLOW_REGION_SLOTS_BY_TYPE } from '@objectstack/spec/automation'; import { CrmFlows as allFlows } from './helpers/src-roster'; import { ContractRenewalFlow } from '../src/revenue/flows/contract-renewal.flow'; @@ -8,7 +8,6 @@ import { OpportunityStagnationFlow } from '../src/sales/flows/opportunity-stagna import { ForecastSnapshotFlow } from '../src/sales/flows/forecast-snapshot.flow'; import forecastDerive from '../src/sales/objects/forecast.hook'; import { makeFlowHarness, type Rec } from './helpers/flow-harness'; -import { COMPOSITION_ENV_VAR } from '../src/sales/data/index'; /** * Scheduled sweeps must declare `organization_id` on every `create_record` (#700). @@ -406,8 +405,9 @@ describe('forecast_snapshot sums only the target row\u2019s organization (#1372) * organization-neutral however many organizations the read spanned: a literal * carries no tenant, a template function reads no row, and a value taken off * the swept row is by construction already in that row's organization. - * `{firstUser.id}` is none of those — it is one specific foreign row's id, - * stamped across every partition. + * `{firstUser.id}` — the shape of the retired `demo_bootstrap` sweep (#1892), + * kept below as {@link CROSS_ROW_SWEEP_FIXTURE} — is none of those: it is one + * specific foreign row's id, stamped across every partition. * * So the rule is: every interpolation token in an `update_record`'s * `config.fields` must resolve to the swept row — the row the node's own @@ -431,9 +431,9 @@ describe('forecast_snapshot sums only the target row\u2019s organization (#1372) * held from both sides: an unexplained violation fails, and so does an * exemption that has stopped matching anything, so it cannot rot into a list * of claims about flows that have since changed. Where the argument is - * mechanical it is also MEASURED — `demo_bootstrap`'s exemption rests on its - * absence from the multi-organization composition, and the test below reads - * that absence out of the composition rather than believing this comment. + * mechanical it should also be MEASURED, by a test that reads the fact the + * argument rests on rather than believing the entry's prose. The register is + * empty today: its one entry, `demo_bootstrap`, left with the flow (#1892). */ /** How a variable came to hold what it holds. */ @@ -675,22 +675,7 @@ interface OrganizationNeutralityExemption { reason: string; } -const ORGANIZATION_NEUTRALITY_EXEMPTIONS: OrganizationNeutralityExemption[] = [ - { - flow: 'demo_bootstrap', - nodeId: '*', - fields: ['owner_id'], - reason: - 'This sweep stamps `{firstUser.id}` — one identity — onto every row it claims, ' - + 'under a system context that spans organizations. That is a real crossing, and ' - + 'it is precisely why the flow is NOT registered in the multi-organization ' - + 'composition (#1361/#1362). The shape it does ship in is the community/demo ' - + 'one, which has a single organization, where "the first user" is the only user ' - + 'there is. The claim is not taken on trust: the test below reads the flow\'s ' - + 'absence out of the SaaS composition, so re-registering it revokes this ' - + 'exemption automatically.', - }, -]; +const ORGANIZATION_NEUTRALITY_EXEMPTIONS: OrganizationNeutralityExemption[] = []; const exemptionCovers = ( e: OrganizationNeutralityExemption, @@ -774,59 +759,6 @@ describe('scheduled update_record writes only organization-neutral values (#1363 }); }); -/** - * The half that keeps `demo_bootstrap`'s exemption honest. - * - * Its argument is not "this write is safe" — the write is a real crossing. - * The argument is "this flow does not run where there is more than one - * organization", and that is a fact about the COMPOSITION, which is readable. - * So it is read, rather than asserted in a comment that nothing rechecks. - */ -describe('the demo_bootstrap exemption is backed by the composition (#1363)', () => { - let communityFlowNames: string[] = []; - let saasFlowNames: string[] = []; - - const loadFlowNames = async (composition: string): Promise => { - const previous = process.env[COMPOSITION_ENV_VAR]; - process.env[COMPOSITION_ENV_VAR] = composition; - vi.resetModules(); - try { - const stack = ((await import('../objectstack.config')) as Rec).default as Rec; - return (Array.isArray(stack.flows) ? (stack.flows as Rec[]) : []).map((f) => String(f.name)); - } finally { - if (previous === undefined) delete process.env[COMPOSITION_ENV_VAR]; - else process.env[COMPOSITION_ENV_VAR] = previous; - vi.resetModules(); - } - }; - - beforeAll(async () => { - communityFlowNames = await loadFlowNames('default'); - saasFlowNames = await loadFlowNames('saas'); - }, 60_000); - - it('keeps demo_bootstrap out of the multi-organization shape', () => { - // A sentinel first: `not.toContain` on an empty list passes for the wrong - // reason, and an empty list is exactly what a broken load returns. - expect(saasFlowNames, 'the SaaS composition registered no flows at all').not.toEqual([]); - expect( - saasFlowNames, - 'demo_bootstrap is now registered in the multi-organization composition, so the\n' - + 'exemption in ORGANIZATION_NEUTRALITY_EXEMPTIONS has stopped being true:\n' - + "`{firstUser.id}` would stamp one tenant's user across every other tenant's\n" - + 'rows. Either drop it from that composition again, or fix the flow and delete\n' - + 'the exemption.', - ).not.toContain('demo_bootstrap'); - }); - - it('and still ships it in the single-organization shape it is exempted for', () => { - // The other direction. An exemption argued from "it only runs where there is - // one organization" says nothing once the flow runs nowhere — and then the - // honest move is to delete the entry, which the staleness test forces. - expect(communityFlowNames).toContain('demo_bootstrap'); - }); -}); - // ─────────────────────────────────────────── PROVING THE RULE CAN GO RED ── /** diff --git a/test/flow-scheduled.test.ts b/test/flow-scheduled.test.ts index c747b1005..1f0cbe14f 100644 --- a/test/flow-scheduled.test.ts +++ b/test/flow-scheduled.test.ts @@ -10,17 +10,15 @@ import { CampaignCompletionFlow } from '../src/marketing/flows/campaign-completi import { CaseSlaMonitorFlow } from '../src/service/flows/case-sla-monitor.flow'; import { ContractExpirationFlow } from '../src/revenue/flows/contract-expiration.flow'; import { ContractRenewalFlow } from '../src/revenue/flows/contract-renewal.flow'; -import { DemoBootstrapFlow } from '../src/sales/flows/demo-bootstrap.flow'; +import { claimSeedOwnership } from '@objectstack/plugin-security'; import { ForecastSnapshotFlow } from '../src/sales/flows/forecast-snapshot.flow'; import { CrmObjects } from './helpers/src-roster'; -import { MarketingUserProfile } from '../src/sales/profiles/marketing-user.profile'; -import { ServiceAgentProfile } from '../src/sales/profiles/service-agent.profile'; import { OpportunityStagnationFlow } from '../src/sales/flows/opportunity-stagnation.flow'; import { QuoteExpirationFlow } from '../src/revenue/flows/quote-expiration.flow'; import forecastDerive from '../src/sales/objects/forecast.hook'; import { TaskDueReminderFlow } from '../src/sales/flows/task-due-reminder.flow'; import { CrmFlows as allFlows } from './helpers/src-roster'; -import { makeFlowHarness, type Rec } from './helpers/flow-harness'; +import { makeFlowHarness, silentLogger, type FlowHarness, type Rec } from './helpers/flow-harness'; import { regionsOf } from './helpers/flow-regions'; /** @@ -791,11 +789,12 @@ describe('forecast_snapshot — nightly per-owner pipeline snapshot', () => { * the current quarter after every re-seeded boot. * * The fix is in the seed data — the current quarter is no longer seeded — and - * this file's job is to show that the invariant survives the two scheduled - * sweeps in EITHER ORDER. That matters because the obvious alternative fix - * (claim `crm_forecast` and let the sweep adopt the claimed row) holds only - * while `demo_bootstrap` reaches the window before `forecast_snapshot` does, - * and a duplicate opened by losing that race never heals. Both orders are run + * this file's job is to show that the invariant survives the ownership claim + * and the scheduled sweep in EITHER ORDER. That matters because the obvious + * alternative fix (claim `crm_forecast` and let the sweep adopt the claimed + * row) holds only while the claim reaches the window before `forecast_snapshot` + * does, and a duplicate opened by losing that race never heals. The claim is + * the platform's own since #1892 — see `claim` below. Both orders are run * below over the REAL seed records; the last case restores a current-quarter * seed row and reproduces the duplicate, so a green run here can never be green * for want of a mechanism. @@ -813,8 +812,8 @@ describe('re-seed × snapshot leaves one row per (owner, period, window) (#702)' .find((d) => d.object === 'crm_forecast')?.records ?? []); const users = (): Rec[] => [ - // `get_user` takes an unordered first row; the demo's first user is the - // dev admin, and `demo_bootstrap` claims every ownerless row for them. + // The demo's first administrator is the dev admin, and the platform's + // seed-ownership claim hands every ownerless row to them. { id: 'usr_admin', name: 'Dev Admin' }, { id: 'rep_two', name: 'Rep Two' }, { id: 'rep_idle', name: 'No Deals At All' }, @@ -840,8 +839,8 @@ describe('re-seed × snapshot leaves one row per (owner, period, window) (#702)' * A fresh insert lands with `owner_id: null`, not with the key absent — the * registry injects the column into every user-owned object, and the seed * write only skips the security plugin's insert-time STAMP. That distinction - * decides whether `demo_bootstrap` can see the row at all: its filter is - * `{ owner_id: null }`, and an absent key is not null. The row below states + * decides whether the seed-ownership claim can see the row at all: its + * predicate is `{ owner_id: null }`, and an absent key is not null. The row below states * neither `owner_id` nor `organization_id` any more; the harness store * materialises every declared column the way a driver does (#1458), so the * fixture no longer has to name the columns the filters happen to read. @@ -857,7 +856,7 @@ describe('re-seed × snapshot leaves one row per (owner, period, window) (#702)' const makeHarness = (forecasts: Rec[] = []) => makeFlowHarness( - { demo_bootstrap: DemoBootstrapFlow, forecast_snapshot: ForecastSnapshotFlow }, + { forecast_snapshot: ForecastSnapshotFlow }, { sys_user: users(), crm_opportunity: opps(), @@ -866,13 +865,40 @@ describe('re-seed × snapshot leaves one row per (owner, period, window) (#702)' { hooks: [forecastDerive] }, ); - /** A cold boot and a warm one, both running the sweeps in `order`. */ + /** + * The ownership claim, run for REAL: `claimSeedOwnership` from + * `@objectstack/plugin-security`, the function the platform re-runs on + * `app:seeded` once the seed settles (objectstack#17872, in the 17.6.0 pin). + * It replaced the app's own `demo_bootstrap` sweep (#1892) and hands every + * ownerless row of every object declaring `owner_id` to one administrator. + * The adapter only maps the harness's `{ modified }` onto the affected-row + * count the engine's `update` returns; the registry is the app's own objects. + */ + const claim = (h: FlowHarness) => + claimSeedOwnership( + { + registry: { getAllObjects: () => Object.values(CrmObjects as unknown as Record) }, + find: (object: string, query: Rec) => h.data.find(object, query), + update: async (object: string, data: Rec, options: Rec) => + (await h.data.update(object, data, options)).modified, + }, + 'usr_admin', + { logger: silentLogger }, + ); + + /** One step of a boot: the ownership claim, or a scheduled flow by name. */ + const step = async (h: FlowHarness, name: string) => { + if (name === 'claim') await claim(h); + else await h.run(name, {}, { event: 'schedule' }); + }; + + /** A cold boot and a warm one, both running the steps in `order`. */ const boot = async (order: readonly string[]) => { const h = makeHarness(); loadSeeds(h.store); - for (const flow of order) await h.run(flow, {}, { event: 'schedule' }); + for (const name of order) await step(h, name); loadSeeds(h.store); - for (const flow of order) await h.run(flow, {}, { event: 'schedule' }); + for (const name of order) await step(h, name); return h; }; @@ -880,8 +906,8 @@ describe('re-seed × snapshot leaves one row per (owner, period, window) (#702)' r.period === 'quarter' && String(r.period_start) <= today && today <= String(r.period_end); const ORDERINGS = [ - ['claim first — the ten-minute sweep reaches the window first', ['demo_bootstrap', 'forecast_snapshot']], - ['sweep first — a boot minutes before 03:00', ['forecast_snapshot', 'demo_bootstrap']], + ['claim first — the seed settles before the snapshot sweep runs', ['claim', 'forecast_snapshot']], + ['sweep first — a boot minutes before 03:00', ['forecast_snapshot', 'claim']], ] as const; for (const [label, order] of ORDERINGS) { @@ -955,14 +981,14 @@ describe('re-seed × snapshot leaves one row per (owner, period, window) (#702)' }; const h = makeHarness([phantom]); await h.run('forecast_snapshot', {}, { event: 'schedule' }); - await h.run('demo_bootstrap', {}, { event: 'schedule' }); + await claim(h); const adminRows = h.store.crm_forecast.filter((r) => inCurrentQuarter(r) && r.owner_id === 'usr_admin'); expect(adminRows, 'the sweep adopted the ownerless row instead of duplicating it').toHaveLength(2); // And it is terminal: the sweep's findOne refreshes whichever row it // reaches first and never sees, let alone merges, the other. await h.run('forecast_snapshot', {}, { event: 'schedule' }); - await h.run('demo_bootstrap', {}, { event: 'schedule' }); + await claim(h); expect( h.store.crm_forecast.filter((r) => inCurrentQuarter(r) && r.owner_id === 'usr_admin'), 'a later pass healed the duplicate — then the seed guard would be optional', @@ -1078,440 +1104,3 @@ describe('loop-nested conditions must be explicit CEL envelopes', () => { ).toEqual([]); }); }); - -/** - * demo_bootstrap — the post-seed ownership claim (#622, re-based on #548). - * - * The failure this guards against is silent by construction. A seeded row - * reaches the database with the ownership column (`owner_id`) empty: seed - * writes run `{ isSystem: true }`, which short-circuits the security - * middleware, so its insert-time auto-stamp never fires — and these seeds - * cannot declare an owner either (a seed cannot name a user; that is why this - * flow exists). `owner_id` is the column the sharing service reads, so under - * `sharingModel: 'private'` such a row is editable by NOBODY, the admin - * included: `PATCH` answers 403, and the attachment surface — which gates on - * `canEdit(parent)` — answers 403 ATTACHMENT_PARENT_ACCESS. - * - * #622 was the two-column version of this: the app also authored its own - * `owner` lookup, the sweep stamped only that one, and the record LOOKED - * claimed everywhere a human would check while the platform still owned it to - * nobody — with the sweep's own filter (`owner != null`) then excluding the row - * forever, so the broken state was terminal. #548 removed the second column, so - * the half-claimed state is no longer reachable and there is nothing left for a - * `plat_only` fixture to describe. - * - * What remains is one column and one question, and these cases assert the - * OUTCOME — every claimed object comes out of bootstrap with a real owner — - * over the object list read from the flow itself, so a newly claimed object is - * covered the day it is added. The single-column claim is NOT weaker than the - * two-column one it replaces: it is the same assertion with the column that - * could disagree with it deleted. - */ -describe('demo_bootstrap — post-seed ownership claim', () => { - const USER = 'usr_first'; - - /** The app's ONE ownership column — the platform anchor sharing reads. */ - const PLATFORM_OWNER = 'owner_id'; - - /** Every object the flow claims, read off the flow's own `get_record` nodes. */ - const claimedObjects = (): string[] => { - const nodes = (DemoBootstrapFlow.nodes ?? []) as Rec[]; - const names = nodes - .filter((n) => n.type === 'get_record' && n.config?.objectName !== 'sys_user') - .map((n) => String(n.config.objectName)); - return [...new Set(names)]; - }; - - /** - * Two rows per claimed object: - * - `unowned` — a fresh seed row, `owner_id` empty. The sweep must claim it. - * - `owned` — already claimed by a real rep. The sweep must leave it alone. - * - * The `app_only` / `plat_only` shapes this fixture used to carry described the - * two columns DISAGREEING, which #548 made unrepresentable — there is one - * column now, so "claimed here but not there" has no spelling. They are not - * re-spelled onto `owner_id` (that would duplicate `unowned` / `owned` under - * new names and assert nothing extra); they are deleted with the state they - * described. - */ - const seedStore = (): Record => { - const store: Record = { sys_user: [{ id: USER, email: 'admin@objectos.ai' }] }; - for (const object of claimedObjects()) { - store[object] = [ - { id: `${object}_unowned`, [PLATFORM_OWNER]: null }, - { id: `${object}_owned`, [PLATFORM_OWNER]: 'rep_9' }, - ]; - } - return store; - }; - - const runBootstrap = async (store: Record = seedStore()) => { - const h = makeFlowHarness({ demo_bootstrap: DemoBootstrapFlow }, store); - await h.run('demo_bootstrap', {}, { event: 'schedule' }); - return h; - }; - - it('claims every object the seed data ships an owner-scoped record for', () => { - // Guards the cases below: they iterate this list, so an empty or truncated - // one would make every assertion vacuous. - const claimed = claimedObjects(); - for (const object of [ - 'crm_lead', 'crm_account', 'crm_contact', 'crm_opportunity', - 'crm_case', 'crm_task', 'crm_quote', 'crm_contract', - // #702: `crm_forecast` is `private` and `sales_rep` reads it with - // `readScope: 'own'`, so an ownerless snapshot row is invisible to every - // rep and editable by nobody — the same defect as the eight above, on the - // one owner-scoped seeded object this list used to omit. - 'crm_forecast', - // #716: the last two, and the ones that hid longest. Both are seeded and - // both declare `owner_id`, but their OWD is `public_read` — so unlike the - // nine above their ownerless rows READ fine for everybody and nothing - // looked broken until somebody tried to WRITE one. `public_read` opens - // the read baseline only; the write filter still needs owner-match, so - // `marketing_user.crm_campaign.allowEdit` and - // `service_agent.crm_knowledge_article.allowEdit` (both at - // `modifyAllRecords: false`, i.e. write depth `own` → `owner_id == - // caller`) were granted permissions that answered 403 on every seeded - // row for everyone but `system_admin`. - 'crm_campaign', - 'crm_knowledge_article', - // #671: the activity model got demo rows, and `crm_event` declares - // `owner_id` under a `private` OWD — so an unclaimed seeded interaction - // is invisible to every rep (`sales_rep` reads it `own`-only), missing - // from the owner axis of `event_metrics`, and editable by nobody. This is - // the cross-table below going red the day the seeds landed, which is the - // mechanism working. - 'crm_event', - ]) { - expect(claimed, `demo_bootstrap never claims ${object}`).toContain(object); - } - }); - - /** - * The other half of #671, and the direction that is easy to get wrong by - * copying the line above: `crm_event_attendee` is seeded in the same commit - * as `crm_event` but declares NO `owner_id` — its access derives from the - * event it hangs off (`sharingModel: 'controlled_by_parent'`). Claiming it - * would stamp a column the object does not have, on rows where ownership - * means nothing. The computed cross-table below asserts this as a general - * rule; this case names the record so a future edit cannot quietly add it. - */ - it('does not claim the attendee junction — it has no ownership to claim', () => { - expect(claimedObjects()).not.toContain('crm_event_attendee'); - }); - - /** - * The roster above is a list a human maintains, and #716 is what happens when - * one falls behind: `crm_campaign` and `crm_knowledge_article` sat seeded, - * owner-scoped and unclaimed for release after release because nothing ever - * COMPUTED the question — and their `public_read` OWD meant the omission - * showed up as a 403 on an edit nobody in a demo tries, rather than as an - * empty list somebody would have reported. - * - * So compute it, from the app's own metadata. An object that is SEEDED and - * declares `owner_id` reaches the database owned by nobody — seed writes run - * `{ isSystem: true }`, which skips the security middleware's insert-time - * stamp, and no seed can name a user — so this sweep is the ONLY thing that - * can give it an owner, and it belongs in the list. Objects with no - * `owner_id` (`crm_product`, and the `controlled_by_parent` children whose - * access derives from their master) have no ownership to claim and must stay - * out — stamping one would write a column the object does not have. - */ - it('leaves no seeded owner-scoped object unclaimed — the cross-table, computed', () => { - const seeded = new Set( - (CrmSeedData as unknown as Array<{ object: string }>).map((d) => d.object), - ); - const claimed = new Set(claimedObjects()); - const objects = Object.values(CrmObjects as unknown as Record).filter( - (o) => o != null && typeof o.name === 'string' && o.fields != null, - ); - const ownerScoped = (o: Rec) => - Object.prototype.hasOwnProperty.call(o.fields as Rec, PLATFORM_OWNER); - - // Guard the guard: a broken walk would make every filter below empty and - // the whole case vacuous. - expect(objects.length, 'no objects read off the barrel — the walk broke').toBeGreaterThan(10); - expect( - objects.filter((o) => seeded.has(String(o.name)) && ownerScoped(o)).length, - 'no seeded owner-scoped object found at all — the cross-table is empty', - ).toBeGreaterThan(5); - - const unclaimed = objects - .filter((o) => seeded.has(String(o.name)) && ownerScoped(o) && !claimed.has(String(o.name))) - .map((o) => `${String(o.name)} (sharingModel: ${String(o.sharingModel)})`); - expect( - unclaimed, - 'these objects ship seed records AND declare the ownership column, so their rows\n' + - 'reach the database owned by nobody and nothing else can claim them. Under any\n' + - 'OWD that leaves them uneditable by every user, `system_admin` aside — and under\n' + - '`public_read` it does so while the rows still read normally, so the only symptom\n' + - 'is a 403 (#716). Add them to CLAIMED_OBJECTS:\n ' + unclaimed.join('\n '), - ).toEqual([]); - - // The other direction: claiming an object with no ownership column would - // stamp a field it does not declare, on rows where ownership means nothing. - const byName = new Map(objects.map((o) => [String(o.name), o])); - const claimedWithoutOwner = [...claimed].filter((name) => { - const object = byName.get(name); - return object != null && !ownerScoped(object); - }); - expect( - claimedWithoutOwner, - `these claimed objects declare no ${PLATFORM_OWNER}:\n ` + claimedWithoutOwner.join('\n '), - ).toEqual([]); - }); - - it('leaves no claimed object ownerless at the PLATFORM level', async () => { - const h = await runBootstrap(); - - const ownerless: string[] = []; - for (const object of claimedObjects()) { - for (const row of h.store[object] ?? []) { - if (row[PLATFORM_OWNER] == null) ownerless.push(`${object}/${row.id as string}`); - } - } - expect( - ownerless, - 'these rows came out of demo_bootstrap owned by nobody at the platform level.\n' + - `Under sharingModel:'private' that makes them read-only for EVERY user, admin\n` + - 'included, and blocks attachments on them:\n ' + ownerless.join('\n '), - ).toEqual([]); - }); - - it('claims the ownership column on every row it touches', async () => { - const h = await runBootstrap(); - - for (const object of claimedObjects()) { - const row = (h.store[object] ?? []).find((r) => r.id === `${object}_unowned`); - expect(row, `${object}_unowned vanished`).toBeTruthy(); - expect(row![PLATFORM_OWNER], `${object}_unowned: ${PLATFORM_OWNER} not claimed`).toBe(USER); - } - }); - - it('stamps no OTHER ownership-shaped column — one owner, or the #622 split is back', async () => { - // The half-claimed state of #622 needed two columns to exist in. This is - // the assertion that keeps it that way: a future sweep that starts writing - // a second `owner`-ish key re-creates the state the fixture above no longer - // has a shape for, and it would do so silently. - const h = await runBootstrap(); - for (const object of claimedObjects()) { - for (const row of h.store[object] ?? []) { - const ownerish = Object.keys(row).filter((k) => k === 'owner' || k.endsWith('_owner')); - expect(ownerish, `${object}/${row.id as string} grew a second ownership column`).toEqual([]); - } - } - }); - - it('never reassigns a record that already has a real owner', async () => { - const h = await runBootstrap(); - for (const object of claimedObjects()) { - const owned = (h.store[object] ?? []).find((r) => r.id === `${object}_owned`); - expect(owned?.[PLATFORM_OWNER], `${object}: overwrote a real owner`).toBe('rep_9'); - } - }); - - it('is a no-op on a fully claimed org', async () => { - const first = await runBootstrap(); - const settled = JSON.parse(JSON.stringify(first.store)) as Record; - - const second = await runBootstrap(JSON.parse(JSON.stringify(settled)) as Record); - expect(second.store).toEqual(settled); - }); - - it('does nothing at all before the first user exists', async () => { - const store = seedStore(); - store.sys_user = []; - const h = await runBootstrap(store); - - // Every row keeps exactly the ownership it started with — in particular the - // sweep must not stamp the literal `{firstUser.id}` placeholder. - for (const object of claimedObjects()) { - const unowned = (h.store[object] ?? []).find((r) => r.id === `${object}_unowned`); - expect(unowned?.[PLATFORM_OWNER], `${object}: claimed with no user present`).toBeNull(); - } - }); -}); - -/** - * #716 end to end: the two `public_read` families, over the records the seed - * loader actually ships. - * - * The block above runs on a synthetic two-row fixture per claimed object. That - * proves the sweep's MECHANICS and nothing about the real seed book — and #716 - * was not a mechanics bug: the sweep worked perfectly, it simply never looked - * at these two objects. So this runs it over the actual `crm_campaign` and - * `crm_knowledge_article` seed records, read from `src/data/` rather than - * restated here, and asserts the outcome the issue is about. - * - * Why these two hid so long is worth keeping in the fixture: their OWD is - * `public_read`, so every one of these rows READS normally for every user even - * while owned by nobody. Nothing is empty, nothing errors, no list is short. - * The only symptom is a write — and a demo org is read almost exclusively. - */ -describe('demo_bootstrap claims the real campaign and knowledge seeds (#716)', () => { - const ADMIN = 'usr_admin'; - const PLATFORM_OWNER = 'owner_id'; - - /** The two families, with the `externalId` their `defineSeed` upserts on. */ - const FAMILIES = [ - ['crm_campaign', 'name'], - ['crm_knowledge_article', 'title'], - ] as const; - - const seedRecordsOf = (object: string): Rec[] => - (CrmSeedData as unknown as Array<{ object: string; records: Rec[] }>).find( - (d) => d.object === object, - )?.records ?? []; - - /** - * One seed-loader pass, faithful to what the loader does: `mode: 'upsert'` on - * the dataset's `externalId` resolves to a PARTIAL update over the columns - * the seed declares when the row already exists, and to an insert otherwise. - * - * Partial is load-bearing — the seeds declare no `owner_id`, so a claimed - * owner survives a warm-boot replay. And a fresh insert lands with - * `owner_id: null` rather than with the key absent: the registry injects the - * column into every user-owned object, and the seed write only skips the - * security plugin's insert-time STAMP. That distinction decides whether the - * sweep can see the row at all — its filter is `{ owner_id: null }`, and an - * absent key is not null. The pushed row no longer states the column: the - * harness store materialises every declared column the way a driver does - * (#1458), which is what makes the seeded row visible to that filter. - */ - const loadSeeds = (store: Record) => { - for (const [object, externalId] of FAMILIES) { - const rows = (store[object] ??= []); - for (const rec of seedRecordsOf(object)) { - const key = String(rec[externalId]); - const existing = rows.find((r) => String(r[externalId]) === key); - if (existing) Object.assign(existing, rec); - else rows.push({ id: `seed_${object}_${key}`, ...rec }); - } - } - }; - - const freshStore = (): Record => { - const store: Record = { sys_user: [{ id: ADMIN, email: 'admin@objectos.ai' }] }; - loadSeeds(store); - return store; - }; - - const sweep = async (store: Record) => { - const h = makeFlowHarness({ demo_bootstrap: DemoBootstrapFlow }, store); - await h.run('demo_bootstrap', {}, { event: 'schedule' }); - return h; - }; - - const ownerless = (rows: Rec[]) => rows.filter((r) => r[PLATFORM_OWNER] == null); - - it('the seed book actually ships rows for both, and ships them ownerless', () => { - // Guard the guard, twice over. An empty family makes every case below - // vacuous, and a family that arrived already owned would mean the fixture - // no longer presents the defect the sweep is supposed to fix. - const store = freshStore(); - for (const [object] of FAMILIES) { - expect(seedRecordsOf(object).length, `${object} ships no seed records`).toBeGreaterThan(0); - expect(store[object].length, `${object} did not load`).toBe(seedRecordsOf(object).length); - expect( - ownerless(store[object]).length, - `${object}: seeds arrived owned — the fixture no longer shows the defect`, - ).toBe(store[object].length); - } - }); - - it('leaves no seeded campaign or article ownerless after one pass', async () => { - const h = await sweep(freshStore()); - - const stillOwnerless: string[] = []; - for (const [object, externalId] of FAMILIES) { - for (const row of ownerless(h.store[object] ?? [])) { - stillOwnerless.push(`${object}/${String(row[externalId])}`); - } - } - expect( - stillOwnerless, - 'these seeded rows came out of demo_bootstrap owned by nobody. Their OWD is\n' + - '`public_read`, so they still READ fine — the failure is silent until an edit,\n' + - 'which answers 403 for every user but system_admin (#716):\n ' + - stillOwnerless.join('\n '), - ).toEqual([]); - - for (const [object] of FAMILIES) { - for (const row of h.store[object]) { - expect(row[PLATFORM_OWNER], `${object}/${String(row.id)}: wrong owner`).toBe(ADMIN); - } - } - }); - - /** - * The permission half of #716, as far as this repo can honestly take it. - * - * This is a MODEL of the platform's write gate, not the platform's own code — - * `@objectstack/plugin-security` is not a dependency of this app and standing - * one up would test the platform rather than us. The model is one line of - * documented behaviour: a grant with `modifyAllRecords: false` and no - * `writeScope` resolves to the `own` write depth, whose write filter is - * `owner_id == caller`, and `public_read` does NOT exempt writes from it - * ("public_read is read-open but write-owned; only a fully public object is - * write-open" — `@objectstack/plugin-sharing` 17.0.0-rc.2, `buildWriteFilter`). - * - * So what this pins is not "the 403 is gone" — only a running server shows - * that. It pins the single input the app controls and #716 got wrong: the row - * a granted editor is measured against has an owner at all. The grants are - * read from the real profile metadata, so the case fails loudly if the - * premise it reasons from (edit granted, `modifyAllRecords` off) ever moves. - */ - it('turns a granted editor from zero editable rows into all of them', async () => { - const GRANTS = [ - ['crm_campaign', (MarketingUserProfile.objects as Rec).crm_campaign, 'marketing_user'], - ['crm_knowledge_article', (ServiceAgentProfile.objects as Rec).crm_knowledge_article, 'service_agent'], - ] as const; - - for (const [object, grant, profile] of GRANTS) { - expect(grant, `${profile} has no ${object} grant`).toBeTruthy(); - expect(grant.allowEdit, `${profile}.${object}: edit grant gone`).toBe(true); - expect(grant.modifyAllRecords, `${profile}.${object}: now modifies all records`).toBe(false); - expect(grant.writeScope, `${profile}.${object}: gained a writeScope`).toBeUndefined(); - } - - // The `own`-depth write filter, applied to the rows a granted editor faces. - const editable = (rows: Rec[], userId: string) => - rows.filter((r) => r[PLATFORM_OWNER] === userId); - - const before = freshStore(); - for (const [object] of GRANTS) { - expect( - editable(before[object], ADMIN).length, - `${object}: an editable row before the sweep — the fixture is wrong`, - ).toBe(0); - } - - const h = await sweep(before); - for (const [object] of GRANTS) { - expect( - editable(h.store[object], ADMIN).length, - `${object}: rows a granted editor still cannot reach`, - ).toBe(seedRecordsOf(object).length); - } - }); - - it('survives a warm boot — the replayed seed does not blank the claim', async () => { - // The seeds re-run on every boot. They declare no `owner_id`, so the upsert - // is a partial write and the claim must persist; if it did not, the sweep - // would re-claim forever and any real reassignment would be undone by the - // next restart. - const h = await sweep(freshStore()); - loadSeeds(h.store); - - for (const [object] of FAMILIES) { - expect(ownerless(h.store[object]).length, `${object}: re-seed blanked the owner`).toBe(0); - expect(h.store[object].length, `${object}: re-seed duplicated rows`).toBe( - seedRecordsOf(object).length, - ); - } - - const settled = JSON.parse(JSON.stringify(h.store)) as Record; - const again = await sweep(JSON.parse(JSON.stringify(settled)) as Record); - expect(again.store).toEqual(settled); - }); -}); diff --git a/test/flow-variable-conditions.test.ts b/test/flow-variable-conditions.test.ts index ff145face..892971cd2 100644 --- a/test/flow-variable-conditions.test.ts +++ b/test/flow-variable-conditions.test.ts @@ -135,11 +135,9 @@ import { flowGraphDeep, regionsOf } from './helpers/flow-regions'; * * ### What measured CLEAN, and why that is not the same as safe * - * - `demo_bootstrap` (`vars.firstUser`) — `get_user` dominates every read and - * binds `null` when the org has no users yet; the whole flow completes on a - * zero-user org (reproduced below). Nothing to fix. - * - `lead_conversion`'s `vars.matchedAccount` / `vars.matchedContact` — same - * shape, same reason: a `get_record` dominates each read. Nothing to fix, + * - `lead_conversion`'s `vars.matchedAccount` / `vars.matchedContact` — a + * `get_record` dominates each read and binds `null` when it matches nothing, + * so every read sees a bound variable. Nothing to fix, * and deliberately NOT guarded — a guard here would be the papering-over * the class table warns about. * - `quote_generation` (`oppRecord.stage`) and `opportunity_approval` @@ -1235,20 +1233,6 @@ describe('the two defects, reproduced end-to-end', () => { } }, 60_000); - it('demo_bootstrap: a zero-user org completes instead of aborting', async () => { - // The `vars.firstUser` reads measured CLEAN — `get_user` dominates them and - // binds `null`. This pins that, so a future edit that moves the read above - // the `get_record` is caught here rather than on a demo org. - const b = await boot(['demo_bootstrap']); - try { - const done = await b.engine.execute('demo_bootstrap', {}); - expect(done.error ?? null).toBeNull(); - expect(done.success).not.toBe(false); - } finally { - await b.close(); - } - }, 60_000); - it('quote_generation: an ordinary run still advances the stage', async () => { const b = await boot(['quote_generation']); try { diff --git a/test/forecast-seeds.test.ts b/test/forecast-seeds.test.ts index 0a73e5f99..7d2b5cc80 100644 --- a/test/forecast-seeds.test.ts +++ b/test/forecast-seeds.test.ts @@ -151,16 +151,16 @@ describe('forecast seed periods are calendar-true (#530)', () => { * * `forecast_snapshot` upserts the row whose window contains today, keyed by * OWNER. A seeded row in that same window cannot satisfy that lookup at the - * moment the sweep reads it — a seed writes no owner, and `demo_bootstrap`'s - * claim is a separate, later sweep — so the flow reports the period missing and - * opens a SECOND row beside it. Both span the quarter; one is ownerless. Every - * owner-grouped consumer then shows a phantom duplicate for the current - * quarter, on every re-seeded dev boot. + * moment the sweep reads it — a seed writes no owner, and the platform's + * seed-ownership claim is a separate, later write — so the flow reports the + * period missing and opens a SECOND row beside it. Both span the quarter; one + * is ownerless. Every owner-grouped consumer then shows a phantom duplicate + * for the current quarter, on every re-seeded dev boot. * - * Claiming `crm_forecast` (which `demo_bootstrap` now does, for the settled - * rows) does NOT make that safe: it only decides which of the two scheduled - * sweeps reaches the window first, and a duplicate opened by losing that race - * never heals. The invariant has to hold whatever the order, so it is enforced + * Claiming `crm_forecast` (which the platform's claim does, for the settled + * rows) does NOT make that safe: it only decides which of the claim and the + * scheduled sweep reaches the window first, and a duplicate opened by losing + * that race never heals. The invariant has to hold whatever the order, so it is enforced * where order cannot reach it — in the seed data. * * The forbidden window is derived from the flow's own lookup filter rather than diff --git a/test/hooks-runtime-sales.test.ts b/test/hooks-runtime-sales.test.ts index 7e296c0e5..76637c0f1 100644 --- a/test/hooks-runtime-sales.test.ts +++ b/test/hooks-runtime-sales.test.ts @@ -452,9 +452,10 @@ describe('account_protection', () => { }); it('does NOT stamp last_activity_date on a system write', async () => { - // demo_bootstrap claims ownerless seeded accounts as a system write every - // 10 minutes; stamping those flattened every seeded activity date to today - // and emptied the churn report buckets. + // The platform's seed-ownership claim re-owns ownerless seeded accounts as + // a system write (the retired demo_bootstrap sweep did, every 10 minutes); + // stamping those flattened every seeded activity date to today and emptied + // the churn report buckets. const input: Rec = { owner_id: 'rep2' }; await hook.handler(makeCtx({ event: 'beforeUpdate', input, previous: { owner_id: null }, user: SYSTEM, diff --git a/test/ownership-model.test.ts b/test/ownership-model.test.ts index 96858cb54..b24032b1a 100644 --- a/test/ownership-model.test.ts +++ b/test/ownership-model.test.ts @@ -369,7 +369,7 @@ describe('every owner-facing surface points at the one column', () => { it('no flow writes or addresses a bare `owner`', () => { // A `notify` addressed to `{record.owner}` resolves to nothing and reaches - // nobody — the exact silent failure `demo_bootstrap` exists to prevent. + // nobody — the exact silent failure an ownerless record causes. const bad: string[] = []; for (const f of flows) { for (const node of walk(f)) { diff --git a/test/saas-composition.test.ts b/test/saas-composition.test.ts index 7588a6a0a..d17c603a9 100644 --- a/test/saas-composition.test.ts +++ b/test/saas-composition.test.ts @@ -1,14 +1,10 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { describe, it, expect, beforeAll, vi } from 'vitest'; import { PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; -import { applySystemFields } from '@objectstack/objectql'; -import { ObjectQL } from '@objectstack/objectql'; -import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import defaultStack from '../objectstack.config'; import { CrmSeedData, SaasTenantSeedData } from '../objectstack.composition'; import { COMPOSITION_ENV_VAR, resolveComposition } from '../src/sales/data/index'; -import { DemoBootstrapFlow } from '../src/sales/flows/demo-bootstrap.flow'; import { SystemAdminProfile } from '../src/sales/profiles/system-admin.profile'; import { TenantAdminProfile } from '../src/sales/profiles/tenant-admin.profile'; import { DemoOrgStaffing } from '../src/sales/sharing/demo-staffing'; @@ -17,20 +13,19 @@ import { DemoOrgStaffing } from '../src/sales/sharing/demo-staffing'; * The SaaS / multi-org composition (#1361). * * `HOTCRM_COMPOSITION=saas` assembles the shape a multi-org operator deploys on - * the enterprise runtime under a walled tenancy posture. Three registrations + * the enterprise runtime under a walled tenancy posture. Two registrations * differ from the community app and nothing else does; these tests pin BOTH * directions, because the two failure modes are opposite and equally bad: * * - the SaaS shape quietly keeping something (a tenant receives another - * company's pipeline, or a demo sweep that crosses the wall), and + * company's pipeline), and * - the community shape quietly losing something (this card's one hard * boundary is that the default composition is behaviourally unchanged). * - * The last block is not an assertion about the app at all — it MEASURES, on a - * real engine under a walled posture, why `demo_bootstrap` is excluded. A - * comment claiming "this would cross the wall" is a story; a failing engine is - * evidence, and if the platform ever stops behaving that way this block goes - * red and the exclusion can be revisited on measurement rather than on memory. + * The flows used to be a third difference: the SaaS shape dropped the + * `demo_bootstrap` ownership sweep, which crossed the organization wall. The + * sweep is retired from the app (#1892), so both shapes register the same + * flows, and that is pinned below too. */ type AnyRec = Record; @@ -111,10 +106,6 @@ describe('the default composition is the community app, untouched', () => { } }); - it('still ships demo_bootstrap', () => { - expect(nameOf((defaultStack as AnyRec).flows as AnyRec[])).toContain(DemoBootstrapFlow.name); - }); - it('still ships system_admin, and does NOT ship tenant_admin', () => { const setNames = nameOf((defaultStack as AnyRec).permissions as AnyRec[]); expect(setNames).toContain(SystemAdminProfile.name); @@ -165,16 +156,12 @@ describe('HOTCRM_COMPOSITION=saas', () => { expect(referenceValues).toEqual([]); }); - it('drops demo_bootstrap — and drops exactly it', () => { + it('registers exactly the flows the community app does', () => { + // Flows are no longer a difference between the shapes (#1892), so any + // divergence here is a registration nobody decided on. const flowNames = nameOf(saas.flows as AnyRec[]); - expect(flowNames).not.toContain('demo_bootstrap'); - // The other direction, which is the one a broken filter fails: every OTHER - // flow the community app ships is still registered. A filter that matched - // nothing would pass the line above only if the flow were already gone. - const communityNames = nameOf((defaultStack as AnyRec).flows as AnyRec[]); - expect(flowNames).toEqual(communityNames.filter((n) => n !== 'demo_bootstrap')); - expect(communityNames).toContain('demo_bootstrap'); - expect(flowNames.length).toBe(communityNames.length - 1); + expect(flowNames, 'the SaaS composition registered no flows at all').not.toEqual([]); + expect(flowNames).toEqual(nameOf((defaultStack as AnyRec).flows as AnyRec[])); }); it('replaces system_admin with tenant_admin, leaving the other personas alone', () => { @@ -266,128 +253,3 @@ describe('tenant_admin is an ORG admin, judged by the platform capability regist expect(TenantAdminProfile.objects).toEqual(SystemAdminProfile.objects); }); }); - -// ──────────────────────── WHY demo_bootstrap is excluded — measured, not ── -// ──────────────────────── asserted: a system context has no organization ── - -describe("the demo_bootstrap sweep crosses the wall — the engine's own answer", () => { - /** - * The flow's two moves, taken from the SHIPPED flow definition rather than - * transcribed, so this cannot drift into measuring something the flow no - * longer does: - * - * 1. select rows whose ownership column is null (`get_record` + filter) - * 2. stamp the first user's id onto each one (`update_record` + fields) - * - * …performed through the engine surface `runAs: 'system'` gives the flow: a - * system execution context. That context is the one the driver's organization - * predicate does not constrain, which is the whole finding — the sweep is not - * "unnecessary" under the wall, it is an identity crossing it. - * - * `OS_TENANCY_POSTURE=isolated` is set because a walled deployment sets it, - * not because the result depends on it: the predicate is the DRIVER's, - * compiled from the execution context's tenant id, and a system context - * carries none. The knob makes the engine's registry organization-scoped; it - * is not what makes the sweep unwalled. Saying so keeps this block a - * measurement of the real mechanism rather than of an environment variable. - */ - const claimed = (() => { - const out: Array<{ objectName: string; column: string }> = []; - for (const node of (DemoBootstrapFlow.nodes ?? []) as AnyRec[]) { - if (node.type !== 'get_record') continue; - const config = (node.config ?? {}) as AnyRec; - const filter = (config.filter ?? {}) as AnyRec; - const [column, value] = Object.entries(filter)[0] ?? []; - if (typeof config.objectName !== 'string' || !column || value !== null) continue; - out.push({ objectName: config.objectName, column }); - } - return out; - })(); - - const probe = claimed.find((c) => c.objectName === 'crm_account'); - - let driver: SqliteWasmDriver; - let ql: AnyRec; - let previousPosture: string | undefined; - - beforeAll(async () => { - previousPosture = process.env.OS_TENANCY_POSTURE; - process.env.OS_TENANCY_POSTURE = 'isolated'; - - const objects: AnyRec[] = (defaultStack as AnyRec).objects ?? []; - const account = objects.find((o) => o.name === 'crm_account')!; - const objectMap: Record = { crm_account: account }; - - driver = new SqliteWasmDriver({ filename: ':memory:' }); - ql = (await ObjectQL.create({ - datasources: { default: driver as never }, - objects: objectMap as never, - } as never)) as AnyRec; - - const scoped = applySystemFields(account as never, { multiTenant: true } as never) as AnyRec; - await driver.initObjects([ - { name: 'crm_account', fields: scoped.fields, indexes: scoped.indexes } as never, - ]); - }, 60_000); - - afterAll(async () => { - await ql?.close?.(); - if (previousPosture === undefined) delete process.env.OS_TENANCY_POSTURE; - else process.env.OS_TENANCY_POSTURE = previousPosture; - }); - - it('reads the sweep out of the shipped flow rather than assuming it', () => { - // If the flow stops selecting ownerless rows, this file must stop claiming - // to have measured what it does. - expect(claimed.length).toBeGreaterThan(0); - expect(probe, 'demo_bootstrap no longer sweeps crm_account by a null ownership column').toBeTruthy(); - expect(probe!.column).toBe('owner_id'); - }); - - it('SENTINEL — an ordinary tenant session cannot see the other tenant at all', () => { - // The wall must be ON before the next test's silence means anything. - return (async () => { - const a = ql.createContext({ userId: 'usr_a', tenantId: 'org_a' }); - const b = ql.createContext({ userId: 'usr_b', tenantId: 'org_b' }); - await a.object('crm_account').insert({ name: 'Tenant A Co', type: 'customer', owner_id: 'usr_a' }); - await b.object('crm_account').insert({ name: 'Tenant B Co', type: 'customer', owner_id: 'usr_b' }); - const seenByA = (await a.object('crm_account').find({})) as AnyRec[]; - expect(seenByA.map((r) => String(r.name))).toEqual(['Tenant A Co']); - expect(seenByA.every((r) => r.organization_id === 'org_a')).toBe(true); - })(); - }); - - it("a runAs:'system' sweep selects rows in EVERY organization", async () => { - const sys = ql.createContext({ isSystem: true }); - await driver - .getKnex() - .raw( - `insert into crm_account (id, name, type, organization_id, owner_id) values ` + - `('sweep_a','Ownerless A','customer','org_a',null), ` + - `('sweep_b','Ownerless B','customer','org_b',null)`, - ); - const selected = (await sys - .object(probe!.objectName) - .find({ where: { [probe!.column]: null } })) as AnyRec[]; - const orgs = new Set(selected.map((r) => String(r.organization_id))); - expect( - orgs.has('org_a') && orgs.has('org_b'), - `the sweep saw only ${[...orgs].join(', ')} — a system context is expected to be unwalled`, - ).toBe(true); - }); - - it('and stamps ONE organization’s user onto ANOTHER organization’s row', async () => { - // The defect in one line: `{firstUser.id}` is whoever `sys_user`'s first row - // happens to be — one identity, written across every partition. - const sys = ql.createContext({ isSystem: true }); - await sys.object('crm_account').update({ id: 'sweep_b', owner_id: 'usr_a' }); - const [row] = (await driver - .getKnex() - .raw(`select organization_id, owner_id from crm_account where id = 'sweep_b'`)) as AnyRec[]; - expect(row.organization_id).toBe('org_b'); - expect( - row.owner_id, - 'the wall refused the cross-organization owner — re-evaluate whether demo_bootstrap still needs excluding', - ).toBe('usr_a'); - }); -}); From e5acc256755d557cd64dfcfd950aa45dda6d7c0c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 22:36:37 +0000 Subject: [PATCH 2/3] docs: state the 30 flows the stack now registers Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01ER8ntXZhYebyQ66aXWdjfT --- README.md | 4 ++-- docs/STATUS.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6270bbc9b..bf4688a8a 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ # HotCRM > **The reference app for AI-written enterprise software.** A complete CRM — -> 18 objects, 31 flows, 5 dashboards, 6 AI skills, 4 languages — built as four +> 18 objects, 30 flows, 5 dashboards, 6 AI skills, 4 languages — built as four > packages (sales, service, revenue, marketing) that compile to one artifact. > The **sales package**, the one a customer installs, carries its whole > business semantics (objects, flows, actions, hooks) in **~54k tokens** @@ -69,7 +69,7 @@ HotCRM is a complete, opinionated CRM built as the **first official application* | `crm_event` | | | | | `crm_event_attendee` | | | | -Plus **6 AI skills** (a skills-only surface — HotCRM defines no agents of its own; the skills attach to the platform `ask` assistant), **5 dashboards**, **31 flows**, **31 actions**, **9 datasets**, **4 language bundles** (en, zh-CN, es-ES, ja-JP), **6 permission profiles**, **12 positions**, and **9 sharing rules**. +Plus **6 AI skills** (a skills-only surface — HotCRM defines no agents of its own; the skills attach to the platform `ask` assistant), **5 dashboards**, **30 flows**, **31 actions**, **9 datasets**, **4 language bundles** (en, zh-CN, es-ES, ja-JP), **6 permission profiles**, **12 positions**, and **9 sharing rules**. > **Business reader?** The ObjectStack docs tour every one of these capabilities in plain business language — [What Can It Do?](https://objectstack.ai/docs/capabilities) — with HotCRM as the running example on every page. diff --git a/docs/STATUS.md b/docs/STATUS.md index 844ece977..9032898ad 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -21,7 +21,7 @@ loader registers: HotCRM v3.1.0 Data: 18 Objects 343 Fields UI: 1 Apps 14 Views 8 Pages 5 Dashboards 10 Reports 31 Actions -Logic: 31 Flows +Logic: 30 Flows Security: 12 Positions 7 Permissions ``` From 5e0fe91fae30bcfd8c45fd6985d13c1320dbb56a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 22:49:54 +0000 Subject: [PATCH 3/3] docs(changeset): scope the seed-ownership claim to a new install's first boot The platform's seed-settle claim covers a new install's first boot only; seed rows a later upgrade adds to an existing install are not claimed yet, tracked upstream in objectstack-ai/objectstack#21486. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01ER8ntXZhYebyQ66aXWdjfT --- .changeset/1892-demo-bootstrap-run-once.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.changeset/1892-demo-bootstrap-run-once.md b/.changeset/1892-demo-bootstrap-run-once.md index e8db7b5f0..75fc170a8 100644 --- a/.changeset/1892-demo-bootstrap-run-once.md +++ b/.changeset/1892-demo-bootstrap-run-once.md @@ -10,10 +10,11 @@ and gave them to the first user. A seed cannot name a user, so seeded demo recor with no owner, and the flow existed to fix that after the fact. On a production tenant it ran 1,776 times in 13 days, took up to 26 minutes, and changed nothing after its first pass. -From ObjectStack 17.6.0 the platform does this itself, once: when the seed data finishes -loading, it hands every seeded record that has no owner to the first administrator. A fresh -`pnpm dev` boot on this release, with the flow kept from running, leaves no ownerless record -on any of the twelve objects the flow used to cover. So the flow is removed. +From ObjectStack 17.6.0 the platform does this itself, on a new install's first boot: when the +seed data finishes loading, it hands every seeded record that has no owner to the first +administrator. A fresh `pnpm dev` boot on this release, with the flow kept from running, +leaves no ownerless record on any of the twelve objects the flow used to cover. So the flow is +removed. **What changes for you:** @@ -21,8 +22,9 @@ on any of the twelve objects the flow used to cover. So the flow is removed. `sys_job`, and **Flow Runs** no longer shows a Demo Bootstrap run every ten minutes. - HotCRM now ships 30 flows, eight of them scheduled. The admin *Automation* page says so in all three locales. -- Seeded demo records are still owned by the first administrator, as before. `pnpm demo:staff` - works unchanged. +- Seeded demo records are still owned by the first administrator, as before. Known limit: seed + records that a later upgrade adds to an existing install do not get an owner from the + platform yet (tracked in objectstack-ai/objectstack#21486). `pnpm demo:staff` works unchanged. - The `saas` composition no longer differs from the community app in its flows. It already left this flow out.