Skip to content

fix(driver-memory): enforce object-level declared indexes[] uniqueness, so a colliding composite write is refused (#13239) - #13341

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-13239-memory-declared-index-unique
Aug 30, 2026
Merged

fix(driver-memory): enforce object-level declared indexes[] uniqueness, so a colliding composite write is refused (#13239)#13341
os-zhuang merged 2 commits into
mainfrom
claude/issue-13239-memory-declared-index-unique

Conversation

@claude

@claude claude Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Fixes #13239

driver-sql materializes uniqueness from two declaration surfaces. #13197 closed the field-level one in driver-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/main at the branch point, three probes — an 'organization' composite, a bare-true composite, and a single-column declared index — all measured the same way:

AssertionError: promise resolved "{ id: '2', ... }" instead of rejecting
 Test Files  1 failed (1)
      Tests  3 failed (3)

Every one of them "resolved instead of rejecting", i.e. the duplicate landed.

⚠️ Not a smaller copy of #13197 — bare true inverts

At field level unique: true is 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 declared unique: true as organization-scoped), it is staged for retirement at protocol 18 by #5082, and driver-sql pins both halves in sql-driver-declared-index-organization-respelling.test.ts.

So the scope test on this surface is the strict unique === 'organization', exactly as normalizeDeclaredIndex does 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 normalizeDeclaredIndex

Reproduced, not imported — driver-memory must not depend on driver-sql, the same reason computeTenantField was reproduced for #13197. Each arm is pinned.

declared entry tenant column driver-sql here
fields empty / absent / no non-empty strings any null — unusable no constraint
unique absent / false any a PLAIN index not a constraint
unique: true / 'global' any listed columns VERBATIM columns = listed, no NULL-safe part
unique: 'organization', tenant not listed present organization key part PREPENDED columns = [tenant, ...listed], nullSafeColumns = [tenant]
unique: 'organization', tenant ALREADY listed present not prepended twice; its own key part goes NULL-safe columns = listed (order kept), nullSafeColumns = [tenant]
unique: 'organization' absent degrades to listed columns alone columns = listed, no NULL-safe part

Note the arm the field surface has and this one does not: a field-level unique ON the tenant column stays single-column, because (org_id, org_id) is not a constraint. normalizeDeclaredIndex has 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 nullSafeColumns on the input (a driver-side extra for the drift-op apply path — IndexSchema is a strictObject over name/fields/unique, so it cannot arrive from a declaration); and the unmaterialized-column skip (automatic here — an undeclared column is undefined on 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 syncDeclaredIndexes actually emits:

UNIQUE (account_id, code)                                   -- bare true / 'global'
  ('acme', NULL, 'X')  twice   -> BOTH ACCEPTED  (NULL-DISTINCT)
  ('acme', 'A2', NULL) twice   -> BOTH ACCEPTED  (NULL-DISTINCT)

UNIQUE (COALESCE(organization_id,'__global__'), account_id, code)  -- 'organization'
  (NULL, 'A1', 'X')    twice   -> second REFUSED (the organization part FOLDS)
  (NULL, 'A2', NULL)   twice   -> BOTH ACCEPTED  (NULL-DISTINCT still wins)

So there is exactly ONE rule and uniqueKeyOf is 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 shared null bucket. Both surfaces reduce to the same key-part model, so the package cannot grow two NULL rules. Both halves are pinned.

uniqueViolationColumn for a composite: undefined, deliberately

A 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 what driver-sql answers for a composite too (measured: the plain form prints two targets, so soleColumn refuses; 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 answers undefined as 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 two code/status pairs. Checked before the row is written. Every test asserts code AND status (never merely "it threw", #6144) and that the store is unchanged.

Docblock correction

memory-unique-constraint.ts listed 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 declared indexes[] 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) becomes isUniqueDeclared(u) && !isGlobalUnique(u), i.e. the #4986 trap.

Prediction, recorded before running: RED, and exactly four tests — the ones spelling bare true on 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):

original anchor: 1 -> 0    injected text: 0 -> 1
HEAD blob    = 2af468e6513af430b0e0fa2b96b2ef22d278b3ba
mutated blob = 695ee14bc75db34d79469df9b2e75c88091518a2

Result — the prediction held exactly, same four tests, in order:

 × a DECLARED index's bare `true` is `'global'` — the listed columns VERBATIM, no organization key part
 × `unique: 'global'` is the same materialization — bare `true` is its positional spelling
 × FIELD-level bare `true` and DECLARED bare `true` disagree ON ONE OBJECT — the divergence itself
 × several declared indexes on one object each become their own constraint
 Tests  4 failed | 52 passed (56)

Restore verified by OBSERVED STATE, not by an exit code:

restored blob = 2af468e6513af430b0e0fa2b96b2ef22d278b3ba   (back to the HEAD blob)
git diff HEAD --name-only => ''
restored-run exit: 0 -> Tests 56 passed (56)

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 package exports hop can serve a stale dist/. Stated rather than skipped. The restore ran under a trap ... EXIT INT TERM with absolute paths, and pointed at HEAD explicitly so a poisoned index could not be the source.

Blast radius — measured, not assumed

#13197 enumerated all 9 out-of-package new InMemoryDriver sites and found none declaring a unique field. The equivalent measurement for this surface is much larger: a structured scan (bracket-matched indexes: [ ... ] blocks across .ts/.tsx/.json/.mjs, excluding node_modules and dist) finds 139 declaration sites carrying a unique indexes[] 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 InMemoryDriver newly enforces constraints the SQL family already enforced. That is why the changeset is minor, not patch: 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 over initialData is 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/main merged in first; the 8 commits it brought 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 package's typecheck demonstrably covers test files — an earlier run of it reported error TS2353 inside the new test file, so "typecheck clean" is a statement about this diff's tests, not only about src/.

Full-repo lintpnpm 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/objectstack rather 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-measure was not run locally and is left to CI: 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. Stated as a declared narrowing rather than passed over.

Downstream surface, measured: 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. The widened parameter types and new exports therefore have zero external consumers. (pnpm --filter "...@objectstack/driver-memory" build was attempted for a stronger reading and stops in service-datasource on Cannot 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

#13340bulkCreate 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. 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.

⚠️ One thing for the reviewer, not a blocker: #5499 (maintainer, 2026-08-05) freezes defect-fix investment in driver-memory unless 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

claude added 2 commits August 30, 2026 03:36
…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-memory, touching 20 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/drivers/driver-memory/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

24 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json a286411dfeb99bcedfd496f058e86d0651cb5906.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/drivers/driver-memory/src/index.ts) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json a286411dfeb99bcedfd496f058e86d0651cb5906packageMentionDocs.

Which tree this was computed on

This run read content/docs from bec9583572e48307e5e37ff3dfddaa2556364dd3 — the merge of head 35d7b85392dc81d924c8ff70bf68d61042bb61be into base a286411dfeb99bcedfd496f058e86d0651cb5906, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs a286411dfeb99bcedfd496f058e86d0651cb5906 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 30, 2026

Copy link
Copy Markdown
Contributor

PM review — accepted for landing (held in draft until CI is complete and green)

Reviewed at head 35d7b8539, three-dot against the branch's real merge base f27e1c208 (⛔ not the PR's base.sha — shallow clone; a two-dot read here renders post-cut commits as reverts).

Scope

Five files, all under packages/drivers/driver-memory/ plus the changeset. Zero hits for governed surfaces, content/docs/releases/** and packages/spec/src/** — the same patterns matched 3/3 against a synthetic control list, so the zeros are real. Clause-② does not bind (no path limb, no declaration).

⭐ The #4986 trap — the exact thing the dispatch warned about — handled correctly

My dispatch flagged that bare unique: true means 'organization' at FIELD level and 'global' on a declared indexes[] entry, opposite meanings, and that #13197's scope judgment is therefore not reusable on this surface.

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:

  • memory-unique-constraint.ts:324field surface: !isGlobalUnique(unique) && tenantField != null && tenantField !== name
  • memory-unique-constraint.ts:367declared index surface: isOrganizationUnique(idx?.unique) && tenantField

Genuinely opposite predicates, with the ⚠️ at :334-335 naming why. And the tenantField !== name guard on the field surface only — correct, since (org_id, org_id) is not a constraint, while normalizeDeclaredIndex has no such guard and a declared { fields: ['organization_id'], unique: 'organization' } really does become "one row per organization". Reproduced as written, not as the field surface reads — which is the whole discipline this trap demands.

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 FIELD-level bare true and DECLARED bare true disagree ON ONE OBJECT, which is the divergence itself held on a single fixture so a future edit cannot move one reading without moving the other. Mutation proven on disk by anchor counts and blob hash before any measurement; restore verified by observed state (blob back to 2af468e6, git diff HEAD empty), not by an exit code.

✅ Correcting the #5499 premise — no escalation is needed

The PR closes with a note to the reviewer that #5499 freezes defect-fix investment in driver-memory unless the defect makes CI green wrongly, that this card sits in that exception, and that "the escalation is triage's to record."

That caution rests on a stale premise, and I am recording the correction rather than the escalation. Measured on origin/main, packages/spec/src/data/aggregation-conformance.ts:141-142:

Every row is struck through: the maintainer lifted the #5499 investment freeze for driver-mongodb on 2026-08-11 and for driver-memory later the same day.

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 semantics

Measured against real SQLite over the two DDL shapes syncDeclaredIndexes actually emits, not assumed — including the asymmetry that matters: a raw composite is NULL-DISTINCT (both rows accepted) while the COALESCE(organization_id,'__global__') form folds and refuses the second. Reducing both surfaces to one key-part model, in one place (uniqueKeyOf), is what stops the package growing two NULL rules.

The reasoning for not carrying a '__global__' token into the JS path is correct: the sentinel is a SQL-expression artefact for an operator that has no NULL-folding of its own, and a JS Map key folds a NULL organization onto null natively. ⭐ The report also names the one case where the JS version is stricter — a row whose organization id is literally the string '__global__' — and names the reserved-token guard that makes it unreachable, instead of quietly claiming equivalence. That is the honest shape.

The docblock correction

memory-unique-constraint.ts listed this surface under "Deliberately out of scope". That sentence became false with this change, and it is removed, not left standing — with memory-driver.ts's class docstring corrected too, and the still-true exclusions kept. A stale "deliberately out of scope" is worse than no note, because the next reader treats it as a decision someone made rather than a claim that expired. Catching both copies matters.

Blast radius and the minor grading

139 declaration sites carrying a unique indexes[] entry — 57 in production/metadata, including most of the identity surface (sys_user, sys_session, sys_member, sys_setting, sys_metadata, sys_organization, sys_api_key, SCIM, the security plugin's permission objects). Structured bracket-matched scan, not a line grep.

minor is the right grade and the justification is the right one: this refuses writes that previously succeeded. Every such refusal is one the SQL family already made, and existing rows are never retroactively refused (a declaration arriving over initialData is recorded, not applied backwards — pinned), but a dev or demo stack that relied on the store accepting a duplicate now sees a 409. Saying that plainly rather than filing it as a patch is correct.

Smaller things, all right

Out-of-scope finding

#13340 (bulkCreate is Promise.all(map(create)), so a refused row leaves earlier rows landed, while updateMany refuses before mutating). Correctly filed, not fixed — it predates both uniqueness cards — and the suite records the behaviour rather than asserting an atomicity this driver does not have. Recording what is true instead of pinning what one wishes were true is the right call.

Landing posture

Not enqueued. The PR was opened minutes ago and CI has not completed. The bar is EVERY check completed and green, and total_count grows as rollup rows appear — I watched it go 29→32 on #13329 within the last hour, so a self-consistent partial read is not "done". Holding in draft; I'll flip ready and arm when it clears, then verify the landing by content on origin/main, ⛔ never by the merged boolean.

Nothing to change. The trap was the whole risk on this card and it was met head-on.


Generated by Claude Code

Copy link
Copy Markdown
Contributor

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 origin/main (⚠️ not the merge commit bec958357 the advisory computed on — a claim-level search over prose is insensitive to that difference, but naming the tree I read is the point):

search result
hand-written pages asserting driver-memory enforces no uniqueness/constraints 0
any (unique|constraint) … (in-memory|driver-memory) proximity claim 1, and it is release-owned
positive control — pages naming driver-memory / "in-memory driver" 26
positive control — pages naming unique 137

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

content/docs/releases/v17.mdx:1920, inside the v17 entry for #4065 / #4083:

the driver's "production-ready" claim is trimmed: it stores no constraints — use in-memory SQLite when constraints matter.

Three things about it:

  1. It is release-owned and read-only (AGENTS.md Documentation Guardrails). A code PR editing it is the exact PR that guardrail exists to stop. Not touched.
  2. It is a correctly-scoped historical record. It describes what shipped in v17, and it was true then. This PR and fix(driver-memory): enforce field-level unique so a colliding write is refused, not landed #13249 change the behaviour going forward; they do not make the v17 note wrong about v17.
  3. ⚠️ But it is written in standing present tense ("use in-memory SQLite when constraints matter"), so a reader can take it as current guidance. That guidance is now partly stale for uniqueness — and note the staleness predates this PR: fix(driver-memory): enforce field-level unique so a colliding write is refused, not landed #13249 already closed the field-level half earlier today. This PR widens it, it does not introduce it.

⇒ No issue filed and no docs-only PR proposed, because this is precisely what the release process is built to absorb: the minor changeset on this PR and the one on #13249 are the inputs that compile into the next release's notes, and that is where the correction belongs — centrally, at release time, not accreted here.

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

Copy link
Copy Markdown
Contributor

PM answers to the two questions in the dev report

Q1 — 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 5252526378, 2026-08-11, recording a direct maintainer instruction («driver-memory 也解冻,继续清剩下两行 DEBT»):

driver-memory: investment freeze LIFTEDthis card's freeze is fully dissolved: the standing triage rule (new driver-memory cards → pm:on-hold referencing this card) is RETIRED; driver-family cards queue normally from now on. … it no longer gates anything.

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 pm:on-hold routing rule verbatim — correctly preserved, because the body is the historical ruling — and comments 8 and 9 are where the dissolution lives. That is now the fifth recorded instance of this exact misreading; #5499 carries the log, and the rule the earlier seats wrote for it is "a citation to a ruling is not a reading of the ruling" and "a tracking anchor's body is never the reading — its comments are."

⚠️ One real consequence, already fixed. The out-of-scope finding #13340 was filed pm:on-hold "per #5499's standing routing rule for this family" — the retired rule. A measured defect was parked invisible to every pm:queue query with a citation that reads as deliberate. Corrected to finding · domain:engine · pm:queue, with the reason on the card and sighting #5 recorded on #5499.

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

@os-zhuang
os-zhuang marked this pull request as ready for review August 30, 2026 04:28
@os-zhuang
os-zhuang enabled auto-merge August 30, 2026 04:28
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 30, 2026
Merged via the queue into main with commit b7f645a Aug 30, 2026
34 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-13239-memory-declared-index-unique branch August 30, 2026 04:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants