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 4584b00f — Lint & 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 e19ae6708ad9e10ba0b24919a54f4c0fbfa5c62f → packageMentionDocs.

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 test → 146/146 tasks, pnpm typecheck → 0 errors across 143 packages, check:generated → 15/15 up to date.

Three defects CI caught that local work had not

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

  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

…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. Plumbing — tsconfig.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 file — schedule-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

@os-litant
os-litant marked this pull request as ready for review September 18, 2026 14:43
@os-litant
os-litant enabled auto-merge September 18, 2026 14:43
@os-litant
os-litant added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit 0a56d3b Sep 18, 2026
52 checks passed
@os-litant
os-litant deleted the claude/zealous-mendel-o0o6aq branch September 18, 2026 15:34
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…asure — narrow `DashboardWidgetSchema.values` for the metric/kpi/gauge/solid-gauge/bullet family (objectui#8894 ruling D) (objectstack-ai#18720)

Fixes objectstack-ai#17779
Clause-②: yes (narrowing)

Executes maintainer ruling **D** on objectui#8894 (decision batch objectstack-ai#119
item 4, 2026-09-12 「同意」) under the standing rule 「协议不正确的应该先修改协议。」 —
judge the protocol wrong: a metric-family widget takes exactly one
measure. The direction was not re-opened here.

## What changed

`DashboardWidgetSchema.values` was `z.array(z.string()).min(1)` with
**no upper bound on any widget type**, so a `metric` tile could declare
three measures; the dataset query selected and computed all three and
the tile rendered `values[0]`. The other two were queried and dropped on
the floor (objectui#7293 defect 1). objectui#8887's sub-caption made the
tile honest about dropping them; it did not make the document legal.

- `checkDashboardWidgetMetricMeasureArity` — a new exported object-level
check, chained onto the same door by identifier, refusing more than one
measure on `metric` / `kpi` / `gauge` / `solid-gauge` / `bullet` and on
a widget that declares no `type` (it defaults to `metric`, and the
message says so rather than claiming the author wrote it). One `custom`
issue at `values`, naming the widget's `id`, the count, and the authored
`type`, and prescribing one measure per tile — "make N tiles for N
measures" — plus the visuals that DO render several numbers.
- **Exactly one is a conjunction**: the field's own `.min(1)` still owns
the empty array (`too_small`, unchanged, and the new check deliberately
adds no second issue there); the new check owns the upper bound.
- `.changeset/17779-...` — `minor`, **BREAKING** banner, ADR-0087
disposition `registered
dashboard-widget-metric-family-multi-measure-refused`.
-
`packages/spec/src/migrations/entries/semantic/18.dashboard-widget-metric-family-multi-measure-refused.ts`
— one new entry file, plus the `gen:migration-registry` lap. No other
file in that directory was touched and nothing was hand-edited inside
the generated regions of `registry.ts`.
- The `values` doc string now states the arity rule it enforces, so the
generated reference page stops saying only "at least one".

## The three questions the dispatch asked, answered by measurement

### 1. `superRefine`, not a per-type union arm — because a union
destroys every other diagnostic on this door

Eight widget bodies through `z.union([metricArm, otherArm])` versus one
more `.superRefine` on the strict object, measured on this tree:

| body | union arms | the spelling shipped |
|---|---|---|
| `bogusProp` on a widget | `(root) invalid_union: Invalid input` | the
strict-object refusal, naming the key + the history sentence |
| `categoryField` / `valueField` | `(root) invalid_union: Invalid input`
| the `WIDGET_GUIDANCE_SETS` ADR-0021 prescription |
| `titel` | `(root) invalid_union: Invalid input` | "Did you mean
`titel` → `title`?" |
| `type: 'ziggurat'` | `(root) invalid_union: Invalid input` |
`invalid_value` at `type`, listing all twenty |
| `metric` + 3 measures | `too_big` at `values` | the curated `custom`
refusal at `values` |

Four of eight bodies lose their whole diagnostic to one bare `Invalid
input`. That is not a new observation on this file: the `compareTo`
docblock already records it for the same reason (objectstack-ai#5014 — "a union
collapses into one bare `Invalid input` on the wire … A plain strict
object's errors reach the author"), and `view-union-diagnostics.test.ts`
is the entire apparatus objectui needed **because** `ViewMetadataSchema`
is a union. A second union here would commission that apparatus again to
buy a refusal the object-level form gives for free. Second datum,
measured: zod 4.4.3 throws `Cannot overwrite keys on object schemas
containing refinements. Use .safeExtend() instead` on a plain
`.extend()` that redeclares a key, so the arms cannot even be built from
the existing door without `.safeExtend()` or a duplicated declaration.

### 2. `major` does collide with `check-changeset-no-major` — so the
changeset is `minor`

The guard is **armed**: there is no `.changeset/pre.json`, so the RC
exemption does not apply, and the only other route is the `allow-major`
PR label whose own error text says "a whole-stack major release is
genuinely intended" — false for this PR. Its header states the
convention: every publishable package is in the Changesets `fixed`
group, so one `major` promotes all ~70 packages; during the launch
window a breaking change ships `minor` and **breaking-ness is carried by
the BREAKING banner plus the ADR-0087 disposition, not by the bump
level**. `pr-automation.yml`'s "WHICH LEVEL" prose says the same in the
place the author reads it. So the card's "major changeset" is satisfied
as `minor` + `**BREAKING**` + `registered ...`, and
`check-adr-0087-registration --base origin/main` reads the changeset
back as `[BREAKING+bang+clause-②-narrowing] registered
dashboard-widget-metric-family-multi-measure-refused`.

### 3. The migration entry's acceptance criteria, re-derived from what
the code refuses

Not a restatement of the card. Two things the card's wording implies
that the machinery does **not** do, both measured and both written into
the entry:

- **The TODO cannot name your dropped measures.** `applyMetaMigrations`
maps `step.semantic` straight onto the result (`chain.ts`) with no
per-document interpolation and no filtering by whether the stack even
carries the shape, and `SemanticMigration` has only static string
fields. `os migrate meta` therefore prints the entry's prose, not a
list. The **refusal** is what names them, per widget, on the re-parse —
so the entry tells the author to drive the fix off `os build`, not off
the migrate output.
- **Splitting into N tiles is not attempted**, as the card says — and
the entry states why in the registry's own terms: N tiles need N ids and
N boxes on a 12-column grid, which is a layout fact about a dashboard
the registry has never seen.

The rest of `acceptanceCriteria` is the measured accept/refuse matrix:
which door refuses (publish, not objectui's `.shape`-mirror editor), the
empty-array carve-out, the aborting `invalid_value` on an unknown
`type`, the un-reachable "does this measure exist in the dataset", and
the fact that `.omit()` / `.pick()` / `.partial()` already threw before
this change.

## Controls

**LIT** — a legal single-measure metric tile parses **identically before
and after**, and the non-metric families are untouched. Sixteen bodies
through `DashboardWidgetSchema.safeParse`, before and after the change:

| body | before | after |
|---|---|---|
| `metric` + 1 measure | ACCEPT, `values: ["amount_sum"]` | ACCEPT,
`values: ["amount_sum"]` |
| `metric` / `kpi` / `gauge` / `solid-gauge` / `bullet` + 2–3 measures |
ACCEPT (all five) | REFUSE `values:custom` (all five) |
| no `type` + 3 measures | ACCEPT, `type: "metric"` | REFUSE
`values:custom` |
| `bar` / `line` / `table` / `pivot` / `funnel` + 3 measures | ACCEPT |
ACCEPT (unchanged) |
| `metric` + `values: []` | REFUSE `values:too_small` | REFUSE
`values:too_small` (one issue, not two) |
| `type: 'ziggurat'` + 3 measures | REFUSE `type:invalid_value` | REFUSE
`type:invalid_value` (alone) |
| `metric` + 3 measures + `bogusProp` | REFUSE `unrecognized_keys` |
REFUSE `unrecognized_keys` |

The whole taxonomy is covered by a pin that asserts the metric family
plus the fifteen others **is** `ChartTypeSchema.options`, so a new chart
type cannot land uncovered by either list.

**DARK** — things that must read **0**, with paths and counts:

- `.min(1)` **array** keys in `packages/spec/src/ui/dashboard.zod.ts`
other than `values`: **0**. The file has exactly two `.min(1)` code
sites at the branch point — `values` (line 706) and `dashboard.columns`
(line 1151, `z.number().int().min(1).max(24)`, a number bound, not an
array). The latter is byte-identical after the change; every other new
`.min(1)` occurrence in the file is inside a docblock.
- `ReportSchema.values` (`packages/spec/src/ui/report.zod.ts`, lines 237
and 314) is a separate declaration, `optional()`, with no `.min(1)` and
no arity check, and its `type` enum (`tabular` / `summary` / `matrix` /
`joined`) contains **0** metric-family members. Untouched, and not the
same defect.
- Fleet census over every tracked `.ts` / `.tsx` / `.json` / `.mdx` /
`.md` / `.yaml` **at the branch point** `72dd95fa5a`: **187**
brace-local literals carrying a `values: [...]`, **39** of them on a
metric-family `type` (both lit controls), and **0** of those carrying
more than one measure. Nothing in the monorepo moves. On this branch the
same scan reads 205 / 49 / **7**, and all seven are the fixtures this PR
added.
- `check:authorable-surface` is green with no regeneration: **0**
authorable keys move. `check:api-surface` reports `0 breaking
(removed/narrowed), 1 added` — the new exported check.

## Verification

Red before green, with the mutation proved on disk and the restore
hash-verified:

```
HEAD blob     : 30c6d78
worktree blob : 30c6d78   (at HEAD before the mutation)
anchor occurrences BEFORE: 1   AFTER: 0   injected line: 1
mutated blob  : 90548649227c3971f16b7dc85b02e1bab8155f96   (differs -> the edit really landed)
RED   vitest exit=1   17 failed | 205 passed (222)
restored blob : 30c6d78   git diff HEAD on the path: empty
GREEN vitest exit=0   222 passed (222)
```

The mutation removed only the
`.superRefine(checkDashboardWidgetMetricMeasureArity)` attachment,
leaving the function declared — so the 17 reds are the door's behaviour,
not a compile failure. The script carried a `trap ... EXIT INT TERM`
restore against an absolute `git rev-parse --show-toplevel` path,
restored with `git checkout HEAD -- path` (never a bare `git checkout
--`), and proved the restore by blob hash **and** an empty `git diff
HEAD`.

- `pnpm --filter @objectstack/spec test` — **486 files / 13933 tests
passed**, exit 0.
- `pnpm --filter @objectstack/spec typecheck` — exit 0
(`check:scripts-typecheck` and `check:test-typecheck` included; the
test-layer ledger held at 54 files / 259 errors / 144 pinned signatures,
shrink-only).
- `pnpm --filter @objectstack/spec check:generated` — **all 15 generated
artifacts up to date**, exit 0, after regenerating exactly the three it
proved stale (`api-surface/`, `export-origins/`,
`content/docs/references/**`).
- Changeset gates: `check-adr-0087-registration --base origin/main` exit
0 (+ `--self-test`, 384 assertions), `check-changeset-no-major --base
origin/main` exit 0, `check-empty-changeset --base origin/main` exit 0.
- `pnpm check:nul-bytes` exit 0 (8812 text files, no raw control bytes),
plus `check:widget-option-census`, `check:liveness`,
`check:exported-any`, `check:dual-source-exports`,
`check:entry-nameability`, `check:empty-state`,
`check:cross-package-test-inputs`, `check:test-source-alias`,
`check:type-check-coverage`, `check:merge-driver`,
`check:pm-widening-tells`, `check:spec-docblock-symbol-anchors`,
`check:dts-closure`, `check:published-files`, `check:spec-parsed-alias`,
`check:page-declaration-shape`, `check:corpus-claim-drift`,
`check:skill-examples`, `check:docs-transcript-drift`,
`check:doc-formula-expressions`, `check:variant-docs`, `check:llms-txt`,
`check:yaml-examples`, `check:objectui-pin-citations`, and the ten doc
gates the regenerated `.mdx` newly derives — every one exit 0.
- **Repo-wide lint, not a narrowing**: `node --stack-size=4000
node_modules/eslint/bin/eslint.js . --no-inline-config --format json` at
`ea17ab8491`, 81s — **6822 files linted, 0 errors, 0 warnings**, exit 0.

## Migration-entry adjacency — checked, not assumed

`packages/spec/src/migrations/entries/` is one file per entry and the
entries README records the measured objectstack-ai#8344 table: two in-flight
registrations merge clean **unless** their ids are adjacent in sort
order or both are the first entry of a new major. Enumerated the `18.*`
semantic directory and every open PR's file list on 2026-09-17:

- In-flight ADDED semantic registrations:
`ui-list-view-groupbyfield-padded-refused` (objectstack-ai#18695),
`structured-region-body-pause-and-end-refused` (objectstack-ai#18688),
`evaluated-expression-slots-source-required` (objectstack-ai#18638),
`manifest-id-reverse-domain-required` (objectstack-ai#18319). (objectstack-ai#18420 modifies an
existing entry, which is not an insertion.)
- This entry's immediate neighbours in the sorted set are
`dashboard-header-modal-target-page-only` and
`dashboard-widget-stage-order-non-funnel-refused` — **both already
landed on `main`**, neither in flight — and it is not the first entry of
major 18. Neither ejection row applies. The seat's expectation about
objectstack-ai#18695 held, and was verified rather than assumed.

The two projections the README names came back **byte-identical, and
that is correct rather than a skipped step**: `build-spec-changes.ts`
and `build-upgrade-guide.ts` both loop `for (major =
MIGRATION_SUPPORT_FLOOR + 1; major <= PROTOCOL_MAJOR; major++)`, and
`PROTOCOL_MAJOR` is **17** while this entry registers under **18**. Both
were regenerated anyway and `check:spec-changes` / `check:upgrade-guide`
are green.

## Acceptance notes

Noted, not filed — neither is a reproducible defect, a contract
violation, or a metadata-authoring trap:

- **zod 4.4.3 refuses `.extend()` that overwrites a key on a refined
object** ("Use `.safeExtend()` instead"), measured here while probing
the union spelling. It is a trap for the next author who mirrors or
re-arms this door — recorded in the new check's docblock and in the
migration entry, which is where that author looks. Successor: whoever
lands objectui#8894's half, which must re-attach this export onto a
`.shape` mirror.
- **An ADR-0087 semantic entry cannot name per-document values.**
`applyMetaMigrations` emits `step.semantic` unconditionally and
`SemanticMigration` carries only static strings, so a card instruction
of the form "emit a structured TODO naming X" is unsatisfiable as
literally written — the refusal message is the only per-document
channel. Recorded in this entry's `acceptanceCriteria`. Successor: the
next card that writes that instruction.

## Downstream, not in this PR

Card item 3 (objectui's contract twins gain the refusal pin; the runtime
warning becomes the door refusal) is the objectui half and objectui#8894
is `pm:blocked` on this card. Nothing in `../objectui` was touched.
Until that package imports and chains
`checkDashboardWidgetMetricMeasureArity`, its `.shape`-mirror editor
keeps accepting three measures on a `metric` and the author meets this
refusal at publish — stated in the check's docblock and in the migration
entry rather than left implied.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_

---

> ⏱️ **席位代改正文(dev 只写一次,⛔ 不 PATCH 正文;事后要改的由本席代写)。** 两处:
>
> - **`applyMigrationChain` → `applyMetaMigrations`(2 处)** ——
前者在树上**不存在**;
> 真函数是 `packages/spec/src/migrations/chain.ts:68`,CLI 调它,根 api-surface
导出它。
>   同一处错名也写进了 ADR-0087 语义条目、并经 `gen:migration-registry` 复制进
>   `registry.ts:6765` —— 那段文本会被 `os migrate meta` 在协议 18 打印出来,
>   所以读者照着 grep 会一无所获。已随 `3b15ca1254` 修正(条目 + 重生成,⛔ 未手改 registry.ts)。
>   ⭐ 这一条由**达档隔离契约复核**判出(记录见下方 PASS/FAIL 评论),⛔ 不是本席自己看出来的。
> - **`Clause-②: no (narrowing)` → `yes (widening)`** —— 该行**只定路由**,⛔
非终审:
>   章程原文「只定是否必过席内契约复核的保守方向」,机械地板「新导出符号…恒 `yes`」。
>   本 diff 在 `api-surface/ui.json` 上**净增一个导出符号**
> (`checkDashboardWidgetMetricMeasureArity`,+1 / 移除 0,本席对着 merge-base
`72dd95fa5a` 实测),
>   ⇒ 地板落在 `yes`。认领侧早已是 `yes (widening)`,正文与 changeset 两个载体**落后于它**;
>   changeset 已随 `881db1280d` 对齐,并在行内写明两条轴(接受集**收窄**、公开面**扩大**),
>   免得 CHANGELOG 读成「本改动放宽了行为」。
>
> ⚠️ **破坏性未受影响**:`check-adr-0087-registration` 仍读作 breaking,
> 经 `**BREAKING**` 横幅与摘要里的 `!`;它失去的 `clause-②-narrowing` 信号从来不是唯一载体
> (实测 `[BREAKING+bang]`,exit 0)。
>
> ⏱️ **再正一次(席位):`yes (widening)` → `yes (narrowing)`。** 上一版本席以为「收窄行为 +
扩大公开面」在这套两态词表里没有正确拼法,于是取了 `widening`
并写了一段话解释「它不是那个意思」。**那个前提是错的**:`readClause2Line` 认 `yes
(narrowing)`,而`check-adr-0087-registration` 的自测逐字命名了这个形状 ——「the
`narrowing` arm beside a `yes` value — a diff that **widens AND
narrows**」。⇒ 值仍是 `yes`(机械地板:新导出符号),但**臂**改回
`narrowing`,`clause-②-narrowing` 信号随之回到 ADR-0087 门禁(实测
`[BREAKING+bang+clause-②-narrowing]`,exit 0)。⭐ 这一条由第二次达档复核在 ③
里作为**边界旗标**提出,⛔ 不是 FAIL;本席自己验过词表才动手。⚠️ 顺带一提 `no (widening)` 读作
**malformed** —— 臂不是自由的:`no` 只配 `narrowing`,`yes` 两者皆可。

---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…O_API_KEY row (objectstack-ai#18959)

Fixes objectstack-ai#18143

Clause-②: no

## The remainder — one line, one file

This card named **four** sites. PR objectstack-ai#18573 landed three of them;
`content/docs/ai/connect-mcp.mdx` belongs to objectstack-ai#17648. What was left is
the fourth: the `OS_MCP_STDIO_API_KEY` row in
`content/docs/deployment/environment-variables.mdx`, located **by
content**, not by the line number the card quotes.

| | the cell |
|:--|:--|
| before | … Mint one from **Setup → Connect an Agent** (or `POST
/api/v1/keys`). … |
| after | … Mint one from the **Connect an Agent** page — **Account →
Developer** for any signed-in user, **Setup → Connect an Agent** for
platform admins — or `POST /api/v1/keys`. … |

One line in, one line out. It is a table cell in a long Markdown table,
so the two-door sentence is compressed to fit: pipe count unchanged (5),
row count unchanged (133 `OS_` rows), still a single line.

## Why the old cell was wrong

`SETUP_APP` declares `requiredPermissions: ['setup.access']`, and a
permissionless principal gets `403 PERMISSION_DENIED` on
`/api/v1/meta/apps/setup`. A **direct minting instruction** naming only
the Setup door therefore tells a non-admin to take a path they cannot
take. Ruling objectstack-ai#16746 (decision batch objectstack-ai#85) delivers the page to them
through a `navigationContributions` entry in the **`account`** app — app
`account`, group `grp_account_developer` (label **Developer**), item
`nav_connect_agent` (label **Connect an Agent**), package id
`com.objectstack.account`. The Setup entry **stays** for admins,
deliberately.

So the fix is **name both doors**, ⛔ not replace Setup with Account —
the shape PR objectstack-ai#18142 and PR objectstack-ai#18573 established. The wording here is
copied from the two sibling pages rather than invented as a fourth
spelling:

- `content/docs/api/index.mdx:68-69` — "…from the **Connect an Agent**
page in the Console — **Account → Developer** for any signed-in user,
**Setup → Connect an Agent** for platform admins."
- `content/docs/getting-started/build-with-claude-code.mdx:435-436` —
"…lives on the **Connect an Agent** page: **Account → Developer** for
any signed-in user, **Setup → Connect an Agent** for platform admins."

## Post-condition probe — written BEFORE the edit, and deliberately NOT
"Setup goes to 0"

An earlier round's first probe was "`Setup → Connect an Agent` must go
to 0 in this file". That probe is **wrong for this card**: the correct
end state keeps the Setup door named, so it would read a correct landing
as a half-done one. The post-conditions here are about the **Account
door appearing alongside**.

Every count is taken on a **whitespace-flattened** file, so wrapped
prose cannot give a false zero, and every zero is paired with a control
from the same population that must hit.

| # | reading (flattened) | before | after | post-condition |
|:--|:--|--:|--:|:--|
| A | this file, `Account → Developer` | 0 | **1** | ≥ 1 — the Account
door appears |
| B | this file, `Setup → Connect an Agent` | 1 | **1** | ≥ 1 — Setup
**stays** named, for admins |
| C | CONTROL, this file, `Connect an Agent` unprefixed | 1 | 2 |
nonzero both sides — the reader has a pulse |
| D | table integrity: `OS_` rows / pipes in the row / lines for that
key | 133 / 5 / 1 | 133 / 5 / 1 | unchanged, single line |
| E | CORPUS CONTROL over `content/docs/**/*.mdx` (404 files), `Connect
an Agent` unprefixed | 10 | 11 | nonzero — the corpus reader has a pulse
|

Corpus-level close-out: the Setup door is still named in exactly **4**
files (unchanged by design), and **every one of the 4 now also names the
Account door** — carriers naming the Setup door but not the Account
door: **0**.

| carrier | `Setup → Connect an Agent` | `Account → Developer` |
|:--|--:|--:|
| `content/docs/ai/connect-mcp.mdx` | 1 | 1 |
| `content/docs/api/index.mdx` | 1 | 1 |
| `content/docs/deployment/environment-variables.mdx` | 1 | 1 |
| `content/docs/getting-started/build-with-claude-code.mdx` | 1 | 1 |

## Serial constraint — re-measured at hunk level, and it does not bite

PR objectstack-ai#18420 (draft, untouched since 2026-09-17T16:16Z) is the only open PR
touching this file. Read from its diff: its **only** hunk in this file
is `@@ -87,7 +87,7 @@`, the `OS_AUTOMATION_SCHEDULED_WORK_ENABLED` row.
This PR changes the row at `:260`. **173 lines apart**, far outside
git's three-line context ⇒ no textual conflict. Nothing in objectstack-ai#18420 was
touched or coordinated.

## Verification

Gate families derived in this worktree from the real change set, not
from a hand-written list: `node scripts/pm/dispatch-gates.mjs
--commands` (change set: 1 path vs merge base `46559f61c`).

- **39 derived families, 39 run, all `exit 0`.** Reconciled with exit
codes recorded: `dispatch-gates --repo objectstack-ai/objectstack --ran`
⇒ "39 derived famil(ies) accounted for — 39 run, 0 NOT-MEASURED (a
DERIVED zero — all 39 recorded an exit code and none of them is 3)".
- Four of them first returned `PREREQUISITE NOT MET` (`exit 3` ×3, plus
`check:skill-examples` exit 1 on an unbuilt `client-react` dist) — **not
findings**. After `turbo run build --filter=@objectstack/formula
--filter=@objectstack/lint --filter=@objectstack/client-react
--filter=@objectstack/client` (exit 0) all four re-ran at `exit 0`:
`check:doc-formula-expressions`, `check:doc-security-posture`,
`check:skill-examples`, `check:docs-transcript-drift`.
- `pnpm --filter @objectstack/spec build` ran first (exit 0), so
`check:docs` read a current tree.
- That derivation is **not** a complete account of CI — the
artifact-roster, wide-population, pending-changeset and path-scheduled
families sit outside it, as the tool says of itself.
- Control characters: `grep -naP` over the edited file finds none (exit
1), with a planted positive control proving the reader fires (exit 0,
hit). `pnpm check:nul-bytes` exit 0.

### `pnpm lint` — a **proven narrowing**, not a skipped run

The repo-wide scan is CI's run. Three pieces of evidence that narrowing
excluded nothing:

1. **Population, read from eslint's own config:** every `files:` glob in
`eslint.config.mjs` enumerates code extensions
(`ts,tsx,mts,cts,js,jsx,mjs,cjs`); the string `mdx` occurs **0** times
in that config. `.mdx` is not in the linted population at all.
2. **File count, read from `--format json`:** eslint over the changed
file returns **0 results**; the positive control
(`scripts/check-nul-bytes.mjs`) returns **1 result** — the reader
resolves files and reports.
3. **Invariance for untouched files:** the config enables no type-aware
linting for any file (its own header: "this repo runs one
`eslint.config.mjs`, which never enables type-aware linting (no
`parserOptions.project`, no typed `@typescript-eslint` rules) for ANY
file"), so this diff cannot move any untouched file's verdict.

### Changeset: `skip-changeset`, measured

Nothing published moves.

- 83 tracked manifests; **70** declare `files[]` (the control: the
reader resolves `files[]` arrays — e.g. `@objectstack/spec` ⇒ `dist`,
`json-schema`, `liveness`, `prompts`, `llms.txt`, `README.md`,
`src/**/*.zod.ts`, `CHANGELOG.md`, `api-surface`, `spec-changes.json`).
Entries reaching `content/docs/**`: **0**.
- Symbol grep over the **2138** files those `files[]` entries actually
resolve to: `Mint one from` ⇒ **0**, `Account → Developer` ⇒ **0**;
positive control `objectstack` ⇒ **1971** files, so the reader reaches
published bytes.
- The only consumer of `content/docs/`, `@objectstack/docs`
(`apps/docs`), is `private: true` and declares no `files[]`.
- The one published manifest whose text mentions `content/docs`
(`@objectstack/plugin-webhooks`) does so in its `description` prose
about a different page; its `files[]` is `dist`, `README.md`,
`CHANGELOG.md`.

## Acceptance notes

Out of scope, noted and **not** filed:

- The card's four deliberately excluded carriers (`docs/adr/0101-…:104`,
`docs/qa/platform-checklist/areas/ai.json:206`, two `.changeset/*.md`)
are dated records, left untouched.
- This same file carries `Setup → Settings` and `Setup →
Authentication`, and the corpus carries 27 other `Setup → X` phrases
(Access Control, People, SSO Providers, Datasources, Approvals …). Those
name genuinely admin-only surfaces addressed to admins — the
Connect-an-Agent defect exists precisely because that one page is
**also** delivered to non-admins through the `account` app, which is not
true of the others. No defect, and the successor question has an answer:
**successor: none** — no PR or reader is routed to them by this change.

---
_Generated by [Claude
Code](https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk)_

---
_Generated by [Claude Code](https://claude.ai/code)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…e-limit remedy (objectstack-ai#18979)

Fixes objectstack-ai#18794

Clause-②: no

## What changed

`packages/spec/docs/SYNC_ARCHITECTURE.md` taught authors that a
rate-limited upstream is answered by `retryConfig`, printed its
`retryableStatusCodes` defaults (429 included), and listed it as a
reason to pick L3 — while nothing reads the key. Five passages in that
one file now say what is measurably true today: **declared but currently
unimplemented**, each pointing at
`packages/spec/liveness/connector.json`.

This is option **A** from the card, and only A. No declaration, schema
or accept set is touched, so the ADR-0049 ruling on these keys is
deliberately not prejudged. The keys are **not** described as retired
(they are still declared and still parse, so an author writing them
still sees no error) and **not** as the host's job (falsified below).

| Site (line at base `106717c3aa`) | Was | Now |
|---|---|---|
| `:157` blockquote | "what L3 does declare for a rate-limited upstream
is `retryConfig`" | declared-but-unimplemented, ledger cited, host-seam
falsification stated inline |
| `:299` example comment | "Retry Configuration — for the connector's
own outbound requests" | DECLARED BUT CURRENTLY UNIMPLEMENTED, ledger
cited |
| `:312` example timeouts | bare `connectionTimeoutMs` /
`requestTimeoutMs` | annotated declared-but-unimplemented, ledger cited
|
| `:336` Best Practices | "`retryConfig` handles the `429` you get for
exceeding a limit" | it does not; ledger cited; retrying is the
provider's to implement |
| `:360` decision row | "**Yes** → L3 (Connector) — `retryConfig`,
`health.circuitBreaker`" | "Not a reason to pick a level"; the row's
existing objectstack-ai#4911 sentence is left byte-identical |

## Measurements — re-taken on this branch, not inherited

**Consumer probe, fold-proof predicate, firing control in the same
run.** Comment leaders are stripped first, then every whitespace run
(newlines included) is collapsed and glued to the access punctuation, so
a folded access cannot hide from it. Read-shaped access only (`x.KEY`,
`x?.KEY`, `x["KEY"]`, destructure). 8763 tracked files, tree
`106717c3aa`:

```
.retryConfig          OUTSIDE packages/spec   0 hits in any packages/ or examples/ file
                      (2 hits total, both in the GENERATED reference page
                       content/docs/references/integration/connector.mdx)
.connectionTimeoutMs  OUTSIDE packages/spec   0
.requestTimeoutMs     OUTSIDE packages/spec   0
.providerConfig       OUTSIDE packages/spec   15 hits across 9 files   (FIRING CONTROL)
                      connector-mcp / connector-openapi / connector-rest providers,
                      service-automation plugin.ts, app-showcase tests
```

**Second control, reachability.** A bare-identifier census in the same
run proves the scan surface reaches the files where these keys actually
live: `connectionTimeoutMs` occurs in 9 files outside `packages/spec`
(the four connector packages, `plugin.ts`, a test) and
`requestTimeoutMs` in 8 — every one of them a WRITE of the literal into
a def so it satisfies the post-parse type, never a read. So the zeros
read as "no reader", not "the probe never looked".

**Host seam, re-read verbatim.**
`packages/spec/src/integration/connector-provider.ts:57` declares
`ConnectorProviderContext` with exactly `name`, `label`, `description?`,
`icon?`, `type`, `providerConfig`, `auth?`, `loadPackageFile?`. None of
the three keys is among them, so a provider factory is never handed them
and has no way to honour them. "Left to the host" is false.

**Changeset, measured rather than assumed.** The trigger is "can a
consumer read a change", not "did bytes move". `npm pack --dry-run
--json --ignore-scripts` on `packages/spec`: 275 entries,
`docs/SYNC_ARCHITECTURE.md` **absent**, and **zero** `docs/` paths at
all, while the positive controls `liveness/connector.json` and
`src/integration/connector.zod.ts` are both present. `files[]` is
`dist`, `json-schema`, `liveness`, `prompts`, `llms.txt`, `README.md`,
`src/**/*.zod.ts`, `CHANGELOG.md`, `api-surface`, `spec-changes.json` —
no `docs` entry. The edited file publishes to nobody, so
`skip-changeset`.

## Verification

- **Gate families.** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` derived 48 commands against commit
`c4c09b853e`. All 48 were run, exit codes landed to disk first, then
reconciled: *"48 derived, 48 run, 0 NOT-MEASURED, 0 UNRUN"* — a derived
zero, every family carrying a recorded exit code. Four of them
(`check:dts-closure`, `check:dual-build-cjs-loads`,
`check:lean-entry-closure`, `check:sourcemap-no-sources-content`) first
exited **3 = PREREQUISITE NOT MET**, which is neither a pass nor a
failure; after `pnpm build` (73/73 tasks successful) all four re-ran
green.
- **DARK leg.** `pnpm --filter @objectstack/spec test` — 488 test files,
14182 tests, all pass. `pnpm --filter @objectstack/spec check:generated`
— "All 15 generated artifacts are up to date". `pnpm --filter
@objectstack/spec typecheck` — clean.
- **This document sits behind a compile gate.**
`packages/spec/src/integration/connector-author-shape.test.ts` extracts
the file's typescript-fenced blocks and compiles them verbatim against
the real schema, pinning the fence count at 2, the elision-sketch count
at 0, and every block to compile clean. 14/14 pass with the edit in
place.
- **Reverse verification, direction stated before running.** Renaming
the edited block's `retryConfig` key to `retryConfigBogus` had to turn
that gate RED on the block I touched. Observed: RED, `TS2561 Object
literal may only specify known properties` — TypeScript's did-you-mean
form of the excess-property error, since the bogus name is one edit from
the real one — on the assertion "L3 example #0 must compile clean"; 1
failed, 13 passed. Mutation landing was proved on disk (anchor 1 to 0,
blob `bd3c6b895c2a` to `c02cf5c9c5e3`) and the restore was proved
independently of the tool's own claim: blob back to `bd3c6b895c2a`
equals HEAD, and `git diff HEAD` empty. This is what shows the comments
added inside the fence are inside the compiled region and compile clean,
rather than sitting outside it.
- **Lint narrowing, declared.** (1) The population is read from eslint's
own config: every `files:` selector in `eslint.config.mjs` targets
`{ts,tsx,mts,cts,js,jsx,mjs,cjs}`, and no markdown selector or processor
exists. (2) The count is read from `--format json`: the one changed file
yields `"File ignored because no matching configuration was supplied."`
with 0 errors, so 0 files of the lint population are touched. (3)
Invariance: the file is outside the lint population entirely, so this
diff cannot move any untouched file's verdict.

## One widening the reviewer should confirm

The acceptance line for `:360` reads "it must not present an
unimplemented key as a selection criterion". That row paired
`retryConfig` with `health.circuitBreaker`, and the `:157` blockquote
pairs them too. `health.circuitBreaker` is unimplemented on the same
evidence: `packages/spec/liveness/connector.json` records every one of
its sub-keys as `dead`, and the same probe run shows 0 read-shaped
consumers outside `packages/spec` in any `packages/` or `examples/` file
(its only hits are the generated reference page and
`content/docs/references/system/cache.mdx`, which is the CACHE's own
breaker, a different subject). Correcting only the `retryConfig` half
would have left the row still presenting an unimplemented key as a
selection criterion. So both halves are corrected, citing the ledger's
existing verdict rather than making a new one. Flagged because it is one
key wider than the card's three.

## Acceptance notes

Noted, not filed. The first two are real sites carrying the same
prescription, each behind a read-only fence this round:

- `content/docs/automation/flows.mdx` (now `:1595`; the card said
`:1588`) is held by open PR objectstack-ai#18420 and is untouched here. Its passage is
about reconciling retry-COUNT conventions between block kinds
(`maxAttempts` includes the first attempt), not the "use `retryConfig`
for upstream rate limiting" prescription, so excluding it does not
damage the card's thesis. Follow-up: sweep it once PR objectstack-ai#18420 lands.
Successor: the seat that picks up that sweep.
- `packages/spec/src/integration/connector.zod.ts:44` carries the **same
sentence verbatim** ("What L3 does declare for a rate-limited upstream
is `retryConfig` — whose `retryableStatusCodes` default ... includes
`429`"), and `content/docs/references/integration/connector.mdx:42` is
that same comment regenerated by `packages/spec/scripts/build-docs.ts`.
Both are behind this round's fence on
`packages/spec/src/integration/**`. So after this PR the repo still
teaches the falsehood in those two places, from one source. Follow-up:
correct that TSDoc block and regenerate, which is a prose-only change
plus `gen:docs`. Successor: whoever takes the ADR-0049 ruling card,
since it lands in the same file.
- `SYNC_ARCHITECTURE.md:192` also names `connectionTimeoutMs` /
`requestTimeoutMs`, but only to state that keys carrying a `.default()`
are optional in the author shape — a true statement about `z.input` that
makes no efficacy claim. Left alone deliberately.
- The Best Practices bullet "**Error Handling**: Implement comprehensive
retry logic with exponential backoff" is left as-is: under this change
it reads correctly as advice to IMPLEMENT retry yourself, which is now
the only true reading.

_Generated by [Claude
Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_

---
_Generated by [Claude
Code](https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3)_

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ai#18012) (objectstack-ai#19066)

Fixes objectstack-ai#18012

Clause-②: yes

Ruling executed: decision batch objectstack-ai#146 item 5, **letter A** — maintainer
「146 同意」 2026-09-17T13:16Z. Carrier: the changeset
`.changeset/18012-between-blank-endpoint-refused.md` —
`@objectstack/spec` **minor**, body carrying **BREAKING for authored
metadata**, ADR-0087 disposition `registered
filter-between-blank-endpoint-refused`.

## What changed

`$between` now requires two endpoints that are **present and
non-empty**. A blank bound at either side is refused at the authoring
door, and the refusal **names the blank side** — `MIN` / `MAX` plus the
index — because the only measured producer pads a half-typed pair, so
both bounds are present and the author is the one person who cannot see
which one is empty.

```
FROM  FieldOperatorsSchema.safeParse({ $between: [1, ''] })  ->  { success: true }
TO    FieldOperatorsSchema.safeParse({ $between: [1, ''] })  ->  { success: false,
        issues: [{ code: 'custom', path: ['$between', 1],
                   message: 'A blank value is not a valid $between endpoint at index 1
                             (the MAX bound). …' }] }
```

Three spellings, one rule, but only one of them changes what parses:

| spelling | before | after |
| --- | --- | --- |
| `''` | parsed green | refused — **the only behavioural change**; `''`
is a string and the endpoint union accepted it |
| `undefined` | refused with zod's bare `Invalid input` | refused with
the pointed sentence naming the side |
| `null` | refused with the 2026-08-31 ruling's own message |
**unchanged** — it prescribes the null predicate, a different remedy for
a different intent |

The refinement rides the endpoint factory `RangeOperatorSchema` (the
documentation copy) and `FieldOperatorsSchema` (the enforced copy)
already share, so the two cannot drift. The published endpoint
description gained the rule in the same edit — declared = enforced —
which is the whole of the regenerated
`content/docs/references/data/filter.mdx` diff (5 rows, one per
carrier).

The empty-string arm is an element-level `superRefine`, deliberately not
the tuple-level refinement the factory's docblock rules out: a tuple
check does not run once an element has failed, whereas the element check
runs exactly when the union accepted the endpoint, which is precisely
when there is an `''` to report.

## The ADR-0087 half the ruling left to measurement

The ruling asked for a D2 conversion entry and explicitly did not pick
the behaviour: 「the dev measures which the load path already does for a
refused operator and follows that precedent」 (drop the operator, or
refuse at load).

**Measured, on `origin/main` before the change: the load path does
neither.** `applyConversionsToStoredItem` — the one primitive every
stored-row rehydration seam calls — never throws and never validates; it
replays only the positively-recognised lossless transforms in the
conversion registry. A stored view carrying `{ close_date: { $between:
['2026-01-01', ''] } }` comes back as the **same object reference**. No
conversion in the registry drops a filter **operator** either: the three
filter-adjacent entries are two key strips and a key rename.

So the precedent to follow is the one the two nearest narrowings of this
same surface already set — `filter-preset-ordering-comparand-refused`
and `analytics-date-range-array-two-bounds-required`, both of which
decline a D2 conversion because rewriting would be the platform guessing
which bound was meant. **Registered as an ADR-0087 D3 semantic entry,
with no D2 conversion and no stored-metadata rewrite.** Dropping the
operator would be worse than guessing: it deletes a constraint the
author wrote and silently **widens** the result set — the failure mode
`$nin` carries in the same file.

Consequence, stated rather than left to be discovered: the read path
does not re-validate stored rows, so **no stored document becomes
unreadable**. What changes is that re-saving one is refused, at the
endpoint's own path, with the blank side named.

## `migrations/registry.ts`

The ruling's Execution line sequenced this on `registry.ts` after objectstack-ai#18319
/ objectstack-ai#18420. The dispatching seat measured that this no longer applies and
said so on the card: the file's three tables are generated regions fed
one-file-per-entry from `entries/`, and all four PRs said to hold it
each add their own entry file. This PR did the same — **one new file
under `entries/semantic/`, then `gen:migration-registry`**. Nothing was
typed between the markers; the `registry.ts` diff is 86 lines of
regenerated output and `check:migration-registry` proves the
regeneration faithful.

## Verification

Run on `e849c873cd` (the merge of `origin/main` into this branch), heavy
runs serialized through the shared verify lock.

- `pnpm --filter @objectstack/spec test` — **491 files / 14309 tests
passed**.
- `pnpm --filter @objectstack/spec typecheck` — clean (`tsc --noEmit` +
scripts + test-layer ledger).
- `pnpm --filter @objectstack/spec check:generated` — **all 16 generated
artifacts up to date** after the merge. Exactly one was proved stale
during the change (`content/docs/references/**`) and regenerated with
`--fix`, which touched only it.
- `pnpm lint` — repo-wide, exit 0.
- Targeted gates, all exit 0: `check-adr-0087-registration --base
origin/main`, `check-changeset-no-major --base origin/main`,
`check-empty-changeset --base origin/main`, `check:nul-bytes`,
`check:where-matcher`, `check:query-options-erasure`,
`check:test-source-alias`, `check:spec-parsed-alias`,
`check:cross-package-test-inputs`, `check:merge-driver`,
`check:published-files`, `check:objectui-changeset`,
`check:type-check-coverage`, `check:doc-anchors`,
`check:docs-single-h1`, `check:docs-spec-enumerations`,
`check:quick-reference-counts`, `check-doc-frontmatter`,
`check-docs-section-name`, `check-closing-keyword-parity`.
- `check:type-check-debt` — **NOT MEASURED**, exit 3 `PREREQUISITE NOT
MET`: it needs the whole workspace dist closure built, which `lint.yml`
does before the step and this run did not. Not a pass and not a finding.
This diff adds no package and moves no ledger entry.

### Reverse verification — the new assertions are not vacuous

Ablated through `scripts/ablation-replace.mjs`, which proves the
mutation reached disk before the command runs (no `-i` family):

```
anchor  "if (endpoint !== '') return;"   x1 -> x0
blob    d17f958 -> 4450ff1fac67          (the mutation landed)
result  5 failed | 162 passed
restore blob == HEAD d17f958, `git diff HEAD` empty
```

Predeclared direction: **red**, and exactly the five empty-string cases
went red. The `undefined` case, the `null` case and all 162 pre-existing
assertions stayed green — which is what separates "this rule is
enforced" from "this file's tests pass". No build step is involved: the
spec suite resolves `./filter.zod` from source, not from `dist`.

### Fixture sweep

Every `$between` array literal in the tree was read for a blank or
absent bound: **3 distinct sites, none of them parsing through this
schema** — the driver-sql undefined-comparand refusal pin, the
service-analytics filter-normalizer pin, and the `parseFilterAST` pin in
`filter-comparand-shape.test.ts`. No fixture had to be rewritten.
Instrument radius: tracked files this repo's `git grep` matches for
`$between`, scanned for array literals; outside it lie the sibling
`../objectui` checkout (a different repo, and its half is its own card)
and any range built programmatically rather than written as a literal.

## Acceptance notes

- **The runtime door is untouched, and it now disagrees with the schema
door about `''`.** `parseFilterAST` still reads an empty string as a
value — pinned on purpose in `filter-comparand-shape.test.ts` ("refuses
ONLY null — falsy and empty-ish members are values, not absence"), and
that file is outside this card's file surface and outside the ruling,
which scoped the spec half to the schema refinement. Flagged, not filed:
the two doors serve two different populations (an author saving a
document vs a caller handing a where-clause to the engine) and aligning
them is a decision of the same class as this card's, not a seat call.
Carrier if it is ever wanted: the same file that carries the null and
ordering runtime twins.
- **Whitespace-only endpoints still parse.** `{ $between: [' ', 'M'] }`
is green, and there is a positive assertion pinning that, so a later
reader cannot widen the refusal without noticing they are doing it. The
ruling enumerated `''`, `null`, `undefined`; narrowing a published face
past what was ruled is the seat call this card's whole history refuses
to make. Noted, not filed.
- **`FilterConditionSchema` judges no comparand at all** — it is
`z.record(z.string(), z.unknown())` at every field position, so it also
lets the already-ruled `{ $field }` endpoint through. Standing shape,
not a hole this narrowing opened; a test now pins it with that `{ $field
}` control beside it so the green reads as a measurement rather than an
oversight. Noted, not filed.
- **The `Clause-②: yes` line is copied from the dispatch's claim
comment, as the ruling set it.** For the record, this diff carries no
widening tell: no key, enum member, union arm, export row or registry
registration is added, and `check:api-surface` is green with no export
delta. Read strictly against the clause's own question
(「本卡放宽接受集或扩大公开面吗」) the direction is narrowing-only; the direction is
carried in prose and by the changeset's `BREAKING for authored metadata`
banner rather than by rewriting the ruling's word.
- The objectui half — the builder stops padding a half-typed pair — is
objectstack-ai/objectui#9695 and is untouched here. It is safe on its
own and may land either side of this PR.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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