Skip to content

feat: chat-context memory ACL — per-team/channel/user agent memory with fail-closed scoping - #881

Merged
Weegy merged 28 commits into
mainfrom
feat/w5-memory-acl
Aug 27, 2026
Merged

feat: chat-context memory ACL — per-team/channel/user agent memory with fail-closed scoping#881
Weegy merged 28 commits into
mainfrom
feat/w5-memory-acl

Conversation

@Weegy

@Weegy Weegy commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

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

scope = axes.isContextFree
  ? ['core', `orchestrator:${slug}:*`]                        // exactly today
  : ['ro:core', `ro:orchestrator:${slug}:*`, …axes.patterns]  // enforce
  : ['ro:core', …axes.patterns]                               // enforce-strict
  • Scope grammarScopedMemoryStore gains team:<ctxKey>:*, channel:<ctxKey>:*, user:<ctxKey>:* and an ro:<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.
  • Context keymemoryContextKey(channelType, nativeId) is the single sanitiser behind every <ctxKey>, every physical path, every purge selector and the promote route.
  • Per-turn bindingMemoryBinder.forOrigin() resolves one stack per chat context (LRU, cap 256) once at the start of each turn; the orchestrator threads the result to dispatchTool as an explicit parameter.
  • Operator surfaces — the Danger-Zone purge axes user/team/channel get a scratch footprint for the first time; POST|GET /api/v1/admin/memory/promotions/:slug is 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.
  • No flag dayagents.context_memory (off | enforce | enforce-strict, migration 0050) defaults to off, and ChatTurnInput.origin is 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.

Property Proof
Team A ↮ Team B memoryContextIsolation.test.ts — A writes, B's view says "does not exist", B's list('/memories') shows only B's own notes, and an explicit read of A's physical path from B's compiled scope raises MemoryScopeViolation (soft-deny on the model surface, hard at the store)
Channel ↮ Channel same file — two channels of one team stay apart while both reach the shared team tier
User ↮ User same file — two personal chats, isolated
Agent ↮ Agent memoryBinder.cache.test.ts — two binders, one root store, same origin: neither sees the other's tree
ro: blocks writes memoryContextIsolation.test.ts + scopedMemoryStore.contexts.test.ts — read/list/exists pass; write/create/delete/rename raise MemoryScopeViolation, including rename across the boundary in both directions
The shared trees are not a side channel memoryContextIsolation.test.ts — A cannot write /memories/core/* or /memories/_x/*, and cannot rename a private note out into core; B sees neither
The audit log is tamper-evident same file — every agent can read /memories/core/audit/…, none can write or delete it
Two structurally different scopes never share a tier memoryAxesForOrigin.test.ts — three pairs that all collapse to the same formatSessionScope string resolve to different tiers
A digest-shaped id cannot pre-image a context memoryContextKey.test.ts + memoryContextIsolation.test.ts
Fail-closed effectiveMemoryScope.test.ts — missing origin, unscoped, system, unknown channel type, unusable patterns and malformed axes all return exactly orchestratorMemoryScope(slug), golden-compared against the real function rather than a re-spelled literal
off is byte-identical to today memoryContextIsolation.test.ts — for every origin, with the flag off and with it defaulted, and the note lands in the agent tree with no /memories/contexts created at all
enforce-strict quarantines legacy knowledge same file — a context turn cannot even read the agent tier
Migration applies twice verified against a scratch postgres:16-alpine: all 50 files applied twice cleanly, one agents_context_memory_check constraint, existing rows default to off, and the CHECK actually refuses an unknown mode

Review findings and their resolutions

Blockers

Finding Fix
memoryContextKey not injective: the passthrough and digest branches shared one output space, so an already-safe id spelled x-<16 hex> pre-imaged a hashed context's key The two output spaces are now disjoint — a raw id matching the digest shape is itself hashed
memoryAxesForOrigin keyed on formatSessionScope, injective only over the strings parseSessionScope emits while §4 has adapters build scopes directly: group 'x' and conversation 'group:x' shared one tier, as did channelId+id and the unescaped concatenation Keys derive from an injective JSON tuple of the structural scope 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 whole ACL Context turns get ro:core; writing the shared trees stays a context-free privilege
memoryBinder.ts contained raw NUL bytes — git classified the wave's most security-critical file as binary, unreviewable in the PR Separator is '�'; the file is text
promoteMemory on move deleted dotfiles it had never copied (list() skips .-names in both backends, the recursive delete does not), reporting success move deletes only the exact paths it wrote; an emptied source directory survives, which is the safe direction
promoteMemory accepted a target nested inside the source, which move then wiped along with it — net knowledge destroyed, success receipt Either root being a path-prefix of the other is refused (target_overlaps_source)
A purge selector without a ~ matched nothing but returned 200 with a warning claiming the scratch trees were affected — and the shipped placeholder invited exactly that spelling 400 invalid_selector; both hints and placeholders now say "never a bare id"
web-ui danger-zone copy asserted purge semantics the backend did not implement The backend lands in this same PR, and the copy was corrected to the selector format actually accepted

Majors

Finding Fix
ro: was a weak grant, not a deny — any overlapping non-ro: pattern silently re-granted write ro: is a veto, evaluated before the positive check
The promotion audit log was writable by every agent (it sits under core, which every agent holds read-write) /memories/core/audit/ is an agent-unwritable deny prefix
effectiveMemoryScope audited a broken axes object only in enforce-strict — the mode production does not run — while its JSDoc promised otherwise axes-missing and no-usable-context-pattern audit in both modes; context-free stays quiet, which is what the original rationale actually covered
The purge warning claimed an effect on zero-target selectors The resolved target count is passed in; zero says so
The user axis fed its two legs incompatible selector spellings and half-executed silently A warning names which leg was a no-op (no KG filter invented — §7 rules that out)
The promote route documented an atomicity the service does not provide Any failure outside the pre-write validation set answers partial: true
ContextMemoryNamespacer aliased the private root to ~team on org turns, breaking its own bijection The shared root is published under its plain name
The namespacer's doc and test claimed context-free construction "reproduces the legacy namespacer exactly" — false for ~team/~agent Doc qualified; the divergence is now asserted in its own test
web-ui: promote client pointed at an unmounted path; response envelopes were unchecked casts (entries.length on undefined white-screens the page); the tree's error path was unreachable so every failure rendered as "no data"; decodeContextKey called a digest a nativeId, inviting an operator to paste it into a destructive selector All four fixed; the tree now shows the verbatim channelType~safeKey, which is the form the selector accepts
teams-origin-producer does not typecheck in a clean tree (its @omadia/channel-sdk maps to the main checkout, which has no TurnOrigin) Merge-order gate, not a code defect — recorded under "what is deliberately not here"
telegram-origin-producer was committed straight onto local main of its own repo, no branch, never pushed Same — out of this repo's scope

What 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 on teams-origin-producer. Two conflicts were resolved by content, not by -X ours: the two independently-written contexts test suites were split into scopedMemoryStore.contexts.test.ts (grammar) and effectiveMemoryScope.test.ts (resolver), and the two independent re-implementations of the grammar in scopedMemoryStore.ts were 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.ts deleted in favour of the channel SDK's; the standalone contextMemoryNamespacer.ts deleted in favour of the MemoryNamespacerBase version; the binder's second copy of effectiveMemoryScope dropped in favour of the one with the pattern allowlist; scopedMemoryStore no longer structurally mirrors MemoryAxes, it imports it; and contextTierRoot() became the single source of truth for a tier's physical root.

Built here (the three units whose agents died mid-run):

  • turn-threadingMemoryBinder.forOrigin() runs once at turn start in runTurnCore and in the streaming mirror, and the result travels as an explicit parameter through executeDirectLinechatInContext(Inner) / chatStreamInnerprepareStreamSlotdispatchTooldispatchToolDeadlineddispatchToolInner. 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 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/~agent convention 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) — 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 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 a pg_constraint probe so the file applies twice), a deny-default parser in configStore (unknown/NULL → off), buildOrchestrator building the binder unconditionally and gating it by mode so off and today's stack are one code path, both barrels, and the promote-router mount.

The three coordinator decisions, as implemented

  1. HTTP/API turns without a resolvable origin get the agent-private scope only — byte-identical to today, no implicit team or channel access for API callers. The HTTP dev route and the API channel emit no origin; both files carry the reasoning.
  2. The team tier is read-write from a matching team-context turn; the agent tier stays read-only from context turns (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.
  3. enforce-strict is 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

  • Promote mount — the spec asks for /api/agents/:slug/memory/promotions, the real purge router lives at /api/v1/admin/memory/purge behind requireAuth, and the spec also says "same gate as purge". Mounted at /api/v1/admin/memory/promotions/:slug rather than introducing a third auth surface for a Danger-Zone-class action.
  • Migration number — the coordinator note says 0049/0050 are taken; middleware/migrations/ actually tops out at 0049_agent_teams_identities.sql on current main, so this is 0050.
  • agentGraphStore column — the spec names it, but that module has no DDL and no agents access. The column is a core migration read by registry/configStore.ts and lifted into AgentRuntimeConfig, matching how every other per-agent flag works.
  • memoryContextKey idempotence — 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.
  • web-ui — §9 lists the operator UI as an explicit non-goal while the wave brief covers "operator surface". It is included, its findings are fixed, and its one real limitation is recorded below rather than hidden.

Gates

Gate Result
middleware npm run typecheck clean
middleware npm test 7666 tests, 7650 pass, 0 fail (rest skipped: PG-dependent cases)
test-typecheck ratchet passes, and the debt drops 371 → 370
Migration double-apply verified twice against a throwaway postgres:16-alpine; container removed
web-ui npm run lint exit 0, zero errors
web-ui npm run typecheck clean
web-ui npm test 886 tests, 886 pass
web-ui npm run i18n:check OK — 3914 keys, en + de

Known limitations / follow-ups

  • The memory browser's context tree is dev-only today. It reads 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.
  • The channel plugins are not in this PR. omadia-channel-teams and omadia-channel-telegram build the TurnOrigin in their own repos and cannot compile until @omadia/channel-sdk ships TurnOrigin. That is a merge-order gate: land this, release the SDK, then their PRs. Until an operator also flips context_memory, the flag stays off and nothing changes either way.
  • promoteMemory is not atomic. The write loop has no rollback and move deletes 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.
  • Promoted files land in the target tier but the KG is untouched for the team/channel axes — §7 rules out inventing a KG filter, and the purge route now says so instead of implying coverage it does not have.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Weegy added 28 commits August 26, 2026 12:48
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.
…losed' into feat/w5-memory-acl

# Conflicts:
#	middleware/test/scopedMemoryStore.contexts.test.ts
…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.
@Weegy
Weegy merged commit 0ef2037 into main Aug 27, 2026
8 checks passed
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
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant