feat(spec,types,triggers)!: group runs package-authored scheduled work without a declaration, owning each run's writes per record - #18420
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 6 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 e6c923322eb68a6112d9142ee0b57e3862f997c1 && git checkout e6c923322eb68a6112d9142ee0b57e3862f997c1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e19ae6708ad9e10ba0b24919a54f4c0fbfa5c62f 4584b00fa3134d10fd4c31677555b13f10941734 && git checkout -B drift-repro e19ae6708ad9e10ba0b24919a54f4c0fbfa5c62f && git merge --no-ff 4584b00fa3134d10fd4c31677555b13f10941734
node scripts/docs-audit/affected-docs.mjs --json e19ae6708ad9e10ba0b24919a54f4c0fbfa5c62f
|
… 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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
`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 <noreply@anthropic.com>
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
⛔ 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 <noreply@anthropic.com>
|
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 |
…e stamp one
The independent contract review's blocking finding was that
`TimeRelativeTrigger` had become a FOURTH consumer of `tenancy.organizationField`
— a key whose contract pins its consumers to three named platform-row writers
and says a fourth needs its own maintainer ruling. The remedy put to the
maintainer was a ruling or a redesign. This is the redesign, and it needs no
ruling because it stops reading the key at all.
The defect underneath the finding is that ONE resolver was answering TWO
questions:
- STAMP — "which column says who this row is ABOUT". Limb 0
(`tenancy.organizationField`) wins over everything, the ADR-0066
`tenancy.enabled: false` opt-out included, because an author declaring it on
an unwalled table is saying the audit trail should follow the row's own
organization even though nothing walls it.
- WALL — "which column is this row SCOPED by", and therefore which
organization work launched from that row may act as.
They coincide on every ordinary object and come apart on exactly one shipped
object: `sys_api_key`, `tenancy: { enabled: false, organizationField:
'active_organization_id' }`, unwalled by design (#8287 — walling the credential
table on an equality that excludes NULL is the defect that card removed). A
sweep over such an object was about to launch runs ACTING AS an organization
derived from an annotation that never meant "act as this".
So `@objectstack/metadata-core` grows a second face rather than a second copy:
`resolveRecordWallOrganizationField` / `createRecordWallOrganizationResolver`
are limbs 1-4 with limb 0 skipped, over the same implementation and the same
memoization glue — a `readStampKey` parameter selects limb 0 alone, so the
limbs the two faces share cannot drift apart. The stamp face keeps its name, its
signature and its answers, limb 0 included; the three sanctioned writers are
untouched.
`TimeRelativeTrigger` binds the WALL face. Under `group` an undeclared sweep
still stamps each run from its own swept record — that is ruling A′ and it is
unchanged for every business object, because for them the wall column IS
`organization_id` (or the declared `tenantField`). On an unwalled object the
sweep now resolves NOTHING and the run takes the existing `walled-posture`
refusal at its first tenant-scoped write, loudly and by name, instead of
acquiring an identity from a stamp annotation.
Pins: the wall face is pinned per limb against the stamp face wherever the two
can diverge (the `sys_api_key` shape both ways, a declared stamp key on a WALLED
object, and an agree-everywhere-else sweep over five shapes), and the trigger
carries the end-to-end discriminator. Reverse-verified: pointing the trigger
back at `createRecordOrganizationResolver` reddens that one pin and only it
(1 failed / 137 passed), and the restore is byte-identical.
⛔ No cross-package parity pin against `objectql`'s `resolveTenantFieldName` —
that package is registered in `check:test-source-alias` as still resolving
metadata-core through `dist/`, so such a pin would be a verdict about build
state. Converging the two spellings belongs to its own card; this change adds no
third one.
Measured: `pnpm typecheck` 143/143 tasks, `pnpm lint` clean, metadata-core
283/283, trigger-schedule 138/138, and the three stamp-key writers green
(service-automation 1634, plugin-approvals 754, plugin-audit 346).
Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH
Co-authored-by: Claude <noreply@anthropic.com>
…n's red `Validate Package Dependencies` went red on this PR's head, and the failure is not this PR's: step 13 (OSV-Scanner) flags `devalue@5.9.0` for GHSA-9rgm-9g3h-6x36 (5.3, fixed 5.9.2), a transitive package reached through `svelte`. This branch's only lockfile change is the `@objectstack/metadata-core` workspace link it added; `devalue` comes from the base. Established rather than assumed: PR #18942's body records `main` itself failing the same required check on scheduled run `35301766597` (branch `main`, sha `36583e98`), with the previous day's run green — so the advisory landed inside that window and every PR touching a manifest inherited the red. That PR is the fix, it is open, and waiting for it to merge is still waiting. So its change is PORTED here verbatim — the `devalue@<6.0.0` override in `pnpm-workspace.yaml` with its rationale, and the lockfile effect — after reading its diff rather than its description. Identical shape: 4 hunks, 9 changed lines, one package moved (5.9.0 → 5.9.2, the single resolved copy). It no-ops the moment `main` carries it. ⛔ No `osv-scanner.toml` exemption: the advisory names a fixed version, which is the one case that file's header forbids exempting. The ledger stays at zero entries, asserted by `check-osv-exemptions` (exit 0). Measured here: `pnpm install --frozen-lockfile --prefer-offline` → exit 0 ("Lockfile is up to date"), `check-override-consistency` → exit 0 with the override covered, `check-osv-exemptions` → exit 0. The scanner itself is CI's to render — `api.osv.dev` is refused by this container's egress proxy — so no local green is claimed for it; what is claimed is its input, and `devalue@5.9.0` is gone from the lockfile. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <noreply@anthropic.com>
The blocking finding is answered by redesign, not by a ruling —
|
| question | limb 0 (tenancy.organizationField) |
consumers | |
|---|---|---|---|
resolveRecordOrganizationField (unchanged) |
who is this row ABOUT — the stamp | read | the three sanctioned platform-row writers |
resolveRecordWallOrganizationField (new) |
what is this row WALLED BY — the scope, and so the identity work launched from it may act as | not read | the sweep |
They coincide on every ordinary object and come apart on exactly one shipped object: sys_api_key, tenancy: { enabled: false, organizationField: 'active_organization_id' } — unwalled by design (#8287; walling the credential table on an equality that excludes NULL is the defect that card removed). The sweep was about to launch runs acting as an organization derived from an annotation that never meant "act as this".
Both faces are one implementation — a readStampKey parameter selects limb 0 alone — so limbs 1–4 cannot drift into two answers. The stamp face keeps its name, signature and answers, limb 0 included; the three sanctioned writers are untouched (service-automation 1634, plugin-approvals 754, plugin-audit 346, all green).
Ruling A′ is unchanged for every business object: their wall column is organization_id (or the declared tenantField). On an unwalled object the sweep now resolves nothing and the run takes the existing walled-posture refusal at its first tenant-scoped write, by name, instead of acquiring an identity from a stamp annotation.
Reverse-verified: pointing the trigger back at createRecordOrganizationResolver reddens the new sys_api_key-shaped pin and only it (1 failed / 137 passed); the restore is byte-identical. pnpm typecheck 143/143, pnpm lint clean, metadata-core 283/283, trigger-schedule 138/138.
⛔ No cross-package parity pin against objectql's resolveTenantFieldName was added, deliberately: that package is registered in check:test-source-alias as still resolving metadata-core through dist/, so the pin would be a verdict about build state. Converging the three spellings of the wall rule (driver computeTenantField, resolveTenantFieldName, this one) belongs to its own card; this change adds no fourth.
The changeset now also carries @objectstack/metadata-core: minor, and its public-surface paragraph is corrected: four new exported names, not two.
Validate Package Dependencies — not this PR's, fix ported — ef6f69b5
Step 13 (OSV-Scanner) flags devalue@5.9.0 for GHSA-9rgm-9g3h-6x36 (5.3, fixed 5.9.2), a transitive package reached through svelte. This branch's only lockfile change of its own is the @objectstack/metadata-core workspace link; devalue comes from the base.
PR #18942 is the fix and records the measurement that settles ownership: main itself failed this same required check on scheduled run 35301766597 (branch main, sha 36583e98), with the previous day's run green. Rather than wait for it to merge, its change is ported here verbatim after reading its diff — the devalue@<6.0.0 override plus its lockfile effect, same shape (4 hunks, 9 lines, one package moved). It no-ops the moment the base carries it.
⛔ No osv-scanner.toml exemption — the advisory names a fixed version, the one case that file's header forbids exempting; the ledger stays at zero entries. Locally: pnpm install --frozen-lockfile exit 0, check-override-consistency exit 0, check-osv-exemptions exit 0. The scanner itself is CI's to render (api.osv.dev is refused by this container's proxy), so no local green is claimed for it.
Still open
An independent Clause-② review is running against cda685b8; its verdict lands here when it returns. The PR stays draft and is not armed for auto-merge.
Generated by Claude Code
…ew returned An isolated contract-review subagent reviewed `cda685b8` and returned PASS WITH FINDINGS. All three are this seat's, all three are declaration- or prose-level, and each is verified against the tree before being fixed rather than taken on the reviewer's word. **1. The changeset did not name `@objectstack/cli`.** `packages/cli/src/commands/doctor.ts` changes the text `os doctor` prints (11 lines, this PR's), `@objectstack/cli` is published (`publishConfig.access: public`), and AGENTS.md requires a changeset for anything that publishes. The sibling changeset for the same doctor text (`.changeset/scheduled-work-deployment-switch.md`) lists it. `Check Changeset` was green only because no gate reads package coverage. Added at `patch` — the text is a fix, no API moves. `@objectstack/lint` still needs nothing: its diff is comment-only. **2. A docblock this PR touched still stated the retired rule.** `schedule-trigger.ts`'s `refuseMissingOrganization` header read "⚠️ Under a WALLED posture (`group` / `isolated`) … and nowhere else". That is false at this head — the caller gates on `requiresActingOrganization`, which is `isolated` only — and it contradicted the zod docblock this same PR rewrote. Same class as the `runOwnership` mismatch fixed in `0752431d`, missed in the same file. The header now names `isolated`, says why `group` is not a near-miss, and names the predicate to gate on (⛔ never `postureEnforcesWall`, which answers `true` for `group`). **3. A published `.describe()` string had a grammar defect** — "acts as the swept record own organization" — shipping in the JSON schema and the generated reference page. Reworded to "acts as the organization of the record it swept", which also avoids an apostrophe inside the single-quoted literal; the first attempt at the possessive broke the parse, which `gen:schema` caught. Regenerated: `gen:schema`, `gen:docs`, `gen:api-surface` (that one needed a `pnpm --filter @objectstack/spec build` first — its staleness was the built-dist phantom, and the artifact came back byte-identical). Also taken, though the review marked it optional: the "one run carries ONE organization's opinion" claim is now stated WITH its exception, in both the spec docblock and the changeset. The history row is stamped (`tenancy.organizationField` wins) while the run's acting organization is a wall reading that never consults that key, so the two coincide on every ordinary object and diverge on the one shipped object that declares the key. That divergence is the correct pair of answers — a row nothing walls has no organization for a run to act as — but it is a divergence, and an unqualified claim of agreement would be the same kind of false docblock as finding 2. Measured: spec 13688/13688, trigger-schedule 138/138, cli 3385/3385, `check:generated` 15/15, `check-adr-0087-registration` and `check-changeset-fixed` exit 0. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <noreply@anthropic.com>
|
Served-tier: Contract reviewReviewed head: Checkout Measured on this head, read-only: ① Derived judgments
② Semver level and changeset declaration
Factual claims checked against the tree: "helper is module-level, NOT a package export" ✓ (item 8); "the new PUBLIC surface … those four" ✓ (items 2, 9); "the switch ships unreleased alongside this change" ✓ ( ⛔ One declaration defect: the changeset does not name ③ Boundary flags
Implemented-by: session_01URii26ZSYx4xPZ9ai47ceH Verdict: PASS WITH FINDINGS — the contract increment matches ruling A′ cell for cell, the fourth-consumer pin on Adoption note — read this before reading the pair above as independenceThe verdict text above is adopted verbatim: it was rendered by an isolated review subagent that did not write this diff, was briefed adversarially, and was given the card, the ruling and the PR only — not this seat's conclusions. ⛔ Nothing in it was rewritten or polished. Three things about it are not what the machine reading wants, and are stated rather than dressed up:
All three findings are fixed in This PR stays draft and is not armed for auto-merge. Generated by Claude Code |
One conflict, in `pnpm-workspace.yaml`, and it is this branch's own port retiring itself: PR #18942 — whose `devalue@<6.0.0` override `ef6f69b5` copied so this PR would stop inheriting `main`'s OSV red — merged as `5e0a1b9e`. Both sides now carry the same override; the comment text differed only where the port was reworded, plus the⚠️ PORTED note saying the block would no-op the moment the base carried it. Resolved by taking `main`'s block whole, which is that note being honoured rather than discarded: the base is the author of record for that pin, and this branch has no other change to the file. `'devalue@<6.0.0': '^5.9.2'` resolves once, and `pnpm install --frozen-lockfile --prefer-offline` answers exit 0 ("Lockfile is up to date") on the merged tree. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <noreply@anthropic.com>
|
Correction to the adoption note: That note predicted the machine reading instead of measuring it. Measured, at head
It stops upstream of the independence clause, so C4 never runs. Two things are true about that:
So
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.Which organization a record belongs to — the WALL question, not the stamp one
⭐ This is the part that changed after review, and it is the heart of the PR. "Which organization does this record belong to" had two different answers in one resolver, and this diff separates them in
@objectstack/metadata-core:tenancy.organizationField)resolveRecordOrganizationField/createRecordOrganizationResolver(unchanged)resolveRecordWallOrganizationField/createRecordWallOrganizationResolver(new)The sweep binds the WALL face. ⛔ It never reads
tenancy.organizationField, so it is not a fourth consumer of that scope-pinned key and needs no ruling to admit one: the key's contract (#8778, cloud#1395) pins its consumers to audit stamping, the approval-row writer and the automation-run recorder, and that list is untouched.Why the split is not cosmetic: the two answers coincide on every ordinary object and come apart on exactly one shipped object —
sys_api_key,tenancy: { enabled: false, organizationField: 'active_organization_id' }, deliberately unwalled (#8287; walling the credential table on an equality that excludes NULL is the defect that card removed). Reading limb 0 here would take a declaration meaning "the audit trail should follow this row's own organization even though nothing walls it" and turn it into an acting identity. On such an object the sweep now resolves nothing and the run takes the existingwalled-posturerefusal at its first tenant-scoped write, by name.Both faces are ONE implementation — a
readStampKeyparameter selects limb 0 alone — so limbs 1–4 cannot drift into two answers. ⛔ No cross-package parity pin againstobjectql'sresolveTenantFieldNameis added: that package is registered incheck:test-source-aliasas still resolvingmetadata-corethroughdist/, so such a pin would be a verdict about build state. Converging the three spellings of the wall rule belongs to its own card (#19054 covers the related key retirement); this change adds no fourth.Why per-record ownership 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. 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.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.The rejected alternative was a fallback 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, and where it does it is whichever organization the platform owner registered under — plausibly one plant of many, not the group's head office. That would be a wrong owner, silently authoritative to every report and export that filters by organization.Design notes for the reviewer
runOwnershipis a second axis, not a rename.requiresActingOrganizationdecides whether BIND refuses;runOwnershipdecides what a run that DID bind carries.runOwnershipis a fact about the POSTURE, not about the switch: it reportsgroup's'per-record'even while the switch is off, when nothing binds.enabledis the discriminator.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. A regression toenabled && postureEnforcesWall(posture)passes every other pin and fails one named live control.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. Said once per engine, naming the remedy. ⛔ Noterror: the writes that matter are still refused loudly.Retired pins, with their reasons (⛔ none deleted silently)
groupalone, and the comment records the verdict per posture: underisolatedit stands; undersinglethe key is still omitted; undergroup"organizations it never declared" is the posture's own read reach.ScheduleTrigger — switched ON under a wallbecomes two blocks, and the replacement site quotes the condition the old pin rested on so the reversal is legible rather than looking like erosion.Tests
['org_plant_a', 'org_plant_b'], a value no previous behaviour could produce.The wall/stamp split has its own discriminator: an object shaped like
sys_api_keyresolves nothing for the sweep. Reverse-verified — pointing the trigger back atcreateRecordOrganizationResolverreddens that pin and only it (1 failed / 137 passed), and the restore is byte-identical.Also pinned: a declaration still outranks the record; 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; the no-getSchemadegradation warns once;groupanswerstruetopostureEnforcesWallwhile still not requiring the declaration; and the two resolver faces agree on every shape where limb 0 is absent.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, so the
group-is-walled behaviour being amended has never appeared in a published version. ADR-0087 disposition isnot-required (already-registered)— the ledger entry predates this diff at the merge base and gained itsgrouprows here.Status — green on
4584b00f; ready and queued by another seatCI: all seven required contexts green on
4584b00f—Lint & Repo Gates,TypeScript Type Check,Test Core,Dogfood Regression Gate,Build Core,Temporal Conformance (live PG + MySQL),Governed Surface Queue Guard— plusValidate Package DependenciesandCheck Changeset. 36 check runs completed, none failed, two deliberate skips.mergeable_state: clean.Independent Clause-② review: PASS WITH FINDINGS, posted verbatim in this comment. Its three findings are fixed in
d563fac6(the changeset now names@objectstack/cli; a stale docblock that still claimed the retired "any walled posture" rule is corrected; a published.describe()grammar defect is fixed and regenerated), each verified against the tree rather than taken on the reviewer's word.docs/adr/**,.claude/**,skills/**,AGENTS.md,CLAUDE.md) — its diff ispackages/**,content/docs/**,.changeset/**and the lockfile, andGoverned Surface Queue Guardpasses accordingly. The authoring seat held it in draft by its own caution, not by that rule. It was flipped ready, armed for auto-merge and enqueued byos-litantat 14:43Z.claude/issue-<n>-<slug>shape).🤖 Generated with Claude Code
https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH