From c31e062872a80abe8147eaa080c0f382d50330b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 09:51:57 +0000 Subject: [PATCH 01/19] feat(spec,types)!: `group` scheduled work binds without a declaration, owning its writes per record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `group` was walled by analogy with `isolated`: with the deployment switch on, every time-triggered flow had to declare `config.organization` or it did not arm. The recorded reason was that which organization a group-wide run's inserts belong to had not been thought through. It is answered now — the swept record's own, which is the subject-first order `ObjectStoreSuspendedRunStore` was already ruled to use for `sys_automation_run` (`organizationOf(record) ?? ctx.tenantId`). Before this change 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. - `ScheduledWorkPolicy` gains `runOwnership: 'unscoped' | 'per-record' | 'declared'`, a second axis from `requiresActingOrganization`: that boolean decides whether BIND refuses, this decides what a run that DID bind carries. Collapsing them is what made `group` walled by analogy. - The separating predicate is `postureUsesUnionScope`, not `postureEnforcesWall` — `group` does enforce a wall, which is exactly why its reads span the group and its writes still need an owner. - `requiresActingOrganization` narrows to `isolated` only. The rejected arm is recorded in the ADR-0087 entry because it is the one a later reader will re-propose: falling back 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; where it does, it is whichever organization the platform owner registered under — plausibly one plant of many. A record-less undeclared run is refused at its first tenant-scoped write instead. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude --- .../automation/schedule-organization.zod.ts | 117 ++++++++++++------ ...edule-flow-acting-organization-required.ts | 71 ++++++++--- packages/types/src/env.ts | 105 +++++++++++++--- 3 files changed, 224 insertions(+), 69 deletions(-) diff --git a/packages/spec/src/automation/schedule-organization.zod.ts b/packages/spec/src/automation/schedule-organization.zod.ts index b26d5eca813..013f7b44590 100644 --- a/packages/spec/src/automation/schedule-organization.zod.ts +++ b/packages/spec/src/automation/schedule-organization.zod.ts @@ -28,37 +28,71 @@ 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. + * + * ⛔ 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 +153,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 swept record own organization. Not required under single, where the run carries no organization.', ); /** @@ -232,15 +266,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/types/src/env.ts b/packages/types/src/env.ts index d9ff5a554ae..df458ef7b13 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` | `'unscoped'` (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 @@ -263,6 +282,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 +323,44 @@ 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. ⚠️ Meaningful only while + * {@link enabled}; when the switch is off nothing binds, so this reports + * `'unscoped'` rather than a state no run can reach. + */ + 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, }; } From 18b47969ec499a3d711567f00380c20eddb2ebd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 09:56:57 +0000 Subject: [PATCH 02/19] feat(triggers)!: `group` sweeps stamp each run from its own swept record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The binding half of ruling A'. With the deployment switch on: - `isolated` — unchanged: an undeclared flow is refused at bind. - `group` — an undeclared flow now ARMS. A `time_relative` sweep reads group-wide (inherent to the posture, ADR-0105 D1) and stamps each run it launches with that record's own organization, resolved through the shared `createRecordOrganizationResolver` rather than a local `organization_id` read: the column is whatever the object declares, and a second implementation of that precedence living in a trigger is the drift the shared resolver exists to end. - A plain `schedule` (cron) flow has no record, so an undeclared one under `group` carries nothing and is refused at its first tenant-scoped write. Deliberately NOT 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. The "never filled from the swept row" pin is retired for `group` ALONE, and the comment records why per posture: under `isolated` it stands; under `single` the key is still omitted, never filled from the row; under `group` "organizations it never declared" is the posture's own read reach, not a boundary violation. Both triggers now share one bind-line vocabulary (`describeScheduleRunOwnership`) so they cannot describe one deployment differently. The undeclared-cron-under-`group` case warns at BOOT as well as at the write: the refusal is correct but arrives at the first tick, which may be hours away and unattended. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude --- .../triggers/trigger-schedule/package.json | 3 +- .../trigger-schedule/src/schedule-trigger.ts | 66 +++++++- .../src/time-relative-trigger.ts | 145 +++++++++++++++--- pnpm-lock.yaml | 3 + 4 files changed, 195 insertions(+), 22 deletions(-) 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.ts b/packages/triggers/trigger-schedule/src/schedule-trigger.ts index dbb994717c3..b1272396e97 100644 --- a/packages/triggers/trigger-schedule/src/schedule-trigger.ts +++ b/packages/triggers/trigger-schedule/src/schedule-trigger.ts @@ -427,6 +427,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 +663,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 +795,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.ts b/packages/triggers/trigger-schedule/src/time-relative-trigger.ts index 13f9c49a66d..bceb4e81c68 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 same precedence (`tenancy.organizationField`, then +// `tenancy.tenantField`, then the default column) that every sanctioned +// platform-row writer already shares. ⛔ Never a local column read: see +// `organizationOfRecord`. +import { + createRecordOrganizationResolver, + 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, 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,37 @@ 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 declares (`tenancy.organizationField`, then + // `tenancy.tenantField`, then the default), a platform-global object + // has none at all, and a second implementation of that precedence + // living in a trigger is exactly the drift `createRecordOrganizationResolver` + // exists to end — it is the shared resolver all three sanctioned + // platform-row writers already hold. + 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 +684,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 +724,40 @@ 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, through the ONE shared + * resolver (`@objectstack/metadata-core`) rather than a column read of this + * trigger's own. + * + * 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) { + this.recordOrgResolver = { engine, resolver: createRecordOrganizationResolver(engine) }; + } + return this.recordOrgResolver.resolver.organizationOf(objectName, record); + } + /** * Claim one dispatch key (#10220): `true` = launch, `false` = an earlier * sweep already dispatched this (flow, record, window). diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4ce2a1d81d..c2eecc4f477 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2942,6 +2942,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 From 244a77c157b0fd9db31408e3613bad08cb35d93d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 10:00:36 +0000 Subject: [PATCH 03/19] test(types,triggers): pin the four bind states and per-record ownership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `group` pins read the SET of organizations across the runs one tick launched, not "a run was stamped": the behaviour this replaces stamped every run in a batch alike, so a pin reading only "the run carries an organization" passes on it too. Two plants' rows in one tick yielding ['org_plant_a', 'org_plant_b'] is a value no previous behaviour could produce. Retired pins are replaced, not deleted, with the reason they rested on quoted at the replacement site: `ScheduleTrigger — switched ON under a wall` becomes two blocks, and the `isolated` half is the old pin kept whole. Also pinned: a declaration still outranks the record (declaring narrows, never widens); a row carrying no organization stamps nothing, with a live control proving the same tick stamped a sibling row; `tenancy: { enabled: false }` resolves nothing even with a stray column present; and the no-`getSchema` degradation warns once. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude --- .../src/schedule-trigger.test.ts | 90 ++++++- .../src/time-relative-trigger.test.ts | 237 ++++++++++++++++++ .../src/time-relative-trigger.ts | 21 ++ packages/types/src/env.test.ts | 67 ++++- 4 files changed, 398 insertions(+), 17 deletions(-) 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/time-relative-trigger.test.ts b/packages/triggers/trigger-schedule/src/time-relative-trigger.test.ts index 81055f76a17..775a0a8f595 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,240 @@ 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` + * + * `createRecordOrganizationResolver` resolves the organization COLUMN from the + * object's registered schema (`tenancy.organizationField`, then + * `tenancy.tenantField`, then the default) 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('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 bceb4e81c68..05e13c1fc23 100644 --- a/packages/triggers/trigger-schedule/src/time-relative-trigger.ts +++ b/packages/triggers/trigger-schedule/src/time-relative-trigger.ts @@ -753,6 +753,27 @@ export class TimeRelativeTrigger implements FlowTrigger { */ 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: createRecordOrganizationResolver(engine) }; } return this.recordOrgResolver.resolver.organizationOf(objectName, record); 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', }); }); From bc0bd77c66fb4c6b1aa672558c291572726aae26 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 10:06:05 +0000 Subject: [PATCH 04/19] docs,changeset: the `group` row in the acting-organization docs and the release note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three doc surfaces carried "under a walled tenancy posture (group/isolated)" as one rule; each now splits the two. The flows page gains the `group` callout and the record-less-cron warning, and states why a near-miss spelling is NOT reported there: an undeclared flow is a legal shape under `group`, so the trigger cannot tell "meant to declare, misspelled it" from "meant not to declare". The changeset declares `Clause-②: yes (widening)` rather than BREAKING: nothing that worked stops working and nothing admitted becomes refused — the accept set widens in one cell. It also records that the switch this depends on ships unreleased alongside the change, so the behaviour being amended has never appeared in a published version. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude --- ...oup-scheduled-work-per-record-ownership.md | 77 ++++++++++++++++++ content/docs/automation/flows.mdx | 78 ++++++++++++++----- content/docs/deployment/tenancy-modes.mdx | 2 +- 3 files changed, 136 insertions(+), 21 deletions(-) create mode 100644 .changeset/group-scheduled-work-per-record-ownership.md 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..ff37449e6eb --- /dev/null +++ b/.changeset/group-scheduled-work-per-record-ownership.md @@ -0,0 +1,77 @@ +--- +"@objectstack/types": minor +"@objectstack/spec": minor +"@objectstack/trigger-schedule": minor +--- + +feat(spec,types,triggers)!: `group` runs package-authored scheduled work without a declaration, owning each run's writes per record (#18378) + + + +`Clause-②: yes (widening)` + +**Nothing that worked stops working, and nothing that was admitted becomes +refused.** The accept set widens in one cell. + +## 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. + +⛔ 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. + +**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. +`@objectstack/trigger-schedule` exports `describeScheduleRunOwnership` so both +triggers describe one deployment identically. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index b3af4bf8451..2eca8310f1b 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -2071,10 +2071,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 }, @@ -2132,10 +2133,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: { @@ -2183,7 +2183,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; @@ -2193,15 +2219,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. @@ -2210,8 +2240,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/tenancy-modes.mdx b/content/docs/deployment/tenancy-modes.mdx index 1dc1de85aea..d6c4b7ef361 100644 --- a/content/docs/deployment/tenancy-modes.mdx +++ b/content/docs/deployment/tenancy-modes.mdx @@ -341,7 +341,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 From 2135026b1ec4fb726193c1af717947e038c69cfd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 10:07:48 +0000 Subject: [PATCH 05/19] fix(cli): os doctor names what each posture owes, not "walled (group/isolated)" The doctor's ON-state fix text told an operator that a time-triggered flow under group/isolated must declare config.organization. Under ruling A' that is true of `isolated` alone; `group` takes the declaration as optional, and the record-less cron case there has its own remedy worth naming at the one place an operator goes looking. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude --- packages/cli/src/commands/doctor.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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' From c16bd661f87a6d4567a0feba47148fac473bda38 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 10:08:34 +0000 Subject: [PATCH 06/19] docs(lint): the near-miss diagnostic's door is `isolated`, not "a walled posture" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retired-rule note explained where the bind-time near-miss scan still fires. That door narrowed with ruling A': under `group` an undeclared flow is a legal armed shape, so a near-miss spelling there cannot be told apart from a deliberate omission. Comment only — no rule, severity or finding changes. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude --- packages/lint/src/validate-flow-trigger-readiness.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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. From b496f65aaa0af6715e848b0db1bc6d8830ec8980 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 10:11:00 +0000 Subject: [PATCH 07/19] fix(changeset): the ADR-0087 marker is `already-registered `, not prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marker's grammar after the arm is a list of ENTRY IDS, not free text — only the `not-required` arm takes a reason clause. The prose moves into the changeset body where it belongs, and the arm corrects to `already-registered`: this amends the pre-existing `schedule-flow-acting-organization-required` entry rather than adding one, and `registered` would assert a registration this diff did not make. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude --- .../group-scheduled-work-per-record-ownership.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.changeset/group-scheduled-work-per-record-ownership.md b/.changeset/group-scheduled-work-per-record-ownership.md index ff37449e6eb..37f4bae3d93 100644 --- a/.changeset/group-scheduled-work-per-record-ownership.md +++ b/.changeset/group-scheduled-work-per-record-ownership.md @@ -6,10 +6,22 @@ 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 — `already-registered`, and why not `registered`.** This +amends the EXISTING semantic entry `schedule-flow-acting-organization-required` +(entry 18) rather than adding one: same authorable key, same deployment switch, +same surface, and the entry predates this diff at the merge base. Nothing is +renamed, retired or re-typed — the start node's `config` is an open record +(ADR-0018) and every flow that parses today parses byte-identically afterwards. +What moves is the BIND-time accept set (it WIDENS) and the RUN-time organization +such a flow's writes carry. The entry's `surface`, `replacement`, `reason` and +`acceptanceCriteria` each gained their `group` row, the rejected +bootstrap-organization arm included — recorded because it is the one a later +reader will re-propose. + **Nothing that worked stops working, and nothing that was admitted becomes refused.** The accept set widens in one cell. From 109d0dd3109ddba755e2ec1089e1fbd8ee0d0a30 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 10:11:26 +0000 Subject: [PATCH 08/19] fix(changeset): `already-registered` is a not-required CATEGORY, not a top-level arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parser accepts exactly two forms — `registered ` and `not-required ( [ids]) `. `already-registered` is one of the second form's categories, and it is the honest one here: the entry predates this diff at the merge base, so `registered` would claim a registration this PR did not make. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude --- ...oup-scheduled-work-per-record-ownership.md | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/.changeset/group-scheduled-work-per-record-ownership.md b/.changeset/group-scheduled-work-per-record-ownership.md index 37f4bae3d93..720dc13527d 100644 --- a/.changeset/group-scheduled-work-per-record-ownership.md +++ b/.changeset/group-scheduled-work-per-record-ownership.md @@ -6,21 +6,17 @@ 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 — `already-registered`, and why not `registered`.** This -amends the EXISTING semantic entry `schedule-flow-acting-organization-required` -(entry 18) rather than adding one: same authorable key, same deployment switch, -same surface, and the entry predates this diff at the merge base. Nothing is -renamed, retired or re-typed — the start node's `config` is an open record -(ADR-0018) and every flow that parses today parses byte-identically afterwards. -What moves is the BIND-time accept set (it WIDENS) and the RUN-time organization -such a flow's writes carry. The entry's `surface`, `replacement`, `reason` and -`acceptanceCriteria` each gained their `group` row, the rejected -bootstrap-organization arm included — recorded because it is the one a later -reader will re-propose. +**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. **Nothing that worked stops working, and nothing that was admitted becomes refused.** The accept set widens in one cell. From 661f85c3328bb245f7bb1b381e84e99957a6a4a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 10:11:55 +0000 Subject: [PATCH 09/19] =?UTF-8?q?docs(changeset):=20say=20what=20the=20`!`?= =?UTF-8?q?=20marks=20=E2=80=94=20behaviour,=20not=20a=20narrowing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The title carries a breaking marker while the body said the accept set widens; both are true and the body now says so together. Nothing admitted becomes refused, but on a `group` deployment with the switch already on, flows that were refused at bind now arm and run — clock-driven work appearing where an operator had none is what earns the banner, even though no consumer has to change anything. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude --- .changeset/group-scheduled-work-per-record-ownership.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.changeset/group-scheduled-work-per-record-ownership.md b/.changeset/group-scheduled-work-per-record-ownership.md index 720dc13527d..50cdda663aa 100644 --- a/.changeset/group-scheduled-work-per-record-ownership.md +++ b/.changeset/group-scheduled-work-per-record-ownership.md @@ -18,8 +18,12 @@ 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. -**Nothing that worked stops working, and nothing that was admitted becomes -refused.** The accept set widens in one cell. +**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 From e800c67d9347b1f0a085eb2f7a3d0cd64ba5d911 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 10:13:16 +0000 Subject: [PATCH 10/19] fix(triggers): read the memoized record-org resolver through a local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TS2531 at the DTS build: a mutable class property does not stay narrowed across the assignment that populates it, so the property read after the cache-fill was possibly null. Reads through a local instead of asserting with `!` — the null branch is the one thing worth keeping honest here, since it is what a host mounting a non-engine adapter actually hits. Caught by the DTS build, not by tests: vitest does not type-check. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude --- .../triggers/trigger-schedule/src/time-relative-trigger.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/triggers/trigger-schedule/src/time-relative-trigger.ts b/packages/triggers/trigger-schedule/src/time-relative-trigger.ts index 05e13c1fc23..0fe74a5eb2d 100644 --- a/packages/triggers/trigger-schedule/src/time-relative-trigger.ts +++ b/packages/triggers/trigger-schedule/src/time-relative-trigger.ts @@ -776,7 +776,11 @@ export class TimeRelativeTrigger implements FlowTrigger { } this.recordOrgResolver = { engine, resolver: createRecordOrganizationResolver(engine) }; } - return this.recordOrgResolver.resolver.organizationOf(objectName, record); + // 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; } /** From acf9c8d69b93d86e41c83638d4115487113179c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 10:14:53 +0000 Subject: [PATCH 11/19] docs: three more pages restating the retired "walled = must declare" rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs-drift check listed these; all three genuinely stated the pre-A' rule. - environment-variables.mdx — the OS_AUTOMATION_SCHEDULED_WORK_ENABLED row - production-readiness.mdx — the go-live checklist item - tenancy-modes.mdx — a `group · isolated` table row I missed when fixing the env table on the same page, plus the paragraph under it explaining why `group` was walled "until [the ownership question] is decided". That question is the one this change answers, so the paragraph now says what the answer is rather than that one is pending. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude --- .../docs/deployment/environment-variables.mdx | 2 +- .../docs/deployment/production-readiness.mdx | 8 ++++-- content/docs/deployment/tenancy-modes.mdx | 28 +++++++++++++++---- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index 286193e447d..1b70503bf8b 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 d6c4b7ef361..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 From fed6360427c2a857f8c743d9ff05bbb617756662 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 10:17:48 +0000 Subject: [PATCH 12/19] chore(spec): regenerate the migration registry and the reference page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are pure projections of edits already in this branch — the amended ADR-0087 entry 18 and the ScheduleOrganizationSchema `.describe()`. No hand edits. gen:docs refused the first attempt because the gitignored packages/spec/json-schema tree was older than src, and rendering reference pages from a stale tree would have described sources the run never read. Generated that tree first (gen:schema), then the docs. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude --- .../automation/schedule-organization.mdx | 94 +++++++++++++------ packages/spec/src/migrations/registry.ts | 71 ++++++++++---- 2 files changed, 119 insertions(+), 46 deletions(-) diff --git a/content/docs/references/automation/schedule-organization.mdx b/content/docs/references/automation/schedule-organization.mdx index 826e96f6bc3..8561695166d 100644 --- a/content/docs/references/automation/schedule-organization.mdx +++ b/content/docs/references/automation/schedule-organization.mdx @@ -30,37 +30,71 @@ 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. + +⛔ 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 +155,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 swept record own organization. Not required under single, where the run carries no organization. **Type:** `string` diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 76b9e2ad8ff..720b8e557a7 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -10297,10 +10297,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` ' @@ -10310,8 +10311,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 ' @@ -10319,8 +10320,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 ' @@ -10332,7 +10342,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 ' @@ -10345,9 +10355,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 — ' @@ -10369,8 +10398,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 ' @@ -10382,8 +10420,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', From 24279c847ec5c1dec1b247b4c486272de35e1c72 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 10:43:03 +0000 Subject: [PATCH 13/19] fix(triggers): alias @objectstack/metadata-core to source in the vitest config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:test-source-alias` caught the new dependency resolving through `dist/`. That made the `group` pins — which assert each run is stamped from its OWN swept record — a verdict about a built artifact rather than about `resolveRecordOrganizationField`'s precedence as it stands in this checkout, which is exactly what those pins exist to hold. The gate's own framing is why this matters: the dangerous case is a test that PASSES. Mine did, against dist. Re-run against source after aliasing: 106 pass. Anchored regex, array form, for the reason the sibling `@objectstack/types` entry states at length — a bare string `find` matches by prefix and would swallow subpaths into `…/src/index.ts/`. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude --- .../triggers/trigger-schedule/vitest.config.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/triggers/trigger-schedule/vitest.config.ts b/packages/triggers/trigger-schedule/vitest.config.ts index 6782ab14446..79ef9cfaa10 100644 --- a/packages/triggers/trigger-schedule/vitest.config.ts +++ b/packages/triggers/trigger-schedule/vitest.config.ts @@ -36,6 +36,22 @@ 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 + // `resolveRecordOrganizationField`'s precedence as it stands in this + // checkout. That precedence (`tenancy.organizationField`, then + // `tenancy.tenantField`, then the default column) 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'), + }, ], }, }); From e856c009262ef3e35850e12640846d3d1383b31b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 11:08:40 +0000 Subject: [PATCH 14/19] fix(triggers): resolve metadata-core types to source via tsconfig paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:type-source-resolution` — the type-side sibling of the vitest alias gate — caught the new dependency's TYPES resolving through `dist/`, making this package's typecheck a verdict about build state rather than about the checkout. Took the `paths` route, not the registry one. Widening that registry is maintainer-only: this package's `@objectstack/types` row was admitted by maintainer ruling on #17396, and the registry's own docblock says the row is a signature, not a door. `paths` requires widening `rootDir` from `./src` to `../..`, because redirecting the dep to source puts its files in this program and `rootDir` is enforced even under `--noEmit`. That route is not always available, so it was MEASURED first: 0 TS6059 with both lines in place (`service-automation` measured 647 for its own case and ruled `paths` out). Same move `plugin-approvals` records for this exact dependency and `rest` for #9960; emit is unaffected since this package builds with tsup and typechecks with --noEmit. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude --- .../triggers/trigger-schedule/tsconfig.json | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/triggers/trigger-schedule/tsconfig.json b/packages/triggers/trigger-schedule/tsconfig.json index caff4761a5c..8db5ef284e2 100644 --- a/packages/triggers/trigger-schedule/tsconfig.json +++ b/packages/triggers/trigger-schedule/tsconfig.json @@ -2,10 +2,44 @@ "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 it, and the precedence it + // implements (`tenancy.organizationField`, then `tenancy.tenantField`, + // then the default column) 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/**/*" From 5ddc99365d6602ea46439db854c718e64aff47ce Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 14:58:23 +0000 Subject: [PATCH 15/19] fix(changeset): `describeScheduleRunOwnership` is module-level, not a package export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The independent contract review on #18420 caught this and it is real: the changeset claimed `@objectstack/trigger-schedule` exports the helper. It does not — the package barrel does not re-export it, and the barrel's own note says an export whose only consumers live inside its own package belongs in a non-barrel module. So the code is right and the claim was wrong. This mattered beyond wording: that false export was cited as part of the `Clause-②: yes` basis. The declaration still stands, on the surface that IS public — `ScheduledRunOwnership` and the `runOwnership` key on `@objectstack/types` — and the changeset now says so. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude --- .../group-scheduled-work-per-record-ownership.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.changeset/group-scheduled-work-per-record-ownership.md b/.changeset/group-scheduled-work-per-record-ownership.md index 50cdda663aa..b426f76c058 100644 --- a/.changeset/group-scheduled-work-per-record-ownership.md +++ b/.changeset/group-scheduled-work-per-record-ownership.md @@ -85,5 +85,11 @@ acting organization"). Two things to check: '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. -`@objectstack/trigger-schedule` exports `describeScheduleRunOwnership` so both -triggers describe one deployment identically. +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, both on `@objectstack/types` — and those alone are what put +`Clause-②` at `yes`. From 0752431da60deb0ec97650515ac08550d967c03f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 16:14:49 +0000 Subject: [PATCH 16/19] docs(types): `runOwnership` reports the posture's rule, switch or no switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The independent contract review on #18420 caught a three-way mismatch and it is real. Two TSDoc claims on `ScheduledWorkPolicy` said that with the scheduled-work switch OFF, `runOwnership` reports `'unscoped'`: - the state table's OFF row printed `'unscoped'` (moot); - the `runOwnership` docblock said "when the switch is off nothing binds, so this reports `'unscoped'` rather than a state no run can reach". The code does no such thing. `resolveScheduledWorkPolicy` computes it as `scheduledRunOwnershipFor(posture)` — posture only, never gated on `enabled` — so OFF + `group` is `'per-record'` and OFF + `isolated` is `'declared'`. The pins in `env.test.ts` already assert exactly that, so the documentation was the only thing asserting the opposite. The CODE is the half that is right, and it is left alone. `runOwnership` answers a question about the posture (where a bound run's writes would get their organization), and `scheduledRunOwnershipFor`'s own docblock says so in as many words — "independent of the switch". Gating it on `enabled` would also make the value a function of two inputs while `requiresActingOrganization`, which IS the switch-gated half, already carries that job. Both consumers return early on `!policy.enabled` (schedule-trigger.ts:643, time-relative-trigger.ts:313) long before they read it, so nothing downstream depended on the documented reading. So: the OFF row now says "the posture's rule (moot)", the docblock says it is a fact about `posture` and not about the switch, and a new paragraph states the one thing a reader could otherwise get wrong — ⛔ `runOwnership` alone is never evidence that a run exists or will; `enabled` is the discriminator and the OFF reason is what an operator gets told. Doc-only: no behaviour change, `pnpm typecheck` clean, 52/52 in env.test.ts. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude --- packages/types/src/env.ts | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/types/src/env.ts b/packages/types/src/env.ts index df458ef7b13..91e4266ffc8 100644 --- a/packages/types/src/env.ts +++ b/packages/types/src/env.ts @@ -249,7 +249,7 @@ export function resolveScheduledWorkEnabled(): boolean { * * | state | `enabled` | `requiresActingOrganization` | `runOwnership` | what binds | * |:--|:--|:--|:--|:--| - * | OFF (default) | `false` | `false` | `'unscoped'` (moot) | nothing — no time trigger arms, no package job schedules | + * | 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` | @@ -271,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: @@ -328,9 +339,12 @@ export interface ScheduledWorkPolicy { */ readonly requiresActingOrganization: boolean; /** - * What an armed run that declared nothing acts as. ⚠️ Meaningful only while - * {@link enabled}; when the switch is off nothing binds, so this reports - * `'unscoped'` rather than a state no run can reach. + * 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; } From cda685b8efbdd81c62ff833def942055b2943953 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 07:26:57 +0000 Subject: [PATCH 17/19] fix(triggers,metadata-core): the sweep asks the WALL question, not the stamp one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The independent contract review's blocking finding was that `TimeRelativeTrigger` had become a FOURTH consumer of `tenancy.organizationField` — a key whose contract pins its consumers to three named platform-row writers and says a fourth needs its own maintainer ruling. The remedy put to the maintainer was a ruling or a redesign. This is the redesign, and it needs no ruling because it stops reading the key at all. The defect underneath the finding is that ONE resolver was answering TWO questions: - STAMP — "which column says who this row is ABOUT". Limb 0 (`tenancy.organizationField`) wins over everything, the ADR-0066 `tenancy.enabled: false` opt-out included, because an author declaring it on an unwalled table is saying the audit trail should follow the row's own organization even though nothing walls it. - WALL — "which column is this row SCOPED by", and therefore which organization work launched from that row may act as. They coincide on every ordinary object and come apart on exactly one shipped object: `sys_api_key`, `tenancy: { enabled: false, organizationField: 'active_organization_id' }`, unwalled by design (#8287 — walling the credential table on an equality that excludes NULL is the defect that card removed). A sweep over such an object was about to launch runs ACTING AS an organization derived from an annotation that never meant "act as this". So `@objectstack/metadata-core` grows a second face rather than a second copy: `resolveRecordWallOrganizationField` / `createRecordWallOrganizationResolver` are limbs 1-4 with limb 0 skipped, over the same implementation and the same memoization glue — a `readStampKey` parameter selects limb 0 alone, so the limbs the two faces share cannot drift apart. The stamp face keeps its name, its signature and its answers, limb 0 included; the three sanctioned writers are untouched. `TimeRelativeTrigger` binds the WALL face. Under `group` an undeclared sweep still stamps each run from its own swept record — that is ruling A′ and it is unchanged for every business object, because for them the wall column IS `organization_id` (or the declared `tenantField`). On an unwalled object the sweep now resolves NOTHING and the run takes the existing `walled-posture` refusal at its first tenant-scoped write, loudly and by name, instead of acquiring an identity from a stamp annotation. Pins: the wall face is pinned per limb against the stamp face wherever the two can diverge (the `sys_api_key` shape both ways, a declared stamp key on a WALLED object, and an agree-everywhere-else sweep over five shapes), and the trigger carries the end-to-end discriminator. Reverse-verified: pointing the trigger back at `createRecordOrganizationResolver` reddens that one pin and only it (1 failed / 137 passed), and the restore is byte-identical. ⛔ No cross-package parity pin against `objectql`'s `resolveTenantFieldName` — that package is registered in `check:test-source-alias` as still resolving metadata-core through `dist/`, so such a pin would be a verdict about build state. Converging the two spellings belongs to its own card; this change adds no third one. Measured: `pnpm typecheck` 143/143 tasks, `pnpm lint` clean, metadata-core 283/283, trigger-schedule 138/138, and the three stamp-key writers green (service-automation 1634, plugin-approvals 754, plugin-audit 346). Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude --- ...oup-scheduled-work-per-record-ownership.md | 30 ++++- .../src/record-organization.test.ts | 98 ++++++++++++++++ .../metadata-core/src/record-organization.ts | 108 +++++++++++++++++- .../src/time-relative-trigger.test.ts | 55 ++++++++- .../src/time-relative-trigger.ts | 56 ++++++--- .../triggers/trigger-schedule/tsconfig.json | 7 +- .../trigger-schedule/vitest.config.ts | 11 +- 7 files changed, 329 insertions(+), 36 deletions(-) diff --git a/.changeset/group-scheduled-work-per-record-ownership.md b/.changeset/group-scheduled-work-per-record-ownership.md index b426f76c058..99c7f5d1ea4 100644 --- a/.changeset/group-scheduled-work-per-record-ownership.md +++ b/.changeset/group-scheduled-work-per-record-ownership.md @@ -2,6 +2,7 @@ "@objectstack/types": minor "@objectstack/spec": minor "@objectstack/trigger-schedule": minor +"@objectstack/metadata-core": minor --- feat(spec,types,triggers)!: `group` runs package-authored scheduled work without a declaration, owning each run's writes per record (#18378) @@ -81,6 +82,27 @@ acting organization"). Two things to check: 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 @@ -91,5 +113,9 @@ deployment differently. ⚠️ That helper is module-level, NOT a package export 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, both on `@objectstack/types` — and those alone are what put -`Clause-②` at `yes`. +`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/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/triggers/trigger-schedule/src/time-relative-trigger.test.ts b/packages/triggers/trigger-schedule/src/time-relative-trigger.test.ts index 775a0a8f595..712fb53fcf1 100644 --- a/packages/triggers/trigger-schedule/src/time-relative-trigger.test.ts +++ b/packages/triggers/trigger-schedule/src/time-relative-trigger.test.ts @@ -1265,9 +1265,11 @@ describe('TimeRelativeTrigger — switched ON under `single` (#17396)', () => { * * ## Why the double carries `getSchema` * - * `createRecordOrganizationResolver` resolves the organization COLUMN from the - * object's registered schema (`tenancy.organizationField`, then - * `tenancy.tenantField`, then the default) and answers `null` without one — so + * `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 @@ -1439,6 +1441,53 @@ describe('TimeRelativeTrigger — switched ON under `group` (#18378)', () => { 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[] = []; diff --git a/packages/triggers/trigger-schedule/src/time-relative-trigger.ts b/packages/triggers/trigger-schedule/src/time-relative-trigger.ts index 0fe74a5eb2d..a16f71c4348 100644 --- a/packages/triggers/trigger-schedule/src/time-relative-trigger.ts +++ b/packages/triggers/trigger-schedule/src/time-relative-trigger.ts @@ -17,13 +17,13 @@ import { } 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 same precedence (`tenancy.organizationField`, then -// `tenancy.tenantField`, then the default column) that every sanctioned -// platform-row writer already shares. ⛔ Never a local column read: see -// `organizationOfRecord`. +// [#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 { - createRecordOrganizationResolver, + createRecordWallOrganizationResolver, type RecordOrganizationResolver, } from '@objectstack/metadata-core'; import type { FlowTrigger, FlowTriggerBinding, JobServiceSurface, TriggerLogger } from './schedule-trigger.js'; @@ -281,9 +281,9 @@ export class TimeRelativeTrigger implements FlowTrigger { /** Whether the in-process-only dedup degradation has been said (once). */ private claimDegradationWarned = false; /** - * [#18378] The record→organization resolver, 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}. + * [#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; @@ -657,12 +657,22 @@ export class TimeRelativeTrigger implements FlowTrigger { // tick still summarised itself as healthy. // // ⛔ NOT a hand-rolled `record.organization_id` read. The column is - // whatever the OBJECT declares (`tenancy.organizationField`, then - // `tenancy.tenantField`, then the default), a platform-global object - // has none at all, and a second implementation of that precedence - // living in a trigger is exactly the drift `createRecordOrganizationResolver` - // exists to end — it is the shared resolver all three sanctioned - // platform-row writers already hold. + // 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' @@ -735,9 +745,17 @@ export class TimeRelativeTrigger implements FlowTrigger { } /** - * [#18378] The swept record's own organization, through the ONE shared - * resolver (`@objectstack/metadata-core`) rather than a column read of this - * trigger's own. + * [#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 @@ -774,7 +792,7 @@ export class TimeRelativeTrigger implements FlowTrigger { `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: createRecordOrganizationResolver(engine) }; + 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 diff --git a/packages/triggers/trigger-schedule/tsconfig.json b/packages/triggers/trigger-schedule/tsconfig.json index 8db5ef284e2..0358b6c3f64 100644 --- a/packages/triggers/trigger-schedule/tsconfig.json +++ b/packages/triggers/trigger-schedule/tsconfig.json @@ -26,9 +26,10 @@ // `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 it, and the precedence it - // implements (`tenancy.organizationField`, then `tenancy.tenantField`, - // then the default column) is exactly what the `group` pins hold. + // 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 diff --git a/packages/triggers/trigger-schedule/vitest.config.ts b/packages/triggers/trigger-schedule/vitest.config.ts index 79ef9cfaa10..e7eede8223a 100644 --- a/packages/triggers/trigger-schedule/vitest.config.ts +++ b/packages/triggers/trigger-schedule/vitest.config.ts @@ -42,11 +42,12 @@ export default defineConfig({ // 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 - // `resolveRecordOrganizationField`'s precedence as it stands in this - // checkout. That precedence (`tenancy.organizationField`, then - // `tenancy.tenantField`, then the default column) 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. + // `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$/, From ef6f69b54fb1f778643d0aae9af9f2dfb0bdb3ad Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 07:30:33 +0000 Subject: [PATCH 18/19] fix(deps): port the devalue 5.9.2 pin so this PR stops inheriting main's red MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Validate Package Dependencies` went red on this PR's head, and the failure is not this PR's: step 13 (OSV-Scanner) flags `devalue@5.9.0` for GHSA-9rgm-9g3h-6x36 (5.3, fixed 5.9.2), a transitive package reached through `svelte`. This branch's only lockfile change is the `@objectstack/metadata-core` workspace link it added; `devalue` comes from the base. Established rather than assumed: PR #18942's body records `main` itself failing the same required check on scheduled run `35301766597` (branch `main`, sha `36583e98`), with the previous day's run green — so the advisory landed inside that window and every PR touching a manifest inherited the red. That PR is the fix, it is open, and waiting for it to merge is still waiting. So its change is PORTED here verbatim — the `devalue@<6.0.0` override in `pnpm-workspace.yaml` with its rationale, and the lockfile effect — after reading its diff rather than its description. Identical shape: 4 hunks, 9 changed lines, one package moved (5.9.0 → 5.9.2, the single resolved copy). It no-ops the moment `main` carries it. ⛔ No `osv-scanner.toml` exemption: the advisory names a fixed version, which is the one case that file's header forbids exempting. The ledger stays at zero entries, asserted by `check-osv-exemptions` (exit 0). Measured here: `pnpm install --frozen-lockfile --prefer-offline` → exit 0 ("Lockfile is up to date"), `check-override-consistency` → exit 0 with the override covered, `check-osv-exemptions` → exit 0. The scanner itself is CI's to render — `api.osv.dev` is refused by this container's egress proxy — so no local green is claimed for it; what is claimed is its input, and `devalue@5.9.0` is gone from the lockfile. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude --- pnpm-lock.yaml | 9 +++++---- pnpm-workspace.yaml | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2eecc4f477..6ab7f9423b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,7 @@ overrides: '@xmldom/xmldom@>=0.8.0 <0.9.0': ^0.8.15 '@xmldom/xmldom@>=0.9.0 <0.10.0': ^0.9.12 qs@>=6.0.0 <7.0.0: ^6.16.0 + devalue@<6.0.0: ^5.9.2 importers: @@ -6366,8 +6367,8 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} - devalue@5.9.0: - resolution: {integrity: sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==} + devalue@5.9.2: + resolution: {integrity: sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==} devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -12614,7 +12615,7 @@ snapshots: detect-node-es@1.1.0: {} - devalue@5.9.0: {} + devalue@5.9.2: {} devlop@1.1.0: dependencies: @@ -15547,7 +15548,7 @@ snapshots: aria-query: 5.3.1 axobject-query: 4.1.0 clsx: 2.1.1 - devalue: 5.9.0 + devalue: 5.9.2 esm-env: 1.2.2 esrap: 2.3.4(@typescript-eslint/types@8.67.0) is-reference: 3.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 59ca7a45329..38804e61509 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -394,3 +394,40 @@ overrides: '@xmldom/xmldom@>=0.8.0 <0.9.0': '^0.8.15' '@xmldom/xmldom@>=0.9.0 <0.10.0': '^0.9.12' 'qs@>=6.0.0 <7.0.0': '^6.16.0' + # OSV 2026-09-18 (#18930) — one advisory, and it names a fixed version, so + # this is the "take the fix" path osv-scanner.toml's own header prescribes + # and NOT an exemption; that ledger stays at ZERO entries. + # devalue GHSA-9rgm-9g3h-6x36 (5.3 medium) — flagged at the single resolved + # line 5.9.0, fixed 5.9.2. It turned `Validate Package Dependencies` red + # on `main` itself (scheduled run 35301766597, step 13), so every PR + # touching any package.json inherited a red that was not its own. + # Transitive-only: no workspace manifest declares devalue, so there is no + # publishable declared range to keep in lockstep and + # check-override-consistency.mjs lists it as an override it cannot + # cross-check against a declared range — correct for this shape, and a + # report, never a failure. Reached through svelte@5.56.9, which declares + # `devalue: ^5.8.1` — a range that ALREADY admits 5.9.2, so this is a + # dedupe onto the patched line rather than a forced upgrade past what the + # dependent supports (the dompurify / nanoid shape above). + # Why the pin rather than a bare resolver refresh: the lockfile sat on + # 5.9.0 purely through lockfile inertia — 5.9.0 satisfied ^5.8.1, so + # nothing re-resolved it when 5.9.1 and 5.9.2 published, the same + # mechanism the hono note above records. `pnpm update devalue -r --depth + # Infinity` does clear it, but it re-resolves the whole tree and moved + # twelve unrelated packages in one 107/87-line lockfile churn; the + # override moves devalue alone and leaves a floor behind, so a future + # transitive reintroduction lands on the patched line instead of silently + # re-arming the advisory. + # ONE resolved copy in the whole tree (`pnpm why devalue`), so the + # floorless selector forces nothing else onto the 5.x line. + # Bound at the 6.0.0 major boundary per this block's header rule, never + # `<5.9.2`, which would self-invalidate the day 5.9.2 is itself flagged + # (the undici 7.28.0 / brace-expansion 5.0.8 specimens, #4961 / #5032). + # ⚠️ The advisory's CONTENT is NOT characterised here: api.osv.dev and + # GitHub's /advisories endpoint are both refused by this container's + # egress proxy, so the severity and the fixed version above are read off + # the scanner's own output line and nothing else. + # ⚠️ PORTED, not authored here: this block and its lockfile effect are + # PR #18942's (card #18930), copied verbatim so this PR stops inheriting + # `main`'s red. It no-ops the moment the base carries it. + 'devalue@<6.0.0': '^5.9.2' From d563fac6ab1a6688ccc45e3b9d3048acf39b987e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 07:56:43 +0000 Subject: [PATCH 19/19] fix(spec,triggers,changeset): the three findings the independent review returned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An isolated contract-review subagent reviewed `cda685b8` and returned PASS WITH FINDINGS. All three are this seat's, all three are declaration- or prose-level, and each is verified against the tree before being fixed rather than taken on the reviewer's word. **1. The changeset did not name `@objectstack/cli`.** `packages/cli/src/commands/doctor.ts` changes the text `os doctor` prints (11 lines, this PR's), `@objectstack/cli` is published (`publishConfig.access: public`), and AGENTS.md requires a changeset for anything that publishes. The sibling changeset for the same doctor text (`.changeset/scheduled-work-deployment-switch.md`) lists it. `Check Changeset` was green only because no gate reads package coverage. Added at `patch` — the text is a fix, no API moves. `@objectstack/lint` still needs nothing: its diff is comment-only. **2. A docblock this PR touched still stated the retired rule.** `schedule-trigger.ts`'s `refuseMissingOrganization` header read "⚠️ Under a WALLED posture (`group` / `isolated`) … and nowhere else". That is false at this head — the caller gates on `requiresActingOrganization`, which is `isolated` only — and it contradicted the zod docblock this same PR rewrote. Same class as the `runOwnership` mismatch fixed in `0752431d`, missed in the same file. The header now names `isolated`, says why `group` is not a near-miss, and names the predicate to gate on (⛔ never `postureEnforcesWall`, which answers `true` for `group`). **3. A published `.describe()` string had a grammar defect** — "acts as the swept record own organization" — shipping in the JSON schema and the generated reference page. Reworded to "acts as the organization of the record it swept", which also avoids an apostrophe inside the single-quoted literal; the first attempt at the possessive broke the parse, which `gen:schema` caught. Regenerated: `gen:schema`, `gen:docs`, `gen:api-surface` (that one needed a `pnpm --filter @objectstack/spec build` first — its staleness was the built-dist phantom, and the artifact came back byte-identical). Also taken, though the review marked it optional: the "one run carries ONE organization's opinion" claim is now stated WITH its exception, in both the spec docblock and the changeset. The history row is stamped (`tenancy.organizationField` wins) while the run's acting organization is a wall reading that never consults that key, so the two coincide on every ordinary object and diverge on the one shipped object that declares the key. That divergence is the correct pair of answers — a row nothing walls has no organization for a run to act as — but it is a divergence, and an unqualified claim of agreement would be the same kind of false docblock as finding 2. Measured: spec 13688/13688, trigger-schedule 138/138, cli 3385/3385, `check:generated` 15/15, `check-adr-0087-registration` and `check-changeset-fixed` exit 0. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude --- ...oup-scheduled-work-per-record-ownership.md | 11 +++++++++ .../automation/schedule-organization.mdx | 18 ++++++++++++++- .../automation/schedule-organization.zod.ts | 18 ++++++++++++++- .../trigger-schedule/src/schedule-trigger.ts | 23 +++++++++++++------ 4 files changed, 61 insertions(+), 9 deletions(-) diff --git a/.changeset/group-scheduled-work-per-record-ownership.md b/.changeset/group-scheduled-work-per-record-ownership.md index 99c7f5d1ea4..bb376732d5b 100644 --- a/.changeset/group-scheduled-work-per-record-ownership.md +++ b/.changeset/group-scheduled-work-per-record-ownership.md @@ -3,6 +3,7 @@ "@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) @@ -54,6 +55,16 @@ halves disagreed under `group`: the history row was stamped from the record whil 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 diff --git a/content/docs/references/automation/schedule-organization.mdx b/content/docs/references/automation/schedule-organization.mdx index 8561695166d..7d1af3edca0 100644 --- a/content/docs/references/automation/schedule-organization.mdx +++ b/content/docs/references/automation/schedule-organization.mdx @@ -83,6 +83,22 @@ 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 @@ -155,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. Required under the isolated tenancy posture: a flow that declares none is not armed. Optional under group, where an undeclared run acts as the swept record own organization. Not required under single, where 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/spec/src/automation/schedule-organization.zod.ts b/packages/spec/src/automation/schedule-organization.zod.ts index 013f7b44590..2c16f4f983a 100644 --- a/packages/spec/src/automation/schedule-organization.zod.ts +++ b/packages/spec/src/automation/schedule-organization.zod.ts @@ -81,6 +81,22 @@ import { z } from 'zod'; * 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 @@ -153,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. Required under the isolated tenancy posture: a flow that declares none is not armed. Optional under group, where an undeclared run acts as the swept record own organization. Not required under single, where 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.', ); /** diff --git a/packages/triggers/trigger-schedule/src/schedule-trigger.ts b/packages/triggers/trigger-schedule/src/schedule-trigger.ts index b1272396e97..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