Skip to content

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

Merged
objectstack-fleet[bot] merged 99 commits into
mainfrom
claude/issue-24583-7b12-index-plain-form
Oct 21, 2026

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Part of #24583

Clause-②: yes (widening). New DDL changes form.

  • Nothing is renamed, and no declared organization composite is dropped or rebuilt by the boot, os migrate plan or os 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.
  • One index is re-keyed. The sys_setting row-identity index, which the metadata protocol rebuilds on every boot, keys the plain organization_id from the next boot on. This is probe-first and a pure relaxation, so it cannot fail on existing rows.
  • Some tenant columns are still nullable: every table of a database 18.0.0 created, and a table the ceremony reported. On those tables a newly declared organization unique no longer constrains NULL-organization rows, and the in-memory driver exempts a record with no organization.

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 packages minor. 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 main at 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') and legacyUniqueReplacements no longer mark the tenant key part NULL-safe. A field-level organization unique is UNIQUE (organization_id, f). buildIndexName is unchanged, so no index is renamed.
  • The same applies on every emitting path:
    • the sql-driver index emitters and the MySQL hash shadow, whose new shadows hash the plain column;
    • Turso, local and remote (it shares the generators);
    • 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_KEY removed);
    • the sys_setting row-identity migration: organization_id is plain and COALESCE(user_id, '') stays. Its duplicate query lists only rows the index refuses.
  • The duplicate pre-flight (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.

  • diffManagedIndexes takes tenantKeyCertified. 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.detectTableIndexDrift sets it from two witnesses:
    • The tenant column is physically NOT NULL. This is self-proving, and 7b-0 guarantees it on fresh tables. The ceremony's SQLite rebuild and its PostgreSQL/MySQL ALTER produce it too.
    • The database is certified. The engine hands every driver a closure, ObjectQL.isOrganizationOwnershipCertified(): a verified sys_migration ownership row at the current shapeGeneration or above. This is the same reading the boot guard boots on, and it covers a birth marker or the ceremony's marker.
    • The driver asks for certification lazily, and only for a table that physically carries the COALESCE tenant key part. A failed read is reported once and read as "not certified". This is the fileColumnsMoved supply-seam precedent, so the driver never learns the ledger's vocabulary and there is no layering break.
  • The COALESCE form is still read: enforcedIndexKey, isSyncReproducibleIndex, isRuntimeManagedIndex and the shadow-key reader are unchanged. An input carrying nullSafeColumns is still honoured verbatim.

Measured

  • The ceremonied 18.0 fixture (ceremoniedFixture):
    • On SQLite, HEAD's own ceremony NOT NULL step rebuilds 62 tables through the drift apply door, and that rebuild re-keys their indexes plain. sys_email_template stays nullable (with sys_sso_provider and one rotation shard) and keeps its 18.0 COALESCE index, so on the fixture as-is the certification witness is what carries it.
    • PostgreSQL/MySQL get NOT NULL by ALTER and 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.
    • On both shapes, HEAD's differ reports 0 recreate_index and 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.
  • A database born on the published 18.0.0:
    • Measured live: the published 18.0.0 boots a fresh SQLite file, then HEAD boots it with 0 errors and 0 refusals, and os migrate plan reports 0 changes over 73 tables.
    • Pinned as the equivalence file's third leg.
  • Fresh install: every organization unique index is plain, with 0 COALESCE and no index drift.
  • packages/qa/dogfood/test/upgrade-18x-index-equivalence.dogfood.test.ts pins all of the above: 10/10.
  • Ablation, via scripts/ablation-replace.mjs on schema-drift.ts:
    • With the equivalence switched off, ablation-dist-preflight showed the marker in 2 built files. The fixture run went red: 2 failed / 5 passed, both drift legs, with 26 recreate_index lines in the output.
    • With only the certification witness switched off, 3 legs went red, the 18.0.0-born leg among them.
    • Restore: the blob equals HEAD, the dist marker is gone, and the tree is clean.
    • Measured before the post-7b-0 merge, which changed no production file of this part.
  • MySQL hash shadow, live on MySQL 8.0.46 (a local container):
    • A fresh shadow's generation is unhex(sha2(concat(organization_id,0x1f,v),256)), which is plain.
    • An 18.0 COALESCE shadow is recreate_index/destructive when uncertified and gives no drift when certified.
    • A re-boot leaves the generated column's expression and the rows untouched.
    • No data migration and no column rebuild. A shadow is regenerated only when its index has already been dropped (stale); the column is derived, so no data moves.

Proof

Measured on head 8769400 (main at 5e9b100 merged in).

  • R1: pnpm dev:<app> -- --fresh on a random port for showcase, crm and todo. Each one:

    • booted on an empty database;
    • answered health 200 and seeded-admin sign-in 200;
    • logged 0 error lines and 0 schema-drift lines.
  • R2 + R3: upgrade-18x-harness, the equivalence file and organization-ownership-boot-refusal-order gave 26/26.

  • R4: built CLI run-dev.js serve under OS_TENANCY_POSTURE=isolated, on a throwaway app declaring the real @objectstack/organizations (invite-only membership).

    • Tenancy: isolated, and Organizations mounted.
    • Health 200 and discovery 200.
    • 0 error lines and 0 refusals.
    • In the store: 13 organization unique indexes, 0 COALESCE, and 0 NULL rows across 29 tables carrying organization_id.
  • Step 4's 7 walled e2e cases (OS_TEST_TIERS=nightly): 7/7.

  • Suites:

    Package Result
    driver-sql 3734 passed, 0 failed
    driver-turso 2550 passed
    driver-memory 1757 passed
    objectql 8641 passed, 1 timeout (see below)
    plugin-security 4234 passed, 2 timeouts (see below)
    metadata-protocol sys-setting-identity-index 34 passed
    cli and every other package CI Test Core 1/6 to 6/6, all green
    • The three timeouts happened while five suites ran at once, in hook-run-as (objectql) and shipped-sets-identity-default-row-scope (plugin-security). This part does not touch either file. Run alone, they pass 25/25 and 32/32.
    • typecheck is 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.

  • Production: 528 lines.
  • Tests: 2,193 lines.
  • Changeset: 20 lines.
  • Fixtures: 0 lines; no fixture was regenerated.

维护者速读(草稿)

  • 改了什么:新建的组织唯一索引改用普通的 organization_id 列,不再用 COALESCE(organization_id, '__global__')。旧库里已有的 COALESCE 索引,只要库已认证(出生标记或仪式标记)或租户列已 NOT NULL,差异检测就视为同一个索引,不重建、不改名。唯一例外是 sys_setting 的行标识索引:元数据协议每次启动都会重建它,下次启动起改用普通列(先探测再替换,只放宽、不会因已有数据失败)。
  • 为什么改:ADR-0131 D1 规定租户列 NOT NULL,没有组织为空的行,__global__ 桶已无意义。不加等价判断的话,每个升级上来的库每次启动都会被提示「删掉重建」13 个唯一索引。
  • 风险与代价(含回滚):在租户列仍可空的表上(用 18.0.0 新建的库的所有表、仪式没收紧的表),新建的组织唯一索引不再约束组织为空的行(与其他可空键一致)。除 sys_setting 行标识索引外,已有索引一律不动。回滚即还原本 PR,不涉及数据迁移;MySQL 实测无需重建列。
  • 席位意见:
  • 你要做的:7b-0 已合并,本分支已合入 main。净改动 2,741 行,低于 3,000 行,且不触及受治理面。等席位做完增量复核后,按其结论落地。

Acceptance notes

  • The tightening recreate_index of 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 carries nullSafeColumns, and the Turso suites pin them that way.
  • On an uncertified database with a nullable tenant column, an 18.0 COALESCE index is still reported as a destructive recreate_index toward the plain key.
    • The finding's message names os migrate organization-ownership as the remedy, never os migrate apply --allow-destructive (B3, pinned in sql-driver-tenant-key-equivalence.test.ts).
    • No unattended path applies it: boot reconciles only safe changes, and the artifact gate refuses.
    • Such a database is refused at os serve until the ceremony certifies it.
  • os generate migration emits 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

claude added 30 commits October 19, 2026 13:06
…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>
…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

# 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
…he ceremony, then boot; boot-refusal-order step 2 back on the binary
…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>
This was referenced Oct 20, 2026
github-merge-queue Bot pushed a commit that referenced this pull request Oct 21, 2026
…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>
@objectstack-fleet

objectstack-fleet Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor Author

Seat ACCEPT · epic PM session_01Y5583bs2RDd53MH6kM1ca3 (os-steve) · 2026-10-21T17:35Z. PR #24893 at 8769400d3f9a5c73d0775afd31e8e32e9e13144c (#24583 parts 7b-1+7b-2; os-dev-report 6322170760). ACCEPT.

Seat check

  • Shape.
    • 32 paths, 2,741 changed lines (+2,057 / -684) on the final head: 528 production, 2,193 tests, 20 changeset.
    • check-governed-merges: NOT governed, and under 3,000 lines. This is an ordinary queue landing.
  • Review. CONTRACT_REVIEW_TIER:
    • PASS 6295919095 at d4e977b87.
    • Delta PASS 6322476417 at 8769400d3:
      • the merges of the 7b-0 branch and of main (including 7b-0 as 5e9b100743) are correctly resolved, and no 7b-0 content is left in this PR's own diff;
      • R1 (the SQLite-ceremony clause) is closed;
      • the index contract holds on a fresh NOT NULL tenant column and on a nullable one (18.0.0-born or ceremonied);
      • the test-only commits weaken nothing.
  • Changeset under feat(gates): the changeset level follows the metadata protocol — Metadata-protocol: line, level axis, console refresh at patch, release notes by reader action (ruling A) #25114:
    • Metadata-protocol: semantic: what a declared unique: 'organization' enforces on a nullable tenant column changes, and nothing is added or refused;
    • minor on all six packages;
    • Clause-②: yes (widening), so no BREAKING banner is owed.
    • The latest Check Changeset run is green.
  • Line rules on the final head:
    • R2 and R3: the 18.x upgrade harness (opt-in) check is green, fixture-18.0.0 leg included. The dev also measured the harness, equivalence and boot-refusal-order suites at 26/26.
    • R1 (dev-measured): showcase, crm and todo each boot fresh with health and sign-in 200, and 0 schema-drift lines.
    • R4 (dev-measured): a walled os serve with organizations has health and discovery 200. Its store held 13 organization-unique indexes, 0 COALESCE, and 0 NULL organization rows across 29 tables. The walled e2e cases pass 7/7.
    • ORGANIZATION_OWNERSHIP_SHAPE_GENERATION stays 1.
  • Gate check-runs on the head:
    • The required checks (TypeScript Type Check, Test Core, Build Core, Lint & Repo Gates, Governed Surface Queue Guard) all succeeded.
    • The latest run of each of the 42 check names is success or skipped.
  • Noted, not blocking: D1. The equivalence dogfood file has no PR-CI verdict, because dogfood shards are skipped on pull requests; the dev's local 10/10 stands until main's full battery runs it.
  • Landing: the seat flips the PR ready and enables auto-merge. This is the last open part of step 7.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 21, 2026 17:36
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 21, 2026 17:36
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review (delta check after PASS at d4e977b)

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8769400d3f9a5c73d0775afd31e8e32e9e13144c
Local-runs: none
Scope: .changeset/24583-plain-organization-key-part.md, packages/qa/dogfood/test/sharing-rule-criteria-required.dogfood.test.ts, packages/plugins/plugin-approvals/src/later-step-original-submitter.integration.test.ts, packages/plugins/plugin-approvals/src/remind-notified-per-person.integration.test.ts, packages/runtime/src/default-organization-statements.golden.test.ts, .changeset/24583-tenant-column-not-null-ddl.md, packages/drivers/driver-sql/src/adr0120-three-posture-conformance.test.ts, packages/drivers/driver-sql/src/sql-driver-legacy-tenant-composite-null-distinct.test.ts, packages/drivers/driver-sql/src/sql-driver-unique-tenancy.test.ts, packages/drivers/driver-turso/src/turso-remote-drift-detection.test.ts, packages/drivers/driver-turso/src/turso-remote-migration-entries.test.ts, packages/services/service-messaging/src/objects/notification-display-title.test.ts, packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts, packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts, packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts, packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts, packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts, packages/services/service-automation/src/sys-user-default-row-scope.integration.test.ts + read to judge them: AGENTS.md, scripts/check-changeset-no-major.mjs, scripts/pm/clause2-line.mjs, scripts/check-adr-0087-registration.mjs, .github/workflows/pr-automation.yml, .github/workflows/upgrade-harness-optin.yml, .github/workflows/ci.yml, packages/drivers/driver-sql/src/schema-drift.ts, packages/drivers/driver-sql/src/sql-driver.ts, packages/drivers/driver-turso/src/turso-driver.ts, packages/objectql/src/engine.ts, packages/qa/dogfood/test/upgrade-18x-index-equivalence.dogfood.test.ts, packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts, scripts/pm/check-governed-merges.mjs (run read-only)

Reviewed, read-only, in a detached worktree of the head 8769400d3f: the five own commits since d4e977b (97f5296, 5bd3515, 8cbc5c9, 9ee9bf2, 8769400; delta-r3.diff, 195 lines, five files) and the three merges (33feb16, 4dadd7e, 22df698), each through git show --remerge-diff and each also compared against git merge-tree of its two parents to find any hand edit outside the conflicted files; the PR's diff against its merge base 5e9b100743 (origin/main at the 7b-0 landing), 32 paths, +2057/-684 = 2741 lines, split production 528 / tests 2193 / changeset 20; an interdiff of every one of the 32 files' delta at the passed head d4e977b (against its merge base 81e6edd) against its delta now; the live PR body and the dev's report 6322170760; the check-runs of the head (49 runs, 42 distinct names, read latest-per-name) and the annotations of both Check Changeset runs; the gate scripts/check-changeset-no-major.mjs and its reader scripts/pm/clause2-line.mjs at the head, the ADR-0087 gate's signal list, and AGENTS.md § Post-Task Checklist 3 as it reads at the head. Nothing was built, run or re-run except node scripts/pm/check-governed-merges.mjs --branch origin/claude/issue-24583-7b12-index-plain-form, which is read-only. The worktree was removed afterwards.

① Derived judgments

1. 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.

  • 33feb16 (7b-0's branch at 5bd3515 into d4e977b): no conflict; the merge tree equals git merge-tree of its parents byte for byte. No hand edit.
  • 4dadd7e (main at 74fcec1, before 7b-0 landed): seven conflicts, all tests; the merge tree differs from the automatic merge only in those seven files. Six took main's side outright; sharing-rule-org-less-caller.dogfood.test.ts took main's side plus 7b-0's relaxTenantColumns line; default-organization-statements.golden.test.ts took main's new test title plus 7b-0's CONTROL relax. Every one of the seven is now byte-identical to main at 5e9b100743 (none is in the PR diff; main's default-organization-statements.golden.test.ts carries the CONTROL relax itself at its line 179), so whatever the intermediate state, nothing of this resolution survives that main does not own.
  • 22df698 (main at 5e9b100, carrying 7b-0): seven conflicts, hand-resolved only there (merge tree vs automatic merge differs in exactly those seven). Judged one by one:
    • .changeset/24583-tenant-column-not-null-ddl.md: main's version taken (patch/patch/patch, Metadata-protocol: none, the ADR-0087 marker and the lifecycle-tier sentence); blob ad64e6dc on both main and head. Correct: 7b-0's final changeset is main's to own, and this part's branch had carried a WIP copy.
    • notification-display-title.test.ts: main's version taken; the branch side's extra NotificationSubscription import is referenced nowhere in the head's file. Identical to main.
    • The five driver-sql/turso suites took this part's side at every conflict hunk; main's non-conflicting 7b-0 additions (the asPre18TenantColumn / initObjectsPre18 imports and relax calls, the pre18Tenant flags) merged in automatically and are present at the head. What was dropped from main's side in each is exactly the ADR-0120 D3 bucket assertion this part retires, and each is replaced by a stronger pair of pins: adr0120-three-posture-conformance.test.ts:290-305 (fresh table: NULL organization refused by the column, NOT NULL message; pre-18 table: NULL twins admitted; the organization row fits once and its duplicate is refused), :321-330 (crm_case, same shape, composite still in force for org_a), :356-363 (crm_team); sql-driver-legacy-tenant-composite-null-distinct.test.ts:90-103 (fresh: not a unique violation, NOT NULL message; pre-18: two untenanted rows with distinct ids); sql-driver-unique-tenancy.test.ts:573-593 (fresh account: NOT NULL refusal, count 0), :594-622 (pre-18 template: two NULL rows plus one org row, count 3, org duplicate refused), :704-734 (team: org duplicate refused on the fresh table, NULL twin admitted after the relax); turso-remote-drift-detection.test.ts:189-218 (the 18.0 COALESCE key on an uncertified nullable table is destructive recreate_index with no nullSafeColumns / tightenNullSafeOnly; the D4 tightening is reached only through ALL_NULL_SAFE), :245-262 (the certified leg: same bytes, resolver true, zero drift on both faces); turso-remote-migration-entries.test.ts:210-250 (coalescedTenantKey: destructive, refused without --allow-destructive, applied with it, certified reads as no entry and nothing moves), :252-273 (bareTenantKey relaxes unconditionally and asserts the synced index IS COALESCE and the op carries tightenNullSafeOnly and nullSafeColumns). These are the re-pins the r1 and r2 records judged (e4c6ffe, de01287, 31c75dd), re-applied over main's version; nothing main meant beyond the retired bucket is lost.
  • Interdiff of the 32 files (delta at d4e977b vs delta now): 17 files identical; of the 15 that moved, schema-drift.ts, memory-unique-constraint.ts, sys-setting-identity-index.ts, generate.ts, the equivalence dogfood test, the certified-supply test and the tenant-key-equivalence test are identical, and sql-driver.ts, engine.ts, turso-driver.ts differ only by hunk-header line numbers plus, for sql-driver.ts, the removal of 7b-0's own hunks (shard NOT NULL at ensureShardTable, the tenantColumn parameter of createColumn, initObjects' NOT NULL), which now live on main (5e9b100743 touched sql-driver.ts by 30 lines). So the production delta is the re-review's, unchanged. The twelve test files that moved lost only 7b-0's asPre18TenantColumn imports and relax calls (now main's) and, in the five hand-resolved suites, main's bucket lines as itemized above. The changeset moved by the two own edits (item 2 and ② below). Two files of the r1 own-paths list, sql-driver-tenant-scope-read-doors.test.ts and sql-driver-tenant-scope.test.ts, left the diff because 7b-0 landed the identical edits (blob equal on d4e977b, head and main). No 7b-0 content remains in the PR diff, and no file in it is one main already owns: all 32 carry this part's own change and all 32 are in the r1 own-paths list.

2. R1 of the re-review — CLOSED. The clause is folded in at .changeset/24583-plain-organization-key-part.md:18: "no declared organization composite is dropped or rebuilt by the boot, os migrate plan or os 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", and mirrored in the PR body's first Clause-② bullet. It is true against the head: the ceremony's SQLite NOT NULL step goes through the drift apply door's tighten_not_null case (sql-driver.ts:14663, :14692) into rebuildSqliteTablePatched (:14748, collecting tighten at :14760), which re-creates the declared indexes from metadata, and the generators key them plain (schema-drift.ts:1855-1888, :1900-1930); on a NOT NULL column COALESCE(organization_id, '__global__') and organization_id enforce the same constraint, so "the same constraint" holds. The PR body's Measured section still carries the 62-table reading. R2 of the re-review (the unsubstituted time-stamp placeholder) is fixed in the live body ("on 2026-10-20 (about 16:15Z)"); R3 (plugin-webhooks red) is cleared by Test Core 2/6 success on this head; R4 (no CI on the head) is cleared by a full run on this head (item 6).

3. The new changeset gate — satisfied; see ② for the level. AGENTS.md:1088-1092 at the head: Metadata-protocol: added|narrowed|semantic|none, "a changed meaning or default is semantic", the level follows the kind alone, a kind takes at least minor on a package the diff moves. The gate (check-changeset-no-major.mjs:907-910): added / narrowed / semantic → at least one moved package minor+; none → every introduced entry patch; its two contradiction lanes are added under Clause-②: no and narrowed without the (narrowing) arm (:919-924, :2029-2033); the reader accepts trailing prose after the arm (the body line Clause-②: yes (widening). New DDL changes form. was read as yes / arm widening in the failed run's own annotation). The changeset carries both lines at :12-13, the body at its lines 3 and 8, both line-initial. The ADR-0087 gate asks for a marker on a breaking declaration only; widening adds no signal (check-adr-0087-registration.mjs:660-661) and semantic adds no signal (:683), so no BREAKING banner and no marker are owed, as the dev states. Check Changeset on this head: run 119350490647 (16:42:03Z) failed with "no readable Metadata-protocol: declaration, and it is the reading this PR needed" — the body then had no such line; the body was edited (PR updated_at 17:20:48Z) and run 119371077449 (17:20:56Z to 17:22:37Z, the latest for the name) is success. Every changeset sentence re-read against the head's code holds as the r2 record found; the one new sentence is item 2's.

4. Under 7b-0's NOT NULL — the index contract holds on both column shapes.

  • Fresh table (NOT NULL): the two CREATE TABLE sites pass tenantColumn (sql-driver.ts:12769 for initObjects, :12161 for a shard) and createColumn makes it notNullable() (:20284, multi-value arm :19862); the generators emit the plain composite and set nullSafeColumns only when the input already carries it (schema-drift.ts:1870-1876, :1915-1922), so the COALESCE form is never emitted on a fresh install. The differ learns tenantColumnNotNull from introspectColumns (sql-driver.ts:13997) and so tenantKeyCertified (:14033-14035) without asking the ledger; the uncertifiedTenantKeyOnly message (schema-drift.ts:2568-2575) cannot fire there. Pinned: the fresh-install leg of the equivalence file (upgrade-18x-index-equivalence.dogfood.test.ts:259-279: no COALESCE organization key part anywhere, no index drift) and sql-driver-unique-tenancy.test.ts:204-218 (DDL carries no COALESCE or __global__).
  • Nullable column, certified (18.0.0-born or ceremonied): isTenantKeyCertified (sql-driver.ts:20576-20603) asks the engine's closure (engine.ts:9404, :12607-12623) → isOrganizationOwnershipCertified (:12640-12660: verified ledger row at shape generation ≥ current); comparisonKey then drops the tenant field from nullSafeColumns on both sides (schema-drift.ts:2412-2418), so an 18.0 COALESCE index matches the plain expectation and no recreate_index is produced; the Turso remote face inherits the resolver (turso-driver.ts:1106). Nothing is rebuilt and nothing is flagged on boot: pinned by the 18.0.0-born leg (upgrade-18x-index-equivalence.dogfood.test.ts:231-256: generation-1 birth marker, nullable columns, at least 10 COALESCE indexes, zero recreate_index and zero destructive drift, every COALESCE index byte-identical after the boot) and the ceremonied legs (:132-141, :165-177). The only index that moves is sys_setting's, by its own boot migration, probe-first, as the changeset says.
  • Nullable column, uncertified: the two forms stay distinct, destructive recreate_index, message names the ceremony and refuses the --allow-destructive route (schema-drift.ts:2590-2609); never applied unattended. Unchanged since r2.
  • R2 holds: 18.x upgrade harness (opt-in) is success on this head (run 119350490433, 16:42:03Z to 16:49:30Z), gated on the PR's needs:upgrade-harness label (upgrade-harness-optin.yml:64-66), running test/upgrade-18x-harness.dogfood.test.ts (:119), which carries the fixture-18.0.0 leg (upgrade-18x-harness.dogfood.test.ts:310-352: birth-marked at generation 1 / 18.0.0, HEAD's plan holds no destructive change, boots with no refusal, every 18.0.0 row intact).

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 relaxTenantColumns call with an organization stamp on the legacy sharing rule; 8cbc5c9 removed that stamp again because the main merge had left the object literal with two organization_id keys (lines 161 and 170 at 4dadd7e), keeping main's organization_id: organizationId (main's sharing-rule-criteria-required.dogfood.test.ts:165), so no relax and no NULL row; 5bd3515 added installDefaultOrganization to the two plugin-approvals engines (no relax; main's final version is identical); 9ee9bf2 deleted a 7b-0 WIP relax loop from the golden test (the file at 22df698 had relaxes at 179 and 208; main has only 179, the CONTROL's, with its pre-18 justification). Each of the four moves toward NOT NULL, not away from it.

② Semver level

minor on all six packages with Clause-②: yes (widening) and Metadata-protocol: semantic is the level the new gate requires. semantic is the correct kind: the authorable keys unique: 'organization' (declared index) and field-level unique: true / 'organization' on a tenant-scoped object keep their spelling and accepted values, and what they produce and enforce changes — the plain key part, and on a nullable tenant column no constraint over NULL-organization rows; nothing an author writes is added (added is wrong) and nothing is refused or tightened (narrowed is wrong); none would be refused by the gate beside any minor (check-changeset-no-major.mjs:925-928) and would misstate a changed meaning. The kind owes minor on at least one moved package; all six are moved by a production file in the diff (sql-driver.ts / schema-drift.ts, turso-driver.ts, memory-unique-constraint.ts, engine.ts, sys-setting-identity-index.ts, generate.ts) and all six are minor. The change is a widening: rows a COALESCE index refused on a nullable column are admitted by the plain one, the sys_setting re-key is a pure relaxation, the differ equivalence removes findings and adds none, and the uncertified arm changes message text only; the gate accepts a widening with no BREAKING banner and the ADR-0087 gate reads no breaking signal from widening or semantic. The delta adds no behaviour over the passed head (production interdiff empty), so the r2 level judgment stands.

③ Boundary flags

  • D1 — not blocking, measurement. The equivalence file upgrade-18x-index-equivalence.dogfood.test.ts has no PR-CI verdict on this head: the dogfood shards are skipped on pull requests (two speeds; Dogfood Regression Gate (N/3) skipped, the bare Dogfood Regression Gate context is the carrier), and the opt-in harness runs only upgrade-18x-harness.dogfood.test.ts. Its green on this head is the dev's local 10/10. Main's full battery after landing is the measurement; same class as the r2 record's R4, narrower.
  • D2 — not blocking, state. The PR is still draft: true (mergeable_state: clean). Ready first, arm second, per Multi-agent discipline §7.
  • CI on the head (item 6). Latest run per name: every completed run is success or skipped; nothing is pending or in progress. The five required contexts are all success: Lint & Repo Gates (16:47:51Z), TypeScript Type Check (16:57:09Z), Test Core (17:14:04Z; shards 1/6 to 6/6 all success), Build Core (16:47:52Z), Governed Surface Queue Guard (16:43:07Z). The only red run on the head is the superseded Check Changeset 119350490647, replaced by the success 119371077449 after the body edit (item 3). Red required checks: none. Pending required checks: none.
  • Size and governance (item 6). check-governed-merges --branch origin/claude/issue-24583-7b12-index-plain-form: origin/main = fccbd9d, branch = 8769400, merge base = 5e9b100; 0 of 32 paths hit the register (NOT governed); 2741 changed lines (+2057 / -684) ≤ 3000, under the human-merge threshold; exit 0. Head repo not measured by that tool; the PR API reports head and base in objectstack-ai/objectstack.
  • Dev's report 6322170760, checked. "No production file changed in this round" — true (interdiff). "2,741 lines over 32 files" — true. "Seven files conflicted" in 22df698, with the resolutions as described — true. "CI Check Changeset was red on the push only because the PR body lacked the Metadata-protocol line" — true per the run's annotation. The report's claim that the gate "re-runs on the edited event" was written at 17:22:35Z, two seconds before the green run completed; it held.

Implemented-by: claude/issue-24583-7b12-index-plain-form
Reviewed-by: session_01Y5583bs2RDd53MH6kM1ca3

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 21, 2026
Merged via the queue into main with commit d5d8146 Oct 21, 2026
49 of 50 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24583-7b12-index-plain-form branch October 21, 2026 18:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants