Skip to content

[finding] classifyIndexFailure is a FIFTH private unique-violation vocabulary, missed by #6250's inventory — and it reads only the message channel #6699

Closed

Description

@os-project-manager

Filed by the #6544 developer agent while migrating import-runner.ts onto @objectstack/types' shared predicate. Out of that card's lane, so recorded rather than fixed.

What

packages/metadata-protocol/src/migrations/view-definition-active-index.ts:279:

export function classifyIndexFailure(message: string): EnsureViewIndexStatus {
    if (/unique constraint failed|duplicate entry|duplicate key value|violates unique/i.test(message)) {
        return 'conflict';
    }
    ...
}

That first arm is the same question isUniqueViolationError (packages/types/src/unique-violation.ts, #6250 / PR #6541) exists to answer once. #6250 inventoried four private vocabularies — service-messaging, rest's mapDataError, rest's sanitizeRowError, driver-sql's inline regex (#6543). This is a fifth, in a package none of those touched, so it was never in the table and is not covered by any of the queued follow-ups.

Why it is observation-class, not a live defect

The four message substrings here are a superset of the shared predicate's message limb minus nothing that matters — all three dialects emit one of these words on a duplicate-key CREATE UNIQUE INDEX failure, so today's callers get the right verdict.

The gap is the one #6250 was about: this reads the message channel only. A driver that reports the conflict on code / errno and gives unhelpful prose (insert failed, a pooled wrapper's Write failed, or the condition on error.cause) is classified failed here, where the shared predicate answers true. That is exactly the hole that made every MySQL conflict a 500 in mapDataError before #6541 — the same shape, in a spot nobody has measured.

Consequence if it ever lands: a real duplicate-row conflict on sys_view_definition reports as failed rather than conflict, which (per the function's own doc comment, and more so since #6417 made the data-conflict path live) is precisely the misreport the ordering in that comment was written to prevent.

Not a mechanical swap

Two things a migration has to keep, both documented in place:

  1. The ordering is load-bearing. Duplicate-row wording is deliberately checked BEFORE dialect wording, because MySQL's duplicate error mentions the key and some drivers wrap both facts in one string. isUniqueViolationError answers a boolean and has no opinion about the second arm, so the call has to stay first, not merge.
  2. This function takes a message: string, not the error. The predicate accepts a bare string, so the swap compiles unchanged — but a string-only call throws away the very code / errno / cause channels that are the reason to migrate. Doing this properly means passing the error object down from the caller, which is the actual work.

Suggested shape

Pass the caught error into classifyIndexFailure (keeping a string overload if a caller genuinely has only prose), call isUniqueViolationError(error) for the first arm, and leave the dialect arm as-is. Sibling to #6543, and the same lane discipline: one consumer per PR.

Related: #6250 (the predicate), #6543 (driver-sql's copy), #6544 (import-runner's copy), #6417 / #5839 (what made this path live).

Activity

  1. added
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 8, 2026
  2. added
    pm:queueReady for the PM dispatch loop
    and removed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 8, 2026
  3. os-zhuang commented on Aug 8, 2026

    @os-zhuang
    Contributor

    Findings-triage round (#4949 discipline): promoted — finding → pm:queue, domain:metadata kept.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  4. self-assigned this
    on Aug 8, 2026
  5. os-zhuang commented on Aug 8, 2026

    @os-zhuang
    Contributor

    Claim: PM loop round 5 (domain:metadata seat, sticker #6367)
    Session: session_01W6bLax4KMrSfnE1ydFU8Dw
    Branch: claude/issue-6699-classify-index-failure-error-channel
    Worktree: objectstack-issue-6699
    Domain: domain:metadata
    File surface: packages/metadata-protocol/src/migrations/view-definition-active-index.ts (classifyIndexFailure + the caller that threads the error object down) + view-definition-active-index.test.ts + changeset. packages/types' isUniqueViolationError is consumed read-only — if its contract turns out insufficient for the first arm, that is a STOP → report, not an edit.
    ⚠️ Base ≥ 50a8d115a — PR #6817 (#6772) landed tonight in this same file (the buildDuplicateProbeSql region), so line anchors in the card body have moved; branch from fresh origin/main.
    Binding constraints from the triage promotion (2026-08-08 14:29Z): arm ordering stays (duplicate/unique wording checked BEFORE dialect wording); the real work is threading the caught error object from the caller — a string-only swap discards the code/errno/cause channels that justify the migration; isUniqueViolationError(error) answers the FIRST arm only, the dialect arm stays as-is. One consumer per PR (family: #6250 / #6543 / #6544).


    Generated by Claude Code

  6. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    Contributor

    os-dev report — #6699 · status done · PR #6845 (draft) · branch claude/issue-6699-classify-index-failure-error-channel

    Premise: valid, but the card's file anchor had expired

    The issue body and the 14:29Z triage comment both place classifyIndexFailure at view-definition-active-index.ts:279. On origin/main @ 220536377 it is not there — #6418 moved it to migrations/partial-index-probe.ts:102 when ensureOverlayIndex adopted the probe-first order; view-definition-active-index.ts:196 only re-exports it now. This is a bigger drift than the claim comment anticipated (it warned about line anchors moving under PR #6817, not about the function having changed file).

    Everything the finding asserts holds at the new address: still classifyIndexFailure(message: string), still its own private regex, and isUniqueViolationError still had zero consumers in this package. So premise_still_valid: true — the fix simply landed one file over.

    Two consequences of the move, both recorded in the PR:

    PM mechanism assumptions — measured

    assumption result
    base ≥ 50a8d115a confirmed, branched from 220536377
    classifyIndexFailure string-only, predicate unconsumed here confirmed (at the new path)
    callers = one in-file site (~:369) + tests falsified — the only call site is probeThenReplaceIndex's catch in partial-index-probe.ts, reached by both migrations; plus the index.ts public re-export and two test files. Full-repo grep in the PR.
    isUniqueViolationError takes string or object, reads code/errno/cause/message confirmed by reading packages/types/src/unique-violation.ts. Named export, (error: unknown) =. boolean, cause walk bounded at depth 4. Contract sufficient — no packages/types edit, no STOP needed.

    What changed

    First arm delegates to isUniqueViolationError; probeThenReplaceIndex passes the caught error object rather than err.message. detail is untouched — still the driver's prose, for the operator. Signature widens string to unknown, so every existing string call compiles and is judged identically.

    Both load-bearing constraints preserved. Arm order unchanged (duplicate/unique before dialect, with the doc comment's rationale kept and extended). Dialect arm untouched and still this module's own message-based vocabulary, reading via a new private indexFailureText that prefers message and falls back to String() — a bare string resolves to itself.

    Measured old-versus-new: all 6 message-channel verdicts unchanged; 6 object-channel cases move from failed/unsupported to conflict (SQLite SQLITE_CONSTRAINT_UNIQUE, MySQL ER_DUP_ENTRY/errno 1062, bare errno, Postgres 23505, a cause-wrapped 23505, and code-plus-dialect-wording). Full table in the PR.

    Reverse verification — direction predicted first, split in two

    Stage A, revert only the call-site threading: predicted exactly one red — the threading pin — because no classifier-level assertion can see how the caller calls it. Result: 1 failed | 754 passed, the probe hands the ERROR to the classifier → expected 'failed' to be 'conflict'.

    Stage B, revert the whole source file to origin/main: predicted all object-channel pins red, all prose pins green. Result: 8 failed | 747 passed, all 8 object-channel, every prose pin green.

    One prediction reported honestly rather than dressed up: a dialect refusal carrying its own code is still 'unsupported' is green in both directions by design — it pins that widening the input did not blind the second arm, so it can never be evidence the fix works.

    Tests and gates

    pnpm --filter @objectstack/metadata-protocol test → 63 files, 755 tests, all pass. tsc --noEmit → 0 errors in the changed files; package total 63, exactly its DEBT ledger entry, so the ratchet does not move. eslint clean. check:nul-bytes, check:durability-log-level, check:adr-anchors, check:doc-authoring, check:empty-changeset, check:startup-registry-verdict, check:error-code-casing, check:type-check-coverage all pass. check:type-check-debt refuses to run locally without a full build closure (its own #6376 guard) and was left to CI.

    CI converged, 24 check runs, 22 success + 2 skipped, 0 failures. Per-job conclusions read individually: ESLint success, TypeScript Type Check success (this is where the debt ratchet ran with the closure built), Check Changeset success, Test Core and all three shards success.

    Changeset: @objectstack/metadata-protocol patch. skip-changeset not applicable — a changeset is present.

    Out of scope

    Filed #6848 (finding, unassigned, no pm:queue): the two arms now read different depths — isUniqueViolationError walks cause, the dialect arm reads the outer message only. Dormant (no shipped driver wraps DDL errors that way) and not a regression from this PR, but if a wrapping driver ever lands, overlay-index.ts silently skips its fallback lookup index rather than merely wording the report differently.


    Generated by Claude Code


    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

pm:dispatchedDispatched to a dev agent by /pm-dispatchpm:queueReady for the PM dispatch loop

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions