fix(triggers,spec,service-automation)!: a time-triggered flow declares its acting organization and the run executes as it - #17334
Conversation
… 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
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
… differential controls Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…reference total Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…gfood 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
…ine 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
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
…l 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
…ished 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
…t 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
…w 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
`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
…anization-r2 os-regen artifacts taken from main where both sides moved them (content/docs/references/index.mdx); the branch's own generated entries kept. The one content conflict, packages/spec/src/type-alias-convention.pin.test.ts, is a semantic merge of two stacking intents: main's -4 (ui#6206-B filter orthography converting four pins to XParsed pairs, 815 -> 811) and this branch's +1 (Iso871, ScheduleOrganizationSchema). Merged truth 812, recomputed from the file rather than arithmetic. Regeneration follows in its own commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
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
📓 Docs Drift CheckThis PR changes 5 package(s): 26 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e59beff040bec2069e82a7470b5028335ccd6941 && git checkout e59beff040bec2069e82a7470b5028335ccd6941
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 31064ca0ef30ed4c03482183f96baba11c8d0a76 18c0f6b44bb9ef31cacb0c3af02d65eb1093ab99 && git checkout -B drift-repro 31064ca0ef30ed4c03482183f96baba11c8d0a76 && git merge --no-ff 18c0f6b44bb9ef31cacb0c3af02d65eb1093ab99
node scripts/docs-audit/affected-docs.mjs --json 31064ca0ef30ed4c03482183f96baba11c8d0a76
|
…clared 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
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
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
`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
…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
|
| CI, newest run per check NAME | 35 names — 31 success, 4 skipped, 0 failure, 0 pending |
| combined commit status | success (Vercel) |
mergeable_state |
⛔ dirty — the branch conflicts with main, which moved e669e5ee → d57611df |
| draft | true |
needs:contract-review |
on the PR and on card #16659 |
⭐ Note the two populations disagree in the useful direction: every gate is green, and the thing stopping it is a merge conflict, which no check run reports. mergeable_state reads both.
What now blocks it — two items, neither of them a defect
-
The conflict.
mainhas moved 12+ commits since this head's base. ⛔ Needs a merge, ⛔ not a rebase (this is a shared branch and other work references it).⚠️ This repo routes some paths to amerge=os-regendriver in.gitattributesthat can exit 0 while silently dropping one side — derive the risk list from.gitattributesat merge time, ⛔ not from any previous incident, and prove per-path preservation with a lit and a dark control. This branch's diff includespackages/spec/api-surface/**,export-origins/**anddeclaration-map/**, which is exactly the family that lost a side silently on another branch today. -
⛔ No readable
Clause-②declaration in the PR body. Measured: not one line in the body beginsClause-②:. TheCheck Changesetgate requires the declaration to start a line (a-,>or**prefix is tolerated; a⇒prefix or surrounding backticks make it a near miss).⚠️ Three PRs hit exactly this today. It clears from the body alone — theeditedtrigger re-reads it with no push and no re-run.
automation — SCHEDULE_ORGANIZATION_KEY, ScheduleOrganizationSchema, ScheduleOrganization, resolveScheduleOrganization, findScheduleOrganizationNearMissInConfig, describeMissingScheduleOrganization — visible in the api-surface, export-origins and declaration-map deltas. ⇒ the declaration is yes, and with yes the level rule applies (at least one moved package graded minor — the changeset already grades four packages minor, so that half is satisfied).
What this seat is doing
- A
CONTRACT_REVIEW_TIERcontract review is commissioned now. ⛔ My standing red line is that a clause-② PR does not enqueue without an at-tier PASS on record for the head that lands.⚠️ Because the conflict resolution will move the head, the verdict will be re-taken on the final head before any enqueue — this first pass exists so its findings can be fixed in the same round as the conflict, rather than costing a second cycle. - The conflict + the declaration line are queued as one dev round, at the head of this seat's queue.
⚠️ The dev cap is 3 and all three slots are occupied by this lane's own cards; ⛔ this seat is not exceeding a cap the maintainer set twice. It goes out on the next free slot, ⛔ and it will be fenced to only the merge and the body line — nothing about this PR's substance is this seat's to change.
⛔ Not governed
check-governed-merges --test over the full 42-path diff will be re-run on the final file list before landing. On the current list, none of the five governed surfaces (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md) appears. ⇒ ordinary merge-queue landing applies, ⛔ not a hand merge — so "跟进到合并" here means: green, reviewed, undrafted, enqueued, and verified landed by content on origin/main.
Generated by Claude Code
Contract review @
|
…ollows) Two non-generated conflicts resolved by semantic merge, both intents stacked: packages/spec/llms.txt — the domain inventory. base 205, this branch +1 (automation 13 -> 14), main -11 (`cloud` retired) +4 (`marketplace` arrived). 205 + 1 - 11 + 4 = 199, and the merged table sums to 199. packages/spec/src/type-alias-convention.pin.test.ts — 811 - 30 + 1 + 1 = 783 pins, recomputed from the merged file rather than from either side's arithmetic. The text merge ALSO took both sides' new pin with no conflict marker and left two declarations sharing the id `Iso871`; this branch's is renumbered to `Iso872`. content/docs/references/index.mdx is the one os-regen path both sides moved: the driver exited 0 keeping ours and dropping main's `marketplace` section. main's side restored here; the regeneration follows. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
…e 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>
… 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>
Contract review @
|
| reading | result | lit | dark |
|---|---|---|---|
| routed paths moved, base→branch / base→main | 8 / 46, intersection exactly 1: content/docs/references/index.mdx |
— | — |
the 45 main-only routed paths at HEAD vs 93447bbad |
git diff --name-only = empty (0) |
same over the 7 branch-only paths lists 7 | fabricated api-surface/zzz-fabricated.json = 0 |
contested index.mdx at HEAD |
schedule-organization=2, Marketplace Protocol=2, Cloud Protocol=0 |
Automation Protocol=2 |
Zzz Fabricated Protocol=0 |
| the same four markers at P1 / P2 | branch: 2/0/2 · main: 0/2/0 — HEAD is the union | ||
| the arithmetic | **Total** | **192** at index.mdx:36 |
One observation, not a finding: at the committed merge f23723f564 the contested file reads branch row = 0, Marketplace = 2 — i.e. the committed merge carried main's side wholesale (its message says so: "os-regen artifacts taken from main; regeneration follows") and the branch row came back through regeneration in 323da7a30d. The body's narrative describes the driver's pre-commit output dropping main's side; I cannot verify that from the commits and do not need to — the landing head carries both sides, which is the claim that matters.
Non-generated conflicts:
packages/spec/llms.txt—## 3. Schema Inventory by Domain (199 schemas); the table rows sum 33+31+30+30+18+14+14+11+5+4+4+3+1+1 = 199 ✓.type-alias-convention.pin.test.ts—grep -oP 'export type Iso\d+' | sort | uniq -d= 0 duplicates;Iso871= main'sEnvironmentTypeSchema(:388),Iso872= this branch'sScheduleOrganizationSchema(:637). Its own predicate: "still declares all 783 isomorphic pins" passes (vitest,--project local). ⭐ Because a duplicate identifier is a type error vitest never sees, I also rantsc --noEmiton@objectstack/spec: exit 0, zeroerror TS.
2. Clause ② — yes, and the delta is exactly what the body claims
git diff origin/main...HEADunderapi-surface/ export-origins/ declaration-map/ api-surface-signatures.jsontouches only the threeautomation.jsonfiles.api-surface/automation.jsondelta: +5 rows, −0 —SCHEDULE_ORGANIZATION_KEY (const),ScheduleOrganization (type),ScheduleOrganizationSchema (const),describeMissingScheduleOrganization (function),resolveScheduleOrganization (function).export-origins: the same five, all toschedule-organization.zod.ts.declaration-map: +2 (the two type-bearing names — that map carries declarations, not consts/functions, so 2 is the right count there, not a discrepancy).findScheduleOrganizationNearMissInConfig: 0 in all three surfaces and 0 undercontent/; the one remaining occurrence is the now-unexportedfunctionatschedule-organization.zod.ts:193(lit:describeMissingScheduleOrganization= 1 in api-surface; dark:ZzzFabricatedSymbol= 0).@objectstack/lint:FLOW_SCHEDULE_ORGANIZATION_MISSINGre-exported atpackages/lint/src/index.ts:123.node scripts/pm/check-clause2-carriers.mjs --pair 17334— exit 0, declaration readable in the fixed spelling, both carriers agree.- Level:
minoron all four packages; the!narrowing ridesminorunder the launch-window convention the changeset itself names, and the ADR-0087 disposition is the carrier. ⇒ five + one published symbols,minor, declaredyes— correct.
3. M1 — protocol 18 is right, and the entry is a real D3 prescription
registry.ts:5062(thestep18doc header) states the convention verbatim — "v17.0.0 was cut before these narrowings landed, so their migration prescriptions belong to the NEXT major";@objectstack/specis at 17.4.0; the sibling18.cloud-subpath-retired.tssits in the same directory. The dispatch's "17" was wrong; the tree is the authority. 18.- The entry (
18.schedule-flow-acting-organization-required.ts) carries all four D3 limbs with content, not placeholders:surfacenames the start-node key and both trigger kinds;replacementis the actionable prescription (declare per organization, no fan-out, no fallback) plus the three split consequences;reasonquotes the ruling untranslated and states why no conversion is possible;acceptanceCriteriais checkable (lint id absent, noNOT BOUNDline,bound: true, audit empty). - Wired:
registry.ts:9393insidestep18(5092–10457);check:migration-registryexit 0 — "registry.ts is current (198 semantic …)". node scripts/check-adr-0087-registration.mjs --base origin/main, run post-commit in the worktree: exit 0,registered schedule-flow-acting-organization-required (new here: …).- The marker fix in
18c0f6b44bis real: at323da7a30dthe<!-- adr-0087: registered … -->remainder was a paragraph of prose; at HEAD it is the bare id.
4. M2 — the banner now tells the truth, and stopping at the fence was right
memory-tenancy-guard.ts:313—if (tenantId === undefined && tenantIds.length === 0) return;and a throw otherwise; no organization-count limb.assertCallNotTenantScoped(is called from 14 lines ofmemory-driver.ts. The banner's driver-conditional sentence names exactly this guard and this code and no longer claims a single-organization install "loses nothing".- The functional gap is real on the tree as read. It is a design decision (exempt time-triggered flows on a store without isolation, or declare
driver-memoryunsupported for them) and outside the fence; the round named it in the banner and the migration entry'sacceptanceCriteriarather than implying it away. Right call. It is now tracked as On driver-memory a time-triggered flow that touches per-organization data has NO legal configuration — PR #17334 moves it from the served case into the refused one #17446 (filed 14:51Z — see ADVISORY A-2).
5. A1 — the fold is a pure move
323da7a30d diff: export function → function on the finder; describeMissingScheduleOrganization(flowName, { kind, nearMiss }) → { kind, config }, computing the near-miss inside. Both callers pass the same value they previously fed the finder (binding.config at schedule-trigger.ts:333; config at validate-flow-trigger-readiness.ts:733). The near-miss vocabulary is still exercised through the sentence: schedule-organization.test.ts describes "the near-miss scan, through the sentence that owns it" (10 near-miss spelling hits), lint suite 3 hits; no test anywhere references the folded name (0 files).
6. A2 — the measurement holds
flow.zod.ts:729: type: z.enum(['autolaunched', 'record_change', 'schedule', 'screen', 'api']); time_relative reads 0 in that file. content/docs/automation/flows.mdx:1926,1966 set type: 'schedule'. ADR corpus: time_relative|timeRelative = 0 files (lit \bschedule\b = 18 files, dark zzzFabricatedTerm = 0). ⇒ every flow the key applies to is authored type: 'schedule'; keeping ScheduleOrganization… is the measured outcome, recorded in the docblock and the reference page.
7. A4 / A5 / A6 — each anchored to a line
- A4
sql-driver.ts:12911—(field = :tenantId OR field IS NULL)with.orWhereNull(field)above it; fixturetime-relative-trigger.test.ts:937has{ id: 'c3', …, organization_id: null }and:965asserts['c1', 'c2', 'c3']under scope. The N-fold fan-out follows and is now item (1) in the banner and the entry. - A5
schedule-trigger.ts:225schedule:${flowName}:${window.startedAt};time-relative-trigger.ts:524time-relative:${flowName}:${scope}:${id}. Item (2). - A6
suspended-run-store.ts:930parseJson(row.context_json, {}), nothing back-fills. Item (3).
8. What the delta could have broken — suites on the built worktree
| run | result |
|---|---|
@objectstack/trigger-schedule test |
exit 0 — 5 files / 105 passed |
@objectstack/lint test |
exit 0 — 103 files / 3724 passed |
@objectstack/spec pin + schedule-organization tests |
exit 0 — 31 passed |
@objectstack/spec full suite |
468/470 files, 13229 / 13238 passed — the 9 failures are in scripts/openapi-self-consistency.test.ts and scripts/def-key-collisions.test.ts, both untouched by this PR (diff = 0; lit: the pin test = 1), both spawning npx tsx from a temp dir that resolved to the absent /tmp/node_modules/… because my worktree is /tmp-rooted. ⇒ NOT MEASURED here (prerequisite), not red; the round reports 13238 at this head and CI is the authority |
tsc --noEmit — spec / lint / trigger-schedule |
exit 0 all three |
pnpm --filter @objectstack/spec check:generated |
exit 0 — 15 of 15 current. api-surface/ stale (exit 1); check-generated.ts:113 marks that gate readsDist: true, so that was a prerequisite miss, and it cleared on the built tree |
check:migration-registry |
exit 0 |
os-verify-lock --status before the heavy runs |
exit 0, lock free |
Findings
MUST-FIX — none.
ADVISORY A-1 — the merge narrative vs the committed merge. The body says the driver's output carried the branch row and lost main's Marketplace section; the committed f23723f564 carries main's side and not the branch row. Both are consistent with the commit message ("taken from main; regeneration follows"), and the landing head is correct. Worth one sentence in the body so the next reader of this merge does not see a contradiction — no code change.
ADVISORY A-2 — #17446 is not named by the artifacts that describe its gap. The driver-memory functional gap now has a card (#17446, 14:51Z), filed after 18c0f6b44b (last changeset edit). The banner and the migration entry's acceptanceCriteria describe the gap but reference only #16589. Not this round's to fix and not blocking; the seat may want to put the number where the gap is described, or leave it to the card.
ADVISORY A-3 — declaration-map carries 2 of the 5 names. By design (it maps type declarations to pages), so this is not a surface discrepancy — recorded so a future count against that file is not misread as a missing export.
Reading of the round's own report
Every number I re-derived matched: 8/46/1 routed paths, 45 byte-identical, 192, 199, 783, 0 duplicate ids, 5 rows, check:generated 15/15 on a built tree, 105 / 3724. The one difference (spec full suite 13229 vs 13238) is my environment, shown above with its cause and controls.
⛔ This comment changes no label, flips nothing ready, enqueues nothing — those are the seat's acts.
Generated by Claude Code
ACCEPT — at-tier PASS on the head that lands, gate cleared on both carriers, undrafted and enqueued
The four landing pre-checks, on head
|
| # | pre-check | reading |
|---|---|---|
| ① | at-tier verdict for the head that lands | PASS, zero must-fix, three advisories — 5620983965 |
| ② | check-clause2-carriers --pair 17334 |
exit 0, both carriers agree — re-run after the clear, still 0 |
| ③ | check-governed-merges --test over the 44 final paths |
0 governed · lit control AGENTS.md exit 3 ⇒ the instrument fires |
| ④ | CI newest-run-per-check-NAME plus the combined commit status | 41 runs, all completed: 0 failure, 0 pending · combined status success |
⇒ ③ settles the merge route: ⛔ not governed, so this is an ordinary queue landing and ⛔ not a hand merge.
The verdict was RE-TAKEN, not inherited
The earlier review (5619271717) returned PASS WITH FINDINGS on 5fb332bdc. That head then moved a long way — a full origin/main merge, M1, M2, the A1 fold, and the Iso872 renumber — so its verdict did not transfer. ⭐ A verdict on a head that no longer exists is not a verdict on this one. The re-review measured the delta in its own worktree at 18c0f6b44b.
What it independently re-derived rather than accepting: the risk list from .gitattributes at the merge commit (18 patterns); 8 branch / 46 main / exactly 1 contested path; the 45 main-only routed paths byte-identical to main-at-merge (0 diffs, lit 7, dark 0); the contested file carrying both sides and totalling 192; llms.txt summing to 199; the pin test at 783 with 0 duplicate Iso ids and tsc --noEmit exit 0 — ⭐ that last one matters because the duplicate-identifier failure mode is a type error vitest cannot see, so the suite passing would not have caught it; the api-surface delta at exactly +5 rows with the folded symbol reading 0 everywhere; and protocol 18 confirmed from registry.ts:5062's own header plus the sibling 18.cloud-subpath-retired.
⭐ It also refused to report a red it could not attribute: the spec suite showed 9 failures in two files this PR does not touch, traced to its own /tmp-rooted worktree resolving npx tsx against an absent /tmp/node_modules — reported as NOT MEASURED, not as a failure of this PR.
One advisory worth reading, since it corrects this PR's own narrative
The body says the merge=os-regen driver dropped main's side on content/docs/references/index.mdx and that step 2 restored it. The reviewer measured the committed merge and found the opposite pre-commit state — the commit carried main's side and had lost the branch row, which regeneration then restored. ⇒ ⭐ The outcome is correct either way (the file at HEAD carries both sides, verified), and the operational lesson is unchanged and still the important one: the driver exited 0 while a side was missing, with no conflict marker. Only the direction in the narrative is backwards. Recorded here rather than by editing the body — a REST PATCH /pulls appends a second attribution footer.
The other two advisories: #17446 is not yet cited at the place in the changeset where the driver-memory gap is described, and the declaration-map delta is +2 (types only) rather than +5, which is by design.
M2 — the gap is filed, and the dedup changed where it goes
The functional gap M2 uncovered — on driver-memory a time-triggered flow touching per-organization data has no legal configuration — is filed as #17446. ⭐ ⛔ It did not go into the decision box: a dedup search found #5499, the maintainer's standing 2026-08-05 ruling freezing the driver-memory / driver-mongodb family, whose direction is replacement by sqlite :memory: and whose standing triage rule sends new cards of that family to pm:on-hold referencing it, ⛔ not to pm:queue. The round's recommended option A is that ruling, for the whole component. I also checked #5499's exception-escalation channel (a defect affecting CI's green correctness overrides the freeze) — ⛔ it does not apply: this PR's own dogfood test runs the memory leg and asserts the refusal, so the semantics are pinned, no false green.
Both gate carriers cleared in ONE stroke, deliberately
needs:contract-review removed from card #16659 and from this PR, seconds apart, each read back. ⭐ Two removals close together is what a legitimate clear looks like in the event stream; a lone removal is indistinguishable from a strip, and 「闸门标签被剥不是红灯是放行」. I broke exactly this on #16929 earlier today by writing a whole label set, so both writes here were four-step: read the current set → remove only the target → write the union → read back the diff. Card #16659 read back as bug, priority:p1, pm:dispatched, domain:services — nothing else moved, and its domain:services lane label is untouched: ⛔ this seat took the card by handover, it does not re-lane it.
Undrafted and auto-merge armed. The merge queue decides the method; ⛔ this seat does not merge its own PR and does not bypass the queue.
Generated by Claude Code
Fixes #16659
Clause-②: yes —packages/spec/src/automation/index.tsgains re-export lines and the generated surface gains five rows:SCHEDULE_ORGANIZATION_KEY,ScheduleOrganizationSchema, theScheduleOrganizationtype,resolveScheduleOrganizationanddescribeMissingScheduleOrganization. New exported symbols on a published entry point. One more published symbol lands on a different package:@objectstack/lintre-exportsFLOW_SCHEDULE_ORGANIZATION_MISSING.findScheduleOrganizationNearMissInConfigis folded into the sentence that was its only consumer (A1 below), so the count above is the one that freezes.⭐ Round 3 (
domain:specseat) — the merge, the two must-fix items, and the advisoriesWritten from session
session_01MkQhmuuJAVDjmeWNixwDDH; the contract review this rounddischarges is comment
5619271717on this PR, and the card's liveClaim:is5619338692. ⛔ Fenced to the merge and that review's findings — nothing about the designis this round's to change, and F7 plus the near-miss vocabulary gap stay out.
1. The merge — per-path preservation, proved
mergeable_statewasdirty. Merged (⛔ never rebased) throughscripts/pm/os-regen-merge.sh, with the risk list read from.gitattributesat mergetime: 18 routed patterns. Against merge base
e669e5eee, 8 routed paths were movedby this branch and 46 by
main— and exactly one by both:content/docs/references/index.mdx. That is the only path where themerge=os-regendriver could drop a side, and it did: after the merge the file carried this branch's
schedule-organizationrow and had silently lost main'smarketplacesection, exit 0,no conflict marker. Step 2 restored main's side; step 4 regenerated the whole chain on the
merged tree.
The proof, per routed path, each reading paired with a lit control (a path or marker
known to have moved, reading non-zero) and a dark control (a fabricated path or marker,
reading zero):
schedule-organization= 1 · main sectionMarketplace Protocol= 2 · main's removalCloud Protocol= 0Automation Protocol= 2Zzz Fabricated Protocol= 0origin/main—git diff --name-only origin/mainover that set is emptyapi-surface/automation.jsonlists itapi-surface/zzz-fabricated.jsonlists 0ScheduleOrganization/schedule-organizationall non-zero)api-surface/api.jsonreads 0 for the same markerZzzFabricatedSymbolinapi-surface/automation.jsonreads 0Both intents stack arithmetically in the regenerated index: 198 pages + 1 (this branch) − 11
(
cloudretired on main) + 4 (marketplacearrived) = 192, which is what the file says.Two non-generated conflicts were hand-resolved as semantic merges, both intents stacked:
packages/spec/llms.txt— 205 + 1 − 11 + 4 = 199, and the merged table sums to 199.packages/spec/src/type-alias-convention.pin.test.ts— 811 − 30 + 1 + 1 = 783,recomputed from the merged file by the test's own predicate rather than from either
side's arithmetic.
marker, leaving two declarations sharing the id
Iso871(main'sEnvironmentTypeSchemaand this branch's
ScheduleOrganizationSchema) — a duplicate-identifier error behind amerge that read clean. This branch's is renumbered
Iso872; ids are claims about pins,not positions.
2. M1 — the ADR-0087 disposition was wrong on substance. Fixed.
not-required (no-migration-prescription)→registered schedule-flow-acting-organization-required, one new entry underpackages/spec/src/migrations/entries/semantic/,gen:migration-registryrun. D3 coversexactly this case, and the changeset's body is a prescription in D3's sense. Direct
precedent:
rest-requireauth-default-flip(protocol 12) — behaviour-only, no shape moved,a deployment judgement no transform can make, registered anyway.
otherwise.
registry.ts'sstep18header states the convention in as many words:"v17.0.0 was cut before these narrowings landed, so their migration prescriptions belong
to the NEXT major … The enforcement itself ships earlier on the 17.x line (launch-window
convention: accept-set narrowings ride minor releases); this step is where
migrate metausers are told, at the major boundary where they look." The same-shaped sibling
18.cloud-subpath-retired(#16325) is filed there too.node scripts/check-adr-0087-registration.mjs --base origin/main, run post-commitbecause the gate reads changesets from git: exit 0, and it names the entry as new
here — i.e. registered by this PR, not claimed against a pre-existing one.
adr-0087: registeredmarker's remainder isparsed as a comma-separated id list, not as prose. Rationale that rides inside it reads
as several hundred nonexistent migration ids. The reasoning lives in the body instead.
3. M2 — the banner's single-organization sentence was false on
driver-memory. Fixed, and it is NOT only a sentence.Verified by running the driver's own exported predicate rather than a re-implementation
of it (
assertCallNotTenantScoped,packages/drivers/driver-memory/src/memory-tenancy-guard.ts),over all twelve doors it guards:
tenantIdpresent (the shape a DECLARED flow produces)MEMORY_MULTI_TENANT_UNSUPPORTEDtenantId(the shape a job tick produced before this change)The guard has no organization-count limb at all:
if (tenantId === undefined && tenantIds.length === 0) return;and a throw otherwise. So a single-organization install is not exempt, and the banner's
"That install loses nothing at run time" was false there. The sentence is now
driver-conditional and names the refusal.
⛔ And the seat's question, answered: it is not only a sentence. On
driver-memoryatime-triggered flow that touches genuinely per-organization data has no legal
configuration — declare an organization and every data node is refused per call; declare
none and the flow is not armed. The one configuration that is served, declaring the swept
objects platform-global (
tenancy: { enabled: false }, ADR-0066), is the one the guard'sown message says must not be reached for on data that really is per-organization. That is a
functional gap on a supported driver wearing a documentation defect's clothes. ⛔ This round
does not fix it — it is out of the fence, and the remedy is a design decision (does a
time-triggered flow on a store with no tenant isolation get an explicit exemption, or is
driver-memorydeclared unsupported for time-triggered data flows?). Reported and stoppedat that boundary; the banner and the migration entry both now say the gap exists rather
than implying it does not.
4. A1 — one export folded. Five, not six.
findScheduleOrganizationNearMissInConfigis no longer published. Both callers(
validate-flow-trigger-readiness.ts,schedule-trigger.ts) ran it only to hand the answerstraight to
describeMissingScheduleOrganizationon the next line, so the scan moved insidethat function, which now takes
describeMissingScheduleOrganization(flowName, { kind, config })and consults the vocabulary itself.
api-surface/export-origins/declaration-mapandthe reference page regenerated; the symbol reads 0 across all four generated surfaces,
with
describeMissingScheduleOrganizationstill present in two of them as the lit control.⭐ A
minorfreezes what it publishes, and removing an export later is breaking where addingone is not.
5. A2 — the name is KEPT, and the decision is a measurement, not a preference.
The question was whether
time_relativeis described as a "schedule" anywhereauthoritative. It is, in the place that governs authoring:
FlowSchema.typeisz.enum(['autolaunched', 'record_change', 'schedule', 'screen', 'api'])— there is no
time_relativeflow type. A time-relative sweep is authored astype: 'schedule'; the docs say so in as many words ("ascheduleflow whosestartnode declares a
timeRelativedescriptor") and therenewalReminderexample incontent/docs/automation/flows.mdxliterally setstype: 'schedule'.TimeRelativeTriggerSchema's own opening line: "Atime_relativetrigger sweeps anobject on a schedule (daily by default)".
resolveFlowTriggerKind's precedence note: a sweep "ALSO carries aschedulecadence(its sweep interval)", and it distinguishes a sweep from "a plain schedule flow" — a
split inside the schedule family, not out of it.
time_relative/timeRelativeacrossdocs/adr/*.md(lit control:
schedulematches many ADRs; dark control: a fabricated term matches none).The ADRs say nothing either way, so the enum and the docs are the authority.
⇒ Every flow this key applies to declares
type: 'schedule'.ScheduleOrganization…isaccurate for both subjects and is kept;
FlowActingOrganization…would have been a renameaway from the authorable vocabulary. Recorded in the module docblock so it is not
re-litigated after the freeze.
6. A4 / A5 / A6 — all three verified, all three now named
Each was checked against the tree before being written down, and each is in the banner
and in the migration entry's
replacement:sql-driver.tsscopes with(field = :tenantId OR field IS NULL), and this PR's own negative-control fixtureselects its
organization_id: nullrow (c3) under scope — the asserted set is['c1','c2','c3']. ⇒ after "one flow per organization", every such row is matched onceper flow: N runs, N notifications, each acting as a different organization. Survives.
scheduleDispatchKeyreturns`schedule:${flowName}:${window.startedAt}`and thesweep's key is
`time-relative:${flowName}:${scope}:${id}`. Both embed the flow name,so N differently-named flows claim under N different keys. Survives.
suspended-run-store.tsrehydrates withparseJson(row.context_json, {}); a row written before this change carries notenantId,and nothing back-fills it. Survives (not a regression — it is how the run already ran —
but the banner implied confinement without the carve-out).
7. Verification at head
18c0f6b44b@objectstack/spectesttype-alias-convention.pin.test.ts› "still declares all 783 isomorphic pins"@objectstack/linttest@objectstack/trigger-scheduletestpnpm --filter @objectstack/spec check:generatedcheck:react-declaration-parityMANIFEST=$PWD/sdui.manifest.jsoninvocation — measured, ⛔ not recorded as unrunnablepnpm lint(repo-wideeslint . --no-inline-config)check-clause2-carriers --pair 17334Gates.
dispatch-gatesderives 119 families for this change set; all 119 were runwith exit codes recorded and reconciled through
--ran: 117 run / 2 NOT MEASURED / 0UNRUN. The two are
check:dual-build-cjs-loadsandcheck:type-check-debt, both atexit 3 — PREREQUISITE NOT MET (each reads a complete repo build). ⛔ Neither is a pass
and neither is a finding; both are hosted by CI, which builds first.
check:skill-examplesrefused the same way on the first attempt and was then genuinelymeasured after building
@objectstack/client-react: exit 0, 258 prose examples across 3surfaces.
origin/mainmoved again while this round ran, sodispatch-gatesprints a STALETREE notice against the newest tip. The derivation above is against the tree at
18c0f6b44b; CI re-derives on the head it tests.⛔ Still owed, and not this round's to take: the contract-review verdict must be re-taken
on the head that lands — this round moved it — and
needs:contract-reviewstays on bothcarriers until the seat clears it.
is being re-filedsource comments(
examples/app-showcase/src/automation/flows/index.ts,examples/app-todo/src/flows/task.flow.ts) still carry no number. The backfill was fencedas opportunistic — only if you are touching those files anyway — and this round touches
neither. The changeset's copy of the sentence is updated to name #17396, because
the changeset was already being edited.
The ruling
Maintainer, 2026-09-08, verbatim: 「多组织定时任务本来只能在组织内运行,应该带组织ID,不允许跨组织的定时任务。」
⭐ This round: the ruling's other half
The first head declared the acting organization and put it on the RUN. The adversarial review on #16659 (comment
5615359989) found the complementary half missing, and the maintainer ruled option A — complete the ruling. Thetime_relativesweep was still selecting across every organization:isSystemis authorization;tenantIdis tenancy. Passing only the first made this a cross-organization scheduled task with a declaration papering over it: a sweep declared for org A matched rows in org B and launched one run per match, each stamped A. Downstream that is worse than the defect the card opened on:update_recordon a B row matches nothing — silently;notifyposts into A's inbox about B's record — a cross-tenant disclosure that was previously refused outright, because an org-less run could write nowhere;The fix, and why this mechanism and not a predicate
The declared organization now rides the sweep's own query as
context.tenantId— the sameExecutionContextaxis the run already uses.Engine.buildDriverOptionsturns it intoDriverOptions.tenantIdand the driver scopes the read. Both members are keysEngineQueryOptions.contextalready accepts (ExecutionContextSchema.partial()), so namingtenantIdon the trigger's structural data-engine slice widens no contract; it declares the slice it uses.⛔ Not a hand-built
organization_idpredicate onwhere. That would be a second implementation of tenancy inside a trigger: it would hardcode a column the object is free to rename (tenancy.tenantField), select nothing on a platform-global object that has no such column, break a federated object outright, and — worst — read as a scoped query to a driver that never learned the caller wanted scoping, so a driver with no isolation would answer it silently instead of refusing it.Both new silences are closed, because that failure mode is this card's own subject
driver-memoryimplements no row-level tenant isolation and refuses anything handed a tenant scope (MEMORY_MULTI_TENANT_UNSUPPORTED, [finding] The InMemory driver silently ignores the engine's tenant scope for objects that OMIT atenancyblock — its guard only refuses an explicitenabled: true, so memory-driver runs show cross-organization rows a SQL driver refuses #16589). The sweep's failure is now logged aterrorrather thanwarn— stderr survives the CLI's boot-quiet window, and "this sweep can no longer see anything" became a reachable state the moment the query was scoped.tenancy.enabled: false(ADR-0066) or federated (ADR-0015) object the engine drops the scope, so the declaration cannot narrow that sweep at all — it still selects across every organization while its runs act as the declared one. The trigger says so at bind, atwarn, naming the object. ⛔ It does not pretend the flow is contained. That branch is a disclosure only: it never changes which rows come back, so a drift from the engine's predicate costs a wrong warning, never a wrong row.The rest of the review's blocking findings
system-write-organization.ts:268-273confirms the second). The banner now states the narrowing in two places — 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 flow per organization.validate-flow-trigger-readiness.tsheld zero occurrences oforganization, againstengine.ts:21-27's own invariant. It now carriesflow-schedule-organization-missing, re-implementing no judgement:resolveFlowTriggerKindsays which flows owe the key,resolveScheduleOrganizationsays whether one was declared,describeMissingScheduleOrganizationwrites the sentence. Severitywarning, measured not argued — see below.suspended-run-store.ts: "a plain scheduled sweep has neither and keeps NULL") now says what happens, keeping the SUBJECT-first precedence it documents.origin/mainthroughscripts/pm/os-regen-merge.sh(⛔ no rebase). The one content conflict,type-alias-convention.pin.test.ts, was a semantic merge of two stacking intents — main's −4 (ui#6206-B, 815 → 811) and this branch's +1 (Iso871) — resolved to 812, recomputed from the file rather than by arithmetic, with both narratives kept. Generated artifacts were then regenerated wholesale on the merged tree: totals 198/1583 → 199/1584, automation 13/73 → 14/74.⛔ F7 was left alone, and so was the whole bind-time half: the review found it well built and genuinely pinned, and this round does not touch it. F6 is the PM's.
The severity decision was measured, not asserted
On the never-fire family's own criterion (#5762 — is this stack enough to know the flow is dead?) the new lint id belongs at
error. It is held atwarningbecause of the corpus, and that was run rather than reasoned: with the severity flipped toerror,objectstack buildonexamples/app-showcasefails, namingshowcase_task_due_reminderandshowcase_scheduled_digest. Neither can be repaired by authoring — the only legal value is asys_organization.idminted per install at runtime, and ⛔ a placeholder id is strictly worse than the omission. Promoting the id is a consequence of the open maintainer decision about package-shipped time-triggered flows, not a lint choice. The probe was restored byte-identically (blob == pre-probe blob) and both example builds pass atwarning, flagging exactly the four shipped flows.404, destroyed with the suspended account, and is being re-filed by the PM. This PR does not invent a number for it: the four in-source comments and the changeset carry the record instead, and the number is owed once the new card exists (grepis being re-filed).Verification
The differential control
⛔ A test that only proves "A's rows are found" does not distinguish the fix from the defect — the defect found them too, alongside B's. So the discriminating number is the count of launched runs.
packages/qa/dogfood/test/schedule-sweep-organization-scope.dogfood.test.tsboots the real automation + messaging + ObjectQL stack with two organizations, one matching row in A and two in B, and atime_relativeflow declared for A. Onsqlite-wasm: exactly 1 run launches (3 is the unfixed reading),update_recordlands on A's row and no other, and nosys_notificationnames a B record. Onmemory: the scoped sweep is refused and reported aterror, with a paired control proving the refusal is about the scope and not about the fixture.Negative control — measured, not argued
Two legs over one fixture: the sweep as it ships (scoped) and the same query with
isSystemonly, which is the pre-fix sweep byte for byte. Asserted equal. The fixture carries a NULL-organization row on purpose — the driver's scope isorg = :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, and leg A is asserted to have really asked for a scope, so this pin reddens under the same ablation as the differential instead of being one that can never fail. Its opposite limb is the DIFFERENTIAL above: add a second organization and the two legs must diverge.What a single-organization install does feel is the bind-time half — the flow is unarmed until it declares one line — and the changeset now says so instead of denying it.
Ablation — direction predicted in writing first, then measured
Reverting the sweep's context to the pre-fix bytes, in one shell under
trap … EXIT INT TERM, with the mutation proven on disk before either run (fixed spelling 1 → 0, ablated spelling 0 → 1,git diff --numstat= 1/1):['a1','b1','b2']-shaped: 3 runs), the notification disclosure, and the memory refusaltrigger-scheduleunitAs predicted,
the run acts on the DECLARED organization's recordstayed green under ablation — the run is scoped either way — which is exactly why the run count, not the write, carries the proof. One unit failure beyond the three enumerated: the unit twin of the memory-refusal pin, predicted for dogfood and omitted from the unit list.Restoration proven by state, not by exit code:
git diff HEADempty,git hash-object== the HEAD blob,git status --porcelainclean. Both suites resolve@objectstack/trigger-scheduleto source (dogfood's vitest alias + tsconfigpaths; the pair is absent fromKNOWN_UNALIASED_TEST_IMPORTS), so nodist/could retain the mutation.Full suites at the round-2 head
5fb332bdc(superseded — see Round 3 §7 for head18c0f6b44b)⛔ Reported as NEW numbers, not as confirmations: nothing on this branch had been run against that day's
mainat all.@objectstack/spectesttype-alias-convention.pin.test.ts› "still declares all 812 isomorphic pins" ✓@objectstack/service-automationtest@objectstack/trigger-scheduletest@objectstack/linttest@objectstack/dogfoodtestpnpm lint(repo-wideeslint . --no-inline-config)objectstack build, showcase + todoGates.
dispatch-gatesderived 117 families for this change set; all 117 were run with exit codes recorded and reconciled via--ran: 116 run / 1 NOT-MEASURED / 0 UNRUN. The one ischeck:dual-build-cjs-loadsat exit 3 — PREREQUISITE NOT MET (it reads a completepnpm build); ⛔ that is not a pass. It is hosted byci.yml'sbuild-corejob, and Build Core is green on this head, so the gate is measured — by CI, not locally. Chasing it locally package-by-package produced a different finding set on every run against a partial tree, naming only packages this diff never touches: a fact about build state, never about the diff.Scope
Against today's
origin/main(merge basee669e5eee): 42 files, +3045 / −53. The patch round itself is 15 files, +1273 / −65 on top of the original head —trigger-schedule's sweep and its pins, the dogfood differential control and its fixture, the lint rule and its suite, the changeset, the flows guide, the four example-flow comments, and one stale comment insuspended-run-store.⛔ Out of scope and untouched: the bind-time refusal, its pins, and the six spec exports the original head added.
Generated by Claude Code