Commit ecdfc94
fix(triggers,spec,service-automation)!: a time-triggered flow declares its acting organization and the run executes as it (#17334)
* fix(triggers,spec,service-automation): a time-triggered flow declares its acting organization and runs as it
A `type: 'schedule'` flow and a `time_relative` sweep launch their runs from a
job tick, which carries no identity, so `AutomationContext.tenantId` was never
set. On an install holding more than one `sys_organization` every tenant-scoped
write beneath the run was then refused by the #8844 guard — the inbox rows a
`notify` node emits and the `sys_automation_run` history row — while the tick
still summarised itself healthy.
- `@objectstack/spec` declares the start-node `config.organization` key, its
value schema, the two kinds that owe it, the near-miss spellings an author
reaches for, and the one refusal sentence every enforcement point says.
- The engine lifts the declaration onto the schedule / time_relative binding.
- Both triggers REFUSE to bind a flow that declares none, naming it at `error`,
and thread the declared organization onto the run as `tenantId`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
* wip(qa): schedule acting-organization dogfood pins
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
* test(qa): pin the three #16659 consequences on both drivers, with two differential controls
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
* chore: changeset for the time-triggered acting-organization declaration
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
* chore(spec): regenerate artifacts and register the #16659 proof
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
* docs: the automation reference tree gained a page — update the quick-reference total
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
* test(qa): resolve @objectstack/trigger-schedule from source in the dogfood pin
The pin's subject is the trigger's own run-context construction, so a dist
merely behind would run it green against the old one — the exact shape this
card is about. check:test-source-alias and check:type-source-resolution both
name this as the required spelling.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
* fix(triggers,spec): the acting-organization refusal throws so the engine records it
`FlowTrigger.start()` is `void`, so logging the refusal and returning left the
engine free to run `boundFlowTriggers.set(...)` and log "bound" one line later:
`getFlowRuntimeStates()` answered `bound: true`, `getTriggerBindingAudit()`
skipped the flow, and the CLI startup summary said every triggered flow was
wired. Both time triggers now throw the same sentence they log, which is the
engine's designed catch path — the flow is never marked bound and the audit
lists it with `binding failed — see earlier warnings`.
Also folds the trigger's inline near-miss scan back into `packages/spec`
(`findScheduleOrganizationNearMissInConfig`, which takes the start-node config
a trigger actually holds), drops the two consumer-less exports
(`TIME_TRIGGERED_FLOW_KINDS`, `requiresScheduleOrganization`), makes the
near-miss vocabulary module-local, renames the module to
`schedule-organization.zod.ts` so its source ships and the docs generator gives
it a page, and corrects the two comments that claimed `FlowSchema` emits the
refusal sentence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
* test(triggers,spec): pin the acting-organization refusal at unit level
The fixture comment promised a refusal suite that did not exist. It exists now:
the throw itself (the F1 contract), the `error` line carrying the same sentence
the engine's audit points at, near-miss naming, the "hot re-publish removes the
key" stop() limb, per-flow isolation, the no-`error`-channel fallback, and every
limb of `resolveBindingOrganization`. The time-relative sweep gets the same two
load-bearing pins, and `packages/spec` gets the unit test every sibling module
in `src/automation` already had.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
* test(qa): pin the refusal on the structured surfaces, and make control B honest
Consequence (3)'s dogfood pin asserted only that the job service was never
asked. That says nothing about what the ENGINE recorded, which is where the
refusal was being lost: `getFlowRuntimeStates()` answered `bound: true` and
`getTriggerBindingAudit()` skipped the flow entirely. Both are now pinned, each
with the declaring flow as its paired control.
Control B's memory limb asserted `status < 300` under a message claiming it
pinned a 503 refusal — opposite polarity, so it certified nothing. It now states
plainly that the HTTP control is unavailable on this driver and pins the reason
at the seam that makes it so: a tenant-scoped read is refused with
MEMORY_MULTI_TENANT_UNSUPPORTED, so the exemption expires by itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
* docs(triggers,automation): the acting organization reaches every published surface
The trigger-schedule README (shipped in files[]) still showed both worked
examples without `organization` and said they auto-launch; after this change
those exact flows are refused. Both now declare the key, and the README states
the refusal and where it shows up.
flows.mdx gains a dedicated "The acting organization" section and both worked
schedule examples declare the key; hooks.mdx and capabilities.mdx point at it so
"needs `triggers`" is no longer the whole story.
The showcase digest's docstring stops promising that it fires: as a
package-shipped flow it has no legal organization to name, no placeholder may be
invented, and what such a flow should do instead is #17150's decision.
Drops the unrelated esbuild@0.28.1/0.28.2 swap from pnpm-lock.yaml; the diff is
now only the link: entry the new dev-dependency needs, and
`pnpm install --frozen-lockfile` leaves it byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
* chore(spec): regenerate for the .zod.ts rename and the narrowed export set
`schedule-organization` now has its own reference page instead of landing in
the "Misc (no single source file)" bucket, and api-surface / export-origins
drop the three consumer-less exports. The changeset's export list matches what
the barrel actually publishes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
* fix(spec): ADR-0122 alias state and the llms.txt inventory for the new module
Two gate findings the .zod.ts rename brought into scope, both real:
check:spec-parsed-alias only reads *.zod.ts, so `ScheduleOrganization` was
declared with z.infer where ADR-0122 reserves the bare name for the author
state; and llms.txt counts *.zod.ts modules, so the automation domain and the
total were one short.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
* test(spec): the ADR-0122 isomorphic-pin count moves 815 -> 816
`ScheduleOrganizationSchema` is a bare `z.string().min(1)` — no coercion, no
default, no transform — so input === infer and it takes a pin rather than a
permanent `ScheduleOrganizationParsed` synonym. The note records that the
module is not new, only its `.zod.ts` name is: this family of gates reads
`*.zod.ts` only, so the violation sat green behind an extension.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
* chore(spec): regenerate the docs reference index on the merged tree
Discharges the os-regen deferral recorded by the merge commit. `gen:schema`
then `gen:docs` on the merged tree: totals 198/1583 -> 199/1584, automation
13/73 -> 14/74, and the `schedule-organization.zod.ts` row. Every other
generated artifact re-derived byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
* fix(trigger-schedule)!: the time-relative sweep SELECTS inside its declared organization
The declaration reached the run and never the query. A sweep declared for org A
still matched rows in every tenant — `find` carried `context: { isSystem: true }`
and nothing else — and then launched a run stamped A about B's record: the run's
`update_record` matched nothing (silently, being scoped to A), `notify` posted
into A's inbox about B's record, and the history row was stamped from the
subject, landing under B. That is the cross-organization scheduled task the
ruling forbids, with a declaration papering over it.
The declared organization now rides the sweep's own query as
`context.tenantId`, the same ExecutionContext axis the run already uses:
`Engine.buildDriverOptions` turns it into `DriverOptions.tenantId` and the
driver scopes the read. ⛔ Not a hand-built `organization_id` predicate — that
would be a second implementation of tenancy inside a trigger, hardcoding a
renameable column, selecting nothing on a platform-global object, breaking a
federated one, and reading as scoped to a driver that never learned scoping was
wanted.
Elevation and tenancy stay independent: `isSystem` says what the sweep may see,
`tenantId` says whose rows they are.
Silence is closed on both new edges: a store that cannot honour the scope
refuses the call and the sweep now logs that at `error` (stderr survives the
CLI's boot-quiet window), and an object the engine exempts from scoping
(`tenancy.enabled: false`, `external`) gets a bind-time warning saying the
declaration cannot narrow this sweep. The bind line names the acting
organization so the sweep's reach is readable from the boot log.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
* test(trigger-schedule): pin the sweep's SELECTION scope, both directions
The existing pin read `expect(calls[0].context).toEqual({ isSystem: true })` —
it was pinning the defect, so it is replaced rather than relaxed: exact equality
against `{ isSystem: true, tenantId: <declared> }`, which reddens the moment the
sweep asks for no scope.
Four new pins:
- every window query carries the scope, not just the first (offset mode issues
one per offset), and `where` still holds only the date window, so no
organization predicate was hand-built onto it;
- DIFFERENTIAL — matching rows in TWO organizations, only the declared one's
record launches a run. The double implements the driver's documented scope
(`org = :tenant OR org IS NULL`) and, with no scope, no predicate at all, so
its "absent" arm reproduces the defect instead of hiding it;
- a store that cannot honour the scope (driver-memory's #16589 refusal) is
reported at `error` naming the flow, never answered unscoped;
- a `tenancy.enabled: false` object is disclosed at bind — the declaration
cannot narrow that sweep — with a paired control that an ordinary object
draws no such line.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
* test(dogfood): the sweep's SELECTION scope on the real driver stack
The differential control the branch never had: `sched_org_sweep`, a
`time_relative` flow declared for org A, with matching rows in BOTH A (one) and
B (two). The discriminating number is the count of LAUNCHED RUNS — 1 with the
fix, 3 on the defect — because a pin asserting only "A's row was touched"
passes on the defect too.
Three properties, three producers:
- selection: exactly one run, and it names A's record;
- the data plane: `update_record` lands on A's row and on no other (the half
the branch left unpinned — the earlier fixture flow was start -> notify ->
end, so nothing would have gone red if the run's narrowing were wrong);
- disclosure: no `sys_notification` names a B record. An unscoped sweep emits
that notification BEFORE its write silently matches nothing, so a fix that
narrowed only the writes would leave the leak open.
The memory limb answers the other question the card is about: a store with no
tenant isolation REFUSES the scoped sweep (#16589) and the failure is reported
at `error` naming the flow, with a paired control proving the refusal is about
the scope and not about the fixture (the same query unscoped still sees all
three rows).
The fixture object gains `due_date` as `datetime`, not `date`: the window is a
pair of ISO instants, and a column the driver truncates to YYYY-MM-DD would put
a per-driver truncation rule between the fixture and the property under test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
* feat(lint): authoring learns the acting-organization requirement
`validate-flow-trigger-readiness.ts` contained zero occurrences of
`organization`, against the invariant `engine.ts`'s own trigger-kind resolver
states: it is shared with `defineStack`'s trigger-capability refusal and with
this rule file, "so the runtime cannot drift from what authoring accepted".
Both time triggers refuse to bind a flow declaring no `config.organization`, so
`defineStack`, `os lint` and `verify_build` were all passing a flow the trigger
then refused — an author's first signal was a production stderr line at boot.
⛔ No judgement is re-implemented: `resolveFlowTriggerKind` says which flows owe
the key, `resolveScheduleOrganization` says whether one was declared (so a
present-but-unusable value is judged identically here and at bind), and
`describeMissingScheduleOrganization` writes the sentence.
Severity is `warning`, and that is MEASURED rather than argued. On the family's
own criterion (#5762 — is this stack enough to know the flow is dead?) it
belongs at `error`. Flipping it to `error` and building the shipped example app
was run: `objectstack build` on examples/app-showcase FAILS, naming
`showcase_task_due_reminder` and `showcase_scheduled_digest` — and neither can
be repaired by authoring, because the only legal value is a
`sys_organization.id` minted per install at runtime and a placeholder id is
strictly worse than the omission. Promoting the id is therefore a consequence
of the open maintainer decision about package-shipped time-triggered flows, not
a lint choice; the probe was restored byte-identically and the two example
builds pass with the rule at `warning`, flagging exactly the four shipped flows.
Fixture triage in the rule's own suite: every time-triggered fixture that is
ABOUT another rule now declares an organization, so it keeps isolating its own
subject; the fixtures that are about the missing key omit it deliberately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
* docs(changeset,examples): say what actually happens, and name the four flows that stop
The changeset asserted "Nothing that was already delivering stops delivering"
and, three paragraphs later, that on a single-organization install such a flow
"was working". Both cannot be true; the second one is, and
`system-write-organization.ts`'s `single`-posture derivation is why. The banner
now states the narrowing in two places instead of one — the bind-time accept set
AND the run-time data plane, which no sentence in it previously mentioned — and
carries the migration consequence an operator reads release notes for: a
deployment running ONE time-triggered flow across ALL organizations must now
declare one per organization.
Also made true rather than absolute: "there is no fallback limb anywhere on this
path" is now "nothing on this path ever CHOOSES an organization", with the
trigger's second read of the SAME declared value (engine-version skew) named
instead of denied.
F5 — the four shipped example flows that stop firing are documented where an
author meets them: `showcase_scheduled_digest`, `showcase_task_due_reminder`,
`task_reminder`, `overdue_escalation`. None can be repaired by authoring, and
⛔ a placeholder organization id must not be invented; what a package-shipped
time-triggered flow should do instead is an open maintainer decision. Its card
was destroyed with a suspended account and is being re-filed, so the dead
reference to it is replaced by the record itself rather than by a number this
seat does not have.
F8 — the `suspended-run-store` comment claiming "a plain scheduled sweep has
neither and keeps NULL" was made false by this very change and now says what
happens, keeping the SUBJECT-first precedence it documents.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
* docs(automation): the declaration bounds the sweep's query, not only its run
The flows guide said the run "executes as that organization" and stopped there,
which is now only half of what a `timeRelative` declaration does. It also bounds
the SELECTION: a sweep runs elevated on purpose, so nothing else keeps it inside
one organization, and elevation and tenancy are independent axes.
Both cases where a declaration cannot deliver containment are stated rather than
left to be discovered — a store with no isolation refuses the scoped sweep, and
a platform-global or federated object gets no scope from the engine at all — and
so is the new authoring-time warning.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
* test(trigger-schedule): the NEGATIVE CONTROL — a single-organization install loses nothing
The claim "a single-organization install behaves byte-identically" was an
argument, not a measurement. It is now a measurement: two legs over one
fixture, the sweep as it ships (scoped) and the same query with `isSystem` only
(the pre-fix sweep, byte for byte), asserted EQUAL. The fixture carries a
NULL-organization row on purpose — the driver's scope is
`org = :tenant OR org IS NULL`, so a platform row stays visible to a scoped
read, and that is part of "identical" rather than an exception to it.
Non-vacuous in both directions: the selected set is asserted to be the three
rows (an empty answer would satisfy the equality with everything broken), and
leg A is asserted to have really asked for a scope — which is what makes this
pin red under the same ablation as the differential, instead of a pin that can
never fail.
Its opposite limb is the DIFFERENTIAL above it: add a second organization and
these two legs must diverge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
* fix(spec,lint,triggers): register the ADR-0087 semantic TODO, fold one export, and correct the banner
Regenerates the whole generated chain on the merged tree, discharging the
os-regen deferral the merge commit recorded.
M1 — ADR-0087 disposition corrected from `not-required
(no-migration-prescription)` to `registered
schedule-flow-acting-organization-required`. D3 covers exactly this case: a
migration that cannot be expressed declaratively gets a structured TODO, not
silence, and this changeset's body IS a prescription ("declare
`config.organization` once per organization, no fan-out"). Direct precedent:
`rest-requireauth-default-flip` (protocol 12) — behaviour-only, no shape moved,
registered anyway. Filed under protocol 18, not 17: v17.0.0 was cut before this
narrowing landed, so the enforcement rides the 17.x line by the launch-window
convention while the prescription belongs at the major boundary where
`migrate meta` users look (`registry.ts`, `step18`).
M2 — the banner's single-organization sentence was false on `driver-memory`.
`assertCallNotTenantScoped` refuses EVERY door handed a `tenantId` regardless
of how many organizations the install holds, so a time-triggered flow that
touches per-organization data on that driver is refused per call when it
declares one and unarmed at boot when it does not.
A1 — `findScheduleOrganizationNearMissInConfig` is no longer published. Both
callers ran it only to hand the answer straight to
`describeMissingScheduleOrganization` on the next line, so the scan moved
inside that function (`{ kind, config }`) and the published surface is five
names, not six. A `minor` freezes what it publishes.
A2 — the name is KEPT, decided by measurement: `FlowSchema.type` is
`z.enum(['autolaunched', 'record_change', 'schedule', 'screen', 'api'])` with no
`time_relative` member, and a time-relative sweep is authored as
`type: 'schedule'`. Recorded in the module docblock so it is not re-litigated.
A4/A5/A6 — three effects of the prescribed split, each verified against the
tree and now named in the banner and in the migration entry: NULL-tenant rows
fan out once per flow under `org = :tenant OR org IS NULL`; dispatch-claim keys
embed the flow name so the current window's claims are abandoned; a run
suspended before the upgrade rehydrates from `context_json` and resumes
org-less.
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Co-authored-by: Claude <noreply@anthropic.com>
* fix(changeset): the `adr-0087: registered` marker takes bare ids, not prose
The marker's remainder is parsed as a comma-separated id LIST, so the
rationale that rode inside it read as 293 nonexistent migration ids and the
gate refused. The reasoning moves into the body prose, where release notes
read it anyway.
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Co-authored-by: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent fd62a66 commit ecdfc94
44 files changed
Lines changed: 3315 additions & 53 deletions
File tree
- .changeset
- content/docs
- automation
- getting-started
- permissions
- references
- automation
- examples
- app-showcase/src/automation/flows
- app-todo/src/flows
- packages
- lint/src
- qa/dogfood
- test
- fixtures
- services/service-automation/src
- spec
- api-surface
- declaration-map
- export-origins
- json-schema.manifest
- scripts/liveness
- src
- automation
- migrations
- entries/semantic
- triggers/trigger-schedule
- src
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
0 commit comments