Skip to content

feat(spec,types,triggers)!: group runs package-authored scheduled work without a declaration, owning each run's writes per record - #18420

Merged
os-litant merged 20 commits into
mainfrom
claude/zealous-mendel-o0o6aq
Sep 18, 2026
Merged

os-litant merged 20 commits into
mainfrom
claude/zealous-mendel-o0o6aq

Conversation

@hotlong

@hotlong hotlong commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Fixes #18378

Implements ruling A′ (Ruling-ref: 5695424700, maintainer, 2026-09-16), which reopened ruling G item 3 (#17396) for group only. ⛔ Nothing about single or isolated is 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 under group.

Clause-②: yes (widening)

What this is

With the switch on and posture group, a time-triggered flow that declares no config.organization now binds and runs, where it was previously refused at bind. What its writes carry follows the record:

posture declaration a bound run's writes act as
single not read nothing — the install's one organization resolves beneath each write
group optional declared ⇒ the declaration; undeclared ⇒ the swept record's own organization
isolated required the declaration; undeclared ⇒ not armed, unchanged

A timeRelative sweep under group reads 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:

face question limb 0 (tenancy.organizationField) consumers
resolveRecordOrganizationField / createRecordOrganizationResolver (unchanged) who is this row ABOUT — the stamp read the three sanctioned platform-row writers
resolveRecordWallOrganizationField / createRecordWallOrganizationResolver (new) what is this row WALLED BY — the scope, and so the identity work launched from it may act as not read this sweep

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 existing walled-posture refusal at its first tenant-scoped write, by name.

Both faces are ONE implementation — a readStampKey parameter selects limb 0 alone — so limbs 1–4 cannot drift into two answers. ⛔ No cross-package parity pin against objectql's resolveTenantFieldName is added: 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 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_run was already ruled to use. ObjectStoreSuspendedRunStore resolves a run's organization as organizationOf(<subject record>) ?? ctx.tenantId — subject first, acting context as the fallback and never the primary. 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, so they were refused while the tick summarised itself as healthy.

⚠️ With one stated exception, recorded rather than smoothed over: the history row is STAMPED while the run's acting organization is a WALL reading, so on the one shipped object that declares the stamp key the two legitimately differ — the row says who it is about, and nothing walls it, so there is no organization for the run to act as.

The refusal that remains, deliberately

A record-less run under group that declared nothing resolves nothing and takes the existing walled-posture refusal 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

  • runOwnership is a second axis, not a rename. requiresActingOrganization decides whether BIND refuses; runOwnership decides what a run that DID bind carries. ⚠️ runOwnership is a fact about the POSTURE, not about the switch: it reports group's 'per-record' even while the switch is off, when nothing binds. enabled is the discriminator.
  • The separating predicate is postureUsesUnionScope, ⛔ not postureEnforcesWall. group does 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 to enabled && postureEnforcesWall(posture) passes every other pin and fails one named live control.
  • One new degradation, at warn. TimeRelativeDataEngine is a type-level narrowing — the plugin resolves the real objectql service, which has getSchema — 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. ⛔ Not error: the writes that matter are still refused loudly.

Retired pins, with their reasons (⛔ none deleted silently)

  • "never filled from the swept row" is retired for group alone, and the comment records the verdict per posture: under isolated it stands; under single the key is still omitted; under group "organizations it never declared" is the posture's own read reach.
  • ScheduleTrigger — switched ON under a wall becomes 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

⚠️ The discriminating assertion is the SET of organizations across the runs one tick launched, never "a run was stamped" — the behaviour this replaces stamped every run in a batch alike. The fixture puts matching rows in two organizations and reads ['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_key resolves nothing for the sweep. Reverse-verified — pointing the trigger back at createRecordOrganizationResolver reddens 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-getSchema degradation warns once; group answers true to postureEnforcesWall while still not requiring the declaration; and the two resolver faces agree on every shape where limb 0 is absent.

What the ! marks

The 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 group deployment 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 is not-required (already-registered) — the ledger entry predates this diff at the merge base and gained its group rows here.

Status — green on 4584b00f; ready and queued by another seat

CI: all seven required contexts green on 4584b00fLint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard — plus Validate Package Dependencies and Check 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.

⚠️ Two corrections to what this section said before, because a stale status on a merging PR is worse than no status:

  1. It said "⛔ still DRAFT, never self-queued: this is a governed-surface PR." This PR does not touch the governed surface (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md) — its diff is packages/**, content/docs/**, .changeset/** and the lockfile, and Governed Surface Queue Guard passes 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 by os-litant at 14:43Z.
  2. It said the machine independence pair reads SELF-REVIEW. Measured, it reads UNJUDGED, and for an upstream reason: this comment has the run and the cause (a claim comment whose branch name does not match the checker's claude/issue-<n>-<slug> shape). ⚠️ That checker is not in CI and reads two lines of a comment; it is not evidence about this diff either way.

🤖 Generated with Claude Code

https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH

…, 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>
@github-actions github-actions Bot added size/xl dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Sep 16, 2026
@github-actions

github-actions Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 6 package(s): @objectstack/cli, @objectstack/lint, @objectstack/metadata-core, @objectstack/spec, @objectstack/trigger-schedule, @objectstack/types, touching 20 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/triggers/trigger-schedule/package.json, packages/triggers/trigger-schedule/tsconfig.json, packages/triggers/trigger-schedule/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via os doctor (command, read off packages/cli/src/commands/doctor.ts))
  • content/docs/automation/jobs.mdx (via os doctor (command, read off packages/cli/src/commands/doctor.ts))
  • content/docs/data-modeling/indexing.mdx (via os doctor (command, read off packages/cli/src/commands/doctor.ts))
  • content/docs/deployment/cli.mdx (via os doctor (command, read off packages/cli/src/commands/doctor.ts))
  • content/docs/deployment/environment-variables.mdx (via os doctor (command, read off packages/cli/src/commands/doctor.ts))
  • content/docs/deployment/production-readiness.mdx (via os doctor (command, read off packages/cli/src/commands/doctor.ts))
  • content/docs/protocol/backward-compatibility.mdx (via os doctor (command, read off packages/cli/src/commands/doctor.ts))
  • content/docs/protocol/kernel/config-resolution.mdx (via os doctor (command, read off packages/cli/src/commands/doctor.ts))
  • content/docs/protocol/kernel/http-protocol.mdx (via os doctor (command, read off packages/cli/src/commands/doctor.ts))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via os doctor (command, read off packages/cli/src/commands/doctor.ts))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/triggers/trigger-schedule/package.json, packages/triggers/trigger-schedule/tsconfig.json, packages/triggers/trigger-schedule/vitest.config.ts) — pages documenting those are invisible to this run
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 142 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json e19ae6708ad9e10ba0b24919a54f4c0fbfa5c62fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from e6c923322eb68a6112d9142ee0b57e3862f997c1 — the merge of head 4584b00fa3134d10fd4c31677555b13f10941734 into base e19ae6708ad9e10ba0b24919a54f4c0fbfa5c62f, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs e19ae6708ad9e10ba0b24919a54f4c0fbfa5c62f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

… 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>
@hotlong
hotlong marked this pull request as ready for review September 16, 2026 11:30

hotlong commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Ready for review — all seven required contexts green on e856c009

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 test146/146 tasks, pnpm typecheck0 errors across 143 packages, check:generated15/15 up to date.

Three defects CI caught that local work had not

Recorded because two of them are the kind that ship silently.

  1. Check Changeset — the ADR-0087 marker was malformed twice: prose written where the grammar takes entry ids, then already-registered used as a top-level arm when it is a category of not-required. Fixed; disposition is now not-required (already-registered schedule-flow-acting-organization-required), which is the honest arm — the entry predates this diff at the merge base, so registered would claim a registration this PR did not make.

  2. check:test-source-alias — the new @objectstack/metadata-core dependency resolved through dist/ in tests, so the group pins asserting "each run is stamped from its own swept record" were a verdict about a built artifact rather than about resolveRecordOrganizationField'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.

  3. 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/types row 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 the paths route, which needs rootDir widened to ../... That route is not always available — service-automation measured 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

hotlong commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

⛔ Blocked — independent contract review returned FAIL, and one finding is a contract violation this PR cannot self-approve past

An 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.

⚠️ Two of its three findings are named here; the third is stated but not yet verified by this seat, and is not being characterised either way.

Finding 1 — ⛔ a fourth consumer of tenancy.organizationField. Verified, and it blocks this PR.

TimeRelativeTrigger.organizationOfRecord() reads per-record ownership through createRecordOrganizationResolver (@objectstack/metadata-core). That resolver's own docblock pins the key it reads:

⛔ The tenancy.organizationField key this resolver reads stays scope-pinned (#8778, widened by name on cloud#1395 …): exactly THREE consumers are sanctioned — audit stamping, the approval-row writer, and the automation-run recorder — and no others. A fourth consumer needs its own maintainer ruling before reading the key, exactly as #8778 required.

and limb 0 repeats it:

⛔ Stamp-only cuts both ways: the key's consumers are pinned to the THREE platform-row writers the cloud#1395 ruling names — a fourth consumer, or any read path, needs its own ruling first.

This trigger is a fourth consumer and a read path. Both prohibitions apply.

The escalation, concretely. tenancy.organizationField is a STAMP-ONLY declaration answering "which column says who this row is ABOUT", and limb 0 makes it win over everything below it, the ADR-0066 tenancy: { enabled: false } opt-out included — which is exactly why it is legitimately declared on objects that are deliberately not org-walled (sys_api_key's active_organization_id, #8287). This diff takes that value and makes it the run's acting identity (ctx.tenantId), so a sweep over such an object would launch runs acting as an organization derived from an annotation that never meant "act as this". "Who this row is about" and "who this run acts as" are two different questions, and this diff answers the second with the first.

It also explains the shape of the test. The tenancy: { enabled: false } resolves nothing pin passes only because its fixture declares no organizationField; add one and limb 0 wins. And this PR's own trigger TSDoc spells the precedence out as "tenancy.organizationFieldtenancy.tenantField → default" — this diff documented that it reads limb 0 while the key's contract forbids it from being a consumer at all.

Remedy is not this seat's to choose. Either (a) a maintainer ruling admitting a fourth consumer, which the contract explicitly requires, or (b) a read-side resolver that starts at limb 1 — skip organizationField, honour tenancy.enabled: falsenull, then tenantField, then the default column. ⛔ Not a local column read in the trigger: a second implementation of that precedence is the drift the shared resolver exists to end. (b) looks right on the merits — A′ asks "which plant does this record belong to", which is the wall question, not the stamp question — but it adds public surface to metadata-core and touches a key with a ruling on it, so it is being put to the maintainer rather than decided here.

Finding 2 — the changeset's export claim was false. Fixed in 5ddc993.

describeScheduleRunOwnership is not re-exported from packages/triggers/trigger-schedule/src/index.ts. The changeset claimed the package exports it. The code is right — the barrel's own note says an export whose only consumers live inside its own package belongs in a non-barrel module — and the claim was wrong. It mattered beyond wording: that false export was cited as part of the Clause-②: yes basis.

⚠️ The same false claim is in this PR's earlier comment, which says the change "adds an exported symbol (ScheduledRunOwnership, describeScheduleRunOwnership)". Correcting it here rather than editing that comment: only ScheduledRunOwnership and the runOwnership key — both on @objectstack/types — are public, and those alone carry Clause-②: yes.

Finding 3 — stated, not yet verified here

The review also reports a TSDoc/test contradiction. Beyond the precedence-docblock point folded into Finding 1 above, this seat has not pinned down what else it refers to and is not guessing at it or calling it benign. It stands open until the full verdict text is read.

Status correction

The earlier "Ready for review — all seven required contexts green" comment is superseded. CI being green was true and is beside the point: this is a prose contract in a docblock, which no gate reads. Seven required contexts, a local pnpm lint && pnpm test at 146/146, a full typecheck, and an ablation-verified pin set all passed over Finding 1 without noticing it.

⛔ This PR should not land until Finding 1 has a ruling or a redesign, and Finding 3 has been read. Not armed for auto-merge; not requesting review.


Generated by Claude Code

@hotlong
hotlong marked this pull request as draft September 17, 2026 15:27
…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>

hotlong commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

Review finding ② is fixed — 0752431d. Finding ① still blocks, and it is a ruling, not a patch.

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). ScheduledWorkPolicy documented runOwnership backwards for the switch-OFF state, in two places: the state table's OFF row printed 'unscoped' (moot), and the field's own docblock said "when the switch is off nothing binds, so this reports 'unscoped'". 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 asserted the true behaviour, so documentation was the only half asserting the opposite, and documentation is the half that changed. The code is deliberately left alone: runOwnership answers a question about the posture, requiresActingOrganization is the switch-gated half, and both consumers return early on !policy.enabled (schedule-trigger.ts:643, time-relative-trigger.ts:313) long before they read it. pnpm typecheck clean, 52/52 in env.test.ts.

① Blocking, unchanged — a fourth consumer of tenancy.organizationField. Verified against the contract rather than taken on the reviewer's word: packages/metadata-core/src/record-organization.ts pins that key to exactly THREE sanctioned consumers (audit stamping, the approval-row writer, the automation-run recorder) and says a fourth needs its own maintainer ruling first. The per-record sweep in time-relative-trigger.ts is a fourth, and it reads through limb 0, where the stamp key also outranks tenancy: { enabled: false }. ⛔ I am not touching that code until the maintainer rules; the two shapes on the table are (a) admit a fourth consumer, or (b) add a read-side resolver in metadata-core starting at limb 1. I recommend (b) and have not decided it.

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. credential/secret/password/apiKey appear zero times across all 21 changed files. The second clause is the finding; the first does not correspond to anything here.

CI: the seven required contexts were green on e856c009; 0752431d is doc-only and is re-running now.


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>

hotlong commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

The blocking finding is answered by redesign, not by a ruling — cda685b8

The review's finding ① was that this diff had become a fourth consumer of tenancy.organizationField, a key whose contract pins its consumers to three named platform-row writers. The remedy put to the maintainer was "a ruling, or a read-side resolver". It is the second, and it turns out to need no ruling at all: the new path never reads the key.

The defect underneath the finding was that one resolver answered two questions.

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>

hotlong commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

Served-tier: CONTRACT_REVIEW_TIER

Contract review

Reviewed head: cda685b8efbdd81c62ff833def942055b2943953

Checkout /home/user/objectstack-18378 prints that head (git rev-parse HEAD); merge-base with main is 588475c3. Every judgment below was read off that tree, not off the PR body. Ruling read: issue #18378 comment 5695424700 (A′: group + switch on binds without a declaration; time_relative runs act as the swept record's organization; a record-less undeclared cron is refused at the write, never a guessed fallback; reads stay group-wide; single / isolated / the switch untouched).

Measured on this head, read-only: packages/triggers/trigger-schedule 138/138, packages/metadata-core 283/283, packages/types/src/env.test.ts 52/52, packages/spec check:generated 15/15, scripts/check-adr-0087-registration.mjs exit 0 on the changeset, scripts/pm/check-widening-tells.mjs --declaration no fires no tell (20 of 23 files NOT MEASURED — no declared surface covers them; the declaration is yes anyway).

① Derived judgments

  1. BIND accept set widens: group + switch on now arms an undeclared time-triggered flow. packages/types/src/env.ts:363-366 (scheduledRunOwnershipFor: wall-less ⇒ 'unscoped'; postureUsesUnionScope'per-record'; else 'declared') and :376 (requiresActingOrganization: enabled && runOwnership === 'declared'), replacing enabled && postureEnforcesWall(posture). Both triggers gate only on that boolean: packages/triggers/trigger-schedule/src/schedule-trigger.ts:686, time-relative-trigger.ts:352. RIGHT — exactly the cell A′ reopened, and only that cell: isolated still refuses (env.test.ts:592, schedule-trigger.test.ts:635-651, time-relative-trigger.test.ts:808), single unchanged. The separating predicate is postureUsesUnionScope (packages/spec/src/security/tenancy-posture.ts:80-82, group only), and the live control at env.test.ts:604-611 asserts postureEnforcesWall('group') === true while requiresActingOrganization === false — a regression to postureEnforcesWall fails there and only there. Pinned as two separate cases rather than a loop (env.test.ts:580, :592), so re-merging them must delete an assertion.

  2. New required key runOwnership on the published ScheduledWorkPolicy, and new exported type ScheduledRunOwnership. env.ts:307-328 (type), :330-349 (interface, readonly runOwnership at :349), reachable through packages/types/src/index.ts:8 (export * from './env.js'). RIGHT. Additive for readers; the only producer is resolveScheduledWorkPolicy() and the only external reader is packages/services/service-automation/src/engine.ts:3413 (reads .enabled). TSDoc, code and pins now agree that it is a fact about the posture, not the switch: table row at env.ts:252 ("the posture's rule (moot)"), docblock :342-348, code :372 (scheduledRunOwnershipFor(posture), never gated on enabled), pin env.test.ts:541-556 (group'per-record', isolated'declared' with enabled:false). The earlier three-way mismatch is gone at this head.

  3. requiresActingOrganization narrowed from "any wall" to isolated only. Docblock env.ts:336-339 ("True only under isolated with the switch on") matches code :376. The bind-refusal sentence's own contract was rewritten to match: packages/spec/src/automation/schedule-organization.zod.ts:268-283 ("emitted by exactly one gate — tenancy posture isolated with … switched on"; "group is NOT a near-miss of isolated"). RIGHT in code and spec.WRONG in one docblock the PR touched: packages/triggers/trigger-schedule/src/schedule-trigger.ts:341-347, the header of refuseMissingOrganization (:412), still reads "⚠️ Under a WALLED posture (group / isolated) with scheduled work switched on, and nowhere else." That sentence is false at this head (:686 fires only under isolated) and contradicts the zod docblock this same PR rewrote. Same class as the finding the PR already fixed in 0752431d; it was missed here.

  4. RUN-time acting organization under group: declaration → swept record → nothing. time-relative-trigger.ts:676-680 (organization ?? (ownership === 'per-record' ? this.organizationOfRecord(…) : null)), stamped at :716, resolved at :772-800 through createRecordWallOrganizationResolver (:795), memoized per engine. RIGHT per A′. The discriminating pin reads the SET across one tick and the record↔org pairing (time-relative-trigger.test.ts:1342-1366, ['org_plant_a','org_plant_b']) — a value neither the old group (zero runs), single (both org-less) nor a declaration (both the same org) can produce; removing the per-record limb reddens it. Declaration outranks record and narrows selection (:1368-1390); a row with no organization stamps nothing, with a live sibling control (:1392-1415); tenancy: { enabled: false } resolves nothing even with a stray column (:1417-1442). 'unscoped' still never fills from the row: the pre-existing negative pin under single stands at :1170-1190. AutomationContext.tenantId (packages/spec/src/contracts/automation-service.ts:89-92) is what notify-node.ts:355 reads as the acting run's organization, so a per-record tenantId does put that plant's notifications in that plant's inbox.

  5. Sweep read reach unchanged — group-wide when undeclared. time-relative-trigger.ts:611 (context: { isSystem: true, …(organization !== null ? { tenantId } : {}) }), pinned time-relative-trigger.test.ts:1324-1340. RIGHT — A′ and ADR-0105 D1 (docs/adr/0105-…md:59-65: group-wide visibility and cross-org workflow "inherent to the shape", multi-plant MES example). The find passes only where and limit: maxRecords (:566-568) — no fields projection — so the organization column is on the fetched row in production and the whole-row test double is representative.

  6. Retired pin "never derived from the swept RECORD's own organization_id" — retired for group alone; the reason is quoted at the retirement site (time-relative-trigger.ts:697-715) and the single half of the prohibition is still pinned (test.ts:1170). RIGHT. The old ScheduleTrigger — switched ON under a wall group block is replaced by an isolated block (schedule-trigger.test.ts:635) and a new group block (:669-735: binds; run carries no tenantId key; no bootstrap/first-row fallback :704; declared acts as declared). RIGHT. Narrative nit only: isolated was already pinned before this PR at schedule-trigger.test.ts:337-341 and time-relative-trigger.test.ts:808 (verified at merge-base), so "the isolated half is that pin kept whole" is a re-pointed group pin, not a preserved one.

  7. Record-less undeclared cron under group: binds, run carries nothing, refused at the write — not converted into a bind refusal. schedule-trigger.ts:686 gates only on requiresActingOrganization; the run context omits tenantId (schedule-trigger.test.ts:681-702). The refusal relied on is pre-existing and unconditional under any wall: packages/objectql/src/tenancy/system-write-organization.ts:268-270 (if (args.posture !== 'single') return { kind: 'refuse', reason: 'walled-posture' }) for any object resolveTenantFieldName (:196-213) scopes. RIGHT per A′ ("refused loudly … never a guessed fallback"), and the boot-time warning is owed and delivered (describeScheduleRunOwnership, schedule-trigger.ts:449-461, :814). See ③ for what is asserted rather than pinned.

  8. New bind-line vocabulary describeScheduleRunOwnership. schedule-trigger.ts:449 is a module-level export function; it is NOT in the package barrel (packages/triggers/trigger-schedule/src/index.ts, none of lines 1-40 mention it) and the package's exports map exposes only "."dist/index.* (package.json:8-14). Not public surface; the changeset's corrected claim is true. The schedule bind line gains an ownership clause under every posture (previously none); RIGHT, and the changeset's "Upgrading" section says so.

  9. New public exports on @objectstack/metadata-core: resolveRecordWallOrganizationField (record-organization.ts:256-261) and createRecordWallOrganizationResolver (:340-342), reachable via packages/metadata-core/src/index.ts:130. One body, readStampKey selecting limb 0 alone (:272-290, if (readStampKey) at :282). RIGHT — and this is what makes the trigger not a fourth consumer of tenancy.organizationField. The key's contract is untouched and honoured: record-organization.ts:26-32 ("exactly THREE consumers … A fourth consumer needs its own maintainer ruling"), :158-160 ("a fourth consumer, or any read path, needs its own ruling first"), and the spec annotation beside the key (packages/spec/src/data/object.zod.ts, "refusal posture is UNCHANGED for a FOURTH consumer"; its .describe() names the three writers and says no read path reads it). The wall face never reads the key, so no ruling is owed. Pinned per limb against the stamp face where they can diverge (record-organization.test.ts:106-118 sys_api_key shape: stamp → active_organization_id, wall → null; :120-132 stamp key on a walled object: wall answers tenantField; :134-153 five agree-shapes) and end-to-end (time-relative-trigger.test.ts:1444-1489, which fails the moment the trigger is pointed back at createRecordOrganizationResolver). The stamp face keeps name, signature and answers (:208-213 delegates with readStampKey: true); the three sanctioned writers are untouched (suspended-run-store.ts:11,322 still binds the stamp face). The "twin of objectql's resolveTenantFieldName" claim holds: same three limbs (system-write-organization.ts:196-213: enabled: false ⇒ null → declared tenantField if present → organization_id if present → null).

  10. Published .describe() on ScheduleOrganizationSchema reworded (schedule-organization.zod.ts:155-157), propagated to the generated content/docs/references/automation/schedule-organization.mdx:158. RIGHT in substance; WRONG spelling on a published string: "acts as the swept record own organization" is missing the possessive (record's). It ships in the JSON schema and the reference page.

  11. ADR-0087 entry 18 amended (packages/spec/src/migrations/entries/semantic/18.schedule-flow-acting-organization-required.ts: surface, replacement, reason, acceptanceCriteria each gain their group row; the rejected slug='default' arm is recorded; the 2026-09-16 maintainer words are quoted verbatim and match the card). registry.ts is a true projection (check:generated ✓). RIGHT.

  12. os doctor ON-state fix text (packages/cli/src/commands/doctor.ts:276-283) now states the per-posture rule. RIGHT in content; the changeset omits the package — see ②.

  13. @objectstack/lint — comment-only (validate-flow-trigger-readiness.ts:668-676); no accept-set change. RIGHT.

  14. Docs (content/docs/automation/flows.mdx, deployment/tenancy-modes.mdx, deployment/environment-variables.mdx, deployment/production-readiness.mdx) agree with the code; MDX callouts are balanced (flows.mdx 2161-2253). No leftover copy of the retired rule found in content/docs or non-dist packages/** prose beyond item 3 (the remaining "group / isolated" hits are unrelated: OS_PLATFORM_OWNER_EMAIL, activation gate, ADR-0131). RIGHT.

  15. New warn-level degradation when the mounted engine exposes no getSchema (time-relative-trigger.ts:773-794), once per engine, pinned test.ts:1510-1537. RIGHT: writes are still refused loudly, so warn is the correct level; the plugin resolves the real objectql/data service (time-relative-plugin.ts:101-106).

  16. Plumbingtsconfig.json rootDir: "../.." + paths for metadata-core, vitest.config.ts alias, package.json dep, pnpm-lock.yaml. Not contract surface. The built dist/index.d.ts at this head keeps private recordOrgResolver; (:467) with no inlined metadata-core types, so emit is unaffected as claimed. Nit: package.json:5 rewrites as the escape — same value, stray churn.

② Semver level and changeset declaration

.changeset/group-scheduled-work-per-record-ownership.md declares @objectstack/types, @objectstack/spec, @objectstack/trigger-schedule, @objectstack/metadata-core at minor, with ! in the title. Level: correct — additive public surface (items 2, 9) plus a behaviour widening, under the repo's no-major convention (scripts/check-changeset-no-major.mjs); the body says what the ! marks (nothing admitted becomes refused; clock-driven work appears where an operator had none). Clause-②: yes (widening): correct — a new exported type, a new key on a published payload and two new package exports are the mechanical floor for yes. ADR-0087 not-required (already-registered schedule-flow-acting-organization-required): correct — entry 18 exists at the merge base (registered by .changeset/schedule-trigger-acting-organization.md; .changeset/scheduled-work-deployment-switch.md uses the same disposition), gate exit 0.

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" ✓ (.changeset/scheduled-work-deployment-switch.md still pending; no CHANGELOG in types/spec/trigger-schedule mentions OS_AUTOMATION_SCHEDULED_WORK_ENABLED; all four packages at 17.4.0); "both stamp-face exports keep their names, signatures and answers, limb 0 included" ✓; "ObjectStoreSuspendedRunStore resolves organizationOf(<subject record>) ?? ctx.tenantId" ✓ (suspended-run-store.ts:916).

One declaration defect: the changeset does not name @objectstack/cli. packages/cli/src/commands/doctor.ts:276-283 changes the text os doctor prints; @objectstack/cli is published (packages/cli/package.json:152-154, publishConfig.access: public); AGENTS.md:1064-1066 requires a changeset for anything that publishes, "never none"; and the sibling changeset for the same doctor text (.changeset/scheduled-work-deployment-switch.md:8) listed @objectstack/cli. Check Changeset is green only because no gate checks package coverage. Remedy: add "@objectstack/cli": patch. (@objectstack/lint needs nothing — comment-only.)

③ Boundary flags

  1. Stale docblock in a touched fileschedule-trigger.ts:341-347 still states the retired "WALLED posture (group / isolated) … and nowhere else" rule (item 3). Doc-only, but it is the header of the very function whose gate this PR narrowed. Fix the sentence to isolated.
  2. "One run carries ONE organization's opinion" is over-stated on the exact shape the split was made for. The history writer still binds the STAMP face (suspended-run-store.ts:11,322,916) while the sweep binds the WALL face. On a tenancy: { enabled: false, organizationField } object (sys_api_key, packages/platform-objects/src/identity/sys-api-key.object.ts:69) the sys_automation_run row is stamped with the stamp column while the run's ctx.tenantId is absent and its inbox/delivery writes are refused. That behaviour is defensible (the stamp says who the row is about; the acting identity is none), but the changeset (.changeset/…md:55-61), the spec docblock (schedule-organization.zod.ts, "Why group is optional" section) and the generated reference page assert agreement without the exception. Answered by assertion, not by a pin; no accept-set consequence.
  3. The record-less-cron refusal is asserted by reference, not pinned end-to-end here. The new pins stop at "no tenantId key on the run context" (schedule-trigger.test.ts:681-702); the "refused loudly at the first tenant-scoped write" outcome rests on system-write-organization.ts:268-270 and that module's own suite. Acceptable given the refusal is pre-existing and unconditional under any wall; noted because the PR's narrative states it as this diff's guarantee.
  4. CI on the reviewed head was in progress at review time (runs 35319770617 / 35319770625 started 2026-09-18 07:31Z: Check Changeset, Governed Surface Queue Guard, Type Check · source gates, Validate Package Dependencies green; Lint & Repo Gates, Type Check · workspace, Test Core, Build Core, Temporal Conformance, Dogfood Regression Gate running). The PR body's "all seven required contexts pass on e856c009" is three commits stale. That is the landing pre-check's item ③, not this verdict's; recorded so the adopter does not read the body as current.
  5. Published .describe() typo (item 10) — schedule-organization.zod.ts:156, regenerate schedule-organization.mdx:158 after fixing.
  6. describeScheduleRunOwnership's 'declared'-with-null branch is unreachable by construction (bind refuses first) and its 'unscoped' message is emitted for single only — verified, no flag.
  7. Nits: package.json:5 escape churn; the "isolated pin kept whole" narrative (item 6).

Implemented-by: session_01URii26ZSYx4xPZ9ai47ceH
Reviewed-by: session_01URii26ZSYx4xPZ9ai47ceH

Verdict: PASS WITH FINDINGS — the contract increment matches ruling A′ cell for cell, the fourth-consumer pin on tenancy.organizationField is honoured by construction and by discriminating pins, and the declared public surface is exactly what the tree exports. The findings are declaration- and prose-level, each with a one-line remedy: add "@objectstack/cli": patch to the changeset; correct schedule-trigger.ts:341-347 to isolated; fix the possessive in the .describe() string at schedule-organization.zod.ts:156 and regenerate; optionally qualify the "one opinion" sentence for the stamp/wall-divergent shape.


Adoption note — read this before reading the pair above as independence

The 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:

  1. Reviewed-by: equals Implemented-by:, so --pair will report SELF-REVIEW. An isolated review subagent has no session of its own, so the rulebook says to record the seat that adopts it — and this seat both wrote the diff and adopts the verdict. In the normal shape the two differ because the dev is a dispatched subagent (a branch) or a remote session. Here they cannot, and no spelling makes them.
  2. Served-tier: is the constant name, but the tier is a dispatch parameter rather than a self-reading. The subagent was dispatched at CONTRACT_REVIEW_TIER. The rulebook is explicit that configuration is not a reading and that get_session inside a subagent measures the dispatching session, so this seat cannot offer the self-measured stamp the fuse asks for.
  3. The reviewed head is superseded. cda685b8 is three commits back; the verdict's own flag ④ says as much about the body it read.

All three findings are fixed in d563fac6, each verified against the tree first rather than taken on the reviewer's word: "@objectstack/cli": patch added to the changeset; refuseMissingOrganization's header corrected to isolated with the predicate to gate on named; the .describe() string reworded ("acts as the organization of the record it swept" — the possessive form broke the single-quoted literal, which gen:schema caught) and gen:schema / gen:docs / gen:api-surface re-run. The optional flag ③.2 is taken too: the "one opinion" claim now carries its exception in both the spec docblock and the changeset. Measured after: spec 13688/13688, trigger-schedule 138/138, cli 3385/3385, check:generated 15/15.

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>

hotlong commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

Correction to the adoption note: --pair does not read SELF-REVIEW — it cannot read this pair at all, and the reason is the branch name.

That note predicted the machine reading instead of measuring it. Measured, at head 4584b00f:

node scripts/pm/check-clause2-carriers.mjs --pair 18420   →  exit 2 (UNJUDGED)

✗ UNJUDGED … the card's NEWEST claim comment (comment 5695433883) matches the claim marker but its Branch: directive parses to ZERO branches … Remedy — the CLAIMING SEAT, with one comment: name the branch on a Branch: line of its OWN (Branch: `claude/issue-<n>-<slug>`)

It stops upstream of the independence clause, so C4 never runs. Two things are true about that:

  1. The claim comment's shape is mine — it wrote the branch inside the Claim: sentence rather than on its own Branch: line.
  2. The remedy does not reach this pair anyway. CLAIM_BRANCH_SHAPE is /claude\/issue-\d+-[A-Za-z0-9][A-Za-z0-9._-]*/g, and this branch is claude/zealous-mendel-o0o6aq — the session's designated branch, which this seat may not rename on its own. A Branch: line carrying that name still parses to zero.

So --pair cannot reach exit 0 here without a branch rename, and behind it two more gaps would remain: the verdict of record names cda685b8 while the head is 4584b00f (base merges moved it), and the independence pair is one session on both sides.

⚠️ None of that is evidence about the code, and it is not being chased. What the checker reads is two lines of a comment; what says this diff is sound is elsewhere: all seven required contexts green on this head, an independent review that found three real defects, and those three fixed in d563fac6. Landing is the maintainer's call and their merge, not a gate this seat can satisfy from here.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

3 participants