Admit a system memory by receipt, and surface it to nobody yet

de36df51d20c · AtlantisPleb · · parent 64ee368d4a9e

Admit a system memory by receipt, and surface it to nobody yet

The memory store gains its third bucket. A `user` memory is what one reader
asked to have remembered; a `system` memory is what the network as a whole has
learned, and the difference in blast radius is the whole design. A wrong user
memory misleads one session. A wrong system memory would reach every session,
so the row carries what makes a claim answerable and the store refuses one that
does not.

## The record shape

A `system` row carries fields the other two buckets carry none of: a `sys:`
slug, a transparency `tier` of `ledger` or `glass` and never lower, an `as_of`
date that dates the claim rather than the insert, an `admission` the author
claims, and a non-empty `evidence_refs` list whose every entry names a `kind`
of `receipt`, `memory`, or `url`, a `ref`, and a digest. The digest is what
keeps evidence from being swapped after admission.

Both halves of that rule are the `memories_system_shape` constraint as well as
the changeset. An evidence-free or sub-`ledger` candidate is unrepresentable at
the table, not merely unwritten by the code that exists today, and a `user` or
`learned` row that carries a tier is refused the same way. Every column is
asserted `IS NOT NULL` before it is compared, because a check constraint passes
when it evaluates to NULL — an absent evidence list, rather than an empty one,
is exactly the hole the constraint exists to close, and it opened once during
this work before the assertion closed it.

## Admission is a receipt

A verdict is a row in `memory_admissions`: attributed to the steward who wrote
it, dated, and append-only, with no `updated_at` and no path that updates one.
`Admissions.status/1` derives the effective status from those records, so the
`admission` column on the candidate is the author's claim and nothing more — a
row that says `admitted` with no steward record behind it reads as a candidate.
A steward who changes their mind writes a second record and both stay readable.

Only a steward admits. The specification called for a published, signed
allowlist of pubkeys evaluated as of an event's timestamp; on this substrate a
single trusted server holds the role, so the check is
`OpenAgents.Accounts.admin?/1` — the operator allowlist of immutable GitHub
numeric IDs, bootstrapped to the owner's account, which is the only
account-level authority this server has. ADMIN-001 now enumerates this module
as an operator gate.

Only the author or a steward corrects a system slug, and supersession is the
only correction path there is.

## Neither path reads another account's memory

An admission record proves its candidate is a system row through the composite
foreign key `(memory_id, memory_bucket) -> memories (id, bucket)` rather than
through a lookup, and a correction authorizes inside the `UPDATE` predicate —
the actor's own `user_id` or the steward role — so a caller with no standing is
refused without learning that the row exists. MEMORY-010's rule that every
query rooted at `Memory` names `user_id` holds unchanged, and its AST proof
reads the new module too.

## Nothing surfaces

`recall/3` reads the `user` and `learned` buckets, named as a predicate in the
query rather than filtered out of its result. No session sees a system memory —
not another account's, and not its own author's.

That is deliberate. An admitted row read into every account's turn is
cross-account recall by construction, which is a privacy-policy change that
MEMORY-001 and MEMORY-010 forbid and that belongs to the recall issue with its
own eligibility filter. A system memory that is stored, evidenced, and admitted
but recalled by nobody is a coherent shippable state; a quietly widened recall
predicate is not. MEMORY-011 records the whole contract.

The HTTP surface is unchanged for the same reason: `POST /api/v1/memories`
names none of the system fields, so a caller cannot propose a network claim
through the route that writes their own memories.

Refs OpenAgentsInc/openagents#61.

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 429 · 2026-08-25T23:28:38.521616Z

Changed files

  • modified INVARIANTS.md
  • modified docs/taxonomy.md
  • modified lib/openagents/memories.ex
  • added lib/openagents/memories/admission.ex
  • added lib/openagents/memories/admissions.ex
  • modified lib/openagents/memories/memory.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260825220000_create_system_memories.exs
  • added test/openagents/memories/system_memory_test.exs
  • modified test/openagents/memories_test.exs
  • modified test/openagents_web/controllers/memory_controller_test.exs
  • modified test/openagents_web/operator_surface_test.exs

Diff

12 files changed, +1484 -34

INVARIANTS.md modified +71

@@ -1119,6 +1119,68 @@ the `memories` table's shape constraint and partial indexes,

1119 1119
`test/openagents_web/controllers/memory_controller_test.exs`, and
1120 1120
`test/openagents_web/controllers/responses_controller_test.exs`.
1121 1121
1122
### MEMORY-011 — A system memory is evidenced, admitted by receipt, and surfaced to nobody
1123
1124
Status: Current
1125
1126
The `system` bucket of `OpenAgents.Memories` holds what the network as a whole
1127
has learned rather than what one account asked to have remembered. A wrong
1128
`user` memory misleads one session; a wrong `system` memory would reach every
1129
session, so the row carries what makes a claim answerable and the store refuses
1130
one that does not.
1131
1132
A system row carries fields the other two buckets carry none of: a `sys:` slug,
1133
a transparency `tier` of `ledger` or `glass` and never lower, an `as_of` date
1134
distinct from the insert time, an `admission` the author claims, and a
1135
non-empty `evidence_refs` list whose every entry names a `kind` of `receipt`,
1136
`memory`, or `url`, a `ref`, and a digest. Both halves are the
1137
`memories_system_shape` constraint as well as the changeset: an evidence-free
1138
or sub-`ledger` candidate is unrepresentable at the table, not merely unwritten
1139
by the code that exists today, and a `user` or `learned` row carrying a tier is
1140
refused the same way. Every column is asserted `IS NOT NULL` before it is
1141
compared, because a check constraint passes when it evaluates to NULL and an
1142
absent evidence list is exactly the hole the constraint exists to close.
1143
1144
Admission is a receipt, never a field. A verdict is a row in
1145
`memory_admissions` — attributed to the steward who wrote it, dated, and
1146
append-only, with no `updated_at` and no path that updates one — and
1147
`OpenAgents.Memories.Admissions.status/1` derives the effective status from
1148
those records. The `admission` column on the candidate is the author's claim
1149
and nothing more, so a row that says `admitted` with no steward record behind
1150
it reads as a candidate. A steward who changes their mind writes a second
1151
record; both stay readable.
1152
1153
Only a steward admits, and the steward set is ADMIN-001's operator allowlist,
1154
bootstrapped to the owner's account. Only the original author or a steward
1155
writes a superseding row on a system slug; anyone else is refused, and
1156
supersession is the only correction path there is.
1157
1158
Neither path reads another account's memory. An admission record proves its
1159
candidate is a system row through the composite foreign key
1160
`(memory_id, memory_bucket) -> memories (id, bucket)` rather than through a
1161
lookup, and a correction authorizes inside the `UPDATE` predicate — the actor's
1162
own `user_id` or the steward role — so a caller with no standing is refused
1163
without learning that the row exists. MEMORY-010's rule that every query rooted
1164
at `OpenAgents.Memories.Memory` names `user_id` holds unchanged, and the AST
1165
proof reads this module too.
1166
1167
Nothing surfaces. `OpenAgents.Memories.recall/3` reads the `user` and `learned`
1168
buckets, named as a predicate in the query rather than filtered out of its
1169
result, and no session sees a system memory — not another account's, and not
1170
its own author's. That is deliberate and it is the whole reason this invariant
1171
can stand beside MEMORY-001 and MEMORY-010 rather than amending them: an
1172
admitted row read into every account's turn is cross-account recall by
1173
construction, so surfacing the bucket is a privacy decision with an eligibility
1174
filter of its own, not a ranking change. A system memory that is stored,
1175
evidenced, and admitted but recalled by nobody is a coherent state; a quietly
1176
widened recall predicate is not.
1177
1178
Evidence: `OpenAgents.Memories.Admissions`, `OpenAgents.Memories.Admission`,
1179
`OpenAgents.Memories.Memory`, the `memories_system_shape` and
1180
`memory_admissions_shape` constraints and the composite foreign key
1181
`memory_admissions_memory_fkey`, and
1182
`test/openagents/memories/system_memory_test.exs`.
1183
1122 1184
### PRIVACY-001 — Secret-bearing profile memory is rejected, never scrub-stored
1123 1185
1124 1186
Status: Current

@@ -3258,6 +3320,14 @@ sentence:

3258 3320
  `OpenAgents.ProfileMemory.forget_active/2`, which supersedes rather than
3259 3321
  deletes, so a retraction is another entry in the audit trail the same
3260 3322
  surface renders and never a row that quietly stops existing.
3323
- Admitting or rejecting a candidate system memory, and correcting an admitted
3324
  one, through `OpenAgents.Memories.Admissions` (MEMORY-011). Only a steward
3325
  admits, and the steward set is this allowlist, bootstrapped to the owner's
3326
  account. The write appends: an admission record is inserted and never
3327
  updated, and a correction supersedes rather than edits, so a reversal is a
3328
  second record beside the first rather than a verdict that quietly changed.
3329
  The authority buys no read — the operator sees no memory of another account
3330
  through this module.
3261 3331
- Recording Gym runs and trials under `POST /api/v1/gym/runs`, the lifecycle
3262 3332
  routes `POST /api/v1/gym/runs/start`, `POST /api/v1/gym/runs/:id/trials`,
3263 3333
  and `PATCH /api/v1/gym/runs/:id`

@@ -6006,6 +6076,7 @@ contract; the invariant prose above defines the assertion, not the filename.

6006 6076
| MEMORY-008 | `test/openagents/experience_memory_test.exs` |
6007 6077
| MEMORY-009 | `test/openagents/graph_memory_test.exs` |
6008 6078
| MEMORY-010 | `test/openagents/memories_test.exs`, `test/openagents_web/controllers/memory_controller_test.exs`, `test/openagents_web/controllers/responses_controller_test.exs` |
6079
| MEMORY-011 | `test/openagents/memories/system_memory_test.exs` |
6009 6080
| PRIVACY-001 | `test/openagents/memory/policy_and_redaction_test.exs`, `test/openagents/memory/scope_boundary_test.exs` |
6010 6081
| TURN-001 | `test/openagents/conversations_test.exs` |
6011 6082
| TURN-002 | `test/openagents/conversations_test.exs` |
docs/taxonomy.md modified +10 -2

@@ -311,12 +311,20 @@ these planes, not a second index.*

311 311
**Memories** — `OpenAgents.Memories`, and a different thing from the memory
312 312
planes above. A memory is account-scoped (`memories.user_id`, not a visitor),
313 313
thread-sourced, and authoritative rather than derived: nothing can rebuild it,
314
because the sentence a reader asked to have remembered is the only copy. Two
315
buckets, `user` and `learned`. Recall runs server-side inside `POST
314
because the sentence a reader asked to have remembered is the only copy. Three
315
buckets, `user`, `learned`, and `system`. Recall runs server-side inside `POST
316 316
/api/v1/responses`, so every client gets it without implementing retrieval.
317 317
Use "memories" for this store and "memory planes" for the projections; a
318 318
durable fact a reader states in the web conversation is still profile memory.
319 319
320
**System memory** — the `system` bucket of that store: what the network as a
321
whole has learned, rather than what one account asked to have remembered. Say
322
"system memory" for a row and "admission record" for the steward's verdict on
323
it, and keep the two apart — a candidate's status is derived from the records,
324
never read from the candidate. Recall does not read this bucket: a system
325
memory is stored, evidenced, and admitted, and surfaced to no session yet
326
(MEMORY-011).
327
320 328
### Threads
321 329
322 330
A **thread** is the unit of agent work. Everything in this section describes a
lib/openagents/memories.ex modified +65 -25

@@ -43,11 +43,14 @@ defmodule OpenAgents.Memories do

43 43
44 44
  ## What it holds
45 45
46
  Two buckets, described on `OpenAgents.Memories.Memory`. `user` memories are
46
  Three buckets, described on `OpenAgents.Memories.Memory`. `user` memories are
47 47
  explicit: a reader said "remember that I prefer X" and something called
48 48
  `create/2`. Nothing here infers a memory from what a turn contained, and
49 49
  nothing should — a store that fills itself is a store nobody trusts.
50 50
  `learned` memories come from server-side consolidation over thread events.
51
  `system` memories are what the network as a whole has learned, and they pass
52
  an evidence-backed admission gate before they mean anything
53
  (`OpenAgents.Memories.Admissions`). `recall/3` reads the first two only.
51 54
52 55
  ## Corrections supersede
53 56

@@ -107,21 +110,7 @@ defmodule OpenAgents.Memories do

107 110
          | {:error, :supersedes_not_found}
108 111
  def create(%User{} = user, attrs) when is_map(attrs) do
109 112
    attrs = normalize(attrs)
110
    body = Map.get(attrs, "body")
111
112
    embedding =
113
      case body do
114
        text when is_binary(text) and text != "" -> Semantic.embedding_for(text)
115
        _absent -> nil
116
      end
117
118
    attrs =
119
      case embedding do
120
        {vector, model} -> Map.merge(attrs, %{"embedding" => vector, "embedding_model" => model})
121
        nil -> attrs
122
      end
123
124
    changeset = Memory.changeset(%Memory{user_id: user.id}, attrs)
113
    changeset = build(user, attrs)
125 114
126 115
    Multi.new()
127 116
    |> Multi.run(:supersedes, fn _repo, _changes -> superseded(user, attrs) end)

@@ -136,11 +125,43 @@ defmodule OpenAgents.Memories do

136 125
    end
137 126
  end
138 127
128
  @doc """
129
  The changeset one write of `attrs` for `user` produces, embedding included.
130
131
  `create/2` is the ordinary way in. This is here for a caller that has to
132
  write a memory inside a transaction of its own —
133
  `OpenAgents.Memories.Admissions.supersede/3` corrects a system claim and
134
  points the old row at the new one, and a nested `Repo.transaction/1` would
135
  make its refusal path roll back more than it meant to.
136
137
  The owner is set on the struct and never cast, here as in `create/2`.
138
  """
139
  @spec build(User.t(), map()) :: Ecto.Changeset.t()
140
  def build(%User{} = user, attrs) when is_map(attrs) do
141
    attrs = normalize(attrs)
142
143
    embedding =
144
      case Map.get(attrs, "body") do
145
        text when is_binary(text) and text != "" -> Semantic.embedding_for(text)
146
        _absent -> nil
147
      end
148
149
    attrs =
150
      case embedding do
151
        {vector, model} -> Map.merge(attrs, %{"embedding" => vector, "embedding_model" => model})
152
        nil -> attrs
153
      end
154
155
    Memory.changeset(%Memory{user_id: user.id}, attrs)
156
  end
157
139 158
  @doc """
140 159
  The account's memories, newest first.
141 160
142 161
  Live only unless `include_superseded: true`. Options: `bucket` to narrow to
143
  one bucket, and `limit`, capped at #{@maximum_listed}.
162
  one bucket, `buckets` to narrow to several, and `limit`, capped at
163
  #{@maximum_listed}. Every one of them is a predicate in the query rather than
164
  a filter over its result.
144 165
  """
145 166
  @spec list(User.t(), keyword()) :: [Memory.t()]
146 167
  def list(%User{} = user, opts \\ []) do

@@ -203,11 +224,19 @@ defmodule OpenAgents.Memories do

203 224
  @doc """
204 225
  What this turn should be told, bounded.
205 226
206
  `query` is the incoming input. Every live memory the account holds is ranked
207
  against it; `user` memories are kept regardless of score and `learned` ones
208
  only above the backend's floor; the result is cut to `maximum_attached`
209
  memories and `maximum_attached_characters`, and what the cut excluded is
210
  counted rather than dropped in silence.
227
  `query` is the incoming input. Every live `user` or `learned` memory the
228
  account holds is ranked against it; `user` memories are kept regardless of
229
  score and `learned` ones only above the backend's floor; the result is cut to
230
  `maximum_attached` memories and `maximum_attached_characters`, and what the
231
  cut excluded is counted rather than dropped in silence.
232
233
  The `system` bucket is not read here, by anyone, including its own author.
234
  A system memory is stored and admitted (`OpenAgents.Memories.Admissions`) and
235
  surfaced to nobody: an admitted row reaches every account's turn or none, and
236
  the first is cross-account recall, which MEMORY-001 and MEMORY-010 forbid.
237
  The bucket list is a predicate in the query rather than a filter applied to
238
  its results, so widening it is a deliberate edit to the recall issue's
239
  eligibility filter and not something a ranking change can do by accident.
211 240
212 241
  Never raises. An unreadable store or an unavailable backend recalls nothing.
213 242
  """

@@ -215,7 +244,9 @@ defmodule OpenAgents.Memories do

215 244
  def recall(user, query, opts \\ [])
216 245
217 246
  def recall(%User{} = user, query, opts) when is_binary(query) and query != "" do
218
    candidates = list(user, limit: maximum_live_memories())
247
    candidates =
248
      list(user, limit: maximum_live_memories(), buckets: Memory.recallable_buckets())
249
219 250
    {backend, ranked, floor} = Retrieval.rank(user.id, query, candidates)
220 251
221 252
    eligible =

@@ -277,9 +308,18 @@ defmodule OpenAgents.Memories do

277 308
        where(query, [memory], is_nil(memory.superseded_by_id))
278 309
      end
279 310
311
    query =
312
      case Keyword.get(opts, :buckets) do
313
        [_first | _rest] = buckets -> where(query, [m], m.bucket in ^buckets)
314
        _all -> query
315
      end
316
280 317
    case Keyword.get(opts, :bucket) do
281
      bucket when bucket in ["user", "learned"] -> where(query, [m], m.bucket == ^bucket)
282
      _all -> query
318
      bucket when is_binary(bucket) ->
319
        if bucket in Memory.buckets(), do: where(query, [m], m.bucket == ^bucket), else: query
320
321
      _all ->
322
        query
283 323
    end
284 324
  end
285 325
lib/openagents/memories/admission.ex added +106

@@ -0,0 +1,106 @@

1
defmodule OpenAgents.Memories.Admission do
2
  @moduledoc """
3
  One verdict on one candidate system memory, and the ground for it.
4
5
  Admission is a receipt, not an assertion. The registry does not turn a
6
  promise green because someone said so, and the memory store does not admit a
7
  claim because its author wrote `admitted` on it: the record here is what a
8
  status is derived from, and the `admission` field on the candidate is only
9
  what the author claimed.
10
11
  The record is append-only. There is no `updated_at` and nothing updates one,
12
  so a steward who changes their mind writes a second record rather than
13
  editing the first, and both stay readable.
14
15
  `memory_bucket` rides the row so the composite foreign key
16
  `(memory_id, memory_bucket) -> memories (id, bucket)` can pin the candidate
17
  to the `system` bucket. That is what lets the write path refuse an admission
18
  record naming a `user` or `learned` row without reading `memories` at all,
19
  which is how the admission path stays clear of the account boundary
20
  MEMORY-010 draws.
21
22
  `role` names the three record roles the specification adds. Only `admission`
23
  is written today; `challenge` and `refutation` are the same enum and land
24
  with the issue that owns them.
25
  """
26
27
  use Ecto.Schema
28
29
  import Ecto.Changeset
30
31
  alias OpenAgents.Accounts.User
32
  alias OpenAgents.Memories.Memory
33
34
  @primary_key {:id, :binary_id, autogenerate: true}
35
  @foreign_key_type :binary_id
36
  @timestamps_opts [type: :utc_datetime_usec]
37
38
  @roles ~w(admission challenge refutation)
39
  @verdicts ~w(admitted rejected)
40
  @ground_characters 2_000
41
42
  schema "memory_admissions" do
43
    belongs_to :memory, Memory
44
    field :memory_bucket, :string, default: "system"
45
    belongs_to :steward, User
46
    field :slug, :string
47
    field :role, :string, default: "admission"
48
    field :verdict, :string
49
    field :ground, :string
50
    timestamps(updated_at: false)
51
  end
52
53
  @type t :: %__MODULE__{}
54
55
  @doc "The record roles the memory store recognises."
56
  @spec roles() :: [String.t()]
57
  def roles, do: @roles
58
59
  @doc "The verdicts an admission record may carry."
60
  @spec verdicts() :: [String.t()]
61
  def verdicts, do: @verdicts
62
63
  @doc "The longest ground the store accepts, in characters."
64
  @spec ground_characters() :: pos_integer()
65
  def ground_characters, do: @ground_characters
66
67
  @doc """
68
  Validates one admission record.
69
70
  The steward and the candidate are set on the struct rather than cast, so a
71
  request body can name neither who admitted nor, by extension, on whose
72
  authority.
73
  """
74
  @spec changeset(t(), map()) :: Ecto.Changeset.t()
75
  def changeset(record, attrs) do
76
    record
77
    |> cast(attrs, [:verdict, :ground])
78
    |> update_change(:ground, &trim/1)
79
    |> put_change(:role, "admission")
80
    |> put_change(:memory_bucket, "system")
81
    |> put_slug()
82
    |> validate_required([:memory_id, :steward_id, :slug, :role, :verdict, :ground])
83
    |> validate_inclusion(:role, @roles)
84
    |> validate_inclusion(:verdict, @verdicts)
85
    |> validate_length(:ground, min: 1, max: @ground_characters, count: :graphemes)
86
    |> foreign_key_constraint(:steward_id)
87
    # The composite key, named as the database names it. A record for a `user`
88
    # or `learned` row fails here rather than in a read that had to cross an
89
    # account to check.
90
    |> foreign_key_constraint(:memory_id,
91
      name: :memory_admissions_memory_fkey,
92
      message: "names no system memory"
93
    )
94
    |> check_constraint(:verdict, name: :memory_admissions_shape)
95
  end
96
97
  defp put_slug(changeset) do
98
    case get_field(changeset, :memory_id) do
99
      id when is_binary(id) -> put_change(changeset, :slug, "adm:" <> id)
100
      _absent -> changeset
101
    end
102
  end
103
104
  defp trim(value) when is_binary(value), do: String.trim(value)
105
  defp trim(value), do: value
106
end
lib/openagents/memories/admissions.ex added +250

@@ -0,0 +1,250 @@

1
defmodule OpenAgents.Memories.Admissions do
2
  @moduledoc """
3
  The gate in front of the system bucket: anyone can propose, only evidence
4
  admits, and only a steward writes the receipt.
5
6
  ## Why admission is a record rather than a field
7
8
  A wrong `user` memory misleads one session. A wrong `system` memory would
9
  reach every session on the network, so the store treats a status the way the
10
  promise registry treats a green promise: it is derived from a receipt
11
  somebody is answerable for, never read from a flag the claimant set. An
12
  author who writes `admission: "admitted"` on their own row has claimed
13
  something, and `status/1` still answers `"candidate"` until a steward records
14
  a verdict.
15
16
  ## Who admits
17
18
  A steward, and nobody else. The specification called for a published, signed
19
  allowlist of pubkeys evaluated as of an event's timestamp; on this substrate
20
  a single trusted server holds the role and checks it where the row is
21
  created, so what survives is the rule the machinery existed to enforce.
22
23
  The role is `OpenAgents.Accounts.admin?/1` — the operator allowlist of
24
  immutable GitHub numeric IDs, bootstrapped to the owner's account. That is
25
  the honest reading of "accounts the operator has marked as stewards,
26
  bootstrapped to the operator's own account", and it is the only account-level
27
  authority this server has: there is no `role` column on `users`, and the one
28
  per-account grant table in the repository grants roles on a repository rather
29
  than on the network. Broadening the steward set later touches the role
30
  assignment, not these record shapes. ADMIN-001 enumerates this module as an
31
  operator gate.
32
33
  ## What this module deliberately does not do
34
35
  It does not surface anything. An admitted system memory is stored, derived,
36
  and read by nobody: `OpenAgents.Memories.recall/3` reads the `user` and
37
  `learned` buckets only, and MEMORY-001 and MEMORY-010 confine recall to the
38
  acting account with no unscoped fallback. Reading an admitted row into every
39
  account's turn is cross-account recall by construction, so it is a privacy
40
  decision that belongs to the recall issue rather than a ranking detail this
41
  one can settle.
42
43
  It also never reads another account's memory. The composite foreign key
44
  `(memory_id, memory_bucket)` is what proves a candidate is a `system` row, so
45
  `record/3` writes without a lookup, and `supersede/3` authorizes inside the
46
  `UPDATE` predicate and learns nothing from a refusal.
47
  """
48
49
  import Ecto.Query
50
51
  alias Ecto.Multi
52
  alias OpenAgents.Accounts
53
  alias OpenAgents.Accounts.User
54
  alias OpenAgents.Memories
55
  alias OpenAgents.Memories.{Admission, Memory}
56
  alias OpenAgents.Repo
57
58
  @doc """
59
  Whether `user` may admit.
60
61
  A steward is an operator account. `admin?/1` refuses a banned account and
62
  reads the GitHub numeric ID rather than the login, so a renamed account keeps
63
  its authority and a transferred login does not inherit it.
64
  """
65
  @spec steward?(User.t() | nil) :: boolean()
66
  def steward?(user), do: Accounts.admin?(user)
67
68
  @doc """
69
  Writes one admission record against a candidate system memory.
70
71
  Attributes: `verdict` (`admitted` or `rejected`) and `ground` (why). The
72
  steward and the candidate are set on the struct, so a request body can name
73
  neither.
74
75
  Refuses `:steward_required` for an account without the role, and
76
  `:not_found` when `memory_id` does not name a system memory — the composite
77
  foreign key decides that, so a caller learns nothing about a row in another
78
  bucket beyond the fact that it is not admissible.
79
  """
80
  @spec record(User.t(), String.t(), map()) ::
81
          {:ok, Admission.t()}
82
          | {:error, Ecto.Changeset.t()}
83
          | {:error, :steward_required}
84
          | {:error, :not_found}
85
  def record(%User{} = steward, memory_id, attrs) when is_map(attrs) do
86
    if steward?(steward) do
87
      with {:ok, id} <- cast_id(memory_id) do
88
        %Admission{memory_id: id, steward_id: steward.id}
89
        |> Admission.changeset(normalize(attrs))
90
        |> Repo.insert()
91
        |> case do
92
          {:ok, admission} -> {:ok, admission}
93
          {:error, changeset} -> refusal(changeset)
94
        end
95
      end
96
    else
97
      {:error, :steward_required}
98
    end
99
  end
100
101
  @doc """
102
  A candidate's effective admission status.
103
104
  Derived from the admission records that reference it, newest verdict first,
105
  and never from the candidate's own `admission` field. A memory with no
106
  admission record behind it is a `candidate`, whatever it says about itself.
107
108
  Answers `nil` for a memory outside the system bucket, which has no admission
109
  status to have.
110
  """
111
  @spec status(Memory.t() | String.t()) :: String.t() | nil
112
  def status(%Memory{bucket: "system", id: id}), do: status(id)
113
  def status(%Memory{}), do: nil
114
115
  def status(memory_id) when is_binary(memory_id) do
116
    case cast_id(memory_id) do
117
      {:ok, id} -> Repo.one(latest(id)) || "candidate"
118
      {:error, :not_found} -> nil
119
    end
120
  end
121
122
  @doc """
123
  Every admission record against one candidate, oldest first.
124
125
  Append-only, so this is the whole history rather than the current state, and
126
  `status/1` is what reads a state out of it.
127
  """
128
  @spec list(Memory.t() | String.t()) :: [Admission.t()]
129
  def list(%Memory{id: id}), do: list(id)
130
131
  def list(memory_id) when is_binary(memory_id) do
132
    case cast_id(memory_id) do
133
      {:ok, id} ->
134
        Repo.all(
135
          from(record in Admission,
136
            where: record.memory_id == ^id,
137
            order_by: [asc: record.inserted_at, asc: record.id]
138
          )
139
        )
140
141
      {:error, :not_found} ->
142
        []
143
    end
144
  end
145
146
  @doc """
147
  Corrects a system memory by writing a replacement and pointing the old row at
148
  it.
149
150
  Only the original author or a steward may correct a system slug. Anyone else
151
  who disagrees files a challenge; the store has no path for editing somebody
152
  else's claim, and supersession is the only correction path there is.
153
154
  `attrs` describe the replacement, which is written under `user`'s account
155
  through the ordinary write path — same evidence requirement, same tier floor,
156
  same constraint. Name the target's slug on it: the slug is what binds the
157
  correction to the claim it corrects. A correction is admitted at the account
158
  ceiling, as `OpenAgents.Memories.create/2` admits one, because it replaces a
159
  live row with a live row.
160
161
  The authorization is the `UPDATE` predicate rather than a read followed by a
162
  decision. A caller with no standing gets `:not_supersedable` and learns
163
  nothing about the row, including whether it exists.
164
  """
165
  @spec supersede(User.t(), String.t(), map()) ::
166
          {:ok, Memory.t()}
167
          | {:error, Ecto.Changeset.t()}
168
          | {:error, :not_supersedable}
169
  def supersede(%User{} = user, target_id, attrs) when is_map(attrs) do
170
    with {:ok, id} <- cast_target(target_id) do
171
      replacement = Memories.build(user, Map.put(normalize(attrs), "bucket", "system"))
172
173
      Multi.new()
174
      |> Multi.insert(:replacement, replacement)
175
      |> Multi.run(:target, fn repo, %{replacement: written} ->
176
        updates = [superseded_by_id: written.id, updated_at: DateTime.utc_now()]
177
178
        case repo.update_all(correctable(user, id), set: updates) do
179
          {1, _rows} -> {:ok, written}
180
          {0, _rows} -> {:error, :not_supersedable}
181
        end
182
      end)
183
      |> Repo.transaction()
184
      |> case do
185
        {:ok, %{replacement: written}} -> {:ok, written}
186
        {:error, :replacement, changeset, _changes} -> {:error, changeset}
187
        {:error, _step, reason, _changes} -> {:error, reason}
188
      end
189
    end
190
  end
191
192
  # ── internal ───────────────────────────────────────────────────────────────
193
194
  # MEMORY-010: the account boundary is a predicate in the query. Here it is
195
  # one half of the authorization — the row is the actor's own — and the other
196
  # half is the steward role, which lives in the operator allowlist rather than
197
  # in a column and so arrives as a bound boolean. A caller who is neither
198
  # matches no row, so the update reports zero and nothing is read out.
199
  defp correctable(%User{id: user_id} = user, target_id) do
200
    steward = steward?(user)
201
202
    from(memory in Memory,
203
      where: memory.id == ^target_id,
204
      where: memory.bucket == "system",
205
      where: is_nil(memory.superseded_by_id),
206
      where: memory.user_id == ^user_id or type(^steward, :boolean)
207
    )
208
  end
209
210
  defp latest(memory_id) do
211
    from(record in Admission,
212
      where: record.memory_id == ^memory_id,
213
      where: record.role == "admission",
214
      order_by: [desc: record.inserted_at, desc: record.id],
215
      limit: 1,
216
      select: record.verdict
217
    )
218
  end
219
220
  # The composite foreign key is what refuses a candidate outside the system
221
  # bucket, so its violation is reported as absence rather than as a changeset
222
  # error about a column the caller never named.
223
  defp refusal(changeset) do
224
    if Enum.any?(changeset.errors, fn {field, _error} -> field == :memory_id end) do
225
      {:error, :not_found}
226
    else
227
      {:error, changeset}
228
    end
229
  end
230
231
  defp cast_id(value) when is_binary(value) do
232
    case Ecto.UUID.cast(value) do
233
      {:ok, id} -> {:ok, id}
234
      :error -> {:error, :not_found}
235
    end
236
  end
237
238
  defp cast_id(_value), do: {:error, :not_found}
239
240
  defp cast_target(value) do
241
    case cast_id(value) do
242
      {:ok, id} -> {:ok, id}
243
      {:error, :not_found} -> {:error, :not_supersedable}
244
    end
245
  end
246
247
  defp normalize(attrs) do
248
    Map.new(attrs, fn {key, value} -> {to_string(key), value} end)
249
  end
250
end
lib/openagents/memories/memory.ex modified +177 -3

@@ -9,13 +9,28 @@ defmodule OpenAgents.Memories.Memory do

9 9
  a memory and nothing can rebuild it, because the sentence a reader typed once
10 10
  is the only copy.
11 11
12
  Two buckets, kept distinct because they earn attention differently:
12
  Three buckets, kept distinct because they earn attention differently:
13 13
14 14
  * `user` — the reader said "remember that I prefer X". Explicit only, never
15 15
    inferred from what a turn happened to contain.
16 16
  * `learned` — server-side consolidation over thread events produced it. It
17 17
    carries `source_ref` so a wrong learning is traced back to the work that
18 18
    taught it.
19
  * `system` — what the network as a whole has learned. See
20
    `OpenAgents.Memories.Admissions`.
21
22
  A `system` row carries fields the other two do not, and they are required
23
  together: a `sys:` slug, a transparency `tier` of `ledger` or `glass`, an
24
  `as_of` date for the claim, an `admission` the author claims, and a non-empty
25
  `evidence_refs` list. A `user` or `learned` row carries none of them. Both
26
  halves of that rule are a database constraint (`memories_system_shape`) as
27
  well as a validation here, so an evidence-free candidate is unrepresentable
28
  rather than merely unwritten by the code that exists today.
29
30
  `admission` is the author's claim and nothing more. Effective status comes
31
  from `OpenAgents.Memories.Admissions.status/1`, which reads the admission
32
  records, so a row that says `admitted` with no steward record behind it still
33
  reads as a candidate.
19 34
20 35
  `superseded_by_id` is how a correction lands. The replacement is a new row
21 36
  and the old row points at it, so the store keeps the chain rather than

@@ -36,10 +51,17 @@ defmodule OpenAgents.Memories.Memory do

36 51
  @foreign_key_type :binary_id
37 52
  @timestamps_opts [type: :utc_datetime_usec]
38 53
39
  @buckets ~w(user learned)
54
  @buckets ~w(user learned system)
55
  @recallable_buckets ~w(user learned)
40 56
  @default_bucket "user"
41 57
  @body_characters 2_000
42 58
  @source_ref_characters 200
59
  @slug_characters 200
60
  @slug_prefix "sys:"
61
  @tiers ~w(ledger glass)
62
  @admissions ~w(candidate admitted rejected)
63
  @evidence_kinds ~w(receipt memory url)
64
  @evidence_refs 20
43 65
44 66
  schema "memories" do
45 67
    belongs_to :user, User

@@ -48,6 +70,14 @@ defmodule OpenAgents.Memories.Memory do

48 70
    field :source_ref, :string
49 71
    field :embedding, {:array, :float}
50 72
    field :embedding_model, :string
73
74
    # The system bucket's fields. Null on every other row.
75
    field :slug, :string
76
    field :entity, :string
77
    field :tier, :string
78
    field :as_of, :date
79
    field :admission, :string
80
    field :evidence_refs, {:array, :map}
51 81
    # The generated `tsvector` the lexical stand-in ranks over. PostgreSQL
52 82
    # writes it; nothing here reads it back, so it never rides a select.
53 83
    field :search_vector, :string, load_in_query: false

@@ -61,6 +91,34 @@ defmodule OpenAgents.Memories.Memory do

61 91
  @spec buckets() :: [String.t()]
62 92
  def buckets, do: @buckets
63 93
94
  @doc """
95
  The buckets recall reads.
96
97
  `system` is stored and admitted but surfaced to nobody. Reading an admitted
98
  system row into every account's turn is cross-account recall by construction,
99
  which MEMORY-001 and MEMORY-010 forbid, so widening this list is a privacy
100
  decision rather than a ranking change. It belongs to the recall issue that
101
  owns the eligibility filter, not to the store.
102
  """
103
  @spec recallable_buckets() :: [String.t()]
104
  def recallable_buckets, do: @recallable_buckets
105
106
  @doc "The transparency tiers a system memory may carry."
107
  @spec tiers() :: [String.t()]
108
  def tiers, do: @tiers
109
110
  @doc "The admission states an author may claim."
111
  @spec admissions() :: [String.t()]
112
  def admissions, do: @admissions
113
114
  @doc "The kinds of evidence a system memory may cite."
115
  @spec evidence_kinds() :: [String.t()]
116
  def evidence_kinds, do: @evidence_kinds
117
118
  @doc "The prefix every system slug carries."
119
  @spec slug_prefix() :: String.t()
120
  def slug_prefix, do: @slug_prefix
121
64 122
  @doc "The bucket a write lands in when it names none."
65 123
  @spec default_bucket() :: String.t()
66 124
  def default_bucket, do: @default_bucket

@@ -75,17 +133,133 @@ defmodule OpenAgents.Memories.Memory do

75 133
  @spec changeset(t(), map()) :: Ecto.Changeset.t()
76 134
  def changeset(memory, attrs) do
77 135
    memory
78
    |> cast(attrs, [:bucket, :body, :source_ref, :embedding, :embedding_model])
136
    |> cast(attrs, [
137
      :bucket,
138
      :body,
139
      :source_ref,
140
      :embedding,
141
      :embedding_model,
142
      :slug,
143
      :entity,
144
      :tier,
145
      :as_of,
146
      :admission,
147
      :evidence_refs
148
    ])
79 149
    |> update_change(:body, &trim/1)
80 150
    |> update_change(:source_ref, &trim/1)
151
    |> update_change(:slug, &trim/1)
152
    |> update_change(:entity, &trim/1)
81 153
    |> validate_required([:bucket, :body])
82 154
    |> validate_inclusion(:bucket, @buckets)
83 155
    |> validate_length(:body, min: 1, max: @body_characters, count: :graphemes)
84 156
    |> validate_length(:source_ref, min: 1, max: @source_ref_characters, count: :graphemes)
157
    |> validate_bucket_fields()
85 158
    |> foreign_key_constraint(:user_id)
86 159
    |> check_constraint(:body, name: :memories_shape)
160
    |> check_constraint(:evidence_refs,
161
      name: :memories_system_shape,
162
      message: "does not satisfy the system-memory shape"
163
    )
164
  end
165
166
  # The system fields, required together on a system row and refused outright
167
  # on the other two. The database says the same thing in
168
  # `memories_system_shape`; this is the half that can explain itself to the
169
  # caller.
170
  defp validate_bucket_fields(changeset) do
171
    case get_field(changeset, :bucket) do
172
      "system" -> validate_system(changeset)
173
      _account_scoped -> refuse_system_fields(changeset)
174
    end
175
  end
176
177
  defp validate_system(changeset) do
178
    changeset
179
    |> validate_required([:slug, :tier, :as_of, :admission, :evidence_refs])
180
    |> validate_length(:slug, min: 1, max: @slug_characters, count: :graphemes)
181
    |> validate_format(:slug, ~r/^sys:/, message: "must start with #{@slug_prefix}")
182
    |> validate_length(:entity, min: 1, max: @slug_characters, count: :graphemes)
183
    |> validate_inclusion(:tier, @tiers)
184
    |> validate_inclusion(:admission, @admissions)
185
    |> validate_evidence_refs()
186
  end
187
188
  defp refuse_system_fields(changeset) do
189
    Enum.reduce([:slug, :entity, :tier, :as_of, :admission, :evidence_refs], changeset, fn
190
      field, acc ->
191
        if is_nil(get_field(acc, field)) do
192
          acc
193
        else
194
          add_error(acc, field, "belongs only to a system memory")
195
        end
196
    end)
197
  end
198
199
  # A system memory without evidence is an assertion, and assertions do not
200
  # enter the shared bucket. The list is required, non-empty, and every entry
201
  # names a kind, a ref, and a digest — the digest so the evidence behind an
202
  # admitted claim cannot be swapped afterwards.
203
  defp validate_evidence_refs(changeset) do
204
    case get_change(changeset, :evidence_refs, get_field(changeset, :evidence_refs)) do
205
      nil ->
206
        changeset
207
208
      [] ->
209
        add_error(changeset, :evidence_refs, "must name at least one piece of evidence")
210
211
      refs when is_list(refs) and length(refs) > @evidence_refs ->
212
        add_error(
213
          changeset,
214
          :evidence_refs,
215
          "names more than #{@evidence_refs} pieces of evidence"
216
        )
217
218
      refs when is_list(refs) ->
219
        if Enum.all?(refs, &evidence_ref?/1) do
220
          put_change(changeset, :evidence_refs, Enum.map(refs, &normalize_ref/1))
221
        else
222
          add_error(
223
            changeset,
224
            :evidence_refs,
225
            "each entry needs a kind of #{Enum.join(@evidence_kinds, ", ")}, a ref, and a digest"
226
          )
227
        end
228
229
      _not_a_list ->
230
        add_error(changeset, :evidence_refs, "must be a list")
231
    end
232
  end
233
234
  defp evidence_ref?(ref) when is_map(ref) do
235
    kind(ref) in @evidence_kinds and present?(entry(ref, "ref", :ref)) and
236
      present?(entry(ref, "digest", :digest))
237
  end
238
239
  defp evidence_ref?(_ref), do: false
240
241
  defp normalize_ref(ref) do
242
    %{
243
      "kind" => kind(ref),
244
      "ref" => String.trim(entry(ref, "ref", :ref)),
245
      "digest" => String.trim(entry(ref, "digest", :digest))
246
    }
87 247
  end
88 248
249
  defp kind(ref), do: entry(ref, "kind", :kind)
250
251
  # A caller writes `%{"kind" => …}` over the API and `%{kind: …}` in Elixir,
252
  # and both mean the same evidence ref.
253
  defp entry(ref, string_key, atom_key) do
254
    case Map.get(ref, string_key, Map.get(ref, atom_key)) do
255
      value when is_binary(value) -> value
256
      _absent -> nil
257
    end
258
  end
259
260
  defp present?(value) when is_binary(value), do: String.trim(value) != ""
261
  defp present?(_value), do: false
262
89 263
  @doc "Points a memory at the memory that replaced it."
90 264
  @spec supersede_changeset(t(), t()) :: Ecto.Changeset.t()
91 265
  def supersede_changeset(memory, replacement) do
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -309,7 +309,8 @@

309 309
    20260825160000,
310 310
    20260825170000,
311 311
    20260825170100,
312
    20260825195623
312
    20260825195623,
313
    20260825220000
313 314
  ],
314 315
  "required_tables": [
315 316
    "users",
priv/repo/migrations/20260825220000_create_system_memories.exs added +202

@@ -0,0 +1,202 @@

1
defmodule OpenAgents.Repo.Migrations.CreateSystemMemories do
2
  use Ecto.Migration
3
4
  # The third memory bucket: what the network as a whole has learned, rather
5
  # than what one account asked to have remembered.
6
  #
7
  # A wrong `user` memory misleads one session. A wrong `system` memory would
8
  # reach every session, so the row carries the things that make a claim
9
  # answerable — who wrote it, when it was observed true, and what evidence
10
  # stands behind it — and the table refuses a row that carries none of them.
11
  #
12
  # Two decisions are load-bearing here, and both are database predicates
13
  # rather than changeset validations (MEMORY-004):
14
  #
15
  #   * A system candidate with an empty evidence list cannot exist. The write
16
  #     path refuses it too, but a constraint is what makes it unrepresentable
17
  #     rather than merely unwritten by the code that exists today.
18
  #
19
  #   * An admission record can only name a `system` row. The composite foreign
20
  #     key on `(id, bucket)` enforces that without any read of `memories`, so
21
  #     the admission path never issues a query across the account boundary
22
  #     MEMORY-010 draws.
23
  #
24
  # Rows in the `user` and `learned` buckets carry none of the system columns,
25
  # and the constraint says so in both directions.
26
  def up do
27
    alter table(:memories) do
28
      # `sys:` prefixed. The prefix is a routing convention, not a boundary —
29
      # the boundary is the admission record and the write authorization.
30
      add :slug, :string
31
      add :entity, :string
32
33
      # `ledger` or `glass`, never lower. A system memory's body reaches every
34
      # agent by definition, which is content plus metadata; a claim that
35
      # cannot ship its content is not a system memory.
36
      add :tier, :string
37
38
      # The date the claim was observed true, distinct from `inserted_at`.
39
      # `inserted_at` orders the chain; `as_of` dates the claim, so a stale
40
      # truth reads as dated rather than as current.
41
      add :as_of, :date
42
43
      # The author's own claim, and nothing more. Effective status is derived
44
      # from `memory_admissions`, so a row that says `admitted` with no steward
45
      # record behind it still reads as a candidate.
46
      add :admission, :string
47
48
      # A non-empty list of `{kind, ref, digest}`. `receipt` points at a forge
49
      # receipt, `memory` at a prior admitted row, `url` at public material;
50
      # the digest is what keeps evidence from being swapped after admission.
51
      add :evidence_refs, :jsonb
52
    end
53
54
    # The original shape constraint named two buckets. Recreating it is how the
55
    # third one becomes writable at all.
56
    drop constraint(:memories, :memories_shape)
57
58
    create constraint(:memories, :memories_shape,
59
             check: """
60
             bucket IN ('user','learned','system')
61
             AND char_length(body) BETWEEN 1 AND 2000
62
             AND (source_ref IS NULL OR char_length(source_ref) BETWEEN 1 AND 200)
63
             AND (superseded_by_id IS NULL OR superseded_by_id <> id)
64
             """
65
           )
66
67
    # The system columns, present together on a system row and absent together
68
    # on every other row. Stating the absent half matters as much as the
69
    # present half: it keeps a `user` row from quietly carrying a tier nothing
70
    # reads and nobody set deliberately.
71
    #
72
    # Every column is asserted `IS NOT NULL` before it is compared, and that is
73
    # not belt and braces. A check constraint passes when it evaluates to NULL,
74
    # so `tier IN ('ledger','glass')` alone admits a row with no tier at all,
75
    # and a length test alone admits a candidate whose evidence list is absent
76
    # rather than empty — which is the exact hole this constraint exists to
77
    # close.
78
    create constraint(:memories, :memories_system_shape,
79
             check: """
80
             (
81
               bucket <> 'system'
82
               AND slug IS NULL
83
               AND entity IS NULL
84
               AND tier IS NULL
85
               AND as_of IS NULL
86
               AND admission IS NULL
87
               AND evidence_refs IS NULL
88
             ) OR (
89
               bucket = 'system'
90
               AND slug IS NOT NULL
91
               AND slug LIKE 'sys:%'
92
               AND char_length(slug) BETWEEN 5 AND 200
93
               AND (entity IS NULL OR char_length(entity) BETWEEN 1 AND 200)
94
               AND tier IS NOT NULL
95
               AND tier IN ('ledger','glass')
96
               AND as_of IS NOT NULL
97
               AND admission IS NOT NULL
98
               AND admission IN ('candidate','admitted','rejected')
99
               AND evidence_refs IS NOT NULL
100
               AND jsonb_typeof(evidence_refs) = 'array'
101
               AND jsonb_array_length(evidence_refs) BETWEEN 1 AND 20
102
               AND NOT jsonb_path_exists(evidence_refs, '$[*] ? (!(@.type() == "object"
103
                     && exists(@.kind ? (@ == "receipt" || @ == "memory" || @ == "url"))
104
                     && exists(@.ref ? (@.type() == "string" && @ != ""))
105
                     && exists(@.digest ? (@.type() == "string" && @ != ""))))')
106
             )
107
             """
108
           )
109
110
    # What the composite foreign key below points at. A memory's bucket cannot
111
    # change out from under an admission record while one references it.
112
    create unique_index(:memories, [:id, :bucket], name: :memories_id_bucket_index)
113
114
    # Admission is a receipt, not a field the author sets.
115
    #
116
    # The record is a row of its own rather than a column on the candidate for
117
    # the reason a promise flip is a receipt rather than a flag: the account
118
    # that wrote the claim is not the account that may admit it, and the store
119
    # keeps every verdict rather than the last one written over the others.
120
    create table(:memory_admissions, primary_key: false) do
121
      add :id, :binary_id, primary_key: true
122
123
      # The candidate this record judges, and the bucket it must be in. The
124
      # bucket rides the row so the composite foreign key can pin it.
125
      add :memory_id, :binary_id, null: false
126
      add :memory_bucket, :string, null: false
127
128
      # The account that wrote the record. A steward at the time of the write;
129
      # the row records who, and the role check runs where the row is created.
130
      add :steward_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
131
132
      # `adm:<memory_id>` for an admission. The slug namespace is the spec's;
133
      # `chl:` and `ref:` join it when challenge and refutation land.
134
      add :slug, :string, null: false
135
136
      # `admission` today. `challenge` and `refutation` are named here because
137
      # they are the same enum, not because this issue writes them.
138
      add :role, :string, null: false
139
140
      # `admitted` or `rejected` on an admission record.
141
      add :verdict, :string
142
143
      # Why. A verdict without a ground cannot be argued with.
144
      add :ground, :text, null: false
145
146
      # Append-only: inserted, never updated, so there is no `updated_at`.
147
      timestamps(type: :utc_datetime_usec, updated_at: false)
148
    end
149
150
    execute("""
151
    ALTER TABLE memory_admissions
152
    ADD CONSTRAINT memory_admissions_memory_fkey
153
    FOREIGN KEY (memory_id, memory_bucket)
154
    REFERENCES memories (id, bucket) ON DELETE CASCADE
155
    """)
156
157
    create constraint(:memory_admissions, :memory_admissions_shape,
158
             check: """
159
             memory_bucket = 'system'
160
             AND role IN ('admission','challenge','refutation')
161
             AND char_length(slug) BETWEEN 1 AND 200
162
             AND char_length(ground) BETWEEN 1 AND 2000
163
             AND (
164
               (role = 'admission'
165
                AND verdict IS NOT NULL
166
                AND verdict IN ('admitted','rejected')
167
                AND slug = 'adm:' || memory_id::text)
168
               OR (role <> 'admission' AND verdict IS NULL)
169
             )
170
             """
171
           )
172
173
    # Deriving a candidate's status reads its records newest last.
174
    create index(:memory_admissions, [:memory_id, :inserted_at])
175
    create index(:memory_admissions, [:steward_id])
176
  end
177
178
  def down do
179
    drop table(:memory_admissions)
180
    drop constraint(:memories, :memories_system_shape)
181
    drop index(:memories, [:id, :bucket], name: :memories_id_bucket_index)
182
    drop constraint(:memories, :memories_shape)
183
184
    create constraint(:memories, :memories_shape,
185
             check: """
186
             bucket IN ('user','learned')
187
             AND char_length(body) BETWEEN 1 AND 2000
188
             AND (source_ref IS NULL OR char_length(source_ref) BETWEEN 1 AND 200)
189
             AND (superseded_by_id IS NULL OR superseded_by_id <> id)
190
             """
191
           )
192
193
    alter table(:memories) do
194
      remove :slug
195
      remove :entity
196
      remove :tier
197
      remove :as_of
198
      remove :admission
199
      remove :evidence_refs
200
    end
201
  end
202
end
test/openagents/memories/system_memory_test.exs added +555

@@ -0,0 +1,555 @@

1
defmodule OpenAgents.Memories.SystemMemoryTest do
2
  @moduledoc """
3
  The system bucket: what it refuses, what a status is derived from, who may
4
  admit, who may correct, and what it is still not allowed to reach.
5
6
  Four properties carry the weight here, and each of them is the kind that
7
  holds until somebody adds a second route to the same table.
8
9
  An evidence-free candidate is refused **by the database**, so these tests
10
  insert around the changeset rather than through it. A validation refused only
11
  in `changeset/2` is an application filter, and MEMORY-004's discipline is
12
  that a boundary is a predicate the store enforces.
13
14
  Status is derived from the admission records, never read from the author's
15
  own field, so an author who writes `admitted` on their own row is still
16
  proposing.
17
18
  Only a steward admits, and only the author or a steward corrects. A caller
19
  with no standing is refused without learning anything about the row.
20
21
  And the bucket surfaces to nobody. A system memory that is stored and
22
  admitted but recalled by no session is the shippable state; recall reaching
23
  it would be cross-account recall, which MEMORY-001 and MEMORY-010 forbid.
24
  """
25
  use OpenAgents.DataCase, async: true
26
27
  alias OpenAgents.Memories
28
  alias OpenAgents.Memories.{Admission, Admissions, Memory, Recall}
29
30
  # The owner account is an operator by definition (`@owner_github_id`), so a
31
  # steward needs no configuration change and these tests stay async.
32
  @owner_github_id 14_167_547
33
34
  defp account(key) do
35
    digest = :crypto.hash(:sha256, key)
36
    github_id = digest |> binary_part(0, 7) |> :binary.decode_unsigned()
37
38
    upsert(github_id, "sysmem-" <> (digest |> Base.encode16(case: :lower) |> binary_part(0, 12)))
39
  end
40
41
  defp steward, do: upsert(@owner_github_id, "AtlantisPleb")
42
43
  defp upsert(github_id, login) do
44
    {:ok, user} =
45
      OpenAgents.Accounts.upsert_github_user(%{
46
        github_id: github_id,
47
        github_login: login,
48
        github_avatar_url: "https://avatars.githubusercontent.com/u/#{github_id}?v=4"
49
      })
50
51
    user
52
  end
53
54
  defp evidence do
55
    [%{"kind" => "receipt", "ref" => "receipt:4f1c", "digest" => "sha256:9ab3"}]
56
  end
57
58
  defp candidate(overrides \\ %{}) do
59
    Map.merge(
60
      %{
61
        "bucket" => "system",
62
        "slug" => "sys:gateway-402-retired-model",
63
        "body" =>
64
          "A 402 from the inference gateway usually means the default model was " <>
65
            "retired upstream. Check gateway status before bisecting local lanes.",
66
        "entity" => "inference-gateway",
67
        "tier" => "ledger",
68
        "as_of" => ~D[2026-08-25],
69
        "admission" => "candidate",
70
        "evidence_refs" => evidence()
71
      },
72
      overrides
73
    )
74
  end
75
76
  # A row assembled as a struct rather than through `changeset/2`. This is the
77
  # second route the constraint exists for.
78
  defp around_the_changeset(user, overrides) do
79
    fields =
80
      Map.merge(
81
        %{
82
          user_id: user.id,
83
          bucket: "system",
84
          body: "Written around the write path.",
85
          slug: "sys:around-the-write-path",
86
          tier: "ledger",
87
          as_of: ~D[2026-08-25],
88
          admission: "candidate",
89
          evidence_refs: evidence()
90
        },
91
        overrides
92
      )
93
94
    Repo.insert(struct(Memory, fields))
95
  end
96
97
  describe "the system fields" do
98
    test "round-trip on a memory row" do
99
      author = account("system-round-trip")
100
101
      assert {:ok, memory} = Memories.create(author, candidate())
102
103
      reread = Repo.get!(Memory, memory.id)
104
105
      assert reread.bucket == "system"
106
      assert reread.slug == "sys:gateway-402-retired-model"
107
      assert reread.entity == "inference-gateway"
108
      assert reread.tier == "ledger"
109
      assert reread.as_of == ~D[2026-08-25]
110
      assert reread.admission == "candidate"
111
112
      assert reread.evidence_refs == [
113
               %{"kind" => "receipt", "ref" => "receipt:4f1c", "digest" => "sha256:9ab3"}
114
             ]
115
    end
116
117
    # `as_of` dates the claim and `inserted_at` orders the chain, so a claim
118
    # observed true last year is still written today and still reads as dated.
119
    test "as_of is the claim's date, not the row's" do
120
      author = account("system-as-of")
121
122
      {:ok, memory} = Memories.create(author, candidate(%{"as_of" => ~D[2025-01-09]}))
123
124
      assert memory.as_of == ~D[2025-01-09]
125
      assert DateTime.to_date(memory.inserted_at) != memory.as_of
126
    end
127
128
    test "a system memory needs a slug, a tier, a date, and an admission" do
129
      author = account("system-required")
130
131
      for {field, key} <- [{"slug", :slug}, {"tier", :tier}, {"as_of", :as_of}] do
132
        assert {:error, changeset} = Memories.create(author, Map.put(candidate(), field, nil))
133
        assert Map.has_key?(errors_on(changeset), key)
134
      end
135
136
      assert {:error, changeset} = Memories.create(author, Map.put(candidate(), "admission", nil))
137
      assert Map.has_key?(errors_on(changeset), :admission)
138
    end
139
140
    test "the slug carries the sys: prefix" do
141
      author = account("system-slug")
142
143
      assert {:error, changeset} =
144
               Memories.create(author, candidate(%{"slug" => "gateway-402"}))
145
146
      assert %{slug: _refused} = errors_on(changeset)
147
    end
148
149
    test "a user or learned row carries none of them" do
150
      author = account("system-fields-elsewhere")
151
152
      assert {:error, changeset} =
153
               Memories.create(author, %{
154
                 "body" => "I use pnpm.",
155
                 "bucket" => "user",
156
                 "tier" => "ledger"
157
               })
158
159
      assert %{tier: _refused} = errors_on(changeset)
160
161
      assert {:error, changeset} =
162
               Memories.create(author, %{
163
                 "body" => "The migration runs first.",
164
                 "bucket" => "learned",
165
                 "evidence_refs" => evidence()
166
               })
167
168
      assert %{evidence_refs: _refused} = errors_on(changeset)
169
    end
170
171
    test "an ordinary memory is unaffected" do
172
      author = account("system-unaffected")
173
174
      assert {:ok, plain} = Memories.create(author, %{"body" => "I use pnpm, not npm."})
175
176
      assert plain.bucket == "user"
177
      assert plain.slug == nil
178
      assert plain.tier == nil
179
      assert plain.as_of == nil
180
      assert plain.admission == nil
181
      assert plain.evidence_refs == nil
182
    end
183
  end
184
185
  describe "evidence is unrepresentable when absent" do
186
    test "the write path refuses a candidate with no evidence" do
187
      author = account("evidence-write-path")
188
189
      assert {:error, changeset} =
190
               Memories.create(author, candidate(%{"evidence_refs" => []}))
191
192
      assert %{evidence_refs: _refused} = errors_on(changeset)
193
194
      assert {:error, changeset} =
195
               Memories.create(author, candidate(%{"evidence_refs" => nil}))
196
197
      assert %{evidence_refs: _refused} = errors_on(changeset)
198
    end
199
200
    test "the write path refuses an evidence ref missing its digest or its kind" do
201
      author = account("evidence-shape")
202
203
      for broken <- [
204
            %{"kind" => "receipt", "ref" => "receipt:4f1c"},
205
            %{"kind" => "rumour", "ref" => "receipt:4f1c", "digest" => "sha256:9ab3"},
206
            %{"kind" => "url", "ref" => "", "digest" => "sha256:9ab3"}
207
          ] do
208
        assert {:error, changeset} =
209
                 Memories.create(author, candidate(%{"evidence_refs" => [broken]}))
210
211
        assert %{evidence_refs: _refused} = errors_on(changeset)
212
      end
213
    end
214
215
    # The point of the whole exercise: not "the changeset refuses it" but "the
216
    # table has no row shaped like that", so a second write path cannot reopen
217
    # the hole.
218
    test "the database refuses an evidence-free candidate inserted around the changeset" do
219
      author = account("evidence-constraint")
220
221
      assert_raise Ecto.ConstraintError, ~r/memories_system_shape/, fn ->
222
        around_the_changeset(author, %{evidence_refs: []})
223
      end
224
225
      assert_raise Ecto.ConstraintError, ~r/memories_system_shape/, fn ->
226
        around_the_changeset(author, %{evidence_refs: nil})
227
      end
228
    end
229
230
    test "the database refuses an evidence ref that names no digest" do
231
      author = account("evidence-constraint-shape")
232
233
      assert_raise Ecto.ConstraintError, ~r/memories_system_shape/, fn ->
234
        around_the_changeset(author, %{
235
          evidence_refs: [%{"kind" => "url", "ref" => "https://openagents.com/"}]
236
        })
237
      end
238
    end
239
240
    test "the database refuses a tier below ledger, around the changeset and through it" do
241
      author = account("tier-constraint")
242
243
      assert {:error, changeset} = Memories.create(author, candidate(%{"tier" => "pulse"}))
244
      assert %{tier: _refused} = errors_on(changeset)
245
246
      assert_raise Ecto.ConstraintError, ~r/memories_system_shape/, fn ->
247
        around_the_changeset(author, %{tier: "dark"})
248
      end
249
    end
250
251
    test "the database refuses system fields on a learned row" do
252
      author = account("bucket-constraint")
253
254
      assert_raise Ecto.ConstraintError, ~r/memories_system_shape/, fn ->
255
        around_the_changeset(author, %{bucket: "learned"})
256
      end
257
    end
258
  end
259
260
  describe "derived admission status" do
261
    test "a candidate with no record behind it is a candidate" do
262
      author = account("status-candidate")
263
264
      {:ok, memory} = Memories.create(author, candidate())
265
266
      assert Admissions.status(memory) == "candidate"
267
    end
268
269
    test "an author's self-claimed admitted still reads as a candidate" do
270
      author = account("status-self-claimed")
271
272
      {:ok, memory} = Memories.create(author, candidate(%{"admission" => "admitted"}))
273
274
      assert memory.admission == "admitted"
275
      assert Admissions.status(memory) == "candidate"
276
    end
277
278
    test "a steward's verdict is what the status is read from" do
279
      author = account("status-admitted")
280
      admitting = steward()
281
282
      {:ok, memory} = Memories.create(author, candidate())
283
284
      assert {:ok, record} =
285
               Admissions.record(admitting, memory.id, %{
286
                 "verdict" => "admitted",
287
                 "ground" => "The receipt shows the 402 and the retirement together."
288
               })
289
290
      assert record.slug == "adm:" <> memory.id
291
      assert record.role == "admission"
292
      assert record.steward_id == admitting.id
293
      assert Admissions.status(memory) == "admitted"
294
    end
295
296
    test "a rejection reads as rejected, and a later verdict replaces an earlier one" do
297
      author = account("status-rejected")
298
      admitting = steward()
299
300
      {:ok, memory} = Memories.create(author, candidate())
301
302
      {:ok, _rejected} =
303
        Admissions.record(admitting, memory.id, %{
304
          "verdict" => "rejected",
305
          "ground" => "The digest does not match the receipt it names."
306
        })
307
308
      assert Admissions.status(memory) == "rejected"
309
310
      {:ok, _admitted} =
311
        Admissions.record(admitting, memory.id, %{
312
          "verdict" => "admitted",
313
          "ground" => "The author re-cited the receipt and the digest matches."
314
        })
315
316
      assert Admissions.status(memory) == "admitted"
317
318
      # Append-only: the reversal is a second record, not an edit of the first.
319
      assert Enum.map(Admissions.list(memory), & &1.verdict) == ["rejected", "admitted"]
320
    end
321
322
    test "a memory outside the system bucket has no admission status" do
323
      author = account("status-other-bucket")
324
325
      {:ok, plain} = Memories.create(author, %{"body" => "I use pnpm."})
326
327
      assert Admissions.status(plain) == nil
328
    end
329
  end
330
331
  describe "only a steward admits" do
332
    test "an ordinary account is refused" do
333
      author = account("admit-non-steward")
334
      other = account("admit-non-steward-other")
335
336
      {:ok, memory} = Memories.create(author, candidate())
337
338
      assert {:error, :steward_required} =
339
               Admissions.record(other, memory.id, %{
340
                 "verdict" => "admitted",
341
                 "ground" => "I say so."
342
               })
343
344
      # And the refusal changes nothing.
345
      assert Admissions.status(memory) == "candidate"
346
      assert Admissions.list(memory) == []
347
    end
348
349
    test "the author cannot admit their own candidate" do
350
      author = account("admit-self")
351
352
      {:ok, memory} = Memories.create(author, candidate())
353
354
      assert {:error, :steward_required} =
355
               Admissions.record(author, memory.id, %{
356
                 "verdict" => "admitted",
357
                 "ground" => "It is true."
358
               })
359
    end
360
361
    test "a record needs a verdict and a ground" do
362
      author = account("admit-shape")
363
      admitting = steward()
364
365
      {:ok, memory} = Memories.create(author, candidate())
366
367
      assert {:error, changeset} =
368
               Admissions.record(admitting, memory.id, %{"verdict" => "maybe", "ground" => "Hm."})
369
370
      assert %{verdict: _refused} = errors_on(changeset)
371
372
      assert {:error, changeset} =
373
               Admissions.record(admitting, memory.id, %{"verdict" => "admitted"})
374
375
      assert %{ground: _refused} = errors_on(changeset)
376
    end
377
378
    # The composite foreign key, not a read. A candidate outside the system
379
    # bucket is not admissible, and saying so costs no query across an account.
380
    test "a record naming a memory outside the system bucket is refused" do
381
      author = account("admit-wrong-bucket")
382
      admitting = steward()
383
384
      {:ok, plain} = Memories.create(author, %{"body" => "I use pnpm."})
385
386
      assert {:error, :not_found} =
387
               Admissions.record(admitting, plain.id, %{
388
                 "verdict" => "admitted",
389
                 "ground" => "Not a system memory."
390
               })
391
392
      assert Repo.aggregate(Admission, :count) == 0
393
    end
394
395
    test "a record naming no memory at all is refused" do
396
      admitting = steward()
397
398
      assert {:error, :not_found} =
399
               Admissions.record(admitting, "not-a-uuid", %{
400
                 "verdict" => "admitted",
401
                 "ground" => "Nothing."
402
               })
403
404
      assert {:error, :not_found} =
405
               Admissions.record(admitting, Ecto.UUID.generate(), %{
406
                 "verdict" => "admitted",
407
                 "ground" => "Nothing."
408
               })
409
    end
410
  end
411
412
  describe "supersession on a system slug" do
413
    test "the author corrects their own claim" do
414
      author = account("supersede-author")
415
416
      {:ok, wrong} = Memories.create(author, candidate())
417
418
      assert {:ok, right} =
419
               Admissions.supersede(
420
                 author,
421
                 wrong.id,
422
                 candidate(%{"body" => "A 402 is the retired-model signal. Check status first."})
423
               )
424
425
      assert Repo.get!(Memory, wrong.id).superseded_by_id == right.id
426
      assert right.user_id == author.id
427
    end
428
429
    test "a steward corrects another account's claim" do
430
      author = account("supersede-steward")
431
      correcting = steward()
432
433
      {:ok, wrong} = Memories.create(author, candidate())
434
435
      assert {:ok, right} =
436
               Admissions.supersede(
437
                 correcting,
438
                 wrong.id,
439
                 candidate(%{"body" => "The gateway 402s when the default model is retired."})
440
               )
441
442
      assert Repo.get!(Memory, wrong.id).superseded_by_id == right.id
443
      assert right.user_id == correcting.id
444
    end
445
446
    test "anyone else is refused, and the row is untouched" do
447
      author = account("supersede-stranger")
448
      stranger = account("supersede-stranger-other")
449
450
      {:ok, memory} = Memories.create(author, candidate())
451
452
      assert {:error, :not_supersedable} =
453
               Admissions.supersede(stranger, memory.id, candidate(%{"body" => "Mine now."}))
454
455
      assert Repo.get!(Memory, memory.id).superseded_by_id == nil
456
457
      # The refusal is a rollback, not a half-write: the replacement the
458
      # stranger proposed does not survive it.
459
      assert Memories.list(stranger, bucket: "system") == []
460
    end
461
462
    test "an already superseded claim is not superseded twice" do
463
      author = account("supersede-twice")
464
465
      {:ok, first} = Memories.create(author, candidate())
466
      {:ok, _second} = Admissions.supersede(author, first.id, candidate(%{"body" => "Second."}))
467
468
      assert {:error, :not_supersedable} =
469
               Admissions.supersede(author, first.id, candidate(%{"body" => "Third."}))
470
    end
471
472
    test "an unreadable target is refused rather than raised" do
473
      author = account("supersede-unreadable")
474
475
      assert {:error, :not_supersedable} =
476
               Admissions.supersede(author, "not-a-uuid", candidate())
477
    end
478
  end
479
480
  # MEMORY-001 and MEMORY-010. The whole bucket is stored and admitted, and
481
  # surfaced to nobody. Widening this is a privacy decision that belongs to the
482
  # recall issue, and these assertions are what make it a decision rather than
483
  # a side effect.
484
  describe "the recall boundary" do
485
    test "an admitted system memory never reaches another account's turn" do
486
      author = account("recall-boundary-author")
487
      reader = account("recall-boundary-reader")
488
      admitting = steward()
489
490
      {:ok, memory} = Memories.create(author, candidate())
491
492
      {:ok, _record} =
493
        Admissions.record(admitting, memory.id, %{
494
          "verdict" => "admitted",
495
          "ground" => "The receipt supports the claim."
496
        })
497
498
      assert Admissions.status(memory) == "admitted"
499
500
      assert %Recall{memories: []} =
501
               Memories.recall(reader, "the inference gateway returned 402")
502
    end
503
504
    test "nor its own author's turn" do
505
      author = account("recall-boundary-own")
506
      admitting = steward()
507
508
      {:ok, memory} = Memories.create(author, candidate())
509
510
      {:ok, _record} =
511
        Admissions.record(admitting, memory.id, %{
512
          "verdict" => "admitted",
513
          "ground" => "The receipt supports the claim."
514
        })
515
516
      %Recall{memories: recalled} =
517
        Memories.recall(author, "the inference gateway returned 402")
518
519
      assert recalled == []
520
    end
521
522
    test "and it does not crowd out the buckets recall does read" do
523
      author = account("recall-boundary-mixed")
524
525
      {:ok, _system} = Memories.create(author, candidate())
526
      {:ok, asked} = Memories.create(author, %{"body" => "I use pnpm, not npm."})
527
528
      %Recall{memories: recalled, dropped: dropped} =
529
        Memories.recall(author, "install the deps")
530
531
      assert Enum.map(recalled, & &1.id) == [asked.id]
532
      assert dropped == 0
533
    end
534
535
    test "the author still reads their own system memories through the store" do
536
      author = account("recall-boundary-list")
537
538
      {:ok, memory} = Memories.create(author, candidate())
539
540
      assert Enum.map(Memories.list(author, bucket: "system"), & &1.id) == [memory.id]
541
      assert {:ok, %Memory{id: id}} = Memories.fetch(author, memory.id)
542
      assert id == memory.id
543
    end
544
545
    test "and another account's system memory is absent rather than forbidden" do
546
      author = account("recall-boundary-fetch")
547
      reader = account("recall-boundary-fetch-other")
548
549
      {:ok, memory} = Memories.create(author, candidate())
550
551
      assert {:error, :not_found} = Memories.fetch(reader, memory.id)
552
      assert Memories.list(reader, bucket: "system") == []
553
    end
554
  end
555
end
test/openagents/memories_test.exs modified +23 -2

@@ -88,11 +88,27 @@ defmodule OpenAgents.MemoriesTest do

88 88
      assert %{body: _} = errors_on(changeset)
89 89
90 90
      assert {:error, changeset} =
91
               Memories.create(user, %{"body" => "Fine.", "bucket" => "system"})
91
               Memories.create(user, %{"body" => "Fine.", "bucket" => "wishlist"})
92 92
93 93
      assert %{bucket: _} = errors_on(changeset)
94 94
    end
95 95
96
    # `system` is in the vocabulary and carries a shape of its own
97
    # (`OpenAgents.Memories.SystemMemoryTest`). A write that names the bucket
98
    # and none of its fields is refused for the fields, not for the bucket.
99
    test "refuses a system memory that carries none of the system fields" do
100
      user = account("create-system-bare")
101
102
      assert {:error, changeset} =
103
               Memories.create(user, %{"body" => "Fine.", "bucket" => "system"})
104
105
      errors = errors_on(changeset)
106
107
      assert Map.has_key?(errors, :evidence_refs)
108
      assert Map.has_key?(errors, :tier)
109
      refute Map.has_key?(errors, :bucket)
110
    end
111
96 112
    test "refuses a body longer than the store's bound" do
97 113
      user = account("create-long")
98 114
      body = String.duplicate("x", Memory.body_characters() + 1)

@@ -305,7 +321,12 @@ defmodule OpenAgents.MemoriesTest do

305 321
  describe "MEMORY-010" do
306 322
    @scoped_modules [
307 323
      "lib/openagents/memories.ex",
308
      "lib/openagents/memories/retrieval/lexical.ex"
324
      "lib/openagents/memories/retrieval/lexical.ex",
325
      # The system bucket's write authority reaches one row of another account
326
      # — a steward correcting a network claim — and it does so as a predicate
327
      # inside the `UPDATE`, naming `user_id` beside the role. Nothing is read
328
      # out, so a caller with no standing learns nothing from the refusal.
329
      "lib/openagents/memories/admissions.ex"
309 330
    ]
310 331
311 332
    test "every query rooted at the memory plane names user_id" do
test/openagents_web/controllers/memory_controller_test.exs modified +21 -1

@@ -91,13 +91,33 @@ defmodule OpenAgentsWeb.MemoryControllerTest do

91 91
      body =
92 92
        conn
93 93
        |> put_chat_api_token("memory-bucket")
94
        |> post(~p"/api/v1/memories", %{"body" => "Fine.", "bucket" => "system"})
94
        |> post(~p"/api/v1/memories", %{"body" => "Fine.", "bucket" => "wishlist"})
95 95
        |> json_response(422)
96 96
97 97
      assert body["code"] == "validation_failed"
98 98
      assert Map.has_key?(body["errors"], "bucket")
99 99
    end
100 100
101
    # This surface writes the account's own memories. The system bucket is a
102
    # network claim behind an admission gate
103
    # (`OpenAgents.Memories.Admissions`), and the route names none of the
104
    # fields such a claim needs, so a caller cannot propose one through here.
105
    test "refuses a system memory, whose fields this route does not carry", %{conn: conn} do
106
      body =
107
        conn
108
        |> put_chat_api_token("memory-system-bucket")
109
        |> post(~p"/api/v1/memories", %{
110
          "body" => "The gateway 402s when the default model is retired.",
111
          "bucket" => "system",
112
          "tier" => "ledger",
113
          "slug" => "sys:gateway-402-retired-model"
114
        })
115
        |> json_response(422)
116
117
      assert body["code"] == "validation_failed"
118
      assert Map.has_key?(body["errors"], "evidence_refs")
119
    end
120
101 121
    test "refuses a supersedes that names no live memory of this account", %{conn: conn} do
102 122
      body =
103 123
        conn
test/openagents_web/operator_surface_test.exs modified +2

@@ -83,6 +83,8 @@ defmodule OpenAgentsWeb.OperatorSurfaceTest do

83 83
    OpenAgents.Deployments.Authority => "separates the fleet operator from a tenant principal",
84 84
    OpenAgents.DeviceAuthorizations => "refuses to mint a device grant for an operator account",
85 85
    OpenAgents.Forge.Promotion => "gates fleet deploy-target promotion",
86
    OpenAgents.Memories.Admissions =>
87
      "gates admitting a system memory and correcting an admitted one",
86 88
    OpenAgents.SCV.CodexAccounts => "gates connecting and disconnecting a Codex account",
87 89
    OpenAgents.SCV.Deployments => "gates starting an SCV deployment",
88 90
    OpenAgents.StagingCleanup => "refuses to delete an operator account",

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