Skip to content

Commit 0a56d3b

Browse files
hotlongclaude
andauthored
feat(spec,types,triggers)!: group runs package-authored scheduled work without a declaration, owning each run's writes per record (#18420)
Fixes #18378 Implements ruling **A′** (`Ruling-ref: 5695424700`, maintainer, 2026-09-16), which reopened ruling G item 3 (#17396) for `group` **only**. ⛔ Nothing about `single` or `isolated` is reopened, and the deployment switch itself (`OS_AUTOMATION_SCHEDULED_WORK_ENABLED`, default OFF in every posture) is untouched — A′ decides only what binds *once the operator has turned it on* under `group`. `Clause-②: yes (widening)` ## What this is With the switch on and posture `group`, a time-triggered flow that declares no `config.organization` now **binds and runs**, where it was previously refused at bind. What 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, whose own example is multi-plant MES) — and stamps each run 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. ## Which organization a record belongs to — the WALL question, not the stamp one ⭐ **This is the part that changed after review, and it is the heart of the PR.** "Which organization does this record belong to" had two different answers in one resolver, and this diff separates them in `@objectstack/metadata-core`: | face | question | limb 0 (`tenancy.organizationField`) | consumers | |---|---|---|---| | `resolveRecordOrganizationField` / `createRecordOrganizationResolver` (**unchanged**) | who is this row **ABOUT** — the stamp | read | the three sanctioned platform-row writers | | `resolveRecordWallOrganizationField` / `createRecordWallOrganizationResolver` (**new**) | what is this row **WALLED BY** — the scope, and so the identity work launched from it may act as | **not read** | this sweep | The sweep binds the WALL face. ⛔ It never reads `tenancy.organizationField`, so it is **not a fourth consumer** of that scope-pinned key and needs no ruling to admit one: the key's contract (#8778, cloud#1395) pins its consumers to audit stamping, the approval-row writer and the automation-run recorder, and that list is untouched. Why the split is not cosmetic: the two answers coincide on every ordinary object and come apart on exactly one shipped object — `sys_api_key`, `tenancy: { enabled: false, organizationField: 'active_organization_id' }`, deliberately unwalled (#8287; walling the credential table on an equality that excludes NULL is the defect that card removed). 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**. On such an object the sweep now resolves **nothing** and the run takes the existing `walled-posture` refusal at its first tenant-scoped write, by name. Both faces are ONE implementation — a `readStampKey` parameter selects limb 0 alone — so limbs 1–4 cannot drift into two answers. ⛔ No cross-package parity pin against `objectql`'s `resolveTenantFieldName` is added: 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 three spellings of the wall rule belongs to its own card ([#19054](#19054) covers the related key retirement); this change adds no fourth. ## Why per-record ownership 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 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 while the tick summarised itself as healthy. ⚠️ With one stated exception, recorded rather than smoothed over: the history row is STAMPED while the run's acting organization is a WALL reading, so on the one shipped object that declares the stamp key the two legitimately differ — the row says who it is about, and nothing walls it, so there is no organization for the run to act as. ## The refusal that remains, deliberately A record-less run under `group` that declared nothing resolves **nothing** and takes the existing `walled-posture` refusal at its first tenant-scoped write (ADR-0112), loudly and by name. ⛔ That is **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. The bind line says so at boot instead, because the write refusal is correct but arrives at the first tick, which may be hours away and unattended. 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. That would be a **wrong owner**, silently authoritative to every report and export that filters by organization. ## Design notes for the reviewer - **`runOwnership` is a second axis, not a rename.** `requiresActingOrganization` decides whether BIND refuses; `runOwnership` decides what a run that DID bind carries. ⚠️ `runOwnership` is a fact about the POSTURE, not about the switch: it reports `group`'s `'per-record'` even while the switch is off, when nothing binds. `enabled` is the discriminator. - **The separating predicate is `postureUsesUnionScope`, ⛔ not `postureEnforcesWall`.** `group` *does* enforce a wall — that is why its writes still need an owner — and it also has group-wide read reach, which is why a batch job there is a capability rather than a boundary violation. A regression to `enabled && postureEnforcesWall(posture)` passes every other pin and fails one named live control. - **One new degradation, at `warn`.** `TimeRelativeDataEngine` is a *type-level* narrowing — the plugin resolves the real `objectql` service, which has `getSchema` — but a host mounting a genuine adapter object would not. Then nothing resolves, every write is refused, and the message is about the WRITE. Said once per engine, naming the remedy. ⛔ Not `error`: the writes that matter are still refused loudly. ## Retired pins, with their reasons (⛔ none deleted silently) - **"never filled from the swept row"** is retired for **`group` alone**, and the comment records the verdict per posture: under `isolated` it stands; under `single` the key is still omitted; under `group` "organizations it never declared" is the posture's own read reach. - **`ScheduleTrigger — switched ON under a wall`** becomes two blocks, and the replacement site quotes the condition the old pin rested on so the reversal is legible rather than looking like erosion. ## Tests ⚠️ **The discriminating assertion is the SET of organizations across the runs one tick launched**, never "a run was stamped" — the behaviour this replaces stamped every run in a batch alike. The fixture puts matching rows in two organizations and reads `['org_plant_a', 'org_plant_b']`, a value no previous behaviour could produce. The wall/stamp split has its own discriminator: an object shaped like `sys_api_key` resolves **nothing** for the sweep. **Reverse-verified** — pointing the trigger back at `createRecordOrganizationResolver` reddens that pin and only it (1 failed / 137 passed), and the restore is byte-identical. Also pinned: a declaration still outranks the record; a row with no organization stamps nothing, with a live control proving the same tick stamped a sibling; `tenancy: { enabled: false }` resolves nothing even with a stray column; the no-`getSchema` degradation warns once; `group` answers `true` to `postureEnforcesWall` while still not requiring the declaration; and the two resolver faces agree on every shape where limb 0 is absent. ## What the `!` marks The breaking marker is for the **behaviour change, not a narrowing**. Nothing that worked stops working and nothing 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. The switch this depends on ships **unreleased** alongside this change, so the `group`-is-walled behaviour being amended has never appeared in a published version. ADR-0087 disposition is `not-required (already-registered)` — the ledger entry predates this diff at the merge base and gained its `group` rows here. ## Status — green on `4584b00f`; ready and queued by another seat **CI: all seven required contexts green** on `4584b00f` — `Lint & Repo Gates`, `TypeScript Type Check`, `Test Core`, `Dogfood Regression Gate`, `Build Core`, `Temporal Conformance (live PG + MySQL)`, `Governed Surface Queue Guard` — plus `Validate Package Dependencies` and `Check Changeset`. 36 check runs completed, none failed, two deliberate skips. `mergeable_state: clean`. **Independent Clause-② review: PASS WITH FINDINGS**, posted verbatim in [this comment](#18420 (comment)). Its three findings are fixed in `d563fac6` (the changeset now names `@objectstack/cli`; a stale docblock that still claimed the retired "any walled posture" rule is corrected; a published `.describe()` grammar defect is fixed and regenerated), each verified against the tree rather than taken on the reviewer's word. ⚠️ **Two corrections to what this section said before**, because a stale status on a merging PR is worse than no status: 1. It said "⛔ still DRAFT, never self-queued: this is a governed-surface PR." **This PR does not touch the governed surface** (`docs/adr/**`, `.claude/**`, `skills/**`, `AGENTS.md`, `CLAUDE.md`) — its diff is `packages/**`, `content/docs/**`, `.changeset/**` and the lockfile, and `Governed Surface Queue Guard` passes accordingly. The authoring seat held it in draft by its own caution, not by that rule. It was flipped ready, armed for auto-merge and enqueued by `os-litant` at 14:43Z. 2. It said the machine independence pair reads SELF-REVIEW. Measured, it reads **UNJUDGED**, and for an upstream reason: [this comment](#18420 (comment)) has the run and the cause (a claim comment whose branch name does not match the checker's `claude/issue-<n>-<slug>` shape). ⚠️ That checker is not in CI and reads two lines of a comment; it is not evidence about this diff either way. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0ec8185 commit 0a56d3b

23 files changed

Lines changed: 1557 additions & 203 deletions
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
---
2+
"@objectstack/types": minor
3+
"@objectstack/spec": minor
4+
"@objectstack/trigger-schedule": minor
5+
"@objectstack/metadata-core": minor
6+
"@objectstack/cli": patch
7+
---
8+
9+
feat(spec,types,triggers)!: `group` runs package-authored scheduled work without a declaration, owning each run's writes per record (#18378)
10+
11+
<!-- 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. -->
12+
13+
`Clause-②: yes (widening)`
14+
15+
**ADR-0087 disposition — `not-required (already-registered)`, not `registered`.**
16+
The ledger entry this change belongs to already exists
17+
(`schedule-flow-acting-organization-required`, entry 18) and predates this diff
18+
at the merge base, so `registered` would assert a registration this PR did not
19+
make. The entry's `surface`, `replacement`, `reason` and `acceptanceCriteria`
20+
each gained their `group` row here, the rejected bootstrap-organization arm
21+
included — recorded because it is the one a later reader will re-propose.
22+
23+
**Marked breaking (`!`) for the behaviour change, not for a narrowing.** Nothing
24+
that worked stops working and nothing that was admitted becomes refused — the
25+
accept set WIDENS in one cell. What earns the banner is the other direction: on a
26+
`group` deployment with the switch already on, flows that were refused at bind
27+
now arm and run, so clock-driven work appears where an operator had none. That is
28+
worth reading before upgrading even though no consumer has to change anything.
29+
30+
## What changes
31+
32+
With `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` on and tenancy posture `group`, a
33+
time-triggered flow that declares no `config.organization` now **binds and
34+
runs**, where it was previously refused at bind. The organization its writes
35+
carry follows the record:
36+
37+
| posture | declaration | a bound run's writes act as |
38+
|---|---|---|
39+
| `single` | not read | nothing — the install's one organization resolves beneath each write |
40+
| `group` | **optional** | declared ⇒ the declaration; undeclared ⇒ **the swept record's own organization** |
41+
| `isolated` | **required** | the declaration; undeclared ⇒ not armed, unchanged |
42+
43+
A `timeRelative` sweep under `group` reads group-wide — inherent to the posture
44+
(ADR-0105 D1) — and stamps each run it launches with that record's organization:
45+
sweep contracts across four plants and each plant's contract yields a run acting
46+
as that plant, whose notifications reach that plant's inboxes.
47+
48+
## Why this is not a fallback that guesses
49+
50+
It is the order `sys_automation_run` was **already** ruled to use.
51+
`ObjectStoreSuspendedRunStore` resolves a run's organization as
52+
`organizationOf(<subject record>) ?? ctx.tenantId` — subject first, acting
53+
context as the fallback and never the primary. Before this change those two
54+
halves disagreed under `group`: the history row was stamped from the record while
55+
the inbox and delivery rows followed an acting context that could not exist
56+
there, so they were refused while the tick summarised itself as healthy.
57+
58+
⚠️ With one stated exception, because the two halves ask different questions:
59+
the history row is STAMPED (`tenancy.organizationField` wins there) while the
60+
run's acting organization is a WALL reading that never consults that key. They
61+
agree on every object where the two coincide — which is every ordinary object,
62+
since a declared stamp column is what makes them differ and one shipped object
63+
declares one (`sys_api_key`, deliberately unwalled). Sweeping that object under
64+
`group` stamps its history row while the run itself acts as nothing: the correct
65+
pair of answers, not a residue of the old disagreement, and recorded rather than
66+
smoothed over.
67+
68+
⛔ A record-less run under `group` that declared nothing still resolves
69+
**nothing** and is refused at its first tenant-scoped write (`walled-posture`,
70+
ADR-0112), loudly and by name. The rejected alternative was a fallback to the
71+
bootstrap organization (`slug='default'`): under a wall that organization is
72+
minted admin-keyed by the enterprise organizations runtime and may not exist at
73+
all, and where it does it is whichever organization the platform owner
74+
registered under — plausibly one plant of many, not the group's head office.
75+
76+
## Upgrading
77+
78+
**Most deployments: nothing to do.** The switch this depends on is OFF by default
79+
and ships unreleased alongside this change, so the `group`-is-walled behaviour
80+
being amended has never appeared in a published version — no released consumer
81+
can be relying on it.
82+
83+
If you run posture `group` **and** turn the switch on, read your boot log: each
84+
time-triggered flow's bind line now names which of the three shapes it bound as
85+
("as organization '…'", "with per-record acting organization", or "with NO
86+
acting organization"). Two things to check:
87+
88+
- A flow you expected to act as ONE organization but which binds per-record is
89+
missing its `config.organization`. Add it — declaring still narrows, bounding
90+
the sweep's query as well as its identity.
91+
- A plain `schedule` cron flow that binds "with NO acting organization" has no
92+
record to derive one from. If it writes notifications, inbox messages or any
93+
other per-organization row, declare `organization` on its start node; the bind
94+
line says so, and so does the refusal at the first tick.
95+
96+
## Which organization a record belongs to — the WALL question, not the stamp one
97+
98+
`@objectstack/metadata-core` gains a second face on the record→organization
99+
resolver, and the split is the point: `resolveRecordOrganizationField` /
100+
`createRecordOrganizationResolver` answer **"who is this row ABOUT"** (the STAMP
101+
question, whose `tenancy.organizationField` limb stays pinned to the three
102+
sanctioned platform-row writers), while the new
103+
`resolveRecordWallOrganizationField` / `createRecordWallOrganizationResolver`
104+
answer **"what is this row WALLED by"** — `tenancy.enabled: false` ⇒ nothing,
105+
then a declared `tenancy.tenantField`, then the kernel's `organization_id`.
106+
107+
The sweep uses the WALL face, because "which organization does this run act as"
108+
is a question about the wall. ⛔ It never reads `tenancy.organizationField`: that
109+
key is declared on exactly one shipped object (`sys_api_key`, deliberately
110+
unwalled, #8287), and reading it here would turn "the audit trail should follow
111+
this row's own organization even though nothing walls it" into an acting
112+
identity. A sweep over such an object resolves **nothing** and takes the
113+
`walled-posture` refusal at its first tenant-scoped write, which is the honest
114+
answer. Limbs 1 to 4 are one implementation shared by both faces, pinned as
115+
such, so the half they agree on cannot drift apart.
116+
117+
**API:** `ScheduledWorkPolicy` gains `runOwnership: 'unscoped' | 'per-record' |
118+
'declared'`, and `requiresActingOrganization` narrows from "any walled posture"
119+
to `isolated` only. The two are deliberately separate axes: the boolean decides
120+
whether BIND refuses, `runOwnership` decides what a run that DID bind carries.
121+
Inside `@objectstack/trigger-schedule`, both triggers share one bind-line
122+
vocabulary (`describeScheduleRunOwnership`) so they cannot describe one
123+
deployment differently. ⚠️ That helper is module-level, NOT a package export: it
124+
is not re-exported from the package barrel, whose own note says an export whose
125+
only consumers live inside its own package belongs in a non-barrel module. The
126+
new PUBLIC surface in this change is `ScheduledRunOwnership` and the
127+
`runOwnership` key on `@objectstack/types`, plus
128+
`resolveRecordWallOrganizationField` and
129+
`createRecordWallOrganizationResolver` on `@objectstack/metadata-core` — and
130+
those four are what put `Clause-②` at `yes`. Nothing existing is renamed or
131+
re-typed: both stamp-face exports keep their names, their signatures and their
132+
answers, limb 0 included.

‎content/docs/automation/flows.mdx‎

Lines changed: 58 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2078,10 +2078,11 @@ export const renewalReminder: Flow = {
20782078
offsetDays: [60, 30, 7], // — or — withinDays: 30 (negative = overdue lookback)
20792079
filter: { status: 'active' }, // optional, ANDed with the date window
20802080
},
2081-
// Required under a WALLED tenancy posture, and for a stronger reason
2082-
// than a plain schedule flow — it bounds the sweep's query as well as
2083-
// its runs. Not required under `single`. See "The acting organization"
2084-
// below.
2081+
// Required under `isolated`, and for a stronger reason than a plain
2082+
// schedule flow — it bounds the sweep's query as well as its runs.
2083+
// Optional under `group` (an undeclared sweep reads group-wide and each
2084+
// run acts as its own record's organization) and not read under
2085+
// `single`. See "The acting organization" below.
20852086
organization: '<sys_organization.id>',
20862087
// schedule: { type: 'cron', expression: '0 8 * * *' } // optional; defaults to daily 08:00 UTC
20872088
},
@@ -2142,10 +2143,9 @@ it: the caller's session rides into the run and every tenant-scoped write below
21422143
resolves the same organization a normal write would. A **time-triggered** flow
21432144
has no such caller — a job tick carries no identity at all.
21442145

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

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

2196-
**Under a wall, a time-triggered flow that declares none is a declaration
2196+
<Callout type="info">
2197+
**Under `group` the declaration is optional, and an undeclared flow still
2198+
runs.** `group` is one legal group over one shared database — group-wide
2199+
visibility and cross-organization workflow are inherent to the shape, not
2200+
violations of it — so a group-level batch job is a capability of the posture. An
2201+
undeclared `timeRelative` sweep there **reads group-wide** and stamps each run it
2202+
launches with **that record's own organization**: sweep a contracts table across
2203+
four plants and each plant's contract produces a run acting as that plant, whose
2204+
notifications land in that plant's inboxes.
2205+
2206+
That is not a new rule so much as one being made consistent: the
2207+
`sys_automation_run` history row was already stamped from the subject record,
2208+
with the acting context only as a fallback. Before this, the history row and the
2209+
inbox rows of the same run could disagree about who owned it.
2210+
2211+
Declaring on a `group` flow still works and still **narrows**: the declaration
2212+
bounds the sweep's query as well as its identity, exactly as under `isolated`.
2213+
2214+
⚠️ A **record-less** flow — a plain `schedule` cron with no sweep — has nothing
2215+
to derive an organization from. Under `group` it binds and runs, but its first
2216+
tenant-scoped write is **refused**, by name, with the remedy. Declare
2217+
`organization` on such a flow if it writes notifications, inbox messages or other
2218+
per-organization rows. The bind line says so at boot rather than leaving it to
2219+
surface at the first tick.
2220+
</Callout>
2221+
2222+
**Under `isolated`, a time-triggered flow that declares none is a declaration
21972223
error**, refused at bind:
21982224

21992225
- the trigger logs the reason at `error`, naming the flow;
@@ -2203,15 +2229,19 @@ error**, refused at bind:
22032229
`bound: false`;
22042230
- nothing fires it.
22052231

2206-
There is deliberately **no fallback** — not the platform organization, not "the
2207-
first row of `sys_organization`", and never the swept record's own
2208-
`organization_id`. Behind a wall, a run that reached every tenant-scoped write
2209-
with nothing to offer would have each of those writes refused one layer below
2210-
anything that summarises the run: the tick reports itself healthy and delivers
2211-
nothing. A wrong `organization_id` is worse still, because it is silently
2212-
authoritative to every report, export and cleanup script that filters by
2213-
organization. ⛔ This is unchanged by the posture split above: `single` **omits**
2214-
the organization, it never invents one.
2232+
There is deliberately **no invented fallback** — not the platform organization,
2233+
not "the first row of `sys_organization`", not the group's bootstrap
2234+
organization. Behind a wall, a run that reached every tenant-scoped write with
2235+
nothing to offer would have each of those writes refused one layer below anything
2236+
that summarises the run: the tick reports itself healthy and delivers nothing. A
2237+
wrong `organization_id` is worse still, because it is silently authoritative to
2238+
every report, export and cleanup script that filters by organization.
2239+
2240+
⛔ The swept record's own `organization_id` is **not** such an invention, and it
2241+
is used under `group` alone — there the row is the only honest owner available
2242+
and the posture's reads already span the group. Under `single` the organization
2243+
is **omitted**, never filled from the row; under `isolated` an undeclared flow
2244+
never binds in the first place.
22152245

22162246
**No fan-out.** A single flow belongs to one organization. A sweep wanted in
22172247
several organizations is declared once per organization.
@@ -2220,8 +2250,16 @@ several organizations is declared once per organization.
22202250
The start node's `config` is an open record, so a near-miss spelling —
22212251
`organizationId`, `organization_id`, `orgId`, `org_id`, `tenantId` — parses
22222252
happily and is then ignored. The bind-time refusal names the spelling you
2223-
actually wrote — under a wall, which is the only place the key is required and
2224-
therefore the only place a near-miss is a mistake.
2253+
actually wrote — under `isolated`, which is the only posture where the key is
2254+
required and therefore the only place a near-miss is unambiguously a mistake.
2255+
</Callout>
2256+
2257+
<Callout type="warn">
2258+
Under `group` a near-miss is **not** reported, because an undeclared flow is a
2259+
legal shape there and the trigger cannot tell "meant to declare, misspelled it"
2260+
from "meant not to declare". The symptom to watch for instead is a sweep whose
2261+
runs act per-record when you expected them all to act as one organization —
2262+
check the bind line, which names which of the two shapes the flow bound as.
22252263
</Callout>
22262264

22272265
### Update-triggered flow

0 commit comments

Comments
 (0)