Skip to content

Schedule 触发器不携带任何 org 上下文——定时 runAs:'system' 清扫在多组织部署里仍会产出 organization_id=NULL 的行(#5494 的用户缺席半边) #6155

Description

@hotlong

PR #6153(修 #5494)把 runAs:'system' 运行的三列染色收口到「引擎知道就染」:触发上下文带 user/org 的运行(手动触发、record-change、带鉴权的 api 触发)三列全染。但 schedule 触发器本身不供给任何 org:

// packages/triggers/trigger-schedule/src/schedule-trigger.ts(handler 内)
const ctx: AutomationContext = {
    event: 'schedule',
    params: { jobId, flowName, schedule },
};

没有 tenantId,引擎无从染 organization_id。于是 #5494 Impact 一节点名的主形态——定时清扫(生产里 sweep 是按 schedule 火的,不是手动)——在多组织部署里仍会把行生在组织分区之外:(organization_id, …) 唯一索引跨 NULL 不生效,org 作用域查询看不见(hotcrm#698 的重复编号证据即此类)。created_by/owner_id 在无用户运行下保持 NULL 是 ADR-0118 D1 的既定表示(PR #6153 已论证并钉住),org 不同:org 不是 actor 列,「这行属于哪个组织」对定时清扫是有真实答案的,只是当前没有任何声明面能把答案交给触发器。

需要一个设计裁决:定时流程的 org 从哪来。候选:

  1. flow 元数据的存储 org(org overlay 的流程天然归属其 org;平台级流程无 org → 维持不染);
  2. schedule 绑定面新增显式 org 声明(作者声明,publish 校验);
  3. 每-org 各火一次(Salesforce 式 per-org scheduling,改动最大);
  4. 维持现状 + lint:多组织部署里,含 create_record 的 schedule 流程若未在 fields 显式给 organization_id / owner_id,发布时警告或拒绝(不新增运行时语义,author-time 收口,与 fix(security): surface schedule/user-less flow runAs fail-open (#1888 follow-up) #2308/Author-time expression validator is blind to registry-injected system columns — record.owner_id / created_at / organization_id are rejected as unknown fields #5378 的既有 lint 面同族)。

单组织部署不受影响(org 列本就 NULL 语义)。与 ADR-0073 M2(automation principal,管 actor 不管 org)正交。

Refs:#5494、PR #6153(修复边界在测试 runas-system-stamping.integration.test.ts 的 user-less 用例里显式钉住)、ADR-0118 D1/D5、ADR-0073 D2/D3、hotcrm#698。查重:仓内无既有单(搜 schedule org tenantId / organization_id schedule flow NULL 均无命中)。


裁决后拆解(2026-08-07,维护者批复 Q1=B / Q2=A / Q3=A,记录见评论 5217380634):方案 1 不实施(前提被探针证伪,见评论 5215848740/5215882772);余下工作全部转移,本单转 pm:blocked 等两单落地后关闭:

Blocked-by: #6283
Blocked-by: #6285

Activity

  1. claude commented on Aug 7, 2026

    @claude
    Contributor

    Triage: needs-user-decision + domain:services + target:v17. Filed 03:52Z, inside this round's own window; picked up rather than deferred.

    Why the decision box and not the queue. The body names four candidate answers to one question — where does a scheduled flow's org come from — and they are not variants of one fix: (1) inherit from the flow metadata's storage org, (2) declare it on the schedule binding, (3) fire per-org, (4) leave runtime semantics alone and reject/warn at publish time. They differ in where the answer is authored, in whether existing scheduled flows change behaviour on upgrade, and in blast radius from a lint rule up to per-org scheduling. That is a product-semantics call on a public contract surface, so it is the maintainer's, not this seat's. Routing was never in question, so nothing here is an escalation of "which repo" — it is an escalation of the underlying product question, which is the sanctioned path.

    domain:services — the landing site for every candidate except (4) is packages/triggers/trigger-schedule/src/schedule-trigger.ts, and packages/triggers/* (flow triggers) is on the domain:services row of the SKILL domain table. Candidate (4) would land partly in the publish-time lint surface instead; recording that now, because if the maintainer picks (4) the label should be re-examined rather than inherited.

    target:v17 — and this is the part worth arguing rather than asserting. Criterion ③: rows written outside the org partition are a stored-data shape, and a (organization_id, …) unique index does not constrain across NULL, so the damage is duplicate/invisible records that a later fix cannot retroactively repartition. The body also identifies the dominant production shape correctly — sweeps fire on a schedule, not by hand — so PR #6153 closing the engine-side half does not close the case that #5494 was filed for. Multi-org deployments only; single-org is unaffected by construction.

    Putting a decision-pending card on the release board is deliberate, not an accident of labelling: it is how the maintainer sees that something needs an answer before the RC, rather than discovering it after. If the full decision cannot fit the v17 window, candidate (4) is the cheap containment — author-time rejection adds no runtime semantics and reuses the existing #2308/#5378 lint surface — and it can ship while (1)–(3) stay open.

    Dedup — the filer's search claim re-verified independently, because the search API is unavailable to this seat and their negative result came from a tool this seat cannot reproduce. Full paginated enumeration of open issues and PRs across all three repos. Related but distinct: #5494 (the parent; its engine-side half is PR #6153, which the body correctly reads as not covering the schedule path), ADR-0073 M2 (automation principal — actor columns, explicitly orthogonal to org). No duplicate; no shadow in objectui or cloud.

    One boundary this seat endorses on the evidence. The body's distinction is the load-bearing one and should survive into whatever gets decided: created_by / owner_id staying NULL under a user-less run is settled representation (ADR-0118 D1, pinned by PR #6153), whereas organization_id is not an actor column — "which org does this row belong to" has a real answer for a scheduled sweep, and today no declaration surface exists to carry it. The decision is about building that surface, not about revisiting D1.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. self-assigned this
    on Aug 7, 2026
  3. hotlong commented on Aug 7, 2026

    @hotlong
    ContributorAuthor

    认领:PM 循环第 6 轮(rc.5 发布后恢复)
    会话:session_015a5qkLzpGXhLL2F5gvJ7dD
    分支:claude/issue-6155-schedule-org-inherit
    Worktree:objectstack-6155
    域:domain:services
    文件面:packages/triggers/trigger-schedule/src/ + packages/services/service-automation/src/(flow 元数据 org 的取用与 publish 校验半边;lint 护栏的确切落点由 dev 定位后申报,越界即停)

    按 2026-08-07 06:50Z 维护者裁决实施:方案 1 主路径(org 取 flow 元数据存储 org,触发上下文携带,引擎照 #6153「知道就染」收口;平台级流程维持 NULL)+ 方案 4 残余护栏(publish lint:多组织下「平台级 + schedule + create_record + 未显式 org」拒绝)。⛔ 不新增 schedule 绑定 org 键、不做 per-org 调度;ADR-0118 D1 边界不动(created_by/owner_id 无用户运行保持 NULL)。


    Generated by Claude Code

  4. hotlong commented on Aug 7, 2026

    @hotlong
    ContributorAuthor

    开发座位前提核验结果:主路径(方案 1)所依赖的「flow 存储 org 可读表面」在 origin/main 上不存在,且方案 4 的两个判定条件各缺一个可读输入。按 dispatch 的 STOP 约定,不写投机代码,回 needs_decision。

    会话:session_015a5qkLzpGXhLL2F5gvJ7dD;基线:origin/main @ 80f7dc6a3;无 PR。


    1. issue 本身的缺陷前提:成立 ✅

    packages/triggers/trigger-schedule/src/schedule-trigger.ts:163-171 的 handler 确实只构造 { event: 'schedule', params: { jobId, flowName, schedule } },没有 tenantId。#6153 已合入的验收文件自己也把这条钉住了(runas-system-stamping.integration.test.ts:179-182:「The schedule trigger supplies no org today … or a future org-aware schedule binding」)。这一半没有争议。

    2. 主路径的前提:不成立 ❌(实测,非推断)

    用真 ObjectStackProtocolImplementation + 内存 sys_metadata 引擎跑了一次探针(临时文件,已删):写入一条 org 归属的 flow(org_sweep,org=org_a)与一条平台级 flow(platform_sweep,org=null),然后走 service-automation 实际调用的那条读路径。

    PROBE saved = {"success":true,…,"message":"Saved flow 'org_sweep' (org=org_a, state=active) [seq=1]"}
    PROBE rows  = [{"name":"org_sweep","org":"org_a"},{"name":"platform_sweep","org":null}]
    
    PROBE getMetaItems({type:flow}) names(registry 空,冷启动形) = ["platform_sweep"]
    PROBE getMetaItems({type:flow, org:org_a}) names            = ["platform_sweep","org_sweep"]
    PROBE org_sweep doc keys = ["name","label","type","runAs","nodes","edges","_diagnostics"]
    PROBE loadMetaFromDb     = {"loaded":1,…}      // 只有 platform_sweep
    

    三条结论,每条都单独致命:

    (a) 服务出来的 flow 文档不携带存储 org。 org_sweep 的键只有 name/label/type/runAs/nodes/edges/_diagnostics。getMetaItems 里 organization_id 全程只当 where 过滤维度用(protocol.ts:3299-3317),返回的是 convertStoredItem(record.metadata),行的 org 列从不落到文档上。FlowSchema 自 protocol 17 起是闭合的,org 也无法藏在文档里;MetadataProtectionFields 只有 _provenance: 'package' | 'org' | 'env-forced' —— 是来源类别,不是 org id。

    (b) 冷启动两条读路径都把 org 行滤掉。 loadMetaFromDb 写死 organization_id: null(protocol.ts:10620-10623,注释即「Per-org overlays are loaded on demand … to avoid cross-org leakage into the process-wide SchemaRegistry」);service-automation/src/plugin.ts:1542 的 protocol.getMetaItems({ type: 'flow' }) 不传 organizationId,于是 orgRecords 恒空。所以今天在跑的每一个定时流程都是平台级的——方案 1 的染色分支若照裁决实现,在当前运行时里永远取不到值,属于 Prime Directive #10 明禁的「declared ≠ enforced」。

    (c) 唯一让 org flow 进引擎的通道是发布写穿,且它按裸名合并。 applyRegistryWriteThrough(publish 分支)把 org 行 hydrate 进进程级 registry,于是同进程内 getMetaItems({type:'flow'}) 能看到它、resyncFlowsFromProtocol 会绑定它——但重启即消失(另开 #6190)。而引擎 this.flows 按裸名键(engine.ts:2146),ScheduleTrigger 作业名是 flow-schedule:{flowName}(schedule-trigger.ts:59,160),getMetaItems 按 (package_id, name) 合并且 org 行覆盖 env 行(protocol.ts:3324-3327)。两个 org 各有一个同名 nightly_sweep,今天是一个注册、一个作业、一个 org。

    (c) 是最关键的一条:方案 1 在真实多组织部署里要正确,就必须给 flow 与作业加 (org, name) 复合身份、每 org 各绑一次——这实质就是被明令禁止的方案 3。 若不加,方案 1 就是「随便挑一个 org 去染所有 org 的行」,比现状的 NULL 更危险(NULL 至少是诚实的)。

    3. 残余护栏(方案 4)的两个条件也各缺一个输入

    • 「多组织部署」可读:postureEnforcesWall(resolveTenancyPosture())(@objectstack/spec/security + @objectstack/types)是权威判据(ADR-0105 D1;resolveMultiOrgEnabled() 已被降级,文档里点名「用它回答『是不是多组织』就是 bug」)。但它是进程级 env,而 AUTHORING_RULES 是 CLI 与运行时共享的纯函数 (stack) => findings,签名里没有部署形态。
    • 「平台级流程」不可读:写入的 org 在 saveMetaItem 手里,可 assertRuntimeAuthoringRules(protocol.ts:2378-2421)既不把 org 传给 evaluateRuntimeAuthoringGate,又有 if (this.environmentId === undefined) return; —— 控制面 kernel 上整条 gate 根本不跑。

    所以「照抄既有 lint 面形状」之外,方案 4 还需要给规则上下文加两个新输入(部署 posture、写入 org),并决定控制面 kernel 上要不要开这道门。这本身也是契约决定。


    需要裁决的三个问题(三轴各自展开)

    Q1 — org flow 到底应不应该被加载与调度?(决定方案 1 有没有主体)

    三轴:① 真实业务需求 —— 目前没有任何被测得的消费者:仓内 example(showcase/CRM)与 hotcrm#698 的证据全部是平台级定时清扫在多组织库里落 NULL;「每个 org 自己的定时流程」这个场景没有拉动证据。② 长期健全 —— B 与 ADR-0005 的既有裁决一致,A 需要一份新 ADR 论证三条被点名的风险(表 schema 分叉 / 执行副作用 / 权限静默漂移)如何消解。③ AI 难写错 —— B 是「写入侧响亮拒绝」,A 是「多一个只有资深读者才知道其身份语义的隐式维度」。建议 B。

    Q2 — 若采 B,平台级定时流程的 org 从哪来?

    三轴:① A 有真实需求且已有消费者形状(#6153 的 fields.owner_id 优先级测试就是同一形状);② A 不新增事实源,org 归属由流程作者在唯一的写点声明;③ A 配合方案 4 的发布期硬拒绝,正是「declared = enforced、写错就响亮失败」。建议 A。

    Q3 — 方案 4 落哪一层?

    • A. 只落运行时发布门(assertRuntimeAuthoringRules 加两个输入:写入 org + 部署 posture),CLI 侧不判——CLI 不知道部署形态,强判会误伤单组织仓库。
    • B. CLI + 运行时都判,CLI 侧从 env 读 posture(会把「构建机的 env」当成「部署形态」,是假信号)。

    建议 A;若采纳,需确认控制面 kernel(environmentId === undefined)上是否也要开这道门——今天整条 gate 在那里不跑,是既有行为还是遗漏,我没有把握,不敢自行改。


    已开的两个衍生单(未在本任务内修)

    ⛔ 本评论不重开方案之争,也不请求推翻 06:50Z 的裁决——它只报告:该裁决的主路径在当前代码里缺少它假定存在的那个表面,而补上这个表面所需的形状恰好落在裁决同时禁止的方案 3 里。请裁决 Q1 后再派发。


    Generated by Claude Code

  5. hotlong commented on Aug 7, 2026

    @hotlong
    ContributorAuthor

    PM 复核:dev 的 STOP 成立,06:50Z 裁决的前提被代码证伪,本单退回决策箱(pm:queue → needs-user-decision;R6 派发 status: needs_decision,零代码零 PR,证据见楼上评论 5215848740)。

    我对两条支柱证据做了独立抽查,均逐字坐实:

    1. ADR-0005 docs/adr/0005-metadata-customization-overlay.md:57:automation flow/workflow/approval per-org override 一律 ❌——「Carry execution side-effects (events, jobs, audit). Per-org variants are a deployment, not an overlay.」
    2. packages/spec/src/kernel/metadata-plugin.zod.ts:649:flow 行却是 supportsOverlay: false, allowOrgOverride: true——与 ADR 相反的未走 ADR 反转(ADR-0005 的租户可定制白名单表说 flow/workflow/approval 一律 ❌ per-org override,注册表却写着 allowOrgOverride: true——反转没有走 ADR(Prime Directive #13) #6191 已立案),且该组合自身矛盾。

    加上 dev 探针实测的三条(flow 文档不携带 org、两条冷启动读路径滤掉 org 行 ⇒ 今天在跑的定时流程全部是平台级、引擎裸名键 ⇒ 正确实现方案 1 必然要 (org,name) 复合身份 = 被禁的方案 3),结论:方案 1 没有可实施的主体——原裁决评估时假定的「flow 存储 org 可读表面」不存在。

    请维护者重裁三问(dev 建议 + 本座位背书均为):

    Q1 若取 A(真要 per-org 定时流程),请先出 ADR 消解 ADR-0005 点名的三条风险,本单按新 ADR 重派。关联:#6190(write-through 幻影绑定,独立缺陷不等裁决)、#6191(注册表反转,Q1 的孪生)。


    Generated by Claude Code

  6. hotlong commented on Aug 7, 2026

    @hotlong
    ContributorAuthor

    维护者裁决(2026-08-07,对话批复,经办 services 座位 session_015a5qkLzpGXhLL2F5gvJ7dD):Q1=B、Q2=A、Q3=A——照三轴分析建议全案采纳。

    拆解与转移(shared-contract / 落点归位):spec 半边 = #6283(flag 回滚 + declared=enforced 核验 + 关 #6191);护栏半边 = #6285(gate 双输入 + 拒绝组合)。本单正文已补 Blocked-by: 两行,标签 needs-user-decision → pm:blocked;两单落地后本单关闭。否决窗口照常:评论或重开即推翻。


    Generated by Claude Code

  7. os-project-manager commented on Aug 9, 2026

    @os-project-manager
    Collaborator

    关闭:2026-08-07 裁决的两个半边均已落地并核实(裁决评论 5217380634 的收尾条件:「两单落地后本单关闭」)。

    半边 卡 状态 核实
    Q1=B —— org 归属 flow overlay 写入侧拒绝,allowOrgOverride:true 回滚 #6283 closed / completed 2026-08-08 01:58Z 一并了结未走 ADR 的反转记录单 #6191
    Q3=A —— 运行时发布门护栏(部署形态 + 写入 org 双输入) #6285 closed / completed;PR #6708 合并为 a5d25734c 多组织部署下「平台级 + schedule + create_record + 未显式 organization_id」发布时拒绝
    Q2=A —— 平台级流程的 org 由作者在 fields.organization_id 显式声明 — 零新代码 #6153 的 fill-only 早已保证作者值优先

    本单最关键的验收项——护栏是否真的可达——有答案了。 立 #6285 时挂着一个 STOP 悬案:assertRuntimeAuthoringRules 的 if (this.environmentId === undefined) return; 会不会让整条 gate 在关键部署形态上根本不跑(那样护栏就是又一处 declared ≠ enforced)。其 dev 在动手前带证据回答了:该短路是 ADR-0005 的控制面豁免(既有设计,已被钉为刻意行为),而每一条真实服务路径都绑定 environment id(os dev/os start → env_local、standalone → proj_local、云端每项目 kernel),护栏正好落在多组织部署所在的那一面。没有擅自拓宽激活面,证据进了 PR 正文与代码注释。

    衍生记录:#6710(#4463 整条 gate 在 host-config 拓扑上不跑——更大的激活面问题,已独立立案,不随本单关闭);#6190(write-through 幻影绑定)按其单自行处置。

    原始缺陷面(定时清扫在多组织库里落 organization_id=NULL)现由「作者显式声明 + 发布期硬拒绝」覆盖;运行时不再有猜 org 的分支——这正是 Q1=B 的用意。

    经办:services 车道 PM session_01USNUyHEr7uaU6MoEWXitei(解锁扫描)。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions