fix(transact): metadata-index ops take their JSON-safe view at the crossing, not at construction
Some checks failed
CI / Node 24 (push) Successful in 12m30s
CI / Node 22 (push) Successful in 12m35s
CI / Integration + conformance (Node 22) (push) Failing after 16m54s
CI / Bun (latest) (push) Successful in 12m26s

transact()'s delete legs (direct unrelate and the noun-remove cascade) hand
the SAME verb object to the graph-retraction op and the metadata-retraction
op. The metadata leg sanitized at PLAN time, when the verb was still clean,
so the wrap returned the same reference — then the graph op's execute-time
endpoint resolution (deliberately deferred for same-batch forward refs)
mirrored BigInt sourceInt/targetInt onto the shared object, and the metadata
op crossed the seam with them. A strict provider rightly refuses that
crossing, so every transact-wrapped edge delete aborted; direct unrelate()
resolves ints at build time, before its sanitize, which is why no existing
gate saw it.

The JSON-safe view now lives in a shared leaf (utils/jsonSafeIndexMetadata)
and is applied INSIDE AddToMetadataIndexOperation and
RemoveFromMetadataIndexOperation at execute and rollback time — the one
place no plan-vs-execute ordering can bypass. Pins: the fleet repro, the
cascade shape, a mixed batch, and unit pins that mutate the entity after
construction against a strict seam (5 red before, 5 green after).
This commit is contained in:
David Snelling 2026-08-31 12:59:40 -07:00
parent 0f0022b1c9
commit 73500e7d10
4 changed files with 263 additions and 27 deletions

View file

@ -14,6 +14,7 @@ import type { MetadataIndexManager } from '../../utils/metadataIndex.js'
import type { GraphVerb } from '../../coreTypes.js'
import type { Operation, RollbackAction } from '../types.js'
import { isZeroNormVector } from '../../utils/distance.js'
import { jsonSafeIndexMetadata } from '../../utils/jsonSafeIndexMetadata.js'
import { prodLog } from '../../utils/logger.js'
/**
@ -390,13 +391,21 @@ export class AddToMetadataIndexOperation implements Operation {
// rollback so add + undo reference the same watermark.
const generation = this.generationFn?.()
// Add to metadata index (skipFlush=true for transaction atomicity)
await this.index.addToIndex(this.id, this.entity, true, false, generation)
// The JSON-safe view is taken HERE, per crossing, never at construction:
// the entity reference this op holds can be mutated between plan and
// execute (a graph op's execute-time endpoint-int resolution mirrors
// BigInts onto a shared verb object) — see jsonSafeIndexMetadata's
// module doc.
await this.index.addToIndex(
this.id, jsonSafeIndexMetadata(this.entity), true, false, generation
)
// Return rollback action
return async () => {
// Remove from metadata index
await this.index.removeFromIndex(this.id, this.entity, generation)
await this.index.removeFromIndex(
this.id, jsonSafeIndexMetadata(this.entity), generation
)
}
}
}
@ -432,13 +441,21 @@ export class RemoveFromMetadataIndexOperation implements Operation {
// Resolve the removal generation once; reuse it for the rollback re-add.
const generation = this.generationFn?.()
// Remove from metadata index
await this.index.removeFromIndex(this.id, this.entity, generation)
// Sanitized per crossing, never at construction — transact()'s delete
// legs hand this op the SAME verb object the graph-retraction op's
// execute-time endpoint resolution mutates (BigInt sourceInt/targetInt),
// so a plan-time view aliases the pollution. See jsonSafeIndexMetadata's
// module doc.
await this.index.removeFromIndex(
this.id, jsonSafeIndexMetadata(this.entity), generation
)
// Return rollback action
return async () => {
// Re-add with original metadata (skipFlush=true)
await this.index.addToIndex(this.id, this.entity, true, false, generation)
await this.index.addToIndex(
this.id, jsonSafeIndexMetadata(this.entity), true, false, generation
)
}
}
}