feat(types,triggers,service-automation,runtime,cli,spec,lint)!: package-authored scheduled work is a deployment decision, off by default - #18198
Conversation
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
📓 Docs Drift CheckThis PR changes 7 package(s): 15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 146 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 98266aa4042268f16363b1d96cd047a3d376f19d && git checkout 98266aa4042268f16363b1d96cd047a3d376f19d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0f95f4341ff381a20fd919142baebff79c883ab3 59af6612569d75d7e83370dc91b2c9f97e2a2378 && git checkout -B drift-repro 0f95f4341ff381a20fd919142baebff79c883ab3 && git merge --no-ff 59af6612569d75d7e83370dc91b2c9f97e2a2378
node scripts/docs-audit/affected-docs.mjs --json 0f95f4341ff381a20fd919142baebff79c883ab3
|
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
⛔
|
| the pending note says | after ruling G |
|---|---|
a time-triggered flow MUST declare config.organization, full stop |
true only under a walled posture (group / isolated) with scheduled work switched on |
| (no deployment gate exists) | package-authored scheduled work is gated by a deployment variable, OFF by default in every posture and every kernel — while off, nothing below the banner happens, because nothing arms |
| 「Nothing that was refused becomes admitted」 | single + on: a flow that note would have left unarmed now binds and runs |
announces the lint finding flow-schedule-organization-missing |
that finding is deleted (Q4, addendum 5642795312) — lint can see neither the switch nor the posture |
Why it lands in the same window rather than as a follow-up
The ruling's own timing clause: all of it 「must land in the same launch window as PR #17334, before the next release cut — otherwise 17.x ships a bind-time contract this ruling immediately narrows」.
What is being asked of the maintainer — one confirmation
Confirm that the pending release note for #16659 may be corrected in place by this PR, on the grounds that ruling G — which you approved on 2026-09-12 — narrows the behaviour that note describes, and that the narrowing ships in the same window so no released version ever saw the wider rule.
⛔ This seat does not clear it itself. A red-by-design carve-out exists in the protocol, but using it here would be precisely the 「routing around it」 the gate's own text names — and a release decision is on the manual floor regardless.
Status of the rest of this PR
⛔ Not a claim that everything else is green. The other jobs were still converging when this was written; any other red is a separate matter and will be diagnosed on its own, ⛔ not folded into this expected one.
Generated by Claude Code
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
… re-read The reason was re-derived inside getTriggerBindingAudit() from a live resolveScheduledWorkPolicy() read. The audit is consumed long after the bind, so an environment that moved in between made it report 'binding failed' for a flow whose trigger was never called — the exact reading ruled item 6 forbids. Record the refusal at the gate; clear it the moment a flow gets past. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
The four declarative-job suites measure the READER, not the deployment, so they arm the switch. Adds the OFF-state pins ruling G Q3 implies: nothing schedules, said once per app at info with the count, and the job-service verdict is not reported because it was never reached. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
…hedule-flow-deployment-switch
|
Rendered by an isolated at-tier review subagent and ADOPTED VERBATIM by the Downgrade-fuse reading, taken before adoption and ⛔ not from the agent's self-report: the reviewer's own transcript carries Head check: PR head at adoption is Contract reviewServed-tier: Worktree: ① Derived judgmentsRuling items, one by one (implemented / contradicted / absent):
Accept-set changes implied by the diff:
Public-surface changes:
Tests — strengthened or weakened: 10 removed Commands run in the worktree (exit codes captured to disk before reading):
CI at head (36 check-runs, read from
② Semver levelBy behaviour: MAJOR — a deployment that upgrades and does nothing loses every packaged time-triggered flow and every packaged ③ Boundary flags
Implemented-by: VERDICT: FAIL Generated by Claude Code |
…ork resolver Contract-review rework, both items in the review's scope. 1. Ruling G item 6 named three surfaces that must carry a DISTINCT reason; only two are delivered. Studio's only status door is `GET /automation/_status`, whose rows are `FlowRuntimeState` — a shape with no reason field — so a policy-disabled flow reaches it as `bound: false` and nothing more. Six published carriers stated the Studio leg as fact: the ADR-0087 semantic entry 18 (and its generated registry mirror), the changeset that ships as CHANGELOG, two docs pages and the `SCHEDULED_WORK_DISABLED_REASON` docblock that reaches `dist/*.d.ts`. Each now names only the audit and the CLI startup summary; the flows page says plainly what Studio cannot show, so nobody re-adds the claim. 2. `resolveScheduledWorkEnabled` / `resolveScheduledWorkPolicy` were pinned nowhere in `@objectstack/types`. Added the sibling switches' template (defaults OFF / truthy case-insensitively / anything else off), the three-row policy table, the bogus-posture throw, and a control proving the opt-IN shape differs from `resolveMultiOrgEnabled`'s opt-OUT one. Also: five comments cited "PR #17476 refuses a second" for the one-organization contract under `single`. #17476 touched only `skills/objectstack-data/**`. The claim is true; the enforcement is plugin-auth's org-create posture gate (`auth-manager.ts` `beforeCreateOrganization` → 403, pinned in `org-create-posture-gate.test.ts`). Citation corrected, claim unchanged. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
|
Rendered by an isolated at-tier review subagent and ADOPTED VERBATIM by the Downgrade-fuse reading, taken before adoption, ⛔ not from the agent's self-report: the reviewer's transcript carries
Contract reviewServed-tier: Worktree: ① Derived judgmentsREQUIRED item 1 (G6 Studio leg): trim is COMPLETE and TRUE.
REQUIRED item 2 ( Citation fix: TRUE, claim unchanged. PR #17476 (merged 2026-09-11T06:26Z) has exactly 3 files, all Ruling G, item by item, at
Accept-set changes (unchanged from the first head; re-derived): narrowing — every packaged time-triggered flow and packaged Public surface: Assertions, whole PR (merge-base..HEAD): removed Commands run in the worktree (exit codes captured to disk before reading): CI at head (40 check-runs read from ② Semver levelBy behaviour MAJOR (default-OFF narrowing of every packaged time-triggered flow and packaged ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Provenance —— 达档条款②复核 PASS,双载体已清。⛔ 但本 PR 仍然不能落地,原因与该判词无关
|
| 检 | 结果 |
|---|---|
| ① 席内达档条款②复核 PASS 在案 | ✅ 5673030422 |
② 双载体已清,--pair 机读 |
✅ exit 0 |
| ③ PR 全部 check 全绿(⛔ 非 required 子集) | 🔴 两红 |
⇒ ⛔ 不转 ready、⛔ 不入队、⛔ 不挂 auto-merge。 PASS 是契约判词,不是放行。
两红都在维护者地板上,且彼此独立:
check:type-source-resolution—— 复核实测签名:@objectstack/service-automation与@objectstack/trigger-schedule各有NEW dist-resolved type import(s) … @objectstack/types (via tsconfig.test.json)。base 提交d4554d4f5a的同一步 success ⇒ 是本 diff 引入的,⛔ 不是 base 红。闸门原文:「no self-serve remedy is left … Never widen that ledger and never widen arootDir— both are maintainer-only」。复核已核实返工没有绕过(全 PR 新增行里rootDir|"paths"|compilerOptions零命中,未碰任何scripts/**、tsconfig*、台账 json)。需要维护者按 A 把KNOWN_DIST_RESOLVED_TYPE_IMPORTS加上@objectstack/types。Check Changeset—— 按设计红,类别仍然吻合(闸门原文已在记录里逐字引用)。需要维护者确认那条待发布 note 可就地修正 —— 即上面那条至今不在案的确认。
一条无人认领的后续,已另立卡
G6 的 Studio 腿诚实地没有交付,而且现在任何会发布的句子都不再声称它交付了(复核逐个载体核过,树上零残留)。
但 objectui#9217(open,pm:blocked)正等着平台把 reason 送上线才能动 —— 而本仓没有任何卡指向那件事。⇒ 已立后续卡承接(FlowRuntimeState / _status 上一个由记录式拒绝填充的 reason,或一个审计路由),否则 #9217 无限期阻着。
Generated by Claude Code
|
| reading | value |
|---|---|
| merge base | d4554d4f5ae |
.changeset/schedule-trigger-acting-organization.md on the merge base |
present |
…on origin/main today |
present |
| added by | ecdfc94118 — 「fix(triggers,spec,service-automation)!: a time-triggered flow declares its acting organization and the run executes as it (#17334)」, a different PR |
| this branch's edit to it | a deliberate prose rewrite, not an overwrite-by-filename-collision |
⇒ DELIBERATE CORRECTION. ⛔ The COLLISION remedy (git checkout <merge-base> -- <file>) is the one thing not to do here: restoring it would republish sentences this PR makes false.
The note, and what changed under it
.changeset/schedule-trigger-acting-organization.md is #17334's pending, unreleased note for #16659. This PR narrows what it announces, so the branch rewrites it in three places:
- Title — 「a time-triggered flow declares its acting organization」 → 「…declares its acting organization behind a tenancy wall … (A schedule-triggered flow's
notifydelivers 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, narrowed by [Decision] 平台自带的四个定时示例流一个都声明不了组织 —— 而新规则要求它们必须声明 #17396)」. - A new
⚠️ banner in front of every statement, because two deployment facts now precede them and neither is metadata: package-authored scheduled work is gated byOS_AUTOMATION_SCHEDULED_WORK_ENABLEDand is OFF by default in every tenancy posture and every kernel (while off, nothing below happens because nothing arms); and with it on, the declaration requirement applies under a walled posture (group/isolated) only — undersinglean armed time-triggered flow declares nothing and resolves the deployment's one organization exactly as before A schedule-triggered flow'snotifydelivers 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. - The
@objectstack/lintbullet — 「teachesvalidate-flow-trigger-readinessthe requirement … severitywarning」 → 「nothing, after [Decision] 平台自带的四个定时示例流一个都声明不了组织 —— 而新规则要求它们必须声明 #17396」, becauseflow-schedule-organization-missingis deleted in the same window and was never published. The stated reason is the rule family's own criterion — is this stack enough to know the flow is dead? — answered no: the deployment switch and the tenancy posture decide it and neither is in any stack.
The branch also amends the note's BREAKING paragraph to record that #17396 moves one sentence in the widening direction: under single with the switch on, a flow that #17334's changeset would have left unarmed now binds and runs.
⇒ Both changes land in the same launch window, so no released version ever saw the wider rule.
What is being asked
Confirm that correcting #17334's pending release note in place — rather than shipping it as written and correcting the CHANGELOG after release — is the right call. That is a release decision, which is why the gate stays red until a person says so rather than letting a diff shape route around it.
⛔ Nothing else about this PR waits on that answer: the other red (Lint & Repo Gates → check:type-source-resolution, @objectstack/service-automation via tsconfig.test.json and @objectstack/trigger-schedule, both on new dist-resolved @objectstack/types imports) is this PR's own and is being worked now.
Generated by Claude Code
Fixes #17396
Implements ruling G (
5642381255, director seat, decision batch #116 item 4) and its addendum (5642795312, batch #118). ⛔ The card body's options A/B/C/D and the later E are superseded; nothing here re-opens which option.What this is
A deployment-level environment variable —
OS_AUTOMATION_SCHEDULED_WORK_ENABLED— decides whether this deployment runs package-authored scheduled work: time-triggered flows (type: 'schedule'with aconfig.schedulecadence, and thetimeRelativesweep) and packageddefineJobcron jobs. It is read at boot besideresolveTenancyPostureinpackages/types/src/env.ts. It is ⛔ not a metadata concept and ⛔ not a new spec key.OFF by default, in every posture and every kernel. Whether a clock-driven workload is affordable is a fact about the deployment — its database, its tenants, its budget — that no package author can know, and a metadata key would ask them to.
The three bind states, resolved once by
resolveScheduledWorkPolicy()so both triggers, the engine's audit, the packaged-job loop andos doctorcannot disagree:defineJobis scheduledsingletimeRelativesweep runs unscopedgroup/isolated)config.organization— the 2026-09-08 #16659 ruling, unchangedgroupis walled here for a measured reason rather than by analogy:resolveSystemWriteOrganizationrefuses an organization-less system insert under any wall andTenancyService.defaultOrgId()answersnull(ADR-0093 D3), so an organization-less group-wide sweep could read the whole group while every row it inserts is refused. Which organization such a sweep's inserts belong to is the part the maintainer said is not yet thought through; until it is answered,groupbehaves as walled.Ruled item 6 — a DISTINCT reason, never "binding failed"
getTriggerBindingAudit()gained a policy branch that outranks both binding branches, andactivateFlowTriggerdoes not calltrigger.start()at all when the switch is off. That second half is what keeps the reason precise: a refusal arriving as a THROW can only be reported through the catch that says Failed to bind, which is the one thing the ruling forbids this state from reading as. A binding failure is a defect with an engineering remedy; this is a deployment policy with an operator remedy, and the two send the reader to different places.The triggers keep their own copy of the gate and still throw — that is what makes the guarantee hold for a host that drives a trigger without this engine. In the engine's own composition it is belt-and-braces and never fires.
Retired pins, with their reasons (⛔ not deleted silently)
tenantIdis never conditional, inScheduleTrigger's handler and inTimeRelativeTrigger.sweep. The argument was sound while EVERY time-triggered run owed a declaration — conditional there meant sometimes we forgot. Under ruling G an absenttenantIdis a declared state reached through exactly one gate (single+ the switch on, where the deployment holds one organization by contract). ⛔ The key is omitted, not set toundefined: the ruling says the run carries no organization, and a present-but-undefined key is a different thing to every consumer that asksin. Pinned both ways.flow-schedule-organization-missing(Q4) is deleted, not re-severitied — id, exported constant, tests. The family's criterion is is this stack enough to know the flow is dead? and the honest answer is no: the switch and the posture decide it and neither is in any stack. Its absence is pinned positively (the exact fixtures the rule fired on now produce[], with a live control proving the function still reports the other rules). ⛔ The bind-time near-miss diagnostic (describeMissingScheduleOrganization) stays — it fires only where the key is required.Q3 — packaged
defineJobcron jobs are under the SAME switchGated in
packages/runtime/src/app-plugin.ts, in thekernel:readyloop that schedulesdefineStack({ jobs })entries — the exact "authored by a package" boundary. ⛔ Platform-internal jobs are not gated and do not pass through there: approvals escalation, the lifecycle Reaper, the messaging dispatch loop and membership backfill each schedule themselves from their own service plugin.Narrowing I did not expect, flagged for review
Two, both stated so the reviewer has the map:
organizationScopeIsInertForno longer warns when no organization is declared. That warning is a disclosure about a declaration; on a flow that declared nothing it would invent a containment claim the author never made.timeRelativedescriptor is invalid — that is the wrong remedy at the wrong door. Pinned.Acceptance notes
os doctorprints the effective value in BOTH states, wherenodeEnvCheckprints only the unset one. OFF is the state in which a shipped capability silently does not run, so a doctor that said nothing by default would leave the operator to discover the switch from the audit line, or not at all. Status isok(the file's only informational status; wideningHealthCheckResult.statusfor one row would re-render every other check) and thefixtext carries the next step in both directions.saascomposition loses its eight remainingscheduleflows by default; its tests that boot scheduled flows need the switch on. hotcrm#1892 and cloud#2216 wait on that relay.notifydelivers 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) ⇒ reported, ⛔ not posted; the seat files follow-ups.pnpm dev --freshsets the switch) is the implementer's call: left alone deliberately. The local harness boots undersinglewith the switch unset, so the showcase's two flows and app-todo's two are listed as disabled by deployment policy — which is now the documented default experience, and makingdevspecial would hide from a developer exactly the state every fresh deployment is in.noted, not filed:packages/lint/src/validate-flow-trigger-readiness.tshas a long// 1f.measurement narrative about three runtime channels that each skip an unroutable flow; it is accurate but now sits beside a retired 1g and reads as one block. Cosmetic, no behaviour. Carrier: the next PR that touches this rule family.Tests
Every touched package's own suite, plus the three-state gate pinned at both layers. See the report comment on #17396 for the gate ledger and exit codes.
Generated by Claude Code