Repository navigation
feat(spec,types,triggers)!: group runs package-authored scheduled work without a declaration, owning each run's writes per record - #18420
Conversation
…, owning its writes per record `group` was walled by analogy with `isolated`: with the deployment switch on, every time-triggered flow had to declare `config.organization` or it did not arm. The recorded reason was that which organization a group-wide run's inserts belong to had not been thought through. It is answered now — the swept record's own, which is the subject-first order `ObjectStoreSuspendedRunStore` was already ruled to use for `sys_automation_run` (`organizationOf(record) ?? ctx.tenantId`). Before this change the two halves disagreed under `group`: the history row was stamped from the record while the inbox and delivery rows followed an acting context that could not exist there. - `ScheduledWorkPolicy` gains `runOwnership: 'unscoped' | 'per-record' | 'declared'`, a second axis from `requiresActingOrganization`: that boolean decides whether BIND refuses, this decides what a run that DID bind carries. Collapsing them is what made `group` walled by analogy. - The separating predicate is `postureUsesUnionScope`, not `postureEnforcesWall` — `group` does enforce a wall, which is exactly why its reads span the group and its writes still need an owner. - `requiresActingOrganization` narrows to `isolated` only. The rejected arm is recorded in the ADR-0087 entry because it is the one a later reader will re-propose: falling back to the bootstrap organization (`slug='default'`). Under a wall that organization is minted admin-keyed by the enterprise organizations runtime and may not exist at all; where it does, it is whichever organization the platform owner registered under — plausibly one plant of many. A record-less undeclared run is refused at its first tenant-scoped write instead. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <noreply@anthropic.com>
The binding half of ruling A'. With the deployment switch on:
- `isolated` — unchanged: an undeclared flow is refused at bind.
- `group` — an undeclared flow now ARMS. A `time_relative` sweep reads group-wide
(inherent to the posture, ADR-0105 D1) and stamps each run it launches with that
record's own organization, resolved through the shared
`createRecordOrganizationResolver` rather than a local `organization_id` read: the
column is whatever the object declares, and a second implementation of that
precedence living in a trigger is the drift the shared resolver exists to end.
- A plain `schedule` (cron) flow has no record, so an undeclared one under `group`
carries nothing and is refused at its first tenant-scoped write. Deliberately NOT a
bind refusal: a cron flow that only reads, or writes only objects declaring
`tenancy: { enabled: false }`, has no write to be refused and must still run.
Refusing it at bind would be ruling G again under a new name.
The "never filled from the swept row" pin is retired for `group` ALONE, and the comment
records why per posture: under `isolated` it stands; under `single` the key is still
omitted, never filled from the row; under `group` "organizations it never declared" is
the posture's own read reach, not a boundary violation.
Both triggers now share one bind-line vocabulary (`describeScheduleRunOwnership`) so they
cannot describe one deployment differently. The undeclared-cron-under-`group` case warns
at BOOT as well as at the write: the refusal is correct but arrives at the first tick,
which may be hours away and unattended.
Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH
Co-authored-by: Claude <noreply@anthropic.com>
The `group` pins read the SET of organizations across the runs one tick launched, not
"a run was stamped": the behaviour this replaces stamped every run in a batch alike, so
a pin reading only "the run carries an organization" passes on it too. Two plants' rows
in one tick yielding ['org_plant_a', 'org_plant_b'] is a value no previous behaviour
could produce.
Retired pins are replaced, not deleted, with the reason they rested on quoted at the
replacement site: `ScheduleTrigger — switched ON under a wall` becomes two blocks, and
the `isolated` half is the old pin kept whole.
Also pinned: a declaration still outranks the record (declaring narrows, never widens);
a row carrying no organization stamps nothing, with a live control proving the same
tick stamped a sibling row; `tenancy: { enabled: false }` resolves nothing even with a
stray column present; and the no-`getSchema` degradation warns once.
Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH
Co-authored-by: Claude <noreply@anthropic.com>
…he release note Three doc surfaces carried "under a walled tenancy posture (group/isolated)" as one rule; each now splits the two. The flows page gains the `group` callout and the record-less-cron warning, and states why a near-miss spelling is NOT reported there: an undeclared flow is a legal shape under `group`, so the trigger cannot tell "meant to declare, misspelled it" from "meant not to declare". The changeset declares `Clause-②: yes (widening)` rather than BREAKING: nothing that worked stops working and nothing admitted becomes refused — the accept set widens in one cell. It also records that the switch this depends on ships unreleased alongside the change, so the behaviour being amended has never appeared in a published version. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <noreply@anthropic.com>
…isolated)" The doctor's ON-state fix text told an operator that a time-triggered flow under group/isolated must declare config.organization. Under ruling A' that is true of `isolated` alone; `group` takes the declaration as optional, and the record-less cron case there has its own remedy worth naming at the one place an operator goes looking. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <noreply@anthropic.com>
…led posture" The retired-rule note explained where the bind-time near-miss scan still fires. That door narrowed with ruling A': under `group` an undeclared flow is a legal armed shape, so a near-miss spelling there cannot be told apart from a deliberate omission. Comment only — no rule, severity or finding changes. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 6 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 142 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e6c923322eb68a6112d9142ee0b57e3862f997c1 && git checkout e6c923322eb68a6112d9142ee0b57e3862f997c1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e19ae6708ad9e10ba0b24919a54f4c0fbfa5c62f 4584b00fa3134d10fd4c31677555b13f10941734 && git checkout -B drift-repro e19ae6708ad9e10ba0b24919a54f4c0fbfa5c62f && git merge --no-ff 4584b00fa3134d10fd4c31677555b13f10941734
node scripts/docs-audit/affected-docs.mjs --json e19ae6708ad9e10ba0b24919a54f4c0fbfa5c62f
|
… prose The marker's grammar after the arm is a list of ENTRY IDS, not free text — only the `not-required` arm takes a reason clause. The prose moves into the changeset body where it belongs, and the arm corrects to `already-registered`: this amends the pre-existing `schedule-flow-acting-organization-required` entry rather than adding one, and `registered` would assert a registration this diff did not make. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <noreply@anthropic.com>
…a top-level arm The parser accepts exactly two forms — `registered <ids>` and `not-required (<category> [ids]) <why>`. `already-registered` is one of the second form's categories, and it is the honest one here: the entry predates this diff at the merge base, so `registered` would claim a registration this PR did not make. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <noreply@anthropic.com>
The title carries a breaking marker while the body said the accept set widens; both are true and the body now says so together. Nothing admitted becomes refused, but on a `group` deployment with the switch already on, flows that were refused at bind now arm and run — clock-driven work appearing where an operator had none is what earns the banner, even though no consumer has to change anything. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <noreply@anthropic.com>
TS2531 at the DTS build: a mutable class property does not stay narrowed across the assignment that populates it, so the property read after the cache-fill was possibly null. Reads through a local instead of asserting with `!` — the null branch is the one thing worth keeping honest here, since it is what a host mounting a non-engine adapter actually hits. Caught by the DTS build, not by tests: vitest does not type-check. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <noreply@anthropic.com>
…rule The docs-drift check listed these; all three genuinely stated the pre-A' rule. - environment-variables.mdx — the OS_AUTOMATION_SCHEDULED_WORK_ENABLED row - production-readiness.mdx — the go-live checklist item - tenancy-modes.mdx — a `group · isolated` table row I missed when fixing the env table on the same page, plus the paragraph under it explaining why `group` was walled "until [the ownership question] is decided". That question is the one this change answers, so the paragraph now says what the answer is rather than that one is pending. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <noreply@anthropic.com>
Both are pure projections of edits already in this branch — the amended ADR-0087 entry 18 and the ScheduleOrganizationSchema `.describe()`. No hand edits. gen:docs refused the first attempt because the gitignored packages/spec/json-schema tree was older than src, and rendering reference pages from a stale tree would have described sources the run never read. Generated that tree first (gen:schema), then the docs. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <noreply@anthropic.com>
…st config `check:test-source-alias` caught the new dependency resolving through `dist/`. That made the `group` pins — which assert each run is stamped from its OWN swept record — a verdict about a built artifact rather than about `resolveRecordOrganizationField`'s precedence as it stands in this checkout, which is exactly what those pins exist to hold. The gate's own framing is why this matters: the dangerous case is a test that PASSES. Mine did, against dist. Re-run against source after aliasing: 106 pass. Anchored regex, array form, for the reason the sibling `@objectstack/types` entry states at length — a bare string `find` matches by prefix and would swallow subpaths into `…/src/index.ts/<sub>`. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <noreply@anthropic.com>
`check:type-source-resolution` — the type-side sibling of the vitest alias gate — caught the new dependency's TYPES resolving through `dist/`, making this package's typecheck a verdict about build state rather than about the checkout. Took the `paths` route, not the registry one. Widening that registry is maintainer-only: this package's `@objectstack/types` row was admitted by maintainer ruling on #17396, and the registry's own docblock says the row is a signature, not a door. `paths` requires widening `rootDir` from `./src` to `../..`, because redirecting the dep to source puts its files in this program and `rootDir` is enforced even under `--noEmit`. That route is not always available, so it was MEASURED first: 0 TS6059 with both lines in place (`service-automation` measured 647 for its own case and ruled `paths` out). Same move `plugin-approvals` records for this exact dependency and `rest` for #9960; emit is unaffected since this package builds with tsup and typechecks with --noEmit. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <noreply@anthropic.com>
Ready for review — all seven required contexts green on
|
| required context | result |
|---|---|
Lint & Repo Gates |
✅ |
TypeScript Type Check |
✅ |
Test Core |
✅ |
Dogfood Regression Gate |
✅ |
Build Core |
✅ |
Temporal Conformance (live PG + MySQL) |
✅ |
Governed Surface Queue Guard |
✅ |
Lint & Repo Gates is called out deliberately: on its two earlier failures it stopped at step #158 of 180, leaving 22 gates unmeasured — its own tail reporter says that is NOT MEASURED, not "passed". This run completed the job, so those 22 have now actually executed.
Local, on the same tree: pnpm lint && pnpm test → 146/146 tasks, pnpm typecheck → 0 errors across 143 packages, check:generated → 15/15 up to date.
Three defects CI caught that local work had not
Recorded because two of them are the kind that ship silently.
-
Check Changeset— the ADR-0087 marker was malformed twice: prose written where the grammar takes entry ids, thenalready-registeredused as a top-level arm when it is a category ofnot-required. Fixed; disposition is nownot-required (already-registered schedule-flow-acting-organization-required), which is the honest arm — the entry predates this diff at the merge base, soregisteredwould claim a registration this PR did not make. -
check:test-source-alias— the new@objectstack/metadata-coredependency resolved throughdist/in tests, so thegrouppins asserting "each run is stamped from its own swept record" were a verdict about a built artifact rather than aboutresolveRecordOrganizationField's precedence in this checkout — which is exactly what those pins exist to hold. The gate's framing is the point: the dangerous case is a test that PASSES. Mine did. Aliased to source, re-run: 106 pass. -
check:type-source-resolution— the same defect on the type side. Two routes existed and one was closed: that registry's docblock records this package's@objectstack/typesrow as admitted by maintainer ruling on [Decision] 平台自带的四个定时示例流一个都声明不了组织 —— 而新规则要求它们必须声明 #17396 and says in as many words that the row "is not a door, it is a signature". So this took thepathsroute, which needsrootDirwidened to../... That route is not always available —service-automationmeasured 647 TS6059 and ruled it out — so it was measured first: 0 TS6059 here. Verified after: gate green, typecheck clean, emit unaffected (1/1 declaration file; the package builds with tsup and typechecks with--noEmit).
One correction to this PR's own earlier text
The title carries ! while the body originally said "Not BREAKING". Both facts are real and the body now states them together: the accept set widens and nothing admitted becomes refused, but on a group deployment with the switch already on, flows that were refused at bind now arm and run — clock-driven work appearing where an operator had none is what earns the banner.
⛔ Not armed for auto-merge, deliberately
This changeset declares Clause-②: yes (widening) — it adds an exported symbol (ScheduledRunOwnership, describeScheduleRunOwnership) and a new key on a published payload (ScheduledWorkPolicy.runOwnership), which the mechanical floor makes yes unconditionally. That owes an in-seat contract review before landing, and a review by the seat that produced the diff would be recorded as SELF-REVIEW. Landing waits on that review.
Generated by Claude Code
…e stamp one
The independent contract review's blocking finding was that
`TimeRelativeTrigger` had become a FOURTH consumer of `tenancy.organizationField`
— a key whose contract pins its consumers to three named platform-row writers
and says a fourth needs its own maintainer ruling. The remedy put to the
maintainer was a ruling or a redesign. This is the redesign, and it needs no
ruling because it stops reading the key at all.
The defect underneath the finding is that ONE resolver was answering TWO
questions:
- STAMP — "which column says who this row is ABOUT". Limb 0
(`tenancy.organizationField`) wins over everything, the ADR-0066
`tenancy.enabled: false` opt-out included, because an author declaring it on
an unwalled table is saying the audit trail should follow the row's own
organization even though nothing walls it.
- WALL — "which column is this row SCOPED by", and therefore which
organization work launched from that row may act as.
They coincide on every ordinary object and come apart on exactly one shipped
object: `sys_api_key`, `tenancy: { enabled: false, organizationField:
'active_organization_id' }`, unwalled by design (#8287 — walling the credential
table on an equality that excludes NULL is the defect that card removed). A
sweep over such an object was about to launch runs ACTING AS an organization
derived from an annotation that never meant "act as this".
So `@objectstack/metadata-core` grows a second face rather than a second copy:
`resolveRecordWallOrganizationField` / `createRecordWallOrganizationResolver`
are limbs 1-4 with limb 0 skipped, over the same implementation and the same
memoization glue — a `readStampKey` parameter selects limb 0 alone, so the
limbs the two faces share cannot drift apart. The stamp face keeps its name, its
signature and its answers, limb 0 included; the three sanctioned writers are
untouched.
`TimeRelativeTrigger` binds the WALL face. Under `group` an undeclared sweep
still stamps each run from its own swept record — that is ruling A′ and it is
unchanged for every business object, because for them the wall column IS
`organization_id` (or the declared `tenantField`). On an unwalled object the
sweep now resolves NOTHING and the run takes the existing `walled-posture`
refusal at its first tenant-scoped write, loudly and by name, instead of
acquiring an identity from a stamp annotation.
Pins: the wall face is pinned per limb against the stamp face wherever the two
can diverge (the `sys_api_key` shape both ways, a declared stamp key on a WALLED
object, and an agree-everywhere-else sweep over five shapes), and the trigger
carries the end-to-end discriminator. Reverse-verified: pointing the trigger
back at `createRecordOrganizationResolver` reddens that one pin and only it
(1 failed / 137 passed), and the restore is byte-identical.
⛔ No cross-package parity pin against `objectql`'s `resolveTenantFieldName` —
that package is registered in `check:test-source-alias` as still resolving
metadata-core through `dist/`, so such a pin would be a verdict about build
state. Converging the two spellings belongs to its own card; this change adds no
third one.
Measured: `pnpm typecheck` 143/143 tasks, `pnpm lint` clean, metadata-core
283/283, trigger-schedule 138/138, and the three stamp-key writers green
(service-automation 1634, plugin-approvals 754, plugin-audit 346).
Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH
Co-authored-by: Claude <noreply@anthropic.com>
…n's red `Validate Package Dependencies` went red on this PR's head, and the failure is not this PR's: step 13 (OSV-Scanner) flags `devalue@5.9.0` for GHSA-9rgm-9g3h-6x36 (5.3, fixed 5.9.2), a transitive package reached through `svelte`. This branch's only lockfile change is the `@objectstack/metadata-core` workspace link it added; `devalue` comes from the base. Established rather than assumed: PR #18942's body records `main` itself failing the same required check on scheduled run `35301766597` (branch `main`, sha `36583e98`), with the previous day's run green — so the advisory landed inside that window and every PR touching a manifest inherited the red. That PR is the fix, it is open, and waiting for it to merge is still waiting. So its change is PORTED here verbatim — the `devalue@<6.0.0` override in `pnpm-workspace.yaml` with its rationale, and the lockfile effect — after reading its diff rather than its description. Identical shape: 4 hunks, 9 changed lines, one package moved (5.9.0 → 5.9.2, the single resolved copy). It no-ops the moment `main` carries it. ⛔ No `osv-scanner.toml` exemption: the advisory names a fixed version, which is the one case that file's header forbids exempting. The ledger stays at zero entries, asserted by `check-osv-exemptions` (exit 0). Measured here: `pnpm install --frozen-lockfile --prefer-offline` → exit 0 ("Lockfile is up to date"), `check-override-consistency` → exit 0 with the override covered, `check-osv-exemptions` → exit 0. The scanner itself is CI's to render — `api.osv.dev` is refused by this container's egress proxy — so no local green is claimed for it; what is claimed is its input, and `devalue@5.9.0` is gone from the lockfile. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <noreply@anthropic.com>
The blocking finding is answered by redesign, not by a ruling —
|
| question | limb 0 (tenancy.organizationField) |
consumers | |
|---|---|---|---|
resolveRecordOrganizationField (unchanged) |
who is this row ABOUT — the stamp | read | the three sanctioned platform-row writers |
resolveRecordWallOrganizationField (new) |
what is this row WALLED BY — the scope, and so the identity work launched from it may act as | not read | the sweep |
They coincide on every ordinary object and come apart on exactly one shipped object: sys_api_key, tenancy: { enabled: false, organizationField: 'active_organization_id' } — unwalled by design (#8287; walling the credential table on an equality that excludes NULL is the defect that card removed). The sweep was about to launch runs acting as an organization derived from an annotation that never meant "act as this".
Both faces are one implementation — a readStampKey parameter selects limb 0 alone — so limbs 1–4 cannot drift into two answers. The stamp face keeps its name, signature and answers, limb 0 included; the three sanctioned writers are untouched (service-automation 1634, plugin-approvals 754, plugin-audit 346, all green).
Ruling A′ is unchanged for every business object: their wall column is organization_id (or the declared tenantField). On an unwalled object the sweep now resolves nothing and the run takes the existing walled-posture refusal at its first tenant-scoped write, by name, instead of acquiring an identity from a stamp annotation.
Reverse-verified: pointing the trigger back at createRecordOrganizationResolver reddens the new sys_api_key-shaped pin and only it (1 failed / 137 passed); the restore is byte-identical. pnpm typecheck 143/143, pnpm lint clean, metadata-core 283/283, trigger-schedule 138/138.
⛔ No cross-package parity pin against objectql's resolveTenantFieldName was added, deliberately: that package is registered in check:test-source-alias as still resolving metadata-core through dist/, so the pin would be a verdict about build state. Converging the three spellings of the wall rule (driver computeTenantField, resolveTenantFieldName, this one) belongs to its own card; this change adds no fourth.
The changeset now also carries @objectstack/metadata-core: minor, and its public-surface paragraph is corrected: four new exported names, not two.
Validate Package Dependencies — not this PR's, fix ported — ef6f69b5
Step 13 (OSV-Scanner) flags devalue@5.9.0 for GHSA-9rgm-9g3h-6x36 (5.3, fixed 5.9.2), a transitive package reached through svelte. This branch's only lockfile change of its own is the @objectstack/metadata-core workspace link; devalue comes from the base.
PR #18942 is the fix and records the measurement that settles ownership: main itself failed this same required check on scheduled run 35301766597 (branch main, sha 36583e98), with the previous day's run green. Rather than wait for it to merge, its change is ported here verbatim after reading its diff — the devalue@<6.0.0 override plus its lockfile effect, same shape (4 hunks, 9 lines, one package moved). It no-ops the moment the base carries it.
⛔ No osv-scanner.toml exemption — the advisory names a fixed version, the one case that file's header forbids exempting; the ledger stays at zero entries. Locally: pnpm install --frozen-lockfile exit 0, check-override-consistency exit 0, check-osv-exemptions exit 0. The scanner itself is CI's to render (api.osv.dev is refused by this container's proxy), so no local green is claimed for it.
Still open
An independent Clause-② review is running against cda685b8; its verdict lands here when it returns. The PR stays draft and is not armed for auto-merge.
Generated by Claude Code
…ew returned An isolated contract-review subagent reviewed `cda685b8` and returned PASS WITH FINDINGS. All three are this seat's, all three are declaration- or prose-level, and each is verified against the tree before being fixed rather than taken on the reviewer's word. **1. The changeset did not name `@objectstack/cli`.** `packages/cli/src/commands/doctor.ts` changes the text `os doctor` prints (11 lines, this PR's), `@objectstack/cli` is published (`publishConfig.access: public`), and AGENTS.md requires a changeset for anything that publishes. The sibling changeset for the same doctor text (`.changeset/scheduled-work-deployment-switch.md`) lists it. `Check Changeset` was green only because no gate reads package coverage. Added at `patch` — the text is a fix, no API moves. `@objectstack/lint` still needs nothing: its diff is comment-only. **2. A docblock this PR touched still stated the retired rule.** `schedule-trigger.ts`'s `refuseMissingOrganization` header read "⚠️ Under a WALLED posture (`group` / `isolated`) … and nowhere else". That is false at this head — the caller gates on `requiresActingOrganization`, which is `isolated` only — and it contradicted the zod docblock this same PR rewrote. Same class as the `runOwnership` mismatch fixed in `0752431d`, missed in the same file. The header now names `isolated`, says why `group` is not a near-miss, and names the predicate to gate on (⛔ never `postureEnforcesWall`, which answers `true` for `group`). **3. A published `.describe()` string had a grammar defect** — "acts as the swept record own organization" — shipping in the JSON schema and the generated reference page. Reworded to "acts as the organization of the record it swept", which also avoids an apostrophe inside the single-quoted literal; the first attempt at the possessive broke the parse, which `gen:schema` caught. Regenerated: `gen:schema`, `gen:docs`, `gen:api-surface` (that one needed a `pnpm --filter @objectstack/spec build` first — its staleness was the built-dist phantom, and the artifact came back byte-identical). Also taken, though the review marked it optional: the "one run carries ONE organization's opinion" claim is now stated WITH its exception, in both the spec docblock and the changeset. The history row is stamped (`tenancy.organizationField` wins) while the run's acting organization is a wall reading that never consults that key, so the two coincide on every ordinary object and diverge on the one shipped object that declares the key. That divergence is the correct pair of answers — a row nothing walls has no organization for a run to act as — but it is a divergence, and an unqualified claim of agreement would be the same kind of false docblock as finding 2. Measured: spec 13688/13688, trigger-schedule 138/138, cli 3385/3385, `check:generated` 15/15, `check-adr-0087-registration` and `check-changeset-fixed` exit 0. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <noreply@anthropic.com>
|
Served-tier: Contract reviewReviewed head: Checkout Measured on this head, read-only: ① Derived judgments
② Semver level and changeset declaration
Factual claims checked against the tree: "helper is module-level, NOT a package export" ✓ (item 8); "the new PUBLIC surface … those four" ✓ (items 2, 9); "the switch ships unreleased alongside this change" ✓ ( ⛔ One declaration defect: the changeset does not name ③ Boundary flags
Implemented-by: session_01URii26ZSYx4xPZ9ai47ceH Verdict: PASS WITH FINDINGS — the contract increment matches ruling A′ cell for cell, the fourth-consumer pin on Adoption note — read this before reading the pair above as independenceThe verdict text above is adopted verbatim: it was rendered by an isolated review subagent that did not write this diff, was briefed adversarially, and was given the card, the ruling and the PR only — not this seat's conclusions. ⛔ Nothing in it was rewritten or polished. Three things about it are not what the machine reading wants, and are stated rather than dressed up:
All three findings are fixed in This PR stays draft and is not armed for auto-merge. Generated by Claude Code |
One conflict, in `pnpm-workspace.yaml`, and it is this branch's own port retiring itself: PR #18942 — whose `devalue@<6.0.0` override `ef6f69b5` copied so this PR would stop inheriting `main`'s OSV red — merged as `5e0a1b9e`. Both sides now carry the same override; the comment text differed only where the port was reworded, plus the⚠️ PORTED note saying the block would no-op the moment the base carried it. Resolved by taking `main`'s block whole, which is that note being honoured rather than discarded: the base is the author of record for that pin, and this branch has no other change to the file. `'devalue@<6.0.0': '^5.9.2'` resolves once, and `pnpm install --frozen-lockfile --prefer-offline` answers exit 0 ("Lockfile is up to date") on the merged tree. Claude-Session: https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH Co-authored-by: Claude <noreply@anthropic.com>
|
Correction to the adoption note: That note predicted the machine reading instead of measuring it. Measured, at head
It stops upstream of the independence clause, so C4 never runs. Two things are true about that:
So
Generated by Claude Code |
…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>
…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>
…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>
…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>
Fixes #18378
Implements ruling A′ (
Ruling-ref: 5695424700, maintainer, 2026-09-16), which reopened ruling G item 3 (#17396) forgrouponly. ⛔ Nothing aboutsingleorisolatedis reopened, and the deployment switch itself (OS_AUTOMATION_SCHEDULED_WORK_ENABLED, default OFF in every posture) is untouched — A′ decides only what binds once the operator has turned it on undergroup.Clause-②: yes (widening)What this is
With the switch on and posture
group, a time-triggered flow that declares noconfig.organizationnow binds and runs, where it was previously refused at bind. What its writes carry follows the record:singlegroupisolatedA
timeRelativesweep undergroupreads group-wide — inherent to the posture (ADR-0105 D1, whose own example is multi-plant MES) — and stamps each run with that record's organization: sweep contracts across four plants and each plant's contract yields a run acting as that plant, whose notifications reach that plant's inboxes.Which organization a record belongs to — the WALL question, not the stamp one
⭐ This is the part that changed after review, and it is the heart of the PR. "Which organization does this record belong to" had two different answers in one resolver, and this diff separates them in
@objectstack/metadata-core:tenancy.organizationField)resolveRecordOrganizationField/createRecordOrganizationResolver(unchanged)resolveRecordWallOrganizationField/createRecordWallOrganizationResolver(new)The sweep binds the WALL face. ⛔ It never reads
tenancy.organizationField, so it is not a fourth consumer of that scope-pinned key and needs no ruling to admit one: the key's contract (#8778, cloud#1395) pins its consumers to audit stamping, the approval-row writer and the automation-run recorder, and that list is untouched.Why the split is not cosmetic: the two answers coincide on every ordinary object and come apart on exactly one shipped object —
sys_api_key,tenancy: { enabled: false, organizationField: 'active_organization_id' }, deliberately unwalled (#8287; walling the credential table on an equality that excludes NULL is the defect that card removed). Reading limb 0 here would take a declaration meaning "the audit trail should follow this row's own organization even though nothing walls it" and turn it into an acting identity. On such an object the sweep now resolves nothing and the run takes the existingwalled-posturerefusal at its first tenant-scoped write, by name.Both faces are ONE implementation — a
readStampKeyparameter selects limb 0 alone — so limbs 1–4 cannot drift into two answers. ⛔ No cross-package parity pin againstobjectql'sresolveTenantFieldNameis added: that package is registered incheck:test-source-aliasas still resolvingmetadata-corethroughdist/, so such a pin would be a verdict about build state. Converging the three spellings of the wall rule belongs to its own card (#19054 covers the related key retirement); this change adds no fourth.Why per-record ownership is not a fallback that guesses
It is the order
sys_automation_runwas already ruled to use.ObjectStoreSuspendedRunStoreresolves a run's organization asorganizationOf(<subject record>) ?? ctx.tenantId— subject first, acting context as the fallback and never the primary. Before this change the two halves disagreed undergroup: the history row was stamped from the record while the inbox and delivery rows followed an acting context that could not exist there, so they were refused while the tick summarised itself as healthy.The refusal that remains, deliberately
A record-less run under
groupthat declared nothing resolves nothing and takes the existingwalled-posturerefusal at its first tenant-scoped write (ADR-0112), loudly and by name.⛔ That is not converted into a bind refusal: a cron flow that only reads, or writes only objects declaring
tenancy: { enabled: false }, has no write to be refused and must still run — refusing it at bind would be ruling G again under a new name. The bind line says so at boot instead, because the write refusal is correct but arrives at the first tick, which may be hours away and unattended.The rejected alternative was a fallback to the bootstrap organization (
slug='default'): under a wall that organization is minted admin-keyed by the enterprise organizations runtime and may not exist at all, and where it does it is whichever organization the platform owner registered under — plausibly one plant of many, not the group's head office. That would be a wrong owner, silently authoritative to every report and export that filters by organization.Design notes for the reviewer
runOwnershipis a second axis, not a rename.requiresActingOrganizationdecides whether BIND refuses;runOwnershipdecides what a run that DID bind carries.runOwnershipis a fact about the POSTURE, not about the switch: it reportsgroup's'per-record'even while the switch is off, when nothing binds.enabledis the discriminator.postureUsesUnionScope, ⛔ notpostureEnforcesWall.groupdoes enforce a wall — that is why its writes still need an owner — and it also has group-wide read reach, which is why a batch job there is a capability rather than a boundary violation. A regression toenabled && postureEnforcesWall(posture)passes every other pin and fails one named live control.warn.TimeRelativeDataEngineis a type-level narrowing — the plugin resolves the realobjectqlservice, which hasgetSchema— but a host mounting a genuine adapter object would not. Then nothing resolves, every write is refused, and the message is about the WRITE. Said once per engine, naming the remedy. ⛔ Noterror: the writes that matter are still refused loudly.Retired pins, with their reasons (⛔ none deleted silently)
groupalone, and the comment records the verdict per posture: underisolatedit stands; undersinglethe key is still omitted; undergroup"organizations it never declared" is the posture's own read reach.ScheduleTrigger — switched ON under a wallbecomes two blocks, and the replacement site quotes the condition the old pin rested on so the reversal is legible rather than looking like erosion.Tests
['org_plant_a', 'org_plant_b'], a value no previous behaviour could produce.The wall/stamp split has its own discriminator: an object shaped like
sys_api_keyresolves nothing for the sweep. Reverse-verified — pointing the trigger back atcreateRecordOrganizationResolverreddens that pin and only it (1 failed / 137 passed), and the restore is byte-identical.Also pinned: a declaration still outranks the record; a row with no organization stamps nothing, with a live control proving the same tick stamped a sibling;
tenancy: { enabled: false }resolves nothing even with a stray column; the no-getSchemadegradation warns once;groupanswerstruetopostureEnforcesWallwhile still not requiring the declaration; and the two resolver faces agree on every shape where limb 0 is absent.What the
!marksThe breaking marker is for the behaviour change, not a narrowing. Nothing that worked stops working and nothing admitted becomes refused — the accept set widens in one cell. What earns the banner is the other direction: on a
groupdeployment with the switch already on, flows that were refused at bind now arm and run, so clock-driven work appears where an operator had none.The switch this depends on ships unreleased alongside this change, so the
group-is-walled behaviour being amended has never appeared in a published version. ADR-0087 disposition isnot-required (already-registered)— the ledger entry predates this diff at the merge base and gained itsgrouprows here.Status — green on
4584b00f; ready and queued by another seatCI: all seven required contexts green on
4584b00f—Lint & Repo Gates,TypeScript Type Check,Test Core,Dogfood Regression Gate,Build Core,Temporal Conformance (live PG + MySQL),Governed Surface Queue Guard— plusValidate Package DependenciesandCheck Changeset. 36 check runs completed, none failed, two deliberate skips.mergeable_state: clean.Independent Clause-② review: PASS WITH FINDINGS, posted verbatim in this comment. Its three findings are fixed in
d563fac6(the changeset now names@objectstack/cli; a stale docblock that still claimed the retired "any walled posture" rule is corrected; a published.describe()grammar defect is fixed and regenerated), each verified against the tree rather than taken on the reviewer's word.docs/adr/**,.claude/**,skills/**,AGENTS.md,CLAUDE.md) — its diff ispackages/**,content/docs/**,.changeset/**and the lockfile, andGoverned Surface Queue Guardpasses accordingly. The authoring seat held it in draft by its own caution, not by that rule. It was flipped ready, armed for auto-merge and enqueued byos-litantat 14:43Z.claude/issue-<n>-<slug>shape).🤖 Generated with Claude Code
https://claude.ai/code/session_01URii26ZSYx4xPZ9ai47ceH