Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c31e062
feat(spec,types)!: `group` scheduled work binds without a declaration…
claude Sep 16, 2026
18b4796
feat(triggers)!: `group` sweeps stamp each run from its own swept record
claude Sep 16, 2026
244a77c
test(types,triggers): pin the four bind states and per-record ownership
claude Sep 16, 2026
bc0bd77
docs,changeset: the `group` row in the acting-organization docs and t…
claude Sep 16, 2026
2135026
fix(cli): os doctor names what each posture owes, not "walled (group/…
claude Sep 16, 2026
c16bd66
docs(lint): the near-miss diagnostic's door is `isolated`, not "a wal…
claude Sep 16, 2026
b496f65
fix(changeset): the ADR-0087 marker is `already-registered <id>`, not…
claude Sep 16, 2026
109d0dd
fix(changeset): `already-registered` is a not-required CATEGORY, not …
claude Sep 16, 2026
661f85c
docs(changeset): say what the `!` marks — behaviour, not a narrowing
claude Sep 16, 2026
e800c67
fix(triggers): read the memoized record-org resolver through a local
claude Sep 16, 2026
acf9c8d
docs: three more pages restating the retired "walled = must declare" …
claude Sep 16, 2026
fed6360
chore(spec): regenerate the migration registry and the reference page
claude Sep 16, 2026
24279c8
fix(triggers): alias @objectstack/metadata-core to source in the vite…
claude Sep 16, 2026
e856c00
fix(triggers): resolve metadata-core types to source via tsconfig paths
claude Sep 16, 2026
5ddc993
fix(changeset): `describeScheduleRunOwnership` is module-level, not a…
claude Sep 17, 2026
0752431
docs(types): `runOwnership` reports the posture's rule, switch or no …
claude Sep 17, 2026
cda685b
fix(triggers,metadata-core): the sweep asks the WALL question, not th…
claude Sep 18, 2026
ef6f69b
fix(deps): port the devalue 5.9.2 pin so this PR stops inheriting mai…
claude Sep 18, 2026
d563fac
fix(spec,triggers,changeset): the three findings the independent revi…
claude Sep 18, 2026
4584b00
Merge origin/main into claude/zealous-mendel-o0o6aq
claude Sep 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions .changeset/group-scheduled-work-per-record-ownership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
---
"@objectstack/types": minor
"@objectstack/spec": minor
"@objectstack/trigger-schedule": minor
"@objectstack/metadata-core": minor
"@objectstack/cli": patch
---

feat(spec,types,triggers)!: `group` runs package-authored scheduled work without a declaration, owning each run's writes per record (#18378)

<!-- adr-0087: not-required (already-registered schedule-flow-acting-organization-required) This amends the EXISTING semantic entry 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), so every flow that parses today parses byte-identically afterwards and `objectstack migrate meta` has nothing new to rewrite. What moves is the BIND-time accept set (it WIDENS) and the RUN-time organization such a flow's writes carry; the entry's own surface/replacement/reason/acceptanceCriteria each gained their `group` row in this diff. -->

`Clause-②: yes (widening)`

**ADR-0087 disposition — `not-required (already-registered)`, not `registered`.**
The ledger entry this change belongs to already exists
(`schedule-flow-acting-organization-required`, entry 18) and predates this diff
at the merge base, so `registered` would assert a registration this PR did not
make. The entry's `surface`, `replacement`, `reason` and `acceptanceCriteria`
each gained their `group` row here, the rejected bootstrap-organization arm
included — recorded because it is the one a later reader will re-propose.

**Marked breaking (`!`) for the behaviour change, not for a narrowing.** Nothing
that worked stops working and nothing that was admitted becomes refused — the
accept set WIDENS in one cell. What earns the banner is the other direction: on a
`group` deployment with the switch already on, flows that were refused at bind
now arm and run, so clock-driven work appears where an operator had none. That is
worth reading before upgrading even though no consumer has to change anything.

## What changes

With `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` on and tenancy posture `group`, a
time-triggered flow that declares no `config.organization` now **binds and
runs**, where it was previously refused at bind. The organization its writes
carry follows the record:

| posture | declaration | a bound run's writes act as |
|---|---|---|
| `single` | not read | nothing — the install's one organization resolves beneath each write |
| `group` | **optional** | declared ⇒ the declaration; undeclared ⇒ **the swept record's own organization** |
| `isolated` | **required** | the declaration; undeclared ⇒ not armed, unchanged |

A `timeRelative` sweep under `group` reads group-wide — inherent to the posture
(ADR-0105 D1) — and stamps each run it launches with that record's organization:
sweep contracts across four plants and each plant's contract yields a run acting
as that plant, whose notifications reach that plant's inboxes.

## Why this is not a fallback that guesses

It is the order `sys_automation_run` was **already** ruled to use.
`ObjectStoreSuspendedRunStore` resolves a run's organization as
`organizationOf(<subject record>) ?? ctx.tenantId` — subject first, acting
context as the fallback and never the primary. Before this change those two
halves disagreed under `group`: the history row was stamped from the record while
the inbox and delivery rows followed an acting context that could not exist
there, so they were refused while the tick summarised itself as healthy.

⚠️ With one stated exception, because the two halves ask different questions:
the history row is STAMPED (`tenancy.organizationField` wins there) while the
run's acting organization is a WALL reading that never consults that key. They
agree on every object where the two coincide — which is every ordinary object,
since a declared stamp column is what makes them differ and one shipped object
declares one (`sys_api_key`, deliberately unwalled). Sweeping that object under
`group` stamps its history row while the run itself acts as nothing: the correct
pair of answers, not a residue of the old disagreement, and recorded rather than
smoothed over.

⛔ A record-less run under `group` that declared nothing still resolves
**nothing** and is refused at its first tenant-scoped write (`walled-posture`,
ADR-0112), loudly and by name. The rejected alternative was a fallback to the
bootstrap organization (`slug='default'`): under a wall that organization is
minted admin-keyed by the enterprise organizations runtime and may not exist at
all, and where it does it is whichever organization the platform owner
registered under — plausibly one plant of many, not the group's head office.

## Upgrading

**Most deployments: nothing to do.** The switch this depends on is OFF by default
and ships unreleased alongside this change, so the `group`-is-walled behaviour
being amended has never appeared in a published version — no released consumer
can be relying on it.

If you run posture `group` **and** turn the switch on, read your boot log: each
time-triggered flow's bind line now names which of the three shapes it bound as
("as organization '…'", "with per-record acting organization", or "with NO
acting organization"). Two things to check:

- A flow you expected to act as ONE organization but which binds per-record is
missing its `config.organization`. Add it — declaring still narrows, bounding
the sweep's query as well as its identity.
- A plain `schedule` cron flow that binds "with NO acting organization" has no
record to derive one from. If it writes notifications, inbox messages or any
other per-organization row, declare `organization` on its start node; the bind
line says so, and so does the refusal at the first tick.

## Which organization a record belongs to — the WALL question, not the stamp one

`@objectstack/metadata-core` gains a second face on the record→organization
resolver, and the split is the point: `resolveRecordOrganizationField` /
`createRecordOrganizationResolver` answer **"who is this row ABOUT"** (the STAMP
question, whose `tenancy.organizationField` limb stays pinned to the three
sanctioned platform-row writers), while the new
`resolveRecordWallOrganizationField` / `createRecordWallOrganizationResolver`
answer **"what is this row WALLED by"** — `tenancy.enabled: false` ⇒ nothing,
then a declared `tenancy.tenantField`, then the kernel's `organization_id`.

The sweep uses the WALL face, because "which organization does this run act as"
is a question about the wall. ⛔ It never reads `tenancy.organizationField`: that
key is declared on exactly one shipped object (`sys_api_key`, deliberately
unwalled, #8287), and reading it here would turn "the audit trail should follow
this row's own organization even though nothing walls it" into an acting
identity. A sweep over such an object resolves **nothing** and takes the
`walled-posture` refusal at its first tenant-scoped write, which is the honest
answer. Limbs 1 to 4 are one implementation shared by both faces, pinned as
such, so the half they agree on cannot drift apart.

**API:** `ScheduledWorkPolicy` gains `runOwnership: 'unscoped' | 'per-record' |
'declared'`, and `requiresActingOrganization` narrows from "any walled posture"
to `isolated` only. The two are deliberately separate axes: the boolean decides
whether BIND refuses, `runOwnership` decides what a run that DID bind carries.
Inside `@objectstack/trigger-schedule`, both triggers share one bind-line
vocabulary (`describeScheduleRunOwnership`) so they cannot describe one
deployment differently. ⚠️ That helper is module-level, NOT a package export: it
is not re-exported from the package barrel, whose own note says an export whose
only consumers live inside its own package belongs in a non-barrel module. The
new PUBLIC surface in this change is `ScheduledRunOwnership` and the
`runOwnership` key on `@objectstack/types`, plus
`resolveRecordWallOrganizationField` and
`createRecordWallOrganizationResolver` on `@objectstack/metadata-core` — and
those four are what put `Clause-②` at `yes`. Nothing existing is renamed or
re-typed: both stamp-face exports keep their names, their signatures and their
answers, limb 0 included.
78 changes: 58 additions & 20 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2078,10 +2078,11 @@ export const renewalReminder: Flow = {
offsetDays: [60, 30, 7], // — or — withinDays: 30 (negative = overdue lookback)
filter: { status: 'active' }, // optional, ANDed with the date window
},
// Required under a WALLED tenancy posture, and for a stronger reason
// than a plain schedule flow — it bounds the sweep's query as well as
// its runs. Not required under `single`. See "The acting organization"
// below.
// Required under `isolated`, and for a stronger reason than a plain
// schedule flow — it bounds the sweep's query as well as its runs.
// Optional under `group` (an undeclared sweep reads group-wide and each
// run acts as its own record's organization) and not read under
// `single`. See "The acting organization" below.
organization: '<sys_organization.id>',
// schedule: { type: 'cron', expression: '0 8 * * *' } // optional; defaults to daily 08:00 UTC
},
Expand Down Expand Up @@ -2142,10 +2143,9 @@ it: the caller's session rides into the run and every tenant-scoped write below
resolves the same organization a normal write would. A **time-triggered** flow
has no such caller — a job tick carries no identity at all.

Under a **walled** tenancy posture (`group` or `isolated`) that is a question
only the author can answer, so a `schedule` or `timeRelative` flow there
**declares the organization it runs as**, on the start node's `config`, beside
the cadence it scopes:
Under the **`isolated`** tenancy posture that is a question only the author can
answer, so a `schedule` or `timeRelative` flow there **declares the organization
it runs as**, on the start node's `config`, beside the cadence it scopes:

```typescript
config: {
Expand Down Expand Up @@ -2193,7 +2193,33 @@ scheduled-work switch, and neither is visible from a stack. A lint rule that
fired on the default posture would be wrong more often than right.
</Callout>

**Under a wall, a time-triggered flow that declares none is a declaration
<Callout type="info">
**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.
</Callout>

**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;
Expand All @@ -2203,15 +2229,19 @@ error**, refused at bind:
`bound: false`;
- nothing fires it.

There is deliberately **no fallback** — not the platform organization, not "the
first row of `sys_organization`", and never the swept record's own
`organization_id`. Behind a wall, a run that reached every tenant-scoped write
with nothing to offer would have each of those writes refused one layer below
anything that summarises the run: the tick reports itself healthy and delivers
nothing. A wrong `organization_id` is worse still, because it is silently
authoritative to every report, export and cleanup script that filters by
organization. ⛔ This is unchanged by the posture split above: `single` **omits**
the organization, it never invents one.
There is deliberately **no invented fallback** — not the platform organization,
not "the first row of `sys_organization`", not the group's bootstrap
organization. Behind a wall, a run that reached every tenant-scoped write with
nothing to offer would have each of those writes refused one layer below anything
that summarises the run: the tick reports itself healthy and delivers nothing. A
wrong `organization_id` is worse still, because it is silently authoritative to
every report, export and cleanup script that filters by organization.

⛔ The swept record's own `organization_id` is **not** such an invention, and it
is used under `group` alone — there the row is the only honest owner available
and the posture's reads already span the group. Under `single` the organization
is **omitted**, never filled from the row; under `isolated` an undeclared flow
never binds in the first place.

**No fan-out.** A single flow belongs to one organization. A sweep wanted in
several organizations is declared once per organization.
Expand All @@ -2220,8 +2250,16 @@ several organizations is declared once per organization.
The start node's `config` is an open record, so a near-miss spelling —
`organizationId`, `organization_id`, `orgId`, `org_id`, `tenantId` — parses
happily and is then ignored. The bind-time refusal names the spelling you
actually wrote — under a wall, which is the only place the key is required and
therefore the only place a near-miss is a mistake.
actually wrote — under `isolated`, which is the only posture where the key is
required and therefore the only place a near-miss is unambiguously a mistake.
</Callout>

<Callout type="warn">
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.
</Callout>

### Update-triggered flow
Expand Down
Loading
Loading