Skip to content

driver-memory enforces field-level unique but not object-level declared indexes[] — a composite unique is a real constraint on driver-sql and nothing at all in memory #13239

Description

@claude

Filed by the domain:engine os-dev working #13197, which added field-level uniqueness to driver-memory. Object-level declared indexes were explicitly out of that card's scope, so they are recorded here rather than carried. Unassigned; grading and routing are triage's.

Dedupe run: the global search/issues endpoint answers 403 from this container (sessions are bound to repository-scoped endpoints), so one targeted MCP search_issues was used instead. It returned 90 hits and the channel demonstrably answered — #13197 itself, #13166, #5499 and #6916 all came back — so the zero for this defect is a reading rather than a silence. Nearest neighbours: #4943 (IndexSchema.partial authorable but emitted by no driver, closed) and #4986 (normalizeDeclaredIndex not scoping a tenant-scoped table on the SQL side, closed). Neither is this.

The observation

driver-sql materializes uniqueness from two declaration surfaces, and packages/drivers/driver-sql/src/schema-drift.ts is explicit that both are indexes on the table:

  1. field-level unique — uniqueIndexesFromFields
  2. object-level indexes[] entries carrying unique — normalizeDeclaredIndex, which is also where a composite unique gets its ADR-0120 D1/D3 organization key part

driver-memory now enforces only the first. An object declaring

{ "indexes": [{ "fields": ["account_id", "code"], "unique": "organization" }] }

gets a real composite UNIQUE on the SQL family and nothing at all in memory: the colliding write lands, and a read returns both rows. That is the same declared-but-unenforced shape #13197 closed for the field surface, one surface over — the ADR-0078 / Prime-Directive-#10 class.

Why it is worth a card rather than a footnote

The two surfaces do not mean the same thing, so this is not a smaller copy of #13197:

Both points are already ruled elsewhere in-tree, so this is mechanical work with a settled shape — it just is not #13197's.

Where it would go

packages/drivers/driver-memory/src/memory-unique-constraint.ts already carries the single judgment point and the refusal envelope (code: 'UNIQUE_VIOLATION', status: 409), and its module docblock names this exclusion by name under "Deliberately out of scope", so the extension point exists and is documented. assertNoUniqueViolation takes a constraint list; a composite constraint is a wider key, not a new seam.

Not claimed here

No position on whether this is worth building. driver-memory is positioned as a dev / demo / in-process driver and is a deliberately WEAK oracle; "it should stay weak on composite indexes" is a legitimate answer, and if it is the answer the docstring's out-of-scope note is already the honest record. What is not legitimate is the current state being neither enforced nor decided.

Related

#13197 (field-level uniqueness, the card that surfaced this) · #4986 / #4943 (the declared-index scope and partial rulings on the SQL side) · #5030 / ADR-0120 D3 (the NULL-organization fold a composite would also need)


Generated by Claude Code

Activity

  1. claude commented on Aug 30, 2026

    @claude
    ContributorAuthor

    Claim — os-dev seat, domain:engine lane.

    • session: session_01LZbWd2jNV1FErXTPSS4Dry
    • branch: claude/issue-13239-memory-declared-index-unique

    Read the comment thread before starting: zero prior comments, so no competing claim. Assignee was empty on arrival and is now set to the shared identity as part of this claim; labels untouched.

    Scope: object-level declared indexes[] entries carrying unique enforced in driver-memory, reproducing normalizeDeclaredIndex's arms rather than importing from driver-sql.

    Generated by Claude Code


    Generated by Claude Code

  2. claude commented on Aug 30, 2026

    @claude
    ContributorAuthor

    os-dev-report

    {
      "issue": 13239,
      "status": "done",
      "branch": "claude/issue-13239-memory-declared-index-unique",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/13341",
      "premise_still_valid": true,
      "summary": "Verified the premise on origin/main first: memory-unique-constraint.ts was present with its 'Deliberately out of scope' bullet naming declared indexes[], and three probes (an 'organization' composite, a bare-true composite, a single-column declared index) all landed the colliding write. driver-memory now enforces object-level declared indexes[] entries carrying unique, reproducing normalizeDeclaredIndex's arms rather than importing driver-sql. Both declaration surfaces reduce to ONE key-part model in uniqueKeyOf, so the package has exactly one NULL rule, and both refusal factories stamp the ADR-0112 envelope through one helper. GOVERNANCE FLAG for the PM, not a blocker: #5499 (maintainer 2026-08-05) freezes defect-fix investment in driver-memory except where a defect makes CI green wrongly, and its standing rule sends new cards in this family to pm:on-hold rather than pm:queue. This card and #13197 both fit that exception (declared-but-unenforced constraints are a false-green channel for the suites this driver backs) and #13197 landing is the precedent, but the escalation is triage's to record and I did not find it recorded. Also noted: my dispatch prompt's first action said to set the assignee, while the standing os-dev clause says never to touch it because the PM has already claimed the card. The card was in fact UNASSIGNED on arrival with zero comments, so the clause's premise did not hold; I assigned it and posted the claim comment, and left every label untouched.",
      "arms_reproduced": {
        "source": "packages/drivers/driver-sql/src/schema-drift.ts :: normalizeDeclaredIndex",
        "bare_true_divergence": "On a DECLARED index bare `unique: true` is the positional spelling of 'global' (listed columns VERBATIM, no organization key part); at FIELD level it is the positional spelling of 'organization'. So the scope test here is the strict `unique === 'organization'` (spec's isOrganizationUnique), never the field surface's isUniqueDeclared && !isGlobalUnique. Pinned by a test that holds BOTH readings on one object.",
        "reproduced": [
          "fields empty / absent / no non-empty strings -> unusable, no constraint (normalizeDeclaredIndex returns null)",
          "unique absent or false -> a PLAIN index, not a constraint",
          "unique: true | 'global' -> columns = listed verbatim, nullSafeColumns = []",
          "unique: 'organization' + tenant column NOT listed -> columns = [tenant, ...listed], nullSafeColumns = [tenant]",
          "unique: 'organization' + tenant column ALREADY listed -> columns = listed (author order kept), nullSafeColumns = [tenant]",
          "unique: 'organization' + no tenant column -> degrades to listed columns alone",
          "field filter is exactly `typeof f === 'string' && f.length > 0` — non-strings dropped, '  ' survives",
          "NO field-surface guard: a declared { fields: ['organization_id'], unique: 'organization' } becomes the single NULL-safe key part ('one row per organization'). The field surface's 'unique on the tenant column stays single-column' arm does NOT exist on this surface, and is pinned as absent."
        ],
        "deliberately_not_reproduced": [
          "The index NAME / buildIndexName: 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. The refusal names COLUMNS; no name is kept.",
          "Pre-resolved nullSafeColumns on the input: a driver-side extra for the drift-op apply path. IndexSchema is a strictObject over name/fields/unique plus tombstones, so it cannot reach a driver from a declaration.",
          "The unmaterialized-column skip in syncDeclaredIndexes: automatic here — an undeclared column is undefined on every row and a NULL key part exempts the row, so such an index constrains nothing. Pinned. Same exposure and same answer as the field surface."
        ]
      },
      "unique_violation_column_decision": "undefined for composites, and pinned. The refusal names the key COLUMNS, carries no index name, and is not shaped like any dialect's grammar, so uniqueViolationColumn extracts nothing. That matches what driver-sql answers for a composite — MEASURED on SQLite: the plain form prints 'UNIQUE constraint failed: t.account_id, t.code' (two targets, soleColumn refuses) and the NULL-safe form prints 'UNIQUE constraint failed: index ...' (an index name, refused at the gate). It is also the safe answer under the #6544 maintainer ruling of 2026-08-08. A SINGLE-column declared index answers undefined too — asserted alongside a #13197 field-level refusal as the baseline, so the posture reads as pre-existing rather than a regression: this driver never names a column. Consequence checked: the engine's isIssuedAutonumberCollision treats undefined as attributable BY DESIGN, so nothing new breaks there.",
      "null_rule_matched": "MEASURED against SQLite over the two DDL shapes syncDeclaredIndexes emits, not assumed. UNIQUE(account_id, code): ('acme',NULL,'X') twice BOTH ACCEPTED and ('acme','A2',NULL) twice BOTH ACCEPTED — NULL-DISTINCT. UNIQUE(COALESCE(organization_id,'__global__'), account_id, code): (NULL,'A1','X') twice -> second REFUSED (the organization part FOLDS), while (NULL,'A2',NULL) twice BOTH ACCEPTED (NULL-DISTINCT still wins). So the composite rule is #13197's rule with a wider key and no new invention: a NULL in any key part exempts the row, except in a NULL-safe part where it folds onto the shared null bucket. One function (uniqueKeyOf) holds it for both surfaces, and the field-level encoding is byte-identical to before (JSON.stringify([scope, value])).",
      "docblock_correction": "memory-unique-constraint.ts's 'Deliberately out of scope' bullet naming declared indexes[] is REMOVED, not left standing; the section now keeps only the exclusions that are still true (primary keys, row-level tenant isolation, non-unique declared indexes) plus an explicit note that this surface moved out of the list at #13239. The module title, the arm table, and the NULL section were rewritten to cover both surfaces. memory-driver.ts's class docstring carried the same false claim ('Object-level declared indexes[] — composite uniques — are not enforced either') and is corrected in the same commit.",
      "blast_radius": "139 declaration sites carry a unique indexes[] entry — 57 in production/metadata sources, 82 in test fixtures. Measured with a bracket-matched scan of `indexes: [ ... ]` blocks across .ts/.tsx/.json/.mjs excluding node_modules and dist, not by a grep window. 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. #13197's equivalent count was ZERO for the field surface, so this card's exposure is categorically larger — that is the changeset-grade argument, and it is measured rather than assumed.",
      "changeset_grade": "minor, for @objectstack/driver-memory only (no other package changed). Same grade #13197 took and for the same reason — it refuses writes that previously succeeded — but the justification is stronger here: 57 production declaration sites vs #13197's zero. Not major: nothing is removed or narrowed; the type changes are a widened parameter (contravariant, still accepts every old argument), an optional field on UniqueAwareSchema, and new exports. Not patch: it is a behaviour change users can observe as a new 409.",
      "tests": "DEFECT REPRODUCED FIRST, on origin/main at the branch point (a throwaway probe file, deleted before committing): `pnpm --filter @objectstack/driver-memory exec vitest run --maxWorkers=2 src/zz-defect-probe-13239.test.ts` -> VERDICT command-exit 1, 'Test Files 1 failed (1) / Tests 3 failed (3)', each failure reading 'AssertionError: promise resolved \"{ id: '2', ... }\" instead of rejecting' — i.e. the colliding write landed on all three shapes ('organization' composite, bare-true composite, single-column declared index). || GREEN AFTER, all at 35d7b853 (origin/main merged in first; its 8 commits touch none of driver-memory, types or spec): `pnpm --filter @objectstack/driver-memory typecheck` -> exit 0, no diagnostics; `pnpm --filter @objectstack/driver-memory exec vitest run --maxWorkers=2` -> 'Test Files 31 passed (31) / Tests 905 passed (905)'. The new file is the 31st (origin/main has 30 test files in this package). || TYPECHECK COVERAGE IS REAL, not assumed: an earlier run of the same command reported 'error TS2353' INSIDE memory-declared-index-unique.test.ts, so this package's tsc program does include test files and 'typecheck clean' is a statement about this diff's tests too. || ABLATION. Mutation: read the declared-index scope the FIELD surface's way — isOrganizationUnique(u) -> isUniqueDeclared(u) && !isGlobalUnique(u), the #4986 trap. PREDICTION RECORDED BEFORE RUNNING: direction RED (not fewer-diagnostics, not a reversal), exactly FOUR tests, named individually in advance. REBUILD: none needed and stated rather than skipped — the mutated subject is imported through a RELATIVE path ('./memory-unique-constraint.js'), so vitest resolves it from src/ and no package `exports` hop can serve a stale dist/; there is no stale-dist false-green to guard against. MUTATION PROVEN ON DISK BEFORE MEASURING, never by the editor's exit code: anchor count 1 -> 0, injected text count 0 -> 1, HEAD blob 2af468e6513af430b0e0fa2b96b2ef22d278b3ba vs mutated blob 695ee14bc75db34d79469df9b2e75c88091518a2 (the script exits 9 and refuses to measure if any of those checks fails). RESULT: prediction held exactly — 'Tests 4 failed | 52 passed (56)', the four being 'a DECLARED index's bare true is global — the listed columns VERBATIM', 'unique: global is the same materialization', 'FIELD-level bare true and DECLARED bare true disagree ON ONE OBJECT', and 'several declared indexes on one object each become their own constraint'. RESTORE VERIFIED BY OBSERVED STATE, not by an exit code: restored blob back to 2af468e6513af430b0e0fa2b96b2ef22d278b3ba, `git diff HEAD --name-only` empty, and the restored-tree re-run 'Tests 56 passed (56)'. The script carried `trap restore EXIT INT TERM` with an absolute REPO_ROOT path and used `git checkout HEAD -- path` (never the bare form, which restores from a possibly-poisoned index). || FULL-REPO LINT: `pnpm lint` (eslint . --no-inline-config) ran WHOLE, exit 0, zero output. No narrowing claimed and none needed. || 20 GATE FAMILIES, derived at this commit by `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (which confirmed the change set is exactly my 5 paths against merge base f27e1c208) rather than recalled — all exit 0, each with its own verdict line: check:nul-bytes ('OK (scanned 7374 text file(s) ... no raw ASCII control bytes)'), check:error-code-casing, check:dispatcher-error-vocabulary, check:engine-double-contract ('OK — 709 pinned'), check:where-matcher ('316 matcher(s) discovered ... 0 silently-wrong'), check:query-options-erasure, check:objectql-double-limit, check:test-source-alias, check:cross-package-test-inputs ('OK: 24 package(s) read outside themselves, all declared'), check:driver-conformance ('OK — 50 covered cell(s)'), 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. Exit codes captured before any pipe. || DECLARED NARROWING: check:type-check-debt --re-measure was NOT run locally — it needs the whole workspace built, and @objectstack/driver-memory appears in neither the DEBT nor the TEST_DEBT ledger, so this diff has no entry there to move. Left to CI. || DOWNSTREAM, measured not assumed: a repo-wide grep finds NO file outside driver-memory/src importing any symbol whose type changed; every out-of-package import from @objectstack/driver-memory is InMemoryDriver, whose shape is unchanged. `pnpm --filter '...@objectstack/driver-memory' build` was attempted for a stronger reading and stops inside service-datasource on \"Cannot find module '@objectstack/driver-sqlite-wasm'\" — an optional driver outside the filtered closure. That is a PREREQUISITE NOT MET for that build invocation, read as NOT MEASURED, never as a red gate on this diff; driver-memory's own build and DTS emit are clean in the same run (dist/index.d.ts 43.20 KB, exporting the new surface). || All heavy runs went through `bash scripts/pm/os-verify-lock.sh -c ...` with OS_VERIFY_LOCK_SLOT=dev-13239; verdicts read from the lock's own VERDICT line, and the multi-part runs are '&&'-joined so the verdict covers every part.",
      "mcp_calls": "1 — a single mcp__github__get_me to learn the shared identity's login. Everything else went through repo-scoped REST (issue read, comment thread read, assign, comment, file the finding, open the PR, read the PR back) and git. The REST probe answered 200 from this container, so the zero-quota channel was available and used; no list/search MCP calls were made.",
      "open_questions": [
        {
          "question": "#5499 (maintainer 2026-08-05) freezes defect-fix investment in driver-memory and routes new cards in this family to pm:on-hold rather than pm:queue, with an exception only for defects that make CI green wrongly — escalated by triage naming them. #13239 was dispatched with pm:dispatched and no visible escalation record. Should that exception be recorded, and does the same apply retroactively to #13197?",
          "options": [
            "A — record the escalation on #5499 (a comment naming #13197 and #13239 as false-green-channel exceptions) and leave both as they are: the work is done, and a declared-but-unenforced constraint on the main unit-test backend is exactly the exception the ruling names.",
            "B — treat the freeze as binding on this card too, close PR #13341 unmerged and move #13239 to pm:on-hold: the cost is the work already done, and it contradicts #13197 which landed an hour earlier under the same reasoning.",
            "C — take it to the maintainer as a scope question: 'is uniqueness enforcement on driver-memory inside or outside the freeze', which decides a family of future cards rather than just this one."
          ],
          "recommendation": "A, because the ruling's own exception clause is written for exactly this shape («driver-memory 的缺陷影响 CI 判绿的正确性(单测后端语义错造成测试假绿/假红)»): driver-memory is stated in #5499 itself as the main backend for packages/objectql's unit tests, and a constraint the SQL family enforces but this driver does not is a false-green generator for every suite that swaps them. #13197 landing makes B a contradiction rather than a correction. C is worth doing IN ADDITION if more of this family is queued, but it should not block a PR whose sibling already landed."
        },
        {
          "question": "My dispatch prompt's FIRST ACTION said to assign #13239 to myself; the standing os-dev clause says never to touch the assignee because the PM has already claimed the card. Which governs when the card arrives unassigned?",
          "options": [
            "A — the clause's premise is a factual claim ('already claimed'), so when it is false the clause does not fire and CLAUDE.md's assign-before-you-code rule governs. That is what I did.",
            "B — the clause is unconditional and the assignee should have been left empty, with the claim carried by the comment alone."
          ],
          "recommendation": "A, because CLAUDE.md is explicit that an unassigned issue reads as an open invitation and that two agents starting on it is the failure the assign step prevents. Recorded here rather than chosen silently; the card was verifiably unassigned with zero comments when I arrived, and no label was touched."
        }
      ],
      "out_of_scope_findings": [
        "filed as #13340: driver-memory's bulkCreate is Promise.all(map(create)), so a refused row leaves every earlier row of the batch landed, while updateMany on the same driver refuses before mutating anything. Older than either uniqueness card; measured at 3 rows after a refused 2-row batch on a 2-row table. Filed unassigned with domain:engine + pm:on-hold + finding per #5499's standing routing rule for this family. Deduped first over 359 open issues via the REST list endpoint plus local grep, with a control term (6 hits for 'driver-memory' in titles) proving the channel answered; 5 keyword hits, none this defect."
      ]
    }

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions