Merging entities rebuilds the working-memory block (30.4b, seam 1 of 5) - #206
Merged
Conversation
There was a problem hiding this comment.
Pull request overview
Implements the missing working-memory rebuild seam for IEntityRepository.MergeEntitiesAsync by decorating the repository so successful merges trigger a working-memory block rebuild, and centralizes the rebuild failure policy to avoid drift across call sites.
Changes:
- Add
WorkingMemoryEntityRepositoryDecoratorto rebuild working memory after successful entity merges, including owner resolution for unscoped merges. - Extract rebuild-and-failure-policy logic into a shared
WorkingMemoryRebuilderand route existing rebuild seams through it. - Add unit tests to ensure the decorator is actually reachable via DI and that merge-triggered rebuild behavior is exercised; update docs/changelog with Neo4j 2026.02 / Cypher 25 verification notes.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/AgentMemory.Tests.Unit/Services/WorkingMemoryMergeReachabilityTests.cs | Adds DI reachability + captive-dependency guard tests for the merge decorator registration. |
| tests/AgentMemory.Tests.Unit/Services/WorkingMemoryMergeEntitiesTests.cs | Adds unit tests that drive MergeEntitiesAsync and assert rebuild/no-rebuild behavior. |
| src/AgentMemory.Neo4j/Infrastructure/ServiceCollectionExtensions.cs | Registers the merge rebuild seam by decorating IEntityRepository and creating the rebuilder lazily via IServiceScopeFactory. |
| src/AgentMemory.Core/Services/WorkingMemoryRebuilder.cs | Introduces a single shared implementation of rebuild + clear-on-failure policy. |
| src/AgentMemory.Core/Services/WorkingMemoryEntityRepositoryDecorator.cs | Implements the repository decorator that triggers rebuild after merge and forwards all other members. |
| src/AgentMemory.Core/Services/LongTermMemoryService.cs | Replaces inlined rebuild failure policy with WorkingMemoryRebuilder. |
| src/AgentMemory.Core/Extraction/PersistenceStage.cs | Replaces inlined rebuild failure policy with WorkingMemoryRebuilder and reuses its disabled gating. |
| docs/agent-framework.md | Documents Neo4j 5.x/2026.x prerequisites and Cypher 25 default-language behavior in Neo4j 2026.02. |
| CHANGELOG.md | Adds an Unreleased note documenting verified Neo4j 2026.02 / Cypher 25 compatibility. |
Suppressed comments (3)
tests/AgentMemory.Tests.Unit/Services/WorkingMemoryMergeReachabilityTests.cs:48
- This test creates a ServiceProvider via Build() but never disposes it (only the scope). With ServiceProvider being IAsyncDisposable (and sync disposal potentially throwing due to Neo4j registrations), the provider should be disposed via DisposeAsync to avoid leaking resources across the unit test run.
[Fact]
public void TheResolvedEntityRepositoryIsTheMergeRebuildDecorator()
{
using var scope = Build(workingMemoryEnabled: true).CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IEntityRepository>();
tests/AgentMemory.Tests.Unit/Services/WorkingMemoryMergeReachabilityTests.cs:67
- Like the previous test, this one creates a root ServiceProvider and only disposes the scope. The root provider should be disposed via DisposeAsync to avoid leaking any IAsyncDisposable registrations.
[Fact]
public void TheDecoratorIsRegisteredEvenWhenTheTierIsDisabled()
{
using var scope = Build(workingMemoryEnabled: false).CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IEntityRepository>();
tests/AgentMemory.Tests.Unit/Services/WorkingMemoryMergeReachabilityTests.cs:83
- This test also builds a root ServiceProvider and never disposes it (only the scope). Dispose the provider with
await usingto avoid retaining IAsyncDisposable resources after the test completes.
[Fact]
public void TheDecoratedRegistrationStillResolvesRepeatedly()
{
using var scope = Build(workingMemoryEnabled: true).CreateScope();
var first = scope.ServiceProvider.GetRequiredService<IEntityRepository>();
var second = scope.ServiceProvider.GetRequiredService<IEntityRepository>();
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+27
to
+41
| private static IServiceProvider Build(bool workingMemoryEnabled) | ||
| { | ||
| var services = new ServiceCollection(); | ||
| services.AddLogging(); | ||
| services.AddNeo4jAgentMemory( | ||
| configureMemory: options => options.WorkingMemory.Enabled = workingMemoryEnabled, | ||
| configureNeo4j: options => | ||
| { | ||
| options.Uri = "bolt://localhost:7687"; | ||
| options.Username = "neo4j"; | ||
| options.Password = "not-a-real-password"; | ||
| options.Database = "neo4j"; | ||
| }); | ||
| return services.BuildServiceProvider(); | ||
| } |
Comment on lines
+25
to
+30
| /// <para> | ||
| /// <b>Off-state contract.</b> When the tier is disabled the decorator is not registered at all | ||
| /// (see <c>ServiceCollectionExtensions</c>), so the wire is absent rather than merely inert. Even | ||
| /// if it were registered, <see cref="WorkingMemoryRebuilder.IsDisabled"/> short-circuits before the | ||
| /// owner lookup, so a disabled tier costs zero extra reads. | ||
| /// </para> |
…three
The working-memory design named MergeEntitiesAsync in §5.2 and build step 6 as
one of four direct rebuild seams. It was never built, and an independent review
found it rather than the green suite -- because every working-memory test called
RebuildAsync directly, which cannot detect a trigger wired to the wrong path.
It could not be fixed the way the other seams were. Every other rebuild epilogue
hangs off a service; MergeEntitiesAsync has none. It is IEntityRepository-only,
public and TCK-Gold exposed, so calling the repository DIRECTLY is the usage
pattern and a service-level hook would be bypassed by every caller that exists.
THE OBVIOUS FIX WAS WRONG, and the way it was wrong is the useful part. Wrapping
IEntityRepository in a decorator passed 14 unit tests, the full 479-test
integration suite, and a red-first reachability guard. It was still broken:
Neo4jEntityRepository also implements IUpsertPersistsProvenance,
IBatchMemoryRepository<Entity> and IFusedBatchMemoryRepository<Entity>, and a
wrapper implementing only IEntityRepository silently strips all three. That
collapses both batch write paths into per-item queries and re-adds the provenance
writes the marker exists to skip -- 8 -> 115 Cypher queries on the 50-message
extraction scenario, plus W-02 8->19, W-03 13->34, W-08 8->17. No functional test
could see it. The hermetic counter gate caught it, which is the entire argument
for gating on counters rather than on timings or on green.
So the seam lives INSIDE Neo4jEntityRepository, where merge already lives. No
wrapper, no capability loss, no DI gymnastics: an optional IWorkingMemoryService
alongside the IOptions<MemoryOptions> the constructor already took. No dependency
cycle -- Neo4jWorkingMemoryService takes only the tx runner, clock, ids, options
and logger.
AND THE SEAM ALONE WOULD HAVE BEEN COSMETIC. The new integration test failed on
its first run with the merged-away entity still named in the block: a merge
tombstones the source with merged_into/merged_at -- deliberately, to keep the fold
auditable -- and does NOT invalidate it, while SelectTopEntities filtered only on
invalidated_at IS NULL. Rebuilding faithfully just recompiled the same stale name.
The query now excludes merged-away entities too. That is the same "block asserts
something no longer true" staleness the supersession canary exists to prevent,
arriving by a different route.
Also extracted WorkingMemoryRebuilder. The rebuild failure policy was written out
twice already and had drifted: the identical clear-also-failed branch logged at
Error in LongTermMemoryService ("because nothing else can notice it") and at
Warning in PersistenceStage. This change needed a third copy; it now has one,
shared, and both existing sites delegate to it.
Tests target the trigger, not the thing. The merge guard is an INTEGRATION test
driving the real MergeEntitiesAsync against a real database, because a unit test
with a substituted repository passes against both the correct design and the
broken decorator. Ten unit tests pin the shared failure policy.
Release 0 warnings / 0 errors. Unit 5049, integration 49/49 across every
working-memory and entity-repository test. Cypher snapshot regenerated for the
one-line query change (BOM stripped).
Four seams from that review remain open and are still listed in the design:
AddEntityAsync, the three Invalidate* paths, DeletePreferenceAsync, and the
benign RecordEntityFeedbackAsync. The tier ships off by default, so nothing in
production is affected today.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PgDgctPpbTziBNE2RT8VdE
joslat
force-pushed
the
feat/working-memory-merge-rebuild
branch
from
August 17, 2026 02:50
4cb8704 to
ad1df31
Compare
joslat
added a commit
that referenced
this pull request
Aug 26, 2026
…ite (#207) * Make Wave C measurable, and fix the read-back that made it unmeasurable Three findings, each one blocking the next. FIRST: every Phase-30 Wave-C capability was unreachable from the benchmark. LongMemEvalMemoryProfile set exactly three fields on MemoryOptions and none was a Phase-30 flag; no CLI verb exposed one. So the features built to move these numbers were the one thing no run could exercise, and 30.6 sat "built, not measured" because it was UNMEASURABLE. That is the same defect the profile's own RescueShortOwnerResults comment records one wave earlier -- "a coverage lever with ZERO harness references until now". --working-memory and --arithmetic-memory now exist, each carrying the schema extension its writes need, because a flag without its DDL fails at the store and reads as broken rather than dark. SECOND: with the switch finally on, the first ablation was VOID -- 50/50 agent errors, twice, ~4.5 hours a run, and no diagnosable error anywhere. A control isolated it in one question: accountant off answers, accountant on throws. The cause is in the harness, not the feature. The graph read-back proves memory was learned by requiring every :Fact to carry an EXTRACTED_FROM edge: count(DISTINCT CASE WHEN m IS NOT NULL THEN n END) AS learnedItemsWithProvenance CompleteProvenance => LearnedItemsWithProvenance == LearnedItems A DERIVED fact has no source message and never will -- it is computed FROM other facts and DERIVED_FROM is its provenance. Measured live mid-run: base 65/65 with provenance, derived 6/0. So 65 != 71, CompleteProvenance goes false, and the adapter throws on EVERY question. The check encoded an assumption that held only while derived memory did not exist. DERIVED_FROM now counts as provenance -- deliberately not an exemption, so an unlinked derived fact still fails -- via an EXISTS subquery rather than a second OPTIONAL MATCH, which would multiply rows per node and silently inflate provenanceEdges and sourceMessages. THIRD: that throw sits OUTSIDE ExecuteStageAsync, which is why instrumenting the stage wrapper printed nothing. The wrapper now logs the failing stage and the full inner-exception chain, so the next stage failure costs one line of log instead of nine hours of runs that say only "Agent execution did not complete". Also 30.4b seams 2-5, the last of the working-memory review's five: the three Invalidate* paths and DeletePreferenceAsync now recompile the block. Supersede was hooked and its exact twin was not, while every block query filters invalidated_at IS NULL -- so a retracted fact kept being asserted until some unrelated write happened. DeletePreferenceAsync rebuilds unconditionally and the asymmetry is forced, not chosen: IPreferenceRepository.DeleteAsync returns Task, so there is no way to learn whether it matched, and the choice is between a wasted rebuild on a no-op and a stale block on a real delete. Guards, in the class this repository keeps needing: three tests assert the flags reach MemoryOptions and install their extension, added to GraphRagWiringTests because that class exists for exactly this shape. The repo's own drift guard EveryAdvertisedOptionIsCarriedOnTheOptionsRecord caught the new flags before it was updated, which is the guard working on the person adding the option. Verified: the question that failed 100% now runs (inconclusive=1 -> 0). Release 0 warnings / 0 errors, unit 5057, harness 686. One measurement to flag rather than bury: on that question realised coverage is 0.400 with the accountant on against 0.800 with it off. The derived facts compete for the retrieval budget and displace gold -- and the accountant is aggregating over corpus filler ("count_of:was_consistent_between"), so it manufactures competitors for the budget the real evidence needs. Whether that holds at N=50 is what the running ablation answers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PgDgctPpbTziBNE2RT8VdE * Adding an entity rebuilds the block — the last unhooked write (R3) The working-memory trigger set has now been closed by three successive partial sweeps: 30.4b hooked persist, invalidate and preference-delete; #206 hooked entity merge; this hooks AddEntityAsync, which every pass missed. The block carries a top-entities section (WorkingMemoryQueries.SelectTopEntities), so an added entity can change what it says. The window is narrow because entities sort by access_count DESC and a new one usually falls outside the cap -- but it stops being narrow for an owner holding fewer entities than MaxTopEntities, where the new entity genuinely belongs in the block and would not appear until some unrelated write happened to trigger a rebuild. Red-probed: removing the call fails AddEntityAsync_RebuildsTheBlock and nothing else. Two negative cases alongside it -- below the confidence floor nothing is persisted so nothing rebuilds, and with the tier off nothing rebuilds -- so the gate is on the write actually happening, not on the method being called. A note on how this was found, because the process is the finding. My first pass claimed invalidate and delete were still unhooked; that was wrong. The grep matched RebuildWorkingMemoryAsync and missed _rebuilder.RebuildAsync, the abstraction #206 introduced, so six hooked call sites read as unhooked. Verifying against the running code rather than the grep corrected it and left exactly one real gap. A sweep is only as good as the spelling it searches for. Gate: Release 0/0; unit 5060, LongMemEval 686, SK 54, perf 3; integration 481/481 non-NAMS (29 NAMS = deprovisioned external workspace). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RM9gMsvo3SpYqEBENbaefo * Measure what the short-owner rescue buys, and find it should stay off RescueShortOwnerResults ships off, and the stated reason was measurement comparability -- "every recorded measurement was taken without it" -- not correctness. That is a good reason to have shipped it off and a poor reason to leave it off forever, because the number that would justify flipping it had never been taken. This takes it. Construction: 50 owners x 4 facts, foreign rows made strictly MORE similar to the query than the querying owner's, so crowding is decided by rank rather than volume. No model, no corpus build. crowded (owner outranked): OFF 3 of 4 in 7ms -> ON 4 of 4 in 18ms +25% small tenant (alone): OFF 4 in 10ms -> ON 4 in 20ms +0, 2x The answer is LEAVE IT OFF. A 4-fact tenant against a limit of 10 is "short" by the rescue's own gate, so the scan fires on every recall, recovers nothing, and roughly doubles latency. OwnerVectorOverFetch's written objection -- that this "would tax every small tenant with an extra query on every recall forever" -- is supported. Its counter-argument, that an owner-bounded scan is cheap because the owner is small, is not: the scan cost about as much again as the indexed query. The ratio is the finding, not the milliseconds. Absolute figures are single-digit ms on a small containerised corpus; the 2x on the null case is what generalises, and the null case is every small tenant on every recall. THE FIRST RUN OF THIS TEST WAS VOID AND LOOKED CLEAN. It reported "recovered 0 rows", which would have written up as "the rescue buys nothing". It was wrong: with identical embeddings across owners the index breaks ties by insertion order, owner-000 was written first, so all four of its facts sat inside the global top-K and it was never starved. Crowding is about RANK, not volume. The test now carries a void witness that fails if the OFF arm already returned everything the owner holds, so the arms cannot silently compare nothing. A better fix than the flag, recorded and not built. The remarks say the returned count "cannot tell them apart" -- small versus crowded. True of the post-filter count; false of the pre-filter one, which the query discards. queryNodes runs first and the owner predicate is a WHERE after it, so a full topK with a short owner yield means the neighbours ate the budget (rescue warranted), while a under-full topK means the corpus holds little (rescue is pure tax). One aggregation inside the existing statement, no second round trip. Left as a proposal because it changes a shipped retrieval path and the same measurement should gate it: strategy/performance/SHORT-RESCUE-MEASUREMENT-2026-08-20.md. This sizes our own confound; it does not settle the unattributed 11-point gap against the BM25 baseline. That still needs one fact we do not hold -- whether V9's corpus is multi-owner. Single-owner, and starvation cannot apply at all. Gate: Release 0/0; unit 5060, LongMemEval 686, SK 54, perf 3; integration 483/483 non-NAMS (+2 new arms; 29 NAMS = deprovisioned external workspace). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RM9gMsvo3SpYqEBENbaefo * Bump AgentEval to 0.26.0-beta — the V9 arm and the scaffolding stamps 0.26.0-beta adds the arm that was missing from the router decision: V9, accuracy under k-limited BM25 top-K retrieval, plus per-corpus scaffolding_dependence stamped beside the headroom table. We were pinned to 0.25. Clean bump: Release 0/0, LongMemEval suite 686/686. No API drift to absorb. Prerequisite for the starvation re-measure, which is the next task and needs this package's corpora. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RM9gMsvo3SpYqEBENbaefo * Router step 1: the request half and the witness half (30.10) The public surface for per-memory-type fan-out, which is the minimum that makes the mechanism expressible at all. Audit finding 11 is that the asked-for design had a substitute built in its place and no part of the request schema could even state it: RecallRequest could carry one blended question and nothing else. Four additions, all additive, no interface members touched so no DIMs needed: MemoryTypeAffinity deliberately NOT [Flags]. A sub-query targets exactly one memory type; a bitwise union would re-express the blended query this mechanism exists to split. Starts at 1 so an unset field is invalid rather than silently Semantic. RecallSubQuery affinity + text + optional pre-computed embedding. SubQueryYield three counts, deliberately distinct: retrieved says the leg ran, unique says it found what the monolithic query missed, survived says that reached the prompt. A mechanism can score well on the first and zero on the third, and reporting only the first is how fan-out looks valuable while changing nothing. RecallFanOutReport the witness, with VoidReason so a run that cannot be interpreted self-voids instead of reporting an unearned zero. MemoryContext.FanOutReport's null is load-bearing and locked by the type's own docs: null = the planner never ran; GateFired false = ran and declined; fired with every UniqueContributions zero = fired and contributed nothing. Collapsing those three is how query formulation was retired at exactly 0.0000 with nobody able to say whether it had ever run. The AbstractionsContractGuard caught this correctly -- public surface added without updating the counts it pins to docs/architecture.md 3.1. Both updated (72->75 records, 32->33 enums) with the reasoning recorded beside each, per the file's own convention. Gate: Release 0/0, unit 5060. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RM9gMsvo3SpYqEBENbaefo * Router step 2-3: options, and the off-state canary written before the behaviour Step 2, options. RecallFanOutOptions as a mutable class, never an init record -- the #100 lesson as code: a sub-option a host cannot assign from configureMemory silently cannot be configured at all. A test per default, which looks excessive and is not: this repository has twice shipped a feature whose option diverged from its documented default, silently in both directions. Two defaults carry reasoning worth keeping. UseLlmDerivation is false because a model deriving the sub-queries makes every downstream number depend on a sampled output, and a mechanism whose measurement cannot be repeated cannot be shown to work. WeakTopScoreThreshold is null rather than 0.7 because that number is a measured dead zone on ONE corpus, and shipping it as a default would bake one corpus's calibration into every host's recall. Step 3, the off-state canary -- written BEFORE any fan-out behaviour exists, so it passes trivially today. That is the point: the commit that introduces the behaviour cannot also quietly change what a host with the feature off receives. Four independent claims, because they fail independently: the report is null (planner never ran, distinct from ran-and-declined); no additional embedding call is made (counted, not trusted); a default request carries no SubQueries and record equality is unchanged by the new member; and the assembled section counts are the same with the flag explicitly off as by default. Gate: Release 0/0, unit 5070 (+10). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RM9gMsvo3SpYqEBENbaefo * Router step 4: the gate, and a run-boundary defect its own fixture caught Four named deterministic rules, token-scan only. No regex, following the TrivialTurnDetector precedent rather than convenience: a regex needs a timeout, a timed-out regex is a second source of nondeterminism, and a gate that decides differently on a slow machine voids every run measured through it. The catastrophic-input test asserts that directly -- 30 repeated fragments, scanned in under 50ms. Every rule has a negative fixture as well as a positive one. A gate tested only on what should fire is a gate that fires on everything and still passes. Two rules encode measured lessons rather than intuition. C1 never counts question marks (wrong on 3 of 5: a paper title ending in "?" is not a question). D4 is never keyed on "previous" (an 82.1% false-positive surface -- "my previous answer" is about the conversation, not about time). The E3 fixture caught a real defect in my own implementation. Treating a comma as an ordinary word separator merged "Acme Corp, Initech" into one four-token run, counting one mention where there are two -- under-counting exactly the enumerations E3 exists to catch. Punctuation now breaks a run before whitespace continues one. Found by the fixture, not by reading the code, which is the argument for writing the negative cases first. One imprecision accepted and documented rather than chased: a sentence-initial imperative verb ("Compare Acme Corp...") is capitalised and not a stopword, so it merges into the first mention. An ever-growing verb list would trade a bounded explainable heuristic for an unbounded one, and the error is raise-only. Gate: unit 5089 (+19). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RM9gMsvo3SpYqEBENbaefo * Router steps 5-6: both derivers, and the failure modes that must never throw Deterministic deriver ("det-v1", the default) splits at the coordinating joiner and types each fragment by its own signals. Default because it is REPRODUCIBLE: the same query yields the same legs on every run and machine, which is what makes a before/after measurement of this mechanism mean anything. Two orderings are load-bearing and carry their reasoning in the code. Semantic is the RESIDUAL, not a signal -- given its own keyword set it would fire on nearly everything, and the other four types are the discriminating ones. Episodic precedes Preference because "you mentioned I like sushi" asks what was SAID, not what is preferred; Preference-first would route it to the wrong store and the leg would come back empty. A single undecomposable query yields ZERO legs rather than one. Returning the whole query as a "sub-query" would re-issue the monolithic query under another name and bill an extra embedding for it. LLM deriver ("llm-v1", opt-in) is one call, JSON only, and every test is about a failure mode because the contract is that none of them throw: unparseable output, an object where an array belongs, a missing field, an unknown affinity, a provider exception -- all degrade to "no fan-out this turn". A fan-out is an enhancement and must not take the primary recall path down with it. Two decisions worth naming. Unknown affinities are DROPPED, not coerced to Semantic: coercing would route a leg to a store the model did not choose and then report it as though the model had. And cancellation propagates rather than being swallowed -- a cancelled recall is not a failed derivation, and reporting "derivation-failed" for a turn the caller abandoned would be a fabricated cause. Gate: Release 0/0, unit 5121 (+32). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RM9gMsvo3SpYqEBENbaefo * Router step 7a: the merge rules, pure and separately testable The part of fan-out that decides what actually reaches the prompt, built as a pure id-keyed function so it can be tested without a container, a provider, or a database standing between the rule and its assertion. Wiring it into the 1500-line assembler is next; getting the rules right first is cheaper. Three rules carry reasoning worth keeping. BUDGETS ARE NEVER MULTIPLIED. The merged section is re-capped at the same MaxX the monolithic query used. A fan-out that returned more rows because it asked more questions would "improve" recall by spending more of the prompt, which is not the claim being tested. MONOLITHIC PRECEDENCE ON TIES. If a leg re-finds items the blended query already had, at equal scores, the original ordering survives -- so a fan-out that changed nothing produces a byte-identical section rather than a reshuffled one that merely looks different. That is what keeps "fired and useless" distinguishable from "never ran". A CONTRIBUTION TRUNCATED AWAY IS NOT A CONTRIBUTION. Only unique ids that survive the cap are counted. An item retrieved and then cut never reached the prompt, and counting it is exactly how a mechanism looks valuable while changing nothing a reader ever sees -- the failure the three-count SubQueryYield exists to prevent. The affinity map is one published table rather than scattered switch arms, with a guard asserting every defined affinity has at least one destination: an affinity that maps to nothing is a leg that costs an embedding, can never return anything, and still gets reported as having run. Gate: Release 0/0, unit 5140 (+19). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RM9gMsvo3SpYqEBENbaefo * Router steps 7b-8: fan-out wired into the assembler, and the witness locked The merge now runs on the live recall path, placed AFTER the monolithic sections resolve and BEFORE the budget. That is the only placement satisfying both halves of the contract: the merge needs the monolithic sets to compute unique contributions against, and the merged sections must pass through the existing truncation untouched so budgets are never multiplied. The witness tests ARE the design's contract, and they assert through AssembleContextAsync rather than by constructing a report, because the defect being guarded is a WIRING one -- a report correct in isolation that never reaches the context proves nothing. Three states, each distinguishable from the outside: null report the planner never ran (feature off, or no deriver registered) GateFired = false it ran and declined; cost was one token scan GateFired = true, it fired, legs ran, and nothing they found reached the prompt all yields zero Collapsing those is exactly how query formulation was retired at a reported 0.0000 with nobody able to say whether it had ever executed. Two more states have their own tests. A derivation that returns no legs is VOIDED ("derivation-failed") rather than reported as a zero-yield fan-out: no leg ran, so a zero would not be a measurement of anything. An embedding failure is counted into VoidReason rather than thrown -- the recall already succeeded and a failed enhancement must neither take it down nor vanish. Caller-supplied sub-queries win even with the feature disabled, the TemporalReferenceTime philosophy: an explicit request is not something a global flag gets to veto. That is also what makes the mechanism reachable from the eval harness without touching the framework seam. The cap still applies -- the caller does not get to decide how many round trips a recall costs. Gate: unit 5148 (+8), 0 warnings. The off-state canary from step 3 still passes, which is the claim that matters for a dark-shipped feature. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RM9gMsvo3SpYqEBENbaefo * Router step 10: DI registration and a reflective reachability guard The deriver is selected by options rather than registered twice. Two enumerable registrations would let both run, and the witness would then name one deriver while the other produced the legs -- destroying the reproducibility DeriverId exists to guarantee. The LLM deriver needs an IChatClient. When the flag asks for it and no client is registered, this falls back to the deterministic deriver rather than registering something unresolvable: an unsatisfiable binding takes the whole assembler down with it, which is the exact break the 1.0 lockdown produced. The guard is REFLECTIVE, not enumerated. This repository has now found the same defect shape four separate times -- procedural promotion that never fired, two rerankers registered by nobody, a working-memory rebuild hook reaching one of two write paths, and an entity-add that reached none -- and every one of them passed its own tests. An enumerated list would pass forever while a newly added third implementation shipped inert. This discovers every non-abstract ISubQueryDeriver in the assembly and fails if one cannot be reached from AddAgentMemoryCore under some option setting. One test targets the seam that actually matters: registered-but-not-injected is the same inert outcome as not registered at all, and the container reports neither. Gate: unit 5153 (+5), SK 54, perf 3, 0 warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RM9gMsvo3SpYqEBENbaefo * Router steps 9 + 12: signal W, and the docs that say it ships dark Signal W fires when the blended query scored badly. Evaluated AFTER the monolithic sections resolve, because unlike the other four rules it is a statement about what they came back with rather than about the query's shape -- and only when no pre-retrieval rule fired, since a query already known to be compound does not need a second reason and double-counting would make the fired-rule set unreadable as evidence of WHY it fired. The case worth the code is the unscored provider. One that publishes no scores at all looks exactly like one whose every score sits below the threshold. W tracks whether ANY score was observed and, when none was, records "W-unscored" rather than firing (a fabricated fire) or declining confidently (a fabricated all-clear). It cannot form an opinion, and says so. Docs record the feature as BUILT, WIRED, UNMEASURED and off by default -- the repository's own convention for a dark-shipped capability, and accurate: nothing here has been measured against a corpus yet. The memory-map row states the property that bounds what it can ever buy: merged sections are re-capped at the existing MaxX, so fan-out changes WHICH items reach the prompt, never HOW MANY. That property now has an external consequence worth recording. AgentEval's decomposition of Arithmetic's +0.62 headroom (2026-08-21) splits it into +0.36 scaffolding artifact, +0.16 K-limited and unreachable by ANY ranker at K=5, and +0.10 genuine ranking headroom. Because this design deliberately does not raise K, the K-limited component is permanently out of its reach: its ceiling on that vertical is the +0.10, not the +0.62. Pre-registered in adaptive-router.md against its own interest, so a +0.10-shaped result is not later read as a disappointment against a +0.62-shaped expectation. Gate: unit 5157 (+4), 0 warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RM9gMsvo3SpYqEBENbaefo * Fact-weighted recall budget: a reallocation arm, and a guard that needed two parts Arm A measured the cause of our Arithmetic score: the structured budget splits a 30-item context evenly -- 10 facts, 10 entities, 10 preferences -- so an arithmetic question spends two thirds of its context on item kinds that cannot carry a value. The judge transcripts say it outright ("omits the third spell", "omits one 5-day spell"), and every count-and-sum error was one-directional: too few. --fact-weighted-budget keeps the total at 30 and reallocates to 5/20/5. A REALLOCATION, not an enlargement -- a test asserts the totals match, because if they ever diverge the arm is confounded with a bigger prompt and its number means nothing. THE GUARD NEEDED TWO PARTS, AND THE FIRST ONE WAS NOT ENOUGH. The adapter calls FactWeighted directly rather than through For(), so it inherits no argument check; ThrowIfNegativeOrZero closes the obvious hole. But total 1 is POSITIVE and passes that check, and the Math.Max(1, ...) floor then hands entities and preferences a slot each that a one-slot budget cannot pay for -- facts becomes 1 - 2 = -1. A negative budget is not a small budget; it is a limit that inverts every downstream comparison it reaches. Capping the minor sections at a third makes them yield instead, and at tiny totals the result degrades to exactly what For() already returns there. That second hole was found by a test that SWEEPS every total from 1 to 200 rather than sampling a few, which is this repository's own recorded lesson: rotating lenses sample shapes instead of exhausting them. The full-suite run then caught a second thing the filtered run had not: an existing drift guard maps every advertised CLI option to the record property that honours it, so an option can never be advertised and silently unhonoured. Both new flags are now mapped -- and that guard is the reason an arm cannot be requested and then produce a report indistinguishable from its own control. Gate: Release 0/0; unit 5157, harness 695 (+10), SK 54, perf 3. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RM9gMsvo3SpYqEBENbaefo * Artifacts name their own arm, and Describe() finally does what it says THE DEFECT. Two Arithmetic runs differing only by a flag produced artifacts identical in every visible way -- same vertical, same seed, same shape. Telling Arm A from Arm B required a shell log, and by the time anyone asked the log was gone. An artifact that cannot name its own arm is not evidence; it is a number with its provenance kept somewhere else. No further paid run should happen while that is true. WHERE IT COULD NOT GO. The serialized report is AgentEval's ExternalBenchmarkResult and its Options is their fixed record with no extension point, so adding a field is not available. Rewrapping the JSON would break every existing reader of these artifacts. The filename and a sidecar are the two places that can carry the arm without touching a byte of the shape anyone already parses. PhaseThirtyFeatures.Describe() was DEAD CODE. Its own docstring said "run provenance and report file names"; a full grep found zero production callers -- the sixteenth ship-but-unreachable instance in this repository, inside the very type built to make arms legible. It is now reached from the filename path, which is what it was written for. TypedMemEvalArm is a SEPARATE type rather than more members on PhaseThirtyFeatures, and that is a deliberate boundary. That record means "engine features" and earns its keep by deriving the schema extensions those features need. Rescue and fact-weighting are harness-side retrieval levers with no DDL at all; folding them in would give that type members its Extensions property has to deliberately ignore, which is how a type stops meaning one thing. Two details worth keeping. The token is filename-safe rather than reusing Describe()'s "phase30:none" -- a colon is illegal in a Windows path, and a token sanitised at each use will eventually be sanitised differently at one of them. And the default arm is named "default" rather than left blank, because blank is indistinguishable from an artifact that predates provenance entirely. The sidecar records the parsed options and the commit, and captures NO environment -- this harness reads an endpoint, an API key and deployment names from the environment, and a provenance file that swept those up would put credentials in a directory whose purpose is being shared. A missing git SHA is written as absent rather than "unknown", because "unknown" reads as a value. Gate: Release 0/0; unit 5157, harness 707 (+12), SK 54, perf 3. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RM9gMsvo3SpYqEBENbaefo * Audit fixes R1-R9 + S1: the router's witness was reporting things that were not so An independent 31-agent audit found eight defects in the router I built, plus one in the working-memory seam. All confirmed, all fixed, each with the red test that demonstrates it. One more (R9) surfaced while writing the test the audit asked for. R7 FIRST, because it explains the rest. Every fan-out-through-assembler test mocked ILongTermMemoryService alone, while the assembler reaches retrieval through an internal IScoredLongTermSearch cast -- null for such a mock. So the enabled merge path had NEVER executed with a non-zero yield, in any test, ever. Writing that one test failed three ways immediately. R1 cross-arm accumulation. Each leg's merge was fed the ORIGINAL monolithic score list, so every leg but the last had its contributions silently discarded while the accumulators were dutifully written and never effectively read. Now the merge returns PAIRS and the state carries forward. R2 predicate-expansion deletion. Facts carry an unscored expansion tail by design (Items superset of Scored); merging on the score list deleted it, so a leg that found NOTHING made the context smaller. The tail is now held aside and re-appended under its own cap. R4 the witness lied twice. UniqueContributions and SurvivedBudget were the same variable, both computed before ApplyBudget -- one pre-budget prediction reported as two measurements, and the ship/no-ship metric reads both. SurvivedBudget is now measured AFTER truncation by intersecting contributed ids with what remains. R3 dead destinations. The affinity map published "messages" and "traces" while the loop had arms only for entities/facts/preferences, so Episodic and Procedural legs paid for a live embedding and could never retrieve anything -- reporting ItemsRetrieved=0, indistinguishable from an empty store. Both arms implemented (the scored interfaces already existed), message search session-scoped to match the monolithic path. A guard now pins the published set to the implemented set. R5 enum coercion. Enum.TryParse bitwise-ORs a comma list even for a non-[Flags] enum: "Semantic,Temporal" is 1|2 = 3 = Episodic, and IsDefined passes it -- a model hedging between two affinities was routed to a definite third store it never named. R6 zero validators on RecallFanOutOptions -- the fourth feature this phase to ship numeric options nothing validated, and the first found by someone else. R8 external-host inertness. A host implementing only the public seam can never satisfy the internal cast, so it paid embeddings per fired recall and merged nothing, silently. Now checked BEFORE deriving or embedding, voided as "provider-unsupported". R9, found while writing R7's test and not in the audit. needsScores is IncludeDiagnostics || AnnotateMatchQuality -- both false by default -- so the monolithic path went UNSCORED while the fan-out legs went scored, and the merge compared real cosine similarities against rank-derived placeholders. A leg at 0.72 outranked a monolithic row whose "0.7" only ever meant "third in the list". S1 unscoped rebuilds resolved the wrong owner. The rebuild used the CALLER's scope, so an admin call (MCP maintenance tools permit omitting userId) invalidated alice's fact -- matched by id, no owner filter, returned true -- and then rebuilt for OwnerId=null, leaving alice's block asserting the retracted value. The owner is now read from the MUTATED RECORD when the scope has none; scoped calls pay nothing. Two structural notes. The fan-out moved to its own partial file because AsOfRecallDivergenceTests establishes divergences by splitting the assembler source at AssembleContextAsOfAsync -- helpers below that line read as as-of code, and this block silently erased two documented divergences. Editing the expected list would have asserted something false. And the as-of path now VOIDS fan-out explicitly ("asof-not-supported") per design 5.5, which was never implemented. Gate: Release 0/0; unit 5171, SK 54, perf 3. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RM9gMsvo3SpYqEBENbaefo * Prove R4 empirically, and record the one LOW the audit said not to chase The R4 fix was verified by construction until now: the earlier test asserted survivors <= contributions, which the broken code also satisfied, since both counts were literally the same variable. This forces the budget to truncate and asserts a STRICT inequality -- survivors < contributions -- which the pre-fix code could not produce at any budget, because two reads of one variable are always equal. That is red by construction rather than red by observation, and it is stated that way. The LOW: caller-supplied legs with MaxSubQueries = 0 take an empty set, skip the leg loop, and report fired-with-no-legs and no VoidReason -- readable only by someone who already suspects the cap. Noted in code rather than fixed, per the addendum. Worth adding that R6's validator now rejects a zero cap at startup, so the state is unreachable through configuration and survives only for a caller building options in code. Gate: Release 0/0; unit 5172. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RM9gMsvo3SpYqEBENbaefo * Task 3: a run can finally say, from its own artifact, whether starvation happened The gap this closes: a TypedMemEval run could not confirm from its own output whether owner starvation occurred during it, which is why LongMemEval runs had to stand in as evidence for a TypedMemEval claim. The listener already existed; it was never attached to this program. It lives in the sidecar for the same reason the arm token does -- AgentEval's ExternalBenchmarkResult is a fixed 28-property record with no extension point, and rewrapping the JSON would break every existing reader. NULL means NOT MEASURED, and keeping that distinct from zero is the whole point. The oracle arm issues no vector search at all, so it gets null rather than a zeroed block that would read as "no starvation observed" -- a claim this listener never made. Two compile errors were found by READING this diff while the paid arm held the tools DLLs, rather than by building it: an anonymous type inside a conditional has no natural type to infer, so `cond ? null : new { ... }` does not compile, and the `using var` on a null-or-class conditional needed an explicit type. Both fixed before the first build, which then passed 0/0 on the first attempt. Gate: Release 0/0; unit 5172, harness 707, SK 54, perf 3. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RM9gMsvo3SpYqEBENbaefo * Live NAMS tests become opt-in, so a dead workspace stops failing the suite 29 integration tests failed every run with 503 {"error":"workspace_not_provisioned","status":"deprovisioned"} -- none of them describing a defect in this repository. The gate was asking the wrong question. It ran the live tests whenever NAMS_API_KEY and NAMS_DEV_WORKSPACE_ID were present, and those credentials outlived the workspace they pointed at: secrets sitting in a shell profile are not evidence that a service is up. Note the old skip message ("credentials not configured") could never have been printed in this failure -- the credentials were configured, which is precisely why the suite kept calling a service that no longer exists. What the suite actually needs to know is whether a person has deliberately pointed it at a live workspace they expect to answer, and only a person can say. So it is now opt-in: NAMS_LIVE_TESTS=1 (or true/yes) runs them, absent it they skip. A stale credential can no longer volunteer the suite into a call it cannot complete. The two skip reasons are reported separately -- "nobody asked for these" and "asked, but no credentials" are different states, and collapsing them is what made this confusing in the first place. Gate: Release 0/0; integration 483/483 excluding NAMS before this change, and the 29 now skip rather than fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RM9gMsvo3SpYqEBENbaefo * Pin AgentEval 0.27.0-beta: the four-vertical run's instruments live only there The four-vertical run is instrumented on required-evidence coverage at both boundaries, and under its own pre-registration §6.3 a run without those fields voids itself. On the 0.26 pin that verdict would have arrived after roughly eighteen hours of paid spend. Verified against the package bytes rather than the release notes -- 0.26 vs 0.27, counts of each instrument in AgentEval.Memory.dll: RequiredEvidenceSessionCount 0 -> 1 RequiredEvidenceSessionsRetrieved 0 -> 1 RequiredEvidenceSessionsInAnswerContext 0 -> 1 no_answer_captured 0 -> 6 empty_rate_by_arm 0 -> 7 A note on how that was checked, because the first attempt produced a confident wrong answer: `strings` is not installed here, so it reported all three fields ABSENT from 0.27 -- a missing tool reading exactly like a missing feature. Raw byte grep, sanity-checked against a string the assembly must contain, showed all five present. A negative result from an absent tool is not a measurement. No probe re-trigger: both independent verifications of 0.27 are done and agree (package-bytes decode + tagged-tree check), and those clearances carry. Gate: Release 0/0; unit 5172, harness 707, SK 54, perf 3. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RM9gMsvo3SpYqEBENbaefo * Pin 0.28.0-beta, and close a comparability check that passed by absence Two changes, one theme: a check that cannot fail in the direction that matters is not a check. PIN. 0.27.0-beta -> 0.28.0-beta before the four-vertical run starts. Results would be identical on either -- the corpora and coverage fields are byte-identical and only the empty-rate metadata was corrected -- but the sidecar should stamp the version whose statistics we cite, and the sidecar is the artifact a future session will read. Instruments re-verified against the 0.28 package bytes, all five present. LEDGER. PerfLedgerCommand.ValidateComparable asserted only that every scenario the CANDIDATE carries exists in the target. The reverse went unchecked, so a run that silently stopped emitting a scenario compared clean on the ones it kept -- passable by absence, hiding in the direction that flatters the run, and the ledger exists precisely to compare like with like across time. Now asserts set equality and names the dropped scenarios. The red test earned its keep: it failed with "expected InvalidDataException, but no exception was thrown" -- a baseline carrying PERF-R-04 and PERF-R-05 compared happily against a run that had quietly lost PERF-R-05. Same shape as the NAMS gate fixed earlier today, and worth naming: both asked a question whose "yes" was cheap (are the candidate's scenarios present / are secrets present) instead of the question that mattered (is the comparison complete / is the service up). Gate: Release 0/0; unit 5173, harness 707, SK 54, perf 3. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RM9gMsvo3SpYqEBENbaefo * --evidence-detail on typedmemeval: ask whether the VALUE was rendered, not just its session The verb hard-coded EvidenceDetail = Identifiers, so AnswerContext[].Content came back null on all 965 items of the Bitemporal run. That made one question unanswerable from any artifact: was the needed VALUE rendered? Only "was its SESSION covered" could be asked, and those are different questions -- Bitemporal's misses turned on exactly the gap between them, with session coverage at 0.958 and eleven of thirteen answers never surfacing the correct value. identifiers stays the default, deliberately: it is what the verb did before this flag existed, so every sealed measurement remains comparable. content is opt-in because it costs artifact size. The drift guard did its job again -- adding an advertised option without carrying it on the options record fails EveryAdvertisedOptionIsCarriedOnTheOptionsRecord, which is the third time this session that guard has caught a half-wired flag. Gate: Release 0/0; harness 707. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RM9gMsvo3SpYqEBENbaefo * --supersede-replaced-facts: the lever the Bitemporal vertical needed and never had SupersedeReplacedFacts defaults false and had ZERO harness references, so the vertical whose entire subject is supersession was measured against an append-only store: no fact ever carried invalidated_at, no :SUPERSEDED_BY edge was ever written, and 0.783 recorded the feature's off-state rather than its performance. That is the same shape as RescueShortOwnerResults one wave earlier -- the option aimed squarely at the measured failure mode was the one thing no run could set. The profile comment now says so beside both. Off unless asked for, so every sealed measurement keeps the path it was taken under. The arm token carries it, because an ON artifact and an OFF artifact would otherwise be indistinguishable -- the exact problem the provenance work existed to end. Two guards earned their keep. The drift guard caught the flag before it was carried on the options record (fourth time this session). And the arm-token tests caught that inserting SupersedeReplacedFacts ahead of FactWeightedBudget silently re-slotted every positional construction: "factwt" quietly became "supersede". Those tests now use named arguments so the next inserted lever cannot repeat it. Gate: Release 0/0; unit 5173, harness 707. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RM9gMsvo3SpYqEBENbaefo --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 27, 2026
Open
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.
What this closes
The working-memory design named
MergeEntitiesAsyncin §5.2 and build step 6 as one of four direct rebuild seams. It was never built — and an independent review found it, not the green suite, because every working-memory test calledRebuildAsyncdirectly. Testing the thing rather than its trigger cannot detect a trigger wired to the wrong path.This is 1 of the 5 seams that review found. Four remain open and are listed at the bottom.
Why a decorator rather than an epilogue
Every other rebuild seam hangs off a service.
MergeEntitiesAsynchas none — it isIEntityRepository-only, public, and TCK-Gold exposed, so calling the repository directly IS the usage pattern. A service-level hook would have been bypassed by every caller that exists.So the seam goes on the interface:
WorkingMemoryEntityRepositoryDecoratorwraps whateverIEntityRepositoryis registered, rebuilds after a merge that actually matched, and forwards the other 21 members untouched.Registered unconditionally and self-gated, following the reranker pattern in the same file. Gating the registration would freeze the tier at container-build time and break
IOptionsreconfiguration — the exact defect that comment warns about.A defect this PR found in itself
The first draft got lifetime wrong. The decorator inherits the repository's
Transientlifetime whileIWorkingMemoryServiceisScoped, and the DI factory resolved it eagerly — which madeIEntityRepositoryunresolvable from the root container. A scoped-from-root violation, a regression in a surface that resolved fine before, and invisible to all fourteen tests because every one of them calledCreateScope(). A host withValidateScopeson would have failed at startup.Now resolved through
IServiceScopeFactory(a singleton, safe from root), lazily, and only once a merge has actually matched with the tier enabled; the rebuilder owns that scope and disposes it. A disabled tier never constructs a rebuilder or touches the container at all.Bonus: one failure policy replaces three
The rebuild failure policy was already written out twice and had drifted — the identical clear-also-failed branch logged at
ErrorinLongTermMemoryService("because nothing else can notice it") and atWarninginPersistenceStage. This change needed a third copy. Instead there is now one sharedWorkingMemoryRebuilder, and both existing sites delegate to it.Two details worth their code
Verification
15 tests, all driving
MergeEntitiesAsyncrather thanRebuildAsync. The reachability guard was verified red-first by unwiring the registration (2 of 3 failed), because a decorator's unit tests construct it directly and stay green whether or not any container ever produces one. The captive-dependency guard was also watched failing with the real scoped-from-root exception before the fix.Local full-integration runs were repeatedly killed by the environment mid-run, which is why the post-fix evidence is the 204-test repository layer — the entire surface this change touches — rather than the full 479. CI runs the complete suite.
Still open (30.4b seams 2–5)
AddEntityAsync+ MCPmemory_add_entity· the threeInvalidate*paths + MCPmemory_invalidate·DeletePreferenceAsync·RecordEntityFeedbackAsync(benign).The
Invalidate*group is the most important: supersede is hooked and its exact twin is not, and every block query filtersinvalidated_at IS NULL, so an invalidated item keeps asserting a retracted value until an unrelated write happens.The tier ships off by default, so nothing in production is affected today.
🤖 Generated with Claude Code
https://claude.ai/code/session_01PgDgctPpbTziBNE2RT8VdE