diff --git a/.changeset/group-scheduled-work-per-record-ownership.md b/.changeset/group-scheduled-work-per-record-ownership.md new file mode 100644 index 00000000000..bb376732d5b --- /dev/null +++ b/.changeset/group-scheduled-work-per-record-ownership.md @@ -0,0 +1,132 @@ +--- +"@objectstack/types": minor +"@objectstack/spec": minor +"@objectstack/trigger-schedule": minor +"@objectstack/metadata-core": minor +"@objectstack/cli": patch +--- + +feat(spec,types,triggers)!: `group` runs package-authored scheduled work without a declaration, owning each run's writes per record (#18378) + + + +`Clause-②: yes (widening)` + +**ADR-0087 disposition — `not-required (already-registered)`, not `registered`.** +The ledger entry this change belongs to already exists +(`schedule-flow-acting-organization-required`, entry 18) and predates this diff +at the merge base, so `registered` would assert a registration this PR did not +make. The entry's `surface`, `replacement`, `reason` and `acceptanceCriteria` +each gained their `group` row here, the rejected bootstrap-organization arm +included — recorded because it is the one a later reader will re-propose. + +**Marked breaking (`!`) for the behaviour change, not for a narrowing.** Nothing +that worked stops working and nothing that was admitted becomes refused — the +accept set WIDENS in one cell. What earns the banner is the other direction: on a +`group` deployment with the switch already on, flows that were refused at bind +now arm and run, so clock-driven work appears where an operator had none. That is +worth reading before upgrading even though no consumer has to change anything. + +## What changes + +With `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` on and tenancy posture `group`, a +time-triggered flow that declares no `config.organization` now **binds and +runs**, where it was previously refused at bind. The organization its writes +carry follows the record: + +| 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** | the declaration; undeclared ⇒ not armed, unchanged | + +A `timeRelative` sweep under `group` reads group-wide — inherent to the posture +(ADR-0105 D1) — and stamps each run it launches with that record's organization: +sweep contracts across four plants and each plant's contract yields a run acting +as that plant, whose notifications reach that plant's inboxes. + +## Why this is not a fallback that guesses + +It is the order `sys_automation_run` was **already** ruled to use. +`ObjectStoreSuspendedRunStore` resolves a run's organization as +`organizationOf() ?? ctx.tenantId` — subject first, acting +context as the fallback and never the primary. Before this change those two +halves disagreed under `group`: the history row was stamped from the record while +the inbox and delivery rows followed an acting context that could not exist +there, so they were refused while the tick summarised itself as healthy. + +⚠️ With one stated exception, because the two halves ask different questions: +the history row is STAMPED (`tenancy.organizationField` wins there) while the +run's acting organization is a WALL reading that never consults that key. They +agree on every object where the two coincide — which is every ordinary object, +since a declared stamp column is what makes them differ and one shipped object +declares one (`sys_api_key`, deliberately unwalled). Sweeping that object under +`group` stamps its history row while the run itself acts as nothing: the correct +pair of answers, not a residue of the old disagreement, and recorded rather than +smoothed over. + +⛔ A record-less run under `group` that declared nothing still resolves +**nothing** and is refused at its first tenant-scoped write (`walled-posture`, +ADR-0112), loudly and by name. The rejected alternative was a fallback to the +bootstrap organization (`slug='default'`): under a wall that organization is +minted admin-keyed by the enterprise organizations runtime and may not exist at +all, and where it does it is whichever organization the platform owner +registered under — plausibly one plant of many, not the group's head office. + +## Upgrading + +**Most deployments: nothing to do.** The switch this depends on is OFF by default +and ships unreleased alongside this change, so the `group`-is-walled behaviour +being amended has never appeared in a published version — no released consumer +can be relying on it. + +If you run posture `group` **and** turn the switch on, read your boot log: each +time-triggered flow's bind line now names which of the three shapes it bound as +("as organization '…'", "with per-record acting organization", or "with NO +acting organization"). Two things to check: + +- A flow you expected to act as ONE organization but which binds per-record is + missing its `config.organization`. Add it — declaring still narrows, bounding + the sweep's query as well as its identity. +- A plain `schedule` cron flow that binds "with NO acting organization" has no + record to derive one from. If it writes notifications, inbox messages or any + other per-organization row, declare `organization` on its start node; the bind + line says so, and so does the refusal at the first tick. + +## Which organization a record belongs to — the WALL question, not the stamp one + +`@objectstack/metadata-core` gains a second face on the record→organization +resolver, and the split is the point: `resolveRecordOrganizationField` / +`createRecordOrganizationResolver` answer **"who is this row ABOUT"** (the STAMP +question, whose `tenancy.organizationField` limb stays pinned to the three +sanctioned platform-row writers), while the new +`resolveRecordWallOrganizationField` / `createRecordWallOrganizationResolver` +answer **"what is this row WALLED by"** — `tenancy.enabled: false` ⇒ nothing, +then a declared `tenancy.tenantField`, then the kernel's `organization_id`. + +The sweep uses the WALL face, because "which organization does this run act as" +is a question about the wall. ⛔ It never reads `tenancy.organizationField`: that +key is declared on exactly one shipped object (`sys_api_key`, deliberately +unwalled, #8287), and reading it here would turn "the audit trail should follow +this row's own organization even though nothing walls it" into an acting +identity. A sweep over such an object resolves **nothing** and takes the +`walled-posture` refusal at its first tenant-scoped write, which is the honest +answer. Limbs 1 to 4 are one implementation shared by both faces, pinned as +such, so the half they agree on cannot drift apart. + +**API:** `ScheduledWorkPolicy` gains `runOwnership: 'unscoped' | 'per-record' | +'declared'`, and `requiresActingOrganization` narrows from "any walled posture" +to `isolated` only. The two are deliberately separate axes: the boolean decides +whether BIND refuses, `runOwnership` decides what a run that DID bind carries. +Inside `@objectstack/trigger-schedule`, both triggers share one bind-line +vocabulary (`describeScheduleRunOwnership`) so they cannot describe one +deployment differently. ⚠️ That helper is module-level, NOT a package export: it +is not re-exported from the package barrel, whose own note says an export whose +only consumers live inside its own package belongs in a non-barrel module. The +new PUBLIC surface in this change is `ScheduledRunOwnership` and the +`runOwnership` key on `@objectstack/types`, plus +`resolveRecordWallOrganizationField` and +`createRecordWallOrganizationResolver` on `@objectstack/metadata-core` — and +those four are what put `Clause-②` at `yes`. Nothing existing is renamed or +re-typed: both stamp-face exports keep their names, their signatures and their +answers, limb 0 included. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 028a2468083..775fd0dc0eb 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -2078,10 +2078,11 @@ export const renewalReminder: Flow = { offsetDays: [60, 30, 7], // — or — withinDays: 30 (negative = overdue lookback) filter: { status: 'active' }, // optional, ANDed with the date window }, - // Required under a WALLED tenancy posture, and for a stronger reason - // than a plain schedule flow — it bounds the sweep's query as well as - // its runs. Not required under `single`. See "The acting organization" - // below. + // Required under `isolated`, and for a stronger reason than a plain + // schedule flow — it bounds the sweep's query as well as its runs. + // Optional under `group` (an undeclared sweep reads group-wide and each + // run acts as its own record's organization) and not read under + // `single`. See "The acting organization" below. organization: '', // schedule: { type: 'cron', expression: '0 8 * * *' } // optional; defaults to daily 08:00 UTC }, @@ -2142,10 +2143,9 @@ it: the caller's session rides into the run and every tenant-scoped write below resolves the same organization a normal write would. A **time-triggered** flow has no such caller — a job tick carries no identity at all. -Under a **walled** tenancy posture (`group` or `isolated`) that is a question -only the author can answer, so a `schedule` or `timeRelative` flow there -**declares the organization it runs as**, on the start node's `config`, beside -the cadence it scopes: +Under the **`isolated`** tenancy posture that is a question only the author can +answer, so a `schedule` or `timeRelative` flow there **declares the organization +it runs as**, on the start node's `config`, beside the cadence it scopes: ```typescript config: { @@ -2193,7 +2193,33 @@ scheduled-work switch, and neither is visible from a stack. A lint rule that fired on the default posture would be wrong more often than right. -**Under a wall, a time-triggered flow that declares none is a declaration + +**Under `group` the declaration is optional, and an undeclared flow still +runs.** `group` is one legal group over one shared database — group-wide +visibility and cross-organization workflow are inherent to the shape, not +violations of it — so a group-level batch job is a capability of the posture. An +undeclared `timeRelative` sweep there **reads group-wide** and stamps each run it +launches with **that record's own organization**: sweep a contracts table across +four plants and each plant's contract produces a run acting as that plant, whose +notifications land in that plant's inboxes. + +That is not a new rule so much as one being made consistent: the +`sys_automation_run` history row was already stamped from the subject record, +with the acting context only as a fallback. Before this, the history row and the +inbox rows of the same run could disagree about who owned it. + +Declaring on a `group` flow still works and still **narrows**: the declaration +bounds the sweep's query as well as its identity, exactly as under `isolated`. + +⚠️ A **record-less** flow — a plain `schedule` cron with no sweep — has nothing +to derive an organization from. Under `group` it binds and runs, but its first +tenant-scoped write is **refused**, by name, with the remedy. Declare +`organization` on such a flow if it writes notifications, inbox messages or other +per-organization rows. The bind line says so at boot rather than leaving it to +surface at the first tick. + + +**Under `isolated`, a time-triggered flow that declares none is a declaration error**, refused at bind: - the trigger logs the reason at `error`, naming the flow; @@ -2203,15 +2229,19 @@ error**, refused at bind: `bound: false`; - nothing fires it. -There is deliberately **no fallback** — not the platform organization, not "the -first row of `sys_organization`", and never the swept record's own -`organization_id`. Behind a wall, a run that reached every tenant-scoped write -with nothing to offer would have each of those writes refused one layer below -anything that summarises the run: the tick reports itself healthy and delivers -nothing. A wrong `organization_id` is worse still, because it is silently -authoritative to every report, export and cleanup script that filters by -organization. ⛔ This is unchanged by the posture split above: `single` **omits** -the organization, it never invents one. +There is deliberately **no invented fallback** — not the platform organization, +not "the first row of `sys_organization`", not the group's bootstrap +organization. Behind a wall, a run that reached every tenant-scoped write with +nothing to offer would have each of those writes refused one layer below anything +that summarises the run: the tick reports itself healthy and delivers nothing. A +wrong `organization_id` is worse still, because it is silently authoritative to +every report, export and cleanup script that filters by organization. + +⛔ The swept record's own `organization_id` is **not** such an invention, and it +is used under `group` alone — there the row is the only honest owner available +and the posture's reads already span the group. Under `single` the organization +is **omitted**, never filled from the row; under `isolated` an undeclared flow +never binds in the first place. **No fan-out.** A single flow belongs to one organization. A sweep wanted in several organizations is declared once per organization. @@ -2220,8 +2250,16 @@ several organizations is declared once per organization. The start node's `config` is an open record, so a near-miss spelling — `organizationId`, `organization_id`, `orgId`, `org_id`, `tenantId` — parses happily and is then ignored. The bind-time refusal names the spelling you -actually wrote — under a wall, which is the only place the key is required and -therefore the only place a near-miss is a mistake. +actually wrote — under `isolated`, which is the only posture where the key is +required and therefore the only place a near-miss is unambiguously a mistake. + + + +Under `group` a near-miss is **not** reported, because an undeclared flow is a +legal shape there and the trigger cannot tell "meant to declare, misspelled it" +from "meant not to declare". The symptom to watch for instead is a sweep whose +runs act per-record when you expected them all to act as one organization — +check the bind line, which names which of the two shapes the flow bound as. ### Update-triggered flow diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index 802124efc20..4a82f64be22 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -87,7 +87,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false | `GOOGLE_CLIENT_SECRET` | string | — | Deployment-level Google OAuth client secret for the open-source Google login implementation. | | `OS_TENANCY_POSTURE` | `single` \| `group` \| `isolated` | derived from `OS_MULTI_ORG_ENABLED` | Which organization wall the authorization kernel enforces (ADR-0105 D1). `single` = no wall. `group` = `organization_id IN accessible_org_ids` — organizations are membership boundaries over one shared dataset, with union read access across every organization the caller belongs to. `isolated` = `organization_id = `, the hard legal-entity wall (formerly spelled `multi`). Unset derives from `OS_MULTI_ORG_ENABLED` (`true` ⇒ `isolated`, else `single`), so existing deployments are unchanged. An unrecognized value **refuses to boot** rather than silently falling back to a posture with no wall. Both walled postures require the enterprise `@objectstack/organizations` runtime — without it the request resolves to `single` and boot is refused unless `OS_ALLOW_DEGRADED_TENANCY=1`. | | `OS_MULTI_ORG_ENABLED` | boolean | `false` | Superseded by `OS_TENANCY_POSTURE`, and still honoured: `true` selects the `isolated` posture. When `true`, organization creation/switching UI is exposed. | -| `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` | boolean | `false` | Whether this deployment runs **package-authored scheduled work**: time-triggered flows (a `type: 'schedule'` flow with a `config.schedule` cadence, and the `timeRelative` sweep) and packaged `defineJob` cron jobs. **OFF by default in every posture and every kernel** — a clock-driven workload's cost is a fact about the deployment, not about the flow, so an author cannot decide it and no metadata key asks them to. Accepts `true`/`1`/`on`/`yes` (case-insensitive); anything else, including unset, is off. While it is off nothing arms, and every such flow is listed by `getTriggerBindingAudit()` and the CLI startup summary as **disabled by deployment policy** — never as a binding failure, and nothing about the flow needs fixing. While it is on, a time-triggered flow under a walled posture (`group` / `isolated`) must declare `config.organization` on its start node or it is not armed; under `single` it needs no declaration and its runs carry no organization. ⛔ Platform-internal scheduled work — approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership backfill — is **not** gated by this: the boundary is *authored by a package*, not *runs on the job service*. `os doctor` prints the effective value. | +| `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` | boolean | `false` | Whether this deployment runs **package-authored scheduled work**: time-triggered flows (a `type: 'schedule'` flow with a `config.schedule` cadence, and the `timeRelative` sweep) and packaged `defineJob` cron jobs. **OFF by default in every posture and every kernel** — a clock-driven workload's cost is a fact about the deployment, not about the flow, so an author cannot decide it and no metadata key asks them to. Accepts `true`/`1`/`on`/`yes` (case-insensitive); anything else, including unset, is off. While it is off nothing arms, and every such flow is listed by `getTriggerBindingAudit()` and the CLI startup summary as **disabled by deployment policy** — never as a binding failure, and nothing about the flow needs fixing. While it is on, what a time-triggered flow owes depends on the posture: under `isolated` it must declare `config.organization` on its start node or it is not armed; under `group` the declaration is optional and an undeclared sweep reads group-wide with each run acting as its own swept record's organization (a record-less cron there carries none, and its tenant-scoped writes are refused — declare one if it writes); under `single` it needs no declaration and its runs carry no organization. ⛔ Platform-internal scheduled work — approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership backfill — is **not** gated by this: the boundary is *authored by a package*, not *runs on the job service*. `os doctor` prints the effective value. | | `OS_PLATFORM_OWNER_EMAIL` | csv | — | The deployment's platform administrators: one email address, or a comma-separated list. A caller resolves `PLATFORM_ADMIN` when their own stored `sys_user` row carries a declared address **and** reads email-verified. Comparison is trimmed and case-insensitive; duplicates collapse and blank entries are dropped. One **unparseable** entry refuses the **whole** variable rather than just that entry — the deployment then has zero configured administrators, loudly — because a silently narrower administrator set is the worse failure. **Required under the walled postures** (`group` / `isolated`): unset or blank there **refuses to boot**, since the first self-registrant is not promoted and no grant row is written. Unset under `single` is normal — that posture still promotes the first human account. Read live per resolution, so revocation is a config change plus a process reload; there is no runtime endpoint that changes it. See [First boot: create the admin](/docs/deployment/self-hosting#first-boot-create-the-admin). | | `OS_OIDC_PROVIDER_ENABLED` | boolean | tracks MCP | When `true`, expose this instance as an OIDC identity provider. When unset it follows the MCP server surface (`OS_MCP_SERVER_ENABLED`, on by default) — the MCP human-client track is OAuth 2.1, so every MCP-enabled deployment is its own authorization server. | | `OS_COOKIE_DOMAIN` | string | — | Cookie domain for cross-subdomain session sharing (e.g. `.example.com`). | diff --git a/content/docs/deployment/production-readiness.mdx b/content/docs/deployment/production-readiness.mdx index d8cf785df13..5d605b27837 100644 --- a/content/docs/deployment/production-readiness.mdx +++ b/content/docs/deployment/production-readiness.mdx @@ -140,8 +140,12 @@ the [HARDENING.md recipes](https://github.com/objectstack-ai/objectstack/blob/ma the startup summary: anything listed there as *disabled by deployment policy* is not broken, it is simply not switched on. Leaving it off is also a legitimate answer — an unsized clock-driven workload is a real cost - — but it should be one somebody made. Under `group` / `isolated`, every - flow you do arm must declare `config.organization`; see + — but it should be one somebody made. Under `isolated`, every flow you do + arm must declare `config.organization`. Under `group` the declaration is + optional — an undeclared sweep acts as each swept record's organization — + but check the bind lines for any record-less cron flow that writes + per-organization rows: it carries no organization and those writes are + refused, so it needs a declaration. See [Tenancy Modes & Membership](/docs/deployment/tenancy-modes). - [ ] Backup / restore drill documented and tested. - [ ] Data-retention windows reviewed (ADR-0057): the platform's default diff --git a/content/docs/deployment/tenancy-modes.mdx b/content/docs/deployment/tenancy-modes.mdx index 1dc1de85aea..61b90e590ec 100644 --- a/content/docs/deployment/tenancy-modes.mdx +++ b/content/docs/deployment/tenancy-modes.mdx @@ -321,12 +321,28 @@ Once it **is** on, the posture decides what a time-triggered flow owes: | posture | what an armed time-triggered flow needs | |---|---| | `single` | nothing. The deployment holds exactly one organization, the run carries **no** organization, and every tenant-scoped insert beneath it resolves that one organization the way a single-organization install always did. | -| `group` · `isolated` | `config.organization` on the start node, naming the organization the run executes as. A flow that declares none is **not armed** and is named in the boot summary. There is no fan-out: a sweep wanted in N organizations is declared N times, and nothing ever chooses an organization for a flow. | - -`group` is walled here like `isolated`, and not by analogy: an organization-less -system insert is refused under any wall, and which organization a group-wide -sweep's own inserts belong to is not yet decided. Until it is, `group` behaves -as walled. +| `group` | **optionally** `config.organization`. Declared, it behaves exactly as `isolated` below — bounding the sweep's query as well as its identity. Undeclared, the flow still arms: a `timeRelative` sweep reads group-wide and each run it launches acts as **its own swept record's organization**. | +| `isolated` | `config.organization` on the start node, naming the organization the run executes as. A flow that declares none is **not armed** and is named in the boot summary. There is no fan-out: a sweep wanted in N organizations is declared N times, and nothing ever chooses an organization for a flow. | + +`group` is walled like `isolated` — an organization-less system insert is refused +under any wall — but that is not the whole picture, and it is why the two rows +differ. `group` is one legal group over one shared database, where group-wide +visibility and cross-organization workflow are *inherent to the shape*, so a +group-level batch job is a capability of the posture rather than a boundary +violation. What was open until 2026-09-16 was which organization such a run's +inserts belong to; the answer is the swept record's own, which is the +subject-first order `sys_automation_run` already used. + + +A **record-less** flow under `group` — a plain `schedule` cron with no sweep — +has nothing to derive an organization from. It binds and runs, but any +tenant-scoped row it writes (a notification, an inbox message) is **refused** at +the write, by name, with the remedy. Declare `organization` on such a flow if it +writes per-organization data; its bind line says so at boot. ⛔ Nothing falls +back to a "default" organization — a wrong `organization_id` is silently +authoritative to every report and export that filters by organization, which is +worse than a refusal. + While the switch is off, such a flow is listed in the startup summary and in `getTriggerBindingAudit()` as **disabled by deployment policy** — not as a @@ -341,7 +357,7 @@ binding failure. Nothing about the flow needs fixing. | `OS_ALLOW_DEGRADED_TENANCY` | `false` | Boot even when a walled posture is requested but the runtime is absent (degraded). Accepts `1` / `true` / `on` / `yes`. Does not cover a runtime that refused to mount. | | `OS_ORG_LIMIT` | unset (unlimited) | Cap on organizations a single user may **own**; organizations they were merely invited into never count against it. Only meaningful under a walled posture, since org creation is refused otherwise. | | `OS_AUTH_MEMBERSHIP_POLICY` | `auto` | Env override for the `auth.membership_policy` setting — `auto` or `invite-only`. | -| `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` | `false` | Whether package-authored scheduled work runs here at all — time-triggered flows and packaged `defineJob` cron jobs. OFF in **every** posture until set. Under `single` a time-triggered flow that is armed by it needs no `config.organization` and its runs carry none; under `group` / `isolated` it must declare one or it is not armed. See [The acting organization](/docs/automation/flows). | +| `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` | `false` | Whether package-authored scheduled work runs here at all — time-triggered flows and packaged `defineJob` cron jobs. OFF in **every** posture until set. Under `single` a time-triggered flow that is armed by it needs no `config.organization` and its runs carry none; under `group` the declaration is optional and an undeclared sweep acts as each swept record's own organization; under `isolated` it must declare one or it is not armed. See [The acting organization](/docs/automation/flows). | | `OS_SKIP_MEMBERSHIP_BACKFILL` | unset | Set to `1` to skip the boot-time membership backfill. | See [Environment variables](/docs/deployment/environment-variables) for the full diff --git a/content/docs/references/automation/schedule-organization.mdx b/content/docs/references/automation/schedule-organization.mdx index 826e96f6bc3..7d1af3edca0 100644 --- a/content/docs/references/automation/schedule-organization.mdx +++ b/content/docs/references/automation/schedule-organization.mdx @@ -30,37 +30,87 @@ Maintainer, 2026-09-08, verbatim: > 多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。 -Under a WALLED tenancy posture (`group` / `isolated`) a time-triggered flow -is **organization-scoped by construction**: it names one organization and the +Under the `isolated` tenancy posture a time-triggered flow is +**organization-scoped by construction**: it names one organization and the run executes as that organization. There is deliberately no fan-out — a tenant that wants the same sweep in N organizations declares it N times — and -under a wall there is deliberately no fallback: a flow that names none is a -DECLARATION ERROR, not a run that quietly picks one. Guessing is the failure -this key exists to prevent, and the platform organization is not a safe -guess: a wrong `organization_id` is worse than a null, because a null is -visibly missing while a wrong value is silently authoritative to every -report, export and cleanup script that filters by organization. - -## Where that requirement bites, and where it does not (#17396) - -⚠️ The sentence above is scoped to walled postures, and the scoping is the -whole of the 2026-09-12 amendment. Two deployment facts decide whether this -key is required, and ⛔ neither of them is metadata — both are read from the -environment at boot, beside `resolveTenancyPosture`: - -| deployment | is this key required? | -|:--|:--| -| package-authored scheduled work switched OFF (the global default) | ⛔ nothing arms, so nothing is required — the flow is listed as *disabled by deployment policy*, never as a binding failure | -| switched ON, posture `single` | **no** — the deployment holds exactly one organization, the run carries none, and every tenant-scoped insert beneath it resolves that one through the #8844 guard | -| switched ON, posture `group` / `isolated` | **yes** — declare or the flow is not armed, exactly as above | - -⇒ The key is never *deprecated* and its meaning never changes: it is the only -way a run under a wall gets an organization, and nothing on this path ever -chooses one. What changed is that a missing key is no longer a defect on -every deployment — so ⛔ do not read the refusal sentence below as a universal -authoring rule, and ⛔ do not re-add an authoring-time lint for it: at -authoring time neither the switch nor the posture is knowable, which is why -the diagnostic lives at BIND and only fires where the answer is settled. +there is deliberately no fallback: a flow that names none is a DECLARATION +ERROR, not a run that quietly picks one. Guessing is the failure this key +exists to prevent, and the platform organization is not a safe guess: a wrong +`organization_id` is worse than a null, because a null is visibly missing +while a wrong value is silently authoritative to every report, export and +cleanup script that filters by organization. + +## Where that requirement bites, and where it does not (#17396, #18378) + +⚠️ The sentence above is scoped by POSTURE, and that scoping is the whole of +the 2026-09-12 and 2026-09-16 amendments. Two deployment facts decide whether +this key is required, and ⛔ neither of them is metadata — both are read from +the environment at boot, beside `resolveTenancyPosture`: + +| deployment | is this key required? | where a bound run's writes get their organization | +|:--|:--|:--| +| package-authored scheduled work switched OFF (the global default) | ⛔ nothing arms, so nothing is required — the flow is listed as *disabled by deployment policy*, never as a binding failure | — | +| switched ON, posture `single` | **no** | the run carries none; every tenant-scoped insert beneath it resolves the install's one organization through the #8844 guard | +| switched ON, posture `group` | **no — optional** | declared ⇒ the declaration, bounding SELECTION and identity alike; undeclared ⇒ **the swept record's own organization** | +| switched ON, posture `isolated` | **yes** — declare or the flow is not armed | the declaration | + +⇒ The key is never *deprecated* and its meaning never changes: where a run +declares one, that is the organization it acts as, and nothing on this path +ever chooses one out of the air. What changed is that a missing key is no +longer a defect on every deployment — so ⛔ do not read the refusal sentence +below as a universal authoring rule, and ⛔ do not re-add an authoring-time +lint for it: at authoring time neither the switch nor the posture is +knowable, which is why the diagnostic lives at BIND and only fires where the +answer is settled. + +## Why `group` is optional rather than required (#18378, ruling A′, 2026-09-16) + +The 2026-09-08 ruling was made for the MULTI-TENANT shape. #18378 asked +whether it binds a `group` — one legal group, one database, with group-wide +visibility and cross-org workflow *inherent to the posture* (ADR-0105 D1, the +multi-plant MES example is the ADR's own). It does not. + +⚠️ The `group` row is not a fallback that guesses. It is the resolution order +`sys_automation_run` has ALREADY been ruled to use: `ObjectStoreSuspendedRunStore` +resolves a run's organization as `organizationOf() ?? ctx.tenantId` +— subject first, the acting context as the fallback and *never* the primary +(`objectql/src/tenancy/platform-object-tenancy.ts`, the `sys_automation_run` +evidence line). Before this card the two halves disagreed under `group`: the +history row was stamped from the record while the inbox and delivery rows +followed an acting context that could not exist there, so they were refused. +Filling the acting context from the record makes one run carry ONE +organization's opinion about who it belonged to — which is the defect #16659 +opened on, read from the other side. + +⚠️ With ONE stated exception, so the sentence above is not read as a promise +it cannot keep. The two halves ask different questions and are answered by +different faces of the shared resolver: the history row is STAMPED (`who is +this row about` — `tenancy.organizationField` wins there, by the #8778 / +cloud#1395 ruling), while the run's acting organization is a WALL reading +(`what is this row scoped by`, which never consults that key). They give the +same answer on every object where the two coincide — every ordinary object, +because a declared stamp column is what makes them differ and one shipped +object declares one (`sys_api_key`, deliberately unwalled, #8287). Sweeping +THAT object under `group` stamps the history row from its stamp column while +the run itself acts as nothing and its inbox writes are refused. That is the +correct pair of answers rather than a residue of the old disagreement — a row +nothing walls has no organization for a run to act as, however clearly it +says who it is about — but it is a divergence, and it is recorded here rather +than smoothed over. + +⛔ A record-less run under `group` that declared nothing still resolves NOTHING, +and takes the existing `walled-posture` refusal at the write +(`resolveSystemWriteOrganization`) — loud, by name, carrying the remedy. The +card's original option A would have fallen back to the bootstrap organization +(`slug='default'`); that arm was rejected on measurement. Under a wall +`AuthPlugin` skips its own default-organization bootstrap and the enterprise +organizations runtime mints one ADMIN-KEYED, so a `group` install with no +resolvable platform admin holds no such organization at all; where one does +exist it is whichever organization the platform owner registered under — +plausibly one plant of many, not the group's head office. Landing a group-wide +cron's notifications in one arbitrary plant's inbox is the wrong-owner failure +the paragraph above forbids, not a lesser version of it. ## Where it lives, and why there @@ -121,7 +171,7 @@ const result = ScheduleOrganizationSchema.parse(data); ## ScheduleOrganization -Organization id (sys_organization.id) this scheduled/time-relative flow runs as. A time-triggered run has no session to inherit a tenant from, so under a walled tenancy posture (group/isolated) a flow that declares none is not armed; under the single posture it is not required and the run carries no organization. +Organization id (sys_organization.id) this scheduled/time-relative flow runs as. A time-triggered run has no session to inherit a tenant from. Required under the isolated tenancy posture: a flow that declares none is not armed. Optional under group, where an undeclared run acts as the organization of the record it swept. Not required under single, where the run carries no organization. **Type:** `string` diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index f1339130858..17eb1b5b417 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -273,10 +273,13 @@ export function scheduledWorkCheck(reading: DotenvReading): HealthCheckResult { ? `ON — packaged time-triggered flows and packaged \`defineJob\` cron jobs are armed (${SCHEDULED_WORK_ENV})` : `OFF (the default) — no packaged time-triggered flow and no packaged \`defineJob\` runs on this deployment`, fix: enabled - ? `Unset ${SCHEDULED_WORK_ENV} to turn it back off. While it is on, a time-triggered\n` - + ' flow under a WALLED tenancy posture (group/isolated) must declare\n' - + ' config.organization on its start node or it is not armed; under `single` it\n' - + ' needs no declaration and its runs carry no organization.\n' + ? `Unset ${SCHEDULED_WORK_ENV} to turn it back off. While it is on, what a\n` + + ' time-triggered flow owes depends on the tenancy posture: under `isolated` it\n' + + ' must declare config.organization on its start node or it is not armed; under\n' + + ' `group` the declaration is optional and an undeclared sweep acts as each\n' + + " swept record's own organization (a record-less cron there carries none, and\n" + + ' its tenant-scoped writes are refused — declare one if it writes); under\n' + + ' `single` it needs no declaration and its runs carry no organization.\n' + ` ${envSourceSentence(reading, provenance)}` : `Set ${SCHEDULED_WORK_ENV}=true to run package-authored scheduled work here.\n` + ' OFF is the global default in every posture and every kernel: a clock-driven\n' diff --git a/packages/lint/src/validate-flow-trigger-readiness.ts b/packages/lint/src/validate-flow-trigger-readiness.ts index 73ab3bfff67..87b1258f154 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.ts @@ -668,8 +668,11 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines // ⛔ What did NOT move: the BIND-time near-miss diagnostic. The // `describeMissingScheduleOrganization` sentence and its `organizationId` // / `tenantId` / … scan still fire at the one door where the key really - // is required — a walled posture with the switch on — and that door - // knows both facts. Authoring-time silence here is not a loss of the + // is required — posture `isolated` with the switch on (#18378 narrowed + // that from "a walled posture": under `group` an undeclared flow is a + // legal armed shape, so a near-miss there cannot be told apart from a + // deliberate omission) — and that door knows both facts. + // Authoring-time silence here is not a loss of the // diagnostic, it is the diagnostic moving to where the question is // answerable. diff --git a/packages/metadata-core/src/record-organization.test.ts b/packages/metadata-core/src/record-organization.test.ts index b595c52e263..3f24d4591d0 100644 --- a/packages/metadata-core/src/record-organization.test.ts +++ b/packages/metadata-core/src/record-organization.test.ts @@ -21,7 +21,9 @@ import { describe, it, expect, vi } from 'vitest'; import { createFieldPresenceProbe, createRecordOrganizationResolver, + createRecordWallOrganizationResolver, resolveRecordOrganizationField, + resolveRecordWallOrganizationField, } from './record-organization.js'; /** Minimal engine double: `getSchema` over a name → definition map. */ @@ -92,6 +94,102 @@ describe('resolveRecordOrganizationField — the four-limb precedence', () => { }); }); +/** + * [#18378] The WALL face — the same limbs MINUS limb 0. + * + * ⭐ The pins are written as a PAIR against the stamp face above wherever the + * two can diverge, because "these two answer the same question except here" is + * the whole claim, and a pin that only exercised the wall face would pass on an + * implementation that had quietly become a second copy of the precedence. + */ +describe('resolveRecordWallOrganizationField — limb 0 is not a limb here', () => { + it('the sys_api_key shape: the stamp face answers the declared column, the wall face answers NULL', () => { + // The ONE shipped object that declares the key, and the reason the two + // faces exist: `enabled: false` says nothing walls this table (#8287), so + // there is no organization for work launched from such a row to ACT AS, + // however clearly the row says who it is ABOUT. + const def = { + name: 'sys_api_key', + tenancy: { enabled: false, organizationField: 'active_organization_id' }, + fields: { id: {}, name: {}, user_id: {}, active_organization_id: {}, revoked: {} }, + }; + expect(resolveRecordOrganizationField(def, hasFieldOf(def))).toBe('active_organization_id'); + expect(resolveRecordWallOrganizationField(def, hasFieldOf(def))).toBeNull(); + }); + + it('a declared organizationField on a WALLED object is still not read — the wall answers its own column', () => { + // The hypothetical an author could write today: the stamp key on an object + // that IS walled, by a different column. The stamp face honours limb 0; the + // wall face takes limb 2, because that is the column the row is scoped by + // and therefore the only one an acting identity may come from. + const def = { + name: 'ws_doc', + tenancy: { enabled: true, tenantField: 'workspace_id', organizationField: 'about_org_id' }, + fields: { id: {}, workspace_id: {}, about_org_id: {}, organization_id: {} }, + }; + expect(resolveRecordOrganizationField(def, hasFieldOf(def))).toBe('about_org_id'); + expect(resolveRecordWallOrganizationField(def, hasFieldOf(def))).toBe('workspace_id'); + }); + + it('limbs 1 to 4 are SHARED — the two faces agree everywhere limb 0 is absent', () => { + // The anti-drift pin. Every shape the stamp face pins above, minus the two + // that declare the key: the answers must be identical, so a future edit + // that "fixes" one body cannot leave the other behind. + const shapes = [ + { name: 'sys_sso_provider', tenancy: { enabled: false }, fields: { id: {}, organization_id: {} } }, + { name: 'ws_doc', tenancy: { enabled: true, tenantField: 'workspace_id' }, fields: { id: {}, workspace_id: {}, organization_id: {} } }, + { name: 'ws_doc_phantom', tenancy: { enabled: true, tenantField: 'nope' }, fields: { id: {}, organization_id: {} } }, + { name: 'crm_deal', fields: { id: {}, organization_id: {} } }, + { name: 'crm_deal_bare', fields: { id: {}, amount: {} } }, + ]; + for (const def of shapes) { + expect( + resolveRecordWallOrganizationField(def, hasFieldOf(def)), + `wall and stamp must agree on '${def.name}'`, + ).toBe(resolveRecordOrganizationField(def, hasFieldOf(def))); + } + expect(resolveRecordWallOrganizationField(undefined, () => true)).toBeNull(); + expect(resolveRecordWallOrganizationField(null, () => true)).toBeNull(); + }); +}); + +describe('createRecordWallOrganizationResolver — the sweep’s memoized face', () => { + it('resolves the wall column end to end, and answers null on the unwalled credential shape', () => { + const engine = engineOf({ + crm_deal: { fields: { id: {}, organization_id: {} } }, + sys_api_key: { + tenancy: { enabled: false, organizationField: 'active_organization_id' }, + fields: { id: {}, active_organization_id: {} }, + }, + }); + const wall = createRecordWallOrganizationResolver(engine); + expect(wall.organizationFieldFor('crm_deal')).toBe('organization_id'); + expect(wall.organizationOf('crm_deal', { id: 'd1', organization_id: 'org_A' })).toBe('org_A'); + expect(wall.organizationFieldFor('sys_api_key')).toBeNull(); + expect(wall.organizationOf('sys_api_key', { id: 'k1', active_organization_id: 'org_key' })).toBeNull(); + // The stamp face over the SAME engine still answers — the divergence is in + // the faces, not in the engine or the fixture. + expect( + createRecordOrganizationResolver(engine).organizationOf('sys_api_key', { + id: 'k1', + active_organization_id: 'org_key', + }), + ).toBe('org_key'); + }); + + it('shares the glue: same degradation posture, same memoization', () => { + expect(createRecordWallOrganizationResolver({}).organizationOf('crm_deal', { organization_id: 'org_A' })).toBeNull(); + const throwing = { getSchema: () => { throw new Error('not booted'); } }; + expect(createRecordWallOrganizationResolver(throwing).organizationOf('crm_deal', { organization_id: 'org_A' })).toBeNull(); + const engine = engineOf({ crm_deal: { fields: { id: {}, organization_id: {} } } }); + const wall = createRecordWallOrganizationResolver(engine); + wall.organizationOf('crm_deal', { organization_id: 'a' }); + const calls = engine.getSchema.mock.calls.filter(([n]) => n === 'crm_deal').length; + wall.organizationOf('crm_deal', { organization_id: 'b' }); + expect(engine.getSchema.mock.calls.filter(([n]) => n === 'crm_deal').length).toBe(calls); + }); +}); + describe('createFieldPresenceProbe', () => { it('answers from the registered schema, map and array field shapes alike, memoized per object', () => { const engine = engineOf({ diff --git a/packages/metadata-core/src/record-organization.ts b/packages/metadata-core/src/record-organization.ts index 5429dff34c3..d614b377fe7 100644 --- a/packages/metadata-core/src/record-organization.ts +++ b/packages/metadata-core/src/record-organization.ts @@ -31,6 +31,15 @@ * the implementation here does not open the key: it closes the excuse for a * fourth copy. * + * ⭐ [#18378] This module now answers TWO questions, and only the first reads + * that key. {@link resolveRecordOrganizationField} is the STAMP answer ("who is + * this row about"), consumers still the three above; + * {@link resolveRecordWallOrganizationField} is the WALL answer ("what is this + * row scoped by", and so which organization work launched from it acts as), + * which skips limb 0 entirely. A caller of the second is not a fourth consumer + * of the key — it never reads it — and the split is what keeps the scope-pin + * from being widened by callers who only ever wanted the wall. + * * A platform row is stamped from the organization the record is ABOUT (#8287's * ruling). To do that the writer has to know which column holds it, and * `organization_id` is not universally the answer: `sys_api_key` carries @@ -199,13 +208,81 @@ export function createFieldPresenceProbe( export function resolveRecordOrganizationField( objectDef: unknown, hasField: (field: string) => boolean, +): string | null { + return resolveOrganizationField(objectDef, hasField, { readStampKey: true }); +} + +/** + * [#18378] The WALL-side sibling: "which column is this object tenant-scoped + * by?" — limbs 1 to 4 of the precedence above, with limb 0 deliberately NOT + * consulted. + * + * ⭐ Same limbs from the same source, because the two questions differ in + * exactly one place. "Which column says who this row is ABOUT" (stamping) and + * "which column is this row WALLED by" (scope, and therefore the organization + * work launched from the row acts as) coincide on every ordinary object, and + * come apart only where an author declared `tenancy.organizationField` — which + * is ONE shipped object, `sys_api_key`, whose whole point is that it is not + * walled (#8287). + * + * ⛔ It does not read `tenancy.organizationField`, and that is the contract + * rather than an omission. The key's consumers stay pinned to the THREE + * platform-row writers the cloud#1395 ruling names; a caller asking the WALL + * question is not a fourth consumer of the stamp key, it is a caller of a + * different question. Reading limb 0 here would take a declaration meaning "the + * audit trail should follow this row's own organization even though nothing + * walls it" and turn it into an ACTING IDENTITY — a sweep over `sys_api_key` + * would then launch runs acting as an organization derived from an annotation + * that never meant "act as this". These limbs resolve `null` there instead, and + * the caller takes the existing `walled-posture` refusal at its first + * tenant-scoped write (ADR-0112), loudly and by name. + * + * ⚠️ The twin of `@objectstack/objectql`'s `resolveTenantFieldName`, which says + * the same of `SqlDriver.computeTenantField` — three spellings of one rule is + * one too many, and this is the sinkable one (this package is `spec` + zod, + * which is why the stamp resolver was sunk here at all). Converging them is its + * own change with its own blast radius: #18378 adds no FOURTH spelling — it + * shares limbs 1 to 4 with the stamp face below, pinned in this package's own + * suite ("the two faces agree everywhere limb 0 is absent") — and leaves the + * existing two where they are. + * + * ⛔ No cross-package parity pin is added here, deliberately and not by + * oversight: `@objectstack/objectql` is registered in `check:test-source-alias` + * as still resolving `@objectstack/metadata-core` through `dist/`, so a pin + * living there would be a verdict about build state rather than about either + * checkout — the passing-test failure that gate exists to catch. The + * convergence, and the alias it needs, belong to the card that does it. + */ +export function resolveRecordWallOrganizationField( + objectDef: unknown, + hasField: (field: string) => boolean, +): string | null { + return resolveOrganizationField(objectDef, hasField, { readStampKey: false }); +} + +/** + * The limbs themselves, in ONE place — `readStampKey` selects limb 0 alone. + * + * A parameter rather than two bodies because limbs 1 to 4 are shared BY + * CONTRACT: the precedence doc above states at length that a platform row's + * stamp must agree with the wall the row is later read through. Two bodies + * would let them answer differently on the day one of them is fixed, which is + * the exact failure the promotion ruling was written against. + */ +function resolveOrganizationField( + objectDef: unknown, + hasField: (field: string) => boolean, + { readStampKey }: { readStampKey: boolean }, ): string | null { if (!objectDef || typeof objectDef !== 'object') return null; const tenancy = (objectDef as { tenancy?: { organizationField?: unknown; tenantField?: unknown } }).tenancy; // Limb 0 — the explicit stamp-only declaration (#8778) wins over everything, - // the ADR-0066 opt-out below included: see the precedence doc above. - const stampField = tenancy?.organizationField; - if (typeof stampField === 'string' && stampField.length > 0 && hasField(stampField)) return stampField; + // the ADR-0066 opt-out below included: see the precedence doc above. Reached + // by the three sanctioned platform-row writers and by nobody else. + if (readStampKey) { + const stampField = tenancy?.organizationField; + if (typeof stampField === 'string' && stampField.length > 0 && hasField(stampField)) return stampField; + } if (isTenancyDisabled(objectDef)) return null; const declared = tenancy?.tenantField; if (typeof declared === 'string' && declared.length > 0 && hasField(declared)) return declared; @@ -245,6 +322,29 @@ export interface RecordOrganizationResolver { * context fallback instead of failing the write. */ export function createRecordOrganizationResolver(engine: unknown): RecordOrganizationResolver { + return createResolver(engine, resolveRecordOrganizationField); +} + +/** + * [#18378] The WALL-side face, over the same glue — what a caller asking "which + * organization does this record BELONG to, and therefore which one does work + * launched from it act as" holds. + * + * Same memoization, same value reading, same best-effort posture as the stamp + * face above; the one difference is which precedence it binds + * ({@link resolveRecordWallOrganizationField}, i.e. limb 0 skipped). Built over + * a shared builder rather than copied, for the reason the interface docblock + * already gives: a per-caller copy of "read the resolved column off the record, + * treating empty as absent" is where the next drift starts. + */ +export function createRecordWallOrganizationResolver(engine: unknown): RecordOrganizationResolver { + return createResolver(engine, resolveRecordWallOrganizationField); +} + +function createResolver( + engine: unknown, + resolveField: (objectDef: unknown, hasField: (field: string) => boolean) => string | null, +): RecordOrganizationResolver { const hasField = createFieldPresenceProbe(engine); const columnCache = new Map(); const organizationFieldFor = (objectName: string): string | null => { @@ -257,7 +357,7 @@ export function createRecordOrganizationResolver(engine: unknown): RecordOrganiz } catch { /* ignore — best-effort; absence just means the caller falls back */ } - const resolved = resolveRecordOrganizationField(objectDef, (field) => hasField(objectName, field)); + const resolved = resolveField(objectDef, (field) => hasField(objectName, field)); columnCache.set(objectName, resolved); return resolved; }; diff --git a/packages/spec/src/automation/schedule-organization.zod.ts b/packages/spec/src/automation/schedule-organization.zod.ts index b26d5eca813..2c16f4f983a 100644 --- a/packages/spec/src/automation/schedule-organization.zod.ts +++ b/packages/spec/src/automation/schedule-organization.zod.ts @@ -28,37 +28,87 @@ import { z } from 'zod'; * * > 多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。 * - * Under a WALLED tenancy posture (`group` / `isolated`) a time-triggered flow - * is **organization-scoped by construction**: it names one organization and the + * Under the `isolated` tenancy posture a time-triggered flow is + * **organization-scoped by construction**: it names one organization and the * run executes as that organization. There is deliberately no fan-out — a * tenant that wants the same sweep in N organizations declares it N times — and - * under a wall there is deliberately no fallback: a flow that names none is a - * DECLARATION ERROR, not a run that quietly picks one. Guessing is the failure - * this key exists to prevent, and the platform organization is not a safe - * guess: a wrong `organization_id` is worse than a null, because a null is - * visibly missing while a wrong value is silently authoritative to every - * report, export and cleanup script that filters by organization. - * - * ## Where that requirement bites, and where it does not (#17396) - * - * ⚠️ The sentence above is scoped to walled postures, and the scoping is the - * whole of the 2026-09-12 amendment. Two deployment facts decide whether this - * key is required, and ⛔ neither of them is metadata — both are read from the - * environment at boot, beside `resolveTenancyPosture`: - * - * | deployment | is this key required? | - * |:--|:--| - * | package-authored scheduled work switched OFF (the global default) | ⛔ nothing arms, so nothing is required — the flow is listed as *disabled by deployment policy*, never as a binding failure | - * | switched ON, posture `single` | **no** — the deployment holds exactly one organization, the run carries none, and every tenant-scoped insert beneath it resolves that one through the #8844 guard | - * | switched ON, posture `group` / `isolated` | **yes** — declare or the flow is not armed, exactly as above | - * - * ⇒ The key is never *deprecated* and its meaning never changes: it is the only - * way a run under a wall gets an organization, and nothing on this path ever - * chooses one. What changed is that a missing key is no longer a defect on - * every deployment — so ⛔ do not read the refusal sentence below as a universal - * authoring rule, and ⛔ do not re-add an authoring-time lint for it: at - * authoring time neither the switch nor the posture is knowable, which is why - * the diagnostic lives at BIND and only fires where the answer is settled. + * there is deliberately no fallback: a flow that names none is a DECLARATION + * ERROR, not a run that quietly picks one. Guessing is the failure this key + * exists to prevent, and the platform organization is not a safe guess: a wrong + * `organization_id` is worse than a null, because a null is visibly missing + * while a wrong value is silently authoritative to every report, export and + * cleanup script that filters by organization. + * + * ## Where that requirement bites, and where it does not (#17396, #18378) + * + * ⚠️ The sentence above is scoped by POSTURE, and that scoping is the whole of + * the 2026-09-12 and 2026-09-16 amendments. Two deployment facts decide whether + * this key is required, and ⛔ neither of them is metadata — both are read from + * the environment at boot, beside `resolveTenancyPosture`: + * + * | deployment | is this key required? | where a bound run's writes get their organization | + * |:--|:--|:--| + * | package-authored scheduled work switched OFF (the global default) | ⛔ nothing arms, so nothing is required — the flow is listed as *disabled by deployment policy*, never as a binding failure | — | + * | switched ON, posture `single` | **no** | the run carries none; every tenant-scoped insert beneath it resolves the install's one organization through the #8844 guard | + * | switched ON, posture `group` | **no — optional** | declared ⇒ the declaration, bounding SELECTION and identity alike; undeclared ⇒ **the swept record's own organization** | + * | switched ON, posture `isolated` | **yes** — declare or the flow is not armed | the declaration | + * + * ⇒ The key is never *deprecated* and its meaning never changes: where a run + * declares one, that is the organization it acts as, and nothing on this path + * ever chooses one out of the air. What changed is that a missing key is no + * longer a defect on every deployment — so ⛔ do not read the refusal sentence + * below as a universal authoring rule, and ⛔ do not re-add an authoring-time + * lint for it: at authoring time neither the switch nor the posture is + * knowable, which is why the diagnostic lives at BIND and only fires where the + * answer is settled. + * + * ## Why `group` is optional rather than required (#18378, ruling A′, 2026-09-16) + * + * The 2026-09-08 ruling was made for the MULTI-TENANT shape. #18378 asked + * whether it binds a `group` — one legal group, one database, with group-wide + * visibility and cross-org workflow *inherent to the posture* (ADR-0105 D1, the + * multi-plant MES example is the ADR's own). It does not. + * + * ⚠️ The `group` row is not a fallback that guesses. It is the resolution order + * `sys_automation_run` has ALREADY been ruled to use: `ObjectStoreSuspendedRunStore` + * resolves a run's organization as `organizationOf() ?? ctx.tenantId` + * — subject first, the acting context as the fallback and *never* the primary + * (`objectql/src/tenancy/platform-object-tenancy.ts`, the `sys_automation_run` + * evidence line). Before this card the two halves disagreed under `group`: the + * history row was stamped from the record while the inbox and delivery rows + * followed an acting context that could not exist there, so they were refused. + * Filling the acting context from the record makes one run carry ONE + * organization's opinion about who it belonged to — which is the defect #16659 + * opened on, read from the other side. + * + * ⚠️ With ONE stated exception, so the sentence above is not read as a promise + * it cannot keep. The two halves ask different questions and are answered by + * different faces of the shared resolver: the history row is STAMPED (`who is + * this row about` — `tenancy.organizationField` wins there, by the #8778 / + * cloud#1395 ruling), while the run's acting organization is a WALL reading + * (`what is this row scoped by`, which never consults that key). They give the + * same answer on every object where the two coincide — every ordinary object, + * because a declared stamp column is what makes them differ and one shipped + * object declares one (`sys_api_key`, deliberately unwalled, #8287). Sweeping + * THAT object under `group` stamps the history row from its stamp column while + * the run itself acts as nothing and its inbox writes are refused. That is the + * correct pair of answers rather than a residue of the old disagreement — a row + * nothing walls has no organization for a run to act as, however clearly it + * says who it is about — but it is a divergence, and it is recorded here rather + * than smoothed over. + * + * ⛔ A record-less run under `group` that declared nothing still resolves NOTHING, + * and takes the existing `walled-posture` refusal at the write + * (`resolveSystemWriteOrganization`) — loud, by name, carrying the remedy. The + * card's original option A would have fallen back to the bootstrap organization + * (`slug='default'`); that arm was rejected on measurement. Under a wall + * `AuthPlugin` skips its own default-organization bootstrap and the enterprise + * organizations runtime mints one ADMIN-KEYED, so a `group` install with no + * resolvable platform admin holds no such organization at all; where one does + * exist it is whichever organization the platform owner registered under — + * plausibly one plant of many, not the group's head office. Landing a group-wide + * cron's notifications in one arbitrary plant's inbox is the wrong-owner failure + * the paragraph above forbids, not a lesser version of it. * * ## Where it lives, and why there * @@ -119,7 +169,7 @@ export const ScheduleOrganizationSchema = z .string() .min(1) .describe( - 'Organization id (sys_organization.id) this scheduled/time-relative flow runs as. A time-triggered run has no session to inherit a tenant from, so under a walled tenancy posture (group/isolated) a flow that declares none is not armed; under the single posture it is not required and the run carries no organization.', + 'Organization id (sys_organization.id) this scheduled/time-relative flow runs as. A time-triggered run has no session to inherit a tenant from. Required under the isolated tenancy posture: a flow that declares none is not armed. Optional under group, where an undeclared run acts as the organization of the record it swept. Not required under single, where the run carries no organization.', ); /** @@ -232,15 +282,20 @@ function findScheduleOrganizationNearMissInConfig( * consequence lives: there is no path by which an organization-less * time-triggered run reaches the data layer once bind refuses. * - * ⚠️ [#17396] BIND is also the only door that knows whether the key is required - * at all. This sentence is emitted by exactly one gate — a walled tenancy - * posture (`group` / `isolated`) with package-authored scheduled work switched - * on — because those are the two deployment facts that decide it, and a trigger - * binding inside a booted kernel is the first place both are readable. Under - * `single` the sentence is never emitted and must not be: nothing is missing - * there. It is written unconditionally as a requirement because every reader - * that receives it IS under that gate; ⛔ do not reuse it to describe a flow on - * a deployment where the key is optional. + * ⚠️ [#17396, #18378] BIND is also the only door that knows whether the key is + * required at all. This sentence is emitted by exactly one gate — tenancy + * posture `isolated` with package-authored scheduled work switched on — because + * those are the two deployment facts that decide it, and a trigger binding + * inside a booted kernel is the first place both are readable. Under `single` + * and under `group` the sentence is never emitted and must not be: nothing is + * missing there. It is written unconditionally as a requirement because every + * reader that receives it IS under that gate; ⛔ do not reuse it to describe a + * flow on a deployment where the key is optional. + * + * ⛔ `group` is NOT a near-miss of `isolated` for this purpose. An undeclared + * `group` flow is a legal, armed, fully-supported shape (ruling A′) — emitting + * this sentence there would send an operator to write a key the deployment does + * not want, and would describe a bound flow as unbound. * * It names the flow (the ruling requires that), the key, where the key goes, * and — when the author wrote a near-miss — which spelling of theirs was diff --git a/packages/spec/src/migrations/entries/semantic/18.schedule-flow-acting-organization-required.ts b/packages/spec/src/migrations/entries/semantic/18.schedule-flow-acting-organization-required.ts index f63d6a9e4bc..f615c9bd9fc 100644 --- a/packages/spec/src/migrations/entries/semantic/18.schedule-flow-acting-organization-required.ts +++ b/packages/spec/src/migrations/entries/semantic/18.schedule-flow-acting-organization-required.ts @@ -13,10 +13,11 @@ export const entry: SemanticMigration = { + 'or re-typed: the start node\'s `config` is an OPEN record (ADR-0018), so the key is an ' + 'ADDITION to a slot that already accepted it, and every flow that parses today parses ' + 'byte-identically after the change. What narrows is the BIND-time accept set and the ' - + 'RUN-time data plane — and what the 2026-09-12 amendment narrows further is WHERE that ' - + 'narrowing applies: the declaration is required under a walled tenancy posture ' - + '(`group` / `isolated`) only, and no time-triggered flow arms anywhere until the ' - + 'deployment switches package-authored scheduled work on.', + + 'RUN-time data plane — and what the 2026-09-12 and 2026-09-16 amendments narrow further ' + + 'is WHERE that narrowing applies: the declaration is required under tenancy posture ' + + '`isolated` only, is OPTIONAL under `group` (where an undeclared run acts as the swept ' + + "record's own organization), is not read under `single`, and no time-triggered flow arms " + + 'anywhere until the deployment switches package-authored scheduled work on.', replacement: 'Two deployment decisions, in this order. (1) DECIDE WHETHER THIS DEPLOYMENT RUNS ' + 'PACKAGE-AUTHORED SCHEDULED WORK AT ALL: `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true` ' @@ -26,8 +27,8 @@ export const entry: SemanticMigration = { + 'DEPLOYMENT POLICY rather than as a binding failure. Platform-internal jobs ' + '(approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership ' + 'backfill) are NOT gated by it: the boundary is "authored by a package", not "runs on ' - + 'the job service". (2) ONLY IF THE SWITCH IS ON AND THE POSTURE IS WALLED, declare the ' - + 'organization each flow runs as, on the start node beside the cadence: ' + + 'the job service". (2) ONLY IF THE SWITCH IS ON AND THE POSTURE IS `isolated`, declare ' + + 'the organization each flow runs as, on the start node beside the cadence: ' + "`config: { schedule: { … }, organization: '' }`. There is " + 'deliberately NO fan-out — a sweep wanted in N organizations is N flows, one per ' + 'organization — and deliberately no fallback: nothing on this path ever chooses an ' @@ -35,8 +36,17 @@ export const entry: SemanticMigration = { + 'report, export and cleanup that filters by organization, while a refusal is visible ' + 'at boot and names its flow. Under the `single` posture with the switch on, declare ' + 'NOTHING: the run carries no organization and every tenant-scoped insert beneath it ' - + 'resolves the deployment\'s one organization through the #8844 guard. ⚠️ Three ' - + 'consequences apply to a WALLED deployment that splits one flow into N, and each is ' + + 'resolves the deployment\'s one organization through the #8844 guard. Under the `group` ' + + 'posture with the switch on, declaring is OPTIONAL and both shapes are supported: a ' + + 'declared flow behaves exactly as under `isolated` (the declaration bounds SELECTION and ' + + 'identity alike), while an UNDECLARED flow arms, reads group-wide — which ADR-0105 D1 ' + + 'makes inherent to the posture — and stamps each run it launches with the SWEPT ' + + "RECORD's own organization, the same subject-first order `sys_automation_run` already " + + 'uses. ⚠️ An undeclared `group` flow that reaches a tenant-scoped write with NO record ' + + 'to derive from — a record-less cron emitting a notification — is REFUSED at that write ' + + '(`walled-posture`, ADR-0112), loudly and by name; declare `config.organization` on that ' + + 'flow, which is the remedy the refusal itself prints. ⚠️ Three ' + + 'consequences apply to an `isolated` deployment that splits one flow into N, and each is ' + 'deployment work: (1) rows whose tenant column is NULL stay visible to a scoped read ' + '(`org = :tenant OR org IS NULL`), so after the split each such row is matched ONCE ' + 'PER FLOW — N runs and N notifications for one row, each acting as a different ' @@ -48,7 +58,7 @@ export const entry: SemanticMigration = { + 'carries no `tenantId`, so it resumes org-less — drain or accept in-flight suspended ' + 'runs rather than assuming the upgrade confines them retroactively.', reason: - 'Two maintainer rulings, both verbatim and untranslated, in the order they were given. ' + 'Three maintainer rulings, all verbatim and untranslated, in the order they were given. ' + '2026-09-08: ' + '「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。」 A time-triggered ' + 'run is launched from a job tick and a job tick carries no identity, so the run reached ' @@ -61,9 +71,28 @@ export const entry: SemanticMigration = { + 'Whether clock-driven work is affordable is a fact about the DEPLOYMENT — its database, ' + 'its tenants, its budget — that no author can know and no metadata key should ask them ' + 'for, so the gate is a deployment variable read at boot and the global default is OFF. ' - + 'Where the switch is on, the 2026-09-08 ruling stands unchanged under a wall and is ' - + 'moot under `single`, which holds exactly one organization and therefore has no ' - + 'cross-organization task to forbid. ⛔ NOT losslessly convertible, and the reason is ' + + '2026-09-16, reopening the `group` half of that amendment and nothing else: ' + + '「group 模式是本地部署的,运行 schedule 应该是可以的,但是你没有权限,可以单独开一个决策卡」 — ' + + 'ruled A′ on #18378. The 2026-09-08 ruling was made for the MULTI-TENANT shape, and ' + + '`group` is not one: ADR-0105 D1 defines it as one legal group over one database with ' + + 'group-wide visibility and cross-org workflow INHERENT to the shape, so a group-level ' + + 'batch job is a capability of the posture rather than the cross-organization task the ' + + 'ruling forbids. What was genuinely unanswered — recorded as unanswered by ruling G item ' + + '3 — was which organization such a run\'s inserts belong to, and the answer is the one ' + + '`sys_automation_run` was already ruled to use: the SUBJECT RECORD\'s organization, with ' + + 'the acting context as the fallback and never the primary. Filling the acting context ' + + 'the same way makes the inbox, delivery and history rows of one run agree about its ' + + 'owner; leaving them to disagree was the defect, not the fix. ⛔ The rejected arm is ' + + 'recorded too, because it is the one a later reader will re-propose: falling back to the ' + + "bootstrap organization (`slug='default'`) for a record-less run. Under a wall that " + + 'organization is minted ADMIN-KEYED by the enterprise organizations runtime and may not ' + + 'exist at all, and where it does it is whichever organization the platform owner ' + + 'registered under — plausibly one plant of many. That is the silently-authoritative ' + + 'wrong owner this entry already forbids, so a record-less undeclared run is refused ' + + 'instead. Where the switch is on, the 2026-09-08 ruling therefore stands unchanged under ' + + '`isolated`, is satisfied per-record under `group`, and is moot under `single`, which ' + + 'holds exactly one organization and therefore has no cross-organization task to forbid. ' + + '⛔ NOT losslessly convertible, and the reason is ' + 'that both remedies are values only the deployment holds: an organization id is minted ' + 'per install at runtime and the switch is an operator decision about cost, so there is ' + 'no authored artifact and no stored representation a transform could rewrite — ' @@ -85,8 +114,17 @@ export const entry: SemanticMigration = { + '`[schedule] NOT BOUND` / `[time-relative] NOT BOUND`, because nothing was refused for ' + 'a declaration. A deployment that sets it to `true` under posture `single` confirms ' + 'that its time-triggered flows are armed while declaring no `config.organization`, and ' - + 'that the runs they launch carry none. A deployment that sets it to `true` under a ' - + 'walled posture (`group` / `isolated`) confirms that every `schedule` / `time_relative` ' + + 'that the runs they launch carry none. A deployment that sets it to `true` under posture ' + + '`group` confirms the shape it wants PER FLOW: for a flow it left undeclared, that boot ' + + 'logs the bind line naming per-record ownership, that a sweep tick launches runs stamped ' + + "with each swept record's own organization (NOT one organization for the batch), and " + + 'that any record-less cron among them either declares `config.organization` or is ' + + 'accepted to fail loudly at its first tenant-scoped write; for a flow it declared, the ' + + '`isolated` criteria below apply unchanged. ⚠️ The discriminating observation for the ' + + 'undeclared case is the SET of organizations across the runs one tick launched — a pin ' + + 'that reads only "a run was stamped" passes on the defect too, which stamped them all ' + + 'alike. A deployment that sets it to `true` under posture `isolated` ' + + 'confirms that every `schedule` / `time_relative` ' + 'flow in the stack declares a non-empty `config.organization` on its start node, that ' + 'boot logs no `NOT BOUND` line, that `getFlowRuntimeStates()` reports `bound: true` and ' + 'that `getTriggerBindingAudit()` lists no time-triggered flow — and, where it ran ONE ' @@ -98,6 +136,7 @@ export const entry: SemanticMigration = { + 'that was skipped. ⚠️ `@objectstack/driver-memory` has NO legal configuration for a ' + 'time-triggered flow that touches per-organization data under a wall: it refuses any ' + 'call handed a tenant scope (`MEMORY_MULTI_TENANT_UNSUPPORTED`), so a declared flow is ' - + 'refused per call while an undeclared one is not armed at all. Multi-organization ' - + 'deployments use `@objectstack/driver-sql`.', + + 'refused per call, an undeclared `isolated` one is not armed at all, and an undeclared ' + + '`group` one arms and sweeps unscoped but is refused at the first write it derives an ' + + 'organization for. Multi-organization deployments use `@objectstack/driver-sql`.', }; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 09ff2453556..4f6f1507678 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -10429,10 +10429,11 @@ const step18: MigrationStep = { + 'or re-typed: the start node\'s `config` is an OPEN record (ADR-0018), so the key is an ' + 'ADDITION to a slot that already accepted it, and every flow that parses today parses ' + 'byte-identically after the change. What narrows is the BIND-time accept set and the ' - + 'RUN-time data plane — and what the 2026-09-12 amendment narrows further is WHERE that ' - + 'narrowing applies: the declaration is required under a walled tenancy posture ' - + '(`group` / `isolated`) only, and no time-triggered flow arms anywhere until the ' - + 'deployment switches package-authored scheduled work on.', + + 'RUN-time data plane — and what the 2026-09-12 and 2026-09-16 amendments narrow further ' + + 'is WHERE that narrowing applies: the declaration is required under tenancy posture ' + + '`isolated` only, is OPTIONAL under `group` (where an undeclared run acts as the swept ' + + "record's own organization), is not read under `single`, and no time-triggered flow arms " + + 'anywhere until the deployment switches package-authored scheduled work on.', replacement: 'Two deployment decisions, in this order. (1) DECIDE WHETHER THIS DEPLOYMENT RUNS ' + 'PACKAGE-AUTHORED SCHEDULED WORK AT ALL: `OS_AUTOMATION_SCHEDULED_WORK_ENABLED=true` ' @@ -10442,8 +10443,8 @@ const step18: MigrationStep = { + 'DEPLOYMENT POLICY rather than as a binding failure. Platform-internal jobs ' + '(approvals escalation, the lifecycle Reaper, the messaging dispatch loop, membership ' + 'backfill) are NOT gated by it: the boundary is "authored by a package", not "runs on ' - + 'the job service". (2) ONLY IF THE SWITCH IS ON AND THE POSTURE IS WALLED, declare the ' - + 'organization each flow runs as, on the start node beside the cadence: ' + + 'the job service". (2) ONLY IF THE SWITCH IS ON AND THE POSTURE IS `isolated`, declare ' + + 'the organization each flow runs as, on the start node beside the cadence: ' + "`config: { schedule: { … }, organization: '' }`. There is " + 'deliberately NO fan-out — a sweep wanted in N organizations is N flows, one per ' + 'organization — and deliberately no fallback: nothing on this path ever chooses an ' @@ -10451,8 +10452,17 @@ const step18: MigrationStep = { + 'report, export and cleanup that filters by organization, while a refusal is visible ' + 'at boot and names its flow. Under the `single` posture with the switch on, declare ' + 'NOTHING: the run carries no organization and every tenant-scoped insert beneath it ' - + 'resolves the deployment\'s one organization through the #8844 guard. ⚠️ Three ' - + 'consequences apply to a WALLED deployment that splits one flow into N, and each is ' + + 'resolves the deployment\'s one organization through the #8844 guard. Under the `group` ' + + 'posture with the switch on, declaring is OPTIONAL and both shapes are supported: a ' + + 'declared flow behaves exactly as under `isolated` (the declaration bounds SELECTION and ' + + 'identity alike), while an UNDECLARED flow arms, reads group-wide — which ADR-0105 D1 ' + + 'makes inherent to the posture — and stamps each run it launches with the SWEPT ' + + "RECORD's own organization, the same subject-first order `sys_automation_run` already " + + 'uses. ⚠️ An undeclared `group` flow that reaches a tenant-scoped write with NO record ' + + 'to derive from — a record-less cron emitting a notification — is REFUSED at that write ' + + '(`walled-posture`, ADR-0112), loudly and by name; declare `config.organization` on that ' + + 'flow, which is the remedy the refusal itself prints. ⚠️ Three ' + + 'consequences apply to an `isolated` deployment that splits one flow into N, and each is ' + 'deployment work: (1) rows whose tenant column is NULL stay visible to a scoped read ' + '(`org = :tenant OR org IS NULL`), so after the split each such row is matched ONCE ' + 'PER FLOW — N runs and N notifications for one row, each acting as a different ' @@ -10464,7 +10474,7 @@ const step18: MigrationStep = { + 'carries no `tenantId`, so it resumes org-less — drain or accept in-flight suspended ' + 'runs rather than assuming the upgrade confines them retroactively.', reason: - 'Two maintainer rulings, both verbatim and untranslated, in the order they were given. ' + 'Three maintainer rulings, all verbatim and untranslated, in the order they were given. ' + '2026-09-08: ' + '「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。」 A time-triggered ' + 'run is launched from a job tick and a job tick carries no identity, so the run reached ' @@ -10477,9 +10487,28 @@ const step18: MigrationStep = { + 'Whether clock-driven work is affordable is a fact about the DEPLOYMENT — its database, ' + 'its tenants, its budget — that no author can know and no metadata key should ask them ' + 'for, so the gate is a deployment variable read at boot and the global default is OFF. ' - + 'Where the switch is on, the 2026-09-08 ruling stands unchanged under a wall and is ' - + 'moot under `single`, which holds exactly one organization and therefore has no ' - + 'cross-organization task to forbid. ⛔ NOT losslessly convertible, and the reason is ' + + '2026-09-16, reopening the `group` half of that amendment and nothing else: ' + + '「group 模式是本地部署的,运行 schedule 应该是可以的,但是你没有权限,可以单独开一个决策卡」 — ' + + 'ruled A′ on #18378. The 2026-09-08 ruling was made for the MULTI-TENANT shape, and ' + + '`group` is not one: ADR-0105 D1 defines it as one legal group over one database with ' + + 'group-wide visibility and cross-org workflow INHERENT to the shape, so a group-level ' + + 'batch job is a capability of the posture rather than the cross-organization task the ' + + 'ruling forbids. What was genuinely unanswered — recorded as unanswered by ruling G item ' + + '3 — was which organization such a run\'s inserts belong to, and the answer is the one ' + + '`sys_automation_run` was already ruled to use: the SUBJECT RECORD\'s organization, with ' + + 'the acting context as the fallback and never the primary. Filling the acting context ' + + 'the same way makes the inbox, delivery and history rows of one run agree about its ' + + 'owner; leaving them to disagree was the defect, not the fix. ⛔ The rejected arm is ' + + 'recorded too, because it is the one a later reader will re-propose: falling back to the ' + + "bootstrap organization (`slug='default'`) for a record-less run. Under a wall that " + + 'organization is minted ADMIN-KEYED by the enterprise organizations runtime and may not ' + + 'exist at all, and where it does it is whichever organization the platform owner ' + + 'registered under — plausibly one plant of many. That is the silently-authoritative ' + + 'wrong owner this entry already forbids, so a record-less undeclared run is refused ' + + 'instead. Where the switch is on, the 2026-09-08 ruling therefore stands unchanged under ' + + '`isolated`, is satisfied per-record under `group`, and is moot under `single`, which ' + + 'holds exactly one organization and therefore has no cross-organization task to forbid. ' + + '⛔ NOT losslessly convertible, and the reason is ' + 'that both remedies are values only the deployment holds: an organization id is minted ' + 'per install at runtime and the switch is an operator decision about cost, so there is ' + 'no authored artifact and no stored representation a transform could rewrite — ' @@ -10501,8 +10530,17 @@ const step18: MigrationStep = { + '`[schedule] NOT BOUND` / `[time-relative] NOT BOUND`, because nothing was refused for ' + 'a declaration. A deployment that sets it to `true` under posture `single` confirms ' + 'that its time-triggered flows are armed while declaring no `config.organization`, and ' - + 'that the runs they launch carry none. A deployment that sets it to `true` under a ' - + 'walled posture (`group` / `isolated`) confirms that every `schedule` / `time_relative` ' + + 'that the runs they launch carry none. A deployment that sets it to `true` under posture ' + + '`group` confirms the shape it wants PER FLOW: for a flow it left undeclared, that boot ' + + 'logs the bind line naming per-record ownership, that a sweep tick launches runs stamped ' + + "with each swept record's own organization (NOT one organization for the batch), and " + + 'that any record-less cron among them either declares `config.organization` or is ' + + 'accepted to fail loudly at its first tenant-scoped write; for a flow it declared, the ' + + '`isolated` criteria below apply unchanged. ⚠️ The discriminating observation for the ' + + 'undeclared case is the SET of organizations across the runs one tick launched — a pin ' + + 'that reads only "a run was stamped" passes on the defect too, which stamped them all ' + + 'alike. A deployment that sets it to `true` under posture `isolated` ' + + 'confirms that every `schedule` / `time_relative` ' + 'flow in the stack declares a non-empty `config.organization` on its start node, that ' + 'boot logs no `NOT BOUND` line, that `getFlowRuntimeStates()` reports `bound: true` and ' + 'that `getTriggerBindingAudit()` lists no time-triggered flow — and, where it ran ONE ' @@ -10514,8 +10552,9 @@ const step18: MigrationStep = { + 'that was skipped. ⚠️ `@objectstack/driver-memory` has NO legal configuration for a ' + 'time-triggered flow that touches per-organization data under a wall: it refuses any ' + 'call handed a tenant scope (`MEMORY_MULTI_TENANT_UNSUPPORTED`), so a declared flow is ' - + 'refused per call while an undeclared one is not armed at all. Multi-organization ' - + 'deployments use `@objectstack/driver-sql`.', + + 'refused per call, an undeclared `isolated` one is not armed at all, and an undeclared ' + + '`group` one arms and sweeps unscoped but is refused at the first write it derives an ' + + 'organization for. Multi-organization deployments use `@objectstack/driver-sql`.', }, { id: 'scim-provider-object-retired', diff --git a/packages/triggers/trigger-schedule/package.json b/packages/triggers/trigger-schedule/package.json index fbab456258a..ea06b61e243 100644 --- a/packages/triggers/trigger-schedule/package.json +++ b/packages/triggers/trigger-schedule/package.json @@ -2,7 +2,7 @@ "name": "@objectstack/trigger-schedule", "version": "17.4.0", "license": "Apache-2.0", - "description": "Schedule flow trigger for ObjectStack — auto-launches flows on a cron/interval/once schedule via the IJobService (ADR-0018)", + "description": "Schedule flow trigger for ObjectStack \u2014 auto-launches flows on a cron/interval/once schedule via the IJobService (ADR-0018)", "main": "dist/index.js", "types": "dist/index.d.ts", "exports": { @@ -19,6 +19,7 @@ }, "dependencies": { "@objectstack/core": "workspace:*", + "@objectstack/metadata-core": "workspace:*", "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*", "croner": "^10.0.1" diff --git a/packages/triggers/trigger-schedule/src/schedule-trigger.test.ts b/packages/triggers/trigger-schedule/src/schedule-trigger.test.ts index c7253f8288d..af7373481e0 100644 --- a/packages/triggers/trigger-schedule/src/schedule-trigger.test.ts +++ b/packages/triggers/trigger-schedule/src/schedule-trigger.test.ts @@ -632,13 +632,10 @@ describe('ScheduleTrigger — switched ON under `single` (#17396)', () => { }); }); -describe('ScheduleTrigger — switched ON under a wall (#17396)', () => { - withScheduledWorkOn('group'); +describe('ScheduleTrigger — switched ON under `isolated` (#17396)', () => { + withScheduledWorkOn('isolated'); - it('`group` is walled: an undeclared flow is refused there too', () => { - // Ruled explicitly — 「group 默认也关」 for the default, and `group` - // behaves as walled while the question of which organization a - // group-wide sweep's inserts belong to is unanswered. + it('an undeclared flow is refused', () => { const job = fakeJobService(); const trigger = new ScheduleTrigger(() => job.service, silentLogger()); expect(() => @@ -654,3 +651,84 @@ describe('ScheduleTrigger — switched ON under a wall (#17396)', () => { expect(job.jobs.size).toBe(1); }); }); + +/** + * [#18378, ruling A′] ⚠️ RETIRED PIN, replaced rather than deleted. + * + * This block used to be `ScheduleTrigger — switched ON under a wall` with + * `withScheduledWorkOn('group')` and a case named "`group` is walled: an + * undeclared flow is refused there too". Its reason was explicit and is quoted + * here so the reversal is legible rather than looking like an erosion: `group` + * behaved as walled *while the question of which organization a group-wide + * sweep's inserts belong to was unanswered*. That question is answered now — + * the swept record's own — so the condition the old pin rested on is gone. + * + * The `isolated` half above is that pin, kept whole: nothing about `isolated` + * was reopened, and the refusal it asserts is byte-identical. + */ +describe('ScheduleTrigger — switched ON under `group` (#18378)', () => { + withScheduledWorkOn('group'); + + it('an undeclared flow BINDS — it is not refused, and the job is armed', async () => { + const job = fakeJobService(); + const trigger = new ScheduleTrigger(() => job.service, silentLogger()); + expect(() => + trigger.start(binding({ organization: undefined, config: {} }), async () => {}), + ).not.toThrow(); + await flush(); + expect(job.jobs.size).toBe(1); + }); + + it('…and its run carries NO organization — a cron flow has no record to derive one from', async () => { + // The half that keeps A′ apart from the rejected option A. `group` + // binding without a declaration does NOT mean the run acquires an + // organization from somewhere: a plain `schedule` flow sweeps nothing, + // so there is nothing to derive, and the key is OMITTED. The write that + // needs one is refused downstream by the tenancy guard, which is the + // loud failure A′ chose over a bootstrap-organization fallback. + const job = fakeJobService(); + const trigger = new ScheduleTrigger(() => job.service, silentLogger()); + const seen: AutomationContext[] = []; + + trigger.start(binding({ organization: undefined, config: {} }), async (ctx) => void seen.push(ctx)); + await flush(); + await job.fire('flow-schedule:nightly_health_sweep'); + + expect(seen).toHaveLength(1); + // Same `in` spelling as the `single` pin above, and for the same + // reason: a present-but-undefined key is a different thing to every + // consumer that asks `in`. + expect('tenantId' in seen[0], 'no tenantId key at all').toBe(false); + }); + + it('⛔ and it still never invents one — no bootstrap organization, no first row', async () => { + // The rejected arm of the card, pinned NEGATIVELY so a later edit that + // "helpfully" adds a fallback fails here by name. + const job = fakeJobService(); + const trigger = new ScheduleTrigger(() => job.service, silentLogger()); + const seen: AutomationContext[] = []; + const orgLessBinding = binding({ organization: undefined, config: {} }); + + expect(resolveBindingOrganization(orgLessBinding)).toBeNull(); + trigger.start(orgLessBinding, async (ctx) => void seen.push(ctx)); + await flush(); + await job.fire('flow-schedule:nightly_health_sweep'); + + expect(seen[0]?.tenantId).toBeUndefined(); + }); + + it('a DECLARED flow under `group` still acts as its declaration', async () => { + // `group` removes the REQUIREMENT, not the capability — the same + // sentence the `single` block records, and the reason the declaration + // outranks per-record ownership everywhere below. + const job = fakeJobService(); + const trigger = new ScheduleTrigger(() => job.service, silentLogger()); + const seen: AutomationContext[] = []; + + trigger.start(binding(), async (ctx) => void seen.push(ctx)); + await flush(); + await job.fire('flow-schedule:nightly_health_sweep'); + + expect(seen[0]?.tenantId).toBe('org_2mtx1w9d0k4bqf7v'); + }); +}); diff --git a/packages/triggers/trigger-schedule/src/schedule-trigger.ts b/packages/triggers/trigger-schedule/src/schedule-trigger.ts index dbb994717c3..2a51686da04 100644 --- a/packages/triggers/trigger-schedule/src/schedule-trigger.ts +++ b/packages/triggers/trigger-schedule/src/schedule-trigger.ts @@ -341,13 +341,22 @@ export function refuseScheduledWorkDisabled( * Refuse to bind a time-triggered flow that declares no acting organization * (#16659): say why at `error`, then THROW so the engine records the refusal. * - * ## When this fires, after #17396 - * - * ⚠️ Under a WALLED posture (`group` / `isolated`) with scheduled work switched - * on, and nowhere else. The 2026-09-08 ruling this implements is unchanged - * where it applies — a flow declares its organization or it is not armed, no - * fan-out, no organization is ever chosen for it — but it applies to the - * postures that have a wall to be crossed. On a `single` deployment with the + * ## When this fires, after #17396 and #18378 + * + * ⚠️ Under posture `isolated` with scheduled work switched on, and nowhere + * else. The 2026-09-08 ruling this implements is unchanged where it applies — a + * flow declares its organization or it is not armed, no fan-out, no + * organization is ever chosen for it — but [#18378, ruling A′] narrowed WHERE + * it applies from "any walled posture" to `isolated` alone. ⛔ `group` is not a + * near-miss of `isolated`: it enforces a wall AND reads group-wide, so an + * undeclared flow there binds and each run it launches acts as its own swept + * record's organization; a record-less one carries nothing and is refused at + * its first tenant-scoped write instead, loudly and by name. The single + * predicate is {@link ScheduledWorkPolicy.requiresActingOrganization}, which + * this function's caller gates on — ⛔ never `postureEnforcesWall`, which + * answers `true` for `group` and would re-arm this refusal there. + * + * On a `single` deployment with the * switch on there is exactly one organization — plugin-auth's ORG-CREATE * POSTURE GATE refuses a second: `auth-manager.ts`'s `beforeCreateOrganization` * answers 403 "Creating additional organizations is disabled on this @@ -427,6 +436,39 @@ export function refuseMissingOrganization( throw new Error(sentence); } +/** + * [#18378] The clause a BIND line carries about which organization this flow's + * runs will act as — one vocabulary, so the two triggers cannot describe the + * same deployment differently. + * + * "Which rows can this flow ever see, and who will own what it writes" is + * answerable from the boot log rather than from the metadata, and after ruling + * A′ that is three distinct answers rather than two. The third — an undeclared + * flow under `group` with nothing to derive from — is the one that earns a + * WARNING rather than a fact: it is legal and armed, and it will nonetheless be + * refused at its first tenant-scoped write. That refusal is loud and correct, + * but it arrives at the first tick; boot is where an operator is reading, so it + * is said here too, with the remedy. + * + * ⛔ `hasRecord` is a fact about the TRIGGER KIND, not about a tick: a + * `time_relative` sweep always has a swept record to derive from by + * construction, and a plain `schedule` flow never does. It is not "did this + * tick match anything". + */ +export function describeScheduleRunOwnership( + policy: ScheduledWorkPolicy, + organization: string | null, + opts: { readonly hasRecord: boolean }, +): string { + if (organization !== null) return ` as organization '${organization}'`; + if (policy.runOwnership === 'per-record') { + return opts.hasRecord + ? ` with per-record acting organization (tenancy posture '${policy.posture}') — the sweep reads group-wide and each run acts as its own swept record's organization` + : ` with NO acting organization (tenancy posture '${policy.posture}') — this flow sweeps no records, so there is nothing to derive one from, and any tenant-scoped row it writes (a notification, an inbox message, its own run history) will be REFUSED at the write. Declare \`organization\` on the start node's config if this flow writes per-organization data`; + } + return ` with NO acting organization (tenancy posture '${policy.posture}') — the run carries none and the deployment's single organization is resolved beneath each write`; +} + /** * Report a scheduled flow that failed to bind to the job service. * @@ -630,12 +672,25 @@ export class ScheduleTrigger implements FlowTrigger { // service is missing", which is a different defect with a different // remedy. // - // [#17396] …and only where the wall makes it answerable. Under + // [#17396] …and only where the posture makes it answerable. Under // `single` the run carries NO organization and the #8844 guard resolves // the deployment's one organization beneath it, so a missing key is not // a defect there — `policy.requiresActingOrganization` is the whole of // that distinction and it is resolved once, centrally, so this trigger, // the sweep trigger and the engine's audit cannot disagree about it. + // + // [#18378] …nor under `group`, and for a different reason worth keeping + // apart from `single`'s. There the key is OPTIONAL, not moot: a + // declared flow acts as its declaration exactly as under `isolated`, + // while an undeclared one is a legal armed shape whose ownership + // follows the record. A PLAIN `schedule` flow has no record, so an + // undeclared one here carries nothing and is refused at its first + // tenant-scoped write — loudly, by the tenancy guard, with the remedy. + // ⛔ That is deliberately NOT converted into a bind refusal: a cron flow + // that only reads, or writes only objects declaring + // `tenancy: { enabled: false }`, has no write to be refused and must + // still run. Refusing it at bind would be ruling G again under a new + // name, which is the thing A′ reopened. The bind line says so instead. const organization = resolveBindingOrganization(binding); if (policy.requiresActingOrganization && organization === null) { // Drop any prior binding for this flow FIRST. A hot re-publish that @@ -749,7 +804,23 @@ export class ScheduleTrigger implements FlowTrigger { `[schedule] bound flow '${binding.flowName}' → ${schedule.type}` + (schedule.expression ? ` '${schedule.expression}'` : '') + (schedule.intervalMs ? ` every ${schedule.intervalMs}ms` : '') + - (schedule.at ? ` at ${schedule.at}` : ''), + (schedule.at ? ` at ${schedule.at}` : '') + + // [#18378] Which organization this flow's runs act as, + // on the BIND line. A plain `schedule` flow has no + // swept record, so `per-record` ownership has nothing + // to derive from and the run carries none — which under + // `group` is a legal, armed shape whose first + // tenant-scoped write is nonetheless refused + // (`walled-posture`). That refusal is correct and + // loud, but it arrives at the first TICK, which may be + // hours away and unattended; boot is where the operator + // is actually reading, so the warning is owed here as + // well. ⛔ Not a reason to refuse the bind: a cron flow + // that only reads, or only writes objects that declare + // `tenancy: { enabled: false }`, is legitimate and must + // still run — which is the whole of what ruling A′ + // reopened. + describeScheduleRunOwnership(policy, organization, { hasRecord: false }), ); }) .catch((err) => { diff --git a/packages/triggers/trigger-schedule/src/time-relative-trigger.test.ts b/packages/triggers/trigger-schedule/src/time-relative-trigger.test.ts index 81055f76a17..712fb53fcf1 100644 --- a/packages/triggers/trigger-schedule/src/time-relative-trigger.test.ts +++ b/packages/triggers/trigger-schedule/src/time-relative-trigger.test.ts @@ -1249,3 +1249,289 @@ describe('TimeRelativeTrigger — switched ON under `single` (#17396)', () => { expect(warns.filter((w) => w.includes('does NOT narrow this sweep'))).toHaveLength(0); }); }); + +/** + * [#18378, ruling A′] Per-record acting organizations under `group`. + * + * ## The discriminating number, stated before the cases + * + * ⚠️ THE ASSERTION THAT DISCRIMINATES IS THE **SET** OF ORGANIZATIONS ACROSS THE + * RUNS ONE TICK LAUNCHED — never "a run was stamped". The defect this replaces + * stamped every run in the batch ALIKE (with the declaration, or with nothing), + * so a pin reading only "the run carries an organization" passes on the old + * behaviour too. The fixture therefore puts matching rows in TWO organizations + * and reads the set as `['org_plant_a', 'org_plant_b']`, which is a value no + * previous behaviour could produce. + * + * ## Why the double carries `getSchema` + * + * `createRecordWallOrganizationResolver` resolves the organization COLUMN from + * the object's registered schema — the WALL precedence (`tenancy.enabled: + * false` ⇒ nothing, then a declared `tenancy.tenantField`, then the kernel's + * `organization_id`; ⛔ the stamp key `tenancy.organizationField` is NOT a limb + * of it) — and answers `null` without one, so + * a double lacking `getSchema` would make every case below pass vacuously with + * no organization resolved, which is the shape of the defect rather than the + * fix. `TimeRelativeDataEngine` is a TYPE-level narrowing; the runtime object a + * host mounts is the real engine, which has `getSchema`. The degradation case + * is pinned separately, last. + */ +describe('TimeRelativeTrigger — switched ON under `group` (#18378)', () => { + withScheduledWorkOn('group'); + + const DESC = { object: 'contracts', dateField: 'end_date', withinDays: 60 }; + const orgLess = () => binding(DESC, { organization: undefined, config: { timeRelative: DESC } }); + + const PLANT_A = 'org_plant_a'; + const PLANT_B = 'org_plant_b'; + + /** Two plants' contracts, both inside the window. The group-wide dataset. */ + const twoPlants = (): Row[] => [ + { id: 'a1', end_date: '2026-07-25T00:00:00.000Z', organization_id: PLANT_A }, + { id: 'b1', end_date: '2026-07-26T00:00:00.000Z', organization_id: PLANT_B }, + ]; + + /** + * The unscoped double plus the `getSchema` the record resolver reads. The + * schema declares no `tenancy` block, so the resolver takes its DEFAULT + * limb (`organization_id`) — the ordinary case for an app object. + */ + function groupDataEngine(rows: Row[]) { + const base = fakeDataEngine(rows); + const engine = { + ...base.engine, + getSchema(name: string) { + return name === 'contracts' + ? { name, fields: { id: {}, end_date: {}, organization_id: {} } } + : undefined; + }, + } as TimeRelativeDataEngine; + return { engine, calls: base.calls }; + } + + it('arms an undeclared sweep — it is not refused', () => { + const job = fakeJobService(); + const trigger = new TimeRelativeTrigger( + () => job.service, + () => groupDataEngine([]).engine, + silentLogger(), + NOW, + ); + expect(() => trigger.start(orgLess(), async () => {})).not.toThrow(); + expect(job.jobs.size).toBe(1); + }); + + it("the sweep's query stays group-wide — no scope key, so every plant's rows match", async () => { + // ADR-0105 D1 makes group-wide READ reach inherent to the posture; A′ + // changes ownership of the WRITES, not the reach of the reads. + const job = fakeJobService(); + const data = groupDataEngine(twoPlants()); + const trigger = new TimeRelativeTrigger(() => job.service, () => data.engine, silentLogger(), NOW); + + trigger.start(orgLess(), async () => {}); + await flush(); + await job.fire('flow-time-relative:renewal_alert'); + + expect(data.calls.length, 'control: the sweep really did query').toBeGreaterThan(0); + for (const call of data.calls) { + expect(call.context?.isSystem, 'the sweep still runs elevated').toBe(true); + expect('tenantId' in (call.context ?? {}), 'no scope key on the find context').toBe(false); + } + }); + + it('⇒ each launched run acts as ITS OWN swept record’s organization', async () => { + const job = fakeJobService(); + const data = groupDataEngine(twoPlants()); + const trigger = new TimeRelativeTrigger(() => job.service, () => data.engine, silentLogger(), NOW); + const seen: AutomationContext[] = []; + + trigger.start(orgLess(), async (ctx) => void seen.push(ctx)); + await flush(); + await job.fire('flow-time-relative:renewal_alert'); + + expect(seen.length, 'both plants’ rows matched — the sweep is group-wide').toBe(2); + // ⚠️ THE DISCRIMINATING ASSERTION. Two DIFFERENT organizations from one + // tick is a value neither the old `group` behaviour (refused at bind, + // zero runs) nor the `single` behaviour (two runs, both org-less) nor a + // declaration (two runs, both the declared org) can produce. + expect(seen.map((c) => c.tenantId).sort()).toEqual([PLANT_A, PLANT_B]); + // …and each run is stamped with the organization of the row it is ABOUT, + // not merely with "some organization" — the pairing is the point. + expect( + seen.map((c) => [(c.record as { id?: unknown }).id, c.tenantId]).sort(), + ).toEqual([ + ['a1', PLANT_A], + ['b1', PLANT_B], + ]); + }); + + it('a DECLARED organization still outranks the record — declaring narrows, it does not widen', async () => { + // A declaration bounds SELECTION as well as identity, so honouring the + // record over it would silently widen a flow the author scoped — the + // #16659 defect. Declaration wins, and the sweep sees one plant only. + const job = fakeJobService(); + const base = tenantScopedDataEngine(twoPlants()); + const engine = { + ...base.engine, + getSchema: (name: string) => + name === 'contracts' ? { name, fields: { id: {}, end_date: {}, organization_id: {} } } : undefined, + } as TimeRelativeDataEngine; + const trigger = new TimeRelativeTrigger(() => job.service, () => engine, silentLogger(), NOW); + const seen: AutomationContext[] = []; + + trigger.start(binding(DESC, { organization: PLANT_A, config: { timeRelative: DESC } }), async (ctx) => + void seen.push(ctx), + ); + await flush(); + await job.fire('flow-time-relative:renewal_alert'); + + expect(seen.map((c) => (c.record as { id?: unknown }).id)).toEqual(['a1']); + expect(seen[0].tenantId).toBe(PLANT_A); + }); + + it('⛔ a row carrying NO organization stamps nothing — it does not fall back', async () => { + // The rejected arm of the card, pinned negatively. A row with no owner + // yields a run with no `tenantId` key, and the tenancy guard refuses + // its writes downstream; ⛔ it must NOT acquire the bootstrap + // organization, the first `sys_organization` row, or a sibling row's. + const job = fakeJobService(); + const data = groupDataEngine([ + { id: 'orphan', end_date: '2026-07-25T00:00:00.000Z' }, + { id: 'b1', end_date: '2026-07-26T00:00:00.000Z', organization_id: PLANT_B }, + ]); + const trigger = new TimeRelativeTrigger(() => job.service, () => data.engine, silentLogger(), NOW); + const seen: AutomationContext[] = []; + + trigger.start(orgLess(), async (ctx) => void seen.push(ctx)); + await flush(); + await job.fire('flow-time-relative:renewal_alert'); + + const orphan = seen.find((c) => (c.record as { id?: unknown }).id === 'orphan'); + const owned = seen.find((c) => (c.record as { id?: unknown }).id === 'b1'); + expect('tenantId' in (orphan ?? {}), 'the orphan run carries no organization key').toBe(false); + // Live control: the SAME tick stamped the owned row, so the negative + // above is evidence about the orphan and not about an inert harness. + expect(owned?.tenantId).toBe(PLANT_B); + }); + + it('an object whose tenancy is disabled resolves nothing — the column, not the value, decides', async () => { + // `tenancy: { enabled: false }` (ADR-0066) means the object is + // platform-global and has no organization of its own, even if a stray + // column is present on the row. The shared resolver owns that + // precedence; this pin is here so a future local column read fails. + const job = fakeJobService(); + const base = fakeDataEngine([ + { id: 'g1', end_date: '2026-07-25T00:00:00.000Z', organization_id: PLANT_A }, + ]); + const engine = { + ...base.engine, + getSchema: (name: string) => + name === 'contracts' + ? { name, tenancy: { enabled: false }, fields: { id: {}, end_date: {}, organization_id: {} } } + : undefined, + } as TimeRelativeDataEngine; + const trigger = new TimeRelativeTrigger(() => job.service, () => engine, silentLogger(), NOW); + const seen: AutomationContext[] = []; + + trigger.start(orgLess(), async (ctx) => void seen.push(ctx)); + await flush(); + await job.fire('flow-time-relative:renewal_alert'); + + expect(seen).toHaveLength(1); + expect('tenantId' in seen[0]).toBe(false); + }); + + it('a declared `tenancy.organizationField` is NOT an acting identity — the stamp key is not read here', async () => { + // ⭐ The discriminating pin for the wall/stamp split (#18378 round 2). + // This is `sys_api_key`'s shape, the ONE shipped object that declares + // the key: `tenancy: { enabled: false, organizationField: '…' }`, the + // column present on the schema AND carrying a value on the row. + // + // The STAMP resolver answers `active_organization_id` here by design — + // limb 0 wins over the ADR-0066 opt-out, which is exactly what the audit + // trail wants ("who is this row ABOUT"). A sweep asking "who does this + // run ACT AS" must not get that answer: the declaration says the object + // is deliberately unwalled (#8287), so acting as the organization it + // names would be an identity derived from an annotation that never meant + // one. Resolving NOTHING sends the run into the `walled-posture` refusal + // at its first tenant-scoped write, which is the honest outcome. + // + // ⛔ This pin fails the moment someone points the sweep back at + // `createRecordOrganizationResolver` — which is the whole point of it. + const job = fakeJobService(); + const base = fakeDataEngine([ + { + id: 'k1', + end_date: '2026-07-25T00:00:00.000Z', + active_organization_id: PLANT_A, + }, + ]); + const engine = { + ...base.engine, + getSchema: (name: string) => + name === 'contracts' + ? { + name, + tenancy: { enabled: false, organizationField: 'active_organization_id' }, + fields: { id: {}, end_date: {}, active_organization_id: {} }, + } + : undefined, + } as TimeRelativeDataEngine; + const trigger = new TimeRelativeTrigger(() => job.service, () => engine, silentLogger(), NOW); + const seen: AutomationContext[] = []; + + trigger.start(orgLess(), async (ctx) => void seen.push(ctx)); + await flush(); + await job.fire('flow-time-relative:renewal_alert'); + + expect(seen).toHaveLength(1); + expect('tenantId' in seen[0], 'the stamp key must not become an acting organization').toBe(false); + }); + + it('says on the BIND line that ownership is per-record, and names the posture', async () => { + const job = fakeJobService(); + const infos: string[] = []; + const trigger = new TimeRelativeTrigger( + () => job.service, + () => groupDataEngine([]).engine, + { info: (m: string) => void infos.push(String(m)), warn: () => {}, debug: () => {} }, + NOW, + ); + + trigger.start(orgLess(), async () => {}); + await flush(); + + const bindLine = infos.find((l) => l.includes('bound flow')); + expect(bindLine).toBeDefined(); + expect(bindLine).toContain('per-record acting organization'); + expect(bindLine).toContain("posture 'group'"); + }); + + it('warns ONCE when the engine exposes no `getSchema` — the misleading-diagnosis seam', async () => { + // Without `getSchema` nothing resolves, every run carries no + // organization and every tenant-scoped write is refused with a message + // about the WRITE — which sends the operator to the flow when the cause + // is the composition. The degradation is functional (the writes are + // still refused loudly, nothing is silently lost), so `warn` is the + // level AGENTS.md prescribes. + const job = fakeJobService(); + const warns: string[] = []; + const data = fakeDataEngine(twoPlants()); // ⛔ deliberately NO getSchema + const trigger = new TimeRelativeTrigger( + () => job.service, + () => data.engine, + { info: () => {}, warn: (m: string) => void warns.push(String(m)), debug: () => {} }, + NOW, + ); + const seen: AutomationContext[] = []; + + trigger.start(orgLess(), async (ctx) => void seen.push(ctx)); + await flush(); + await job.fire('flow-time-relative:renewal_alert'); + + const hits = warns.filter((w) => w.includes('no `getSchema`')); + expect(hits.length, 'said once per engine, not once per record').toBe(1); + expect(hits[0]).toContain('will be refused'); + for (const ctx of seen) expect('tenantId' in ctx).toBe(false); + }); +}); diff --git a/packages/triggers/trigger-schedule/src/time-relative-trigger.ts b/packages/triggers/trigger-schedule/src/time-relative-trigger.ts index 13f9c49a66d..a16f71c4348 100644 --- a/packages/triggers/trigger-schedule/src/time-relative-trigger.ts +++ b/packages/triggers/trigger-schedule/src/time-relative-trigger.ts @@ -10,11 +10,22 @@ import type { TimeRelativeTrigger as TimeRelativeDescriptor } from '@objectstack import { normalizeSchedule, reportBindFailure, + describeScheduleRunOwnership, refuseMissingOrganization, refuseScheduledWorkDisabled, resolveBindingOrganization, } from './schedule-trigger.js'; import { resolveScheduledWorkPolicy } from '@objectstack/types'; +import type { ScheduledRunOwnership } from '@objectstack/types'; +// [#18378] The ONE resolver for "which organization does this record BELONG +// to" — the WALL question (`tenancy.enabled: false` ⇒ nothing, then a declared +// `tenancy.tenantField`, then the kernel's `organization_id`), shared with the +// platform's own wall reading rather than re-spelled here. ⛔ Never a local +// column read, and ⛔ never the STAMP question: see `organizationOfRecord`. +import { + createRecordWallOrganizationResolver, + type RecordOrganizationResolver, +} from '@objectstack/metadata-core'; import type { FlowTrigger, FlowTriggerBinding, JobServiceSurface, TriggerLogger } from './schedule-trigger.js'; /** @@ -269,6 +280,12 @@ export class TimeRelativeTrigger implements FlowTrigger { private readonly localClaims = new Map(); /** Whether the in-process-only dedup degradation has been said (once). */ private claimDegradationWarned = false; + /** + * [#18378] The record→organization resolver — the WALL face — paired with + * the engine it was built over so a kernel rebuild cannot be answered from + * the previous kernel's object registry. See {@link organizationOfRecord}. + */ + private recordOrgResolver: { engine: unknown; resolver: RecordOrganizationResolver } | null = null; constructor( getJobService: () => JobServiceSurface | null, @@ -415,7 +432,14 @@ export class TimeRelativeTrigger implements FlowTrigger { const handler: JobHandler = async () => { try { - await this.sweep(binding.flowName, desc, maxRecords, organization, callback); + await this.sweep( + binding.flowName, + desc, + maxRecords, + organization, + policy.runOwnership, + callback, + ); } catch (err) { // Error isolation: a sweep failure must not crash the job // runner / ticker. Log and swallow. @@ -459,9 +483,13 @@ export class TimeRelativeTrigger implements FlowTrigger { // and an operator reading the boot log is owed the // difference between "sees one organization's rows" and // "sees every row this install holds". - (organization !== null - ? ` as organization '${organization}'` - : ` with NO acting organization (tenancy posture '${policy.posture}') — the sweep is unscoped and its runs carry no organization`), + // [#18378] Three answers now, not two — the third is an + // undeclared sweep under `group`, which reads + // group-wide and stamps each run from its own record. + // One shared vocabulary so this trigger and the plain + // schedule trigger cannot describe one deployment + // differently. + describeScheduleRunOwnership(policy, organization, { hasRecord: true }), ); }) .catch((err) => { @@ -497,10 +525,26 @@ export class TimeRelativeTrigger implements FlowTrigger { * posture gate refuses one), so an * unscoped sweep is not the cross-organization task the ruling forbids * — it is the shape a single-organization install had before #16659. - * Under a wall `start()` still refuses an undeclared binding, and with - * the switch off nothing binds, so `null` cannot arrive from either. + * Under `isolated` `start()` still refuses an undeclared binding, and + * with the switch off nothing binds, so `null` cannot arrive from + * either. + * + * [#18378] `null` is reachable from a THIRD gate now — the switch on + * under `group` — and that one is not unscoped. See `ownership`. */ organization: string | null, + /** + * [#18378] What a run launched from a matched row acts as when the flow + * declared nothing. Resolved once per bind from the deployment + * ({@link ScheduledWorkPolicy.runOwnership}) so this trigger, the plain + * schedule trigger and the engine's audit cannot disagree. + * + * Only `'per-record'` changes behaviour here, and only while + * `organization === null`: an explicit declaration outranks it, because + * a declaration bounds SELECTION as well as identity and silently + * widening a flow the author scoped would be the #16659 defect again. + */ + ownership: ScheduledRunOwnership, callback: (ctx: AutomationContext) => Promise, ): Promise { const engine = this.getDataEngine(); @@ -602,22 +646,47 @@ export class TimeRelativeTrigger implements FlowTrigger { deduped++; continue; } + // [#18378] The acting organization for THIS record's run. + // + // Order is declaration → record → nothing, and it is the order + // `ObjectStoreSuspendedRunStore` already resolves `sys_automation_run` + // with (`organizationOf(record) ?? ctx.tenantId`). Before this card + // the two disagreed under `group`: the history row was stamped from + // the record while the inbox and delivery rows followed an acting + // context that could not exist there, so they were refused and the + // tick still summarised itself as healthy. + // + // ⛔ NOT a hand-rolled `record.organization_id` read. The column is + // whatever the OBJECT is WALLED by (`tenancy.enabled: false` ⇒ none, + // then a declared `tenancy.tenantField`, then the kernel's + // `organization_id`), a platform-global object has none at all, and a + // second implementation of that precedence living in a trigger is + // exactly the drift `createRecordWallOrganizationResolver` exists to + // end. + // + // ⛔ And NOT the STAMP question either. `tenancy.organizationField` + // answers "who is this row ABOUT" for the three sanctioned + // platform-row writers; it is declared on exactly one shipped object + // (`sys_api_key`, deliberately unwalled, #8287), and reading it here + // would turn "the audit trail should follow this row's organization + // even though nothing walls it" into an ACTING IDENTITY. A sweep over + // such an object resolves NOTHING and takes the `walled-posture` + // refusal at its first tenant-scoped write, which is the honest + // answer. + const runOrganization = + organization ?? + (ownership === 'per-record' + ? this.organizationOfRecord(engine, desc.object, record) + : null); try { const ctx: AutomationContext = { record, object: desc.object, event: 'time_relative', - // [#16659] The declared acting organization — the same key - // a record-change run inherits from its triggering session, + // [#16659] The acting organization — the same key a + // record-change run inherits from its triggering session, // and the one `notify-node.ts` and the run-history writer - // already read. ⛔ Never derived from the swept RECORD's - // own `organization_id`: the sweep runs elevated and can - // match rows in any tenant, so keying on the row would let - // one flow write into organizations it never declared — - // the cross-organization scheduled task the ruling forbids. - // That prohibition is untouched by #17396: the `single` - // branch below omits the key, it does not fill it from the - // row. + // already read. // // [#17396] ⚠️ RETIRED PIN, with its reason — the same one // `ScheduleTrigger`'s handler records. The unconditional @@ -625,8 +694,26 @@ export class TimeRelativeTrigger implements FlowTrigger { // correct while every time-triggered run owed a // declaration. Under ruling G the absent key is a declared // deployment state (`single` + the switch on), not a - // forgotten one, and it is unreachable from any other gate. - ...(organization !== null ? { tenantId: organization } : {}), + // forgotten one. + // + // [#18378] ⚠️ SECOND RETIRED PIN, and this one names a + // prohibition rather than a spelling. This site carried + // "⛔ Never derived from the swept RECORD's own + // `organization_id`", whose reason was that the sweep runs + // elevated and can match rows in any tenant, so keying on + // the row would let one flow write into organizations it + // never declared. That reason is POSTURE-SPECIFIC and was + // written before `group` was distinguished: + // - under `isolated` it stands unchanged, and the gate + // above means an undeclared flow never reaches here; + // - under `single` there is no second organization to + // cross to, and the key is still omitted — ⛔ the + // `'unscoped'` arm does NOT fill from the row; + // - under `group` "organizations it never declared" is + // not a boundary violation but the posture's own read + // reach (ADR-0105 D1), and the row is the only honest + // owner available. Retired for THAT posture alone. + ...(runOrganization !== null ? { tenantId: runOrganization } : {}), // Expose the record as params too, so flows with named `isInput` // variables matching record fields get them seeded (parity with // the record-change trigger). @@ -647,10 +734,73 @@ export class TimeRelativeTrigger implements FlowTrigger { } this.logger.debug?.( - `[time-relative] flow '${flowName}' swept '${desc.object}' as organization '${organization}': ${matched.length} matched, ${launched} launched, ${deduped} already dispatched, ${failed} failed`, + `[time-relative] flow '${flowName}' swept '${desc.object}' ` + + (organization !== null + ? `as organization '${organization}'` + : ownership === 'per-record' + ? 'with per-record acting organization' + : 'with no acting organization') + + `: ${matched.length} matched, ${launched} launched, ${deduped} already dispatched, ${failed} failed`, ); } + /** + * [#18378] The swept record's own organization — the WALL question, through + * the shared resolver (`@objectstack/metadata-core`) rather than a column + * read of this trigger's own. + * + * ⛔ The WALL face, never the stamp one. "Which organization does this row + * belong to" is what an acting identity may be derived from; + * `tenancy.organizationField` answers a different question ("who is this row + * about") for three named platform-row writers, and this sweep is not one of + * them. On the one shipped object that declares it — `sys_api_key`, unwalled + * by design (#8287) — the wall face answers `null`, and that refusal is the + * correct outcome rather than a gap. + * + * The resolver memoizes the column per object internally; this memoizes the + * RESOLVER per engine, because a kernel rebuild hands back a different + * engine whose object schemas may differ, and a resolver outliving its + * engine would answer from the previous kernel's registry. Identity compare + * rather than a cache key: the engine object IS the identity. + * + * `null` — no organization column on this object, no value on this row, or + * an engine double without `getSchema` — is a legitimate answer and NOT an + * error: the caller omits `tenantId`, and the write that needs one is + * refused by the tenancy guard with its own remedy. ⛔ Do not substitute a + * fallback here; that is the arm ruling A′ rejected. + */ + private organizationOfRecord(engine: unknown, objectName: string, record: unknown): string | null { + if (this.recordOrgResolver?.engine !== engine) { + // [#18378] The ONE genuinely invisible way per-record ownership can + // fail, said once. `TimeRelativeDataEngine` is a TYPE-level + // narrowing — `TimeRelativeTriggerPlugin` resolves the real + // `objectql` service and merely types it as this interface, so the + // runtime object carries `getSchema` — but a host that mounted a + // genuine adapter object instead would hand us one that does not. + // The resolver would then answer `null` for every record, every run + // would carry no organization, and every tenant-scoped write would + // be refused with a message about the WRITE. That reads as "this + // flow is broken" and sends the operator to the flow; the cause is + // the composition. ⛔ Not an `error` and not a throw: the writes + // that matter are still refused loudly by the tenancy guard, so + // nothing is silently lost — this is a functional degradation whose + // only defect is a misleading diagnosis, which AGENTS.md puts at + // `warn`. + if (typeof (engine as { getSchema?: unknown } | null)?.getSchema !== 'function') { + this.logger.warn( + `[time-relative] the data engine exposes no \`getSchema\` — per-record acting organizations cannot be resolved, so every run this sweep launches will carry none and each tenant-scoped write it makes will be refused. ` + + `Mount the ObjectQL engine itself (service 'objectql' or 'data'), or declare \`organization\` on the flow's start node to bind the sweep to one organization instead.`, + ); + } + this.recordOrgResolver = { engine, resolver: createRecordWallOrganizationResolver(engine) }; + } + // Read through a local: a mutable class property does not stay narrowed + // across the assignment above, and `!` would assert away the one thing + // worth keeping honest here. + const cached = this.recordOrgResolver; + return cached ? cached.resolver.organizationOf(objectName, record) : null; + } + /** * Claim one dispatch key (#10220): `true` = launch, `false` = an earlier * sweep already dispatched this (flow, record, window). diff --git a/packages/triggers/trigger-schedule/tsconfig.json b/packages/triggers/trigger-schedule/tsconfig.json index caff4761a5c..0358b6c3f64 100644 --- a/packages/triggers/trigger-schedule/tsconfig.json +++ b/packages/triggers/trigger-schedule/tsconfig.json @@ -2,10 +2,45 @@ "extends": "../../../tsconfig.json", "compilerOptions": { "outDir": "./dist", - "rootDir": "./src", + // [#18378] Widened from `./src` as a CONSEQUENCE of the `paths` rule + // below — the same move `packages/plugins/plugin-approvals` records for + // #10101 and `packages/rest` for #9960, and for the same mechanical + // reason: redirecting `@objectstack/metadata-core` to source puts + // `packages/metadata-core/src/**` into this program, and `rootDir` is + // enforced over every program file even under `--noEmit` (TS6059). + // `../..` is the directory that genuinely contains every file in the + // program. Emit is unaffected: this package builds with tsup and + // `typecheck` passes `--noEmit`. + // + // ⚠️ MEASURED before taking this route, because it is not always + // available: `tsc --noEmit` here reports **0** TS6059 with both lines in + // place. That measurement is the precondition the gate's failure text + // names — where it does NOT hold (`service-automation` measured 647 + // TS6059 for its own case) `paths` is ruled out and the dependency has to + // be reached another way. + "rootDir": "../..", "types": [ "node" - ] + ], + // [#18378] Resolve the shared record→organization resolver to SOURCE for + // `tsc --noEmit`, so this package's typecheck is a verdict about the + // checkout rather than about `metadata-core/dist` build state + // (`pnpm check:type-source-resolution`). The time-relative sweep reads + // per-record acting organizations through its WALL face, and the precedence + // that implements (`tenancy.enabled: false` ⇒ nothing, then a declared + // `tenancy.tenantField`, then the kernel's `organization_id`) is exactly + // what the `group` pins hold. + // + // ⛔ NOT an entry in that gate's registry. Widening it is maintainer-only: + // this package's `@objectstack/types` row was admitted by maintainer + // ruling on #17396 and its docblock says in as many words that the row is + // a signature and not a door. `paths` is the self-serve route, and the + // measurement above is what makes it available. + "paths": { + "@objectstack/metadata-core": [ + "../../metadata-core/src/index.ts" + ] + } }, "include": [ "src/**/*" diff --git a/packages/triggers/trigger-schedule/vitest.config.ts b/packages/triggers/trigger-schedule/vitest.config.ts index 6782ab14446..e7eede8223a 100644 --- a/packages/triggers/trigger-schedule/vitest.config.ts +++ b/packages/triggers/trigger-schedule/vitest.config.ts @@ -36,6 +36,23 @@ export default defineConfig({ find: /^@objectstack\/types$/, replacement: path.resolve(__dirname, '../../types/src/index.ts'), }, + { + // [#18378] Same rule, same reason, for the record→organization + // resolver the time-relative sweep reads per-record ownership through. + // Unaliased this resolved `metadata-core/dist`, so the `group` pins — + // which assert that each run is stamped from its OWN swept record — + // were a verdict about a built artifact rather than about + // `resolveRecordWallOrganizationField`'s precedence as it stands in this + // checkout. That precedence (`tenancy.enabled: false` ⇒ nothing, then a + // declared `tenancy.tenantField`, then the kernel's `organization_id` — + // ⛔ the stamp key is NOT a limb of it) is exactly what those pins exist + // to hold, so reading it from `dist` is the passing-test failure + // `check:test-source-alias` was built to catch. + // + // ANCHORED regex for the reason the entry above states at length. + find: /^@objectstack\/metadata-core$/, + replacement: path.resolve(__dirname, '../../metadata-core/src/index.ts'), + }, ], }, }); diff --git a/packages/types/src/env.test.ts b/packages/types/src/env.test.ts index 43b42773215..4b34e8eb09d 100644 --- a/packages/types/src/env.test.ts +++ b/packages/types/src/env.test.ts @@ -1,6 +1,9 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { afterEach, describe, expect, it, vi } from 'vitest'; +// [#18378] The live control below asserts the two axes are independent, which +// needs the wall predicate beside the policy. +import { postureEnforcesWall } from '@objectstack/spec/security'; import { _resetEnvDeprecationWarnings, collectConfiguredLocales, @@ -505,7 +508,7 @@ describe('resolveScheduledWorkEnabled (#17396, ruling G items 1-2)', () => { * and the packaged-job loop cannot disagree about which of the three a * deployment is in; this is the table its docblock states. */ -describe('resolveScheduledWorkPolicy (#17396, ruling G — the three bind states)', () => { +describe('resolveScheduledWorkPolicy (#17396 ruling G, #18378 ruling A′ — the four bind states)', () => { const originalSwitch = process.env[SCHEDULED_WORK_ENV]; const originalPosture = process.env.OS_TENANCY_POSTURE; const originalMultiOrg = process.env.OS_MULTI_ORG_ENABLED; @@ -531,17 +534,26 @@ describe('resolveScheduledWorkPolicy (#17396, ruling G — the three bind states enabled: false, posture: 'single', requiresActingOrganization: false, + runOwnership: 'unscoped', }); }); it('row 1 holds under a WALL too — the OFF reason is the one to report, not an authoring remedy', () => { clean(); - for (const posture of ['group', 'isolated'] as const) { + // ⚠️ `runOwnership` still reports the posture's rule while the switch is + // OFF — it is a fact about the posture, not about the switch — but nothing + // binds, so no run can reach it. `enabled` is the discriminator, and the + // pin asserts both rather than letting the pair drift. + for (const [posture, runOwnership] of [ + ['group', 'per-record'], + ['isolated', 'declared'], + ] as const) { process.env.OS_TENANCY_POSTURE = posture; expect(resolveScheduledWorkPolicy()).toEqual({ enabled: false, posture, requiresActingOrganization: false, + runOwnership, }); } }); @@ -554,20 +566,52 @@ describe('resolveScheduledWorkPolicy (#17396, ruling G — the three bind states enabled: true, posture: 'single', requiresActingOrganization: false, + runOwnership: 'unscoped', }); }); - it('row 3 — ON under a wall (`group` and `isolated` alike): the declaration is required', () => { + // [#18378, ruling A′] The row that used to pair `group` with `isolated`. + // + // ⚠️ THE DISCRIMINATING ASSERTION IS THAT THE TWO POSTURES DISAGREE. A pin + // that looped over both and expected one shape is exactly what this card + // retired, so these are deliberately two cases with two different + // expectations rather than one parameterised case — a future edit that + // re-merges them has to delete an assertion to do it. + it('row 3 — ON under `group`: binds WITHOUT a declaration, owning its writes per record', () => { clean(); process.env[SCHEDULED_WORK_ENV] = 'true'; - for (const posture of ['group', 'isolated'] as const) { - process.env.OS_TENANCY_POSTURE = posture; - expect(resolveScheduledWorkPolicy()).toEqual({ - enabled: true, - posture, - requiresActingOrganization: true, - }); - } + process.env.OS_TENANCY_POSTURE = 'group'; + expect(resolveScheduledWorkPolicy()).toEqual({ + enabled: true, + posture: 'group', + requiresActingOrganization: false, + runOwnership: 'per-record', + }); + }); + + it('row 4 — ON under `isolated`: the declaration is required, unchanged', () => { + clean(); + process.env[SCHEDULED_WORK_ENV] = 'true'; + process.env.OS_TENANCY_POSTURE = 'isolated'; + expect(resolveScheduledWorkPolicy()).toEqual({ + enabled: true, + posture: 'isolated', + requiresActingOrganization: true, + runOwnership: 'declared', + }); + }); + + it('`group` enforces a wall and STILL does not require the declaration — the two axes are independent', () => { + // The live control for the finding that motivated A′: the separating + // predicate is read reach (`postureUsesUnionScope`), not the wall + // (`postureEnforcesWall`), and `group` answers true to BOTH. A resolver + // that regressed to `enabled && postureEnforcesWall(posture)` passes every + // other case in this block and fails only here. + clean(); + process.env[SCHEDULED_WORK_ENV] = 'true'; + process.env.OS_TENANCY_POSTURE = 'group'; + expect(postureEnforcesWall('group')).toBe(true); + expect(resolveScheduledWorkPolicy().requiresActingOrganization).toBe(false); }); it('reports the REQUESTED posture, derived from the legacy boolean when unset', () => { @@ -578,6 +622,7 @@ describe('resolveScheduledWorkPolicy (#17396, ruling G — the three bind states enabled: true, posture: 'isolated', requiresActingOrganization: true, + runOwnership: 'declared', }); }); diff --git a/packages/types/src/env.ts b/packages/types/src/env.ts index 7fc343fc7a7..32e3403b5ea 100644 --- a/packages/types/src/env.ts +++ b/packages/types/src/env.ts @@ -20,6 +20,7 @@ import { normalizeTenancyPosture, postureEnforcesWall, + postureUsesUnionScope, TENANCY_POSTURES, type TenancyPosture, } from '@objectstack/spec/security'; @@ -210,12 +211,19 @@ export const SCHEDULED_WORK_ENV = 'OS_AUTOMATION_SCHEDULED_WORK_ENABLED'; * * Unset means off. A deployment that wants package-authored scheduled work * turns it on explicitly — including a `single` private install and a `group` - * one. `group` is not free today and is off for a measured reason rather than - * by analogy: it is a WALLED posture, so `resolveSystemWriteOrganization` - * refuses an organization-less system insert under it and - * `TenancyService.defaultOrgId()` answers `null` (ADR-0093 D3). Which - * organization a group-wide sweep's inserts belong to is not yet decided, and - * until it is, `group` behaves as walled. + * one. ⚠️ The switch is orthogonal to the posture and stays OFF by default in + * all three: whether clock-driven work is affordable is a fact about the + * deployment's database, tenants and budget, which no posture answers. + * + * [#18378] What the posture decides is what binds ONCE THE OPERATOR HAS TURNED + * IT ON — see {@link resolveScheduledWorkPolicy}. `group` used to be walled + * here by analogy with `isolated`, on the ground that + * `resolveSystemWriteOrganization` refuses an organization-less system insert + * under any wall and `TenancyService.defaultOrgId()` answers `null` (ADR-0093 + * D3), leaving which organization a group-wide sweep's inserts belong to + * undecided. That question is answered (ruling A′, 2026-09-16): the swept + * record's own. Both facts above still hold — they are why an undeclared + * `group` run with NO record to derive from is still refused at the write. * * Accepts `true`/`1`/`on`/`yes`, case-insensitive; anything else — including an * unset variable and an empty string — is off. ⚠️ Deliberately NOT the @@ -239,11 +247,22 @@ export function resolveScheduledWorkEnabled(): boolean { * One resolver rather than two reads at each call site, because the three * states are not independent and spelling them apart is how they drift: * - * | state | `enabled` | `requiresActingOrganization` | what binds | - * |:--|:--|:--|:--| - * | OFF (default) | `false` | `false` | nothing — no time trigger arms, no package job schedules | - * | ON under `single` | `true` | `false` | every time-triggered flow, carrying NO organization | - * | ON under a wall (`group` / `isolated`) | `true` | `true` | only a flow that declares `config.organization` | + * | state | `enabled` | `requiresActingOrganization` | `runOwnership` | what binds | + * |:--|:--|:--|:--|:--| + * | OFF (default) | `false` | `false` | the posture's rule (moot) | nothing — no time trigger arms, no package job schedules | + * | ON under `single` | `true` | `false` | `'unscoped'` | every time-triggered flow, carrying NO organization | + * | ON under `group` | `true` | `false` | `'per-record'` | every time-triggered flow; a declared one acts as its declaration, an undeclared one acts as each swept record's own organization | + * | ON under `isolated` | `true` | `true` | `'declared'` | only a flow that declares `config.organization` | + * + * [#18378, ruling A′] The `group` row was `requiresActingOrganization: true` + * until 2026-09-16 — it was walled by analogy with `isolated`, recorded as + * provisional at the time because which organization a group-wide run's inserts + * belong to was the part that was not yet thought through. It is answered now: + * the swept record's own, which is the subject-first order `sys_automation_run` + * was already ruled to use. ⛔ Do not re-derive this row from + * `postureEnforcesWall` — `group` DOES enforce a wall, and that is precisely + * why its reads span the group and its writes still need an owner. The + * predicate that separates it is {@link postureUsesUnionScope}. * * `requiresActingOrganization` is `false` when the switch is OFF because * nothing binds there at all: reporting a declaration requirement for a flow @@ -252,6 +271,17 @@ export function resolveScheduledWorkEnabled(): boolean { * {@link SCHEDULED_WORK_DISABLED_REASON} — and it is the one that must be * reported. * + * ⚠️ `runOwnership` is NOT gated on the switch the way that boolean is, and the + * OFF row above says "the posture's rule" rather than a value for exactly that + * reason: it answers a question about the POSTURE — where a bound run's writes + * would get their organization — so with the switch off it still reports + * `'per-record'` under `group` and `'declared'` under `isolated`, not + * `'unscoped'`. Nothing binds there, so no run can reach the state it names: + * ⛔ never read `runOwnership` alone as evidence that a run exists or that one + * is going to; {@link ScheduledWorkPolicy.enabled} is the discriminator, and + * the OFF reason above is what an operator gets told. Both halves are pinned in + * `env.test.ts`. + * * ⚠️ `posture` is what the deployment ASKED FOR, exactly as * {@link resolveTenancyPosture} answers it — whether the wall is actually * ENFORCED is the `tenancy` service's answer. That is the right authority here: @@ -263,6 +293,40 @@ export function resolveScheduledWorkEnabled(): boolean { * unrecognized `OS_TENANCY_POSTURE` — a typo'd posture must not silently * resolve to `single` and drop the declaration requirement with it. */ +/** + * [#18378] Where a BOUND time-triggered run's writes get their organization, + * once the flow's own declaration has been consulted and found absent. + * + * It is a separate axis from {@link ScheduledWorkPolicy.requiresActingOrganization} + * because the two answer different doors: that boolean decides whether BIND + * refuses, this decides what a run that DID bind carries. Collapsing them is + * what made `group` walled by analogy in the first place — the posture has a + * wall (so an org-less insert is refused) AND group-wide reads (so the batch is + * legitimate), and only a second axis can say both. + */ +export type ScheduledRunOwnership = + /** + * `single`: the run carries no organization at all. The install holds exactly + * one (plugin-auth's org-create posture gate refuses a second) and the #8844 + * guard resolves it beneath every tenant-scoped insert. + */ + | 'unscoped' + /** + * `group`: the run acts as the SWEPT RECORD's own organization. Reads stay + * group-wide — inherent to the posture (ADR-0105 D1) — and ownership follows + * the row, which is the order `ObjectStoreSuspendedRunStore` already uses for + * `sys_automation_run` (`organizationOf(record) ?? ctx.tenantId`, subject + * first). A run with no record to derive from resolves nothing and takes the + * existing `walled-posture` refusal at its first tenant-scoped write; ⛔ there + * is no limb that picks one instead. + */ + | 'per-record' + /** + * `isolated`: the declaration, or the flow does not arm. The 2026-09-08 + * ruling on cross-organization scheduled tasks, unchanged. + */ + | 'declared'; + export interface ScheduledWorkPolicy { /** Whether package-authored scheduled work runs on this deployment at all. */ readonly enabled: boolean; @@ -270,20 +334,47 @@ export interface ScheduledWorkPolicy { readonly posture: TenancyPosture; /** * Whether an armed time-triggered flow must declare `config.organization`. - * True only under a walled posture with the switch on — the 2026-09-08 - * ruling on cross-organization scheduled tasks, unchanged. + * True only under `isolated` with the switch on — the 2026-09-08 ruling on + * cross-organization scheduled tasks, narrowed to that posture by #18378. */ readonly requiresActingOrganization: boolean; + /** + * What an armed run that declared nothing acts as. ⚠️ A fact about + * {@link posture}, NOT about the switch: it reports that posture's rule + * (`group` ⇒ `'per-record'`, `isolated` ⇒ `'declared'`) whether or not + * {@link enabled}. With the switch off nothing binds, so no run can reach the + * state this names — ⛔ read it together with {@link enabled}, never alone as + * evidence that a run exists. + */ + readonly runOwnership: ScheduledRunOwnership; +} + +/** + * Which ownership rule a posture implies, independent of the switch. + * + * ⛔ Deliberately NOT `postureEnforcesWall ? 'declared' : 'unscoped'`. Both + * walled postures enforce a wall; what separates them is READ REACH, and + * {@link postureUsesUnionScope} is the protocol's existing name for exactly + * that distinction (`group` only). A posture whose reads already span every + * organization in the deployment is one where a batch job is a capability + * rather than a boundary violation — so it is the one posture that can own its + * writes per-row instead of demanding a declaration up front. + */ +function scheduledRunOwnershipFor(posture: TenancyPosture): ScheduledRunOwnership { + if (!postureEnforcesWall(posture)) return 'unscoped'; + return postureUsesUnionScope(posture) ? 'per-record' : 'declared'; } /** Resolve {@link ScheduledWorkPolicy} from the environment. */ export function resolveScheduledWorkPolicy(): ScheduledWorkPolicy { const enabled = resolveScheduledWorkEnabled(); const posture = resolveTenancyPosture(); + const runOwnership = scheduledRunOwnershipFor(posture); return { enabled, posture, - requiresActingOrganization: enabled && postureEnforcesWall(posture), + requiresActingOrganization: enabled && runOwnership === 'declared', + runOwnership, }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 756b81444e6..b05528233df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2940,6 +2940,9 @@ importers: '@objectstack/core': specifier: workspace:* version: link:../../core + '@objectstack/metadata-core': + specifier: workspace:* + version: link:../../metadata-core '@objectstack/spec': specifier: workspace:* version: link:../../spec