Skip to content

fix(state): resolve memory scopes most-specific-first when a recall widens - #438

Merged
ion-alpha-dev merged 3 commits into
mainfrom
memory-scope-resolution
Jul 31, 2026
Merged

fix(state): resolve memory scopes most-specific-first when a recall widens#438
ion-alpha-dev merged 3 commits into
mainfrom
memory-scope-resolution

Conversation

@ion-alpha-dev

@ion-alpha-dev ion-alpha-dev commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

What

Memory recall can now resolve up the instance/project/workspace hierarchy instead of matching one exact scope. RecallQuery.IncludeAncestors widens a scoped recall to that scope's enclosing scopes, most-specific first.

  • Scope.Ancestors is the enclosing chain; Scope.Depth ranks specificity.
  • RecallQuery.ScopeChain and state.SortRecall are the one filter and one sort all three MemoryStore implementations now resolve through.
  • state's own memory store joins the memorytest conformance suite.

Why

Scope's doc comment says memory is "resolved most-specific-first". Nothing implemented that. Both stores compared the whole struct for equality (state/memory.go on it.Scope != q.Scope, storage/sqlite on a three-column predicate), so a fact written at {Project: X} was invisible to a reader at {Project: X, Workspace: W}. An agent running workspace-under-project recalled only what that exact workspace had written, which is to say nothing worth having: the durable general facts are exactly the ones written at the outer scope.

Widening is opt-in rather than the new default. Exact matching is a partitioning guarantee readers rely on, and memorytest already pinned it: a scoped recall must not see the enclosing scope's items. Making every read hierarchical would trade one wrong behaviour for another, so a caller says at the call site that it wants to inherit.

SkillStore.List matches scopes exactly for the same reason and is deliberately left alone; the divergence is now documented on Scope instead of being an accident. Memory went first because it is the read that runs on every turn.

How to verify

go test -race ./state/... ./memory/... ./storage/sqlite/...
golangci-lint run ./state/... ./memory/... ./storage/sqlite/...

The new memorytest ScopeResolution case is the contract: it covers the ancestor walk, the sibling scope a widened read must never reach, the skipped empty level, limit applied after resolution, and the zero scope still meaning "unfiltered". It fails against exact matching in both backends, confirmed by forcing the chain back to a single scope, which reds TestConformance/ScopeResolution and TestMemoryFacadeConformance/ScopeResolution.

Three new property tests cover the chain invariants: starts at the scope, ends global, no repeats, strictly decreasing Depth; ancestors only ever widen; a widened recall extends the narrow one without reordering it.

Notes for reviewers

  • Ordering only changes for reads that widen. An unfiltered or single-scope recall keeps the exact most-recent-first order it returned before. RanksByScope is false for both, and SortRecall falls straight through to the recency comparison.
  • Depth reads the innermost set field rather than counting set fields. Counting ties two levels of a chain whose outer field is empty ({Project: "x"} and its global parent). Reading the innermost keeps Depth a total order over any chain, which is what lets SQLite express the identical ranking as an argument-free ORDER BY CASE.
  • SQLite orders in SQL, not after collection, or LIMIT truncates the wrong rows. A widened read also falls off the prepared memoryRecall statement, since that binds exactly one scope triple; the built query is the only shape that can express a variable-length chain.
  • Additive on the wire: RecallQuery's zero value behaves exactly as before, so no existing caller changes.
  • state's MemoryStore and the SQLite provider's own memory store were the two implementations of the interface the conformance suite never ran. That gap is how the same bug could sit in three places without a test noticing; both now run the same suite.

…idens

state.Scope documents the instance/project/workspace axis as one on which memory
is "resolved most-specific-first", but no store did that. Both MemoryStore
implementations compared the whole struct for equality - state/memory.go on
`it.Scope != q.Scope`, storage/sqlite on a three-column predicate - so a fact
written at {Project: X} was invisible to a reader at {Project: X, Workspace: W}.
An agent running workspace-under-project therefore recalled only what that exact
workspace had written, and the general facts worth keeping were unreachable.

The widening is opt-in via RecallQuery.IncludeAncestors rather than the new
default. Exact matching is a partitioning guarantee existing readers rely on
(memorytest already pinned it: a scoped recall must not see the enclosing
scope's items), so silently widening every read would trade one wrong behaviour
for another. A caller states at the call site that it wants to inherit.

The resolution rule lives in state and every backend resolves through it:

- Scope.Ancestors walks the enclosing chain, most-specific first, skipping a
  level whose field is empty rather than inventing one, so {Project: "x"}
  resolves straight to the global scope with no phantom instance in between.
- Scope.Depth ranks specificity by the innermost set field. Counting set fields
  would tie two levels of a chain whose outer field is empty; reading the
  innermost one keeps Depth a total order over any chain, which is what lets
  SQLite express the same ranking as an argument-free ORDER BY CASE.
- RecallQuery.ScopeChain and SortRecall give the three implementations one
  filter and one sort instead of three that drift apart.

Ordering by scope applies only when the read actually spans levels, so an
unfiltered or single-scope recall keeps the plain most-recent-first order it has
always returned. SQLite orders in SQL rather than after collection, because
LIMIT would otherwise truncate the wrong rows; it also falls off the prepared
statement for a widened read, since that statement binds exactly one scope
triple.

memorytest gains a ScopeResolution case covering the walk, the sibling scope it
must never reach, the empty-level skip, limit-after-resolution, and the zero
scope still meaning "unfiltered". It fails against exact matching in both
backends. state's own MemoryStore now runs that suite too: it was the one
implementation of the interface the conformance suite never covered, which is
how it could resolve scopes differently from the others unnoticed.

SkillStore.List has the same exact-match behaviour and is left alone, documented
on Scope rather than changed, since memory is the read that runs every turn.

Signed-off-by: Ion Alpha <contact@ionalpha.io>
@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.98496% with 8 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
memory/memorytest/memorytest.go 84.61% 4 Missing and 4 partials ⚠️

📢 Thoughts on this report? Let us know!

Patch coverage on the scope-resolution change came in under the gate, and the
uncovered lines were all in one shape: an assert-and-fail block repeated at
every assertion, whose t.Fatalf branch never runs on a green build.

Collapse them into four helpers (write, recall, wantOrder, wantCount) so each
assertion in the suite is a single call and the failure branches live in one
place. That is better to read as well as to measure - the suite now states what
it expects rather than how it checks.

Two real coverage gaps came out of it, both the same kind of gap the scope bug
itself came from - an implementation nothing held to the shared contract:

- The SQLite provider's own memory store (the FTS-backed one behind
  state.Provider, distinct from the resource-backed facade) never ran the
  MemoryStore suite. statetest exercises its CRUD but never a recall that
  resolves across scope levels, so the SQL that widens one had nothing checking
  it. It now runs memorytest.RunSuite like the other implementations.
- The facade's widened read walks one List per ancestor, and a backend outage
  inside that walk was untested. TestRecallBackendErrorsPropagate now covers the
  widened path alongside the scoped and scope-spanning ones.

Patch coverage 96.4%; what remains is the four helpers' own failure branches.

Signed-off-by: Ion Alpha <contact@ionalpha.io>
@ion-alpha-dev
ion-alpha-dev merged commit ca68ac4 into main Jul 31, 2026
26 checks passed
@ion-alpha-dev
ion-alpha-dev deleted the memory-scope-resolution branch July 31, 2026 18:37
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 31, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant