Surface an admitted system memory to every account, behind a switch that is off

b02c8e070b65 · AtlantisPleb · · parent 1a88db3666b9

Surface an admitted system memory to every account, behind a switch that is off

The system bucket stores what the network has learned, admits it by a
steward's receipt, and contests it by record. Until now it surfaced to
nobody, because reading an admitted row into every account's turn is
cross-account recall by construction and MEMORY-001 confines recall to the
acting account with no unscoped fallback.

This makes the decision rather than smuggling it. MEMORY-001 carries an
amendment naming the exception, its four bounds, and why each one is there.
Production ships the switch off: `OPENAGENTS_FEATURE_SYSTEM_MEMORY_RECALL`
is `false` in `config/config.exs`, in the production fleet profile, and in
the staging gate profile, so the capability exists and nothing changes for
any reader until an operator turns it on.

What replaces the scope predicate is specification section 7.1's eligibility
filter, written as database predicates in
`OpenAgents.Memories.SystemRecall`: the `system` bucket, a live row, a tier
at or above `ledger`, and membership of the id set the admission records
derive as `admitted`. A candidate, a rejected row, a suspended row, and a
superseded row are absent from the read rather than filtered out of its
result, and a row's own `admission` column is read by nothing.

Section 7.2's caps bound volume where admission bounds truth. At ranking
time a stable round-robin over accounts holds any one account to a quarter
of a message's pool; a note takes at most one memory per account and two in
all, and spends the same per-turn character budget the account's own
memories spend. What the caps exclude is not counted into the note's
`dropped` line, because that count would tell every account how large the
shared store is.

A system line reads `[From memory: (system, as of <date>, <status>)]`, so a
reader can tell a network claim from their own, see the date the claim was
observed true rather than the date the row was written, and see the status a
steward's records derived. Section 7.1 draws the body inside the brackets;
this repository's rails put it after them, and one note with two bracket
shapes reads worse than either, so the label is the specification's and the
placement is the rails'.

With the switch off, `user` and `learned` recall is byte for byte what it
was, and the test proves it by comparison rather than by assertion: the same
account's recall is captured with no system store behind it, an admitted
store is written, and the two results are compared whole.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 432 · 2026-08-26T01:35:47.597019Z

Changed files

  • modified INVARIANTS.md
  • modified config/config.exs
  • modified config/runtime.exs
  • modified lib/openagents/memories.ex
  • modified lib/openagents/memories/memory.ex
  • modified lib/openagents/memories/note.ex
  • modified lib/openagents/memories/retrieval.ex
  • modified lib/openagents/memories/retrieval/lexical.ex
  • modified lib/openagents/memories/retrieval/semantic.ex
  • added lib/openagents/memories/system_recall.ex
  • modified ops/deploy/fleet-startup.template.sh
  • modified ops/staging/gate-5-profile.sh
  • added test/openagents/memories/system_recall_test.exs
  • modified test/openagents/memories_test.exs
  • modified test/openagents_web/controllers/responses_controller_test.exs

Diff

15 files changed, +1397 -58

INVARIANTS.md modified +97 -25

@@ -777,6 +777,49 @@ conversation or user is refused, and no API offers a cross-conversation or

777 777
unscoped fallback. GitHub identity establishes account continuity but does not
778 778
turn conversation evidence into verified facts about a person.
779 779
780
**One exception, and it is named here rather than discovered later: the
781
`system` memory bucket.** An admitted system memory (MEMORY-011) is written by
782
one account and read into every account's turn. That is cross-account recall by
783
construction, it cannot be reconciled with the rule above, and it is the point
784
of the bucket rather than a side effect of one, so it is admitted as an
785
amendment to this invariant instead of being smuggled past it. Four bounds make
786
it an exception rather than a hole:
787
788
* **It lives in one module.** `OpenAgents.Memories.SystemRecall` and the one
789
  shared ranking query in `OpenAgents.Memories.Retrieval.Lexical` are the only
790
  reads in either memory plane that name no account. Both are enumerated by
791
  name, so a second unscoped query fails the enumeration until somebody adds it
792
  there on purpose.
793
* **It is off unless an operator turns it on.**
794
  `OPENAGENTS_FEATURE_SYSTEM_MEMORY_RECALL` sets
795
  `:memory_recall, :system_bucket_enabled`, which is `false` in
796
  `config/config.exs` and declared `false` in both the production
797
  (`ops/deploy/fleet-startup.template.sh`) and staging
798
  (`ops/staging/gate-5-profile.sh`) profiles. With it off no query in that
799
  module runs, and `user` and `learned` recall is byte for byte what it was
800
  before the bucket had a recall path at all.
801
* **An eligibility filter replaces the scope predicate, and it is a predicate.**
802
  MEMORY-004's discipline holds through the exception rather than around it:
803
  what stands in for `user_id` is `bucket = 'system'`, `superseded_by_id IS
804
  NULL`, `tier IN ('ledger','glass')`, and membership of the id set
805
  `OpenAgents.Memories.Admissions.statuses/0` derived as `admitted`. A
806
  candidate, a rejected row, a suspended row, and a sub-`ledger` row are absent
807
  from the read rather than filtered out of its result, and a row's own
808
  `admission` column — the author's claim about their own row — is read by
809
  nothing (system-memory specification section 7.1).
810
* **Per-source caps bound it at ranking time.** At most 25% of one message's
811
  ranked candidate pool comes from a single account, by a stable round-robin
812
  over accounts in rank order; at most one memory per account and two in total
813
  attach to a note; and system rows spend the same per-turn character budget as
814
  the account's own, so a note that quotes the network is no longer than one
815
  that does not (section 7.2). What the caps exclude is not counted into the
816
  note's `dropped` line, because that count would tell every account how large
817
  the shared store is.
818
819
Nothing else gains an unscoped read from this. Every other query in both planes
820
still names `conversation_id` or `user_id`, and the enumerations below are what
821
prove it.
822
780 823
The recall APIs are enumerable rather than remembered. Recall reaches
781 824
PostgreSQL only through the backend `:recall_search_backend` names in
782 825
`config/config.exs`, so the backends are the population, and each backend's

@@ -789,8 +832,10 @@ fails until this contract accounts for it.

789 832
790 833
Evidence: `OpenAgents.Memory.RecallSnapshot`, `OpenAgents.Memory.LexicalRecall`,
791 834
`OpenAgents.Memory.HybridRecall`, cross-scope tests in
792
`OpenAgents.Memory.LexicalRecallTest`, and the entry-point enumeration in
793
`OpenAgents.Memory.ScopeBoundaryTest`.
835
`OpenAgents.Memory.LexicalRecallTest`, the entry-point enumeration in
836
`OpenAgents.Memory.ScopeBoundaryTest`, and, for the amendment,
837
`OpenAgents.Memories.SystemRecall`, `test/openagents/memories/system_recall_test.exs`,
838
and the unscoped-query enumeration in `test/openagents/memories_test.exs`.
794 839
795 840
### MEMORY-002 — Recalled history is classified evidence, not current profile truth
796 841

@@ -1074,9 +1119,17 @@ Scope is a database predicate, never an application filter. Every Ecto query

1074 1119
rooted at `OpenAgents.Memories.Memory` names `user_id`, in the context and in
1075 1120
every retrieval backend that reads PostgreSQL, and the queries are read from
1076 1121
each module's own source AST rather than remembered, so a query added beside
1077
the scoped ones fails until it carries its scope. No read offers a cross-account
1078
or unscoped fallback, and a memory of another account is refused as absent
1079
rather than as forbidden.
1122
the scoped ones fails until it carries its scope. A memory of another account
1123
is refused as absent rather than as forbidden.
1124
1125
The `system` bucket is the one exception, amended into MEMORY-001 rather than
1126
assumed here. Its two queries — the eligibility read in
1127
`OpenAgents.Memories.SystemRecall` and the shared ranking query in
1128
`OpenAgents.Memories.Retrieval.Lexical` — name no account, and the enumeration
1129
admits exactly those two by module and by count: each must name the `system`
1130
bucket in place of `user_id`, and a third unscoped query fails until somebody
1131
declares it. The `user` and `learned` buckets keep the unamended rule, and they
1132
keep it whether the system flag is on or off.
1080 1133
1081 1134
Writes are explicit. Nothing infers a memory from what a turn contained: a row
1082 1135
exists because a caller asked for it through `POST /api/v1/memories`.

@@ -1199,29 +1252,48 @@ that the row exists. MEMORY-010's rule that every query rooted at

1199 1252
`OpenAgents.Memories.Memory` names `user_id` holds unchanged, and the AST proof
1200 1253
reads this module too.
1201 1254
1202
Nothing surfaces. `OpenAgents.Memories.recall/3` reads the `user` and `learned`
1203
buckets, named as a predicate in the query rather than filtered out of its
1204
result, and no session sees a system memory — not another account's, and not
1205
its own author's, and not one of any derived status: admitted, suspended, and
1206
challenged rows all reach the same number of turns. A challenged memory nobody
1207
can see is still a coherent state; the point of recording the challenge is that
1208
a wrong admitted claim has a path other than editing someone else's row, and
1209
that a contested claim is marked before an eligibility filter exists to read
1210
the mark. That is deliberate and it is the whole reason this invariant
1211
can stand beside MEMORY-001 and MEMORY-010 rather than amending them: an
1212
admitted row read into every account's turn is cross-account recall by
1213
construction, so surfacing the bucket is a privacy decision with an eligibility
1214
filter of its own, not a ranking change. A system memory that is stored,
1215
evidenced, and admitted but recalled by nobody is a coherent state; a quietly
1216
widened recall predicate is not.
1255
Surfacing is a decision an operator makes, not a state the store drifts into.
1256
`OpenAgents.Memories.recall/3` reads the `user` and `learned` buckets, named as
1257
a predicate in the query rather than filtered out of its result, and it reads
1258
the `system` bucket only when `:memory_recall, :system_bucket_enabled` is on.
1259
That switch is `false` in `config/config.exs` and declared `false` in the
1260
production and staging profiles, so on every deployment that has not made the
1261
decision no session sees a system memory — not another account's, not its own
1262
author's, and not one of any derived status. This is the decision MEMORY-001's
1263
amendment records, and the reason it is a decision is that an admitted row read
1264
into every account's turn is cross-account recall by construction rather than a
1265
ranking change.
1266
1267
When the switch is on, `OpenAgents.Memories.SystemRecall` is the whole of what
1268
surfaces. Only rows the admission records derived as `admitted` are eligible:
1269
candidates, rejected rows, and rows suspended by an open evidenced challenge
1270
reach no turn, and neither does a superseded row or one below the `ledger`
1271
tier. The filter is the query rather than a pass over its result. Per-source
1272
caps bound the rest — 25% of one message's ranked pool per account by stable
1273
round-robin, one memory per account and two in total per note — so an account
1274
that wrote most of the admitted store still does not own a note. Every step is
1275
deterministic: a total read order, the ranking module's stable tie-breaks, and
1276
a round-robin that consults nothing else, so equal inputs give equal notes.
1277
1278
A note names what it is quoting. A system line reads
1279
`[From memory: (system, as of <date>, <status>)] <body>`: the bucket, the
1280
`as_of` date that dates the claim rather than the insert time that orders the
1281
chain, and the status the admission records derived — never the `admission`
1282
column, which is what the author claimed about their own row. A reader can tell
1283
a network claim from their own, and can see how old it is.
1284
1285
Recall degrades to silence, never to an error. An empty store, an unreadable
1286
one, and an unavailable ranking backend each surface nothing and fail no turn.
1217 1287
1218 1288
Evidence: `OpenAgents.Memories.Admissions`, `OpenAgents.Memories.Admission`,
1219
`OpenAgents.Memories.Memory`, `OpenAgents.Memories.Evidence`, the
1289
`OpenAgents.Memories.Memory`, `OpenAgents.Memories.SystemRecall`,
1290
`OpenAgents.Memories.Note`, `OpenAgents.Memories.Evidence`, the
1220 1291
`memories_system_shape` and `memory_admissions_shape` constraints and the
1221 1292
composite foreign keys `memory_admissions_memory_fkey` and
1222 1293
`memory_admissions_challenge_fkey`,
1223
`test/openagents/memories/system_memory_test.exs`, and
1224
`test/openagents/memories/challenge_test.exs`.
1294
`test/openagents/memories/system_memory_test.exs`,
1295
`test/openagents/memories/challenge_test.exs`, and
1296
`test/openagents/memories/system_recall_test.exs`.
1225 1297
1226 1298
### PRIVACY-001 — Secret-bearing profile memory is rejected, never scrub-stored
1227 1299

@@ -6111,7 +6183,7 @@ contract; the invariant prose above defines the assertion, not the filename.

6111 6183
| DATA-001 | `test/openagents/conversations_test.exs` |
6112 6184
| DATA-002 | `test/openagents/accounts_test.exs`, `test/openagents/conversations_test.exs` |
6113 6185
| DATA-003 | `test/openagents/conversations_test.exs` |
6114
| MEMORY-001 | `test/openagents/memory/lexical_recall_test.exs`, `test/openagents/memory/scope_boundary_test.exs` |
6186
| MEMORY-001 | `test/openagents/memory/lexical_recall_test.exs`, `test/openagents/memory/scope_boundary_test.exs`, `test/openagents/memories/system_recall_test.exs` |
6115 6187
| MEMORY-002 | `test/openagents/memory/evidence_test.exs`, `test/openagents/turn_memory_evidence_journeys_test.exs` |
6116 6188
| MEMORY-003 | `test/openagents/profile_memory_test.exs` |
6117 6189
| MEMORY-004 | `test/openagents/memory/lexical_recall_test.exs`, `test/openagents/tools/conversation_recall_tools_test.exs`, `test/openagents/memory/scope_boundary_test.exs` |

@@ -6121,7 +6193,7 @@ contract; the invariant prose above defines the assertion, not the filename.

6121 6193
| MEMORY-008 | `test/openagents/experience_memory_test.exs` |
6122 6194
| MEMORY-009 | `test/openagents/graph_memory_test.exs` |
6123 6195
| MEMORY-010 | `test/openagents/memories_test.exs`, `test/openagents_web/controllers/memory_controller_test.exs`, `test/openagents_web/controllers/responses_controller_test.exs` |
6124
| MEMORY-011 | `test/openagents/memories/system_memory_test.exs`, `test/openagents/memories/challenge_test.exs` |
6196
| MEMORY-011 | `test/openagents/memories/system_memory_test.exs`, `test/openagents/memories/challenge_test.exs`, `test/openagents/memories/system_recall_test.exs` |
6125 6197
| PRIVACY-001 | `test/openagents/memory/policy_and_redaction_test.exs`, `test/openagents/memory/scope_boundary_test.exs` |
6126 6198
| TURN-001 | `test/openagents/conversations_test.exs` |
6127 6199
| TURN-002 | `test/openagents/conversations_test.exs` |
config/config.exs modified +11

@@ -294,8 +294,19 @@ config :openagents,

294 294
  # target retrieval backend; with it off, recall runs on the lexical stand-in.
295 295
  # The three bounds are the store's ceiling per account and the per-turn
296 296
  # ceilings on how much memory may reach the model.
297
  #
298
  # `system_bucket_enabled` is the cross-account switch, and it is off here and
299
  # in production. With it off, recall reads the `user` and `learned` buckets
300
  # and nothing else, exactly as it did before the system bucket existed. With
301
  # it on, an admitted system memory written by one account reaches every
302
  # account's turn under the eligibility filter and the caps in
303
  # `OpenAgents.Memories.SystemRecall` (MEMORY-001, MEMORY-011).
304
  # `maximum_system_pool` bounds the ranked candidate pool the per-source cap
305
  # is a share of.
297 306
  memory_recall: [
298 307
    embeddings_enabled: false,
308
    system_bucket_enabled: false,
309
    maximum_system_pool: 40,
299 310
    provider: OpenAgents.Memory.OpenAIEmbeddings,
300 311
    model_id: "text-embedding-3-small",
301 312
    model_version: "2024-01",
config/runtime.exs modified +6

@@ -272,6 +272,7 @@ if config_env() == :prod and runtime_role == :web do

272 272
  shadow_enabled = feature.("SHADOW_PROGRAMS")
273 273
  tool_embeddings_enabled = feature.("TOOL_EMBEDDINGS")
274 274
  memory_embeddings_enabled = feature.("MEMORY_EMBEDDINGS")
275
  system_memory_recall_enabled = feature.("SYSTEM_MEMORY_RECALL")
275 276
  computers_enabled = feature.("COMPUTERS")
276 277
  conversation_reset_enabled = feature.("CONVERSATION_RESET")
277 278
  incident_fixer_enabled = feature.("INCIDENT_FIXER")

@@ -390,10 +391,15 @@ if config_env() == :prod and runtime_role == :web do

390 391
    |> Application.fetch_env!(:tool_discovery)
391 392
    |> Keyword.put(:embeddings_enabled, tool_embeddings_enabled)
392 393
394
  # `system_bucket_enabled` is the one switch in this file that widens who a
395
  # row can reach rather than how well it is ranked. It is declared `false` in
396
  # `ops/deploy/fleet-startup.template.sh`, so production reads the `user` and
397
  # `learned` buckets and nothing else until an operator turns it on.
393 398
  memory_recall =
394 399
    :openagents
395 400
    |> Application.fetch_env!(:memory_recall)
396 401
    |> Keyword.put(:embeddings_enabled, memory_embeddings_enabled)
402
    |> Keyword.put(:system_bucket_enabled, system_memory_recall_enabled)
397 403
398 404
  forge_repos = parse_csv.("OPENAGENTS_FORGE_REPOSITORIES")
399 405
  forge_owner = required_text.("OPENAGENTS_FORGE_OWNER")
lib/openagents/memories.ex modified +37 -15

@@ -88,7 +88,7 @@ defmodule OpenAgents.Memories do

88 88
89 89
  alias Ecto.Multi
90 90
  alias OpenAgents.Accounts.User
91
  alias OpenAgents.Memories.{Memory, Recall, Retrieval}
91
  alias OpenAgents.Memories.{Memory, Recall, Retrieval, SystemRecall}
92 92
  alias OpenAgents.Memories.Retrieval.Semantic
93 93
  alias OpenAgents.Repo
94 94

@@ -229,16 +229,24 @@ defmodule OpenAgents.Memories do

229 229
  account holds is ranked against it; `user` memories are kept regardless of
230 230
  score and `learned` ones only above the backend's floor; the result is cut to
231 231
  `maximum_attached` memories and `maximum_attached_characters`, and what the
232
  cut excluded is counted rather than dropped in silence.
233
234
  The `system` bucket is not read here, by anyone, including its own author.
235
  A system memory is stored, admitted, and contestable
236
  (`OpenAgents.Memories.Admissions`) and surfaced to nobody, whatever status
237
  its records derive: an admitted row reaches every account's turn or none, and
238
  the first is cross-account recall, which MEMORY-001 and MEMORY-010 forbid.
239
  The bucket list is a predicate in the query rather than a filter applied to
240
  its results, so widening it is a deliberate edit to the recall issue's
241
  eligibility filter and not something a ranking change can do by accident.
232
  cut excluded is counted rather than dropped in silence. That read names
233
  `user_id`, and it is the whole of what this function does unless an operator
234
  says otherwise.
235
236
  The `system` bucket is the one exception, and it is off by default. When
237
  `:memory_recall, :system_bucket_enabled` is on, `OpenAgents.Memories.SystemRecall`
238
  appends what the network has learned: rows a steward's records derived as
239
  `admitted`, at or above the `ledger` tier, live, and unsuspended, capped at
240
  one per writing account and two per note, spending whatever the account's own
241
  memories left of the character budget. Those rows are cross-account by
242
  design — that is the point of the bucket, and it is why the switch exists.
243
  With it off, this function does what it did before the bucket had a recall
244
  path at all, byte for byte.
245
246
  System rows never consume the account's count budget and are never counted
247
  into `dropped`. `dropped` tells a reader their own store was larger than the
248
  turn; a count of network claims that did not fit would tell them how large
249
  the shared store is, which is a fact about other accounts.
242 250
243 251
  Never raises. An unreadable store or an unavailable backend recalls nothing.
244 252
  """

@@ -256,9 +264,9 @@ defmodule OpenAgents.Memories do

256 264
        if memory.bucket == "user" or score > floor, do: [memory], else: []
257 265
      end)
258 266
259
    {kept, dropped} = bound(eligible, opts)
267
    {kept, dropped, remaining} = bound(eligible, opts)
260 268
261
    %Recall{memories: kept, dropped: dropped, backend: backend}
269
    %Recall{memories: kept ++ shared(query, remaining), dropped: dropped, backend: backend}
262 270
  rescue
263 271
    _error -> %Recall{memories: [], dropped: 0, backend: :lexical}
264 272
  end

@@ -283,11 +291,15 @@ defmodule OpenAgents.Memories do

283 291
  # Count first, size second, and the count of what neither admitted. Taking
284 292
  # the highest-ranked memories until the character budget is spent keeps the
285 293
  # note about this turn rather than about whichever memory is longest.
294
  #
295
  # What is left of the character budget travels out with the result, because
296
  # the system bucket spends the same budget rather than one of its own: a note
297
  # that quotes the network is no longer than a note that does not.
286 298
  defp bound(memories, opts) do
287 299
    count = Keyword.get(opts, :maximum_attached, maximum_attached())
288 300
    characters = Keyword.get(opts, :maximum_attached_characters, maximum_attached_characters())
289 301
290
    {kept, _left} =
302
    {kept, left} =
291 303
      memories
292 304
      |> Enum.take(count)
293 305
      |> Enum.reduce({[], characters}, fn memory, {kept, remaining} ->

@@ -297,7 +309,17 @@ defmodule OpenAgents.Memories do

297 309
      end)
298 310
299 311
    kept = Enum.reverse(kept)
300
    {kept, length(memories) - length(kept)}
312
    {kept, length(memories) - length(kept), left}
313
  end
314
315
  # The cross-account half, and nothing at all unless an operator turned it on.
316
  # `SystemRecall.pool/1` answers `[]` with the flag off without issuing a
317
  # query, so this is a no-op on every deployment that has not made the
318
  # decision (MEMORY-001).
319
  defp shared(query, remaining) do
320
    query
321
    |> SystemRecall.pool()
322
    |> SystemRecall.attachable(remaining)
301 323
  end
302 324
303 325
  defp scope(%User{id: user_id}, opts) do
lib/openagents/memories/memory.ex modified +16 -7

@@ -80,6 +80,13 @@ defmodule OpenAgents.Memories.Memory do

80 80
    # The generated `tsvector` the lexical stand-in ranks over. PostgreSQL
81 81
    # writes it; nothing here reads it back, so it never rides a select.
82 82
    field :search_vector, :string, load_in_query: false
83
84
    # What `OpenAgents.Memories.Admissions` derived for this row, carried so a
85
    # note can print the status a steward's receipts produce rather than the
86
    # `admission` field the author claimed. Virtual on purpose: a derived
87
    # status has no column, because a column is exactly the thing an author
88
    # could write for themselves.
89
    field :derived_status, :string, virtual: true
83 90
    belongs_to :superseded_by, __MODULE__, foreign_key: :superseded_by_id
84 91
    timestamps()
85 92
  end

@@ -91,13 +98,15 @@ defmodule OpenAgents.Memories.Memory do

91 98
  def buckets, do: @buckets
92 99
93 100
  @doc """
94
  The buckets recall reads.
95
96
  `system` is stored and admitted but surfaced to nobody. Reading an admitted
97
  system row into every account's turn is cross-account recall by construction,
98
  which MEMORY-001 and MEMORY-010 forbid, so widening this list is a privacy
99
  decision rather than a ranking change. It belongs to the recall issue that
100
  owns the eligibility filter, not to the store.
101
  The buckets account-scoped recall reads.
102
103
  `system` is not one of them and never becomes one. Reading an admitted system
104
  row into every account's turn is cross-account recall by construction, so it
105
  cannot ride the query that names `user_id`; it has a plane of its own in
106
  `OpenAgents.Memories.SystemRecall`, under an eligibility filter that replaces
107
  the scope predicate and a feature flag that is off by default (MEMORY-001).
108
  Widening this list would surface the bucket without either one, so it stays
109
  two buckets long.
101 110
  """
102 111
  @spec recallable_buckets() :: [String.t()]
103 112
  def recallable_buckets, do: @recallable_buckets
lib/openagents/memories/note.ex modified +32

@@ -12,6 +12,25 @@ defmodule OpenAgents.Memories.Note do

12 12
  When the bounds excluded something, the block says so in its last line. A
13 13
  note that trailed off would leave the model believing it had been told
14 14
  everything the account remembers.
15
16
  ## The system bucket reads differently on purpose
17
18
  A `user` or `learned` line is labelled with its bucket and its age. A
19
  `system` line is labelled `(system, as of <date>, admitted)`, because a claim
20
  the network makes has to be weighed against a claim the reader made, and the
21
  three things that decide that weight are which bucket it came from, what date
22
  it was observed true, and whether a steward admitted it. The date is the
23
  row's `as_of` rather than its age, since `as_of` dates the claim while the
24
  insert time only orders the chain, and a stale truth should read as dated.
25
26
  The status is the one the admission records derived
27
  (`OpenAgents.Memories.Admissions`), carried on `derived_status`. It is never
28
  the `admission` column, which is the author's own claim about their own row.
29
30
  Specification section 7.1 draws the example with the body inside the
31
  brackets. This repository's rails put the body after them, and one note
32
  carrying two bracket shapes would be harder to read than either, so the label
33
  is the specification's verbatim and the placement is the rails'.
15 34
  """
16 35
17 36
  alias OpenAgents.Memories.{Memory, Recall}

@@ -31,10 +50,23 @@ defmodule OpenAgents.Memories.Note do

31 50
    |> Enum.join("\n")
32 51
  end
33 52
53
  defp line(%Memory{bucket: "system"} = memory) do
54
    "[From memory: (system, as of #{as_of(memory)}, #{status(memory)})] #{memory.body}"
55
  end
56
34 57
  defp line(%Memory{} = memory) do
35 58
    "[From memory: #{memory.bucket}, #{age(memory)}] #{memory.body}"
36 59
  end
37 60
61
  defp as_of(%Memory{as_of: %Date{} = date}), do: Date.to_iso8601(date)
62
  defp as_of(_undated), do: "date unknown"
63
64
  # Derived, never claimed. A row that reached a note without a derived status
65
  # is a bug in the eligibility filter rather than a candidate to describe as
66
  # one, so it says what it knows and nothing more.
67
  defp status(%Memory{derived_status: derived}) when is_binary(derived), do: derived
68
  defp status(_underived), do: "status unknown"
69
38 70
  defp omission(0), do: []
39 71
40 72
  defp omission(dropped) do
lib/openagents/memories/retrieval.ex modified +68 -5

@@ -45,6 +45,21 @@ defmodule OpenAgents.Memories.Retrieval do

45 45
  @callback score(user_id :: String.t(), query :: String.t(), candidates :: [Memory.t()]) ::
46 46
              {:ok, %{optional(String.t()) => float()}} | :error
47 47
48
  @doc """
49
  Scores `candidates` from the shared `system` bucket, which no account owns.
50
51
  This is the one scoring path that takes no `user_id`, and the reason it can
52
  is that its caller has already narrowed the candidates with the eligibility
53
  filter in `OpenAgents.Memories.SystemRecall` — the predicate MEMORY-001's
54
  amendment puts in place of the scope predicate for this bucket. A backend
55
  that reads PostgreSQL still names `bucket` here, so the query cannot reach an
56
  account-scoped row even if a caller assembled the candidate list wrongly.
57
58
  Never reachable from the account's own recall: `rank/3` calls `score/3`.
59
  """
60
  @callback score_shared(query :: String.t(), candidates :: [Memory.t()]) ::
61
              {:ok, %{optional(String.t()) => float()}} | :error
62
48 63
  @doc "The score a `learned` memory must clear before it interrupts a turn."
49 64
  @callback floor() :: float()
50 65

@@ -72,14 +87,49 @@ defmodule OpenAgents.Memories.Retrieval do

72 87
  def rank(user_id, query, candidates, module) do
73 88
    case module.score(user_id, query, candidates) do
74 89
      {:ok, scores} ->
75
        {name(module), ordered(candidates, scores), module.floor()}
90
        {name(module), order(candidates, scores), module.floor()}
76 91
77 92
      :error when module != Lexical ->
78 93
        Logger.info("memory_retrieval_fell_back backend=#{name(module)}")
79 94
        rank(user_id, query, candidates, Lexical)
80 95
81 96
      :error ->
82
        {name(module), ordered(candidates, %{}), module.floor()}
97
        {name(module), order(candidates, %{}), module.floor()}
98
    end
99
  end
100
101
  @doc """
102
  Ranks shared `system` candidates against `query`, highest first.
103
104
  Returns `{backend, ranked}`. There is no floor in the answer: the floor is
105
  what decides whether an unasked-for `learned` memory earns a turn, and every
106
  candidate reaching here has already cleared a steward's admission. What
107
  bounds this pool is the caps in `OpenAgents.Memories.SystemRecall`, not a
108
  score threshold.
109
110
  Falls back to the stand-in the way `rank/3` does, and a stand-in that cannot
111
  answer either scores nothing rather than failing the turn.
112
  """
113
  @spec rank_shared(String.t(), [Memory.t()]) :: {backend(), [ranked()]}
114
  def rank_shared(query, candidates) when is_binary(query) and is_list(candidates) do
115
    rank_shared(query, candidates, backend())
116
  end
117
118
  @doc "Ranks shared candidates with a named backend."
119
  @spec rank_shared(String.t(), [Memory.t()], module()) :: {backend(), [ranked()]}
120
  def rank_shared(_query, [], module), do: {name(module), []}
121
122
  def rank_shared(query, candidates, module) do
123
    case module.score_shared(query, candidates) do
124
      {:ok, scores} ->
125
        {name(module), order(candidates, scores)}
126
127
      :error when module != Lexical ->
128
        Logger.info("memory_retrieval_fell_back backend=#{name(module)} pool=system")
129
        rank_shared(query, candidates, Lexical)
130
131
      :error ->
132
        {name(module), order(candidates, %{})}
83 133
    end
84 134
  end
85 135

@@ -94,9 +144,22 @@ defmodule OpenAgents.Memories.Retrieval do

94 144
  def name(Semantic), do: :semantic
95 145
  def name(_lexical), do: :lexical
96 146
97
  # Highest score first, then newest first, so an account whose memories all
98
  # score zero still gets a stable, meaningful order rather than table order.
99
  defp ordered(candidates, scores) do
147
  @doc """
148
  Pairs `candidates` with `scores` and orders them, highest first.
149
150
  Highest score first, then newest first, so a set of memories that all score
151
  zero still gets a stable, meaningful order rather than table order.
152
  `Enum.sort_by/2` is stable, so candidates that tie on both keys keep the
153
  order the caller read them in — which is why every caller reads them under a
154
  total order (`desc: inserted_at, desc: id`) and equal inputs give equal
155
  output.
156
157
  This is public because the system bucket ranks a pool no account owns
158
  (`OpenAgents.Memories.SystemRecall`) and must break its ties the same way
159
  this module breaks the account's. One tie-break rule, one place.
160
  """
161
  @spec order([Memory.t()], %{optional(String.t()) => float()}) :: [ranked()]
162
  def order(candidates, scores) when is_list(candidates) and is_map(scores) do
100 163
    candidates
101 164
    |> Enum.map(&{&1, Map.get(scores, &1.id, 0.0)})
102 165
    |> Enum.sort_by(fn {memory, score} -> {-score, -stamp(memory)} end)
lib/openagents/memories/retrieval/lexical.ex modified +41

@@ -66,6 +66,20 @@ defmodule OpenAgents.Memories.Retrieval.Lexical do

66 66
    _error -> :error
67 67
  end
68 68
69
  @impl true
70
  def score_shared(query, candidates) do
71
    text = prepare(query)
72
    ids = Enum.map(candidates, & &1.id)
73
74
    if text == "" or ids == [] do
75
      {:ok, %{}}
76
    else
77
      {:ok, shared(text, ids)}
78
    end
79
  rescue
80
    _error -> :error
81
  end
82
69 83
  # `user_id` is the scope predicate, and it is written here rather than
70 84
  # inherited from the candidate ids (MEMORY-010). The id list narrows the
71 85
  # read; it does not bound it, and a caller that assembled that list wrongly

@@ -89,6 +103,33 @@ defmodule OpenAgents.Memories.Retrieval.Lexical do

89 103
    |> Map.new(fn {id, rank} -> {id, rank / 1} end)
90 104
  end
91 105
106
  # The shared bucket's ranking query, and the one query in this module that
107
  # names no account. `bucket` is what stands in its place, which is
108
  # MEMORY-001's amendment written as a predicate rather than as a filter over
109
  # the result: a caller who assembled the candidate ids wrongly still cannot
110
  # reach an account-scoped row through this read. The caller narrowed those
111
  # ids to admitted, live, `ledger`-or-above rows before it got here
112
  # (`OpenAgents.Memories.SystemRecall`); this query re-states the two
113
  # predicates it can state cheaply rather than trusting the list alone.
114
  defp shared(text, ids) do
115
    from(memory in Memory,
116
      where: memory.bucket == "system",
117
      where: is_nil(memory.superseded_by_id),
118
      where: memory.id in ^ids,
119
      where: fragment("? @@ websearch_to_tsquery('english', ?)", memory.search_vector, ^text),
120
      select: {
121
        memory.id,
122
        fragment(
123
          "ts_rank_cd(?, websearch_to_tsquery('english', ?), 32)",
124
          memory.search_vector,
125
          ^text
126
        )
127
      }
128
    )
129
    |> Repo.all()
130
    |> Map.new(fn {id, rank} -> {id, rank / 1} end)
131
  end
132
92 133
  # `websearch_to_tsquery` reads `-` as negation, quotes as phrases, and `or`
93 134
  # as disjunction. A turn carrying punctuation would ask for something the
94 135
  # reader did not, so everything but letters and digits goes first; then the
lib/openagents/memories/retrieval/semantic.ex modified +17

@@ -53,6 +53,23 @@ defmodule OpenAgents.Memories.Retrieval.Semantic do

53 53
    _error -> :error
54 54
  end
55 55
56
  # This backend issues no query at all, so the shared pool costs it nothing
57
  # but a predicate on the candidates: only `system` rows are scored, which
58
  # keeps an account-scoped row out of the shared ranking even if a caller
59
  # assembled the list wrongly. The eligibility filter narrowed the list before
60
  # it arrived (`OpenAgents.Memories.SystemRecall`).
61
  @impl true
62
  def score_shared(query, candidates) do
63
    with true <- available?(),
64
         {:ok, vector} <- embed(query) do
65
      {:ok, cosines(vector, Enum.filter(candidates, &(&1.bucket == "system")))}
66
    else
67
      _unavailable -> :error
68
    end
69
  rescue
70
    _error -> :error
71
  end
72
56 73
  @doc """
57 74
  The embedding to store on a new memory, or `nil` when the rail is off.
58 75
lib/openagents/memories/system_recall.ex added +294

@@ -0,0 +1,294 @@

1
defmodule OpenAgents.Memories.SystemRecall do
2
  @moduledoc """
3
  The one recall path that crosses an account boundary, and the two things that
4
  make crossing it defensible: an eligibility filter written as a database
5
  predicate, and per-source caps that bound how much of a note any one writer
6
  can own.
7
8
  ## What this widens, and why it is a decision rather than a detail
9
10
  Every other read in `OpenAgents.Memories` names `user_id`. That is
11
  MEMORY-001 and MEMORY-010: recall is confined to the acting account, with no
12
  unscoped fallback anywhere. The `system` bucket is the deliberate exception —
13
  a row one account wrote, admitted by a steward's receipt, read into every
14
  account's turn. That is the point of the bucket and it is also the sharpest
15
  thing about it, so this module exists to hold the exception in one place
16
  where it can be read, enumerated, and tested, rather than as a widened
17
  bucket list somewhere in the account's query.
18
19
  Two properties keep it bounded:
20
21
  * **The unscoped reads are enumerable.** The eligibility read below and the
22
    shared ranking query in `OpenAgents.Memories.Retrieval.Lexical` are the
23
    only two reads in either memory plane that name no account, both name the
24
    `system` bucket in place of one, and both are declared by module and by
25
    count in `test/openagents/memories_test.exs`. A third fails that test until
26
    somebody declares it on purpose.
27
  * **It is off unless an operator turns it on.** `enabled?/0` reads
28
    `:memory_recall, :system_bucket_enabled`, which is `false` in
29
    `config/config.exs` and declared `false` in the production and staging
30
    profiles. With it off nothing here issues a query, so `recall/3` behaves
31
    byte for byte as it did before this module existed.
32
  * **The eligibility filter replaces the scope predicate.** It is not an
33
    application-side filter over rows this module read anyway. What reaches
34
    `memories` is a query whose predicates are the bucket, liveness, the tier
35
    floor, and the set of ids a steward's records derived as `admitted`.
36
37
  ## The eligibility filter (specification section 7.1)
38
39
  A row surfaces only when all of these hold, and every one of them is a
40
  predicate in `pool_query/1`:
41
42
  * `bucket = 'system'` — the whole namespace, one query.
43
  * `superseded_by_id IS NULL` — a corrected claim is the correction, not both.
44
  * `tier IN ('ledger','glass')` — at or above `ledger`. A `dark` or `pulse`
45
    claim cannot ship its content, so it is not a system memory; the table
46
    refuses one outright, and the predicate is here as well because a floor
47
    stated in exactly one place is a floor that moves when that place does.
48
  * `id IN (…)` — the ids `OpenAgents.Memories.Admissions.statuses/0` derived
49
    as `admitted`. Candidates, rejected rows, and rows suspended by an open
50
    evidenced challenge are all absent from that set, so none of them reaches
51
    the query at all.
52
53
  The derived status is bound into the query rather than compared after it.
54
  Deriving it is a fold over `memory_admissions` — a network-level table with
55
  no account column — and the fold cannot be pushed into the `memories` query
56
  because the challenge-flood cap is a property of the whole record set rather
57
  than of one row. So the honest description is: one read derives the admitted
58
  set, and the `memories` read then names that set as a predicate. Nothing is
59
  filtered out of a result.
60
61
  A row's own `admission` column is read by nothing here. An author who writes
62
  `admitted` on their own row has claimed something, and the claim reaches no
63
  turn.
64
65
  ## Per-source caps (specification section 7.2)
66
67
  Admission is the gate on truth; the caps are the gate on volume. A writer who
68
  passes the first still cannot win a note by flooding.
69
70
  * **Per-pool:** at most 25% of the ranked candidate pool's slots come from
71
    one account, enforced by a stable round-robin over accounts in rank order.
72
    Accounts enter the rotation in the order of their best-ranked memory, and
73
    each contributes its own memories in rank order, so the pool is a spread of
74
    the store rather than the top of whichever account writes most.
75
  * **Per-note:** at most 1 memory per account and at most 2 in total actually
76
    attach, matching the knowledge-base note limit. Past two, more network
77
    claims are noise rather than context.
78
79
  This is the pool cap the store-level cap in `Admissions` deliberately did not
80
  imply. That one bounds how much of the admitted **store** one account's
81
  challenges can suspend; this one bounds how much of one message's **pool**
82
  one account's claims can occupy, and a share of the store does not imply the
83
  same share of a pool, because an account's writing can concentrate on one
84
  subject.
85
86
  ## Determinism
87
88
  Equal inputs give equal notes. The eligibility read is ordered
89
  `desc: inserted_at, desc: id`, which is total; ranking is
90
  `OpenAgents.Memories.Retrieval.order/2`, the same stable sort the account's
91
  own recall uses; and the round-robin walks accounts and memories in that
92
  order without consulting anything else. No step reads insertion order from
93
  the database, a map's traversal order, or the wall clock.
94
95
  ## It degrades to silence
96
97
  An empty store, an unreadable one, an unavailable ranking backend: each one
98
  recalls nothing. `pool/1` never raises, because a turn that cannot reach the
99
  network's memory is a turn without it, not a failed turn.
100
  """
101
102
  import Ecto.Query
103
104
  alias OpenAgents.Memories.{Admissions, Memory, Retrieval}
105
  alias OpenAgents.Repo
106
107
  # Specification section 7.2's numbers. They are constants rather than
108
  # configuration: they are the poisoning posture the design argues for, not a
109
  # dial an operator tunes per deployment.
110
  @pool_share 25
111
  @per_account_per_note 1
112
  @per_note 2
113
114
  # The tier floor from section 5.2. `ledger` means the body and metadata are
115
  # readable by every account, which is what recall does with them.
116
  @tiers ~w(ledger glass)
117
118
  @doc """
119
  Whether this deployment surfaces the system bucket at all.
120
121
  `false` in `config/config.exs` and in the production profile. Read it before
122
  anything else: with it off, no query in this module runs and recall is the
123
  account-scoped read it has always been.
124
  """
125
  @spec enabled?() :: boolean()
126
  def enabled?, do: setting(:system_bucket_enabled, false) == true
127
128
  @doc "The tiers a system memory must carry to surface. The floor is `ledger`."
129
  @spec tiers() :: [String.t()]
130
  def tiers, do: @tiers
131
132
  @doc "The share of one message's ranked pool a single account may fill, as a percentage."
133
  @spec pool_share() :: pos_integer()
134
  def pool_share, do: @pool_share
135
136
  @doc """
137
  How many of `slots` one account may fill, rounded up.
138
139
  Rounded up so a pool of one or two candidates is not emptied by its own cap,
140
  and so the bound reads the same way `Admissions.challenge_cap/1` reads.
141
  """
142
  @spec pool_cap(non_neg_integer()) :: non_neg_integer()
143
  def pool_cap(slots) when is_integer(slots) and slots >= 0 do
144
    div(slots * @pool_share + 99, 100)
145
  end
146
147
  @doc "The most memories one account may put in a single note."
148
  @spec per_account_per_note() :: pos_integer()
149
  def per_account_per_note, do: @per_account_per_note
150
151
  @doc "The most system memories one note may carry in total."
152
  @spec per_note() :: pos_integer()
153
  def per_note, do: @per_note
154
155
  @doc """
156
  The ranked, capped candidate pool for `query`, highest first.
157
158
  Empty when the flag is off, when `query` is empty, and when nothing in the
159
  store is eligible. Each memory carries the status the admission records
160
  derived, on `derived_status`, so a note prints what a steward's receipts say
161
  rather than what the row claims about itself.
162
  """
163
  @spec pool(String.t()) :: [Memory.t()]
164
  def pool(query) when is_binary(query) and query != "" do
165
    if enabled?() do
166
      case eligible() do
167
        [] -> []
168
        candidates -> candidates |> rank(query) |> round_robin()
169
      end
170
    else
171
      []
172
    end
173
  rescue
174
    _error -> []
175
  end
176
177
  def pool(_query), do: []
178
179
  @doc """
180
  What one note attaches from `pool`, within `characters` of body text.
181
182
  The two caps of section 7.2's per-note half: one memory per account, two in
183
  total. The character budget is whatever the account's own memories left of
184
  MEMORY-010's per-turn ceiling, so the system bucket widens who a note can
185
  quote without widening how long a note can be.
186
187
  What the caps and the budget exclude is not reported. The account's own
188
  `dropped` count exists so a reader can see their store was larger than the
189
  turn; a count of network claims that did not fit would tell every account how
190
  big the shared store is, which is a fact about other accounts.
191
  """
192
  @spec attachable([Memory.t()], non_neg_integer()) :: [Memory.t()]
193
  def attachable(pool, characters) when is_list(pool) and is_integer(characters) do
194
    {kept, _seen, _left} =
195
      Enum.reduce(pool, {[], %{}, characters}, fn memory, {kept, seen, remaining} ->
196
        written = Map.get(seen, memory.user_id, 0)
197
        cost = String.length(memory.body)
198
199
        if length(kept) < @per_note and written < @per_account_per_note and cost <= remaining do
200
          {[memory | kept], Map.put(seen, memory.user_id, written + 1), remaining - cost}
201
        else
202
          {kept, seen, remaining}
203
        end
204
      end)
205
206
    Enum.reverse(kept)
207
  end
208
209
  # ── internal ───────────────────────────────────────────────────────────────
210
211
  # The eligibility filter, as one query. `admitted` is derived first because
212
  # the derivation is a fold over a table with no account column; what happens
213
  # here is that the derived set is named as a predicate rather than compared
214
  # to rows this read returned anyway.
215
  #
216
  # MEMORY-001: one of the two queries in the memory plane that name no
217
  # account, and these predicates are what stand in its place. The other is the
218
  # shared ranking query in `OpenAgents.Memories.Retrieval.Lexical`, which
219
  # names the bucket too and reads only the ids this one already narrowed.
220
  defp eligible do
221
    admitted = admitted_ids()
222
223
    if admitted == [] do
224
      []
225
    else
226
      admitted
227
      |> pool_query()
228
      |> Repo.all()
229
      |> Enum.map(&%{&1 | derived_status: "admitted"})
230
    end
231
  end
232
233
  # Named rather than inlined so the MEMORY-001 amendment has one query to
234
  # point at, and so a test can read this module's source and prove that the
235
  # predicates below are the ones standing in for `user_id`.
236
  defp pool_query(admitted) when is_list(admitted) do
237
    from(memory in Memory,
238
      where: memory.bucket == "system",
239
      where: is_nil(memory.superseded_by_id),
240
      where: memory.tier in ^@tiers,
241
      where: memory.id in ^admitted,
242
      order_by: [desc: memory.inserted_at, desc: memory.id],
243
      limit: ^maximum_pool()
244
    )
245
  end
246
247
  # Only `admitted`. `candidate`, `rejected`, and `suspended` are every other
248
  # value the derivation produces, and none of them surfaces.
249
  defp admitted_ids do
250
    for {id, "admitted"} <- Admissions.statuses(), do: id
251
  end
252
253
  # The same backend the account's recall runs on, and the same tie-breaks,
254
  # scored over a pool no account owns.
255
  defp rank(candidates, query) do
256
    {_backend, ranked} = Retrieval.rank_shared(query, candidates)
257
    ranked
258
  end
259
260
  # Section 7.2's per-pool cap. Accounts enter in the order of their
261
  # best-ranked memory and each contributes in its own rank order, one per
262
  # round, until the pool is full or every account is spent. A prolific account
263
  # therefore holds its share of the slots and no more, and an account with one
264
  # good memory is not pushed out by an account with forty mediocre ones.
265
  defp round_robin(ranked) do
266
    memories = Enum.map(ranked, fn {memory, _score} -> memory end)
267
    slots = min(length(memories), maximum_pool())
268
    cap = pool_cap(slots)
269
270
    accounts = memories |> Enum.map(& &1.user_id) |> Enum.uniq()
271
    by_account = Enum.group_by(memories, & &1.user_id)
272
273
    0..max(cap - 1, 0)
274
    |> Enum.flat_map(fn round ->
275
      Enum.flat_map(accounts, fn account ->
276
        case by_account |> Map.fetch!(account) |> Enum.at(round) do
277
          nil -> []
278
          memory -> [memory]
279
        end
280
      end)
281
    end)
282
    |> Enum.take(slots)
283
  end
284
285
  defp maximum_pool, do: setting(:maximum_system_pool, 40)
286
287
  # `|| []` rather than a `get_env/3` default: the key can be present and nil,
288
  # and a nil there would reach `Keyword.get/3` as a hard crash on a path whose
289
  # whole contract is to degrade.
290
  defp setting(key, fallback) do
291
    (Application.get_env(:openagents, :memory_recall) || [])
292
    |> Keyword.get(key, fallback)
293
  end
294
end
ops/deploy/fleet-startup.template.sh modified +7

@@ -92,6 +92,12 @@ export OPENAGENTS_FEATURE_SCV_CODEX="false"

92 92
export OPENAGENTS_FEATURE_SCV_DEPLOY="true"
93 93
export OPENAGENTS_FEATURE_SEMANTIC_MEMORY="true"
94 94
export OPENAGENTS_FEATURE_SHADOW_PROGRAMS="true"
95
# Cross-account recall of the system bucket (MEMORY-001, MEMORY-011). Off in
96
# production: an admitted system memory is written by one account and read by
97
# every account, which is the one place recall crosses the account boundary,
98
# and it ships dark until an operator turns it on deliberately. With it off,
99
# recall reads the `user` and `learned` buckets and nothing else.
100
export OPENAGENTS_FEATURE_SYSTEM_MEMORY_RECALL="false"
95 101
export OPENAGENTS_FEATURE_TOOL_EMBEDDINGS="true"
96 102
export OPENAGENTS_FEATURE_TOOLS="true"
97 103
export OPENAGENTS_FEATURE_TURN_RECOVERY="true"

@@ -165,6 +171,7 @@ ENV_NAMES=(

165 171
  OPENAGENTS_FEATURE_SCV_DEPLOY OPENAGENTS_SCV_DEPLOY_OUTPUT_ROOT
166 172
  OPENAGENTS_SCV_TEMPORARY_ROOT
167 173
  OPENAGENTS_FEATURE_SEMANTIC_MEMORY OPENAGENTS_FEATURE_SHADOW_PROGRAMS
174
  OPENAGENTS_FEATURE_SYSTEM_MEMORY_RECALL
168 175
  OPENAGENTS_FEATURE_TOOL_EMBEDDINGS OPENAGENTS_FEATURE_TOOLS
169 176
  OPENAGENTS_FEATURE_TURN_RECOVERY OPENAGENTS_FEATURE_VOICE
170 177
  OPENAGENTS_FEATURE_VOICE_RECORDING OPENAGENTS_FEATURE_VOICE_RETENTION
ops/staging/gate-5-profile.sh modified +1

@@ -45,6 +45,7 @@ export OPENAGENTS_FEATURE_MEMORY_PORTABILITY="false"

45 45
export OPENAGENTS_FEATURE_RA="false"
46 46
export OPENAGENTS_FEATURE_SEMANTIC_MEMORY="false"
47 47
export OPENAGENTS_FEATURE_SHADOW_PROGRAMS="false"
48
export OPENAGENTS_FEATURE_SYSTEM_MEMORY_RECALL="false"
48 49
export OPENAGENTS_FEATURE_TOOL_EMBEDDINGS="false"
49 50
export OPENAGENTS_FEATURE_TOOLS="true"
50 51
export OPENAGENTS_FEATURE_TURN_RECOVERY="false"
test/openagents/memories/system_recall_test.exs added +598

@@ -0,0 +1,598 @@

1
defmodule OpenAgents.Memories.SystemRecallTest do
2
  @moduledoc """
3
  The one recall path that crosses an account boundary: what it takes to turn
4
  it on, what it lets through when it is on, and what it changes when it is
5
  off.
6
7
  Four properties carry the weight.
8
9
  **Off is off, and off is the default.** The flag is `false` in
10
  `config/config.exs` and declared `false` in the production and staging
11
  profiles, and with it off an account's own recall is not merely similar to
12
  what it was before this bucket had a recall path — it is byte for byte the
13
  same. That is proved by comparison here rather than asserted: the same
14
  account's recall is captured with no system store behind it, an admitted
15
  system store is then written, and the two results are compared whole.
16
17
  **Eligibility is derived, never claimed.** A row surfaces only when the
18
  admission records derive `admitted` for it. A candidate, a rejected row, a
19
  suspended row, a superseded row, and a row whose own `admission` column says
20
  `admitted` with no steward behind it all reach nobody.
21
22
  **The caps bound volume, not truth.** One account that wrote most of the
23
  admitted store still fills at most its quarter of a message's pool and at
24
  most one line of a note.
25
26
  **It is deterministic.** Equal inputs give equal notes, every time.
27
  """
28
  use OpenAgents.DataCase, async: false
29
30
  alias OpenAgents.Memories
31
  alias OpenAgents.Memories.{Admissions, Memory, Note, Recall, SystemRecall}
32
33
  # The owner account is an operator by definition, so a steward needs no
34
  # configuration change.
35
  @owner_github_id 14_167_547
36
37
  setup do
38
    original = Application.get_env(:openagents, :memory_recall)
39
    on_exit(fn -> Application.put_env(:openagents, :memory_recall, original) end)
40
    :ok
41
  end
42
43
  defp surfacing(enabled?) do
44
    settings =
45
      :openagents
46
      |> Application.get_env(:memory_recall, [])
47
      |> Keyword.put(:system_bucket_enabled, enabled?)
48
49
    Application.put_env(:openagents, :memory_recall, settings)
50
  end
51
52
  defp account(key) do
53
    digest = :crypto.hash(:sha256, key)
54
    github_id = digest |> binary_part(0, 7) |> :binary.decode_unsigned()
55
56
    upsert(github_id, "sysrec-" <> (digest |> Base.encode16(case: :lower) |> binary_part(0, 12)))
57
  end
58
59
  defp steward, do: upsert(@owner_github_id, "AtlantisPleb")
60
61
  defp upsert(github_id, login) do
62
    {:ok, user} =
63
      OpenAgents.Accounts.upsert_github_user(%{
64
        github_id: github_id,
65
        github_login: login,
66
        github_avatar_url: "https://avatars.githubusercontent.com/u/#{github_id}?v=4"
67
      })
68
69
    user
70
  end
71
72
  defp evidence do
73
    [%{"kind" => "receipt", "ref" => "receipt:4f1c", "digest" => "sha256:9ab3"}]
74
  end
75
76
  defp candidate(overrides \\ %{}) do
77
    Map.merge(
78
      %{
79
        "bucket" => "system",
80
        "slug" => "sys:gateway-402-retired-model",
81
        "body" =>
82
          "A 402 from the inference gateway usually means the default model was " <>
83
            "retired upstream. Check gateway status before bisecting local lanes.",
84
        "entity" => "inference-gateway",
85
        "tier" => "ledger",
86
        "as_of" => ~D[2026-08-25],
87
        "admission" => "candidate",
88
        "evidence_refs" => evidence()
89
      },
90
      overrides
91
    )
92
  end
93
94
  # A row assembled as a struct rather than through `changeset/2`, so the tier
95
  # floor is proved against the table rather than against the validation.
96
  defp around_the_changeset(user, overrides) do
97
    fields =
98
      Map.merge(
99
        %{
100
          user_id: user.id,
101
          bucket: "system",
102
          body: "Written around the write path.",
103
          slug: "sys:around-the-write-path",
104
          tier: "ledger",
105
          as_of: ~D[2026-08-25],
106
          admission: "candidate",
107
          evidence_refs: evidence()
108
        },
109
        overrides
110
      )
111
112
    Repo.insert(struct(Memory, fields))
113
  end
114
115
  # Written, then judged. The verdict is a steward's record, which is the only
116
  # thing `status/1` reads.
117
  defp admitted(author, overrides \\ %{}) do
118
    {:ok, memory} = Memories.create(author, candidate(overrides))
119
120
    {:ok, _record} =
121
      Admissions.record(steward(), memory.id, %{
122
        "verdict" => "admitted",
123
        "ground" => "The receipt supports the claim."
124
      })
125
126
    memory
127
  end
128
129
  @turn "the inference gateway returned 402 on the default model"
130
131
  describe "the flag, off" do
132
    test "is the default this repository ships" do
133
      refute SystemRecall.enabled?()
134
    end
135
136
    test "surfaces an admitted system memory to nobody, including its author" do
137
      author = account("off-author")
138
      reader = account("off-reader")
139
140
      memory = admitted(author)
141
      assert Admissions.status(memory) == "admitted"
142
143
      assert %Recall{memories: []} = Memories.recall(reader, @turn)
144
      assert %Recall{memories: []} = Memories.recall(author, @turn)
145
    end
146
147
    test "issues no query for the bucket at all" do
148
      assert SystemRecall.pool(@turn) == []
149
    end
150
151
    test "leaves user and learned recall byte for byte what it was" do
152
      reader = account("off-identical")
153
      author = account("off-identical-author")
154
155
      {:ok, _asked} = Memories.create(reader, %{"body" => "I use pnpm, not npm."})
156
157
      {:ok, _learned} =
158
        Memories.create(reader, %{
159
          "body" => "The inference gateway returned 402 during the last deploy.",
160
          "bucket" => "learned"
161
        })
162
163
      before = Memories.recall(reader, @turn)
164
      before_note = Note.render(before)
165
166
      # An admitted store, written by another account and by this one, is now
167
      # behind the same query.
168
      _theirs = admitted(author)
169
      _mine = admitted(reader, %{"slug" => "sys:precommit-installs-the-push-guard"})
170
171
      after_store = Memories.recall(reader, @turn)
172
173
      assert after_store == before
174
      assert Note.render(after_store) == before_note
175
      refute before_note == nil
176
    end
177
  end
178
179
  describe "the flag, on" do
180
    setup do
181
      surfacing(true)
182
      :ok
183
    end
184
185
    test "an admitted ledger memory reaches an account that did not write it" do
186
      author = account("on-author")
187
      reader = account("on-reader")
188
189
      memory = admitted(author)
190
191
      %Recall{memories: recalled} = Memories.recall(reader, @turn)
192
193
      assert Enum.map(recalled, & &1.id) == [memory.id]
194
    end
195
196
    test "and reaches its own author too" do
197
      author = account("on-own-author")
198
199
      memory = admitted(author)
200
201
      %Recall{memories: recalled} = Memories.recall(author, @turn)
202
203
      assert Enum.map(recalled, & &1.id) == [memory.id]
204
    end
205
206
    test "a candidate with no verdict behind it reaches nobody" do
207
      author = account("on-candidate")
208
      reader = account("on-candidate-reader")
209
210
      {:ok, memory} = Memories.create(author, candidate())
211
      assert Admissions.status(memory) == "candidate"
212
213
      assert %Recall{memories: []} = Memories.recall(reader, @turn)
214
    end
215
216
    test "nor does a row that claims admission for itself" do
217
      author = account("on-self-claimed")
218
      reader = account("on-self-claimed-reader")
219
220
      {:ok, memory} = Memories.create(author, candidate(%{"admission" => "admitted"}))
221
222
      assert memory.admission == "admitted"
223
      assert Admissions.status(memory) == "candidate"
224
      assert %Recall{memories: []} = Memories.recall(reader, @turn)
225
    end
226
227
    test "a rejected memory reaches nobody" do
228
      author = account("on-rejected")
229
      reader = account("on-rejected-reader")
230
231
      {:ok, memory} = Memories.create(author, candidate())
232
233
      {:ok, _record} =
234
        Admissions.record(steward(), memory.id, %{
235
          "verdict" => "rejected",
236
          "ground" => "The receipt does not say this."
237
        })
238
239
      assert Admissions.status(memory) == "rejected"
240
      assert %Recall{memories: []} = Memories.recall(reader, @turn)
241
    end
242
243
    test "a suspended memory leaves recall until the challenge is resolved" do
244
      author = account("on-suspended")
245
      reader = account("on-suspended-reader")
246
      challenger = account("on-suspended-challenger")
247
248
      memory = admitted(author)
249
250
      {:ok, challenge} =
251
        Admissions.challenge(challenger, memory.id, %{
252
          "ground" => "The gateway returns 402 for an expired credential too.",
253
          "evidence_refs" => [
254
            %{"kind" => "url", "ref" => "https://example.test/status", "digest" => "sha256:1c2d"}
255
          ]
256
        })
257
258
      assert Admissions.status(memory) == "suspended"
259
      assert %Recall{memories: []} = Memories.recall(reader, @turn)
260
261
      {:ok, _refutation} =
262
        Admissions.refute(steward(), challenge.id, %{
263
          "ground" => "The receipt distinguishes the two cases."
264
        })
265
266
      assert Admissions.status(memory) == "admitted"
267
      %Recall{memories: restored} = Memories.recall(reader, @turn)
268
      assert Enum.map(restored, & &1.id) == [memory.id]
269
    end
270
271
    test "a superseded memory reaches nobody; its replacement does" do
272
      author = account("on-superseded")
273
      reader = account("on-superseded-reader")
274
275
      memory = admitted(author)
276
277
      {:ok, replacement} =
278
        Admissions.supersede(
279
          author,
280
          memory.id,
281
          candidate(%{"body" => "A 402 means the credential expired. Check the key first."})
282
        )
283
284
      {:ok, _record} =
285
        Admissions.record(steward(), replacement.id, %{
286
          "verdict" => "admitted",
287
          "ground" => "The corrected claim is supported."
288
        })
289
290
      %Recall{memories: recalled} = Memories.recall(reader, @turn)
291
292
      assert Enum.map(recalled, & &1.id) == [replacement.id]
293
    end
294
295
    # The tier floor is enforced in three places and reachable through none of
296
    # them. `dark` and `pulse` are refused by the changeset, refused by
297
    # `memories_system_shape` when a caller writes around it, and named as a
298
    # predicate in the eligibility read so the floor does not depend on the
299
    # table alone.
300
    test "a sub-ledger tier cannot be written, and the read names the floor anyway" do
301
      author = account("on-dark-tier")
302
303
      assert {:error, changeset} = Memories.create(author, candidate(%{"tier" => "dark"}))
304
      assert %{tier: ["is invalid"]} = errors_on(changeset)
305
306
      assert_raise Ecto.ConstraintError, ~r/memories_system_shape/, fn ->
307
        around_the_changeset(author, %{tier: "pulse"})
308
      end
309
310
      assert SystemRecall.tiers() == ~w(ledger glass)
311
    end
312
313
    test "an empty store recalls nothing rather than failing the turn" do
314
      reader = account("on-empty")
315
316
      assert %Recall{memories: [], dropped: 0} = Memories.recall(reader, @turn)
317
    end
318
  end
319
320
  describe "the note" do
321
    setup do
322
      surfacing(true)
323
      :ok
324
    end
325
326
    test "names the bucket, the claim date, and the derived status" do
327
      author = account("note-author")
328
      reader = account("note-reader")
329
330
      _memory =
331
        admitted(author, %{"body" => "Check gateway status first.", "as_of" => ~D[2026-08-25]})
332
333
      note = reader |> Memories.recall(@turn) |> Note.render()
334
335
      assert note ==
336
               "[From memory: (system, as of 2026-08-25, admitted)] Check gateway status first."
337
    end
338
339
    test "reads differently from the account's own memories in the same note" do
340
      reader = account("note-mixed")
341
      author = account("note-mixed-author")
342
343
      {:ok, _asked} = Memories.create(reader, %{"body" => "I use pnpm, not npm."})
344
      _memory = admitted(author, %{"body" => "Check gateway status first."})
345
346
      note = reader |> Memories.recall(@turn) |> Note.render()
347
348
      assert [own, network] = String.split(note, "\n")
349
      assert own =~ ~r/^\[From memory: user, /
350
351
      assert network ==
352
               "[From memory: (system, as of 2026-08-25, admitted)] Check gateway status first."
353
    end
354
355
    test "prints the status the records derived rather than the column the author wrote" do
356
      author = account("note-derived")
357
      reader = account("note-derived-reader")
358
359
      _memory =
360
        admitted(author, %{"admission" => "rejected", "body" => "Check gateway status first."})
361
362
      note = reader |> Memories.recall(@turn) |> Note.render()
363
364
      assert note =~ "admitted)"
365
      refute note =~ "rejected"
366
    end
367
368
    test "spends the account's character budget rather than one of its own" do
369
      reader = account("note-budget")
370
      author = account("note-budget-author")
371
372
      {:ok, _asked} = Memories.create(reader, %{"body" => String.duplicate("a", 60)})
373
      _memory = admitted(author, %{"body" => String.duplicate("b", 60)})
374
375
      %Recall{memories: recalled} =
376
        Memories.recall(reader, @turn, maximum_attached_characters: 80)
377
378
      assert length(recalled) == 1
379
      assert hd(recalled).bucket == "user"
380
    end
381
  end
382
383
  describe "the caps" do
384
    setup do
385
      surfacing(true)
386
      :ok
387
    end
388
389
    test "hold the pool to a quarter of its slots per account" do
390
      prolific = account("cap-prolific")
391
      other = account("cap-other")
392
393
      for index <- 1..8 do
394
        admitted(prolific, %{
395
          "slug" => "sys:prolific-#{index}",
396
          "body" => "Gateway claim number #{index} about the inference gateway."
397
        })
398
      end
399
400
      only = admitted(other, %{"slug" => "sys:other-1", "body" => "One claim about the gateway."})
401
402
      pool = SystemRecall.pool(@turn)
403
404
      # Nine eligible rows, so the cap is three per account, rounded up.
405
      assert SystemRecall.pool_cap(9) == 3
406
      assert Enum.count(pool, &(&1.user_id == prolific.id)) == 3
407
      assert only.id in Enum.map(pool, & &1.id)
408
    end
409
410
    test "hold a note to one memory per account and two in all" do
411
      prolific = account("note-cap-prolific")
412
      second = account("note-cap-second")
413
      third = account("note-cap-third")
414
      reader = account("note-cap-reader")
415
416
      for index <- 1..4 do
417
        admitted(prolific, %{
418
          "slug" => "sys:note-prolific-#{index}",
419
          "body" => "Gateway claim number #{index} about the inference gateway."
420
        })
421
      end
422
423
      admitted(second, %{
424
        "slug" => "sys:note-second",
425
        "body" => "A second account's gateway claim."
426
      })
427
428
      admitted(third, %{"slug" => "sys:note-third", "body" => "A third account's gateway claim."})
429
430
      %Recall{memories: recalled} = Memories.recall(reader, @turn)
431
432
      assert length(recalled) == 2
433
434
      authors = Enum.map(recalled, & &1.user_id)
435
      assert authors == Enum.uniq(authors)
436
      assert Enum.count(recalled, &(&1.user_id == prolific.id)) <= 1
437
    end
438
439
    test "leave a single-account store recallable rather than empty" do
440
      author = account("cap-single")
441
      reader = account("cap-single-reader")
442
443
      for index <- 1..4 do
444
        admitted(author, %{
445
          "slug" => "sys:single-#{index}",
446
          "body" => "Gateway claim number #{index} about the inference gateway."
447
        })
448
      end
449
450
      %Recall{memories: recalled} = Memories.recall(reader, @turn)
451
452
      assert length(recalled) == 1
453
    end
454
455
    test "do not report what they excluded" do
456
      prolific = account("cap-silent")
457
      reader = account("cap-silent-reader")
458
459
      for index <- 1..4 do
460
        admitted(prolific, %{
461
          "slug" => "sys:silent-#{index}",
462
          "body" => "Gateway claim number #{index} about the inference gateway."
463
        })
464
      end
465
466
      %Recall{dropped: dropped} = Memories.recall(reader, @turn)
467
468
      assert dropped == 0
469
    end
470
471
    test "round up so a pool of one is not emptied by its own cap" do
472
      assert SystemRecall.pool_cap(0) == 0
473
      assert SystemRecall.pool_cap(1) == 1
474
      assert SystemRecall.pool_cap(4) == 1
475
      assert SystemRecall.pool_cap(5) == 2
476
      assert SystemRecall.pool_cap(8) == 2
477
    end
478
  end
479
480
  describe "determinism" do
481
    setup do
482
      surfacing(true)
483
      :ok
484
    end
485
486
    test "equal inputs give equal pools and equal notes" do
487
      first = account("det-first")
488
      second = account("det-second")
489
      reader = account("det-reader")
490
491
      for index <- 1..3 do
492
        admitted(first, %{
493
          "slug" => "sys:det-first-#{index}",
494
          "body" => "First account's gateway claim number #{index}."
495
        })
496
497
        admitted(second, %{
498
          "slug" => "sys:det-second-#{index}",
499
          "body" => "Second account's gateway claim number #{index}."
500
        })
501
      end
502
503
      pools = for _repeat <- 1..5, do: Enum.map(SystemRecall.pool(@turn), & &1.id)
504
      notes = for _repeat <- 1..5, do: reader |> Memories.recall(@turn) |> Note.render()
505
506
      assert Enum.uniq(pools) == [hd(pools)]
507
      assert Enum.uniq(notes) == [hd(notes)]
508
    end
509
  end
510
511
  # MEMORY-004 and MEMORY-001's amendment. The eligibility filter is what
512
  # replaces the scope predicate for this bucket, so it has to be a predicate:
513
  # written into the query, not applied to what the query returned.
514
  describe "MEMORY-004" do
515
    @source "lib/openagents/memories/system_recall.ex"
516
517
    test "the eligibility filter is written into the query" do
518
      query = @source |> queries() |> List.first()
519
520
      assert query, "no query rooted at `Memory` found in #{@source}"
521
522
      for predicate <- ~w(bucket superseded_by_id tier id)a do
523
        assert names?(query, predicate),
524
               """
525
               The eligibility read in #{@source} does not name `#{predicate}`.
526
               MEMORY-001's amendment replaces the account predicate with this
527
               filter, so every part of it belongs in the query.
528
529
               #{Macro.to_string(query)}
530
               """
531
      end
532
    end
533
534
    test "and it names no account" do
535
      for query <- queries(@source) do
536
        refute names?(query, :user_id),
537
               """
538
               A query in #{@source} names `user_id`. This module holds the
539
               reads that deliberately do not; an account-scoped read belongs
540
               in `OpenAgents.Memories`.
541
               """
542
      end
543
    end
544
545
    defp queries(path) do
546
      path
547
      |> File.read!()
548
      |> Code.string_to_quoted!()
549
      |> Macro.prewalker()
550
      |> Enum.filter(fn
551
        {:from, _meta, [{:in, _, [_binding, {:__aliases__, _, [:Memory]}]} | _]} -> true
552
        _other -> false
553
      end)
554
    end
555
556
    defp names?(query, field) do
557
      query
558
      |> Macro.prewalker()
559
      |> Enum.any?(fn
560
        {{:., _, [_, ^field]}, _, _} -> true
561
        _other -> false
562
      end)
563
    end
564
  end
565
566
  # The bucket still belongs to the account that wrote it for every purpose but
567
  # recall. Reading a network claim in a turn does not make it listable,
568
  # fetchable, or correctable by the reader.
569
  describe "the write boundary is unmoved" do
570
    setup do
571
      surfacing(true)
572
      :ok
573
    end
574
575
    test "a reader who recalls a system memory still cannot read it through the store" do
576
      author = account("boundary-author")
577
      reader = account("boundary-reader")
578
579
      memory = admitted(author)
580
581
      %Recall{memories: recalled} = Memories.recall(reader, @turn)
582
      assert Enum.map(recalled, & &1.id) == [memory.id]
583
584
      assert {:error, :not_found} = Memories.fetch(reader, memory.id)
585
      assert Memories.list(reader, bucket: "system") == []
586
      assert {:error, :not_supersedable} = Admissions.supersede(reader, memory.id, candidate())
587
    end
588
589
    test "and the account's own buckets stay account-scoped with the flag on" do
590
      reader = account("boundary-scoped")
591
      other = account("boundary-scoped-other")
592
593
      {:ok, _theirs} = Memories.create(other, %{"body" => "Deploy with yarn."})
594
595
      assert %Recall{memories: []} = Memories.recall(reader, "deploy with yarn")
596
    end
597
  end
598
end
test/openagents/memories_test.exs modified +52 -6

@@ -326,22 +326,56 @@ defmodule OpenAgents.MemoriesTest do

326 326
      # — a steward correcting a network claim — and it does so as a predicate
327 327
      # inside the `UPDATE`, naming `user_id` beside the role. Nothing is read
328 328
      # out, so a caller with no standing learns nothing from the refusal.
329
      "lib/openagents/memories/admissions.ex"
329
      "lib/openagents/memories/admissions.ex",
330
      # The system bucket's read path, which names no account at all.
331
      "lib/openagents/memories/system_recall.ex"
330 332
    ]
331 333
332
    test "every query rooted at the memory plane names user_id" do
334
    # MEMORY-001's amendment, written as a budget. Two queries in the plane
335
    # name no account — the system bucket's eligibility read and its shared
336
    # ranking query — and each of them must name the `system` bucket in place
337
    # of `user_id`. Declaring the count by module is what makes a third
338
    # unscoped query fail here: it is admitted by name and by number, never by
339
    # shape, so nothing widens by resembling something that already did.
340
    @unscoped_queries %{
341
      "lib/openagents/memories/retrieval/lexical.ex" => 1,
342
      "lib/openagents/memories/system_recall.ex" => 1
343
    }
344
345
    test "every query rooted at the memory plane names user_id, or the system bucket" do
333 346
      for path <- @scoped_modules, query <- memory_queries(path) do
334
        assert names_user_id?(query),
347
        assert names_user_id?(query) or names_system_bucket?(query),
335 348
               """
336
               A query in #{path} is rooted at `Memory` and does not name
337
               `user_id`. MEMORY-010 requires the account boundary to be a
338
               database predicate. Add the column, or amend the invariant.
349
               A query in #{path} is rooted at `Memory` and names neither
350
               `user_id` nor the `system` bucket. MEMORY-010 requires the
351
               account boundary to be a database predicate, and MEMORY-001's
352
               amendment admits exactly one substitute for it. Add the column,
353
               or amend the invariant.
339 354
340 355
               #{Macro.to_string(query)}
341 356
               """
342 357
      end
343 358
    end
344 359
360
    test "and no more queries name no account than the amendment declares" do
361
      for path <- @scoped_modules do
362
        declared = Map.get(@unscoped_queries, path, 0)
363
364
        found =
365
          path
366
          |> memory_queries()
367
          |> Enum.count(&(not names_user_id?(&1)))
368
369
        assert found == declared,
370
               """
371
               #{path} has #{found} query or queries rooted at `Memory` that
372
               name no account, and MEMORY-001's amendment declares #{declared}.
373
               Every one of them reads across the account boundary. Declare it
374
               here on purpose, and amend MEMORY-001 to say why, or scope it.
375
               """
376
      end
377
    end
378
345 379
    test "the enumeration actually finds queries" do
346 380
      found = Enum.flat_map(@scoped_modules, &memory_queries/1)
347 381
      assert length(found) >= 4

@@ -372,6 +406,18 @@ defmodule OpenAgents.MemoriesTest do

372 406
373 407
    defp rooted_at_memory?(_node), do: false
374 408
409
    # `memory.bucket == "system"` in a query. The literal is required: a query
410
    # comparing `bucket` to a bound variable could be handed any bucket at all,
411
    # which is the account boundary back where it started.
412
    defp names_system_bucket?(query) do
413
      query
414
      |> Macro.prewalker()
415
      |> Enum.any?(fn
416
        {:==, _, [{{:., _, [_, :bucket]}, _, _}, "system"]} -> true
417
        _other -> false
418
      end)
419
    end
420
375 421
    # `memory.user_id` in a query, or `user_id:` in a repo call's clauses.
376 422
    defp names_user_id?(query) do
377 423
      query
test/openagents_web/controllers/responses_controller_test.exs modified +120

@@ -254,6 +254,126 @@ defmodule OpenAgentsWeb.ResponsesControllerTest do

254 254
    end
255 255
  end
256 256
257
  # MEMORY-001's amendment, at the surface it changes. Recall is server-side,
258
  # so the network's memory reaches the model with no tool in the request and
259
  # no client plumbing at all — and it reaches it only where an operator turned
260
  # the switch on.
261
  describe "recall for the system bucket" do
262
    setup do
263
      swap_lane(RecordingTestProvider)
264
      Application.put_env(:openagents, :test_recording_provider_observer, self())
265
      previous = Application.get_env(:openagents, :memory_recall) || []
266
267
      on_exit(fn ->
268
        Application.delete_env(:openagents, :test_recording_provider_observer)
269
        Application.put_env(:openagents, :memory_recall, previous)
270
      end)
271
272
      %{settings: previous}
273
    end
274
275
    defp surfacing(settings, enabled?) do
276
      Application.put_env(
277
        :openagents,
278
        :memory_recall,
279
        Keyword.put(settings, :system_bucket_enabled, enabled?)
280
      )
281
    end
282
283
    # The steward set is the operator allowlist of GitHub numeric IDs, and the
284
    # owner's account is in it by definition, so this needs no configuration
285
    # change.
286
    defp steward do
287
      github_id = 14_167_547
288
289
      {:ok, user} =
290
        OpenAgents.Accounts.upsert_github_user(%{
291
          github_id: github_id,
292
          github_login: "AtlantisPleb",
293
          github_avatar_url: "https://avatars.githubusercontent.com/u/#{github_id}?v=4"
294
        })
295
296
      user
297
    end
298
299
    defp system_candidate(author) do
300
      OpenAgents.Memories.create(author, %{
301
        "bucket" => "system",
302
        "slug" => "sys:gateway-402-retired-model",
303
        "body" => "A 402 from the inference gateway usually means the model was retired.",
304
        "tier" => "ledger",
305
        "as_of" => ~D[2026-08-25],
306
        "admission" => "candidate",
307
        "evidence_refs" => [
308
          %{"kind" => "receipt", "ref" => "receipt:4f1c", "digest" => "sha256:9ab3"}
309
        ]
310
      })
311
    end
312
313
    test "attaches an admitted memory another account wrote, with no tool in the request",
314
         %{conn: conn, settings: settings} do
315
      surfacing(settings, true)
316
317
      author = github_user("responses-system-author")
318
      {:ok, memory} = system_candidate(author)
319
320
      {:ok, _record} =
321
        OpenAgents.Memories.Admissions.record(steward(), memory.id, %{
322
          "verdict" => "admitted",
323
          "ground" => "The receipt supports the claim."
324
        })
325
326
      conn = put_chat_api_token(conn, "responses-system-reader")
327
328
      assert conn
329
             |> post(~p"/api/v1/responses", %{input: "the inference gateway returned 402"})
330
             |> json_response(200)
331
332
      assert_receive {:recorded_request, _id, request}
333
      assert request.instructions =~ "[From memory: (system, as of 2026-08-25, admitted)]"
334
      assert request.instructions =~ "the model was retired"
335
      assert request.tool_definitions == []
336
    end
337
338
    test "attaches nothing for a candidate", %{conn: conn, settings: settings} do
339
      surfacing(settings, true)
340
341
      author = github_user("responses-system-candidate-author")
342
      {:ok, _memory} = system_candidate(author)
343
344
      conn = put_chat_api_token(conn, "responses-system-candidate-reader")
345
346
      assert conn
347
             |> post(~p"/api/v1/responses", %{input: "the inference gateway returned 402"})
348
             |> json_response(200)
349
350
      assert_receive {:recorded_request, _id, request}
351
      refute request.instructions =~ "From memory"
352
    end
353
354
    test "and nothing at all with the switch off", %{conn: conn, settings: settings} do
355
      surfacing(settings, false)
356
357
      author = github_user("responses-system-off-author")
358
      {:ok, memory} = system_candidate(author)
359
360
      {:ok, _record} =
361
        OpenAgents.Memories.Admissions.record(steward(), memory.id, %{
362
          "verdict" => "admitted",
363
          "ground" => "The receipt supports the claim."
364
        })
365
366
      conn = put_chat_api_token(conn, "responses-system-off-reader")
367
368
      assert conn
369
             |> post(~p"/api/v1/responses", %{input: "the inference gateway returned 402"})
370
             |> json_response(200)
371
372
      assert_receive {:recorded_request, _id, request}
373
      refute request.instructions =~ "From memory"
374
    end
375
  end
376
257 377
  describe "an unrecognized caller is unchanged" do
258 378
    setup do
259 379
      swap_lane(RecordingTestProvider)

This page updates live while a promote is in flight · changelog