Skip to content

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

Draft
hotlong wants to merge 16 commits into
mainfrom
claude/zealous-mendel-o0o6aq
Draft

hotlong wants to merge 16 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.

Why this 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 (platform-object-tenancy.ts's own evidence line). 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. Filling the acting context the same way makes one run carry ONE organization's opinion about who it belonged to.

Two findings that moved A → A′, both measured before the ruling

The card recommended plain A, whose record-less-cron cell fell back to the bootstrap organization (slug='default'). That arm was put to the maintainer as rejected, and the card's own ③ axis understated it:

  1. The fallback is not reliably there, and where it is, it is probably not the head office. Under a wall AuthPlugin skips its own default-organization bootstrap and hands the job to the enterprise organizations runtime, whose helper is admin-keyed — it answers no_admin for anyone but the resolved platform admin, so a group install with no such admin bootstraps no organization at all (walled-default-org-self-registrant.pin.test.ts). Where one does exist it is whichever organization the platform owner registered under: plausibly one plant of many. A would land a group-wide cron's notifications in one arbitrary plant's inbox — a wrong owner, silently authoritative to every report and export that filters by organization, not merely an unread one. A′ refuses instead, and avoids amending ADR-0093 D3 (defaultOrgId() answers null under any wall) or standing up a second resolver beside it.
  2. The record-derived half is already the resolution order elsewhere, so it narrows drift rather than inventing a rule. This closes the card's confidence gap ② on that side; the gap survives only for the record-less cron cell, which is the cell A′ refuses.

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.

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. Collapsing them is precisely what made group walled by analogy.
  • 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. Only a predicate on read reach says both. A regression to enabled && postureEnforcesWall(posture) passes every other pin and fails one named live control.
  • ⛔ No local organization_id read. Per-record ownership goes through the shared createRecordOrganizationResolver (@objectstack/metadata-core, a new workspace dep on trigger-schedule; deps are spec + zod only, no cycle). The column is whatever the object declares (tenancy.organizationFieldtenancy.tenantField → default), a platform-global object has none, and a second copy of that precedence living in a trigger is the drift that resolver exists to end.
  • 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, sending the operator to the flow when the cause is the composition. Said once per engine, naming the remedy. ⛔ Not error: the writes that matter are still refused loudly, so nothing is silently lost.

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, never filled from the row; under group "organizations it never declared" is the posture's own read reach.
  • ScheduleTrigger — switched ON under a wall becomes two blocks. The isolated half is that pin kept whole; the replacement site quotes the condition the old pin rested on ("while the question of which organization a group-wide sweep's inserts belong to was unanswered") so the reversal is legible rather than looking like erosion.

Tests

⚠️ 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, so a weaker pin passes on it too. The fixture puts matching rows in two organizations and reads ['org_plant_a', 'org_plant_b'], a value no previous behaviour could produce.

Reverse-verified: ablating the per-record limb turns 3 pins red including that one, while the other 57 stay green; the restore was confirmed byte-identical against HEAD before re-running.

Also pinned: a declaration still outranks the record (declaring narrows, never widens); a row with no organization stamps nothing, with a live control proving the same tick stamped a sibling; tenancy: { enabled: false } resolves nothing even with a stray column present; the no-getSchema degradation warns once; and group answers true to postureEnforcesWall while still not requiring the declaration.

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 (#18198's changeset is still pending in .changeset/), so the group-is-walled behaviour being amended has never appeared in a published version and no released consumer can be relying on it. ADR-0087 disposition is not-required (already-registered) — the ledger entry predates this diff at the merge base and gained its group rows here; registered would claim a registration this PR did not make.

Status — ready, green, waiting on contract review

All seven required contexts pass on e856c009: Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Merges cleanly against main (probed on a driver-free bare clone, which is what GitHub computes).

Locally on the same tree: pnpm lint && pnpm test → 146/146 tasks, pnpm typecheck → 0 errors across 143 packages, check:generated → 15/15 up to date.

⚠️ Lint & Repo Gates is worth a second look rather than a glance: its two earlier failures stopped at step #158 of 180, leaving 22 gates unmeasured — its own tail reporter calls that NOT MEASURED, not "passed". The passing run completed the job, so those 22 have now actually executed.

Three defects CI caught that local work had not, recorded because two are the kind that ship silently — a malformed ADR-0087 marker, and the new metadata-core dependency resolving through dist/ on both the test and the type side, which made the group pins a verdict about a built artifact rather than about the checkout. Detail in this comment.

Not armed for auto-merge, deliberately. Clause-②: yes owes an independent contract review, and a verdict from the seat that wrote the diff does not count as one — check-clause2-carriers says so in as many words, and its named remedy is a seat that did not write the diff.

🤖 Generated with Claude Code

https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH

…, owning its writes per record

`group` was walled by analogy with `isolated`: with the deployment switch on, every
time-triggered flow had to declare `config.organization` or it did not arm. The
recorded reason was that which organization a group-wide run's inserts belong to had
not been thought through. It is answered now — the swept record's own, which is the
subject-first order `ObjectStoreSuspendedRunStore` was already ruled to use for
`sys_automation_run` (`organizationOf(record) ?? ctx.tenantId`). Before this change the
two halves disagreed under `group`: the history row was stamped from the record while
the inbox and delivery rows followed an acting context that could not exist there.

- `ScheduledWorkPolicy` gains `runOwnership: 'unscoped' | 'per-record' | 'declared'`,
  a second axis from `requiresActingOrganization`: that boolean decides whether BIND
  refuses, this decides what a run that DID bind carries. Collapsing them is what made
  `group` walled by analogy.
- The separating predicate is `postureUsesUnionScope`, not `postureEnforcesWall` —
  `group` does enforce a wall, which is exactly why its reads span the group and its
  writes still need an owner.
- `requiresActingOrganization` narrows to `isolated` only.

The rejected arm is recorded in the ADR-0087 entry because it is the one a later reader
will re-propose: falling back to the bootstrap organization (`slug='default'`). Under a
wall that organization is minted admin-keyed by the enterprise organizations runtime and
may not exist at all; where it does, it is whichever organization the platform owner
registered under — plausibly one plant of many. A record-less undeclared run is refused
at its first tenant-scoped write instead.

Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH
Co-authored-by: Claude <[email protected]>
The binding half of ruling A'. With the deployment switch on:

- `isolated` — unchanged: an undeclared flow is refused at bind.
- `group` — an undeclared flow now ARMS. A `time_relative` sweep reads group-wide
  (inherent to the posture, ADR-0105 D1) and stamps each run it launches with that
  record's own organization, resolved through the shared
  `createRecordOrganizationResolver` rather than a local `organization_id` read: the
  column is whatever the object declares, and a second implementation of that
  precedence living in a trigger is the drift the shared resolver exists to end.
- A plain `schedule` (cron) flow has no record, so an undeclared one under `group`
  carries nothing and is refused at its first tenant-scoped write. Deliberately NOT a
  bind refusal: a cron flow that only reads, or writes only objects declaring
  `tenancy: { enabled: false }`, has no write to be refused and must still run.
  Refusing it at bind would be ruling G again under a new name.

The "never filled from the swept row" pin is retired for `group` ALONE, and the comment
records why per posture: under `isolated` it stands; under `single` the key is still
omitted, never filled from the row; under `group` "organizations it never declared" is
the posture's own read reach, not a boundary violation.

Both triggers now share one bind-line vocabulary (`describeScheduleRunOwnership`) so they
cannot describe one deployment differently. The undeclared-cron-under-`group` case warns
at BOOT as well as at the write: the refusal is correct but arrives at the first tick,
which may be hours away and unattended.

Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH
Co-authored-by: Claude <[email protected]>
The `group` pins read the SET of organizations across the runs one tick launched, not
"a run was stamped": the behaviour this replaces stamped every run in a batch alike, so
a pin reading only "the run carries an organization" passes on it too. Two plants' rows
in one tick yielding ['org_plant_a', 'org_plant_b'] is a value no previous behaviour
could produce.

Retired pins are replaced, not deleted, with the reason they rested on quoted at the
replacement site: `ScheduleTrigger — switched ON under a wall` becomes two blocks, and
the `isolated` half is the old pin kept whole.

Also pinned: a declaration still outranks the record (declaring narrows, never widens);
a row carrying no organization stamps nothing, with a live control proving the same
tick stamped a sibling row; `tenancy: { enabled: false }` resolves nothing even with a
stray column present; and the no-`getSchema` degradation warns once.

Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH
Co-authored-by: Claude <[email protected]>
…he release note

Three doc surfaces carried "under a walled tenancy posture (group/isolated)" as one
rule; each now splits the two. The flows page gains the `group` callout and the
record-less-cron warning, and states why a near-miss spelling is NOT reported there:
an undeclared flow is a legal shape under `group`, so the trigger cannot tell "meant to
declare, misspelled it" from "meant not to declare".

The changeset declares `Clause-②: yes (widening)` rather than BREAKING: nothing that
worked stops working and nothing admitted becomes refused — the accept set widens in one
cell. It also records that the switch this depends on ships unreleased alongside the
change, so the behaviour being amended has never appeared in a published version.

Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH
Co-authored-by: Claude <[email protected]>
…isolated)"

The doctor's ON-state fix text told an operator that a time-triggered flow under
group/isolated must declare config.organization. Under ruling A' that is true of
`isolated` alone; `group` takes the declaration as optional, and the record-less cron
case there has its own remedy worth naming at the one place an operator goes looking.

Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH
Co-authored-by: Claude <[email protected]>
…led posture"

The retired-rule note explained where the bind-time near-miss scan still fires. That
door narrowed with ruling A': under `group` an undeclared flow is a legal armed shape,
so a near-miss spelling there cannot be told apart from a deliberate omission.

Comment only — no rule, severity or finding changes.

Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH
Co-authored-by: Claude <[email protected]>
@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 5 package(s): @objectstack/cli, @objectstack/lint, @objectstack/spec, @objectstack/trigger-schedule, @objectstack/types, touching 14 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 51297e9ee17db23f50d38c1431f5a4dcc47632ffpackageMentionDocs.

Which tree this was computed on

This run read content/docs from d96619c86eab8f5be6f221a5cf859a5155bec6c6 — the merge of head 0752431da60deb0ec97650515ac08550d967c03f into base 51297e9ee17db23f50d38c1431f5a4dcc47632ff, 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 d96619c86eab8f5be6f221a5cf859a5155bec6c6 && git checkout d96619c86eab8f5be6f221a5cf859a5155bec6c6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 51297e9ee17db23f50d38c1431f5a4dcc47632ff 0752431da60deb0ec97650515ac08550d967c03f && git checkout -B drift-repro 51297e9ee17db23f50d38c1431f5a4dcc47632ff && git merge --no-ff 0752431da60deb0ec97650515ac08550d967c03f

node scripts/docs-audit/affected-docs.mjs --json 51297e9ee17db23f50d38c1431f5a4dcc47632ff

⚠️ 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 51297e9ee17db23f50d38c1431f5a4dcc47632ff → 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 <[email protected]>
…a top-level arm

The parser accepts exactly two forms — `registered <ids>` and
`not-required (<category> [ids]) <why>`. `already-registered` is one of the second
form's categories, and it is the honest one here: the entry predates this diff at the
merge base, so `registered` would claim a registration this PR did not make.

Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH
Co-authored-by: Claude <[email protected]>
The title carries a breaking marker while the body said the accept set widens; both are
true and the body now says so together. Nothing admitted becomes refused, but on a
`group` deployment with the switch already on, flows that were refused at bind now arm
and run — clock-driven work appearing where an operator had none is what earns the
banner, even though no consumer has to change anything.

Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH
Co-authored-by: Claude <[email protected]>
TS2531 at the DTS build: a mutable class property does not stay narrowed across the
assignment that populates it, so the property read after the cache-fill was possibly
null. Reads through a local instead of asserting with `!` — the null branch is the one
thing worth keeping honest here, since it is what a host mounting a non-engine adapter
actually hits.

Caught by the DTS build, not by tests: vitest does not type-check.

Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH
Co-authored-by: Claude <[email protected]>
…rule

The docs-drift check listed these; all three genuinely stated the pre-A' rule.

- environment-variables.mdx — the OS_AUTOMATION_SCHEDULED_WORK_ENABLED row
- production-readiness.mdx — the go-live checklist item
- tenancy-modes.mdx — a `group · isolated` table row I missed when fixing the env
  table on the same page, plus the paragraph under it explaining why `group` was
  walled "until [the ownership question] is decided". That question is the one this
  change answers, so the paragraph now says what the answer is rather than that one
  is pending.

Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH
Co-authored-by: Claude <[email protected]>
Both are pure projections of edits already in this branch — the amended ADR-0087 entry
18 and the ScheduleOrganizationSchema `.describe()`. No hand edits.

gen:docs refused the first attempt because the gitignored packages/spec/json-schema tree
was older than src, and rendering reference pages from a stale tree would have described
sources the run never read. Generated that tree first (gen:schema), then the docs.

Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH
Co-authored-by: Claude <[email protected]>
…st config

`check:test-source-alias` caught the new dependency resolving through `dist/`. That made
the `group` pins — which assert each run is stamped from its OWN swept record — a verdict
about a built artifact rather than about `resolveRecordOrganizationField`'s precedence as
it stands in this checkout, which is exactly what those pins exist to hold.

The gate's own framing is why this matters: the dangerous case is a test that PASSES.
Mine did, against dist. Re-run against source after aliasing: 106 pass.

Anchored regex, array form, for the reason the sibling `@objectstack/types` entry states
at length — a bare string `find` matches by prefix and would swallow subpaths into
`…/src/index.ts/<sub>`.

Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH
Co-authored-by: Claude <[email protected]>
`check:type-source-resolution` — the type-side sibling of the vitest alias gate — caught
the new dependency's TYPES resolving through `dist/`, making this package's typecheck a
verdict about build state rather than about the checkout.

Took the `paths` route, not the registry one. Widening that registry is maintainer-only:
this package's `@objectstack/types` row was admitted by maintainer ruling on #17396, and
the registry's own docblock says the row is a signature, not a door.

`paths` requires widening `rootDir` from `./src` to `../..`, because redirecting the dep
to source puts its files in this program and `rootDir` is enforced even under `--noEmit`.
That route is not always available, so it was MEASURED first: 0 TS6059 with both lines in
place (`service-automation` measured 647 for its own case and ruled `paths` out). Same
move `plugin-approvals` records for this exact dependency and `rest` for #9960; emit is
unaffected since this package builds with tsup and typechecks with --noEmit.

Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH
Co-authored-by: Claude <[email protected]>
@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

… package export

The independent contract review on #18420 caught this and it is real: the changeset
claimed `@objectstack/trigger-schedule` exports the helper. It does not — the package
barrel does not re-export it, and the barrel's own note says an export whose only
consumers live inside its own package belongs in a non-barrel module. So the code is
right and the claim was wrong.

This mattered beyond wording: that false export was cited as part of the `Clause-②: yes`
basis. The declaration still stands, on the surface that IS public — `ScheduledRunOwnership`
and the `runOwnership` key on `@objectstack/types` — and the changeset now says so.

Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH
Co-authored-by: Claude <[email protected]>

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 <[email protected]>

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

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

2 participants