feat: make the context-memory ACL switchable from the operator UI - #903
Merged
Conversation
W5 (#881) shipped the chat-context memory ACL behind `agents.context_memory`, a column with no UI and no API. The only way to enable it was a hand-written UPDATE, which left the whole wave inert in practice. API: GET/PUT /api/v1/operator/agents/:slug/context-memory on the existing operator-agents router. PUT validates against the same value list as the CHECK constraint of migration 0050, rejects an unknown mode with 400 rather than coercing it to `off`, logs the change under `[security-audit]`, and reloads the registry so the next turn is already scoped. Deliberately NOT a field on the rename/enable PATCH: a memory-scope change must not ride along with an unrelated edit. No schema change. UI: a control on the agent detail page. Switching away from `off` surfaces the three semantics an operator needs first (team tier read-write, agent tier read-only, API turns agent-private only) and requires an explicit acknowledgement; switching back to `off` does not, because the safe direction must never be harder than the unsafe one. Also fixes the one place the threading could silently regress: `dispatchTool`, `dispatchToolDeadlined` and `dispatchToolInner` took the `TurnMemoryBinding` in an optional position, so a call site that forgot it compiled cleanly and fell back to the agent-global stack at runtime. Verified by dropping the argument — it passed `tsc`. Now required (`| undefined`), runtime unchanged. Adds the first integration coverage of the binding: real `runTurn` and `chatStream` turns with a Teams TurnOrigin, asserting the physical write path. Refs #899, part of #860
…ting
The `as TurnOrigin` casts hid a malformed scope: `{ kind: 'channel', id }` is
not a member of the `ScopeId` union — the real kind is `conversation`. The
cases still passed, but through the TEAM axis derived from the container,
never through the channel tier they claimed to exercise, and the
`typecheck:test` ratchet rejected the casts as new errors.
Fixtures are now built structurally with no cast, so a malformed origin is a
compile error rather than a silent degradation to context-free — which would
turn these into assertions about the fallback instead of about the threading.
Mutation check re-run against the corrected fixtures: all seven still turn red
when the binding is dropped at the tool-loop call sites.
Refs #899, part of #860
Weegy
force-pushed
the
feat/context-memory-flag
branch
from
August 27, 2026 13:29
8fbc158 to
4a51d2e
Compare
# Conflicts: # docs/CHANGELOG.md
This was referenced Aug 27, 2026
Closed
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.
Closes nothing —
Refs #899, part of #860.W5 (#881) shipped the chat-context memory ACL — the scoping, the
/memories/contexts/tree, the promote route and its audit log — behindagents.context_memory, a plain column with no UI and no API endpoint. The only supported way to enable it was a hand-writtenUPDATE, which left the whole wave inert. This adds the operator surface, and reads the one part of the wave that shipped with a stated residual risk.No schema change. The column and its CHECK constraint already exist (migration
0050).What the route can do
On the existing operator-agents router (
{ ok: true }envelope,requireAuthfrom the parent mount). The union is taken from the code, not from the docs:ContextMemoryModeinmemoryBinder.tsand the CHECK constraint in0050both spelloff | enforce | enforce-strict, and a test pins the route's list against the migration file so the two cannot drift.Four deliberate choices:
PUTrejects an unknown mode with400 invalid_bodyrather than coercing it tooff. A security switch that reports success while writing nothing is worse than an error. An untouched agent is auditable; one silently reset tooffwhile the UI showsenforceis not.GETnarrows deny-default. A value written by a newer middleware during a rolling deploy reads back asoff— the same ruleparseContextMemoryModeapplies in the orchestrator. The UI must never show "enforcing" for a value the runtime routes as off.registry.reload(), so the next turn is already scoped. Without it the switch would only take effect on the next process restart — the shape of bug that reads as "it did nothing" in production. The mode change is logged under[security-audit].PATCH /:slug. That handler is the dashboard's rename/enable form and sends whatever it holds; folding a memory-scope change into it would let an unrelated edit carry one along. A test pins that a rename leaves an enforcing agent enforcing.ConfigStore.updateAgentgrew acontextMemoryfield on its patch type; the UPDATE usesCOALESCE, so a patch that does not mention the flag can never reset an enforcing agent.What the UI can do
A Chat-context memory section on the agent detail page, next to the other per-agent settings. Radio group, save button, localized error banner.
Switching away from
offsurfaces the three semantics before the operator can commit, and requires an explicit acknowledgement:Switching back to
off, and tighteningenforce→enforce-strict, need no acknowledgement: the safe direction must never be harder than the unsafe one. Changing the selection re-arms the gate, so an acknowledgement given for one mode cannot unlock a save of another.The mode list is intersected with the server's
modesarray — the server can take a mode away, but it cannot add one, since a mode this bundle has no label for cannot be rendered. Everything from the wire is narrowed throughparseContextMemoryModebefore it reaches state; a component that renders a security state should not inherit its trust from a type annotation. (That last point was not planned — the test asserting it failed first, against a component that trusted the DTO.)i18n: all strings through the catalogue, EN + DE complete,
npm run i18n:checkgreen.orchestrator.tsfinding — theTurnOriginthreadingThe W5 integration named one residual risk: the binding is threaded through ~8 signatures in a ~7300-line file, covered by tests but never exercised by a real streaming turn. I read that path. Verdict: the threading itself is correct — and now proven, not asserted — but it had one latent defect, which is fixed here, and it has two boundaries outside the orchestrator where the flag buys nothing.
The threading is sound
The binding is created exactly twice, once per turn entry point, both after
inputis final:runTurnCore(orchestrator.ts:3457) andchatStreamInContext(:5106). It travels as a required parameter throughexecuteDirectLine(:3583),chatInContext(:4124),chatInContextInner(:4234),chatStreamInner(:5297) andprepareStreamSlot(:6203) down todispatchToolInner:6782:Every one of the four dispatch call sites passes it — the non-streaming tool loop (
:4664), the streaming slot (:6216), sub-agent delegation via Direct Line (:3739), and the deadline/retry wrappers (:6436,:6446,:6449). The system prompt gets the matching treatment on both paths (:4415,:5539), so the model is never told about/memories/~team/on a turn where that path is not mapped. The_rulesdecorator sits inside the binder's own stack (memoryBinder.ts:227), not on a separate orchestrator-side path. I found no path on which a turn carrying an origin reaches the agent-private handler.Fail-closed is real and layered:
mode: 'off'discards the origin in one branch before axes are resolved (memoryBinder.ts:162-165); an absent origin,unscoped,system, a channelType outside{teams, telegram, http, api}, or unusable patterns all resolve context-free (turnOrigin.ts:233-248,scopedMemoryStore.ts:195-220); a binder that throws is caught and degrades to the agent-private handler with a[security-audit]log (orchestrator.ts:3277-3287); and the shared trees are granted asro:core, notcore, so/memories/core/notes.mdis not a one-line bypass.Finding → fix: the binding could be dropped without a compile error
dispatchTool(:6316),dispatchToolDeadlined(:6375) anddispatchToolInner(:6753) took the binding in an optional position (turnMemory?: TurnMemoryBinding), while all six other signatures on the path take it in a required one. A call site that simply forgot the argument therefore compiled cleanly and fell back tothis.memoryToolHandler— the agent-global stack — at runtime. That is precisely the silent scope widening the wave exists to prevent, and the code comment two lines above calls it "a structural impossibility".Verified rather than assumed: I deleted
turnMemoryfrom the two tool-loop call sites andtscpassed. All seven cases of the new integration test then turned red.Fixed by making the three parameters required (
TurnMemoryBinding | undefined). Zero runtime change — every existing call site already passed all four arguments, so the build was clean on the first try. The same mutation now failsnpm run typecheckwith exit 2. A future call site that forgets the binding is a compile error, not a leak.New: the first integration coverage of the binding
middleware/test/orchestrator/contextMemoryTurnBinding.test.ts— 7 cases. Every W5 suite so far stopped atMemoryBinder's handler; none constructed anOrchestratoror ran a turn, andcontextBoundappeared nowhere inmiddleware/test/. These build a real orchestrator with a scripted provider that calls thememorytool, drive bothrunTurnandchatStreamwith a TeamsTurnOrigin, and assert the physical path recorded at the undecorated root store:/memories/contexts/<slug>/…and not in the agent-global tree — buffered and streaming;enforceandenforce-strict;mode: 'off'ignores a present origin entirely (the byte-identical rollout default that makes this UI safe to ship);channelTypenarrows instead of throwing.The harness deliberately wires a build-time
memoryToolHandlerover the raw root, so a lost binding would still succeed and write to the wrong place — which is what makes the assertions load-bearing. All seven were confirmed red under mutation before being reported green.The streaming case is the one that mattered: an async generator is resumed in the async context of whoever calls
.next(), which is exactly how an earlier wave silently lostturnContexton every streaming turn. It passes.Two boundaries where the flag buys nothing (pre-existing, not fixed here)
Both are older than W5 and outside this PR's scope, but an operator flipping the switch should know, so they are now in the doc's "known limits" list:
claude-cliprovider.buildOrchestrator.ts:523-545returns aCliChatAgent, not theOrchestrator, sobindTurnMemorynever runs andoriginis never read. Tools reach memory throughToolDispatchService, which has no memory branch — the divergence is already flagged attoolDispatchService.ts:568-571("scoped-memory shadowing"). Settingenforce-stricton a CLI-provider agent changes nothing.memorytool.adaptNativeToolForSubAgent(src/agents/subAgentToolHydration.ts:189-208) returnshandle: (input) => handler(input)over the process-wide raw handler. The parent's dispatch carries the binding correctly, but the sub-agent's own loop calls the adapted raw handler — writing into the undecorated store, with no agent namespace and no context tier. This is reachable through supported operator configuration (an Agent Builder tool grant) and defeats both the W5 context ACL and the pre-existing per-agent isolation. Worth its own issue; the minimal fix is to denymemoryinresolveSubAgentToolsuntil sub-agents can be handed a turn-bound handler.One boundary worth stating rather than fixing: the knowledge graph is not partitioned by context.
maybePromoteTurnstampsoriginAgent, not a context key, so a fact auto-promoted from a team-A turn is recallable in team B for the same agent even underenforce-strict. That may well be an accepted boundary like the session-transcript decision (A3a), but "full quarantine" is not literally true of KG-derived recall. Relatedly,enforcemeans context turns can no longer author durable_rulesat all (ro:corecovers top-level_*) — a real behaviour change, correctly implemented, and now worth an operator-facing line.Docs
docs/teams-multi-agent-identities.md§8 — the "there is no UI and no API endpoint, set it in the database" note is replaced with where the switch is, what the routes do, and why the mode is not on the rename PATCH; the known-limits list swaps the "no switch" entry for the two boundaries above; the file map gains the route, the component and the new test.docs/CHANGELOG.md— an Added entry for the switch and a Fixed entry for the threading defect.Gates
middleware:npm run buildmiddleware:npm run typecheckmiddleware:npm run lintmiddleware:npm run typecheck:testmiddleware:npm testnot okacross 12 348 lines of TAPweb-ui:npm run lintweb-ui:npm run typecheckweb-ui:npm run testweb-ui:npm run i18n:checkNew tests: 10 router cases, 7 orchestrator integration cases, 11 UI cases. No test pollution encountered — the full runs were green on both sides.
origin/mainmoved while this was in flight (#902); merged in, with the expecteddocs/CHANGELOG.md[Unreleased]collision resolved by keeping both entries. All gates re-run after the merge.