|
| 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. |
0 commit comments