Repository navigation
fix: forgotten notes free their slots, caller notes are protected from the digest, honest digest outcomes - #6
Merged
Merged
Conversation
…caller's note
- retireForgottenNotes(state, forgottenKeys): drops forgotten notes from
profile.notes and retires their registry entries (retiredReason
"forgotten"), never deleting them.
- addNoteFact takes optional forgottenKeys and heals before the dedup/cap
checks; caller notes are tagged origin "note"; a cap-evicted note that is
written again registers again (alreadyRegistered ignores retired entries).
- carryOverConcurrentNotes takes { forgottenFactKeys, pack }: skips forgotten
notes (the pipeline had just pruned them, so they looked like concurrent
writes), mirrors concurrent retirements, and holds the notes cap, evicting
the oldest digest-produced note first.
- applyProfileFactsFromDigest evicts only digest-produced notes; when every
slot is a caller note the incoming fact is dropped (cap_rejected_incoming).
Caller notes are skipped by the >=0.6 in-place rewrite and by facet
consolidation. Legacy caller notes are recognised by shape (facet notes,
confidence 0.9).
- generateDigestStage2 marks degraded { reason: "stage2_chunks_failed",
failed, total } when chunks were skipped (a throw, or only unparseable
replies); isEmptyInvalidJsonPass exported.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nding; honest digest outcomes
- forget runs in one transaction and retires the forgotten note in the
latest snapshot (retireForgottenNotes); remember passes the scope's
forgotten keys so older stores heal on the next note write.
- The first note's placeholder Digest is stamped just before the scope's
earliest event, so events ingested before it stay pending.
- The digest reads every forgotten key (contents stay bounded to 100 for the
prompt) and passes fresh keys plus the facet pack to the write-time note
carry-over.
- A stage 2 that produced nothing parseable, a missing scope, or no model
provider now throw before writing: digestNow reports "failed" and the
events stay pending. A written digest that skipped stage-2 chunks or left
pending events out of selection reports { ran: true, partial: true }
(additive).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nding - forgetFact writes the ForgottenFact row and retires the forgotten note in the latest snapshot in one transaction. - addNote passes the scope's forgotten keys to addNoteFact (heals older snapshots) and stamps the first note's placeholder Digest just before the scope's earliest event. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s cap The digest write passes every forgotten key (re-read at write time) and the facet pack to carryOverConcurrentNotes, in both the scope digest and the rebuild chain. The pipeline prune also uses every forgotten key; contents stay bounded to the latest 100 for the prompt. Degraded digests are still written as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
…orgotten; prune retires - addNoteFact: a note that revises a note retired as "forgotten" supersedes it (returns `superseded`, chain old -> new), taking one slot while the forgotten note takes none. Restores the forget-then-remember revision link 1.0.0 had while the forgotten note still sat in profile.notes. - addNoteFact: the exact text of a forgotten note (its key is in forgottenKeys) is a no-op, so it no longer evicts a visible note at the cap for something facts() hides. - pruneForgottenFacts retires forgotten registry entries (retiredReason "forgotten") instead of deleting them, so why() keeps their history; stage 2 never shows forgotten entries to the model. - isTotalStage2Failure: a chunked run fails only when every chunk failed; one bad chunk among chunks that extracted nothing is partial. - isCallerNoteEntry(entry, registry): a legacy 0.9 notes entry that several sources were merged into (a consolidation output) is not a caller note. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t-then-revise regression tests
- A digest fails only when stage 2 distilled nothing (isTotalStage2Failure);
a run where some chunks failed is written as { ran: true, partial: true }.
- After 3 consecutive total stage-2 failures for a scope the degraded digest
is written as partial so the scope moves on. Counted in a new MCP-private
DigestFailure table (migration 2, like DigestLock), reset on every write.
- The digest writer reads forgotten keys inside its write transaction.
- Embedded regression tests: forget then remember a revision keeps the why
chain (mirrors Butler's test); the exact forgotten text at the cap stays
hidden and evicts nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
forgetFact, addNote and the worker's digest write each run in one transaction that first takes pg_advisory_xact_lock(hashtext(scopeId)) and reads the snapshot (and, for the digest write, the forgotten keys) under it. At READ COMMITTED with no row lock, concurrent writers could read the same snapshot and drop each other's change; an integration test of concurrent note writes fails without the lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes five limitations Butler (a
statecore-mcp@1.0.0consumer) documented. All five were confirmed againstmain724f309.1. Forgotten notes kept their cap slots
forgetonly inserted a ForgottenFact row.facts()hid the note, but it stayed inprofile.notes, so a full notes facet (cap 30) stayed full and the next note evicted a live one.retireForgottenNotes(state, forgottenKeys, makeNow, pack)removes the forgotten notes' strings fromprofile.notesand marks their registry entriesretiredAt/retiredReason: "forgotten". Nothing is deleted.forgetnow runs in one transaction that inserts the ForgottenFact row and retires the note in the latest snapshot. The APIforgetFactdoes the same, with the upsert and the snapshot update in one$transaction.addNoteFacttakes optionalforgottenKeysand runs the helper before the dedup and cap checks. Stores written before this fix heal on their next note write. Both embeddedrememberand APIaddNotepass the keys.alreadyRegisterednow requires!e.retiredAt. Before, a note that was cap-evicted and then written again stayed inprofile.noteswith no active registry entry behind it.2(a). A full digest did not free forgotten slots
pruneForgottenFactsremoved forgotten notes from the pipeline state. At write timecarryOverConcurrentNotessaw their ids missing and appended all of them again as if they were concurrent writes, with no cap check.carryOverConcurrentNotes(pipeline, latest, { forgottenFactKeys, pack })skips forgotten notes and retires any forgotten note the pipeline still holds (covers a forget that lands mid-digest). It also copies over retirements and supersessions made concurrently in the latest snapshot, and holds the notes cap: it evicts the oldest digest-produced note first, then the oldest note, retiring each ascap_evicted.CreateDigestWithSnapshotInputgainsforgottenFactKeysandpackin bothapps/mcpandapps/worker(including the worker's rebuild chain). The keys are re-read at write time.3(b). Events before the first note were never pending
The first note created a placeholder Digest stamped "now". Pending means "newer than the latest Digest", so earlier events were never digested (the pipeline exited
no_new_events). The placeholder Digest and its snapshot are now stampedmin(ingestedAt in scope) − 1ms, or now when the scope has no events. Done in both the embedded and API paths.4(c). A digest could evict or rewrite a caller's note — production behaviour change
addNoteFacttags caller notes withorigin: "note". Legacy caller notes are recognised by shape: facetnotes, confidence0.9, no origin. I checked that no digest path writes 0.9 (stream evidence is 0.6, documents 0.85).applyProfileFactsFromDigest: at the cap, a digest-produced note may evict only the oldest digest-produced note. If every slot holds a caller note, the incoming digest note is dropped and logged ascap_rejected_incoming(heldBy: "caller_notes").protected_lower_authoritywhen the text differed) and by supersession.Net effect in production: digest-extracted notes can no longer push out notes users wrote explicitly. On a scope whose notes facet is full of caller notes, new digest-extracted notes are dropped, and the selection log records the drop.
5(d). A digest reported "ran" when it had not processed everything
generateDigestStage2addsdegraded: { reason: "stage2_chunks_failed", errors, failed, total }when chunks were skipped, either because the pass threw or because every reply was unparseable.isEmptyInvalidJsonPassis exported.invalid_json_outputwith no facts, the run now throws before writing. The result is"failed", and the events stay pending (writing would have moved the watermark past events nobody read). A missing scope or no model provider is also"failed"now; both used to return"ran".digestNow()/statecore-mcp digestreturn{ ran: true, partial: true }. The field is additive.DigestRunOutcomeis unchanged. A new internalrunDigestAttemptcarriespartial, andmaybeRunDigeststill returns the plain outcome. Butler'sparseDigestOutcome(a non-strict zod object) reads{ ran: true, partial: true }as"ran".Follow-up round (review findings + a Butler regression)
supersededcame back null.addNoteFactnow also matches notes retired as"forgotten":supersededis returned andwhyshows old → new.isTotalStage2Failure). If only some chunks failed, the digest is written withpartial: true.partial: trueso the scope moves on. The count lives in a new MCP-privateDigestFailuretable (embedded migration 2, likeDigestLock) and resets on every write.pruneForgottenFactsretires instead of deleting. Forgotten registry entries are markedretiredReason: "forgotten", sowhykeeps their history. Stage 2 never shows forgotten entries to the model.forgetFact,addNote(whose read-modify-write is now inside one transaction) and the worker's digest write each takepg_advisory_xact_lock(hashtext(scopeId)). They read the snapshot, and for the digest write the forgotten keys, while holding the lock. A new integration test of concurrent note writes fails without the lock.notesentry at 0.9 that more than one source was merged into is not treated as a caller note, so it can still be consolidated or evicted.Also: embedded
recallfactRegistry entries for notes now carryorigin: "note"(an additive field). The/v1output strips it throughFactRegistryEntrySchema, so the frozen contract is untouched.Tests
fact-retire,handoff,stage2-chunking,digest-control,facet-consolidationdigest-writeadd-note,memory-facts.serviceembedded,digest-now,digest-triggerNo test calls a real model. MCP tests use stub chat models, and the no-provider test mocks
createModelProviderand points at a dead port.Ran locally: core 555, worker 43, statecore-mcp 113, and api 141 (integration included, against a local pgvector test DB).
pnpm lintandpnpm buildare clean.Release
A changeset is included:
statecore-mcpminor (→ 1.1.0 together with the pending patch changeset),@statecore/coreminor,@statecore/workerand@statecore/apipatch.changeset versionwas not run.🤖 Generated with Claude Code