Skip to content

[Decision] group posture is an on-premise shape — should package-authored scheduled flows run under group with the switch on, and which organization do their writes carry? (ruling G item 3, reopened by the maintainer) #18378

Description

@os-elon-musk

Filed by the director seat (session_01WCEaPsmKY4UyoivKkkaUHt) on the maintainer's instruction during the 2026-09-16 design discussion of PR #18198. Maintainer, verbatim and untranslated (live PM chat):

group 模式是本地部署的,运行 schedule 应该是可以的,但是你没有权限,可以单独开一个决策卡

⇒ Ruling G item 3 (#17396, 5642381255: 「group is off by default, like isolated」, and with the switch on 「behaves as walled」) is reopened for group only by the maintainer's own words; it stays a tenancy-boundary decision, so this seat files the card and does not rule it. ⛔ Nothing about single or isolated is reopened; ⛔ nothing here delays PR #18198.

一句话问题

一个集团版本地部署(group 姿态:多个工厂或分公司作为组织,共用一个数据库)把定时开关打开之后,包内的定时流要不要跑;跑的时候,它写出来的通知、运行记录、待办归哪个组织。

Governing text

  • Ruling G item 3 and its recorded reason: 「resolveSystemWriteOrganization refuses an organization-less system insert under any wall (walled-posture), and TenancyService.defaultOrgId() answers null under any wall by ADR-0093 D3. An organization-less scheduled run in group could read the whole group and update records in place, but the inbox, delivery and sys_automation_run rows it inserts would be refused … Which organization a group-wide sweep's inserts belong to is the part the maintainer said is not yet thought through」.
  • ADR-0105 D1: group = 「organizations share one database as data」 with membership/invitation boundaries, group-wide visibility and cross-org workflow inherent to the shape (the multi-plant MES example is the ADR's own); postureEnforcesWall('group') = true, postureStampsOrganization('group') = true (packages/spec/src/security/tenancy-posture.ts).
  • ADR-0093 D3: target-org resolution consumes declared mode — single ⇒ the bootstrap organization (slug='default'); a walled mode ⇒ none, the framework never guesses.
  • The 2026-09-08 ruling on A schedule-triggered flow's notify delivers nothing on a multi-organization install: the run carries no organization, so the tenant-scoped inbox/delivery writes are refused (#8844) while the run reads healthy #16659 (「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务」) — made for the multi-tenant shape; whether it binds a group (one legal group, cross-org visibility inherent) is exactly what this card asks.
  • After PR feat(types,triggers,service-automation,runtime,cli,spec,lint)!: package-authored scheduled work is a deployment decision, off by default #18198: resolveScheduledWorkPolicy() (packages/types/src/env.ts) answers group + on ⇒ requiresActingOrganization: true; schedule-organization.zod.ts's docblock and .describe() say the declaration is required under a walled posture. Option A changes that protocol prose and that row — 「协议不正确的应该先修改协议」 applies, so the change is in packages/spec / packages/types first.

Premises, with re-check commands

git grep -n "requiresActingOrganization" origin/main -- packages/types/src/env.ts          # the policy table (after #18198 lands)
git grep -n "defaultOrgId" origin/main -- packages/spec/src packages/core/src | head          # null under walls (ADR-0093 D3)
git grep -n "walled-posture" origin/main -- packages/spec/src/api/error-code-ledger.zod.ts  # the refusal an org-less system insert meets
git grep -n "never filled from the swept row" origin/main -- packages/triggers               # the time_relative pin option A retargets

Options × real cost

what what a customer sees
A (seat recommends) group + on binds without a declaration, like single. Writes follow the record: a time_relative run carries the swept record's organization_id; a cron schedule run that has no record carries config.organization if declared, otherwise the group's bootstrap organization (slug='default'), and the boot/audit line states which. Group-wide reads stay group-wide (inherent to the posture). Packaged schedules (contract expiry, SLA monitors, reminders) run on a multi-plant install out of the box; a record-less cron flow's artefacts land in the group's home organization unless the author declares one
B Keep ruling G: group stays walled; declaration required; packaged flows do not run; admins clone per organization (ADR-0126 §7.1) Every plant clones every packaged schedule by hand
C Automatic per-organization fan-out under group: one run per organization, reads scoped to it Unambiguous ownership, no cross-org logic possible, cost ∝ organizations, new scheduler machinery
D Bind without a declaration, but a write whose organization cannot be resolved (record-less cron, no declaration) is refused loudly (ADR-0112 envelope); record-derived writes follow the record Strictest; a cron flow that emits notifications without declaring fails at runtime, by name

业务含义直译

A = 集团总部的定时任务照常跑,单据写到它所属的工厂,没有单据的写到总部。B = 每个工厂各自复制一份任务。C = 每个工厂各跑一份,总部看不到跨厂的汇总。D = 跑可以,写不清归属就当场拒绝。

四轴(从业务立场)

  • ① 项目长远合理性:group 按 ADR-0105 就是「一家集团、多个分支、一个数据库」,集团级批处理是这个形状的固有能力(主流 ERP 多工厂、Salesforce/Dynamics 多业务部门都在一个 org 内跑全局批处理,行级归属由记录自己决定)。A 只新增一条「无记录时归总部」的兜底规则;C 新增一套扇出机制;B 把成本永久推给管理员。⇒ A。
  • ② 实际业务拉动:今天任何 group 本地部署要跑包内定时流只能逐组织克隆;维护者本人指出 group 是本地部署、应可运行。⚠️ 本席未测有多少 group 部署在线。
  • ③ 防 AI 犯错:静默风险在 A 的兜底 —— 写进总部组织的通知可能没人读;缓解是 boot/audit 行点名兜底组织、os doctor 打印。D 最响亮但把失败推到运行时。B/C 无此风险但代价见①。
  • ④ 创业阶段不扩散:A 一条规则、复用 ADR-0093 D3 已有的 bootstrap 组织概念;C 是机制;B 零新增但人工成本永久。

Prior rulings read: group,posture,schedule,organization,wall,defaultorgid,time_relative,bootstrap → 66 hits; ADR-0105 D1/D4/D5, ADR-0131 D3, ADR-0057 D9, ADR-0099 D2, ADR-0106 D6, ADR-0120 D5 — none rules the write-ownership of an organization-less scheduled run under group; ruling G item 3 recorded it as unanswered.

推荐:A(回退 D)。自检:只看①选 A;②③④ 是否翻转:否(③ 只要求兜底被响亮说出)。
置信缺口:① 09-08 「不允许跨组织的定时任务」是否有意覆盖 group(其上下文是多租户),只有维护者能答;② sys_automation_run / inbox 的其他消费者是否假定 group 下的 tenantId 必来自声明;③ 现网 group 部署数未测。

裁后执行

  • A ⇒ spec 卡(packages/spec 先:schedule-organization.zod.ts 的 docblock 与 .describe()、ADR-0087 条目 18 的 group 行),再 types/triggers/service-automation:resolveScheduledWorkPolicy 的 group 行改为不要求声明并带写归属规则;time_relative 在 group 下把 tenantId 从被扫记录填入(退掉「never filled from the swept row」在 group 上的那半钉子);无记录的 cron 走 config.organization 否则 bootstrap 组织,boot/audit 行点名;文档四页;changeset minor + BREAKING;Clause-②: yes。在 PR feat(types,triggers,service-automation,runtime,cli,spec,lint)!: package-authored scheduled work is a deployment decision, off by default #18198 落地之后再动,⛔ 不并入它。
  • B ⇒ 关卡 not planned,ruling G 原样。
  • C ⇒ 扇出设计卡(ADR 级)。
  • D ⇒ 同 A 的绑定改动,写归属改为拒绝。

Refs: #17396 (ruling G) · PR #18198 · #16659 (2026-09-08 ruling) · ADR-0105 · ADR-0093 D3 · ADR-0126 §7.1

os-decision-facets

  • ① 项目长远合理性:group 是一家集团一个库,集团级批处理是固有能力;A 一条兜底规则,C 一套机制,B 永久人工。
  • ② 实际业务拉动:group 本地部署今天只能逐组织克隆;维护者指出应可运行;部署数未测。
  • ③ 防 AI 犯错:A 的兜底须在 boot/audit 行点名;D 最响亮但失败在运行时。
  • ④ 创业阶段不扩散:A 复用 bootstrap 组织概念;C 新增扇出机制。
  • Prior rulings read: group,posture,schedule,organization,wall,defaultorgid,time_relative,bootstrap → 66 hits; ADR-0105 D1/D4/D5, ADR-0131 D3, ADR-0057 D9, ADR-0099 D2, ADR-0106 D6, ADR-0120 D5;皆不裁。
  • 推荐:A(回退 D);只看①选 A;②③④ 是否翻转:否。

维护者速读

事情。 您说 group(集团版本地部署,多个工厂或分公司共用一个库)应该可以跑定时任务。裁决 G 当时把 group 和云上多租户一样默认关、开了也要每条流声明组织,原因是那时没想清楚:一条集团级定时任务写出来的通知和运行记录归哪个组织——在围墙姿态下,没有组织的系统写入会被拒绝。

选项。 A 像单租户一样直接跑;写入跟着记录走(扫描到哪个工厂的单据就归哪个工厂),没有单据的任务归总部组织,启动日志写明。B 维持现状,每个工厂自己克隆一份。C 自动给每个组织各跑一份,互不可见。D 跑可以,但写不清归属的当场拒绝。

席位意见。 推荐 A:group 的定义就是一家集团一个库,跨分支可见是它的本意;A 只多一条兜底规则,并且在日志里说清楚。回退 D。

你要做的:回一个字母 —— A / B / C / D。


Generated by Claude Code

Activity

  1. hotlong commented on Sep 16, 2026

    @hotlong
    Contributor

    Ruling — A′ (maintainer, 2026-09-16)

    Maintainer, verbatim and untranslated (live PM chat), ruling on the options as put to them:

    同意并处理

    The option agreed to is A′ — the card's A with its record-less fallback replaced by a refusal. A′ is this seat's wording, not the maintainer's; it was put to them as the recommended arm beside the card's own A / B / D and selected. Stated in full:

    group + switch on binds without a declaration, like single.

    • time_relative: the run carries the swept record's organization_id; writes follow the record.
    • cron schedule (no record): carries config.organization when declared; when it is not declared, the write is refused loudly (ADR-0112 envelope) — ⛔ never a guessed fallback organization.
    • Group-wide reads stay group-wide (inherent to the posture).
    • The boot/audit line names the effective binding in both directions.

    ⇒ Card options A, B, C and D are closed. A′ = A's binding + A's record-derived writes + D's refusal on the record-less cron cell only.

    Why A′ and not the card's A — two findings that moved it

    Both were measured on origin/main before the ruling and are the whole of the delta:

    1. A's slug='default' fallback does not reliably exist under group, and where it does it is probably not the head office. Under a wall AuthPlugin skips its own default-organization bootstrap and hands the job to the enterprise @objectstack/organizations runtime, whose helper is admin-keyed: it binds the account resolved by the config anchor or the legacy cross-tenant grant, and answers no_admin for anyone else — so a group install with no resolvable platform admin bootstraps no organization at all (packages/plugins/organizations/src/walled-default-org-self-registrant.pin.test.ts). Where one does exist it is whichever organization the platform owner registered under — on a multi-plant install, plausibly a plant rather than the group's head office. A's fallback therefore lands a group-wide cron's notifications in one arbitrary plant's inbox, silently. That is sharper than the card's ③ axis recorded it ("可能没人读"): the failure is a wrong owner, not an unread one, and a wrong organization_id is authoritative to every report, export and cleanup script that filters by organization. A′ avoids it, and avoids amending ADR-0093 D3 (defaultOrgId() answers null under any wall) or standing up a second resolver beside it.

    2. A's record-derived half is already the resolution order elsewhere, so it is a narrowing of drift rather than a new principle. suspended-run-store.ts:916 resolves ownership as organizationOf(<record>) ?? ctx.tenantId ?? null — subject first, context second — and the run-history row is stamped the same way (recorded in packages/qa/dogfood/test/schedule-sweep-organization-scope.dogfood.test.ts's header). This closes the card's confidence gap ② on the record-derived side: the other consumers of sys_automation_run / inbox do not assume a declared tenantId under group. The gap survives only for the record-less cron cell, which is exactly the cell A′ refuses.

    Governing text

    Scope of the reopening, unchanged

    ⛔ Nothing about single or isolated is reopened. Ruling G item 3 stands for isolated; the deployment switch itself (OS_AUTOMATION_SCHEDULED_WORK_ENABLED, default OFF in every posture) is untouched — A′ decides only what binds once the operator has turned it on under group.

    Blocked-by — cleared, verified live

    The card's 裁后执行 gated execution on PR #18198 ("在 PR #18198 落地之后再动, ⛔ 不并入它"). Verified at ruling time, not recalled: #18198 is merged (merged_at 2026-09-16T09:01:54Z, merged: true). The gate is discharged and nothing was merged into that PR. This card moves straight to a working state rather than pm:blocked.


    Generated by Claude Code

  2. self-assigned this
    on Sep 16, 2026
  3. hotlong commented on Sep 16, 2026

    @hotlong
    Contributor

    Claim: session_01URii26ZSYx4xPZ9ai47ceH · branch claude/zealous-mendel-o0o6aq

    Executing ruling A′ (Ruling-ref: 5695424700, retrieved this pass). Blocked-by: #18198 discharged — verified live, not recalled: merged: true, and resolveScheduledWorkPolicy now resolves on origin/main (git grep -c "resolveScheduledWorkPolicy" origin/main -- packages/types/src/env.ts → 1).

    Execution order follows the card's 裁后执行 with A's fallback cell replaced per the ruling: packages/spec first (Prime Directive #12 — 协议不正确的应该先修改协议), then types / triggers / service-automation, then docs + changeset.

    ⛔ The branch name is the session's designated branch rather than claude/issue-18378-*; this comment is the discoverability record for #18378.


    Generated by Claude Code

  4. removed their assignment
    on Sep 18, 2026
  5. hotlong commented on Sep 18, 2026

    @hotlong
    Contributor

    Landed — PR #18420 merged 2026-09-18T15:35Z

    Ruling A′ (Ruling-ref: 5695424700) is implemented and on main. Fixes #18378 auto-closed this card; pm:dispatched and the assignee were cleared by hand in the same action.

    What landed, per posture, with the switch on:

    posture declaration a bound run's writes act as
    single not read nothing — the install's one organization resolves beneath each write
    group optional declared ⇒ the declaration; undeclared ⇒ the swept record's own organization
    isolated required unchanged — undeclared ⇒ not armed

    A record-less run under group that declared nothing resolves nothing and takes the existing walled-posture refusal at its first tenant-scoped write, by name. ⛔ The card's option-A fallback to the bootstrap organization (slug='default') was not implemented: it was measured as unreliable under a wall (admin-keyed bootstrap, may not exist) and, where it exists, plausibly one plant rather than the group's head office — a wrong owner, silently authoritative to every report that filters by organization.

    One design change the card did not anticipate. The first implementation read the swept record's organization through the STAMP resolver, which made the sweep a fourth consumer of tenancy.organizationField — a key whose contract pins its consumers to three named platform-row writers. That was caught by the independent contract review and answered by redesign rather than by a ruling: @objectstack/metadata-core now has a WALL face (resolveRecordWallOrganizationField / createRecordWallOrganizationResolver) that shares limbs 1–4 with the stamp face and skips limb 0 entirely, and the sweep binds that. The key's consumer list is untouched.

    Follow-ups filed from this work:

    Contract review disposition: an isolated review subagent returned PASS WITH FINDINGS; all three findings (a changeset missing @objectstack/cli, a stale docblock still claiming the retired "any walled posture" rule, a grammar defect in a published .describe()) were verified against the tree and fixed in d563fac6. ⚠️ The machine-read pair on that verdict is UNJUDGED rather than clean — recorded on the PR, and the rule behind it is what #19061 retires.


    Generated by Claude Code

  6. added a commit that references this issue on Sep 28, 2026
    0a56d3b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions