Repository navigation
feat(driver-sql,driver-turso,driver-memory,objectql,cli,metadata-protocol): plain organization key part in new unique indexes; the differ reads COALESCE(tenant) as tenant on a certified database (18.x step 7b-1 + 7b-2) - #24893
Conversation
…r retired; webhook activation door Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ Co-authored-by: Claude <noreply@anthropic.com>
…he 18.0 dual read; door pins for webhook Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ Co-authored-by: Claude <noreply@anthropic.com>
…asserts no row and a dispatch Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ Co-authored-by: Claude <noreply@anthropic.com>
…the checklist, the webhooks guide and the showcase; changeset Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ Co-authored-by: Claude <noreply@anthropic.com>
…tcher's switch read Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ Co-authored-by: Claude <noreply@anthropic.com>
…posture builds no wall, as its 18.0 seeded copy stamped with the Default Organization was Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ Co-authored-by: Claude <noreply@anthropic.com>
…red writes; the declared-webhook double is drivable by the limit gate Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ Co-authored-by: Claude <noreply@anthropic.com>
…does not implement Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ Co-authored-by: Claude <noreply@anthropic.com>
…authors; checklist and admitsOrganization docblock say a declared webhook is deployment-wide under single Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ Co-authored-by: Claude <noreply@anthropic.com>
…0g-webhooks-registry
…the fixture's customized declared-webhook mirror Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ Co-authored-by: Claude <noreply@anthropic.com>
…n fail on a double delivery Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ Co-authored-by: Claude <noreply@anthropic.com>
…0g-webhooks-registry # Conflicts: # content/docs/permissions/tenant-audit-census.mdx # docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
…0g-webhooks-registry
…0g-webhooks-registry # Conflicts: # content/docs/permissions/tenant-audit-census.mdx # docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
…-registry' into claude/issue-24581-ceremony-open
…NOT NULL door end to end
…he ceremony, then boot; boot-refusal-order step 2 back on the binary
…he checklist; changeset
…ap, docs and strictness-ledger artifacts
…el — the cipher names no organization Claude-Session: https://claude.ai/code/session_014UATXWqAXqkaZkbG92ox6V Co-authored-by: Claude <noreply@anthropic.com>
…sys_flow_credential leave reach) Claude-Session: https://claude.ai/code/session_014UATXWqAXqkaZkbG92ox6V Co-authored-by: Claude <noreply@anthropic.com>
…red expression replaces is refused, and nothing is written (#25086) Closes #25000 Clause-②: no (narrowing: under `strictReadonlyWrites`, a write whose value a stored expression replaces is refused instead of reported; it ships now under the amended 「甲」, #6015 `6303865025`) ## What changes `strictReadonlyWrites` refuses every drop that `onFieldsDropped` reports. Since the `stored_expression` reason landed (#24690, PR #24778 `7532ea3c08`), one reported drop was left out of the refusal: a caller value for a stored (non-`formula`) field that declares an `expression`, which the engine replaces with the computed value. Under strict such a write completed and was only reported. The amended ruling 「甲」 (#6015 `6303865025`) ships the refusal in 18.x as a BREAKING minor, and triage re-timed this card to 18.x (`6305854988`). - **Insert (single, batch, `insertMany`).** The strict check in `engine.insert` now judges `insertDrops`, the one event list that includes the stored-expression replacement, after the compute pass. Before, it judged only the strips' drops, ahead of that pass. A replaced value throws `ReadonlyFieldRejectedError` (`operation: 'insert'`) before validation and before any driver dispatch, and no listener fires. `insertMany` delegates to `insert`, so it refuses the whole call, as it does for the other strict drops. - **Update by id and predicate update.** The `stored_expression` report now goes through `emitDrop`, the channel every strip reports through, and no longer calls `notifyDrop` directly. On both branches the one `assertNoStrictDrops()` call now comes after the stored-expression pass. So under strict the write is refused before the statement and nothing is written. An object that declares no stored expression skips that pass, so its refusal happens exactly where it did before. - **One refusal names everything.** A payload that carries another strip's drop as well as a replaced value is now refused once, naming both, with `stored_expression` last. Before, it was refused for the other drop alone. - **Wording.** `REASON_PHRASE.stored_expression` (written by #24690 ahead of this refusal) is reused. The per-reason message gains a `stored_expression` remedy (leave the field out, or send the computed value). That remedy is spoken only when the reason is present, so every other refusal's message is unchanged byte for byte, by the same rule the `computed` remedy follows. - **Docs.** The TSDoc in `packages/spec/src/contracts/data-engine.ts` (`strictReadonlyWrites`) and `packages/spec/src/data/data-engine.zod.ts` (`DroppedFieldsEvent.reason`) no longer names `stored_expression` as the arm strict does not refuse. The objectql comments in `engine.ts` and `readonly-strict-errors.ts` follow. No `.describe()` text moved: `check:generated` reports all 14 artifacts current, so the generated reference docs have nothing to regenerate. **Unchanged:** a strict write that sends the computed value, or no value, is not refused. A stored expression field that is also `readonly` is still refused once, as `readonly`. Without strict, the write completes with the computed value and is reported. The error code is unchanged. ## Tests `packages/objectql/src/engine-stored-expression-dropped-fields.test.ts`: the strict block changes from "reported, not refused" to "refused, nothing written". It covers update by id, insert single, batch, `insertMany` and predicate update. Each refusal asserts the `code`, `drops` (and `operation`/`fields` where they matter), that the store is unchanged, and that the listener stayed silent. A non-strict control sits beside each refusal (the write succeeds and is reported), together with the strict controls for "computed value sent / no value". The union case is also covered (`readonly_when` + `stored_expression` in one refusal), and so is the `readonly`-only insert refusal. All runs were taken on the final code commit `a8e9d3611c`: - `pnpm --filter @objectstack/objectql test`: 455 files / 8569 tests passed. `pnpm --filter @objectstack/objectql typecheck`: `tsc --noEmit` clean, and `check:test-typecheck` OK (ledger held). - `pnpm --filter @objectstack/spec build && check:generated`: all 14 generated artifacts up to date. `spec test`: 705 files / 21424 passed (1 todo). `spec typecheck`: OK. `spec test:repo`: 72 files / 1237 passed. - `node scripts/pm/dispatch-gates.mjs --commands` derived 94 commands from merge base `688052e14`, and all 94 exited 0. On the first pass, `check:dual-build-cjs-loads` and `check:type-check-debt` printed PREREQUISITE NOT MET (exit 3, nothing measured). I re-ran `check:type-check-debt` under the verify lock: its closure build ran and it re-measured OK, `none above its recorded number`. That full build also produced the missing `dist/` trees, and `check:dual-build-cjs-loads` then passed (112 entry points across 69 packages load). `dispatch-gates --ran` reconciles 94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN. The changeset gates are among the 94: `check-adr-0087-registration`, `check-changeset-no-major`, `check-empty-changeset`, `check:changeset-gate-self-tests`. **Ablation.** The fix was committed first. Each leg went through `scripts/ablation-replace.mjs`, with an anchor hit of 1 to 0 and the blob proven back to the HEAD blob `3e8c0ec2bd1a` with `git diff HEAD` empty. The suite imports `./engine.js` from source, so no build is involved. - A, update paths: `emitDrop` lets `stored_expression` past strict. Three tests go red (update by id, predicate update, the union case), and the other 22 stay green. - B, insert: the strict check ignores a `stored_expression`-only list. Three tests go red (insert single, batch, `insertMany`), and the other 22 stay green. ## Producers measured No non-test caller in this repository passes `strictReadonlyWrites`: the examples, `packages/qa` dogfood and the runtime and REST doors pass none. The option is also not on the serializable options bag, so no wire caller can set it. No in-repo producer moves. In the objectql suite, the in-package strict tests on stored-expression objects (`engine-stored-expression-write.test.ts`, `engine-write-formula-hydration.test.ts`, `engine-formula-write-strip.test.ts`) stay green unchanged. ## Changeset `.changeset/25000-strict-stored-expression-refusal.md`: `@objectstack/objectql` `minor`, with a BREAKING section that maps FROM (completed, reported) TO (refused, nothing written), gives the remedy, and carries the claim's `Clause-②` line and an ADR-0087 `not-required (no-migration-prescription)` marker (the precedent shape of `bb4f5cc005`). `@objectstack/spec` is not listed: only TSDoc comments moved there, and no `.describe()` text or export did. ## Acceptance notes - The release-owned 18.x upgrade checklist (`content/docs/releases/`) is not edited here. The changeset carries the text that release compilation reads. - The pending `.changeset/24690-stored-expression-dropped-fields.md` (in the open Version Packages PR) says strict "is unchanged" and that the refusal is deferred to 19.0. That file is outside this card's claimed file surface, so this PR leaves it alone. The new changeset says it supersedes that bullet. The report names the one-line amendment for the seat to decide. - `content/docs/data-modeling/validation-rules.mdx` (the `expression` on a stored field paragraph) still says a sent value is "not reported as dropped", which has been false since #24690. That is a hand-written doc outside this card's surface, and it is noted in the report. Open PRs touching the same files were checked: #25072 and #24893 edit `engine.ts` in disjoint hunks (lines 4628–6300 and 9043–12250). No open PR touches `data-engine.zod.ts`, `contracts/data-engine.ts` or `readonly-strict-errors.ts`. --- _Generated by [Claude Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Conflicts: 7b-0's changeset and service-messaging fixture take main's version; five driver-sql/turso suites take main's version with this part's own re-pins (e4c6ffe, de01287, 31c75dd) re-applied. Claude-Session: https://claude.ai/code/session_01NnXVu3vF2TKFLMi5Me4CUd Co-authored-by: Claude <noreply@anthropic.com>
The pre-18 relax a 7b-0 work-in-progress commit added rode into this branch through the earlier main merge; main's version (after #25016) supersedes it and this part does not change that file. Claude-Session: https://claude.ai/code/session_01NnXVu3vF2TKFLMi5Me4CUd Co-authored-by: Claude <noreply@anthropic.com>
… rebuild re-keys plain Claude-Session: https://claude.ai/code/session_01NnXVu3vF2TKFLMi5Me4CUd Co-authored-by: Claude <noreply@anthropic.com>
|
Seat ACCEPT · epic PM Seat check
|
Contract review (delta check after PASS at d4e977b)Served-tier: Reviewed, read-only, in a detached worktree of the head ① Derived judgments1. Merge resolutions — correct; no production file differs from main except by this part's own change; the PR diff is what the re-review passed, minus 7b-0, plus the deltas named.
2. R1 of the re-review — CLOSED. The clause is folded in at 3. The new changeset gate — satisfied; see ② for the level. AGENTS.md:1088-1092 at the head: 4. Under 7b-0's NOT NULL — the index contract holds on both column shapes.
5. The test-only commits — none weakens an assertion, none adds a relax. All four land on files that are now byte-identical to main (none of their paths is in the PR diff): 97f5296 replaced a ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Part of #24583
Clause-②: yes (widening). New DDL changes form.
os migrate planoros migrate apply. On SQLite the ownership ceremony's NOT NULL step rebuilds the tables it tightens, and their composites come back plain, enforcing the same constraint.sys_settingrow-identity index, which the metadata protocol rebuilds on every boot, keys the plainorganization_idfrom the next boot on. This is probe-first and a pure relaxation, so it cannot fail on existing rows.Metadata-protocol: semantic
A declared
unique: 'organization'index, and a field-level organization unique on a tenant-scoped object, change what they enforce where the tenant column is nullable. Nothing an author writes is added or refused. The changeset grades all six packagesminor. There is no BREAKING banner and no ADR-0087 marker, because the change is a widening.Clause-② text corrected by the epic seat on 2026-10-20 (about 16:15Z) after the contract review's B1 (record 6293077099). It mirrors
.changeset/24583-plain-organization-key-part.md. The first bullet's precision clause (re-review 6295919095, R1) was folded in at 8769400.18.x step 7, parts 7b-1 + 7b-2 (claim 6289404450, governing record 6285122667). 7b-0 (#24940) has merged as 5e9b100, and this branch has merged
mainat that commit, so the diff is this part alone.What changes
7b-2: new DDL keys the plain organization column (ADR-0131 D1: the tenant column is NOT NULL).
uniqueIndexesFromFields,normalizeDeclaredIndex(unique: 'organization') andlegacyUniqueReplacementsno longer mark the tenant key part NULL-safe. A field-level organization unique isUNIQUE (organization_id, f).buildIndexNameis unchanged, so no index is renamed.memory-unique-constraint.ts, where a NULL organization now exempts the row like any other NULL key part;os generate migration, which now emits the organization composite it used to list as NOT EMITTED (GLOBAL_TENANT_KEYremoved);sys_settingrow-identity migration:organization_idis plain andCOALESCE(user_id, '')stays. Its duplicate query lists only rows the index refuses.probeNullSafeUniqueDuplicates, local and Turso remote) leaves out rows with a NULL plain key part. The index never refuses those rows.__global__as the autonumber counter key is untouched (7b-3's ground).7b-1: the differ reads
COALESCE(tenant)≡tenant, in both directions, where the tenant column holds no NULL.diffManagedIndexestakestenantKeyCertified. When it is set, the tenant key part's form drops out of the comparison identity. The column set, uniqueness and other key parts still have to match.SqlDriver.detectTableIndexDriftsets it from two witnesses:ALTERproduce it too.ObjectQL.isOrganizationOwnershipCertified(): a verifiedsys_migrationownership row at the currentshapeGenerationor above. This is the same reading the boot guard boots on, and it covers a birth marker or the ceremony's marker.fileColumnsMovedsupply-seam precedent, so the driver never learns the ledger's vocabulary and there is no layering break.enforcedIndexKey,isSyncReproducibleIndex,isRuntimeManagedIndexand the shadow-key reader are unchanged. An input carryingnullSafeColumnsis still honoured verbatim.Measured
ceremoniedFixture):sys_email_templatestays nullable (withsys_sso_providerand one rotation shard) and keeps its 18.0 COALESCE index, so on the fixture as-is the certification witness is what carries it.ALTERand a Turso remote store not at all, so there every 18.0 index survives the ceremony. A SQLite store certified by an earlier 18.x ceremony keeps them too. The pin therefore also restores all 13 COALESCE indexes exactly as 18.0 wrote them.recreate_indexand 0 destructive drift. The 12 declared composites survive the boot byte for byte.sys_setting's index is rebuilt by its own runtime migration, which rebuilds it every boot anyway.os migrate planreports 0 changes over 73 tables.packages/qa/dogfood/test/upgrade-18x-index-equivalence.dogfood.test.tspins all of the above: 10/10.scripts/ablation-replace.mjsonschema-drift.ts:ablation-dist-preflightshowed the marker in 2 built files. The fixture run went red: 2 failed / 5 passed, both drift legs, with 26recreate_indexlines in the output.unhex(sha2(concat(organization_id,0x1f,v),256)), which is plain.recreate_index/destructive when uncertified and gives no drift when certified.stale); the column is derived, so no data moves.Proof
Measured on head 8769400 (
mainat 5e9b100 merged in).R1:
pnpm dev:<app> -- --freshon a random port for showcase, crm and todo. Each one:R2 + R3:
upgrade-18x-harness, the equivalence file andorganization-ownership-boot-refusal-ordergave 26/26.R4: built CLI
run-dev.js serveunderOS_TENANCY_POSTURE=isolated, on a throwaway app declaring the real@objectstack/organizations(invite-only membership).Tenancy: isolated, and Organizations mounted.organization_id.Step 4's 7 walled e2e cases (
OS_TEST_TIERS=nightly): 7/7.Suites:
sys-setting-identity-indexhook-run-as(objectql) andshipped-sets-identity-default-row-scope(plugin-security). This part does not touch either file. Run alone, they pass 25/25 and 32/32.typecheckis clean on all seven packages touched.node scripts/pm/dispatch-gates.mjs --commands: 85 commands plus the 4 roster gates whose roster sits under a path this diff touches. That is 89 in all, each exit captured before any pipe, and all 89 exit 0.pnpm --filter @objectstack/spec check:generated: exit 0, with all 14 artifacts up to date.check-governed-merges --branch origin/claude/issue-24583-7b12-index-plain-form: exit 0. 0 of 32 paths are governed.Size
2,741 changed lines over 32 files, under the 3,000-line threshold. Now that 7b-0 is in
main, this is no longer Tier H by size, and it touches no governed surface.维护者速读(草稿)
organization_id列,不再用COALESCE(organization_id, '__global__')。旧库里已有的 COALESCE 索引,只要库已认证(出生标记或仪式标记)或租户列已 NOT NULL,差异检测就视为同一个索引,不重建、不改名。唯一例外是sys_setting的行标识索引:元数据协议每次启动都会重建它,下次启动起改用普通列(先探测再替换,只放宽、不会因已有数据失败)。__global__桶已无意义。不加等价判断的话,每个升级上来的库每次启动都会被提示「删掉重建」13 个唯一索引。sys_setting行标识索引外,已有索引一律不动。回滚即还原本 PR,不涉及数据迁移;MySQL 实测无需重建列。Acceptance notes
recreate_indexof ADR-0120 D4 (bare composite → NULL-safe) is no longer produced by any generator. Its apply path and pre-flight remain for an op that already carriesnullSafeColumns, and the Turso suites pin them that way.recreate_indextoward the plain key.os migrate organization-ownershipas the remedy, neveros migrate apply --allow-destructive(B3, pinned insql-driver-tenant-key-equivalence.test.ts).os serveuntil the ceremony certifies it.os generate migrationemits the tenant column nullable, while the driver's fresh table is NOT NULL (7b-0). The generated composite is now the platform's own constraint, which is stronger than the nothing it emitted before, but the column's nullability still differs from the driver's. That is 7b-0's ground and is not changed here.🤖 Generated with Claude Code
https://claude.ai/code/session_01NnXVu3vF2TKFLMi5Me4CUd
Generated by Claude Code