.maestro/playbooks/2026-02-23-Issue-Triage/2026-02-23-Root-Cause-Fixes/TRIAGE-03-Data-Integrity-Deduplication.md
Two distinct data bugs: (1) Each PostToolUse creates 6-10 identical observation records because store.ts does a raw INSERT with zero deduplication. (2) Project identity uses basename(gitRoot) which collides when two repos share the same folder name (e.g., both named "monorepo"). These are root causes, not symptoms.
Issues resolved: #1061, #1158 (duplicate observations), #1200 (project name collision), #1046 (empty project string), #1052, #1036 (stuck isProcessing)
Duplicate observations — CONFIRMED: src/services/sqlite/observations/store.ts (56 lines) does a plain INSERT INTO observations with NO uniqueness check. No content hash, no idempotency, no dedup. Every call creates a new row.
Project name collision — CONFIRMED: src/shared/paths.ts:getCurrentProjectName() uses basename(git rev-parse --show-toplevel) — returns only the folder name. Two repos at ~/work/monorepo and ~/personal/monorepo both return "monorepo", sharing all data. This affects SQLite (project column) AND Chroma (collection name cm__monorepo).
Add content-hash deduplication to src/services/sqlite/observations/store.ts:
(memory_session_id, title, narrative) — these are the semantic identity of an observationcrypto.createHash('sha256').update(memory_session_id + title + narrative).digest('hex').slice(0, 16) for a fast short hashcontent_hash TEXT column to the observations table via a migration in src/services/sqlite/MigrationRunner.ts (or wherever migrations are defined)SELECT id FROM observations WHERE content_hash = ? AND created_at_epoch > ? with a 30-second windowUPDATE observations SET content_hash = substr(hex(randomblob(8)), 1, 16) WHERE content_hash IS NULL (gives existing rows unique hashes so they don't block new inserts)computeObservationContentHash() and findDuplicateObservation() helpers. Applied dedup to store.ts, transactions.ts (both functions), and SessionStore.storeObservation(). Migration 22 adds content_hash column with backfill and index. Added to both MigrationRunner and SessionStore. Fix project name collision in src/shared/paths.ts:
getCurrentProjectName() and change it to include parent directory: path.basename(path.dirname(gitRoot)) + '/' + path.basename(gitRoot)work/monorepo vs personal/monorepo — unique enough without being a full pathbasename(cwd)LIKE or exact match, so old data with short names will still be found when searching by projectChromaSync.ts constructor (line 78-86) where collectionName is derived from project — the new format with / will be sanitized to _ by the existing regex, producing cm__work_monorepobasename(dirname(root)) + '/' + basename(root). ChromaSync sanitizer already handles / → _.Fix empty project string race condition:
store.ts, before the INSERT, validate that project is a non-empty stringcwd using getCurrentProjectName(cwd) as fallbackconst resolvedProject = project || getCurrentProjectName() guard before INSERT. Fix stuck isProcessing flag:
isProcessing in the codebase to find where it's set and clearedisProcessing, also check updated_at_epoch — if it's been stuck for >5 minutes, reset ithasAnyPendingWork() in PendingMessageStore.ts to reset stuck 'processing' messages older than 5 minutes before counting. Acts as a self-healing side effect at the read site. Run npm test and fix any failures
tests/sqlite/data-integrity.test.ts — all pass. 21 pre-existing failures confirmed on clean branch (same count). Zero regressions from TRIAGE-03 changes.