Conversation
…, owning its writes per record `group` was walled by analogy with `isolated`: with the deployment switch on, every time-triggered flow had to declare `config.organization` or it did not arm. The recorded reason was that which organization a group-wide run's inserts belong to had not been thought through. It is answered now — the swept record's own, which is the subject-first order `ObjectStoreSuspendedRunStore` was already ruled to use for `sys_automation_run` (`organizationOf(record) ?? ctx.tenantId`). Before this change the two halves disagreed under `group`: the history row was stamped from the record while the inbox and delivery rows followed an acting context that could not exist there. - `ScheduledWorkPolicy` gains `runOwnership: 'unscoped' | 'per-record' | 'declared'`, a second axis from `requiresActingOrganization`: that boolean decides whether BIND refuses, this decides what a run that DID bind carries. Collapsing them is what made `group` walled by analogy. - The separating predicate is `postureUsesUnionScope`, not `postureEnforcesWall` — `group` does enforce a wall, which is exactly why its reads span the group and its writes still need an owner. - `requiresActingOrganization` narrows to `isolated` only. The rejected arm is recorded in the ADR-0087 entry because it is the one a later reader will re-propose: falling back to the bootstrap organization (`slug='default'`). Under a wall that organization is minted admin-keyed by the enterprise organizations runtime and may not exist at all; where it does, it is whichever organization the platform owner registered under — plausibly one plant of many. A record-less undeclared run is refused at its first tenant-scoped write instead. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <[email protected]>
The binding half of ruling A'. With the deployment switch on:
- `isolated` — unchanged: an undeclared flow is refused at bind.
- `group` — an undeclared flow now ARMS. A `time_relative` sweep reads group-wide
(inherent to the posture, ADR-0105 D1) and stamps each run it launches with that
record's own organization, resolved through the shared
`createRecordOrganizationResolver` rather than a local `organization_id` read: the
column is whatever the object declares, and a second implementation of that
precedence living in a trigger is the drift the shared resolver exists to end.
- A plain `schedule` (cron) flow has no record, so an undeclared one under `group`
carries nothing and is refused at its first tenant-scoped write. Deliberately NOT a
bind refusal: a cron flow that only reads, or writes only objects declaring
`tenancy: { enabled: false }`, has no write to be refused and must still run.
Refusing it at bind would be ruling G again under a new name.
The "never filled from the swept row" pin is retired for `group` ALONE, and the comment
records why per posture: under `isolated` it stands; under `single` the key is still
omitted, never filled from the row; under `group` "organizations it never declared" is
the posture's own read reach, not a boundary violation.
Both triggers now share one bind-line vocabulary (`describeScheduleRunOwnership`) so they
cannot describe one deployment differently. The undeclared-cron-under-`group` case warns
at BOOT as well as at the write: the refusal is correct but arrives at the first tick,
which may be hours away and unattended.
Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH
Co-authored-by: Claude <[email protected]>
The `group` pins read the SET of organizations across the runs one tick launched, not
"a run was stamped": the behaviour this replaces stamped every run in a batch alike, so
a pin reading only "the run carries an organization" passes on it too. Two plants' rows
in one tick yielding ['org_plant_a', 'org_plant_b'] is a value no previous behaviour
could produce.
Retired pins are replaced, not deleted, with the reason they rested on quoted at the
replacement site: `ScheduleTrigger — switched ON under a wall` becomes two blocks, and
the `isolated` half is the old pin kept whole.
Also pinned: a declaration still outranks the record (declaring narrows, never widens);
a row carrying no organization stamps nothing, with a live control proving the same
tick stamped a sibling row; `tenancy: { enabled: false }` resolves nothing even with a
stray column present; and the no-`getSchema` degradation warns once.
Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH
Co-authored-by: Claude <[email protected]>
…he release note Three doc surfaces carried "under a walled tenancy posture (group/isolated)" as one rule; each now splits the two. The flows page gains the `group` callout and the record-less-cron warning, and states why a near-miss spelling is NOT reported there: an undeclared flow is a legal shape under `group`, so the trigger cannot tell "meant to declare, misspelled it" from "meant not to declare". The changeset declares `Clause-②: yes (widening)` rather than BREAKING: nothing that worked stops working and nothing admitted becomes refused — the accept set widens in one cell. It also records that the switch this depends on ships unreleased alongside the change, so the behaviour being amended has never appeared in a published version. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <[email protected]>
…isolated)" The doctor's ON-state fix text told an operator that a time-triggered flow under group/isolated must declare config.organization. Under ruling A' that is true of `isolated` alone; `group` takes the declaration as optional, and the record-less cron case there has its own remedy worth naming at the one place an operator goes looking. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <[email protected]>
…led posture" The retired-rule note explained where the bind-time near-miss scan still fires. That door narrowed with ruling A': under `group` an undeclared flow is a legal armed shape, so a near-miss spelling there cannot be told apart from a deliberate omission. Comment only — no rule, severity or finding changes. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <[email protected]>
📓 Docs Drift CheckThis PR changes 5 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 142 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 d96619c86eab8f5be6f221a5cf859a5155bec6c6 && git checkout d96619c86eab8f5be6f221a5cf859a5155bec6c6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 51297e9ee17db23f50d38c1431f5a4dcc47632ff 0752431da60deb0ec97650515ac08550d967c03f && git checkout -B drift-repro 51297e9ee17db23f50d38c1431f5a4dcc47632ff && git merge --no-ff 0752431da60deb0ec97650515ac08550d967c03f
node scripts/docs-audit/affected-docs.mjs --json 51297e9ee17db23f50d38c1431f5a4dcc47632ff
|
… prose The marker's grammar after the arm is a list of ENTRY IDS, not free text — only the `not-required` arm takes a reason clause. The prose moves into the changeset body where it belongs, and the arm corrects to `already-registered`: this amends the pre-existing `schedule-flow-acting-organization-required` entry rather than adding one, and `registered` would assert a registration this diff did not make. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <[email protected]>
…a top-level arm The parser accepts exactly two forms — `registered <ids>` and `not-required (<category> [ids]) <why>`. `already-registered` is one of the second form's categories, and it is the honest one here: the entry predates this diff at the merge base, so `registered` would claim a registration this PR did not make. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <[email protected]>
The title carries a breaking marker while the body said the accept set widens; both are true and the body now says so together. Nothing admitted becomes refused, but on a `group` deployment with the switch already on, flows that were refused at bind now arm and run — clock-driven work appearing where an operator had none is what earns the banner, even though no consumer has to change anything. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <[email protected]>
TS2531 at the DTS build: a mutable class property does not stay narrowed across the assignment that populates it, so the property read after the cache-fill was possibly null. Reads through a local instead of asserting with `!` — the null branch is the one thing worth keeping honest here, since it is what a host mounting a non-engine adapter actually hits. Caught by the DTS build, not by tests: vitest does not type-check. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <[email protected]>
…rule The docs-drift check listed these; all three genuinely stated the pre-A' rule. - environment-variables.mdx — the OS_AUTOMATION_SCHEDULED_WORK_ENABLED row - production-readiness.mdx — the go-live checklist item - tenancy-modes.mdx — a `group · isolated` table row I missed when fixing the env table on the same page, plus the paragraph under it explaining why `group` was walled "until [the ownership question] is decided". That question is the one this change answers, so the paragraph now says what the answer is rather than that one is pending. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <[email protected]>
Both are pure projections of edits already in this branch — the amended ADR-0087 entry 18 and the ScheduleOrganizationSchema `.describe()`. No hand edits. gen:docs refused the first attempt because the gitignored packages/spec/json-schema tree was older than src, and rendering reference pages from a stale tree would have described sources the run never read. Generated that tree first (gen:schema), then the docs. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <[email protected]>
…st config `check:test-source-alias` caught the new dependency resolving through `dist/`. That made the `group` pins — which assert each run is stamped from its OWN swept record — a verdict about a built artifact rather than about `resolveRecordOrganizationField`'s precedence as it stands in this checkout, which is exactly what those pins exist to hold. The gate's own framing is why this matters: the dangerous case is a test that PASSES. Mine did, against dist. Re-run against source after aliasing: 106 pass. Anchored regex, array form, for the reason the sibling `@objectstack/types` entry states at length — a bare string `find` matches by prefix and would swallow subpaths into `…/src/index.ts/<sub>`. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <[email protected]>
`check:type-source-resolution` — the type-side sibling of the vitest alias gate — caught the new dependency's TYPES resolving through `dist/`, making this package's typecheck a verdict about build state rather than about the checkout. Took the `paths` route, not the registry one. Widening that registry is maintainer-only: this package's `@objectstack/types` row was admitted by maintainer ruling on #17396, and the registry's own docblock says the row is a signature, not a door. `paths` requires widening `rootDir` from `./src` to `../..`, because redirecting the dep to source puts its files in this program and `rootDir` is enforced even under `--noEmit`. That route is not always available, so it was MEASURED first: 0 TS6059 with both lines in place (`service-automation` measured 647 for its own case and ruled `paths` out). Same move `plugin-approvals` records for this exact dependency and `rest` for #9960; emit is unaffected since this package builds with tsup and typechecks with --noEmit. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <[email protected]>
Ready for review — all seven required contexts green on
|
| required context | result |
|---|---|
Lint & Repo Gates |
✅ |
TypeScript Type Check |
✅ |
Test Core |
✅ |
Dogfood Regression Gate |
✅ |
Build Core |
✅ |
Temporal Conformance (live PG + MySQL) |
✅ |
Governed Surface Queue Guard |
✅ |
Lint & Repo Gates is called out deliberately: on its two earlier failures it stopped at step #158 of 180, leaving 22 gates unmeasured — its own tail reporter says that is NOT MEASURED, not "passed". This run completed the job, so those 22 have now actually executed.
Local, on the same tree: pnpm lint && pnpm test → 146/146 tasks, pnpm typecheck → 0 errors across 143 packages, check:generated → 15/15 up to date.
Three defects CI caught that local work had not
Recorded because two of them are the kind that ship silently.
-
Check Changeset— the ADR-0087 marker was malformed twice: prose written where the grammar takes entry ids, thenalready-registeredused as a top-level arm when it is a category ofnot-required. Fixed; disposition is nownot-required (already-registered schedule-flow-acting-organization-required), which is the honest arm — the entry predates this diff at the merge base, soregisteredwould claim a registration this PR did not make. -
check:test-source-alias— the new@objectstack/metadata-coredependency resolved throughdist/in tests, so thegrouppins asserting "each run is stamped from its own swept record" were a verdict about a built artifact rather than aboutresolveRecordOrganizationField's precedence in this checkout — which is exactly what those pins exist to hold. The gate's framing is the point: the dangerous case is a test that PASSES. Mine did. Aliased to source, re-run: 106 pass. -
check:type-source-resolution— the same defect on the type side. Two routes existed and one was closed: that registry's docblock records this package's@objectstack/typesrow as admitted by maintainer ruling on [Decision] 平台自带的四个定时示例流一个都声明不了组织 —— 而新规则要求它们必须声明 #17396 and says in as many words that the row "is not a door, it is a signature". So this took thepathsroute, which needsrootDirwidened to../... That route is not always available —service-automationmeasured 647 TS6059 and ruled it out — so it was measured first: 0 TS6059 here. Verified after: gate green, typecheck clean, emit unaffected (1/1 declaration file; the package builds with tsup and typechecks with--noEmit).
One correction to this PR's own earlier text
The title carries ! while the body originally said "Not BREAKING". Both facts are real and the body now states them together: the accept set widens and nothing admitted becomes refused, but on a group deployment with the switch already on, flows that were refused at bind now arm and run — clock-driven work appearing where an operator had none is what earns the banner.
⛔ Not armed for auto-merge, deliberately
This changeset declares Clause-②: yes (widening) — it adds an exported symbol (ScheduledRunOwnership, describeScheduleRunOwnership) and a new key on a published payload (ScheduledWorkPolicy.runOwnership), which the mechanical floor makes yes unconditionally. That owes an in-seat contract review before landing, and a review by the seat that produced the diff would be recorded as SELF-REVIEW. Landing waits on that review.
Generated by Claude Code
… package export The independent contract review on #18420 caught this and it is real: the changeset claimed `@objectstack/trigger-schedule` exports the helper. It does not — the package barrel does not re-export it, and the barrel's own note says an export whose only consumers live inside its own package belongs in a non-barrel module. So the code is right and the claim was wrong. This mattered beyond wording: that false export was cited as part of the `Clause-②: yes` basis. The declaration still stands, on the surface that IS public — `ScheduledRunOwnership` and the `runOwnership` key on `@objectstack/types` — and the changeset now says so. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <[email protected]>
⛔ Blocked — independent contract review returned FAIL, and one finding is a contract violation this PR cannot self-approve pastAn independent Clause-② review (a separate session, so the machine-read independence pair genuinely differs) returned FAIL. It could not post its own verdict from its container — a 403 on repo scope — so its full text is still in that session's transcript. This comment records what the seat that wrote the diff has independently verified against the code, not a relay of its summary. Finding 1 — ⛔ a fourth consumer of
|
…switch The independent contract review on #18420 caught a three-way mismatch and it is real. Two TSDoc claims on `ScheduledWorkPolicy` said that with the scheduled-work switch OFF, `runOwnership` reports `'unscoped'`: - the state table's OFF row printed `'unscoped'` (moot); - the `runOwnership` docblock said "when the switch is off nothing binds, so this reports `'unscoped'` rather than a state no run can reach". The code does no such thing. `resolveScheduledWorkPolicy` computes it as `scheduledRunOwnershipFor(posture)` — posture only, never gated on `enabled` — so OFF + `group` is `'per-record'` and OFF + `isolated` is `'declared'`. The pins in `env.test.ts` already assert exactly that, so the documentation was the only thing asserting the opposite. The CODE is the half that is right, and it is left alone. `runOwnership` answers a question about the posture (where a bound run's writes would get their organization), and `scheduledRunOwnershipFor`'s own docblock says so in as many words — "independent of the switch". Gating it on `enabled` would also make the value a function of two inputs while `requiresActingOrganization`, which IS the switch-gated half, already carries that job. Both consumers return early on `!policy.enabled` (schedule-trigger.ts:643, time-relative-trigger.ts:313) long before they read it, so nothing downstream depended on the documented reading. So: the OFF row now says "the posture's rule (moot)", the docblock says it is a fact about `posture` and not about the switch, and a new paragraph states the one thing a reader could otherwise get wrong — ⛔ `runOwnership` alone is never evidence that a run exists or will; `enabled` is the discriminator and the OFF reason is what an operator gets told. Doc-only: no behaviour change, `pnpm typecheck` clean, 52/52 in env.test.ts. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <[email protected]>
|
Review finding ② is fixed — This supersedes the body's "Status — ready, green" section: the PR is back in draft and the independent Clause-② review returned FAIL with two findings. ② Fixed (doc-only). ① Blocking, unchanged — a fourth consumer of One clarification on the review's own summary line, which compresses ① as "org-scoped credential read + stamp-only key priority conflict": there is no credential read in this diff. CI: the seven required contexts were green on Generated by Claude Code |
Fixes #18378
Implements ruling A′ (
Ruling-ref: 5695424700, maintainer, 2026-09-16), which reopened ruling G item 3 (#17396) forgrouponly. ⛔ Nothing aboutsingleorisolatedis reopened, and the deployment switch itself (OS_AUTOMATION_SCHEDULED_WORK_ENABLED, default OFF in every posture) is untouched — A′ decides only what binds once the operator has turned it on undergroup.Clause-②: yes (widening)What this is
With the switch on and posture
group, a time-triggered flow that declares noconfig.organizationnow binds and runs, where it was previously refused at bind. What its writes carry follows the record:singlegroupisolatedA
timeRelativesweep undergroupreads group-wide — inherent to the posture (ADR-0105 D1, whose own example is multi-plant MES) — and stamps each run with that record's organization: sweep contracts across four plants and each plant's contract yields a run acting as that plant, whose notifications reach that plant's inboxes.Why this is not a fallback that guesses
It is the order
sys_automation_runwas already ruled to use.ObjectStoreSuspendedRunStoreresolves a run's organization asorganizationOf(<subject record>) ?? ctx.tenantId— subject first, acting context as the fallback and never the primary (platform-object-tenancy.ts's own evidence line). Before this change the two halves disagreed undergroup: the history row was stamped from the record while the inbox and delivery rows followed an acting context that could not exist there, so they were refused while the tick summarised itself as healthy. Filling the acting context the same way makes one run carry ONE organization's opinion about who it belonged to.Two findings that moved A → A′, both measured before the ruling
The card recommended plain A, whose record-less-cron cell fell back to the bootstrap organization (
slug='default'). That arm was put to the maintainer as rejected, and the card's own ③ axis understated it:AuthPluginskips its own default-organization bootstrap and hands the job to the enterprise organizations runtime, whose helper is admin-keyed — it answersno_adminfor anyone but the resolved platform admin, so agroupinstall with no such admin bootstraps no organization at all (walled-default-org-self-registrant.pin.test.ts). Where one does exist it is whichever organization the platform owner registered under: plausibly one plant of many. A would land a group-wide cron's notifications in one arbitrary plant's inbox — a wrong owner, silently authoritative to every report and export that filters by organization, not merely an unread one. A′ refuses instead, and avoids amending ADR-0093 D3 (defaultOrgId()answersnullunder any wall) or standing up a second resolver beside it.The refusal that remains, deliberately
A record-less run under
groupthat declared nothing resolves nothing and takes the existingwalled-posturerefusal at its first tenant-scoped write (ADR-0112), loudly and by name.⛔ That is not converted into a bind refusal: a cron flow that only reads, or writes only objects declaring
tenancy: { enabled: false }, has no write to be refused and must still run — refusing it at bind would be ruling G again under a new name. The bind line says so at boot instead, because the write refusal is correct but arrives at the first tick, which may be hours away and unattended.Design notes for the reviewer
runOwnershipis a second axis, not a rename.requiresActingOrganizationdecides whether BIND refuses;runOwnershipdecides what a run that DID bind carries. Collapsing them is precisely what madegroupwalled by analogy.postureUsesUnionScope, ⛔ notpostureEnforcesWall.groupdoes enforce a wall — that is why its writes still need an owner — and it also has group-wide read reach, which is why a batch job there is a capability rather than a boundary violation. Only a predicate on read reach says both. A regression toenabled && postureEnforcesWall(posture)passes every other pin and fails one named live control.organization_idread. Per-record ownership goes through the sharedcreateRecordOrganizationResolver(@objectstack/metadata-core, a new workspace dep ontrigger-schedule; deps are spec + zod only, no cycle). The column is whatever the object declares (tenancy.organizationField→tenancy.tenantField→ default), a platform-global object has none, and a second copy of that precedence living in a trigger is the drift that resolver exists to end.warn.TimeRelativeDataEngineis a type-level narrowing — the plugin resolves the realobjectqlservice, which hasgetSchema— but a host mounting a genuine adapter object would not. Then nothing resolves, every write is refused, and the message is about the WRITE, sending the operator to the flow when the cause is the composition. Said once per engine, naming the remedy. ⛔ Noterror: the writes that matter are still refused loudly, so nothing is silently lost.Retired pins, with their reasons (⛔ none deleted silently)
groupalone, and the comment records the verdict per posture: underisolatedit stands; undersinglethe key is still omitted, never filled from the row; undergroup"organizations it never declared" is the posture's own read reach.ScheduleTrigger — switched ON under a wallbecomes two blocks. Theisolatedhalf is that pin kept whole; the replacement site quotes the condition the old pin rested on ("while the question of which organization a group-wide sweep's inserts belong to was unanswered") so the reversal is legible rather than looking like erosion.Tests
['org_plant_a', 'org_plant_b'], a value no previous behaviour could produce.Reverse-verified: ablating the per-record limb turns 3 pins red including that one, while the other 57 stay green; the restore was confirmed byte-identical against
HEADbefore re-running.Also pinned: a declaration still outranks the record (declaring narrows, never widens); a row with no organization stamps nothing, with a live control proving the same tick stamped a sibling;
tenancy: { enabled: false }resolves nothing even with a stray column present; the no-getSchemadegradation warns once; andgroupanswerstruetopostureEnforcesWallwhile still not requiring the declaration.What the
!marksThe breaking marker is for the behaviour change, not a narrowing. Nothing that worked stops working and nothing admitted becomes refused — the accept set widens in one cell. What earns the banner is the other direction: on a
groupdeployment with the switch already on, flows that were refused at bind now arm and run, so clock-driven work appears where an operator had none.The switch this depends on ships unreleased alongside this change (#18198's changeset is still pending in
.changeset/), so thegroup-is-walled behaviour being amended has never appeared in a published version and no released consumer can be relying on it. ADR-0087 disposition isnot-required (already-registered)— the ledger entry predates this diff at the merge base and gained itsgrouprows here;registeredwould claim a registration this PR did not make.Status — ready, green, waiting on contract review
All seven required contexts pass on
e856c009:Lint & Repo Gates,TypeScript Type Check,Test Core,Dogfood Regression Gate,Build Core,Temporal Conformance (live PG + MySQL),Governed Surface Queue Guard. Merges cleanly againstmain(probed on a driver-free bare clone, which is what GitHub computes).Locally on the same tree:
pnpm lint && pnpm test→ 146/146 tasks,pnpm typecheck→ 0 errors across 143 packages,check:generated→ 15/15 up to date.Lint & Repo Gatesis worth a second look rather than a glance: its two earlier failures stopped at step #158 of 180, leaving 22 gates unmeasured — its own tail reporter calls that NOT MEASURED, not "passed". The passing run completed the job, so those 22 have now actually executed.Three defects CI caught that local work had not, recorded because two are the kind that ship silently — a malformed ADR-0087 marker, and the new
metadata-coredependency resolving throughdist/on both the test and the type side, which made thegrouppins a verdict about a built artifact rather than about the checkout. Detail in this comment.⛔ Not armed for auto-merge, deliberately.
Clause-②: yesowes an independent contract review, and a verdict from the seat that wrote the diff does not count as one —check-clause2-carrierssays so in as many words, and its named remedy is a seat that did not write the diff.🤖 Generated with Claude Code
https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH