Skip to content

Commit ecdfc94

Browse files
os-teslaclaude
andauthored
fix(triggers,spec,service-automation)!: a time-triggered flow declares its acting organization and the run executes as it (#17334)
* fix(triggers,spec,service-automation): a time-triggered flow declares its acting organization and runs as it A `type: 'schedule'` flow and a `time_relative` sweep launch their runs from a job tick, which carries no identity, so `AutomationContext.tenantId` was never set. On an install holding more than one `sys_organization` every tenant-scoped write beneath the run was then refused by the #8844 guard — the inbox rows a `notify` node emits and the `sys_automation_run` history row — while the tick still summarised itself healthy. - `@objectstack/spec` declares the start-node `config.organization` key, its value schema, the two kinds that owe it, the near-miss spellings an author reaches for, and the one refusal sentence every enforcement point says. - The engine lifts the declaration onto the schedule / time_relative binding. - Both triggers REFUSE to bind a flow that declares none, naming it at `error`, and thread the declared organization onto the run as `tenantId`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * wip(qa): schedule acting-organization dogfood pins Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * test(qa): pin the three #16659 consequences on both drivers, with two differential controls Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * chore: changeset for the time-triggered acting-organization declaration Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * chore(spec): regenerate artifacts and register the #16659 proof Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * docs: the automation reference tree gained a page — update the quick-reference total Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * test(qa): resolve @objectstack/trigger-schedule from source in the dogfood pin The pin's subject is the trigger's own run-context construction, so a dist merely behind would run it green against the old one — the exact shape this card is about. check:test-source-alias and check:type-source-resolution both name this as the required spelling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * fix(triggers,spec): the acting-organization refusal throws so the engine records it `FlowTrigger.start()` is `void`, so logging the refusal and returning left the engine free to run `boundFlowTriggers.set(...)` and log "bound" one line later: `getFlowRuntimeStates()` answered `bound: true`, `getTriggerBindingAudit()` skipped the flow, and the CLI startup summary said every triggered flow was wired. Both time triggers now throw the same sentence they log, which is the engine's designed catch path — the flow is never marked bound and the audit lists it with `binding failed — see earlier warnings`. Also folds the trigger's inline near-miss scan back into `packages/spec` (`findScheduleOrganizationNearMissInConfig`, which takes the start-node config a trigger actually holds), drops the two consumer-less exports (`TIME_TRIGGERED_FLOW_KINDS`, `requiresScheduleOrganization`), makes the near-miss vocabulary module-local, renames the module to `schedule-organization.zod.ts` so its source ships and the docs generator gives it a page, and corrects the two comments that claimed `FlowSchema` emits the refusal sentence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * test(triggers,spec): pin the acting-organization refusal at unit level The fixture comment promised a refusal suite that did not exist. It exists now: the throw itself (the F1 contract), the `error` line carrying the same sentence the engine's audit points at, near-miss naming, the "hot re-publish removes the key" stop() limb, per-flow isolation, the no-`error`-channel fallback, and every limb of `resolveBindingOrganization`. The time-relative sweep gets the same two load-bearing pins, and `packages/spec` gets the unit test every sibling module in `src/automation` already had. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * test(qa): pin the refusal on the structured surfaces, and make control B honest Consequence (3)'s dogfood pin asserted only that the job service was never asked. That says nothing about what the ENGINE recorded, which is where the refusal was being lost: `getFlowRuntimeStates()` answered `bound: true` and `getTriggerBindingAudit()` skipped the flow entirely. Both are now pinned, each with the declaring flow as its paired control. Control B's memory limb asserted `status < 300` under a message claiming it pinned a 503 refusal — opposite polarity, so it certified nothing. It now states plainly that the HTTP control is unavailable on this driver and pins the reason at the seam that makes it so: a tenant-scoped read is refused with MEMORY_MULTI_TENANT_UNSUPPORTED, so the exemption expires by itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * docs(triggers,automation): the acting organization reaches every published surface The trigger-schedule README (shipped in files[]) still showed both worked examples without `organization` and said they auto-launch; after this change those exact flows are refused. Both now declare the key, and the README states the refusal and where it shows up. flows.mdx gains a dedicated "The acting organization" section and both worked schedule examples declare the key; hooks.mdx and capabilities.mdx point at it so "needs `triggers`" is no longer the whole story. The showcase digest's docstring stops promising that it fires: as a package-shipped flow it has no legal organization to name, no placeholder may be invented, and what such a flow should do instead is #17150's decision. Drops the unrelated esbuild@0.28.1/0.28.2 swap from pnpm-lock.yaml; the diff is now only the link: entry the new dev-dependency needs, and `pnpm install --frozen-lockfile` leaves it byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * chore(spec): regenerate for the .zod.ts rename and the narrowed export set `schedule-organization` now has its own reference page instead of landing in the "Misc (no single source file)" bucket, and api-surface / export-origins drop the three consumer-less exports. The changeset's export list matches what the barrel actually publishes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * fix(spec): ADR-0122 alias state and the llms.txt inventory for the new module Two gate findings the .zod.ts rename brought into scope, both real: check:spec-parsed-alias only reads *.zod.ts, so `ScheduleOrganization` was declared with z.infer where ADR-0122 reserves the bare name for the author state; and llms.txt counts *.zod.ts modules, so the automation domain and the total were one short. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * test(spec): the ADR-0122 isomorphic-pin count moves 815 -> 816 `ScheduleOrganizationSchema` is a bare `z.string().min(1)` — no coercion, no default, no transform — so input === infer and it takes a pin rather than a permanent `ScheduleOrganizationParsed` synonym. The note records that the module is not new, only its `.zod.ts` name is: this family of gates reads `*.zod.ts` only, so the violation sat green behind an extension. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * chore(spec): regenerate the docs reference index on the merged tree Discharges the os-regen deferral recorded by the merge commit. `gen:schema` then `gen:docs` on the merged tree: totals 198/1583 -> 199/1584, automation 13/73 -> 14/74, and the `schedule-organization.zod.ts` row. Every other generated artifact re-derived byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * fix(trigger-schedule)!: the time-relative sweep SELECTS inside its declared organization The declaration reached the run and never the query. A sweep declared for org A still matched rows in every tenant — `find` carried `context: { isSystem: true }` and nothing else — and then launched a run stamped A about B's record: the run's `update_record` matched nothing (silently, being scoped to A), `notify` posted into A's inbox about B's record, and the history row was stamped from the subject, landing under B. That is the cross-organization scheduled task the ruling forbids, with a declaration papering over it. The declared organization now rides the sweep's own query as `context.tenantId`, the same ExecutionContext axis the run already uses: `Engine.buildDriverOptions` turns it into `DriverOptions.tenantId` and the driver scopes the read. ⛔ Not a hand-built `organization_id` predicate — that would be a second implementation of tenancy inside a trigger, hardcoding a renameable column, selecting nothing on a platform-global object, breaking a federated one, and reading as scoped to a driver that never learned scoping was wanted. Elevation and tenancy stay independent: `isSystem` says what the sweep may see, `tenantId` says whose rows they are. Silence is closed on both new edges: a store that cannot honour the scope refuses the call and the sweep now logs that at `error` (stderr survives the CLI's boot-quiet window), and an object the engine exempts from scoping (`tenancy.enabled: false`, `external`) gets a bind-time warning saying the declaration cannot narrow this sweep. The bind line names the acting organization so the sweep's reach is readable from the boot log. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * test(trigger-schedule): pin the sweep's SELECTION scope, both directions The existing pin read `expect(calls[0].context).toEqual({ isSystem: true })` — it was pinning the defect, so it is replaced rather than relaxed: exact equality against `{ isSystem: true, tenantId: <declared> }`, which reddens the moment the sweep asks for no scope. Four new pins: - every window query carries the scope, not just the first (offset mode issues one per offset), and `where` still holds only the date window, so no organization predicate was hand-built onto it; - DIFFERENTIAL — matching rows in TWO organizations, only the declared one's record launches a run. The double implements the driver's documented scope (`org = :tenant OR org IS NULL`) and, with no scope, no predicate at all, so its "absent" arm reproduces the defect instead of hiding it; - a store that cannot honour the scope (driver-memory's #16589 refusal) is reported at `error` naming the flow, never answered unscoped; - a `tenancy.enabled: false` object is disclosed at bind — the declaration cannot narrow that sweep — with a paired control that an ordinary object draws no such line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * test(dogfood): the sweep's SELECTION scope on the real driver stack The differential control the branch never had: `sched_org_sweep`, a `time_relative` flow declared for org A, with matching rows in BOTH A (one) and B (two). The discriminating number is the count of LAUNCHED RUNS — 1 with the fix, 3 on the defect — because a pin asserting only "A's row was touched" passes on the defect too. Three properties, three producers: - selection: exactly one run, and it names A's record; - the data plane: `update_record` lands on A's row and on no other (the half the branch left unpinned — the earlier fixture flow was start -> notify -> end, so nothing would have gone red if the run's narrowing were wrong); - disclosure: no `sys_notification` names a B record. An unscoped sweep emits that notification BEFORE its write silently matches nothing, so a fix that narrowed only the writes would leave the leak open. The memory limb answers the other question the card is about: a store with no tenant isolation REFUSES the scoped sweep (#16589) and the failure is reported at `error` naming the flow, with a paired control proving the refusal is about the scope and not about the fixture (the same query unscoped still sees all three rows). The fixture object gains `due_date` as `datetime`, not `date`: the window is a pair of ISO instants, and a column the driver truncates to YYYY-MM-DD would put a per-driver truncation rule between the fixture and the property under test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * feat(lint): authoring learns the acting-organization requirement `validate-flow-trigger-readiness.ts` contained zero occurrences of `organization`, against the invariant `engine.ts`'s own trigger-kind resolver states: it is shared with `defineStack`'s trigger-capability refusal and with this rule file, "so the runtime cannot drift from what authoring accepted". Both time triggers refuse to bind a flow declaring no `config.organization`, so `defineStack`, `os lint` and `verify_build` were all passing a flow the trigger then refused — an author's first signal was a production stderr line at boot. ⛔ No judgement is re-implemented: `resolveFlowTriggerKind` says which flows owe the key, `resolveScheduleOrganization` says whether one was declared (so a present-but-unusable value is judged identically here and at bind), and `describeMissingScheduleOrganization` writes the sentence. Severity is `warning`, and that is MEASURED rather than argued. On the family's own criterion (#5762 — is this stack enough to know the flow is dead?) it belongs at `error`. Flipping it to `error` and building the shipped example app was run: `objectstack build` on examples/app-showcase FAILS, naming `showcase_task_due_reminder` and `showcase_scheduled_digest` — and neither can be repaired by authoring, because the only legal value is a `sys_organization.id` minted per install at runtime and a placeholder id is strictly worse than the omission. Promoting the id is therefore a consequence of the open maintainer decision about package-shipped time-triggered flows, not a lint choice; the probe was restored byte-identically and the two example builds pass with the rule at `warning`, flagging exactly the four shipped flows. Fixture triage in the rule's own suite: every time-triggered fixture that is ABOUT another rule now declares an organization, so it keeps isolating its own subject; the fixtures that are about the missing key omit it deliberately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * docs(changeset,examples): say what actually happens, and name the four flows that stop The changeset asserted "Nothing that was already delivering stops delivering" and, three paragraphs later, that on a single-organization install such a flow "was working". Both cannot be true; the second one is, and `system-write-organization.ts`'s `single`-posture derivation is why. The banner now states the narrowing in two places instead of one — the bind-time accept set AND the run-time data plane, which no sentence in it previously mentioned — and carries the migration consequence an operator reads release notes for: a deployment running ONE time-triggered flow across ALL organizations must now declare one per organization. Also made true rather than absolute: "there is no fallback limb anywhere on this path" is now "nothing on this path ever CHOOSES an organization", with the trigger's second read of the SAME declared value (engine-version skew) named instead of denied. F5 — the four shipped example flows that stop firing are documented where an author meets them: `showcase_scheduled_digest`, `showcase_task_due_reminder`, `task_reminder`, `overdue_escalation`. None can be repaired by authoring, and ⛔ a placeholder organization id must not be invented; what a package-shipped time-triggered flow should do instead is an open maintainer decision. Its card was destroyed with a suspended account and is being re-filed, so the dead reference to it is replaced by the record itself rather than by a number this seat does not have. F8 — the `suspended-run-store` comment claiming "a plain scheduled sweep has neither and keeps NULL" was made false by this very change and now says what happens, keeping the SUBJECT-first precedence it documents. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * docs(automation): the declaration bounds the sweep's query, not only its run The flows guide said the run "executes as that organization" and stopped there, which is now only half of what a `timeRelative` declaration does. It also bounds the SELECTION: a sweep runs elevated on purpose, so nothing else keeps it inside one organization, and elevation and tenancy are independent axes. Both cases where a declaration cannot deliver containment are stated rather than left to be discovered — a store with no isolation refuses the scoped sweep, and a platform-global or federated object gets no scope from the engine at all — and so is the new authoring-time warning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * test(trigger-schedule): the NEGATIVE CONTROL — a single-organization install loses nothing The claim "a single-organization install behaves byte-identically" was an argument, not a measurement. It is now a measurement: two legs over one fixture, the sweep as it ships (scoped) and the same query with `isSystem` only (the pre-fix sweep, byte for byte), asserted EQUAL. The fixture carries a NULL-organization row on purpose — the driver's scope is `org = :tenant OR org IS NULL`, so a platform row stays visible to a scoped read, and that is part of "identical" rather than an exception to it. Non-vacuous in both directions: the selected set is asserted to be the three rows (an empty answer would satisfy the equality with everything broken), and leg A is asserted to have really asked for a scope — which is what makes this pin red under the same ablation as the differential, instead of a pin that can never fail. Its opposite limb is the DIFFERENTIAL above it: add a second organization and these two legs must diverge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * fix(spec,lint,triggers): register the ADR-0087 semantic TODO, fold one export, and correct the banner Regenerates the whole generated chain on the merged tree, discharging the os-regen deferral the merge commit recorded. M1 — ADR-0087 disposition corrected from `not-required (no-migration-prescription)` to `registered schedule-flow-acting-organization-required`. D3 covers exactly this case: a migration that cannot be expressed declaratively gets a structured TODO, not silence, and this changeset's body IS a prescription ("declare `config.organization` once per organization, no fan-out"). Direct precedent: `rest-requireauth-default-flip` (protocol 12) — behaviour-only, no shape moved, registered anyway. Filed under protocol 18, not 17: v17.0.0 was cut before this narrowing landed, so the enforcement rides the 17.x line by the launch-window convention while the prescription belongs at the major boundary where `migrate meta` users look (`registry.ts`, `step18`). M2 — the banner's single-organization sentence was false on `driver-memory`. `assertCallNotTenantScoped` refuses EVERY door handed a `tenantId` regardless of how many organizations the install holds, so a time-triggered flow that touches per-organization data on that driver is refused per call when it declares one and unarmed at boot when it does not. A1 — `findScheduleOrganizationNearMissInConfig` is no longer published. Both callers ran it only to hand the answer straight to `describeMissingScheduleOrganization` on the next line, so the scan moved inside that function (`{ kind, config }`) and the published surface is five names, not six. A `minor` freezes what it publishes. A2 — the name is KEPT, decided by measurement: `FlowSchema.type` is `z.enum(['autolaunched', 'record_change', 'schedule', 'screen', 'api'])` with no `time_relative` member, and a time-relative sweep is authored as `type: 'schedule'`. Recorded in the module docblock so it is not re-litigated. A4/A5/A6 — three effects of the prescribed split, each verified against the tree and now named in the banner and in the migration entry: NULL-tenant rows fan out once per flow under `org = :tenant OR org IS NULL`; dispatch-claim keys embed the flow name so the current window's claims are abandoned; a run suspended before the upgrade rehydrates from `context_json` and resumes org-less. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com> * fix(changeset): the `adr-0087: registered` marker takes bare ids, not prose The marker's remainder is parsed as a comma-separated id LIST, so the rationale that rode inside it read as 293 nonexistent migration ids and the gate refused. The reasoning moves into the body prose, where release notes read it anyway. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fd62a66 commit ecdfc94

44 files changed

Lines changed: 3315 additions & 53 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-automation": minor
4+
"@objectstack/trigger-schedule": minor
5+
"@objectstack/lint": minor
6+
---
7+
8+
fix(triggers,spec,service-automation,lint)!: a time-triggered flow declares its acting organization, and both its query and its run are confined to it (#16659)
9+
10+
<!-- adr-0087: registered schedule-flow-acting-organization-required -->
11+
12+
**Registered as an ADR-0087 semantic migration**
13+
(`schedule-flow-acting-organization-required`, protocol 18). Nothing authorable
14+
is renamed, retired or re-typed — no `packages/spec` key changes its name, its
15+
type or its optionality, no stored shape moves, and every flow, node and
16+
start-node `config` that parses today parses byte-identically afterwards,
17+
because the start node's `config` is an OPEN record (ADR-0018) and the new
18+
`organization` key is an addition to a slot that already accepted anything. So
19+
`objectstack migrate meta` has nothing MECHANICAL to prescribe: the remedy is a
20+
value only the deployment holds, a `sys_organization.id` minted at runtime, with
21+
no authored artifact and no stored representation a rewrite could act on — and
22+
inventing one is precisely what the ruling forbids. ⚠️ That is the argument
23+
against a CONVERSION, and it is not an argument for silence: ADR-0087 D3 says a
24+
migration that cannot be expressed declaratively gets a structured TODO
25+
(surface, reason, acceptance criteria) rather than nothing, and what follows IS
26+
a prescription in that sense — declare `config.organization` once per
27+
organization, no fan-out, then act on the three consequences of the split named
28+
below. Direct precedent: `rest-requireauth-default-flip` (protocol 12) —
29+
behaviour-only, no shape moved, a deployment judgement no transform can make,
30+
registered anyway. Filed under protocol **18**, not 17: v17.0.0 was cut before
31+
this narrowing landed, so the enforcement rides the 17.x line by the
32+
launch-window convention while the prescription belongs at the major boundary
33+
where `migrate meta` users look.
34+
35+
**BREAKING** in the accept-set sense, and in TWO places rather than one —
36+
landing in the launch window as `minor` on all four packages (the lockstep
37+
convention: during the window the bump level is not the carrier, this banner and
38+
the disposition above are). Nothing that was refused becomes admitted.
39+
40+
1. **Bind time.** A `schedule` or `time_relative` flow that declares no
41+
`organization` is no longer armed.
42+
2. **Run time — the DATA PLANE.** A time-triggered run now carries a
43+
`tenantId`, and a `time_relative` sweep now carries one on its own query.
44+
Where a run previously read, updated and deleted across every organization,
45+
it is now confined to the one it declares.
46+
47+
⚠️ **Read (2) as a narrowing that can stop something that was working**, because
48+
it is one. Two shapes to plan for, and neither is hypothetical:
49+
50+
- **A deployment running ONE time-triggered flow to cover ALL organizations must
51+
now declare one flow per organization.** That is the ruling
52+
(「不允许跨组织的定时任务」) and it is the whole point, but it is migration
53+
work: there is no fan-out, and a sweep wanted in N organizations is N
54+
declarations. Nothing detects the shape for you — the flow simply starts
55+
seeing one organization's rows.
56+
57+
⚠️ **And the split has three effects the sentence above does not carry.** Each
58+
is deployment work, and none of them is detected for you either:
59+
60+
1. **A NULL-organization row fans out N-fold.** The driver's scope is
61+
`org = :tenant OR org IS NULL` (`sql-driver.ts`), so a platform row with no
62+
tenant column value stays visible to a *scoped* read — this PR's own
63+
negative control fixture selects exactly that row under scope, on purpose.
64+
After the split every `organization_id IS NULL` row in a swept object is
65+
therefore matched **once per flow**: N runs, N notifications, each acting
66+
as a different organization. Before the split it was matched once. ⇒ Either
67+
backfill the tenant column on swept objects or declare the object
68+
platform-global (`tenancy: { enabled: false }`, ADR-0066), which stops the
69+
scope rather than multiplying under it.
70+
2. **The current window's dispatch claims are abandoned.** The dedup key
71+
embeds the FLOW NAME — `schedule:<flowName>:<window>` and
72+
`time-relative:<flowName>:<scope>:<recordId>` — so N differently-named
73+
flows claim under N different keys. A window already delivered under the
74+
old name can deliver again, once, under each new one. ⇒ Cut over at a
75+
window boundary, or accept one duplicate window.
76+
3. **A run suspended before the upgrade is not retroactively confined.**
77+
Resume rebuilds the run's context from `context_json`
78+
(`suspended-run-store.ts`), and a row written before this change carries no
79+
`tenantId` — so it resumes org-less, exactly as it ran. Nothing back-fills
80+
it. Not a regression (that is how it already ran), but the banner would
81+
otherwise imply "after upgrade, runs are confined". ⇒ Drain in-flight
82+
suspended time-triggered runs, or accept that the tail of them is
83+
unconfined.
84+
- **On a SINGLE-organization install a time-triggered flow WAS delivering**
85+
the #8844 guard derives the only organization there — and after this change it
86+
is unarmed at boot until someone adds one line. On `@objectstack/driver-sql`
87+
that install loses nothing at run time once the line is added: the scope is
88+
`org = :tenant OR org IS NULL` and its one organization is the only scope there
89+
was. ⛔ **On `@objectstack/driver-memory` it does lose something, and the loss
90+
has no legal configuration.** That driver refuses *any* call handed a tenant
91+
scope (`assertCallNotTenantScoped`, `MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589)
92+
`find` / `findOne` / `create` / `update` / `upsert` / `delete` / `count` /
93+
`bulk*` / `aggregate`, one call at a time, regardless of how many
94+
organizations the install holds. So a time-triggered flow that touches
95+
per-organization data on that driver is refused per call if it declares an
96+
organization and unarmed at boot if it does not. The declaration is not what
97+
breaks it — the driver has no row-level tenant isolation to offer either way —
98+
but this change is what moves such a flow from the "no organization context at
99+
all → served" case into the refused one. Multi-organization deployments use
100+
`@objectstack/driver-sql`; a `driver-memory` install whose swept objects are
101+
genuinely platform-global can declare them so (`tenancy: { enabled: false }`,
102+
ADR-0066) and is served unchanged, and ⛔ that is not a way to silence the
103+
refusal on data that really is per-organization.
104+
105+
A `type: 'schedule'` flow and a `time_relative` sweep now declare their acting organization on the start node, and the run executes as that organization.
106+
107+
Maintainer ruling, 2026-09-08, verbatim: 「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。」
108+
109+
A time-triggered flow launches its run from a job tick, and a job tick carries no identity, so `ScheduleTrigger` and `TimeRelativeTrigger` built an `AutomationContext` with no `tenantId`. Two consumers already read that key and both resolved NULL: `notify-node.ts` threads it onto the notification it emits (#11303), and `AutomationEngine.recordLog` copies it onto the `sys_automation_run` history row (#10101). On an install holding more than one `sys_organization` the #8844 guard then refused every tenant-scoped row beneath the run — `sys_inbox_message`, `sys_notification_delivery`, `sys_notification_receipt` and the history row — one layer BELOW anything that summarises a run. So the tick selected its rows, landed its `update_record` steps, reported `unmeasured=0`, and delivered nothing.
110+
111+
- **`@objectstack/spec`** declares the start-node `config.organization` key (`schedule-organization.zod.ts`): `SCHEDULE_ORGANIZATION_KEY`, `ScheduleOrganizationSchema`, the `ScheduleOrganization` type, `resolveScheduleOrganization` and `describeMissingScheduleOrganization` — five names, so the engine's lift and both triggers cannot drift about what counts as declared. The near-miss scan is module-local and runs INSIDE the refusal sentence (`describeMissingScheduleOrganization(flowName, { kind, config })`): both callers only ever wanted the sentence, and a `minor` freezes what it publishes — removing an export later is breaking where adding one is not.
112+
- **`@objectstack/lint`** teaches `validate-flow-trigger-readiness` the requirement, so an author learns at authoring time rather than from a production stderr line at boot. It re-implements no judgement: `resolveFlowTriggerKind` says which flows owe the key and `resolveScheduleOrganization` says whether one was declared, which are the same two answers the triggers refuse with. Severity `warning`, not `error` — see **The four flows this repo itself ships** below.
113+
- **`@objectstack/service-automation`** lifts the declaration onto the `schedule` / `time_relative` binding, beside `schedule`. `record_change` and `api` bindings leave it `undefined` by construction: both are fired by a caller who already carries an organization, and lifting a declared one onto them would let a flow overrule the tenant of the write that triggered it.
114+
- **`@objectstack/trigger-schedule`** refuses to bind a time-triggered flow that declares none — at `error`, naming the flow, and dropping any prior binding so a hot re-publish that REMOVES the key cannot leave the previous job armed — and threads the declared organization onto the run as `tenantId`, **and onto the `time_relative` sweep's own query**. The refusal is **thrown** from `start()`, not merely logged: `FlowTrigger.start` returns `void`, so a logged-and-returned refusal leaves the engine free to record the flow as bound. Thrown, it takes the engine's designed catch path — the flow is never marked bound, `getFlowRuntimeStates()` reports `bound: false`, and `getTriggerBindingAudit()` lists it, so the `kernel:bootstrapped` warning and the CLI startup summary both name it.
115+
116+
**What an existing deployment feels.** A scheduled or time-relative flow with no `organization` stops being armed at boot; the log line names the flow, the key, where the key goes, and — when the author wrote a near-miss (`organizationId`, `tenantId`, `orgId`, …) — which spelling of theirs the open `config` record accepted and then ignored. On a SINGLE-organization install such a flow was working, because the #8844 guard derives the only organization there; it now needs one line to say so. That cost is the ruling's, not an implementation choice: "declared = enforced" is what makes the multi-organization case safe, and a posture-conditional refusal would leave a flow that is legal on a one-organization install and silently inert the day a second organization is created — which is the defect being closed, moved one step later.
117+
118+
⛔ Nothing on this path ever CHOOSES an organization — not the install's only one, not the platform organization, not the first row of `sys_organization`, not the swept record's own `organization_id`. (The trigger does read the declared value from two places, the lifted binding field and the raw start-node `config`; that is one value read twice, so an engine predating the lift reports a correctly declared flow as declared instead of turning a version skew into an authoring error. It resolves nothing the author did not write.) A wrong `organization_id` is worse than a refusal: a refusal is visible at boot and names its flow, while a wrong value is silently authoritative to every report, export and cleanup that filters by organization. ⛔ There is no fan-out either: a sweep wanted in N organizations is declared N times, and a single flow never spans them.
119+
120+
**Run-history volume is bounded by a contract that already exists.** Scheduled runs now persist to `sys_automation_run` where they previously could not, and that table's retention is two-sided and declared: a per-flow cap on terminal rows enforced at WRITE time (`runHistoryMaxPerFlow`, default 100) and declarative age retention (`retention: { maxAge: '30d', onlyWhen: { status: { $in: ['completed', 'failed'] } } }`, ADR-0057 / #2834, with `paused` rows retained regardless of age). A minute-cadence flow is bounded by the per-flow cap, not by the tick rate. Measured before landing this: nothing in the tree depends on scheduled runs NOT reaching `sys_automation_run` — no test asserts an absent or zero run-history row for a time-triggered flow, and no deployment config, migration or quota keys off that emptiness.
121+
122+
No object's tenancy declaration changes, and `NotifyConfigSchema` is untouched — the two routes the ruling excluded. `system-write-organization.ts` stays exactly as it is: the producer it guards against now carries what it demands.
123+
124+
**What the declaration now bounds, precisely.** The value goes onto the run's `AutomationContext.tenantId`, and — for a `time_relative` sweep — onto its `find` context as well. From there it is the platform's existing tenancy path and nothing new: `Engine.buildDriverOptions` turns `context.tenantId` into `DriverOptions.tenantId`, and the driver scopes reads, updates, deletes and aggregates to that organization. ⛔ No `organization_id` predicate is hand-built anywhere — that would be a second implementation of tenancy inside a trigger, hardcoding a column an object is free to rename, selecting nothing on a platform-global object and breaking a federated one. Two consequences follow from using the platform's mechanism rather than a private one, and both are stated rather than discovered:
125+
126+
- **A store that cannot scope refuses the call instead of answering it.** `@objectstack/driver-memory` implements no row-level tenant isolation and refuses any call handed a tenant scope (`MEMORY_MULTI_TENANT_UNSUPPORTED`, #16589), so a time-triggered flow on that driver fails loudly rather than quietly crossing organizations. Multi-organization deployments use `@objectstack/driver-sql`; this is the same refusal that driver already gives every other org-scoped read.
127+
- **On a platform-global (`tenancy: { enabled: false }`, ADR-0066) or federated (ADR-0015) object the declaration cannot narrow anything** — the engine drops the scope for those by design. Such a sweep still selects across every organization while its runs act as the declared one, and the trigger says so at bind, at `warn`, naming the object. ⛔ It does not pretend the flow is contained.
128+
129+
**The four flows this repo itself ships stop firing, and cannot be repaired by authoring.** `showcase_scheduled_digest` and `showcase_task_due_reminder` (`examples/app-showcase`), `task_reminder` and `overdue_escalation` (`examples/app-todo`) are all time-triggered and none declares an organization. There is no value they COULD declare: organization ids are minted per install at runtime, so a package-shipped flow has nothing to write there, and ⛔ inventing a placeholder is strictly worse than the omission — a value matching no row is silently authoritative. Each of the four now carries a comment saying it does not fire as shipped and why. What a package-shipped time-triggered flow should do instead is an open maintainer decision, tracked on #17396; this changeset and those comments are the record until it is ruled. That corpus is also why the new lint id is a `warning`: at `error` it gates `objectstack build`, which was run and refuses `examples/app-showcase` outright — the repo would be unable to build its own examples for a defect they have no way to fix.

0 commit comments

Comments
 (0)