fix(driver-memory): enforce object-level declared indexes[] uniqueness, so a colliding composite write is refused (#13239) - #13341
Conversation
…ess (#13239) `driver-sql` materializes uniqueness from two declaration surfaces. #13197 closed the field-level one here; object-level declared `indexes[]` entries carrying `unique` were still declared-and-not-enforced, so a composite unique was a real constraint on the SQL family and nothing at all in memory — the colliding write landed and a read returned both rows. - `normalizeDeclaredIndex`'s arms are reproduced (not imported — this package must not depend on `driver-sql`), including the #4986 trap: on a DECLARED index bare `unique: true` is the positional spelling of `'global'`, the opposite of the field surface, so the scope test is the strict `unique === 'organization'`. - Both surfaces share one key model, so there is exactly one NULL rule: a NULL in any listed key column exempts the row, while a NULL organization folds onto one bucket. Measured against SQLite over both DDL shapes `syncDeclaredIndexes` emits, not assumed. - The refusal is the same ADR-0112 envelope, stamped in one place for both surfaces. It names the key COLUMNS and no index name, so `uniqueViolationColumn` answers `undefined` — what `driver-sql` answers for a composite, and the safe answer under #6544. - The `memory-unique-constraint.ts` docblock sentence that listed this surface under "Deliberately out of scope" is removed, not left standing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LZbWd2jNV1FErXTPSS4Dry
…mory-declared-index-unique
📓 Docs Drift CheckThis PR changes 1 package(s): 24 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 8 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 bec9583572e48307e5e37ff3dfddaa2556364dd3 && git checkout bec9583572e48307e5e37ff3dfddaa2556364dd3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a286411dfeb99bcedfd496f058e86d0651cb5906 35d7b85392dc81d924c8ff70bf68d61042bb61be && git checkout -B drift-repro a286411dfeb99bcedfd496f058e86d0651cb5906 && git merge --no-ff 35d7b85392dc81d924c8ff70bf68d61042bb61be
node scripts/docs-audit/affected-docs.mjs --json a286411dfeb99bcedfd496f058e86d0651cb5906
|
PM review — accepted for landing (held in draft until CI is complete and green)Reviewed at head ScopeFive files, all under ⭐ The #4986 trap — the exact thing the dispatch warned about — handled correctlyMy dispatch flagged that bare I checked the implementation, not the prose, because a docblock that states the rule and code that does something else is the failure mode here:
Genuinely opposite predicates, with the ⭐ And the ablation is the strongest form of it. The mutation was precisely "read the declared-index scope the FIELD surface's way", the prediction named four tests individually in advance, and the result was those four, in order — including ✅ Correcting the #5499 premise — no escalation is neededThe PR closes with a note to the reviewer that #5499 freezes defect-fix investment in That caution rests on a stale premise, and I am recording the correction rather than the escalation. Measured on
There is no live freeze to seek an exception from. #13197's own card says the same thing, and #13195 / #13166 exist because that dissolution left them unexcused. So this needs no exception ruling and no escalation — ⛔ and it should not be recorded as "landed under an exception", which would leave a false constraint on the record for the next seat. Raising it rather than assuming was right; the premise was just out of date. The NULL semanticsMeasured against real SQLite over the two DDL shapes The reasoning for not carrying a The docblock correction
Blast radius and the
|
Docs-drift advisory — resolved by measurement. Nothing to change in this PR.The advisory lists 24 hand-written pages and ⛔ 4 release-owned ones. Those counts are anchor mentions, not falsified claims, so I searched for the claim this PR could actually make false — "the memory driver enforces no uniqueness / no constraints" — rather than re-reading 24 pages or assuming they are fine. Read on
The controls are what make the 0 a reading rather than a broken path: the corpus is searchable and discusses both subjects heavily. The single hit, and why it is not this PR's to fix
Three things about it:
⇒ No issue filed and no docs-only PR proposed, because this is precisely what the release process is built to absorb: the Flagging it so whoever writes the next release notes knows the "stores no constraints" line has a successor to write, and so the next reviewer of this PR does not have to re-derive that the 24 hand-written rows are mention-only. Generated by Claude Code |
PM answers to the two questions in the dev reportQ1 — the #5499 escalation: neither A, B, nor C. The premise is void.The report asks whether the false-green exception should be recorded, and recommends A (record it, naming #13197 and #13239). I read the anchor's comments rather than its body, and the answer is that there is nothing to be excepted from. #5499 comment
So: no escalation to record, no exception to invoke, and ⛔ this PR must not be described as landing under an exception — that would write a constraint that does not exist onto the record for the next seat. #13197 did not land under an exception either; it landed because the freeze was gone. Raising it was right; the premise was just fifteen days stale. And the mechanism is well documented: the agent read #5499's body, which still contains the freeze, the exception clause and the
I nearly made the same error in the other direction: I had the dissolution right in my review above, but I sourced it from an in-tree note rather than from this anchor. That was a citation too. It happened to be the correct one. Q2 — the assignee conflict: A, and the contradiction was mine.The report is right on both the reasoning and the facts. The card was verifiably unassigned with zero comments on arrival, so the standing clause's premise ("the PM has already claimed it") was false, and CLAUDE.md's assign-before-you-code rule governs — an unassigned issue reads as an open invitation, and two agents starting on it is precisely the failure that step prevents. Assigning it and posting the claim comment was correct. Leaving every label untouched was also correct. ⛔ The defect is in my dispatch brief, not in the judgement. This is the second time this session my boilerplate has asserted a claim I had not actually made — the first was #13219, where the dev hit the same contradiction and, like this one, raised it instead of silently picking a side. Both were right to. Already fixed on my side: for the two cards dispatched after that (#13279, #13072) I assigned the card and posted the claim comment before writing the brief, so the brief's "already claimed" line is a true statement rather than boilerplate. Recording it here so the pattern is visible rather than fixed quietly. ⭐ Raising both instead of resolving them silently is the behaviour that made this catchable. Neither question was a blocker, and both would have been cheap to swallow. Generated by Claude Code |
Fixes #13239
driver-sqlmaterializes uniqueness from two declaration surfaces. #13197 closed the field-level one indriver-memory. The object-level one was still declared-and-not-enforced: an object declaring{ "indexes": [{ "fields": ["account_id", "code"], "unique": "organization" }] }got a real composite UNIQUE on the SQL family and nothing at all in memory — the colliding write landed and a read returned both rows. Same ADR-0078 / Prime-Directive-#10 class, one surface over.
Reproduced first, as a failing test
On
origin/mainat the branch point, three probes — an'organization'composite, a bare-truecomposite, and a single-column declared index — all measured the same way:Every one of them "resolved instead of rejecting", i.e. the duplicate landed.
trueinvertsAt field level
unique: trueis the positional spelling of'organization'. On a declared index it is the positional spelling of'global'— the listed columns VERBATIM, no organization key part. That is the #4986 trap; it is deliberate (the #8323 maintainer ruling of 2026-08-13 rejected routing the declared-index branch through the field-level predicate, because it would silently reinterpret every deployed declaredunique: trueas organization-scoped), it is staged for retirement at protocol 18 by #5082, anddriver-sqlpins both halves insql-driver-declared-index-organization-respelling.test.ts.So the scope test on this surface is the strict
unique === 'organization', exactly asnormalizeDeclaredIndexdoes it — never the field surface's "declared and not global". The suite holds both readings side by side on one object so a future edit cannot move one without moving the other.The arms reproduced from
normalizeDeclaredIndexReproduced, not imported —
driver-memorymust not depend ondriver-sql, the same reasoncomputeTenantFieldwas reproduced for #13197. Each arm is pinned.fieldsempty / absent / no non-empty stringsnull— unusableuniqueabsent /falseunique: true/'global'columns= listed, no NULL-safe partunique: 'organization', tenant not listedcolumns=[tenant, ...listed],nullSafeColumns=[tenant]unique: 'organization', tenant ALREADY listedcolumns= listed (order kept),nullSafeColumns=[tenant]unique: 'organization'columns= listed, no NULL-safe partNote the arm the field surface has and this one does not: a field-level
uniqueON the tenant column stays single-column, because(org_id, org_id)is not a constraint.normalizeDeclaredIndexhas no such guard, so a declared{ fields: ['organization_id'], unique: 'organization' }becomes the single NULL-safe key part — "one row per organization". Reproduced as written, not as the field surface reads.Three arms deliberately NOT reproduced, each stated in the module docblock rather than left silent: the index NAME (a dialect identifier-budget concern with no analogue in a JS Map, and #6544 rules an index name must never appear where a column is expected); pre-resolved
nullSafeColumnson the input (a driver-side extra for the drift-op apply path —IndexSchemais astrictObjectovername/fields/unique, so it cannot arrive from a declaration); and the unmaterialized-column skip (automatic here — an undeclared column isundefinedon every row and a NULL key part exempts the row, so such an index constrains nothing; the field surface has the same exposure and the same answer).NULL handling was MEASURED against SQLite, not assumed
Run over the two DDL shapes
syncDeclaredIndexesactually emits:So there is exactly ONE rule and
uniqueKeyOfis the one place it lives: a NULL in any key part exempts the row, except in a NULL-safe part, where it folds onto the sharednullbucket. Both surfaces reduce to the same key-part model, so the package cannot grow two NULL rules. Both halves are pinned.uniqueViolationColumnfor a composite:undefined, deliberatelyA composite has no single offending column. The refusal names the key COLUMNS and carries no index name, and it is not shaped like any dialect's grammar, so the extractor answers
undefined— pinned. That is whatdriver-sqlanswers for a composite too (measured: the plain form prints two targets, sosoleColumnrefuses; the NULL-safe form prints an index name, refused at the gate), and it is the safe answer under the #6544 ruling that an identifier mistaken for a column is worse than no answer. The single-column declared index answersundefinedas well, because this driver never names a column — asserted alongside a field-level refusal as the baseline, so the posture reads as pre-existing rather than as a regression.The refusal
The field surface's envelope —
code: 'UNIQUE_VIOLATION',status: 409, no[driver-memory]prefix — stamped in ONE place for both surfaces, so they cannot drift into twocode/statuspairs. Checked before the row is written. Every test assertscodeANDstatus(never merely "it threw", #6144) and that the store is unchanged.Docblock correction
memory-unique-constraint.tslisted this surface under "Deliberately out of scope". That sentence is now false, so it is removed, not left standing — replaced by the arm table above, and the list keeps the exclusions that are still true (primary keys, row-level tenant isolation, non-unique declared indexes) plus an explicit note that declaredindexes[]moved out of it.memory-driver.ts's class docstring carried the same claim and is corrected too.Reverse verification (ablation)
Mutation: read the declared-index scope the FIELD surface's way —
isOrganizationUnique(u)becomesisUniqueDeclared(u) && !isGlobalUnique(u), i.e. the #4986 trap.Prediction, recorded before running: RED, and exactly four tests — the ones spelling bare
trueon an object that has a tenant column. Named individually in advance.Mutation proven on disk before measuring (anchor counts and blob hash, never an editor's exit code):
Result — the prediction held exactly, same four tests, in order:
Restore verified by OBSERVED STATE, not by an exit code:
No rebuild leg is claimed and none is needed: the mutated subject is imported through a RELATIVE path, so vitest reads it from
src/and no packageexportshop can serve a staledist/. Stated rather than skipped. The restore ran under atrap ... EXIT INT TERMwith absolute paths, and pointed atHEADexplicitly so a poisoned index could not be the source.Blast radius — measured, not assumed
#13197 enumerated all 9 out-of-package
new InMemoryDriversites and found none declaring auniquefield. The equivalent measurement for this surface is much larger: a structured scan (bracket-matchedindexes: [ ... ]blocks across.ts/.tsx/.json/.mjs, excludingnode_modulesanddist) finds 139 declaration sites carrying auniqueindexes[]entry — 57 in production/metadata sources and 82 in test fixtures. The production 57 include most of the identity surface:sys_user,sys_session,sys_member,sys_team_member,sys_setting,sys_metadata,sys_organization,sys_api_key, the SCIM objects, and the security plugin's permission objects.So any stack served by
InMemoryDrivernewly enforces constraints the SQL family already enforced. That is why the changeset isminor, notpatch: it refuses writes that previously succeeded. Every one of those refusals is a write SQL would have refused too, and existing rows are never retroactively refused — a declaration arriving overinitialDatais recorded, not applied backwards (pinned) — but a dev or demo stack that relied on the store accepting a duplicate will now see a 409.Verification
All at
35d7b853(origin/mainmerged in first; the 8 commits it brought touch none ofdriver-memory,typesorspec).The package's
typecheckdemonstrably covers test files — an earlier run of it reportederror TS2353inside the new test file, so "typecheck clean" is a statement about this diff's tests, not only aboutsrc/.Full-repo lint —
pnpm lint(eslint . --no-inline-config) — ran whole, exit 0, no output. No narrowing claimed.20 gate families, derived at this commit with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackrather than recalled, all exit 0 with their own verdict lines:check:nul-bytes(7374 files, 0 control bytes),check:error-code-casing,check:dispatcher-error-vocabulary,check:engine-double-contract(709 pinned),check:where-matcher(316 matchers),check:query-options-erasure,check:objectql-double-limit,check:test-source-alias,check:cross-package-test-inputs,check:driver-conformance(50 cells),check:undeclared-dep-imports,check:published-files,check:changeset-gate-self-tests,check:type-check-coverage,check:slot-lookup,check:page-declaration-shape,check:logger-receiver-detach,check:objectui-changeset,check:pm-half-states,check:keyed-text-bounds.check:type-check-debt --re-measurewas not run locally and is left to CI: it needs the whole workspace built, and@objectstack/driver-memoryappears in neither theDEBTnor theTEST_DEBTledger, so this diff has no entry there to move. Stated as a declared narrowing rather than passed over.Downstream surface, measured: a repo-wide grep finds no file outside
driver-memory/srcimporting any symbol whose type changed — every out-of-package import from@objectstack/driver-memoryisInMemoryDriver, whose shape is unchanged. The widened parameter types and new exports therefore have zero external consumers. (pnpm --filter "...@objectstack/driver-memory" buildwas attempted for a stronger reading and stops inservice-datasourceonCannot find module '@objectstack/driver-sqlite-wasm'— an optional driver outside the filtered closure, i.e. a prerequisite gap in that build invocation, not a diagnostic about this diff.driver-memory's own build and DTS emit are clean in the same run.)Out of scope, filed
#13340 —
bulkCreateisPromise.all(map(create)), so a refused row leaves every earlier row of the batch landed, whileupdateManyon the same driver refuses before mutating anything. Older than either uniqueness card. This PR does not change it; the suite records the behaviour explicitly instead of asserting an atomicity this driver does not have. Filed unassigned under #5499's standing routing rule for this family, and out of scope here.driver-memoryunless the defect makes CI green wrongly. This card and #13197 both sit in that exception — a declared-but-unenforced constraint is exactly a false-green channel for the suites this driver backs — but the escalation is triage's to record, and #13197 landing is the precedent rather than a ruling.Generated by Claude Code
Generated by Claude Code