Skip to content

Commit 92a80ed

Browse files
committed
Merge origin/main into claude/issue-19057-onnavigate-mode-union
Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
2 parents 94aeec6 + 54818fe commit 92a80ed

27 files changed

Lines changed: 2618 additions & 242 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/api/data-api.mdx‎

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -114,12 +114,31 @@ Why each one matters, since none of them changes which rows match:
114114
- **`expand`** — an unexpanded relation is indistinguishable from one whose
115115
foreign keys are all null, so clients render raw ids where names belong.
116116

117-
Sorts accept any of these spellings, all equivalent:
118-
`?sort=-created_at`, `?$orderby=-created_at`, and — on
119-
`POST /data/:object/query` — `{"orderBy": [{"field": "created_at", "order":
120-
"desc"}]}`, `{"orderBy": ["-created_at"]}` or `{"orderBy": {"created_at":
121-
"desc"}}`. A shape that is none of these (a number, an entry naming no field,
122-
a direction that is neither `asc` nor `desc`) is `400 INVALID_SORT`.
117+
Sort spellings are slot-specific, and the two routes take different sets — a
118+
slot that does not take a shape answers `400` instead of sorting. Measured on
119+
both routes:
120+
121+
| Sort value | On the querystring — `GET /data/:object` | In the body of `POST /data/:object/query` |
122+
|:---|:---|:---|
123+
| `-created_at`, or `created_at desc` — the shorthand string | `?sort=-created_at`, `?$orderby=-created_at` and `?orderBy=-created_at` — each of them sorts; they normalize to one parameter | `400 VALIDATION_FAILED` on every slot — `orderBy`, `$orderby` and `sort` alike |
124+
| `["-created_at"]` — an array of those shorthand strings | `400 INVALID_SORT` — this route parses no JSON here, so the whole value is read as one field name | `400 VALIDATION_FAILED` on every slot |
125+
| `{"created_at": "desc"}` — a field-to-direction map (`1` / `-1` are read as `asc` / `desc`) | `400 INVALID_SORT` — same reason | sorts on `$orderby` and on `sort`; on `orderBy` it is `400 VALIDATION_FAILED` |
126+
| `[{"field": "created_at", "order": "desc"}]` — `SortNode[]`, the canonical shape | `400 INVALID_SORT` — same reason | `{"orderBy": …}` sorts, and so do `$orderby` and `sort` |
127+
128+
So read the last column before copying a sort into a request body. The
129+
shorthand string is a querystring spelling and reaches no body slot at all; the
130+
map reaches `$orderby` and `sort` but never `orderBy`; `SortNode[]` is the one
131+
shape the canonical slot and its two transport aliases all accept.
132+
133+
Naming something that is not a field on the object is `400 INVALID_SORT` on
134+
either route — `?sort=no_such_field` and
135+
`{"orderBy": [{"field": "no_such_field", "order": "asc"}]}` alike. A body whose
136+
sort has the wrong *shape* never reaches that check: it is
137+
`400 VALIDATION_FAILED`, and the offending path is named in `fields`
138+
(`query.orderBy.0` for an entry that names no field, `query.orderBy.0.order`
139+
for a direction that is neither `asc` nor `desc`). The querystring has no body
140+
to validate first, so it reports that direction as `400 INVALID_SORT` —
141+
`?sort=name sideways`.
123142

124143
`GET /data/:object/:id` applies the same `select` and `expand` rules, so the
125144
list and single-record routes cannot disagree about one field map.

‎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)