feat: chat-context memory ACL — per-team/channel/user agent memory with fail-closed scoping - #881
Merged
Conversation
W5 memory-ACL (#860, design #870 §3) needs one partition key for every chat context tree: /memories/contexts/<slug>/<axis>/<ctxKey>/, the team:/channel:/ user: scope patterns, the purge selectors and the promote route. This is that single choke point. The construction is scopeGraphKey's (#575 D3), with a 64-character budget: an id that already matches /^[a-z0-9_-]{1,64}$/ is carried through byte-identically, anything else keeps a readable sanitized stem plus a 16-hex sha256 digest of the RAW input. Plain sanitizing is not injective — a Teams id (19:abc@thread.tacv2) collapses onto the literal 19-abc-thread-tacv2 and onto every sibling that differs only in punctuation, which stops being a recall nuisance and becomes a cross-team memory leak the moment the key is a security boundary. The channel type is normalised (trim + lowercase) because it is a type token; the native id is hashed byte-exact because it is identity, and over-partitioning is the safe direction. Both halves go through the same safe-segment function, so the key can never carry a ':' that would break the team:<ctxKey>:* pattern, and '~' — outside the safe alphabet — makes the split back into type and id unambiguous.
…eam/~agent segments Adds the context-scoped variant of the orchestrator memory bijection needed by the chat-context memory ACL (epic #860, design #870, wave W5). `ContextMemoryNamespacer` is the existing `/memories` <-> physical-tree bijection with a per-CONTEXT private root: the model's bare `/memories/...` maps to the narrowest tier of the turn (channel or user), while two reserved model-facing segments address the wider tiers: /memories/~team/... -> /memories/contexts/<slug>/team/<teamKey>/... /memories/~agent/... -> /memories/orchestrators/<slug>/... The `~` prefix is collision-free by construction: the namespacer never emits a `~` segment outward, so no pre-existing physical path can clash. The shared-segment passthrough (core, sessions, chat-sessions, `_*`) is unchanged, and `contexts` is deliberately NOT a shared segment. A reserved segment with no bound root (e.g. `~team` on a turn without a team axis) is left in the outer namespace, where it matches no compiled pattern and the `ScopedMemoryStore` denies it — enforcement stays in the store, the mapper never throws, and a rewrite bug still surfaces as `MemoryScopeViolation`. The class is parameterized by plain root strings (`privateRoot`, `teamRoot`, `agentRoot`) on purpose: the axes -> roots translation belongs to the `MemoryBinder`, so this mapper carries no channel-SDK dependency. `OrchestratorMemoryNamespacer` keeps its exact signature and behaviour; both classes now share one base implementation. The existing `orchestratorMemoryNamespacer.test.ts` suite is untouched and still green.
…mory tiers W5 (design spec #870 §6, epic #860). Context-scoped memory keeps team A's knowledge out of team B, so sharing across that line is never implicit — it is this one operator action, and it is audited three ways: (a) an append-only JSONL line in /memories/core/audit/memory-promotions.jsonl ({ts, agentSlug, actor, mode, sourcePath, targetPath, reason, bytes}), inside the shared core namespace so agents can read it; (b) promoted-from / promoted-by / promoted-at frontmatter in every promoted markdown file (structured payloads stay byte-identical); (c) a structured [security-audit] log line, the buildOrchestrator idiom. Runs on the ROOT (undecorated) MemoryStore, exactly like memoryPurge: promotion crosses the scopes a ScopedMemoryStore enforces, so it cannot run inside one. It stays backend-agnostic for the same reason — only the existing list/fileExists/ directoryExists/readFile/writeFile/delete surface is used, so the filesystem and Postgres stores need no schema change (§7). Both tier roots are built from the same agentSlug, so promotion is structurally per-agent (§9: never cross-agent). Anything that would escape those two roots — a traversal segment, a slash inside a context key, an absolute path — is REJECTED, never clamped. Context keys are validated against the memoryContextKey shape; they are derived at the route boundary, never re-derived here. Conflicts are detected before the first byte lands (target files must not exist unless overwrite is set), and a move deletes the source only after every write succeeded, so a rejected promotion leaves both tiers untouched. Test: middleware/test/memoryPromote.test.ts — copy/move of files and of a subtree deeper than the two-level list walk, provenance merge into an existing frontmatter block, one audit line per promotion, non-markdown payloads left byte-identical, and the rejection matrix for every target outside the agent.
The team/channel/user purge axes existed but had no scratch footprint at
all: `resolvePurgeTargets` returned `[]` for them because scratch memory
was agent-scoped. The chat-context memory ACL introduces
`/memories/contexts/<agent>/<axis>/<ctxKey>`, so those axes now have
something to delete.
- `axis: 'team' | 'channel' | 'user'` deletes exactly the named context
tree across EVERY agent, enumerated via `store.list('/memories/contexts')`.
The isolation axis is agent x context, so a context purge has to cross
the agents rather than address one. list + delete only, so it stays
backend-agnostic across the filesystem and Postgres stores.
- `axis: 'agent'` additionally takes the agent's whole context forest.
- `axis: 'all'` picks `contexts` up for free: ordinary scratch, and
deliberately not added to `PROTECTED_SEED_ENTRIES`.
- New `memoryContextKey(channelType, nativeId)` in the channel SDK, next
to `scopeGraphKey` and built the same way: injective via a sha256 digest
of the raw id, byte-identical (and therefore idempotent) on an
already-safe one. An operator may type the raw native id or the derived
key; there is one sanitiser, not two.
Preview counts targets, so an agent that holds context trees now previews
as 2 and a team present in three agents previews as 3. Preview and execute
share `resolvePurgeTargets`, so the Danger Zone shows the number the delete
acts on.
The route's team/channel warning read backwards once the trees existed
("only scratch memory is affected", when nothing was affected). It now
names the untouched half - the Knowledge-Graph - without inventing a KG
filter for axes that have no KG column. Server-side type-to-confirm still
guards the selector the operator typed, never the derived ctxKey.
W5 memory ACL, first half of #871 (design spec #870 §3). - `compilePattern(pattern, agentSlug)` takes the agent slug as a second parameter and gains three chat-context tokens: `team:<ctxKey>:*`, `channel:<ctxKey>:*` and `user:<ctxKey>:*`, mapping to `/memories/contexts/<agentSlug>/<axis>/<ctxKey>/`. - New `ro:<pattern>` access modifier: the wrapped pattern grants read / list / exists only; write, delete and rename raise `MemoryScopeViolation`. - `ScopedMemoryStore.allowed()` splits into `allowedRead()` / `allowedWrite()`; `ro:` patterns count for reads only. Reads stay soft (list filters, exists returns false, explicit read throws), writes stay hard — unchanged behaviour for every pre-existing token. - Context trees deliberately live under the new top-level segment `/memories/contexts/` rather than under `/memories/orchestrators/<slug>/`, so `orchestrator:<slug>:*` cannot reach them and no context scope can reach the agent tree. `orchestratorMemoryScope()` is untouched. `effectiveMemoryScope` is intentionally not part of this change — it needs the `MemoryAxes` type from the channel SDK and ships as its own unit. Tests: middleware/test/scopedMemoryStore.contexts.test.ts — token matrix per axis (exact root, child, neighbour key, neighbour axis, neighbour agent, legacy agent tree) for read and write, the `ro:` modifier, collision-freedom in both directions, and compatibility of the legacy grammar.
…ry-ACL) Agent memory is isolated per agent today but not per chat context: what an agent learns in Teams team A lands in the agent-global tree and is quotable in team B on the next turn. Closing that hole needs a typed statement of where a turn came from, carried on the one contract every channel adapter shares. - `src/turnOrigin.ts` (new): `TurnOrigin` (channelType + `ScopeId` from #575, optional container and `Principal` from #333), `MemoryAxes`, and the pure `memoryAxesForOrigin` that translates an origin into the scope patterns of design #870 §3 — one branch per row of the §2 tier table. - `src/chatAgent.ts`: one additive optional field, `ChatTurnInput.origin`. An older channel plugin that sends nothing resolves to the context-free axes, so there is no flag day. Fail-closed throughout: a missing origin, an `unscoped` or `system` scope, a channel type with no §2 row, or a blank identity all return the frozen context-free axes — row 1 of the table, byte-identical to today's behaviour, reaching no context tree. Guessing a context is the unsafe direction; refusing one is not. Context keys come exclusively from `memoryContextKey`; there is deliberately no second sanitizer, because a non-injective key collapse is what turns a recall nuisance into two teams sharing one memory tree. Test: middleware/test/memoryAxesForOrigin.test.ts — the §2 table as cases (28 assertions), including team A/B isolation and every fail-closed path.
…il-closed Agent memory is isolated per agent today but not per chat context: what an agent learns in Teams team A lands in the agent-global tree and is quotable in team B on the next turn. `effectiveMemoryScope` is the per-turn resolver that closes that hole (design #870 §2, §4 step 7): it intersects the static agent scope with the turn's dynamic context axes and emits the scope tokens the grammar compiles. scope = axes.isContextFree ? ['core', `orchestrator:${slug}:*`] // exactly today : ['core', `ro:orchestrator:${slug}:*`, …axes.patterns] Three properties, each failing in the safe direction: - Fail-closed. A missing origin, an `unscoped` scope and an unknown channel type all arrive as `isContextFree: true` and take row 1 of the §2 table — agent-private, no context tree reachable. The branch delegates to `orchestratorMemoryScope` rather than re-spelling it, so the golden comparison holds by construction instead of by test. - The agent tier is read-only from context turns. Without the `ro:` modifier, "note this globally" in team A would be a permanent leak channel into team B. New knowledge leaves a context only via the operator promote action. - Never a throw on the message path. A malformed `axes` is a channel-plugin bug, not a reason to drop a user's turn; it degrades to the agent-private scope and says so in the log. `axes.patterns` is allowlisted against the three context tiers rather than passed through, because it crosses a package boundary from an independently versioned channel plugin: `'core'`, `'orchestrator:<other-agent>:*'` or a raw path would otherwise WIDEN the turn instead of narrowing it. Everything outside `team:`/`channel:`/`user:` is dropped and logged; a context turn left with no usable tier takes row 1 too. enforce-strict (§10 Q3, settled by the coordinator) drops the agent tier from context turns entirely and audits every unresolvable origin with the reason it was refused — 'axes-missing' and 'no-usable-context-pattern' are different bugs in different producers, and collapsing them would make the audit line useless. Emitting `ro:` before `compilePattern` understands it is deliberate and safe: an unrecognised token is soft-denied, so the interim behaviour is narrower than the target, never wider. `ScopedMemoryStore` stays the backstop throughout. `MemoryAxes` is declared here as a structural mirror of the channel SDK's type, not a second definition of it — the canonical type and its only producer (`memoryAxesForOrigin`) land in the sibling SDK change, and the swap to `import type { MemoryAxes } from '@omadia/channel-sdk'` is a one-line diff. Part of #860, implements design #870.
W5 (#860, design spec #870 §6) — the HTTP surface for the one explicit operator act that moves knowledge across an agent's context boundaries. POST /:slug/memory/promotions run a copy/move between two tiers GET /:slug/memory/promotions read that agent's promotion audit log Factory only: the index.ts mount and startup log belong to the wiring unit. Recommended mount is /api/v1/operator/agents behind requireAuth — the same cookie-session gate memoryPurge documents, on the prefix operatorAgents.ts already owns for per-agent operator actions. The spec writes the path as /api/agents/:slug/memory/promotions, a surface this repo does not have; keeping the resource shape on the existing gated prefix reconciles both halves without a third top-level API surface. Router form follows memoryPurge: zod body schema, 400 invalid_request carrying error.issues, an audit-write failure logged but never allowed to mask a promotion that already landed (200 + warning, from the receipt the service attaches to audit_write_failed). Service error codes map to 404 source_not_found, 409 target_exists/target_is_directory, 400 for the validation family, 500 otherwise. The audited actor comes from the session (omadia_user_id ?? sub, the uiPrefs.ts idiom), not from the body and not hardcoded — an audit trail that always names the UI instead of the human is worthless. agentSlug comes from the path. GET reads the service-written JSONL at /memories/core/audit/memory-promotions.jsonl, filters to this agent, returns newest-first, honours ?limit, and counts rather than throws on an unparseable line. Test: middleware/test/memoryPromoteRoute.test.ts — 14 HTTP cases over a real express server, a real InMemoryMemoryStore and the real promoteMemory service; per-test fixtures per the spec's pollution guard.
The W5 memory-ACL producers live outside this repository: omadia-channel-teams and omadia-channel-telegram resolve @omadia/channel-sdk to this package's built dist/index.d.ts. A type that is not named in index.ts cannot be named by a channel plugin at all, so turnOrigin.ts was reachable from middleware/src and from nowhere else — which is precisely the half of the tree that does not produce origins. Found by compiling the Teams producer against the built SDK.
…annel/user isolation W5 chat-context memory ACL (design spec #870 §3–§5). Agent memory was isolated per Agent but not per chat context: a note the model wrote in Teams team A was readable and quotable in team B on the next turn. `MemoryBinder.forOrigin` replaces the single build-time memory stack with one stack per chat context, resolved synchronously at the start of a turn. - `memoryBinder.ts`: `MemoryBinder.forOrigin(origin) -> BoundTurnMemory`, `effectiveMemoryScope(slug, axes)` (static agent scope intersected with the turn's context axes) and a bounded LRU (default cap 256) keyed by the canonical scope string. Eviction drops a wrapper, never data. - `contextMemoryNamespacer.ts`: per-context bijection. Plain `/memories/<x>` lands in the narrowest tier; the reserved `~team` / `~agent` segments open the wider tiers. Read-only-ness of `~agent` is enforced one layer down, so a rewrite bug still surfaces as a `MemoryScopeViolation` rather than a leak. - `registry/scopedMemoryStore.ts`: `team:`/`channel:`/`user:` tokens over the new `/memories/contexts/<slug>/…` top-level segment, plus the `ro:` access modifier and the `allowed` -> `allowedRead`/`allowedWrite` split. - `turnOrigin.ts`: `TurnOrigin`, `MemoryAxes`, `memoryAxesForOrigin` and `memoryContextKey`. Temporary home — the design places these in `@omadia/channel-sdk`; the declarations are structurally identical so the SDK-injection unit can re-point the imports mechanically. Fail-closed throughout: no origin, a machine scope or an unresolvable one yields `['core', 'orchestrator:<slug>:*']` on today's namespacer, byte for byte. `buildOrchestrator` is deliberately untouched — the swap belongs to the wiring unit. Tests: `memoryContextIsolation.test.ts` (team A/B, channel/channel with a shared team tier, user/user, agent tier read-only, context-free turn blind to the context trees) and `memoryBinder.cache.test.ts` (LRU cap, LRU recency, eviction is not data loss, key collision-freedom across agents and axes). All store-level assertions, no LLM in the loop.
The memory browser gains a context dimension. `/memories/contexts/<slug>/
{team,channel,user}/<ctxKey>` is a new physical top level (design #870 §2),
and a flat listing of `/memories` cannot show an operator which chat context
a note belongs to — nor let them move one out of it.
- Sidebar tree, derived from the store listing itself, so it never claims a
context tree that does not exist: agent tier plus one branch per axis.
Context keys render as `channelType · nativeId`; a KG display name replaces
that when the (best-effort) label resolver answers, 404 degrades to the key.
- "Promote…" on a file inside a context tree: target tier constrained by the
source axis (channel -> team|agent, team|user -> agent, never cross-agent),
copy/move, optional target path, mandatory reason. POSTs to
/api/v1/operator/agents/:slug/memory/promotions.
- Audit tab reads the promotions log (memory-promotions.jsonl) for the agent
in hand and explains a 403/404 instead of rendering a raw status.
- Danger Zone: the user/team/channel selectors now state that the value is a
context key (`<channelType>~<safeKey>`) or the raw native id, and that those
axes gained a scratch footprint across every agent.
The route prefix is the repo's existing operator mount, not the design's
`/api/agents/:slug` sketch, which is not a mount point here; it lives in one
helper in api.ts so it is a single edit if the backend lands elsewhere.
Verified by web-ui/app/memory/__tests__/page.contexts.test.tsx.
…into feat/w5-memory-acl
… into feat/w5-memory-acl
…-tree' into feat/w5-memory-acl
…losed' into feat/w5-memory-acl # Conflicts: # middleware/test/scopedMemoryStore.contexts.test.ts
…to feat/w5-memory-acl
…at/w5-memory-acl # Conflicts: # middleware/packages/harness-orchestrator/src/registry/scopedMemoryStore.ts
…to feat/w5-memory-acl # Conflicts: # docs/CHANGELOG.md # middleware/packages/harness-channel-sdk/src/scopeId.ts
…-promote' into feat/w5-memory-acl # Conflicts: # docs/CHANGELOG.md
Three units independently re-implemented the same primitives while working in parallel. Collapse them onto a single definition each, and close the review findings that only exist because of the duplication: - memoryContextKey / memoryAxesForOrigin / TurnOrigin: the channel SDK copy is canonical. Delete harness-orchestrator/src/turnOrigin.ts and import from @omadia/channel-sdk; teamAxisKey moves next to MemoryAxes and is exported. - ContextMemoryNamespacer: keep the MemoryNamespacerBase version in orchestratorMemoryNamespacer.ts (one bijection implementation) and delete the standalone contextMemoryNamespacer.ts. - effectiveMemoryScope: keep the scopedMemoryStore.ts version with the pattern allowlist; drop the binder's second, passthrough copy. - scopedMemoryStore no longer mirrors MemoryAxes structurally - it imports it. - contextTierRoot() is the single source of truth for a tier's physical root, shared by the pattern compiler and the namespacer.
… wiring
turn-threading (orchestrator.ts):
- MemoryBinder.forOrigin() runs ONCE at the start of every turn, in runTurnCore
and in the streaming mirror, and the result travels as an explicit parameter
through executeDirectLine / chatInContext(Inner) / chatStreamInner /
prepareStreamSlot / dispatchTool / dispatchToolDeadlined / dispatchToolInner.
Deliberately not turnContext (AsyncLocalStorage): a generator is resumed in
its caller's async context, which is exactly how turnContext.enter was
silently losing the turn context on streaming turns before W3-A. A lost
binding there would not fail, it would quietly widen the scope.
- dispatchToolInner routes the tool through the turn-bound handler.
- The system prompt gains the ~team / ~agent convention on context-bound turns
only, so a non-context turn's prompt stays byte-identical.
http-api-origin-fail-closed (coordinator decision 1):
- orchestratorDispatcher forwards metadata.origin through a validating reader —
a shape it does not recognise is DROPPED, which resolves context-free.
- routes/chat.ts and harness-channel-api/chatRouter.ts emit no origin, with the
reasoning recorded: their scope strings are caller-supplied transcript labels
('http-default' is shared), so deriving a memory partition from one would let
a caller name another's tier.
wiring-rollout-flag:
- migration 0050: agents.context_memory TEXT NOT NULL DEFAULT 'off', CHECK
guarded by a pg_constraint probe so the file applies twice.
- configStore lifts it with a deny-default parser (unknown/NULL -> 'off').
- buildOrchestrator builds the MemoryBinder unconditionally and gates it by
MODE, so 'off' and today's stack are one code path.
- both barrels export the new surface; the promote router is mounted at
/api/v1/admin/memory/promotions behind requireAuth, the same gate as purge.
Blockers: - memoryContextKey was not injective: the passthrough and digest branches shared one output space, so an already-safe id spelled in the digest shape pre-imaged a hashed context's key. The two spaces are now disjoint. - memoryAxesForOrigin keyed on formatSessionScope, which is injective only over the strings parseSessionScope emits while the design has adapters build scopes directly. group 'x' and conversation 'group:x' shared one tier, as did channelId+id and the unescaped concatenation. Keys now derive from an injective JSON tuple of the structural parts. - The shared trees (core, sessions, chat-sessions, _*) were read-WRITE from a context turn, so /memories/core/notes.md was a one-line bypass of the ACL. Context turns now get ro:core; shared writes stay a context-free privilege. - memoryBinder.ts contained raw NUL bytes and was classified binary by git. - promoteMemory move deleted dotfiles it had never copied (list() skips them, delete() does not), and accepted a target nested inside the source, which move then wiped along with it. Both refused; move deletes only what it wrote. - A purge selector with no channel-type half matched nothing and answered 200 with a warning claiming the scratch trees were affected. Now 400 invalid_selector. Majors: - ro: was a weak grant, not a deny: any overlapping non-ro pattern re-granted write. It is now a veto evaluated before the positive check. - /memories/core/audit/ is agent-unwritable, so the promotion audit log an operator relies on cannot be rewritten by the agent it describes. - effectiveMemoryScope audited a broken axes object only in strict mode - the mode production does not run. axes-missing and no-usable-context-pattern now audit in both; context-free stays quiet, which is what the rationale covered. - The purge warning claimed an effect on zero-target selectors, and the user axis silently half-executed across two incompatible selector spellings. - The promote route documented an atomicity it does not have; a post-write failure now answers partial: true instead of reading as 'nothing happened'. - ContextMemoryNamespacer aliased the private root to ~team on org turns. - web-ui: promote path realigned to the mounted route, response envelopes validated at the boundary, the tree's error path made reachable, and decodeContextKey renamed nativeId -> safeKey (it is not round-trippable).
…ry ACL Records the scope grammar and why /memories/contexts is a top-level segment, the context-key injectivity argument (disjoint branch output spaces; keying on a structural tuple rather than formatSessionScope), the effective-scope table including ro:core, the explicit turn threading and why it is not AsyncLocalStorage, the off-by-default rollout flag, and the purge/promote operator surfaces. Also names the two open follow-ups: the dev-only listing the memory browser still reads, and the channel plugins that can only build once the SDK ships TurnOrigin.
…Purge The two functions share resolvePurgeTargets, so a caller that may omit the selector for one (axis 'all' ignores it) must be able to omit it for the other. The asymmetry made purgeMemory(store, 'all') a type error while the preview call was fine — the test-typecheck ratchet was carrying that as known debt, and this wave's new 'all' case would have added a second instance. Fixed at the signature instead: the baseline drops from 371 to 370. Also corrects the danger-zone header comment, which still described the selector as accepting a bare native id.
The code block still showed a bare 'core' on the context branch and listed three properties; it emits 'ro:core' and there are four. Adds the missing one: the shared trees are the single model-facing surface two contexts address by the same path, which is why they are read-only from a context turn.
26 tasks
Weegy
added a commit
that referenced
this pull request
Aug 27, 2026
Operators had no single place that says how to get several omadia agents into Microsoft Teams as separate named bots. The knowledge was spread across three repos, two manifests, five migrations and a handful of PR descriptions, so every attempt rediscovered the same traps: consent that silently does not apply, ARM fields whose absence is a partial success rather than a failure, and a teams_bots block nothing syncs for you. The new guide walks the whole path and states the platform limits up front, because they decide the architecture: Teams cannot change a bot name per message, bots never see each other, and rate limits are per bot (which is an argument FOR separate identities, not against them). Every API path, field name, state and setup key is verified against main rather than carried over from the draft, which predated three waves -- the operator UI (#896), the memory ACL (#881) and the .template ingest fix (#880) all landed after it. The one claim that could not be grounded in code is marked as a VERIFY comment instead of asserted. Part of #860
This was referenced Aug 27, 2026
Closed
Weegy
added a commit
that referenced
this pull request
Aug 27, 2026
* feat(#899): make the context-memory ACL switchable from the operator UI 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 * test(#899): build the TurnOrigin fixtures structurally instead of casting 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
This was referenced Aug 27, 2026
Open
Design spec: chat-context memory ACL (team:/channel:/user: scopes, fail-closed, promote action)
#870
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.
Agent memory is isolated per agent today, but not per chat context. What an agent learns in Teams team A lands in one agent-global tree and is quotable in team B on the next turn. This wave partitions that tree by chat context, fail-closed, and ships it off by default.
Refs #871 #872 #873, part of #860. Design: #870 (comment 5412545550) plus the coordinator decisions (comment 5422379504).
What this is
ScopedMemoryStoregainsteam:<ctxKey>:*,channel:<ctxKey>:*,user:<ctxKey>:*and anro:<pattern>access modifier. The context trees live under a new top-level segment/memories/contexts/, deliberately not under/memories/orchestrators/<slug>/:orchestrator:<slug>:*matches only the agent tree, so no legacy scope reaches a context tree and no context scope reaches the agent tree. That placement is the structural collision-freedom argument — please don't "tidy" the two trees together.memoryContextKey(channelType, nativeId)is the single sanitiser behind every<ctxKey>, every physical path, every purge selector and the promote route.MemoryBinder.forOrigin()resolves one stack per chat context (LRU, cap 256) once at the start of each turn; the orchestrator threads the result todispatchToolas an explicit parameter.user/team/channelget a scratch footprint for the first time;POST|GET /api/v1/admin/memory/promotions/:slugis the one way knowledge crosses a context boundary, on the same auth gate as purge; the memory browser gains a context dimension, a promote dialog and an audit tab.agents.context_memory(off|enforce|enforce-strict, migration 0050) defaults tooff, andChatTurnInput.originis optional. Every combination of old/new middleware and old/new channel plugin behaves exactly as today until an operator switches an agent over.Security properties, and how each is tested
Every assertion below is store-level. Nothing here depends on LLM output, and per the repo's documented cross-file pollution bug every test builds its own
InMemoryMemoryStore+ binder per test — no module-level fixtures.memoryContextIsolation.test.ts— A writes, B'sviewsays "does not exist", B'slist('/memories')shows only B's own notes, and an explicit read of A's physical path from B's compiled scope raisesMemoryScopeViolation(soft-deny on the model surface, hard at the store)memoryBinder.cache.test.ts— two binders, one root store, same origin: neither sees the other's treero:blocks writesmemoryContextIsolation.test.ts+scopedMemoryStore.contexts.test.ts— read/list/exists pass; write/create/delete/rename raiseMemoryScopeViolation, including rename across the boundary in both directionsmemoryContextIsolation.test.ts— A cannot write/memories/core/*or/memories/_x/*, and cannot rename a private note out intocore; B sees neither/memories/core/audit/…, none can write or delete itmemoryAxesForOrigin.test.ts— three pairs that all collapse to the sameformatSessionScopestring resolve to different tiersmemoryContextKey.test.ts+memoryContextIsolation.test.tseffectiveMemoryScope.test.ts— missing origin,unscoped,system, unknown channel type, unusable patterns and malformed axes all return exactlyorchestratorMemoryScope(slug), golden-compared against the real function rather than a re-spelled literaloffis byte-identical to todaymemoryContextIsolation.test.ts— for every origin, with the flagoffand with it defaulted, and the note lands in the agent tree with no/memories/contextscreated at allenforce-strictquarantines legacy knowledgepostgres:16-alpine: all 50 files applied twice cleanly, oneagents_context_memory_checkconstraint, existing rows default tooff, and the CHECK actually refuses an unknown modeReview findings and their resolutions
Blockers
memoryContextKeynot injective: the passthrough and digest branches shared one output space, so an already-safe id spelledx-<16 hex>pre-imaged a hashed context's keymemoryAxesForOriginkeyed onformatSessionScope, injective only over the stringsparseSessionScopeemits while §4 has adapters build scopes directly:group 'x'andconversation 'group:x'shared one tier, as didchannelId+idand the unescaped concatenationcore,sessions,chat-sessions,_*) were read-write from a context turn, so/memories/core/notes.mdwas a one-line bypass of the whole ACLro:core; writing the shared trees stays a context-free privilegememoryBinder.tscontained raw NUL bytes — git classified the wave's most security-critical file as binary, unreviewable in the PR'�'; the file is textpromoteMemoryonmovedeleted dotfiles it had never copied (list()skips.-names in both backends, the recursivedeletedoes not), reporting successmovedeletes only the exact paths it wrote; an emptied source directory survives, which is the safe directionpromoteMemoryaccepted a target nested inside the source, whichmovethen wiped along with it — net knowledge destroyed, success receipttarget_overlaps_source)~matched nothing but returned 200 with a warning claiming the scratch trees were affected — and the shipped placeholder invited exactly that spellinginvalid_selector; both hints and placeholders now say "never a bare id"Majors
ro:was a weak grant, not a deny — any overlapping non-ro:pattern silently re-granted writero:is a veto, evaluated before the positive checkcore, which every agent holds read-write)/memories/core/audit/is an agent-unwritable deny prefixeffectiveMemoryScopeaudited a broken axes object only inenforce-strict— the mode production does not run — while its JSDoc promised otherwiseaxes-missingandno-usable-context-patternaudit in both modes;context-freestays quiet, which is what the original rationale actually covereduseraxis fed its two legs incompatible selector spellings and half-executed silentlypartial: trueContextMemoryNamespaceraliased the private root to~teamon org turns, breaking its own bijection~team/~agententries.lengthonundefinedwhite-screens the page); the tree's error path was unreachable so every failure rendered as "no data";decodeContextKeycalled a digest anativeId, inviting an operator to paste it into a destructive selectorchannelType~safeKey, which is the form the selector acceptsteams-origin-producerdoes not typecheck in a clean tree (its@omadia/channel-sdkmaps to the main checkout, which has noTurnOrigin)telegram-origin-producerwas committed straight onto localmainof its own repo, no branch, never pushedWhat came from the unit branches vs. what was built here
Merged from unit branches (12):
context-key,turn-origin-contract,scope-grammar-contexts-tree,effective-scope-fail-closed,context-namespacer,memory-binder,purge-context-axes,promote-service,promote-route,web-ui-context-browser-promote, plus the SDK barrel export carried onteams-origin-producer. Two conflicts were resolved by content, not by-X ours: the two independently-written contexts test suites were split intoscopedMemoryStore.contexts.test.ts(grammar) andeffectiveMemoryScope.test.ts(resolver), and the two independent re-implementations of the grammar inscopedMemoryStore.tswere collapsed onto one.Reconciled here. Three units re-implemented the same primitives while working in parallel. Collapsed onto one definition each:
harness-orchestrator/src/turnOrigin.tsdeleted in favour of the channel SDK's; the standalonecontextMemoryNamespacer.tsdeleted in favour of theMemoryNamespacerBaseversion; the binder's second copy ofeffectiveMemoryScopedropped in favour of the one with the pattern allowlist;scopedMemoryStoreno longer structurally mirrorsMemoryAxes, it imports it; andcontextTierRoot()became the single source of truth for a tier's physical root.Built here (the three units whose agents died mid-run):
turn-threading—MemoryBinder.forOrigin()runs once at turn start inrunTurnCoreand in the streaming mirror, and the result travels as an explicit parameter throughexecuteDirectLine→chatInContext(Inner)/chatStreamInner→prepareStreamSlot→dispatchTool→dispatchToolDeadlined→dispatchToolInner. Deliberately notturnContext(AsyncLocalStorage): a generator is resumed in its caller's async context, which is exactly howturnContext.enterwas silently losing the turn context on every streaming turn before W3-A. A binding lost that way would not fail — it would quietly fall back to the agent-global tree. The system prompt gains the~team/~agentconvention on context-bound turns only, so a non-context turn's prompt (and its cache key) stays byte-identical.http-api-origin-fail-closed(coordinator decision 1) —orchestratorDispatcherforwardsmetadata.originthrough a validating reader; a shape it does not recognise is dropped, which resolves context-free.routes/chat.tsandharness-channel-api/chatRouter.tsemit no origin at all, with the reasoning recorded in both: their scope strings are caller-supplied transcript labels ('http-default'is shared), so deriving a memory partition from one would let any caller name another's tier.wiring-rollout-flag— migration 0050 (CHECK guarded by apg_constraintprobe so the file applies twice), a deny-default parser inconfigStore(unknown/NULL →off),buildOrchestratorbuilding the binder unconditionally and gating it by mode sooffand today's stack are one code path, both barrels, and the promote-router mount.The three coordinator decisions, as implemented
origin; both files carry the reasoning.ro:orchestrator:<slug>:*), so "note this globally" cannot become a permanent leak channel from team A into team B. Cross-tier sharing is the operator promote action, nothing else.enforce-strictis settled, not an open question — an unknown or unparseable origin resolves to the agent-private scope and logs loudly; never a throw on the message path, never a wider scope. In strict mode a context turn cannot even read the agent tier.Deviations from the spec, and why
/api/agents/:slug/memory/promotions, the real purge router lives at/api/v1/admin/memory/purgebehindrequireAuth, and the spec also says "same gate as purge". Mounted at/api/v1/admin/memory/promotions/:slugrather than introducing a third auth surface for a Danger-Zone-class action.middleware/migrations/actually tops out at0049_agent_teams_identities.sqlon current main, so this is 0050.agentGraphStorecolumn — the spec names it, but that module has no DDL and noagentsaccess. The column is a core migration read byregistry/configStore.tsand lifted intoAgentRuntimeConfig, matching how every other per-agent flag works.memoryContextKeyidempotence — the spec's purge selector relies on it. Kept for ordinary safe ids, dropped for the digest shape, because idempotence there is the pre-image hole. The purge selector resolves both readings and acts on the union of the trees that actually exist.Gates
middlewarenpm run typecheckmiddlewarenpm testpostgres:16-alpine; container removedweb-uinpm run lintweb-uinpm run typecheckweb-uinpm testweb-uinpm run i18n:checkKnown limitations / follow-ups
GET /bot-api/dev/memory/list, which is unauthenticated and never mounted in production, so the panel is inert there. §9 puts the operator UI outside this wave, so no operator-gated listing endpoint exists yet. What this PR guarantees is that the absence is now visible — a non-404 failure reaches the error state instead of rendering as an empty tree. Backing it with an operator-gated listing (matching the purge gate) is the follow-up.omadia-channel-teamsandomadia-channel-telegrambuild theTurnOriginin their own repos and cannot compile until@omadia/channel-sdkshipsTurnOrigin. That is a merge-order gate: land this, release the SDK, then their PRs. Until an operator also flipscontext_memory, the flag staysoffand nothing changes either way.promoteMemoryis not atomic. The write loop has no rollback andmovedeletes after it. This PR makes the ambiguity honest (partial: true) rather than pretending otherwise; attaching a partial receipt to a mid-flight failure is the durable fix.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.