hippo-memory 1.30.0 → 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/api.d.ts +49 -0
  2. package/dist/api.d.ts.map +1 -1
  3. package/dist/api.js +78 -2
  4. package/dist/api.js.map +1 -1
  5. package/dist/audit.d.ts +1 -1
  6. package/dist/audit.d.ts.map +1 -1
  7. package/dist/audit.js.map +1 -1
  8. package/dist/capture.d.ts.map +1 -1
  9. package/dist/capture.js +79 -23
  10. package/dist/capture.js.map +1 -1
  11. package/dist/cli.d.ts +3 -0
  12. package/dist/cli.d.ts.map +1 -1
  13. package/dist/cli.js +233 -13
  14. package/dist/cli.js.map +1 -1
  15. package/dist/connectors/github/ingest.d.ts.map +1 -1
  16. package/dist/connectors/github/ingest.js +17 -0
  17. package/dist/connectors/github/ingest.js.map +1 -1
  18. package/dist/connectors/slack/ingest.d.ts.map +1 -1
  19. package/dist/connectors/slack/ingest.js +15 -0
  20. package/dist/connectors/slack/ingest.js.map +1 -1
  21. package/dist/consolidate.d.ts +1 -0
  22. package/dist/consolidate.d.ts.map +1 -1
  23. package/dist/consolidate.js +471 -291
  24. package/dist/consolidate.js.map +1 -1
  25. package/dist/dag.d.ts +17 -1
  26. package/dist/dag.d.ts.map +1 -1
  27. package/dist/dag.js +115 -40
  28. package/dist/dag.js.map +1 -1
  29. package/dist/db.d.ts.map +1 -1
  30. package/dist/db.js +43 -1
  31. package/dist/db.js.map +1 -1
  32. package/dist/extract.d.ts.map +1 -1
  33. package/dist/extract.js +24 -1
  34. package/dist/extract.js.map +1 -1
  35. package/dist/importers.d.ts +10 -0
  36. package/dist/importers.d.ts.map +1 -1
  37. package/dist/importers.js +242 -159
  38. package/dist/importers.js.map +1 -1
  39. package/dist/mcp/server.d.ts.map +1 -1
  40. package/dist/mcp/server.js +58 -24
  41. package/dist/mcp/server.js.map +1 -1
  42. package/dist/reject-flow.d.ts +66 -0
  43. package/dist/reject-flow.d.ts.map +1 -0
  44. package/dist/reject-flow.js +207 -0
  45. package/dist/reject-flow.js.map +1 -0
  46. package/dist/rejection.d.ts +100 -0
  47. package/dist/rejection.d.ts.map +1 -0
  48. package/dist/rejection.js +157 -0
  49. package/dist/rejection.js.map +1 -0
  50. package/dist/server.d.ts.map +1 -1
  51. package/dist/server.js +4 -0
  52. package/dist/server.js.map +1 -1
  53. package/dist/shared.d.ts +9 -0
  54. package/dist/shared.d.ts.map +1 -1
  55. package/dist/shared.js +57 -6
  56. package/dist/shared.js.map +1 -1
  57. package/dist/sleep-redact.d.ts +2 -0
  58. package/dist/sleep-redact.d.ts.map +1 -1
  59. package/dist/sleep-redact.js +2 -0
  60. package/dist/sleep-redact.js.map +1 -1
  61. package/dist/src/api.js +78 -2
  62. package/dist/src/api.js.map +1 -1
  63. package/dist/src/audit.js.map +1 -1
  64. package/dist/src/capture.js +79 -23
  65. package/dist/src/capture.js.map +1 -1
  66. package/dist/src/cli.js +233 -13
  67. package/dist/src/cli.js.map +1 -1
  68. package/dist/src/connectors/github/ingest.js +17 -0
  69. package/dist/src/connectors/github/ingest.js.map +1 -1
  70. package/dist/src/connectors/slack/ingest.js +15 -0
  71. package/dist/src/connectors/slack/ingest.js.map +1 -1
  72. package/dist/src/consolidate.js +471 -291
  73. package/dist/src/consolidate.js.map +1 -1
  74. package/dist/src/dag.js +115 -40
  75. package/dist/src/dag.js.map +1 -1
  76. package/dist/src/db.js +43 -1
  77. package/dist/src/db.js.map +1 -1
  78. package/dist/src/extract.js +24 -1
  79. package/dist/src/extract.js.map +1 -1
  80. package/dist/src/importers.js +242 -159
  81. package/dist/src/importers.js.map +1 -1
  82. package/dist/src/mcp/server.js +58 -24
  83. package/dist/src/mcp/server.js.map +1 -1
  84. package/dist/src/reject-flow.js +207 -0
  85. package/dist/src/reject-flow.js.map +1 -0
  86. package/dist/src/rejection.js +157 -0
  87. package/dist/src/rejection.js.map +1 -0
  88. package/dist/src/server.js +4 -0
  89. package/dist/src/server.js.map +1 -1
  90. package/dist/src/shared.js +57 -6
  91. package/dist/src/shared.js.map +1 -1
  92. package/dist/src/sleep-redact.js +2 -0
  93. package/dist/src/sleep-redact.js.map +1 -1
  94. package/dist/src/store.js +511 -38
  95. package/dist/src/store.js.map +1 -1
  96. package/dist/src/version.js +1 -1
  97. package/dist/store.d.ts +116 -6
  98. package/dist/store.d.ts.map +1 -1
  99. package/dist/store.js +511 -38
  100. package/dist/store.js.map +1 -1
  101. package/dist/version.d.ts +1 -1
  102. package/dist/version.js +1 -1
  103. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  104. package/extensions/openclaw-plugin/package.json +1 -1
  105. package/openclaw.plugin.json +1 -1
  106. package/package.json +1 -1
package/dist/store.js CHANGED
@@ -14,6 +14,13 @@ import { tokenize } from './search.js';
14
14
  import { appendAuditEvent } from './audit.js';
15
15
  import { resolveTenantId } from './tenant.js';
16
16
  import { deriveOriginProject, originFromSource } from './project-identity.js';
17
+ import { checkRejectionGuard, RejectedValueError, rejectionDigest, normalizeValueForRejection, insertRejectedValue, findRejectedValue, } from './rejection.js';
18
+ // AT1 (plan §5): resolveConflict's kind-aware loser removal needs
19
+ // archiveRawMemory for kind='raw' losers. raw-archive.ts imports
20
+ // markSummaryDirtyInTx from this module — both imports are used only
21
+ // inside function bodies (never at module-evaluation time), so the cycle
22
+ // is the standard safe mutual-function-reference shape under NodeNext ESM.
23
+ import { archiveRawMemory } from './raw-archive.js';
17
24
  /**
18
25
  * Emit an audit event for a mutation against `db`. Wrapped so a broken audit
19
26
  * log can never crash the surrounding mutation — the SQLite store is still the
@@ -34,6 +41,16 @@ function audit(db, op, targetId, metadata, actor = 'cli', tenantId) {
34
41
  // table is broken; the mutation has already succeeded.
35
42
  }
36
43
  }
44
+ /**
45
+ * Refusal audit for the AT1 rejection guard (plan §3). Written by the
46
+ * transaction OWNER post-rollback — writeEntry's catch (no outer tx exists
47
+ * there, so this lands in a fresh implicit transaction) and api.supersede's
48
+ * catch (after its own ROLLBACK) — never inside a scope the caller's own
49
+ * rollback could claw back. Best-effort `audit()` semantics: never throws.
50
+ */
51
+ export function auditRejectionRefusal(db, err, actor) {
52
+ audit(db, 'reject_refusal', err.entryId, { digest: err.digest, reason: err.reason }, actor, err.tenantId);
53
+ }
37
54
  const INDEX_VERSION = 3;
38
55
  const MEMORY_SELECT_COLUMNS = `id, created, last_retrieved, retrieval_count, strength, half_life_days, layer, tags_json, emotional_valence, schema_fit, source, outcome_score, outcome_positive, outcome_negative, conflicts_with_json, pinned, confidence, content, parents_json, starred, trace_outcome, source_session_id, valid_from, superseded_by, extracted_from, dag_level, dag_parent_id, kind, scope, owner, artifact_ref, tenant_id, origin_project, descendant_count, earliest_at, latest_at, summary_dirty, last_rebuilt_at, rebuild_count, dag_level_3_built_at`;
39
56
  // F1 (v1.7.0): qualified-and-aliased columns for the FTS join in
@@ -630,28 +647,130 @@ function writeMarkdownMirror(hippoRoot, entry) {
630
647
  fs.mkdirSync(dir, { recursive: true });
631
648
  fs.writeFileSync(path.join(dir, `${entry.id}.md`), serializeEntry(entry), 'utf8');
632
649
  }
650
+ // AT1 P1 fix (codex): `writeMarkdownMirror` writes ANY layer's mirror,
651
+ // including `trace/<id>.md` for Layer.Trace rows (auto-promoted traces,
652
+ // consolidate.ts) — but this enumeration only walked
653
+ // Buffer/Episodic/Semantic. A rejected/forgotten trace row's markdown
654
+ // content survived on disk while the purge (and `hippo reject`/plain
655
+ // `forget`) reported success, and a stale trace mirror is exactly the
656
+ // resurrection channel bootstrapLegacyStore/rebuildIndex guard against.
657
+ // Fixes BOTH the AT1 reject-flow purge and the pre-existing plain-`forget`
658
+ // gap for trace rows (deleteEntry has always called this same function).
633
659
  export function removeEntryMirrors(hippoRoot, id) {
634
- for (const layer of [Layer.Buffer, Layer.Episodic, Layer.Semantic]) {
660
+ for (const layer of [Layer.Buffer, Layer.Episodic, Layer.Semantic, Layer.Trace]) {
635
661
  const file = path.join(layerDir(hippoRoot, layer), `${id}.md`);
636
662
  if (fs.existsSync(file)) {
637
663
  fs.unlinkSync(file);
638
664
  }
639
665
  }
640
666
  }
667
+ /**
668
+ * AT1 mirror-purge honesty fix (docs/plans/2026-08-15-at1-rejected-value-tombstone.md):
669
+ * the candidate markdown mirror paths still on disk for `id`, computed the
670
+ * same way `removeEntryMirrors` walks them (one per layer: buffer/episodic/
671
+ * semantic), filtered to the ones that still `fs.existsSync`. Used to report
672
+ * an EXPLICIT path when a best-effort purge fails and no reaper exists to
673
+ * retry it — plain `removeEntryMirrors` returns void, giving no way to name
674
+ * which file is stuck.
675
+ */
676
+ export function getExistingEntryMirrorPaths(hippoRoot, id) {
677
+ // AT1 P1 fix (codex): same missing Layer.Trace as removeEntryMirrors above
678
+ // — kept in lockstep with it since this function's whole purpose is
679
+ // walking the mirror paths "the same way removeEntryMirrors walks them"
680
+ // (see its own doc comment).
681
+ return [Layer.Buffer, Layer.Episodic, Layer.Semantic, Layer.Trace]
682
+ .map((layer) => path.join(layerDir(hippoRoot, layer), `${id}.md`))
683
+ .filter((file) => fs.existsSync(file));
684
+ }
685
+ /**
686
+ * AT1 fix: best-effort markdown-mirror purge shared by `reject-flow.ts`'s
687
+ * `rejectValue` and `resolveConflict`'s post-commit purge. Both used to log
688
+ * "will retry via reaper on next open" for EVERY failure, but the reaper
689
+ * (`cleanupArchivedMirrors`, raw-archive-mirror-cleanup.ts) only scans
690
+ * `raw_archive` — that message was false for a non-raw id, which has no
691
+ * reaper at all.
692
+ *
693
+ * Retries the unlink once synchronously (the common real-world failure is a
694
+ * transient lock/AV-scanner false positive, not a permanent one). On a
695
+ * second failure: raw ids still get the honest reaper message (true); non-raw
696
+ * ids get the EXPLICIT leftover file path(s) and a manual-delete instruction,
697
+ * since nothing will ever retry them automatically.
698
+ *
699
+ * Returns true if the mirror ended up purged (first or second attempt).
700
+ */
701
+ export function purgeMirrorBestEffort(hippoRoot, id, isRaw, logPrefix) {
702
+ try {
703
+ removeEntryMirrors(hippoRoot, id);
704
+ return true;
705
+ }
706
+ catch {
707
+ try {
708
+ removeEntryMirrors(hippoRoot, id);
709
+ return true;
710
+ }
711
+ catch (secondErr) {
712
+ const msg = secondErr instanceof Error ? secondErr.message : String(secondErr);
713
+ if (isRaw) {
714
+ console.error(`${logPrefix}: mirror cleanup failed for ${id} (will retry via reaper on next open): ${msg}`);
715
+ }
716
+ else {
717
+ const leftover = getExistingEntryMirrorPaths(hippoRoot, id);
718
+ const pathsNote = leftover.length > 0 ? leftover.join(', ') : `${id}.md (path unresolved)`;
719
+ console.error(`${logPrefix}: mirror cleanup failed for ${id} - no automatic retry exists for this file, ` +
720
+ `delete it manually: ${pathsNote} (${msg})`);
721
+ }
722
+ return false;
723
+ }
724
+ }
725
+ }
641
726
  function bootstrapLegacyStore(db, hippoRoot) {
642
727
  const countRow = db.prepare(`SELECT COUNT(*) AS count FROM memories`).get();
643
728
  const memoryCount = Number(countRow?.count ?? 0);
644
729
  if (memoryCount > 0)
645
730
  return false;
731
+ // AT1 P2 fix: memoryCount alone is not a reliable "already bootstrapped"
732
+ // signal once the rejection guard exists. If EVERY legacy mirror row is
733
+ // rejected, memories stays at 0 rows even after a successful bootstrap
734
+ // pass, so the memoryCount>0 gate above never trips — every subsequent
735
+ // initStore() call would re-run this whole function: re-scan the legacy
736
+ // mirrors, re-attempt (and re-refuse, re-auditing) every row, and
737
+ // re-INSERT the legacy consolidation_runs rows with no dedup, duplicating
738
+ // them on each open. A dedicated meta flag marks bootstrap as
739
+ // attempted-and-settled regardless of how many rows actually landed.
740
+ if (getMeta(db, 'legacy_bootstrap_completed', '0') === '1')
741
+ return false;
646
742
  const legacyEntries = loadLegacyEntriesFromMarkdown(hippoRoot);
647
743
  if (legacyEntries.length === 0)
648
744
  return false;
649
745
  db.exec('BEGIN');
650
746
  try {
747
+ // AT1 (plan §3, round-3 redesign): run the guard LIVE per row rather
748
+ // than bypassing it. bootstrapLegacyStore is exactly the channel through
749
+ // which a stale/never-purged markdown mirror could resurrect a rejected
750
+ // value; a skip-and-count here closes that structurally, independent of
751
+ // mirror state. The refusal audit is written INLINE inside this
752
+ // still-open loop transaction (plain audit() — nothing is rolled back
753
+ // on a per-row skip, so the post-rollback auditRejectionRefusal helper
754
+ // is the wrong tool here).
755
+ let rejectedCount = 0;
651
756
  for (const entry of legacyEntries) {
652
757
  // v39: legacy markdown carries no origin_project; stamp from the store
653
758
  // location so bootstrapped rows stay visible to ambient context.
654
- upsertEntryRow(db, stampOriginProjectForImport(hippoRoot, entry));
759
+ const stamped = stampOriginProjectForImport(hippoRoot, entry);
760
+ try {
761
+ upsertEntryRow(db, stamped);
762
+ }
763
+ catch (err) {
764
+ if (err instanceof RejectedValueError) {
765
+ rejectedCount++;
766
+ audit(db, 'reject_refusal', err.entryId, { digest: err.digest, reason: err.reason }, 'cli', err.tenantId);
767
+ continue;
768
+ }
769
+ throw err;
770
+ }
771
+ }
772
+ if (rejectedCount > 0) {
773
+ console.error(`bootstrapLegacyStore: skipped ${rejectedCount} rejected value(s) found in legacy mirrors`);
655
774
  }
656
775
  const legacyIndex = loadLegacyIndexFile(hippoRoot);
657
776
  setMeta(db, 'last_retrieval_ids', JSON.stringify(legacyIndex.last_retrieval_ids ?? []));
@@ -674,6 +793,9 @@ function bootstrapLegacyStore(db, hippoRoot) {
674
793
  const row = run;
675
794
  insertRun.run(String(row.timestamp ?? new Date().toISOString()), Number(row.decayed ?? 0), Number(row.merged ?? 0), Number(row.removed ?? 0));
676
795
  }
796
+ // AT1 P2 fix: stamp completion regardless of how many rows actually
797
+ // landed (all-rejected included) — see the gate comment above.
798
+ setMeta(db, 'legacy_bootstrap_completed', '1');
677
799
  db.exec('COMMIT');
678
800
  }
679
801
  catch (error) {
@@ -733,7 +855,32 @@ function loadLegacyStatsFile(hippoRoot) {
733
855
  };
734
856
  }
735
857
  }
736
- function upsertEntryRow(db, entry) {
858
+ /**
859
+ * `bypassRejectionGuard` (AT1, plan §3): ONLY `batchWriteAndDelete`'s call
860
+ * site passes `true`. Consolidation merges are DETERMINISTIC CONCATENATION
861
+ * (mergeContents, consolidate.ts:736-751), not LLM paraphrase — the bypass
862
+ * is safe because the producer (consolidate.ts's merge pass) now checks the
863
+ * merged content's rejection digest against the tenant's tombstones BEFORE
864
+ * ever assembling a batch to write, and skips the merge entirely on a hit.
865
+ * Every other caller (writeEntryDbOnly, bootstrapLegacyStore, rebuildIndex)
866
+ * leaves this false and the guard runs live.
867
+ *
868
+ * AT1 P1 fix (codex, batch-transaction rejection race): the producer check
869
+ * above runs on a DIFFERENT connection BEFORE this transaction opens — a
870
+ * `hippo reject X` that commits in that window is invisible to it. This
871
+ * parameter's contract is UNCHANGED (still the sole bypass, still trusted
872
+ * by the producer-side check for the common case); what changed is that
873
+ * `batchWriteAndDelete` no longer trusts it BLINDLY. It now runs its own
874
+ * in-transaction point-probe (same connection, same digest lookup this
875
+ * function's guard would have done) immediately before each upsert and
876
+ * skips — rather than writes — any entry whose content matches a tombstone
877
+ * that landed after the producer's check. See batchWriteAndDelete for the
878
+ * skip logic.
879
+ */
880
+ function upsertEntryRow(db, entry, bypassRejectionGuard = false) {
881
+ if (!bypassRejectionGuard) {
882
+ checkRejectionGuard(db, entry.tenantId ?? 'default', entry.id, entry.content);
883
+ }
737
884
  db.prepare(`
738
885
  INSERT INTO memories(
739
886
  id, created, last_retrieved, retrieval_count, strength, half_life_days, layer,
@@ -813,7 +960,14 @@ function deleteFtsRow(db, id) {
813
960
  // Best effort.
814
961
  }
815
962
  }
816
- function buildIndexFromDb(db) {
963
+ /**
964
+ * Derive the current `HippoIndex` (entries + last-retrieval/trace lockstep
965
+ * meta) from SQLite, the source of truth. Exported (AT1) for the same
966
+ * reason as `writeIndexMirror` below: `src/reject-flow.ts` needs to rebuild
967
+ * the index mirror post-commit after a (possibly multi-row) reject removal,
968
+ * without duplicating this query.
969
+ */
970
+ export function buildIndexFromDb(db) {
817
971
  const rows = db.prepare(`SELECT id, created, last_retrieved, strength, layer, tags_json, pinned FROM memories ORDER BY created ASC, id ASC`).all();
818
972
  const entries = {};
819
973
  for (const row of rows) {
@@ -853,7 +1007,14 @@ function buildStatsFromDb(db) {
853
1007
  consolidation_runs: runs,
854
1008
  };
855
1009
  }
856
- function writeIndexMirror(hippoRoot, index) {
1010
+ /**
1011
+ * Write the `index.json` mirror file for a given (already-derived) index.
1012
+ * Exported (AT1) so `src/reject-flow.ts` can replicate `deleteEntry`'s exact
1013
+ * post-commit "removeEntryMirrors then rewrite the index once" sequence for
1014
+ * the reject verb's (possibly multi-row) removal, without duplicating
1015
+ * `buildIndexFromDb`'s query.
1016
+ */
1017
+ export function writeIndexMirror(hippoRoot, index) {
857
1018
  fs.writeFileSync(path.join(hippoRoot, 'index.json'), JSON.stringify(index, null, 2), 'utf8');
858
1019
  }
859
1020
  function writeStatsMirror(hippoRoot, stats) {
@@ -981,6 +1142,16 @@ export function writeEntry(hippoRoot, entry, opts) {
981
1142
  opts?.afterCommit?.();
982
1143
  writeEntryMirrors(hippoRoot, db, stamped);
983
1144
  }
1145
+ catch (error) {
1146
+ // AT1 (plan §3): writeEntryDbOnly's own SAVEPOINT has already unwound by
1147
+ // the time this catch runs, so the refusal audit lands post-rollback in
1148
+ // a fresh implicit transaction — then rethrow so the caller sees the
1149
+ // refusal.
1150
+ if (error instanceof RejectedValueError) {
1151
+ auditRejectionRefusal(db, error, opts?.actor ?? 'cli');
1152
+ }
1153
+ throw error;
1154
+ }
984
1155
  finally {
985
1156
  closeHippoDb(db);
986
1157
  }
@@ -1246,35 +1417,66 @@ export function loadChildrenOf(hippoRoot, parentId, tenantId) {
1246
1417
  closeHippoDb(db);
1247
1418
  }
1248
1419
  }
1420
+ /**
1421
+ * AT1 (plan §4, round-2 fix, designed from source): db-scoped delete core.
1422
+ * `deleteEntry` used to open+close its OWN connection, which meant it could
1423
+ * never compose inside a caller's transaction (unlike writeEntry/
1424
+ * writeEntryDbOnly, which already split this way). Split identically: row-
1425
+ * meta SELECT, `DELETE FROM memories`, FTS delete, `forget` audit, DAG
1426
+ * dirty-mark. NO filesystem I/O — the caller's own transaction may still be
1427
+ * rolled back, and mirror writes must only happen post-commit.
1428
+ *
1429
+ * `opts.suppressForgetAudit` (default false, off): two AT1 callers set this
1430
+ * so a removed non-raw row does NOT ALSO emit a `forget` row, because each
1431
+ * already writes its own aggregate audit trail — `src/reject-flow.ts`'s
1432
+ * `rejectValue` (single `reject_value` row covering every same-digest row
1433
+ * removed) and `resolveConflict` (`conflict_resolve` row per resolution).
1434
+ * Default keeps `deleteEntry` byte-identical to its pre-split behavior.
1435
+ *
1436
+ * Returns `{tenantId, dagParentId}` for the removed row, or `null` if no row
1437
+ * with `id` existed.
1438
+ */
1439
+ export function deleteEntryCore(db, id, opts) {
1440
+ const row = db
1441
+ .prepare(`SELECT id, tenant_id, dag_parent_id FROM memories WHERE id = ?`)
1442
+ .get(id);
1443
+ if (!row?.id)
1444
+ return null;
1445
+ db.prepare(`DELETE FROM memories WHERE id = ?`).run(id);
1446
+ deleteFtsRow(db, id);
1447
+ if (!opts?.suppressForgetAudit) {
1448
+ audit(db, 'forget', id, undefined, opts?.actor ?? 'cli', row.tenant_id);
1449
+ }
1450
+ // v0.30 / E2 — DAG live-coupling: forget of a child under a level-2
1451
+ // summary marks parent dirty. Non-atomic with the DELETE (no SAVEPOINT
1452
+ // wrapper here, same as pre-split deleteEntry); markSummaryDirtyInTx is
1453
+ // idempotent so any future child mutation re-marks parent if this fails.
1454
+ // Acceptable degradation, mirrors the pre-split audit best-effort posture.
1455
+ if (row.dag_parent_id) {
1456
+ markSummaryDirtyInTx(db, row.dag_parent_id, row.tenant_id ?? 'default', opts?.actor ?? 'cli');
1457
+ }
1458
+ return { tenantId: row.tenant_id ?? 'default', dagParentId: row.dag_parent_id ?? null };
1459
+ }
1249
1460
  /**
1250
1461
  * Delete an entry from SQLite and mirrors.
1251
1462
  *
1252
1463
  * `opts.actor` defaults to 'cli'. The api.* layer threads `ctx.actor` so HTTP
1253
1464
  * callers land with `api_key:<key_id>` in the audit log without a duplicate
1254
1465
  * emit from the api wrapper.
1466
+ *
1467
+ * Thin wrapper over `deleteEntryCore` (open → core → mirrors → close);
1468
+ * behavior is byte-identical to the pre-split implementation for every
1469
+ * existing caller.
1255
1470
  */
1256
1471
  export function deleteEntry(hippoRoot, id, opts) {
1257
1472
  initStore(hippoRoot);
1258
1473
  const db = openHippoDb(hippoRoot);
1259
1474
  try {
1260
- const row = db
1261
- .prepare(`SELECT id, tenant_id, dag_parent_id FROM memories WHERE id = ?`)
1262
- .get(id);
1263
- if (!row?.id)
1475
+ const result = deleteEntryCore(db, id, opts);
1476
+ if (!result)
1264
1477
  return false;
1265
- db.prepare(`DELETE FROM memories WHERE id = ?`).run(id);
1266
- deleteFtsRow(db, id);
1267
1478
  removeEntryMirrors(hippoRoot, id);
1268
1479
  writeIndexMirror(hippoRoot, buildIndexFromDb(db));
1269
- audit(db, 'forget', id, undefined, opts?.actor ?? 'cli', row.tenant_id);
1270
- // v0.30 / E2 — DAG live-coupling: forget of a child under a level-2
1271
- // summary marks parent dirty. Non-atomic with the DELETE (deleteEntry
1272
- // has no SAVEPOINT wrapper); markSummaryDirtyInTx is idempotent so any
1273
- // future child mutation re-marks parent if this fails. Acceptable
1274
- // degradation, mirrors deleteEntry's existing audit best-effort posture.
1275
- if (row.dag_parent_id) {
1276
- markSummaryDirtyInTx(db, row.dag_parent_id, row.tenant_id ?? 'default', opts?.actor ?? 'cli');
1277
- }
1278
1480
  return true;
1279
1481
  }
1280
1482
  finally {
@@ -1291,7 +1493,14 @@ export function batchWriteAndDelete(hippoRoot, toWrite, toDeleteIds) {
1291
1493
  initStore(hippoRoot);
1292
1494
  const db = openHippoDb(hippoRoot);
1293
1495
  try {
1294
- db.exec('BEGIN');
1496
+ // BEGIN IMMEDIATE (codex delta-review P2): the AT1 tombstone probes below
1497
+ // READ before the first write. Under a deferred BEGIN, that read pins a
1498
+ // WAL snapshot; a concurrent writer (e.g. `hippo reject`) committing
1499
+ // between probe and first upsert would make the later write-lock upgrade
1500
+ // fail with SQLITE_BUSY and roll back the ENTIRE batch — the exact race
1501
+ // the probe exists to contain. Taking the write lock up front serializes
1502
+ // the probe and the writes on one consistent snapshot.
1503
+ db.exec('BEGIN IMMEDIATE');
1295
1504
  // v0.30 / E2 — DAG live-coupling: BEFORE deletes, snapshot dag_parent_id
1296
1505
  // for every doomed row so we can mark parents dirty post-COMMIT. Done
1297
1506
  // inside the same BEGIN so the SELECT sees pre-delete state.
@@ -1315,8 +1524,61 @@ export function batchWriteAndDelete(hippoRoot, toWrite, toDeleteIds) {
1315
1524
  // origin would make freshly consolidated memories vanish from ambient
1316
1525
  // context (codex gating review P1).
1317
1526
  const stampedWrites = toWrite.map((e) => stampOriginProject(hippoRoot, e));
1527
+ // AT1 P1 fix (codex, batch-transaction rejection race): the producer-side
1528
+ // check (e.g. consolidate.ts's merge pass) runs BEFORE this transaction,
1529
+ // on a different connection. A `hippo reject X` that commits in that
1530
+ // window is invisible to it — a queued same-id write of X already
1531
+ // sitting in `toWrite` (decay/replay re-persist, or a merge built before
1532
+ // the reject) would silently re-INSERT the just-rejected row via the
1533
+ // blind bypass. Fix: one indexed point probe per batch entry, on THIS
1534
+ // connection, INSIDE this transaction — closes the race regardless of
1535
+ // which write class hits it. N is small per sleep, so the extra query
1536
+ // per entry is cheap.
1537
+ //
1538
+ // Skip, don't throw: the batch must still complete for every OTHER
1539
+ // entry. Skipping is correct for every write class here — a merge
1540
+ // summary skip just means that rollup is absent this cycle (its source
1541
+ // facts stay merely demoted, recoverable next sleep); a skipped
1542
+ // demotion/replay re-persist of a rejected-removed row means it stays
1543
+ // gone, which is the entire point of the tombstone.
1544
+ let batchRejectedSkips = 0;
1545
+ const skippedWriteIds = new Set();
1318
1546
  for (const entry of stampedWrites) {
1319
- upsertEntryRow(db, entry);
1547
+ const entryTenantId = entry.tenantId ?? 'default';
1548
+ // Codex delta-review P2 fix: reuse checkRejectionGuard rather than a
1549
+ // bare tombstone probe — the guard's content-INTRODUCTION
1550
+ // classification must apply here too. A tombstone can legitimately
1551
+ // coexist with a live same-content row (resolveConflict deliberately
1552
+ // excludes keepId from its sweep; unreject-then-re-reject windows), and
1553
+ // an unconditional skip would starve that row of decay/replay metadata
1554
+ // updates forever. The guard throws only when the write is new-row or
1555
+ // changes content TO the rejected value; unchanged same-id re-persists
1556
+ // pass through, exactly as on the writeEntry path.
1557
+ try {
1558
+ checkRejectionGuard(db, entryTenantId, entry.id, entry.content);
1559
+ }
1560
+ catch (err) {
1561
+ if (err instanceof RejectedValueError) {
1562
+ batchRejectedSkips++;
1563
+ skippedWriteIds.add(entry.id);
1564
+ audit(db, 'reject_refusal', entry.id, { digest: err.digest, reason: err.reason }, 'sleep-batch', entryTenantId);
1565
+ continue;
1566
+ }
1567
+ throw err;
1568
+ }
1569
+ // AT1 (plan §3, corrected): bypass the rejection guard here.
1570
+ // Consolidation merges are DETERMINISTIC CONCATENATION (mergeContents,
1571
+ // consolidate.ts:736-751) of already-guarded leaf facts, not an LLM
1572
+ // paraphrase — refusing mid-batch would abort the whole consolidation
1573
+ // transaction. The bypass is safe because consolidate.ts's merge pass
1574
+ // now checks the merged content's rejection digest against the
1575
+ // tenant's tombstones BEFORE ever pushing a merge into pendingWrites,
1576
+ // skipping that merge entirely on a hit, AND because the point-probe
1577
+ // immediately above closes the race window between that producer
1578
+ // check and this COMMIT. The guard itself still belongs on leaf
1579
+ // inserts, which write through writeEntry / writeEntryDbOnly and stay
1580
+ // guarded (bypassRejectionGuard defaults false).
1581
+ upsertEntryRow(db, entry, true);
1320
1582
  // Hook for writes: child upserted under a level-2 summary marks parent dirty.
1321
1583
  if (entry.dag_parent_id) {
1322
1584
  dirtyParents.add(entry.dag_parent_id);
@@ -1333,8 +1595,15 @@ export function batchWriteAndDelete(hippoRoot, toWrite, toDeleteIds) {
1333
1595
  markSummaryDirtyInTx(db, parentId, tenantById.get(parentId) ?? 'default', 'batch');
1334
1596
  }
1335
1597
  db.exec('COMMIT');
1336
- // Sync mirrors once after all DB writes
1598
+ if (batchRejectedSkips > 0) {
1599
+ console.error(`batchWriteAndDelete: skipped ${batchRejectedSkips} write(s) whose content matches a rejected value (tombstone hit during the batch transaction)`);
1600
+ }
1601
+ // Sync mirrors once after all DB writes. Entries skipped above were
1602
+ // never inserted — writing their markdown mirror would resurrect the
1603
+ // exact content the skip just kept out of the DB.
1337
1604
  for (const entry of stampedWrites) {
1605
+ if (skippedWriteIds.has(entry.id))
1606
+ continue;
1338
1607
  writeMarkdownMirror(hippoRoot, entry);
1339
1608
  }
1340
1609
  for (const id of toDeleteIds) {
@@ -1445,9 +1714,28 @@ export function rebuildIndex(hippoRoot) {
1445
1714
  if (legacyEntries.length > 0) {
1446
1715
  db.exec('BEGIN');
1447
1716
  try {
1717
+ // AT1 (plan §3, round-3 redesign): same guard-with-per-row-skip as
1718
+ // bootstrapLegacyStore — rebuildIndex is the other channel through
1719
+ // which a stale markdown mirror could resurrect a rejected value.
1720
+ // Refusal audit written INLINE (nothing rolls back on a skip).
1721
+ let rejectedCount = 0;
1448
1722
  for (const entry of legacyEntries) {
1449
1723
  // v39: same store-derived origin stamp as bootstrapLegacyStore.
1450
- upsertEntryRow(db, stampOriginProjectForImport(hippoRoot, entry));
1724
+ const stamped = stampOriginProjectForImport(hippoRoot, entry);
1725
+ try {
1726
+ upsertEntryRow(db, stamped);
1727
+ }
1728
+ catch (err) {
1729
+ if (err instanceof RejectedValueError) {
1730
+ rejectedCount++;
1731
+ audit(db, 'reject_refusal', err.entryId, { digest: err.digest, reason: err.reason }, 'cli', err.tenantId);
1732
+ continue;
1733
+ }
1734
+ throw err;
1735
+ }
1736
+ }
1737
+ if (rejectedCount > 0) {
1738
+ console.error(`rebuildIndex: skipped ${rejectedCount} rejected value(s) found in legacy mirrors`);
1451
1739
  }
1452
1740
  db.exec('COMMIT');
1453
1741
  }
@@ -1916,11 +2204,19 @@ export function replaceDetectedConflicts(hippoRoot, detected, detectedAt = new D
1916
2204
  /**
1917
2205
  * Resolve a conflict by keeping one memory and weakening the other.
1918
2206
  * Sets conflict status to 'resolved' and halves the loser's half-life.
1919
- * If --forget is used, the loser is deleted entirely.
2207
+ * If --forget is used, the loser is removed entirely (kind-aware: raw rows
2208
+ * are archived via archiveRawMemory, others deleted via deleteEntryCore —
2209
+ * AT1 fix for the pre-existing crash where a raw loser aborted the whole
2210
+ * resolve transaction against the append-only trigger). `opts.rejectLoserValue`
2211
+ * additionally tombstones the loser's normalized digest so it cannot be
2212
+ * re-asserted later.
2213
+ *
2214
+ * Every resolution path (weaken / forget / reject) emits a `conflict_resolve`
2215
+ * audit row (AT1 — previously resolveConflict wrote zero audit rows on any path).
1920
2216
  *
1921
2217
  * Returns the resolved conflict, or null if not found.
1922
2218
  */
1923
- export function resolveConflict(hippoRoot, conflictId, keepId, forgetLoser = false, tenantId) {
2219
+ export function resolveConflict(hippoRoot, conflictId, keepId, forgetLoser = false, tenantId, opts) {
1924
2220
  initStore(hippoRoot);
1925
2221
  const db = openHippoDb(hippoRoot);
1926
2222
  // When tenantId is set, the conflict lookup requires BOTH members in-tenant
@@ -1959,9 +2255,81 @@ export function resolveConflict(hippoRoot, conflictId, keepId, forgetLoser = fal
1959
2255
  // Mark conflict as resolved
1960
2256
  db.prepare(`UPDATE memory_conflicts SET status = 'resolved', updated_at = datetime('now') WHERE id = ?`)
1961
2257
  .run(conflictId);
1962
- if (forgetLoser) {
1963
- // Delete the losing memory
1964
- db.prepare(`DELETE FROM memories WHERE id = ?${memScope}`).run(loserId, ...memArgs);
2258
+ // AT1 (plan §5): removal (forgetLoser OR rejectLoserValue — a tombstoned
2259
+ // value cannot be left live) is now kind-aware. The old bare
2260
+ // `DELETE FROM memories WHERE id = ?` aborted the whole transaction when
2261
+ // the loser was kind='raw' (append-only trigger fires); route through
2262
+ // the same helpers the reject verb uses (both db-scoped, both compose
2263
+ // inside this BEGIN/COMMIT). loserRemoved / loserWasRaw drive both the
2264
+ // conflicts_with_json skip below and the post-commit mirror purge.
2265
+ let loserRemoved = false;
2266
+ let loserWasRaw = false;
2267
+ let rejectedDigest;
2268
+ // AT1 P1 fix (codex): same-tenant duplicates of the loser's content that
2269
+ // rejectLoserValue also removes (see below) — separate from loserId so
2270
+ // the audit + post-commit mirror purge can cover ALL of them, not just
2271
+ // loserId.
2272
+ const extraRemovedIds = [];
2273
+ const extraRemovedRawIds = [];
2274
+ const removeLoser = forgetLoser || opts?.rejectLoserValue === true;
2275
+ if (removeLoser) {
2276
+ const loserRow = db
2277
+ .prepare(`SELECT kind, content, tenant_id FROM memories WHERE id = ?${memScope}`)
2278
+ .get(loserId, ...memArgs);
2279
+ if (loserRow) {
2280
+ const actor = opts?.rejectedBy ?? 'cli';
2281
+ const reason = opts?.reason ?? `resolveConflict ${conflictId}: kept ${keepId}`;
2282
+ if (opts?.rejectLoserValue) {
2283
+ rejectedDigest = rejectionDigest(loserRow.content);
2284
+ insertRejectedValue(db, {
2285
+ tenantId: loserRow.tenant_id ?? 'default',
2286
+ digest: rejectedDigest,
2287
+ reason,
2288
+ rejectedBy: actor,
2289
+ rejectedAt: new Date().toISOString(),
2290
+ sourceMemoryId: loserId,
2291
+ normalizedChars: normalizeValueForRejection(loserRow.content).length,
2292
+ });
2293
+ // AT1 P1 fix (codex): reject-flow.ts's `rejectValue` removes ALL
2294
+ // live same-tenant rows whose normalized digest matches, not just
2295
+ // the one id passed — but this branch only ever removed loserId,
2296
+ // leaving same-TENANT duplicates live while their shared content
2297
+ // was tombstoned. Same O(N) scan pattern as reject-flow.ts (human-
2298
+ // triggered command, tenant's row count is human-scale). CRITICAL
2299
+ // BOUNDARY: tenant-scoped ONLY — tombstones are tenant-scoped by
2300
+ // design, so a same-content row in ANOTHER tenant is legitimately
2301
+ // live and must NOT be touched here. `keepId` is excluded even if
2302
+ // its content coincidentally matches: the human explicitly chose
2303
+ // to keep it in this same resolution, and this branch must not
2304
+ // undo that choice in the same transaction.
2305
+ const loserTenantId = loserRow.tenant_id ?? 'default';
2306
+ const dupRows = db
2307
+ .prepare(`SELECT id, kind, content FROM memories WHERE tenant_id = ? AND id != ? AND id != ?`)
2308
+ .all(loserTenantId, loserId, keepId);
2309
+ for (const dup of dupRows) {
2310
+ if (rejectionDigest(dup.content) !== rejectedDigest)
2311
+ continue;
2312
+ if (dup.kind === 'raw') {
2313
+ archiveRawMemory(db, dup.id, { reason, who: actor });
2314
+ extraRemovedRawIds.push(dup.id);
2315
+ }
2316
+ else {
2317
+ deleteEntryCore(db, dup.id, { actor, suppressForgetAudit: true });
2318
+ }
2319
+ extraRemovedIds.push(dup.id);
2320
+ }
2321
+ }
2322
+ if (loserRow.kind === 'raw') {
2323
+ archiveRawMemory(db, loserId, { reason, who: actor });
2324
+ loserWasRaw = true;
2325
+ }
2326
+ else {
2327
+ deleteEntryCore(db, loserId, { actor, suppressForgetAudit: true });
2328
+ }
2329
+ loserRemoved = true;
2330
+ }
2331
+ // loserRow undefined = tenant-scope mismatch (or already gone); matches
2332
+ // the old tenant-scoped DELETE's silent 0-rows-affected behavior.
1965
2333
  }
1966
2334
  else {
1967
2335
  // Halve the loser's half-life (weakens it over time)
@@ -1976,7 +2344,7 @@ export function resolveConflict(hippoRoot, conflictId, keepId, forgetLoser = fal
1976
2344
  db.prepare(`UPDATE memories SET conflicts_with_json = ?, updated_at = datetime('now') WHERE id = ?${memScope}`)
1977
2345
  .run(JSON.stringify(cleaned), keepId, ...memArgs);
1978
2346
  }
1979
- if (!forgetLoser) {
2347
+ if (!loserRemoved) {
1980
2348
  const loserRow = db.prepare(`SELECT conflicts_with_json FROM memories WHERE id = ?${memScope}`).get(loserId, ...memArgs);
1981
2349
  if (loserRow) {
1982
2350
  const refs = JSON.parse(loserRow.conflicts_with_json || '[]');
@@ -1985,8 +2353,55 @@ export function resolveConflict(hippoRoot, conflictId, keepId, forgetLoser = fal
1985
2353
  .run(JSON.stringify(cleaned), loserId, ...memArgs);
1986
2354
  }
1987
2355
  }
2356
+ // AT1: the missing audit (plan §5 — resolveConflict wrote ZERO audit_log
2357
+ // rows on any path before this). Every path — weaken, forget, reject —
2358
+ // lands exactly one conflict_resolve row.
2359
+ audit(db, 'conflict_resolve', keepId, {
2360
+ conflictId,
2361
+ keepId,
2362
+ loserId,
2363
+ disposition: loserRemoved ? (loserWasRaw ? 'archived_raw' : 'deleted') : 'weakened',
2364
+ rejected: Boolean(opts?.rejectLoserValue),
2365
+ rejectedDigest,
2366
+ // AT1 P1 fix: every row this call removed, not just loserId — the
2367
+ // same-tenant duplicate sweep above (extraRemovedIds) needs an
2368
+ // audit trail too.
2369
+ removedIds: loserRemoved ? [loserId, ...extraRemovedIds] : [],
2370
+ }, opts?.rejectedBy ?? 'cli', tenantId);
1988
2371
  db.exec('COMMIT');
1989
2372
  syncMirrorFiles(hippoRoot, db);
2373
+ // AT1 P1b fix: mirror purge + reaper stamp for EVERY removed loser, not
2374
+ // just the rejectLoserValue path. Pre-AT1, the plain forgetLoser path on
2375
+ // a raw loser crashed outright (bare DELETE FROM memories hit the
2376
+ // append-only trigger) — there is no legacy "successful forget, no
2377
+ // purge" behavior to preserve for that case. Post-AT1's kind-aware
2378
+ // removal (archiveRawMemory / deleteEntryCore above) makes plain
2379
+ // --forget succeed on every kind, but until this fix the mirror was
2380
+ // only purged when rejectLoserValue was ALSO set: a plain raw --forget
2381
+ // left its markdown mirror orphaned (the reaper still catches it
2382
+ // eventually, since archiveRawMemory's own raw_archive insert leaves
2383
+ // mirror_cleaned_at NULL) and a plain non-raw --forget left its mirror
2384
+ // orphaned FOREVER (no reaper exists for non-raw rows). Same post-commit
2385
+ // purge+reaper pattern as the reject verb (src/reject-flow.ts) and
2386
+ // api.archiveRaw — reusing removeEntryMirrors + raw_archive bookkeeping.
2387
+ if (loserRemoved) {
2388
+ // AT1 P1 fix: loop over loserId AND every same-tenant duplicate the
2389
+ // rejectLoserValue sweep above removed (extraRemovedIds) — previously
2390
+ // only loserId's mirror was purged, leaving duplicate mirrors orphaned
2391
+ // despite their rows being gone.
2392
+ for (const removedId of [loserId, ...extraRemovedIds]) {
2393
+ const isRaw = removedId === loserId ? loserWasRaw : extraRemovedRawIds.includes(removedId);
2394
+ // AT1 fix: purgeMirrorBestEffort retries once, then — for non-raw ids,
2395
+ // which cleanupArchivedMirrors' reaper never scans — reports the
2396
+ // EXPLICIT leftover path(s) instead of the false "will retry via
2397
+ // reaper" claim. See its own doc comment (store.ts, near
2398
+ // removeEntryMirrors) for the full rationale.
2399
+ const mirrorOk = purgeMirrorBestEffort(hippoRoot, removedId, isRaw, 'resolveConflict');
2400
+ if (mirrorOk && isRaw) {
2401
+ db.prepare(`UPDATE raw_archive SET mirror_cleaned_at = ? WHERE memory_id = ?`).run(new Date().toISOString(), removedId);
2402
+ }
2403
+ }
2404
+ }
1990
2405
  return { conflict: { ...conflict, status: 'resolved' }, loserId };
1991
2406
  }
1992
2407
  catch (error) {
@@ -2288,8 +2703,11 @@ export function loadChildrenOfSummary(hippoRoot, summaryId, tenantId) {
2288
2703
  * WHERE includes `AND summary_dirty = 1` so concurrent sleep's race-loser
2289
2704
  * becomes a no-op (no rebuild_count bump, no audit row).
2290
2705
  *
2291
- * Returns true on actual rebuild (changes > 0), false on race-loss /
2292
- * unknown id / archived / wrong dag_level.
2706
+ * Returns `{ changed, refused }`. `changed` is true when this call's UPDATE
2707
+ * (content or metadata-only) affected a row; false on race-loss / unknown id
2708
+ * / archived / wrong dag_level. `refused` is true only when a tombstone hit
2709
+ * suppressed the content write AND the metadata UPDATE still landed — see
2710
+ * the return-semantics comment below for the full contract.
2293
2711
  */
2294
2712
  export function applyRebuildResult(hippoRoot, summary, patch) {
2295
2713
  assertTenantId('applyRebuildResult', summary.tenantId);
@@ -2299,9 +2717,31 @@ export function applyRebuildResult(hippoRoot, summary, patch) {
2299
2717
  db.exec('SAVEPOINT rebuild_summary');
2300
2718
  try {
2301
2719
  const nowIso = new Date().toISOString();
2720
+ // AT1 P1a fix (docs/plans/2026-08-15-at1-rejected-value-tombstone.md):
2721
+ // applyRebuildResult's bumpRebuildCount branch wrote patch.content via
2722
+ // a direct UPDATE, bypassing the rejection guard entirely (the guard
2723
+ // lives in upsertEntryRow's INSERT path, which this function never
2724
+ // calls). A rebuild that regenerates byte-identical content to an
2725
+ // already-rejected value (e.g. deterministic summarization of an
2726
+ // unchanged child set) would silently re-assert it every sleep cycle.
2727
+ // Check BEFORE choosing which UPDATE to run — only the
2728
+ // bumpRebuildCount branch ever writes content, so a miss or a
2729
+ // zero-child call is a no-op here (one indexed point query, guarded
2730
+ // path only).
2731
+ const tombstone = patch.bumpRebuildCount
2732
+ ? findRejectedValue(db, summary.tenantId, rejectionDigest(patch.content))
2733
+ : null;
2734
+ // On a hit: do NOT write the new content. Fall through to the SAME
2735
+ // metadata-only behavior the zero-child branch already has —
2736
+ // descendant_count/earliest_at/latest_at update + summary_dirty
2737
+ // cleared, no content write, no rebuild_count bump. Clearing dirty
2738
+ // (rather than leaving it set) is deliberate: leaving it dirty would
2739
+ // make every following sleep cycle re-attempt and re-refuse the
2740
+ // identical rebuild forever (the DAG-loop this fix closes).
2741
+ const applyContentWrite = patch.bumpRebuildCount && !tombstone;
2302
2742
  // ONE prepared UPDATE per branch. Test #8 inspects the SQL string.
2303
2743
  // v0.30 / E5: widened dag_level=2 -> IN (2, 3) on both branches.
2304
- const sql = patch.bumpRebuildCount
2744
+ const sql = applyContentWrite
2305
2745
  ? `UPDATE memories
2306
2746
  SET content = ?,
2307
2747
  descendant_count = ?,
@@ -2325,24 +2765,57 @@ export function applyRebuildResult(hippoRoot, summary, patch) {
2325
2765
  AND dag_level IN (2, 3)
2326
2766
  AND summary_dirty = 1
2327
2767
  AND kind != 'archived'`;
2328
- const result = patch.bumpRebuildCount
2768
+ const result = applyContentWrite
2329
2769
  ? db.prepare(sql).run(patch.content, patch.descendant_count, patch.earliest_at, patch.latest_at, nowIso, summary.id, summary.tenantId)
2330
2770
  : db.prepare(sql).run(patch.descendant_count, patch.earliest_at, patch.latest_at, summary.id, summary.tenantId);
2771
+ // Return-value semantics (v0.30/T4 split): `changed` reflects whether
2772
+ // THIS call's UPDATE (content or metadata-only) affected a row — NOT
2773
+ // whether patch.content specifically landed. On a refusal, metadata
2774
+ // still applies, so changed=true even though content did not change.
2775
+ // This preserves the pre-T4 no-infinite-retry choice: the caller
2776
+ // (dag.ts rebuildDirtySummaries) treats changed=false as "race lost,
2777
+ // silently retry next cycle" — returning false on a refusal would
2778
+ // retry the same doomed LLM rebuild forever, so changed=true settles
2779
+ // this cycle (dirty cleared) regardless of refusal.
2780
+ // `refused` is the T4 addition: true only when a tombstone hit AND
2781
+ // the metadata UPDATE landed (changed=true) — a refusal that loses
2782
+ // the race to a concurrent writer reports refused=false too, since
2783
+ // nothing from this call took effect. Before T4, a refusal also
2784
+ // counted toward the caller's `rebuilt` stat because `changed` alone
2785
+ // could not distinguish it; the caller now increments `refused`
2786
+ // instead of `rebuilt` when this is true, so the stat reflects what
2787
+ // happened without changing dirty-clearing or retry behavior.
2331
2788
  const changed = (result.changes ?? 0) > 0;
2789
+ const refused = Boolean(tombstone) && changed;
2790
+ if (tombstone && changed) {
2791
+ // refused === true here (same condition, narrowed for the tombstone.*
2792
+ // access below). Best-effort refusal audit, written INLINE inside
2793
+ // this still-open SAVEPOINT — nothing here rolls back on a refusal
2794
+ // (the metadata UPDATE above already committed to this savepoint), so the
2795
+ // post-rollback auditRejectionRefusal helper (writeEntry/supersede's
2796
+ // tool) is the wrong one here; a direct audit() call is correct and
2797
+ // commits with the rest of this savepoint.
2798
+ audit(db, 'reject_refusal', summary.id, { digest: tombstone.digest, reason: tombstone.reason }, patch.actor, summary.tenantId);
2799
+ console.error(`applyRebuildResult: refused rebuild content for ${summary.id} — matches a rejected value ` +
2800
+ `(digest ${tombstone.digest.slice(0, 12)}...); metadata updated, content unchanged`);
2801
+ }
2332
2802
  if (changed) {
2333
2803
  // FTS sync — bare UPDATE on memories does NOT update memories_fts.
2334
2804
  // R1 HIGH must-fix from plan-eng-r1. Construct the patched entry in
2335
2805
  // memory and reuse the existing syncFtsRow helper (delete-then-insert).
2336
2806
  // earliest_at/latest_at preserve null semantics (R2 must-fix).
2807
+ // AT1: content stays summary.content (unchanged) when the write was
2808
+ // refused — applyContentWrite is false, so patch.content was never
2809
+ // written to the row FTS must mirror.
2337
2810
  const patchedEntry = {
2338
2811
  ...summary,
2339
- content: patch.content,
2812
+ content: applyContentWrite ? patch.content : summary.content,
2340
2813
  descendant_count: patch.descendant_count,
2341
2814
  earliest_at: patch.earliest_at,
2342
2815
  latest_at: patch.latest_at,
2343
2816
  summary_dirty: 0,
2344
- last_rebuilt_at: patch.bumpRebuildCount ? nowIso : summary.last_rebuilt_at,
2345
- rebuild_count: patch.bumpRebuildCount
2817
+ last_rebuilt_at: applyContentWrite ? nowIso : summary.last_rebuilt_at,
2818
+ rebuild_count: applyContentWrite
2346
2819
  ? (summary.rebuild_count ?? 0) + 1
2347
2820
  : summary.rebuild_count,
2348
2821
  };
@@ -2357,7 +2830,7 @@ export function applyRebuildResult(hippoRoot, summary, patch) {
2357
2830
  }, patch.actor, summary.tenantId);
2358
2831
  }
2359
2832
  db.exec('RELEASE SAVEPOINT rebuild_summary');
2360
- return changed;
2833
+ return { changed, refused };
2361
2834
  }
2362
2835
  catch (e) {
2363
2836
  try {