fix(driver-sql): hash-shadow UNIQUE indexes carry the NULL-safe organization key part (ADR-0120 D3) - #13016
Conversation
…adow-null-safe-key
…adow-null-safe-key
📓 Docs Drift CheckThis PR changes 1 package(s): 6 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 — 9 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 bb9f739c671558113463fb98d795c7095bcf00c9 && git checkout bb9f739c671558113463fb98d795c7095bcf00c9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d41d166b857e5ff188ba79bcf756c8b110a48cb0 1b04af6506767ef9e2c4d818380554612755ca1e && git checkout -B drift-repro d41d166b857e5ff188ba79bcf756c8b110a48cb0 && git merge --no-ff 1b04af6506767ef9e2c4d818380554612755ca1e
node scripts/docs-audit/affected-docs.mjs --json d41d166b857e5ff188ba79bcf756c8b110a48cb0
|
|
ACCEPT — armed ( Entry qualification: every check. 32 runs at The direction taken, and why it was the right oneThe card offered two: hash the declared key, or refuse the shadow for NULL-safe uniques. The first was taken, and the deciding evidence is not aesthetic — ADR-0120 D4's pre-flight ⇒ My Clause ② judgment held under measurement rather than by assertion. I ruled this a bug fix restoring declared behaviour because ADR-0120 D3 already declares the COALESCE semantics, and said the judgment flips if the declaration turned out ambiguous. It is not ambiguous, and the dev checked instead of taking my word. Ruling 2's control is untouched and green. Plain composite parts keep Ruling 4's edge is diagnosed — loud, named, and not fatalAn ⭐ And the write path follows the same key, so a genuine NULL-org duplicate is now named in declared terms instead of being misreported as a HASH COLLISION. Nobody asked for that; it is the difference between a fix and a fix someone can operate. Both of my Zone 2 assumptions were re-derived, not inherited
Ablation, predicted before running: reverting the call site to the bare column list must red the three org-scoped pins and keep both controls green. Observed exactly: 3 failed (collide-NULL-org, boot-diagnosis, write-path-naming) / 12 passed including both controls / 1 skipped. Mutation confirmed on disk by anchored grep counts; restore proven by empty
|
Fixes #12998
What
When MySQL refuses a declared UNIQUE index directly, the #11627 hash-shadow route now hashes the DECLARED key instead of the raw column list:
syncDeclaredIndexespassesnorm.nullSafeColumnsthrough tocreateHashShadowUniqueIndex, and the generation expression embeds each NULL-safe organization key part (ADR-0120 D3) asCOALESCE(organization_id, '__global__')inside theCONCAT. NULL-organization rows therefore fold into the global bucket and collide with each other — the same key the direct index would have enforced. Plain key parts keep the deliberate CONCAT-NULL semantics: an any-NULL tuple still conflicts with nothing.Route taken: the card's first direction (hash the declared key). The refusal alternative would have left the two live members constraint-less on MySQL; the expression form enforces what ADR-0120 D3 already declares, and the D4 duplicate pre-flight (
probeNullSafeUniqueDuplicates) already probes this exact COALESCE key, so plan-time probing and enforcement now agree (verified at source and exercised live through the new boot-diagnosis pin).The data-dependent edge, disclosed (also in the changeset)
Turning this constraint on is data-dependent: a MySQL database that accumulated duplicate NULL-organization rows while the shadow enforced nothing fails the shadow ALTER with ER_DUP_ENTRY on its next boot. That failure is now diagnosed, never fatal and never silent: the boot continues, the durability log names the conflicting groups (probed over the same COALESCE key) and the operator action, and the constraint is honestly reported as NOT enforced until the data is deduplicated — the same disposition as the direct NULL-safe route (ADR-0120 D4). Live-measured message shape:
The write-path duplicate diagnosis follows the key too:
hashShadowSourceColumnsreads the COALESCE wrapping back from the stored generation expression, andexplainHashShadowDuplicatere-selects through the same fold (COALESCE on NULL-safe parts, MySQL's null-safe equality on plain parts). Without that, a genuine NULL-organization duplicate re-selected with a bare equals-NULL matched nothing and would have been misreported as a HASH COLLISION with "please report it, the write is legitimate".Both collision directions pinned (live MySQL 8.0.46)
New file
sql-driver-12998-shadow-null-safe-key.test.ts, all read back from information_schema, never from our own DDL:Ablation (mutation and restore both proven on disk by grep counts and by HEAD-blob hash): with the nullSafe pass-through removed at the call site, the three org-scoped pins go red exactly as predicted (second NULL-org insert accepted; no boot diagnosis; no write-path refusal) while the plain-composite control and the entire #11627 file stay green.
Live members
Measured with faithful driver-level shapes on live MySQL 8.0.46: both
sys_notification_preference(user_id, topic, channel · organization-scoped) andsys_notification_subscription(topic, principal · organization-scoped) take the shadow route today (unbounded TEXT key parts) and, with this change, their generation expressions carry the COALESCE part; duplicate NULL-organization admin-global rows are refused. Width re-derivation for the post-#12978 world: the injected organization_id is varchar(255), so the org part alone spends 255 of the 768-char utf8mb4 key budget; any id-family bound (255) plus topic (255) already exceeds the remaining 513 chars, so both members stay shadow-carried under any plausible #12978 bounds. #12978 remains open and is not addressed here; neither card assumes the other has landed.Out of scope, filed separately
The drift differ reads a healthy shadow-carried UNIQUE as destructive
recreate_indexdrift (physical key is the shadow column, so the column-identity comparison never matches), and the proposed remedy would fail on the surviving generated column — pre-existing (this diff does not touch the differ), measured live, filed as #13015 (finding). #13015 is not addressed here.Verification
sql-driver-12998-shadow-null-safe-key.test.ts,sql-driver-11627-hash-shadow-key.test.ts,sql-driver-keyed-text-mysql.test.ts, both tenant-scoped-declared-unique suites,adr0120-three-posture-conformance.test.ts,declared-index-retired-keys.test.ts, and all fiveschema-drift.*.test.tssuites: 12 files, 245 passed, 3 skipped (the unprovisioned live-Postgres cells; the Postgres control runs in CI's Temporal Conformance job — this diff is selected by MySQL error codes and the SQLite no-shadow control ran green locally).pnpm --filter @objectstack/driver-sql typecheckgreen, and the tsc program demonstrably covers the new test file (an earlier revision failed typecheck inside it).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, non-stale tree) plus convention-triggered and changeset families: run at 1b04af6 — full list with exit codes in the os-dev-report comment on driver-sql (MySQL): the #11627 hash shadow drops the NULL-safe organization key part — a shadow-carried org-scoped UNIQUE reintroduces #5030's NULL-org zero-constraint #12998.Generated by Claude Code
Generated by Claude Code