Repository navigation
fix(engine-core): a system-context insert resolves the install's organization, or is refused — the runtime producer of the autonumber fork (#8844) - #8930
Conversation
… inserts, or refuse (#8844) The runtime twin of #8686's ruling, one layer up. A system-context write — a hook, a scheduled job, a custom endpoint, a `runAs: system` flow — carried no organization, so the tenant column landed NULL and the SQL driver's autonumber counter filed the row under the `__global__` pseudo-tenant. One object then ran two counters that could not see each other, and the partitioned unique index `(COALESCE(organization_id, '__global__'), <field>)` could not see across the two partitions either: a field declared `unique` held the same value twice, with no error and no warning. Measured on 17.0.0 GA across five objects on one single-tenant install. Per the 2026-08-15 maintainer ruling (Option 1), the engine's stamp resolution now answers the same question a session write answers: - single-tenant with exactly one organization: derive and stamp it, by threading `DriverOptions.tenantId` — the same knob a session write sets, so no driver changed (`fillAutoNumberFields` is duplicated in driver-sql and driver-turso); - multi-organization (a walled posture, or a `single` posture whose data holds several organizations): refuse loudly with ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED (500, registered in the ADR-0112 ledger) rather than silently default to `__global__`; - already-minted duplicates are reported, never rewritten — the #8686 posture, ruled again here. Nothing renumbers anything. Three populations are outside the rule by construction so the refusal cannot break automation that was never at risk: objects with no organization column, `tenancy.enabled: false` objects and federated objects; the `sys_`/`cloud_`/ `ai_` platform namespaces (#8672's reasoning, which does not generalize to application objects); and any write already carrying an organization. Before any organization exists there is nothing to derive and no partition to fork from, so first boot is untouched and #8686's handoff still adopts those rows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
…8844) `check:query-options-erasure` counted the new `this.find(sys_organization, {...} as any)` as a ninth erasing site in engine.ts, which is grandfathered for its EXISTING sites only. The literal type-checks against `EngineQueryOptions` unmodified, so the cast was never needed — removed at the source rather than laundered through `as unknown as`, and the ratchet is back at its baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 109 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also reference the affected code. These are read-only:
|
… list with a counterfactual pin (objectstack-ai#18700) Fixes objectstack-ai#18560 **Clause-②: no** `scripts/pm/check-widening-tells.mjs` is the TELL half of the clause-② gate (the DECLARATION half, `check-clause2-carriers.mjs`, is a different file and PR objectstack-ai#18681's subject). Its T1 reader decides a line declares a schema member by what the property's VALUE opens with, and that vocabulary was five alternatives inside one 130-character regex literal. A form missing from it is not a line judged leniently — it is a line that is not a key line at all: `memberTellKind` answers `null`, the row neither fires nor spends the objectstack-ai#16943 replacement budget nor earns it on the removed side, and nothing in the output says so. The silence is indistinguishable from a correct `no`. ## The counterfactual, before and after Re-derived here rather than inherited from the card's reading (objectui#9647 comment 5707064702, an at-tier reviewer's measurement). Run as `PM_SWEEP_REPO=objectstack-ai/objectui node scripts/pm/check-widening-tells.mjs --declaration no --diff …`, the BEFORE leg against the file as it stands at `6dfa3ea77` (this branch's merge base), the AFTER leg at `7f1a81419`: | diff | declaration | BEFORE (6dfa3ea) | AFTER (7f1a814) | |---|---|---|---| | objectui#9647, unmodified | `no` | exit 0, 1 file judged, **0 tells** | exit 0, 1 file judged, 0 tells | | objectui#9647 plus one key added through `stripImportedDefaults(` | `no` | **exit 0, 0 tells** | **exit 4, T1 at `packages/types/src/zod/data-display.zod.ts:584`** | | the same widened diff | `yes` | exit 0 | exit 0 (a `yes` is never blocked) | Row 2 is the red this PR turns. Row 1 does NOT change verdict, and that is the correction the re-derivation forced. ## What the re-derivation corrects The two helpers the card names are objectui's REFUSAL family (`packages/types/src/zod/tombstone.zod.ts`), read off the source: - `retirementTombstone(guidance)` returns `z.never({ error: guidance }).optional().describe(guidance)` — the same primitive as this repo's `retiredKey()`; - `handlerKeyRefusal(key, disposition, label)` returns a `z.custom` predicate typed `never` that returns `false`, and its own docblock records that "The predicate refuses EVERYTHING, a live function included". A key declared through either is a key an author may NO LONGER write. Making those two fire a tell would re-mint, on 290 objectui key lines, the exact false positive objectstack-ai#17955 removed on 255 objectstack ones — and a false tell does not cost a word in a comment, it costs the false `yes` this file's own header refuses to ask an author for. The form that DOES carry a widening, and that no seat had named, is `stripImportedDefaults()` (`packages/types/src/zod/imported-defaults.ts`), whose docblock states its contract as "the same TypeScript type, the same keys, the same checks, the same registry metadata and the same accept set". It returns a LIVE schema and is spelled at 45 key positions on the judged objectui surface. ## The named list `SCHEMA_PROPERTY_FORMS` — exported, frozen, and the constant `SCHEMA_PROPERTY` is BUILT from it. Two fields carry two questions: `pattern` (what makes the line a KEY LINE) and `writable` (whether the key it declares is one an author may write). Counts are key-POSITION counts, each with the tree it was taken against. | form | writable | measured | |---|---|---| | `z.` | yes | 7,784 at objectstack `6dfa3ea77` · 1,482 at objectui `15f01223d` | | `lazySchema(` | yes | 0 at objectstack `6dfa3ea77` (live at DECLARATION positions) | | `strictObject(` | yes | 47 at objectstack `6dfa3ea77` | | `*Schema` | yes | 995 at objectstack `6dfa3ea77` · 56 at objectui `15f01223d` | | `stripImportedDefaults(` | yes | **45 at objectui `15f01223d`** (added) | | `retiredKey(` | no | 255 at objectstack `6dfa3ea77` | | `retirementTombstone(` | no | **187 at objectui `15f01223d`** (added) | | `handlerKeyRefusal(` | no | **90 at objectui `15f01223d`** (added) | | `aliasKeyRefusal(` | no | **13 at objectui `15f01223d`** (added) | The `writable: false` arm is objectstack-ai#17955's decline generalised from one helper name to the family, on the SAME positive, line-local evidence: the value must BE the call and nothing after it. `declaresRetiredKeyTombstone` is renamed `declaresUnwritableKey` accordingly; it is not imported by any other file. **The `no` criterion is not loosened anywhere, and the direction is provable rather than argued.** An unrecognised line reports NOTHING, so no row that fires today can stop firing when the list grows. A self-test case keeps the literal this replaced as the reference and asserts it: every legacy verdict is byte-identical, and the only cells that move are the four added forms moving from "not a key line" to "a key line" — one direction, zero losses. ## The pins New battery, registered on the roster and pinned: `'objectstack-ai#18560 — the declaring vocabulary is a NAMED list, every form pinned by a counterfactual fixture': 30` (39 cases run against a floor of 30). Its unit is the FORM, not the assertion: - a frozen fixture roster is asserted EQUAL to the form set, in both directions and naming the offenders — a form added to the list without a fixture reds, a form silently dropped from the list reds. The fixtures are deliberately NOT generated from the list, which would make every future form pass by construction; - every form is asserted RECOGNISED (`memberTellKind` answers T1) — writable or not, because an unrecognised line is invisible to both sides of the budget, which is the defect itself; - every form's fixture is then driven through `tellsInFile` and asserted against its OWN register: a `writable` form must FIRE with its file:line, an unwritable one must be recognised and DECLINE; - every unwritable form carries the chained-arm control that FIRES, so the decline is bound to the evidence the line carries and never to the helper's name; - the objectui#9647 shape is carried as a case in all three readings, plus the `stripImportedDefaults(` widening with its refusal, its file:line, the `yes` control and the default-board control. `check:entry-guard` is the reason the vocabulary's structural guard is a const initializer's `map` step rather than a top-level `for`: this file exports bindings the sibling gate imports, so a load-time throw would run inside the importer. ## The census — report-only, no re-grade, no state write Has the silence already been relied on? **Zero confirmed `Clause-②: no` landings through these forms, over 11 of 18 rows read.** - **Horizon.** objectui's full history (`git rev-parse --is-shallow-repository` = false, 10,282 commits, initial commit 2026-01-13) up to the checkout tip `15f01223d` (2026-09-16). PR objectui#9647 itself is OUTSIDE it: `git merge-base --is-ancestor 604476d HEAD` exits 1, with the initial commit as the control leg at exit 0 on the same non-shallow checkout. The judgeable part of that window starts 2026-09-10, when objectstack-ai#17278 first let this CLI be told which board it judges. - **Population.** 46 commits add a key through one of the four added forms on `packages/types/src/zod/**`; 18 land inside the judgeable window. - **Coverage — read (11 of 18):** 10 carry `Clause-②: yes` in the PR body (objectui#8884, objectstack-ai#8895, objectstack-ai#8930, objectstack-ai#8967, objectstack-ai#9051, objectstack-ai#9338, objectstack-ai#9495, objectstack-ai#9539, objectstack-ai#9541, objectstack-ai#9565); 1 (objectui#9443) carries a "Clause-② carriers" section attaching `needs:contract-review` with no `yes`/`no` token. - **Coverage — NOT ATTEMPTED (7 of 18):** objectui#8984, objectstack-ai#9254, objectstack-ai#9261, objectstack-ai#9343, objectstack-ai#9544, objectstack-ai#9589, objectstack-ai#9621 carry no declaration in the PR body; the remaining carrier is each card's claim comment, which was not read. **This is NOT a zero** — it is seven rows unread and named. - The commit-local changeset is a weak carrier in objectui: only 2 commits in the whole history carry a `Clause-②` line in a changeset, which is why the census reads PR bodies rather than the tree. ## The ablation From the committed fix, `HEAD` blob `1b741ee6c22db6523475b698d392a8ecd41686d6`, under a `trap '…' EXIT INT TERM` restoring `git checkout HEAD -- scripts/pm/check-widening-tells.mjs` at an absolute path. No build or `dist/` is involved — the gate runs from source, so there is no rebuild leg to prove. - **mutation:** the `retirementTombstone(` and `handlerKeyRefusal(` rows deleted from `SCHEMA_PROPERTY_FORMS`; - **mutation proved on disk, not from an exit code:** anchor counts 1 → 0 for each form, file 257,365 → 256,697 bytes, blob `9fb65edf6f68b1501a0aec29d0ddb4c95bcf44a2` which is not the HEAD blob; - **predicted direction:** RED. **Observed:** RED — `--self-test` exits 1, "6 of 377 case(s) failed" plus the verdict-handshake report. The failures name the mechanism: the orphaned-fixture case prints both dropped forms, both RECOGNITION cases fail, both objectui#9647 cases fail, and the one-direction case fails; - **restore verified by hash, not by a return code:** `git hash-object` back to `1b741ee6c22db6523475b698d392a8ecd41686d6`, `git diff HEAD` empty, `git status --porcelain` clean, and the suite back to 381 cases pass. ## Self-test `pnpm check:pm-widening-tells` — **381 cases pass** (342 before this PR; +39). Exit 0. The two gates that IMPORT this module were run too: `check:pm-clause2-carriers` exit 0, `check:pm-prior-rulings` exit 0. ## Derived gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` from the worktree, no hand-fed path list — 29 commands, each run with `$?` captured BEFORE any pipe. Reconciled with `--ran`: "29 derived famil(ies) accounted for — 29 run, 0 NOT-MEASURED (a DERIVED zero — all 29 recorded an exit code and none of them is 3)". ``` exit 0 node scripts/check-ci-filter-parity.mjs exit 0 node scripts/check-closing-keyword-parity.mjs exit 0 node scripts/check-closing-keyword-parity.mjs --self-test exit 0 node scripts/check-comment-mask-corpus.mjs exit 0 node scripts/check-declaration-mirrors.mjs exit 0 node scripts/check-declaration-mirrors.mjs --self-test exit 0 node scripts/check-scripts-symbol-anchors.mjs exit 0 node scripts/check-scripts-symbol-anchors.mjs --self-test exit 0 node scripts/check-self-test-wired.mjs exit 0 node scripts/check-self-test-wired.mjs --self-test exit 0 node scripts/check-self-test-workflow-commands.mjs exit 0 node scripts/check-self-test-workflow-commands.mjs --self-test exit 0 node scripts/check-whole-set-label-write.mjs exit 0 node scripts/check-whole-set-label-write.mjs --self-test exit 0 node scripts/pm/bare-root-worklist.mjs --self-test exit 0 pnpm check:agent-test-spelling exit 0 pnpm check:bash32-floor exit 0 pnpm check:cli-command-ids exit 0 pnpm check:cross-package-test-inputs exit 0 pnpm check:driver-memory-census exit 0 pnpm check:entry-guard exit 0 pnpm check:nul-bytes exit 0 pnpm check:parse-guard exit 0 pnpm check:pm-dispatch-gates exit 0 pnpm check:pm-widening-tells exit 0 pnpm check:pnpm-filter-targets exit 0 pnpm check:ratchet-remedy-authority exit 0 pnpm check:refd-timer-probe exit 0 pnpm check:watch-hint-literal ``` Repo-wide `pnpm lint` (`eslint . --no-inline-config`) at `7f1a81419`: **exit 0**, no findings. Control-byte scan over the edited file: no hits. `skip-changeset`: `scripts/pm/**` is not published by any package's `files[]`, and this diff touches nothing else. ## One boundary this does NOT close, recorded rather than left to be found A FILE-LOCAL declaring factory. Both trees mint them — `placeholderFree(` (23 key lines), `strictIdent(` (12), `emptyProps(` (9) at objectstack `6dfa3ea77`; `chatbotRequestBodyArm(` (2), `retiredDeclarativeKanbanKey(` (1) at objectui `15f01223d` — and a list of shared, exported helpers cannot name a factory private to one file. A name-shaped heuristic is refused in the header, with the overturn condition written down. Filed as its own finding in the report on the card, not fixed here. --- _Generated by [Claude Code](https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #8844
The runtime twin of #8686, one layer up. That card fixed one producer of untenanted rows — the seed loader — and shipped a one-shot backfill for what it had already written. This is the other producer, which is still running: an ordinary application write made under a system execution context (a hook, a scheduled job, a custom endpoint, a
runAs: systemflow). A backfill cannot reach it, because it mints a fresh duplicate on every tick — which is what makes #8686's repair self-undoing on any install with server-side automation.Implements the maintainer ruling in comment 5302919977 — Option 1, all five binding points, not re-adjudicated.
Premises re-verified on today's tree (
main@8664a2c99, not the escalation's6b6b606)The escalation's line references were dated evidence; each was re-located by content and re-checked:
packages/objectql/src/registry.ts:555,additions.organization_id = { ...TENANT_SCOPE_FIELD_DEF }(same line, re-checked by content)engine.tsdocuments thebeforeInsertstamp pathpackages/objectql/src/engine.ts:8158SqlDriver.createcallsinjectTenantOnInsertatsql-driver.ts:4402andfillAutoNumberFieldsat:4421, so the counter reads an already-stamped row. The direction of causation holds; the fix site does not move.fillAutoNumberFieldsis duplicated across driversdriver-tursoreaches it by inheritingSqlDriver.create. Relevant to the tests, not the fix site: no driver changed.What changed
packages/objectql/src/tenancy/system-write-organization.ts(new) — the ruling as one pure, directly-testable decision, plus the refusal class.engine.tssupplies the measured facts and resolves the answer intoDriverOptions.tenantId— the same knob a session write sets, soinjectTenantOnInsertstamps the column and the counter scopes by the real organization. That is why no driver had to change, on either of the two that carryfillAutoNumberFields.__global__fork stops being minted by hooks, cron and system endpoints.group/isolated), or asingleposture whose data holds several organizations, raisesERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED(status 500, registered in the ADR-0112 ledger) before anything reaches the driver. Never silently defaulted to__global__.The refusal is the dangerous half — how it was kept narrow
A refusal that is too broad breaks every system write on a walled install, including unattended automation. Four populations are outside the rule by construction, not by exemption:
tenancy.enabled: false(ADR-0066), federated (ADR-0015)tenancy: { enabled: false }is the declared way to hold org-less rows — stated once on the object and checkable — rather than a per-write bypass flag, which is the lenient-consumer accommodation PD #12 forbids.sys_/cloud_/ai_platform namespacesbeforeInserthook (the resolution runs after the hooks for exactly this reason). That IS "carrying an explicit organization".sys_organization-insert handoff adopts exactly these rows. Refusing here would refuse first boot itself.Topology buckets, as measured (Zone 2 point 3): posture read live per call, service first (
tenancyPostureProvider, what is ENFORCED) then env (resolveTenancyPosture, what was ASKED for) — the same two-stepapp-plugin.organizationWallActiveuses, and never the demotedresolveMultiOrgEnabled().single+ 1 org: derive.single+ 0 orgs: leave untenanted.single+ 2 or more orgs: refuse (ambiguous).group/isolated: refuse (walled, without probing the database at all). An unreadableOS_TENANCY_POSTURE: degrades to the walled reading and refuses, because a typo'd posture is exactly where guessingsinglewould be worst.Scoped to INSERT, deliberately. The ruling's yardstick is "the way a session write does", and stamping the organization is an insert-side mechanism — an update neither stamps it nor can fork a counter. Extending it would not be following the session write, it would be inventing a second rule.
Tests, and the ablation that proves they can fail
packages/objectql/src/system-write-organization.test.ts— 20 cases: the decision function's branches, then the engine seam (DriverOptions.tenantId), each with its discriminating control.packages/runtime/src/system-write-tenancy-autonumber-split.integration.test.ts— 4 cases on a realObjectQL+SqlDriverover better-sqlite3, asserting on the database:_objectstack_sequences, the stored rows, the duplicate count.Two ablation legs, disjoint red sets — each half of the fix is independently pinned. Directions were predicted before running; one prediction was wrong and the measured direction is reported:
__global__)expected undefined to be 'ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED'[binding point 2]) / 3 greenexpected undefined to be 'org_msokm9oaz0cal87q'[binding point 1]expected 5 to be 10,[#8686 regression]the__global__row reappears beside the organization'sCorrection to a prediction. Leg B was predicted to leave
[first boot]green; it went RED (expected 2 to be 1). The case's second half asserts that once the organization exists the runtime producer is stamped, so it pins the derive branch too — the test is stronger than predicted, and this reports what was observed rather than what was expected.packages/runtimeresolves@objectstack/objectqlfromdist/, so every ablation leg was rebuilt and proved live in the artifact withnode scripts/ablation-dist-preflight.mjs @objectstack/objectql '__OS_ABLATION_*__'(and proved absent again after restore). Without that, leg A's first runtime run was a false green — measured here, not hypothesised.Verification — union re-run at final HEAD
445359f0bEdited packages:
spec10644,objectql3695,runtime2456 tests, all passing;typecheckgreen acrossobjectqland its 43 DOWNSTREAM consumers (prefix filter...@objectstack/objectql).Downstream test sweep (consumers, not dependencies):
rest1948,plugin-security1235,plugin-auth1238,plugin-sharing599,plugin-approvals484,plugin-audit239,driver-turso994,driver-mongodb384,service-datasource426,service-settings456,service-messaging229,service-queue53,trigger-record-change78,trigger-schedule46,cli1358,dogfood779,http-conformance72,verify28 — all green. #8686's ownseed-tenancy-autonumber-split.integration.test.tsruns green beside the new file.Gates: every family
scripts/pm/dispatch-gates.mjsderives from the final changed paths, pluscheck:error-code-casing,check:nul-bytes,check:engine-double-contract,check:tenant-chokepoint,check:type-check-coverage,check:type-check-debt --re-measure(on the built workspace),check:query-options-erasure,pnpm lint, andpnpm --filter @objectstack/spec check:generated(13/13 current).One ratchet moved and was fixed at the source, not raised:
check:query-options-erasurecounted the newthis.find(sys_organization, {…} as any)as a ninth erasing site inengine.ts(grandfathered for its existing eight only). The literal type-checks againstEngineQueryOptionsunmodified, so the cast came out — commit445359f0b. Ratchet back at baseline, no ceiling touched.Not done here, deliberately
No driver change (Option 2 is off the table). No renumbering of already-minted identifiers — reported instead, via #8928. #8672 remains open and untouched; this PR is the evidence its on-hold reasoning does not generalize, not a change to it.
The issue body was self-checked for sanitizer truncation: intact — its escaped arrow spans survived as entities and no prose is cut.
Generated by Claude Code