Contest a system memory by record, and suspend it while the argument runs

7701e1d7fc74 · AtlantisPleb · · parent 088d1668457f

Contest a system memory by record, and suspend it while the argument runs

A reader who finds an admitted system claim wrong had one path: ask the author
or a steward to supersede it. This adds the path the specification names in
section 7.3, on the records #61 already shaped for it — the `memory_admissions`
role enum carried `challenge` and `refutation` from the start and only
`admission` was written.

A **challenge** is any account's row against a system memory: slug
`chl:<memory>`, a ground, and optional evidence of its own. A challenge
carrying evidence suspends its admitted target from recall until it is
resolved, which is the fail-safe direction — a contested claim silently absent
is cheaper than a poisoned claim silently present. An unevidenced challenge is
recorded and changes nothing, so an empty evidence list is refused at the table
rather than read as absence.

Two resolutions, both steward-only. A **refutation** answers one challenge
(slug `ref:<challenge>`) and restores the target. A steward's supersession on
the target's slug records a refutation of each open challenge in the same
transaction, so the second resolution path leaves a receipt rather than
something a reader infers from a pointer. An author's own correction resolves
nothing: the challenges stand against the row that was wrong. A reversal is a
further challenge; nothing is ever edited.

`Admissions.status/1` now derives `candidate`, `admitted`, `rejected`, or
`suspended`, and the precedence rules are stated in the moduledoc. Every
ordering it uses is over `{inserted_at, id}` — data on the records — and
resolution is set membership rather than a comparison of dates, so a refutation
backfilled earlier than its challenge still resolves it. A test inserts one
six-record set in all 720 orders and asserts one answer.

The flood cap bounds one account to suspending at most 25% of the admitted
store, rounded up, earliest first; the rest are recorded and queue. Stated
without overstatement: with one server and no anonymous publisher there is no
forged attribution, so this covers a prolific or systematically wrong
challenger rather than an attack, and it bounds the store rather than the
per-message recall pool of section 7.2, which does not exist until recall reads
this bucket.

Three shapes are database predicates rather than validations. The composite
foreign key `(challenge_id, memory_id, challenge_role) -> (id, memory_id,
role)` makes a refutation of an admission record, or of a challenge against
another memory, unrepresentable — `challenge_role` carries the literal because
PostgreSQL takes no literal in a foreign key. A challenge's evidence list is
checked in `memory_admissions_shape` with every column asserted `IS NOT NULL`
before it is compared, since a check constraint passes when it evaluates to
NULL. Each role's slug is pinned to the specification's.

`steward_id` becomes `author_id`: only a steward admits, but anyone may
challenge, and the column records who wrote the record. `OpenAgents.Memories.
Evidence` holds the evidence-ref shape both records now share.

Recall is untouched. `recall/3` still reads the `user` and `learned` buckets as
a predicate in the query, and a suspended, a challenged, and an admitted system
row all reach the same number of turns, which is none. Surfacing the bucket
stays the recall issue's decision, with an eligibility filter that now has a
status to read.

Refs OpenAgentsInc/openagents#62.

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 430 · 2026-08-26T00:02:29.580675Z

Changed files

  • modified INVARIANTS.md
  • modified docs/taxonomy.md
  • modified lib/openagents/memories.ex
  • modified lib/openagents/memories/admission.ex
  • modified lib/openagents/memories/admissions.ex
  • added lib/openagents/memories/evidence.ex
  • modified lib/openagents/memories/memory.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260825230000_create_memory_challenges.exs
  • added test/openagents/memories/challenge_test.exs
  • modified test/openagents/memories/system_memory_test.exs
  • modified test/openagents_web/operator_surface_test.exs

Diff

12 files changed, +1836 -177

INVARIANTS.md modified +69 -24

@@ -1119,7 +1119,7 @@ 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
1122
### MEMORY-011 — A system memory is evidenced, admitted by receipt, contested by record, and surfaced to nobody
1123 1123
1124 1124
Status: Current
1125 1125

@@ -1142,8 +1142,9 @@ compared, because a check constraint passes when it evaluates to NULL and an

1142 1142
absent evidence list is exactly the hole the constraint exists to close.
1143 1143
1144 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
1145
`memory_admissions` — attributed through `author_id` to the account that wrote
1146
it, dated, and append-only, with no `updated_at` and no path that updates one —
1147
and
1147 1148
`OpenAgents.Memories.Admissions.status/1` derives the effective status from
1148 1149
those records. The `admission` column on the candidate is the author's claim
1149 1150
and nothing more, so a row that says `admitted` with no steward record behind

@@ -1155,19 +1156,58 @@ bootstrapped to the owner's account. Only the original author or a steward

1155 1156
writes a superseding row on a system slug; anyone else is refused, and
1156 1157
supersession is the only correction path there is.
1157 1158
1158
Neither path reads another account's memory. An admission record proves its
1159
candidate is a system row through the composite foreign key
1159
Disagreement is a record too. Any account may write a **challenge** against a
1160
system memory — slug `chl:<memory>`, role `challenge`, a ground, and optional
1161
evidence of its own — and a challenge that carries evidence suspends an
1162
admitted target until it is resolved. That is the fail-safe direction: a
1163
contested claim silently absent is cheaper than a poisoned claim silently
1164
present. An unevidenced challenge is recorded and changes nothing, so an empty
1165
evidence list is refused at the table rather than read as absence. Two
1166
resolutions, both steward-only: a **refutation** of one challenge (slug
1167
`ref:<challenge>`, role `refutation`), or a superseding row on the target's
1168
slug, which records a refutation of each open challenge in the same
1169
transaction so the resolution is a receipt rather than an inference. A
1170
refutation from a non-steward account is refused on the role, before the
1171
challenge is read. A reversal is a further challenge; nothing is ever edited.
1172
1173
The derived status — `candidate`, `admitted`, `rejected`, or `suspended` —
1174
follows from the set of records and not from the order they arrived in. Every
1175
ordering the derivation uses is over `{inserted_at, id}`, which is data on the
1176
records, and resolution is set membership rather than a comparison of dates, so
1177
a refutation backfilled earlier than its challenge still resolves it and every
1178
permutation of one record set derives one answer.
1179
1180
One account's open evidenced challenges suspend at most 25% of the admitted
1181
store, rounded up; the earliest by `{inserted_at, id}` take effect and the rest
1182
queue with no recall effect. Stated without overstatement: with one server and
1183
no anonymous publisher there is no forged attribution, so this bounds a
1184
prolific or systematically wrong challenger rather than defending against an
1185
attack, and it is a bound on the store rather than on the per-message recall
1186
pool of specification section 7.2, which does not exist until recall reads this
1187
bucket.
1188
1189
None of these paths reads another account's memory. An admission or challenge
1190
record proves its target is a system row through the composite foreign key
1160 1191
`(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.
1192
lookup; a refutation is held to a challenge against the memory it restores by
1193
the composite foreign key
1194
`(challenge_id, memory_id, challenge_role) -> (id, memory_id, role)`, which is
1195
why `challenge_role` carries the literal the check constraint pins; and a
1196
correction authorizes inside the `UPDATE` predicate — the actor's own `user_id`
1197
or the steward role — so a caller with no standing is refused without learning
1198
that the row exists. MEMORY-010's rule that every query rooted at
1199
`OpenAgents.Memories.Memory` names `user_id` holds unchanged, and the AST proof
1200
reads this module too.
1166 1201
1167 1202
Nothing surfaces. `OpenAgents.Memories.recall/3` reads the `user` and `learned`
1168 1203
buckets, named as a predicate in the query rather than filtered out of its
1169 1204
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
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
1171 1211
can stand beside MEMORY-001 and MEMORY-010 rather than amending them: an
1172 1212
admitted row read into every account's turn is cross-account recall by
1173 1213
construction, so surfacing the bucket is a privacy decision with an eligibility

@@ -1176,10 +1216,12 @@ evidenced, and admitted but recalled by nobody is a coherent state; a quietly

1176 1216
widened recall predicate is not.
1177 1217
1178 1218
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`.
1219
`OpenAgents.Memories.Memory`, `OpenAgents.Memories.Evidence`, the
1220
`memories_system_shape` and `memory_admissions_shape` constraints and the
1221
composite foreign keys `memory_admissions_memory_fkey` and
1222
`memory_admissions_challenge_fkey`,
1223
`test/openagents/memories/system_memory_test.exs`, and
1224
`test/openagents/memories/challenge_test.exs`.
1183 1225
1184 1226
### PRIVACY-001 — Secret-bearing profile memory is rejected, never scrub-stored
1185 1227

@@ -3320,14 +3362,17 @@ sentence:

3320 3362
  `OpenAgents.ProfileMemory.forget_active/2`, which supersedes rather than
3321 3363
  deletes, so a retraction is another entry in the audit trail the same
3322 3364
  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.
3365
- Admitting or rejecting a candidate system memory, refuting a challenge
3366
  against one, and correcting an admitted one, through
3367
  `OpenAgents.Memories.Admissions` (MEMORY-011). Only a steward admits and only
3368
  a steward refutes, and the steward set is this allowlist, bootstrapped to the
3369
  owner's account. Challenging is the one path here that needs no authority:
3370
  any account may record that an admitted claim is wrong, which is the whole
3371
  reason the record exists. The write appends: every record is inserted and
3372
  never updated, and a correction supersedes rather than edits, so a reversal
3373
  is a second record beside the first rather than a verdict that quietly
3374
  changed. The authority buys no read — the operator sees no memory of another
3375
  account through this module.
3331 3376
- Recording Gym runs and trials under `POST /api/v1/gym/runs`, the lifecycle
3332 3377
  routes `POST /api/v1/gym/runs/start`, `POST /api/v1/gym/runs/:id/trials`,
3333 3378
  and `PATCH /api/v1/gym/runs/:id`

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

6076 6121
| MEMORY-008 | `test/openagents/experience_memory_test.exs` |
6077 6122
| MEMORY-009 | `test/openagents/graph_memory_test.exs` |
6078 6123
| 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` |
6124
| MEMORY-011 | `test/openagents/memories/system_memory_test.exs`, `test/openagents/memories/challenge_test.exs` |
6080 6125
| PRIVACY-001 | `test/openagents/memory/policy_and_redaction_test.exs`, `test/openagents/memory/scope_boundary_test.exs` |
6081 6126
| TURN-001 | `test/openagents/conversations_test.exs` |
6082 6127
| TURN-002 | `test/openagents/conversations_test.exs` |
docs/taxonomy.md modified +10

@@ -325,6 +325,16 @@ never read from the candidate. Recall does not read this bucket: a system

325 325
memory is stored, evidenced, and admitted, and surfaced to no session yet
326 326
(MEMORY-011).
327 327
328
**Challenge** and **refutation** — the two other records in that table. A
329
*challenge* is any account's statement that an admitted system memory is wrong;
330
one carrying evidence of its own is an *evidenced challenge*, and only an
331
evidenced challenge suspends its target. A *refutation* is a steward's
332
resolution of one challenge. Say "suspended" for the status an open evidenced
333
challenge derives, not "rejected" — a rejection is a steward's verdict on a
334
candidate, and a suspension is a claim that was admitted and is now contested.
335
Say "queued" for a challenge held back by the flood cap: it is recorded and
336
attributed, and it has no effect on any status.
337
328 338
### Threads
329 339
330 340
A **thread** is the unit of agent work. Everything in this section describes a
lib/openagents/memories.ex modified +5 -3

@@ -49,7 +49,8 @@ defmodule OpenAgents.Memories do

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 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
52
  an evidence-backed admission gate before they mean anything, and can be
53
  challenged and resolved by further records afterwards
53 54
  (`OpenAgents.Memories.Admissions`). `recall/3` reads the first two only.
54 55
55 56
  ## Corrections supersede

@@ -231,8 +232,9 @@ defmodule OpenAgents.Memories do

231 232
  cut excluded is counted rather than dropped in silence.
232 233
233 234
  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
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
236 238
  the first is cross-account recall, which MEMORY-001 and MEMORY-010 forbid.
237 239
  The bucket list is a predicate in the query rather than a filter applied to
238 240
  its results, so widening it is a deliberate edit to the recall issue's
lib/openagents/memories/admission.ex modified +129 -32

@@ -1,27 +1,50 @@

1 1
defmodule OpenAgents.Memories.Admission do
2 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.
3
  One record about one system memory: a verdict on it, a challenge to it, or a
4
  refutation of a challenge.
5
6
  All three are receipts, not assertions. The registry does not turn a promise
7
  green because someone said so, and the memory store does not admit a claim
8
  because its author wrote `admitted` on it: the records here are what a status
9
  is derived from, and the `admission` field on the candidate is only what the
10
  author claimed.
11
12
  Every record is append-only. There is no `updated_at` and nothing updates
13
  one, so a steward who changes their mind writes a second record rather than
14
  editing the first, and both stay readable. A reversal is a further record for
15
  the same reason: it is how the store keeps the argument rather than the last
16
  word in it.
17
18
  ## The three roles
19
20
  * `admission` — a steward's verdict, `admitted` or `rejected`, slug
21
    `adm:<memory>`. Only a steward writes one.
22
  * `challenge` — any account's statement that the claim is wrong, slug
23
    `chl:<memory>`. A challenge carrying its own `evidence_refs` is an
24
    *evidenced* challenge and suspends its target; one without is recorded and
25
    changes nothing.
26
  * `refutation` — a steward's resolution of one challenge, slug
27
    `ref:<challenge>`, restoring the target. Only a steward writes one.
28
29
  `author_id` is who wrote the record, whichever role it carries. The column
30
  was `steward_id` while `admission` was the only role written; anyone may
31
  challenge, so the steward rule lives at the write path rather than in a
32
  column name that would be wrong on two roles out of three.
33
34
  ## What the database holds rather than the changeset
14 35
15 36
  `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.
37
  `(memory_id, memory_bucket) -> memories (id, bucket)` can pin the target to
38
  the `system` bucket. That is what lets a write refuse a record naming a
39
  `user` or `learned` row without reading `memories` at all, which is how these
40
  paths stay clear of the account boundary MEMORY-010 draws.
41
42
  `challenge_role` is always the literal `challenge` on a refutation and null
43
  elsewhere. It exists so the second composite foreign key,
44
  `(challenge_id, memory_id, challenge_role) -> (id, memory_id, role)`, can
45
  insist that a refutation names a challenge — and one against the same memory
46
  it claims to restore. PostgreSQL will not put a literal in a foreign key, so
47
  the literal is a column the `memory_admissions_shape` constraint pins.
25 48
  """
26 49
27 50
  use Ecto.Schema

@@ -29,7 +52,7 @@ defmodule OpenAgents.Memories.Admission do

29 52
  import Ecto.Changeset
30 53
31 54
  alias OpenAgents.Accounts.User
32
  alias OpenAgents.Memories.Memory
55
  alias OpenAgents.Memories.{Evidence, Memory}
33 56
34 57
  @primary_key {:id, :binary_id, autogenerate: true}
35 58
  @foreign_key_type :binary_id

@@ -42,11 +65,14 @@ defmodule OpenAgents.Memories.Admission do

42 65
  schema "memory_admissions" do
43 66
    belongs_to :memory, Memory
44 67
    field :memory_bucket, :string, default: "system"
45
    belongs_to :steward, User
68
    belongs_to :author, User
69
    belongs_to :challenge, __MODULE__
70
    field :challenge_role, :string
46 71
    field :slug, :string
47 72
    field :role, :string, default: "admission"
48 73
    field :verdict, :string
49 74
    field :ground, :string
75
    field :evidence_refs, {:array, :map}
50 76
    timestamps(updated_at: false)
51 77
  end
52 78

@@ -65,9 +91,22 @@ defmodule OpenAgents.Memories.Admission do

65 91
  def ground_characters, do: @ground_characters
66 92
67 93
  @doc """
68
  Validates one admission record.
94
  Whether this record is an evidenced challenge.
95
96
  The one distinction recall turns on: an evidenced challenge suspends its
97
  target, an unevidenced one is recorded and changes nothing. An empty list
98
  cannot reach the table, so absence is the only unevidenced shape there is.
99
  """
100
  @spec evidenced_challenge?(t()) :: boolean()
101
  def evidenced_challenge?(%__MODULE__{role: "challenge", evidence_refs: [_first | _rest]}),
102
    do: true
103
104
  def evidenced_challenge?(%__MODULE__{}), do: false
105
106
  @doc """
107
  Validates one admission record: a steward's verdict on a candidate.
69 108
70
  The steward and the candidate are set on the struct rather than cast, so a
109
  The author and the target are set on the struct rather than cast, so a
71 110
  request body can name neither who admitted nor, by extension, on whose
72 111
  authority.
73 112
  """

@@ -75,15 +114,69 @@ defmodule OpenAgents.Memories.Admission do

75 114
  def changeset(record, attrs) do
76 115
    record
77 116
    |> cast(attrs, [:verdict, :ground])
117
    |> put_role("admission")
118
    |> put_slug(:memory_id, "adm:")
119
    |> validate_required([:verdict])
120
    |> validate_inclusion(:verdict, @verdicts)
121
    |> validate_shape()
122
  end
123
124
  @doc """
125
  Validates one challenge: any account's statement that an admitted claim is
126
  wrong, and the ground for saying so.
127
128
  `evidence_refs` is the only field beyond the ground a caller may name, and it
129
  is optional. Carrying it is what makes the challenge evidenced, and an
130
  evidenced challenge is the one that suspends its target — so the shape is
131
  checked here and again at the table.
132
  """
133
  @spec challenge_changeset(t(), map()) :: Ecto.Changeset.t()
134
  def challenge_changeset(record, attrs) do
135
    record
136
    |> cast(attrs, [:ground, :evidence_refs])
137
    |> put_role("challenge")
138
    |> put_slug(:memory_id, "chl:")
139
    |> Evidence.validate(:evidence_refs)
140
    |> validate_shape()
141
  end
142
143
  @doc """
144
  Validates one refutation: a steward's resolution of one challenge.
145
146
  The challenge and the memory it restores are set on the struct, so a request
147
  body names neither. The ground is all a caller supplies.
148
  """
149
  @spec refutation_changeset(t(), map()) :: Ecto.Changeset.t()
150
  def refutation_changeset(record, attrs) do
151
    record
152
    |> cast(attrs, [:ground])
153
    |> put_role("refutation")
154
    |> put_change(:challenge_role, "challenge")
155
    |> put_slug(:challenge_id, "ref:")
156
    |> validate_required([:challenge_id])
157
    |> foreign_key_constraint(:challenge_id,
158
      name: :memory_admissions_challenge_fkey,
159
      message: "names no challenge against this memory"
160
    )
161
    |> validate_shape()
162
  end
163
164
  # Everything the three roles agree on. `role` is put rather than cast, so no
165
  # request body can turn a challenge into a refutation and no caller reaches a
166
  # steward-only role through the path that does not check for one.
167
  defp put_role(changeset, role) do
168
    changeset
78 169
    |> update_change(:ground, &trim/1)
79
    |> put_change(:role, "admission")
170
    |> put_change(:role, role)
80 171
    |> put_change(:memory_bucket, "system")
81
    |> put_slug()
82
    |> validate_required([:memory_id, :steward_id, :slug, :role, :verdict, :ground])
172
  end
173
174
  defp validate_shape(changeset) do
175
    changeset
176
    |> validate_required([:memory_id, :author_id, :slug, :role, :ground])
83 177
    |> validate_inclusion(:role, @roles)
84
    |> validate_inclusion(:verdict, @verdicts)
85 178
    |> validate_length(:ground, min: 1, max: @ground_characters, count: :graphemes)
86
    |> foreign_key_constraint(:steward_id)
179
    |> foreign_key_constraint(:author_id)
87 180
    # The composite key, named as the database names it. A record for a `user`
88 181
    # or `learned` row fails here rather than in a read that had to cross an
89 182
    # account to check.

@@ -92,11 +185,15 @@ defmodule OpenAgents.Memories.Admission do

92 185
      message: "names no system memory"
93 186
    )
94 187
    |> check_constraint(:verdict, name: :memory_admissions_shape)
188
    |> check_constraint(:evidence_refs,
189
      name: :memory_admissions_shape,
190
      message: "does not satisfy the record shape"
191
    )
95 192
  end
96 193
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)
194
  defp put_slug(changeset, field, prefix) do
195
    case get_field(changeset, field) do
196
      id when is_binary(id) -> put_change(changeset, :slug, prefix <> id)
100 197
      _absent -> changeset
101 198
    end
102 199
  end
lib/openagents/memories/admissions.ex modified +408 -47

@@ -1,24 +1,122 @@

1 1
defmodule OpenAgents.Memories.Admissions do
2 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.
3
  The gate in front of the system bucket, and the argument behind it: anyone
4
  can propose, only evidence admits, anyone may challenge, and only a steward
5
  resolves.
5 6
6
  ## Why admission is a record rather than a field
7
  ## Why every state is a record rather than a field
7 8
8 9
  A wrong `user` memory misleads one session. A wrong `system` memory would
9 10
  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.
11
  promise registry treats a green promise: it is derived from receipts somebody
12
  is answerable for, never read from a flag the claimant set. An author who
13
  writes `admission: "admitted"` on their own row has claimed something, and
14
  `status/1` still answers `"candidate"` until a steward records a verdict.
15
16
  The same reasoning carries to disagreement. A reader who finds an admitted
17
  claim wrong needs a path other than editing somebody else's row, so a
18
  challenge is a record, a refutation is a record, and a reversal is a further
19
  record. Nothing here updates anything, and the whole argument stays readable.
20
21
  ## The three roles
22
23
  * `record/3` writes an **admission**: a steward's verdict on a candidate.
24
  * `challenge/3` writes a **challenge**: any account's statement that an
25
    admitted claim is wrong, and the ground for saying so. A challenge that
26
    carries its own evidence is an *evidenced* challenge.
27
  * `refute/3` writes a **refutation**: a steward's resolution of one
28
    challenge, restoring the target.
29
30
  `supersede/3` is the other resolution path — a correction or a tombstone on
31
  the target's slug — and when a steward exercises it, it records a refutation
32
  of each open challenge on the target in the same transaction. That keeps the
33
  resolution a receipt rather than something a reader has to infer, and it
34
  keeps `status/1` a fold over records with no read of `memories` in it.
35
36
  ## How a status is derived
37
38
  These rules are the whole of it, and they are stated in precedence order.
39
  Every one of them reads the set of records; none of them reads the order the
40
  records were inserted in or the order a query happened to return them.
41
42
  1. **Admission first.** The effective verdict is the latest `admission`
43
     record by `{inserted_at, id}`, and a memory with no admission record
44
     behind it is a `candidate`. The `id` tie-break is what makes two records
45
     written in the same microsecond derive the same answer every time.
46
  2. **Only an admitted claim can be suspended.** A challenge against a
47
     candidate or a rejected memory is recorded and changes nothing: suspension
48
     means removal from recall, and neither of those is eligible for recall to
49
     begin with. Such a challenge also consumes none of its author's cap
50
     (rule 6), because it suspends nothing.
51
  3. **An open evidenced challenge suspends its target.** The status is
52
     `suspended`. This is the fail-safe direction: a contested claim silently
53
     absent is cheaper than a poisoned claim silently present. An *unevidenced*
54
     challenge is recorded and has no effect at all — that distinction is the
55
     one thing recall turns on, so an empty evidence list is refused at the
56
     table rather than read as absence.
57
  4. **A refutation resolves the challenge it names, and only that one.** A
58
     challenge is open when no refutation names it. Resolution is set
59
     membership, not a comparison of dates, which is what makes the derivation
60
     independent of arrival order: a refutation backfilled with an
61
     `inserted_at` earlier than its challenge still resolves it, and a second
62
     refutation of an already-resolved challenge changes nothing. A steward who
63
     changes their mind writes a **new challenge**; the refuted one stays
64
     refuted, and both stay readable.
65
  5. **Nothing else resolves a challenge.** A later admission record does not:
66
     re-admitting a suspended claim leaves it suspended, because the two
67
     resolution paths the specification names are a refutation and a
68
     superseding row, and a steward who means to restore a claim should have to
69
     say which challenge they are answering.
70
  6. **A challenge-flood cap bounds one account.** See below.
71
72
  Conflicts resolve the same way in every direction. Two verdicts on one
73
  candidate: the later one wins, ties broken by `id`. Two refutations of one
74
  challenge: the first closes it and the rest are no-ops. A challenge and a
75
  refutation written in either order: the same status, because neither rule
76
  above compares their timestamps.
77
78
  A superseded memory keeps whatever admission status its records derive.
79
  Supersession is not an admission state, it is a pointer at the row that
80
  replaced this one, and it is read where recall decides what to serve.
81
82
  ## What the flood cap actually bounds
83
84
  An account's open evidenced challenges suspend at most 25% of the
85
  admitted system store, rounded up, and beyond that they are recorded and
86
  queue with no recall effect. The challenges that take effect are the earliest
87
  by `{inserted_at, id}`, so the outcome is deterministic and equal inputs give
88
  equal outcomes. Several challenges by one account against the same memory
89
  count once: the bound is on memories suspended, not on records written.
90
91
  Two honest qualifications.
92
93
  This is **not** a defence against an attack. There is one server and no
94
  anonymous publisher, so there is no forged attribution and every challenger
95
  is an account the operator already accepts claims from. What the cap covers
96
  is the ordinary case: one prolific, or one systematically wrong, challenger
97
  should not be able to empty the store while a steward works through the
98
  queue.
99
100
  And it is a bound on the **store**, not on a per-message recall pool.
101
  Specification section 7.2 states the share against the ranked pool for a
102
  message, and no such pool exists yet — recall does not read this bucket. A
103
  share of the store does not imply the same share of every pool drawn from it,
104
  because an account's challenges can concentrate on one subject. The recall
105
  issue that builds the pool applies section 7.2's cap at ranking time; this
106
  one bounds what can be suspended at all.
107
108
  ## Who may write each record
109
110
  * **Admission** — a steward, and nobody else.
111
  * **Challenge** — any account, including the claim's own author. Challenging
112
    is the path for a reader with no standing to correct, so restricting it
113
    would remove the only reason the record exists.
114
  * **Refutation** — a steward, and nobody else. On this substrate that is an
115
    authorization check rather than a signature check: the server rejects the
116
    write on the role.
117
  * **Supersession** — the original author or a steward. Anyone else who
118
    disagrees files a challenge; the store has no path for editing somebody
119
    else's claim.
22 120
23 121
  The role is `OpenAgents.Accounts.admin?/1` — the operator allowlist of
24 122
  immutable GitHub numeric IDs, bootstrapped to the owner's account. That is

@@ -30,6 +128,11 @@ defmodule OpenAgents.Memories.Admissions do

30 128
  assignment, not these record shapes. ADMIN-001 enumerates this module as an
31 129
  operator gate.
32 130
131
  The check runs before anything is read, so a refusal tells a caller with no
132
  standing nothing about the row they named. Role is never cast from a request
133
  body either: each changeset puts its own, so the challenge path cannot be
134
  talked into writing a refutation.
135
33 136
  ## What this module deliberately does not do
34 137
35 138
  It does not surface anything. An admitted system memory is stored, derived,

@@ -38,12 +141,19 @@ defmodule OpenAgents.Memories.Admissions do

38 141
  acting account with no unscoped fallback. Reading an admitted row into every
39 142
  account's turn is cross-account recall by construction, so it is a privacy
40 143
  decision that belongs to the recall issue rather than a ranking detail this
41
  one can settle.
144
  one can settle. A challenged memory nobody can see is still a coherent state:
145
  the point is that a wrong claim has a path other than editing someone else's
146
  row, and that a contested claim is marked so the eligibility filter has
147
  something to read when it arrives.
42 148
43 149
  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.
150
  `(memory_id, memory_bucket)` is what proves a target is a `system` row, so
151
  `record/3` and `challenge/3` write without a lookup; `refute/3` reads only
152
  `memory_admissions`, which is network-level rather than account-scoped, and
153
  the composite foreign key on `(challenge_id, memory_id, challenge_role)` is
154
  what actually holds a refutation to a challenge against the memory it
155
  restores. `supersede/3` authorizes inside the `UPDATE` predicate and learns
156
  nothing from a refusal.
47 157
  """
48 158
49 159
  import Ecto.Query

@@ -55,8 +165,15 @@ defmodule OpenAgents.Memories.Admissions do

55 165
  alias OpenAgents.Memories.{Admission, Memory}
56 166
  alias OpenAgents.Repo
57 167
168
  # The share of the admitted store one account's open evidenced challenges may
169
  # suspend, as a percentage. Section 7.2's number, applied to the store
170
  # because there is no recall pool yet. See the module doc.
171
  @challenge_share 25
172
173
  @correction_ground "Resolved by a steward's correction on the target's slug."
174
58 175
  @doc """
59
  Whether `user` may admit.
176
  Whether `user` may admit, refute, and correct any system claim.
60 177
61 178
  A steward is an operator account. `admin?/1` refuses a banned account and
62 179
  reads the GitHub numeric ID rather than the login, so a renamed account keeps

@@ -65,6 +182,22 @@ defmodule OpenAgents.Memories.Admissions do

65 182
  @spec steward?(User.t() | nil) :: boolean()
66 183
  def steward?(user), do: Accounts.admin?(user)
67 184
185
  @doc "The percentage of the admitted store one account's challenges may suspend."
186
  @spec challenge_share() :: pos_integer()
187
  def challenge_share, do: @challenge_share
188
189
  @doc """
190
  How many admitted memories one account's open evidenced challenges may
191
  suspend, given `admitted` admitted memories in the store.
192
193
  Rounded up, so a store of one admitted claim can still have that claim
194
  challenged. An empty store has nothing to suspend and a cap of zero.
195
  """
196
  @spec challenge_cap(non_neg_integer()) :: non_neg_integer()
197
  def challenge_cap(admitted) when is_integer(admitted) and admitted >= 0 do
198
    div(admitted * @challenge_share + 99, 100)
199
  end
200
68 201
  @doc """
69 202
  Writes one admission record against a candidate system memory.
70 203

@@ -85,13 +218,9 @@ defmodule OpenAgents.Memories.Admissions do

85 218
  def record(%User{} = steward, memory_id, attrs) when is_map(attrs) do
86 219
    if steward?(steward) do
87 220
      with {:ok, id} <- cast_id(memory_id) do
88
        %Admission{memory_id: id, steward_id: steward.id}
221
        %Admission{memory_id: id, author_id: steward.id}
89 222
        |> Admission.changeset(normalize(attrs))
90
        |> Repo.insert()
91
        |> case do
92
          {:ok, admission} -> {:ok, admission}
93
          {:error, changeset} -> refusal(changeset)
94
        end
223
        |> insert()
95 224
      end
96 225
    else
97 226
      {:error, :steward_required}

@@ -99,11 +228,72 @@ defmodule OpenAgents.Memories.Admissions do

99 228
  end
100 229
101 230
  @doc """
102
  A candidate's effective admission status.
231
  Writes one challenge against a system memory.
232
233
  Attributes: `ground` (why the claim is wrong) and an optional
234
  `evidence_refs`. Carrying evidence is what makes this an evidenced challenge,
235
  and an evidenced challenge suspends an admitted target from recall until a
236
  steward resolves it. Without evidence the challenge is recorded and changes
237
  nothing — which is still worth writing, because it is on the record and a
238
  steward can read it.
239
240
  Any account may challenge, including the claim's own author. The target and
241
  the author are set on the struct, so a request body names neither, and
242
  `:not_found` covers both a target that is not a system memory and one that
243
  does not exist.
244
  """
245
  @spec challenge(User.t(), String.t(), map()) ::
246
          {:ok, Admission.t()}
247
          | {:error, Ecto.Changeset.t()}
248
          | {:error, :not_found}
249
  def challenge(%User{} = user, memory_id, attrs) when is_map(attrs) do
250
    with {:ok, id} <- cast_id(memory_id) do
251
      %Admission{memory_id: id, author_id: user.id}
252
      |> Admission.challenge_changeset(normalize(attrs))
253
      |> insert()
254
    end
255
  end
103 256
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.
257
  @doc """
258
  Writes one refutation of a challenge, restoring its target.
259
260
  Attributes: `ground` (why the challenge does not stand). Only a steward may
261
  refute, and the check runs before the challenge is read, so an account
262
  without the role learns nothing about the challenge it named.
263
264
  A refutation resolves the one challenge it names. A target under two open
265
  challenges needs two refutations, which is the point: each ground was raised
266
  separately and each is answered separately.
267
  """
268
  @spec refute(User.t(), String.t(), map()) ::
269
          {:ok, Admission.t()}
270
          | {:error, Ecto.Changeset.t()}
271
          | {:error, :steward_required}
272
          | {:error, :not_found}
273
  def refute(%User{} = steward, challenge_id, attrs) when is_map(attrs) do
274
    if steward?(steward) do
275
      with {:ok, id} <- cast_id(challenge_id),
276
           {:ok, challenged} <- fetch_challenge(id) do
277
        %Admission{
278
          memory_id: challenged.memory_id,
279
          author_id: steward.id,
280
          challenge_id: challenged.id
281
        }
282
        |> Admission.refutation_changeset(normalize(attrs))
283
        |> insert()
284
      end
285
    else
286
      {:error, :steward_required}
287
    end
288
  end
289
290
  @doc """
291
  A system memory's effective status: `candidate`, `admitted`, `rejected`, or
292
  `suspended`.
293
294
  Derived from the records that reference it, never from the candidate's own
295
  `admission` field. A memory with no record behind it is a `candidate`,
296
  whatever it says about itself. The precedence rules are in the module doc.
107 297
108 298
  Answers `nil` for a memory outside the system bucket, which has no admission
109 299
  status to have.

@@ -114,16 +304,49 @@ defmodule OpenAgents.Memories.Admissions do

114 304
115 305
  def status(memory_id) when is_binary(memory_id) do
116 306
    case cast_id(memory_id) do
117
      {:ok, id} -> Repo.one(latest(id)) || "candidate"
307
      {:ok, id} -> Map.get(statuses(), id, "candidate")
118 308
      {:error, :not_found} -> nil
119 309
    end
120 310
  end
121 311
122 312
  @doc """
123
  Every admission record against one candidate, oldest first.
313
  Every system memory some record names, and its derived status.
314
315
  The whole store at once, because the challenge cap is a property of the store
316
  rather than of one memory: how many of an account's challenges take effect
317
  depends on how many admitted claims there are and on which of that account's
318
  challenges came first. `status/1` reads one answer out of this.
124 319
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.
320
  The system bucket is small by design — network-level claims, not per-account
321
  ones — so this reads the records rather than pushing the fold into SQL, where
322
  the ordering rules would be harder to see and no easier to trust. A memory
323
  with no record at all is absent here and is a `candidate`.
324
  """
325
  @spec statuses() :: %{optional(String.t()) => String.t()}
326
  def statuses do
327
    Repo.all(
328
      from(record in Admission,
329
        select: %{
330
          id: record.id,
331
          memory_id: record.memory_id,
332
          author_id: record.author_id,
333
          role: record.role,
334
          verdict: record.verdict,
335
          challenge_id: record.challenge_id,
336
          inserted_at: record.inserted_at,
337
          evidence: fragment("coalesce(jsonb_array_length(?), 0)", record.evidence_refs)
338
        }
339
      )
340
    )
341
    |> derive()
342
  end
343
344
  @doc """
345
  Every record against one system memory, oldest first.
346
347
  Append-only, so this is the whole argument rather than the current state:
348
  verdicts, challenges, and refutations together. `status/1` is what reads a
349
  state out of it.
127 350
  """
128 351
  @spec list(Memory.t() | String.t()) :: [Admission.t()]
129 352
  def list(%Memory{id: id}), do: list(id)

@@ -158,6 +381,13 @@ defmodule OpenAgents.Memories.Admissions do

158 381
  ceiling, as `OpenAgents.Memories.create/2` admits one, because it replaces a
159 382
  live row with a live row.
160 383
384
  When a **steward** corrects, the same transaction records a refutation of
385
  every open challenge on the target. That is the specification's second
386
  resolution path, and writing it down keeps the resolution a receipt rather
387
  than something a reader has to infer from a pointer. An author correcting
388
  their own claim resolves nothing: the challenges stand against the row that
389
  was wrong, and the replacement is a new claim with no challenges on it.
390
161 391
  The authorization is the `UPDATE` predicate rather than a read followed by a
162 392
  decision. A caller with no standing gets `:not_supersedable` and learns
163 393
  nothing about the row, including whether it exists.

@@ -180,6 +410,7 @@ defmodule OpenAgents.Memories.Admissions do

180 410
          {0, _rows} -> {:error, :not_supersedable}
181 411
        end
182 412
      end)
413
      |> Multi.run(:resolutions, fn repo, _changes -> resolve_by_correction(repo, user, id) end)
183 414
      |> Repo.transaction()
184 415
      |> case do
185 416
        {:ok, %{replacement: written}} -> {:ok, written}

@@ -189,7 +420,70 @@ defmodule OpenAgents.Memories.Admissions do

189 420
    end
190 421
  end
191 422
192
  # ── internal ───────────────────────────────────────────────────────────────
423
  # ── writing ────────────────────────────────────────────────────────────────
424
425
  defp insert(changeset) do
426
    case Repo.insert(changeset) do
427
      {:ok, written} -> {:ok, written}
428
      {:error, refused} -> refusal(refused)
429
    end
430
  end
431
432
  # The steward's correction is itself the resolution, so it leaves one
433
  # refutation per open challenge behind it. Unevidenced challenges are
434
  # resolved too: the correction answers the ground whether or not the ground
435
  # arrived with evidence, and leaving one open would mean the argument reads
436
  # as unfinished after it was settled.
437
  defp resolve_by_correction(repo, %User{} = user, target_id) do
438
    if steward?(user) do
439
      target_id
440
      |> open_challenges()
441
      |> repo.all()
442
      |> Enum.reduce_while({:ok, []}, fn challenge, {:ok, written} ->
443
        %Admission{memory_id: target_id, author_id: user.id, challenge_id: challenge.id}
444
        |> Admission.refutation_changeset(%{"ground" => @correction_ground})
445
        |> repo.insert()
446
        |> case do
447
          {:ok, refutation} -> {:cont, {:ok, [refutation | written]}}
448
          {:error, changeset} -> {:halt, {:error, changeset}}
449
        end
450
      end)
451
    else
452
      {:ok, []}
453
    end
454
  end
455
456
  # ── reading ────────────────────────────────────────────────────────────────
457
458
  # Only `memory_admissions`, and only the id: the record table is
459
  # network-level rather than account-scoped, and this crosses no account
460
  # boundary. A refutation still has to survive the composite foreign key,
461
  # which is what actually binds it to a challenge against this memory.
462
  defp fetch_challenge(id) do
463
    query =
464
      from(record in Admission,
465
        where: record.id == ^id,
466
        where: record.role == "challenge",
467
        select: %{id: record.id, memory_id: record.memory_id}
468
      )
469
470
    case Repo.one(query) do
471
      nil -> {:error, :not_found}
472
      challenged -> {:ok, challenged}
473
    end
474
  end
475
476
  defp open_challenges(target_id) do
477
    resolved =
478
      from(record in Admission, where: record.role == "refutation", select: record.challenge_id)
479
480
    from(record in Admission,
481
      where: record.memory_id == ^target_id,
482
      where: record.role == "challenge",
483
      where: record.id not in subquery(resolved),
484
      select: %{id: record.id}
485
    )
486
  end
193 487
194 488
  # MEMORY-010: the account boundary is a predicate in the query. Here it is
195 489
  # one half of the authorization — the row is the actor's own — and the other

@@ -207,21 +501,88 @@ defmodule OpenAgents.Memories.Admissions do

207 501
    )
208 502
  end
209 503
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
    )
504
  # ── derivation ─────────────────────────────────────────────────────────────
505
506
  # The rules in the module doc, in precedence order. Every ordering below is
507
  # over `{inserted_at, id}`, which is data on the records themselves, so the
508
  # same set of records derives the same statuses whatever order they arrived
509
  # or were read in.
510
  defp derive(records) do
511
    verdicts = verdicts(records)
512
    admitted = for {id, "admitted"} <- verdicts, into: MapSet.new(), do: id
513
    suspended = suspensions(records, admitted)
514
515
    Map.new(verdicts, fn {memory_id, verdict} ->
516
      if verdict == "admitted" and MapSet.member?(suspended, memory_id) do
517
        {memory_id, "suspended"}
518
      else
519
        {memory_id, verdict}
520
      end
521
    end)
522
  end
523
524
  # Rule 1. The latest verdict per memory. A memory with challenges but no
525
  # verdict is absent from this map and reads as a candidate.
526
  defp verdicts(records) do
527
    records
528
    |> Enum.filter(&(&1.role == "admission"))
529
    |> Enum.group_by(& &1.memory_id)
530
    |> Map.new(fn {memory_id, written} ->
531
      {memory_id, written |> Enum.max_by(&key/1) |> Map.fetch!(:verdict)}
532
    end)
533
  end
534
535
  # Rules 2 through 6. An open evidenced challenge against an admitted memory
536
  # suspends it, up to the cap on its author.
537
  defp suspensions(records, admitted) do
538
    resolved =
539
      for %{role: "refutation", challenge_id: id} <- records,
540
          is_binary(id),
541
          into: MapSet.new(),
542
          do: id
543
544
    cap = challenge_cap(MapSet.size(admitted))
545
546
    records
547
    |> Enum.filter(fn record ->
548
      record.role == "challenge" and record.evidence > 0 and
549
        not MapSet.member?(resolved, record.id) and
550
        MapSet.member?(admitted, record.memory_id)
551
    end)
552
    |> Enum.group_by(& &1.author_id)
553
    |> Enum.flat_map(fn {_author, challenges} -> effective(challenges, cap) end)
554
    |> MapSet.new()
218 555
  end
219 556
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.
557
  # One account's share. Several challenges against the same memory count once,
558
  # because the bound is on memories suspended rather than on records written,
559
  # and the earliest of them is the one that dates the suspension.
560
  defp effective(challenges, cap) do
561
    challenges
562
    |> Enum.group_by(& &1.memory_id)
563
    |> Enum.map(fn {_memory_id, written} -> Enum.min_by(written, &key/1) end)
564
    |> Enum.sort_by(&key/1)
565
    |> Enum.take(cap)
566
    |> Enum.map(& &1.memory_id)
567
  end
568
569
  # The total order every rule sorts by. `inserted_at` is compared as an
570
  # integer rather than as a `DateTime` struct, because Erlang term order over
571
  # a struct compares its keys alphabetically — `day` before `month` before
572
  # `year` — which is not time. `id` breaks a tie, so two records written in
573
  # the same microsecond still derive one answer.
574
  defp key(record), do: {DateTime.to_unix(record.inserted_at, :microsecond), record.id}
575
576
  # ── refusals ───────────────────────────────────────────────────────────────
577
578
  # The composite foreign keys are what refuse a target outside the system
579
  # bucket and a refutation naming something that is not a challenge against
580
  # this memory, so their violations are reported as absence rather than as a
581
  # changeset error about a column the caller never named.
223 582
  defp refusal(changeset) do
224
    if Enum.any?(changeset.errors, fn {field, _error} -> field == :memory_id end) do
583
    if Enum.any?(changeset.errors, fn {field, _error} ->
584
         field in [:memory_id, :challenge_id]
585
       end) do
225 586
      {:error, :not_found}
226 587
    else
227 588
      {:error, changeset}
lib/openagents/memories/evidence.ex added +100

@@ -0,0 +1,100 @@

1
defmodule OpenAgents.Memories.Evidence do
2
  @moduledoc """
3
  What a piece of evidence looks like, in one place.
4
5
  Two records cite evidence: a system memory, where the list is required and a
6
  claim without one is an assertion rather than a memory
7
  (`OpenAgents.Memories.Memory`), and a challenge, where the list is optional
8
  and its presence is exactly what separates a challenge that suspends its
9
  target from one that is merely recorded
10
  (`OpenAgents.Memories.Admission`).
11
12
  The shape is the same on both, so it is defined once. Each entry names a
13
  `kind` of `receipt`, `memory`, or `url`, a `ref`, and a `digest` — the digest
14
  so the evidence behind an admitted claim, or behind a suspension, cannot be
15
  swapped for something else afterwards.
16
17
  This is the half that can explain itself to a caller. The other half is a
18
  check constraint on each table, because a validation the changeset applies is
19
  an application filter and a second write path reopens it (MEMORY-004).
20
  """
21
22
  import Ecto.Changeset
23
24
  @kinds ~w(receipt memory url)
25
  @maximum 20
26
27
  @doc "The kinds of evidence a record may cite."
28
  @spec kinds() :: [String.t()]
29
  def kinds, do: @kinds
30
31
  @doc "The most pieces of evidence one record may cite."
32
  @spec maximum() :: pos_integer()
33
  def maximum, do: @maximum
34
35
  @doc """
36
  Validates an evidence list, normalising the entries it accepts.
37
38
  Absence passes: a caller that requires evidence says so with
39
  `validate_required/2` first, and the two records differ on that point. An
40
  empty list never passes, on either record — a list that names nothing is a
41
  malformed citation rather than an absent one, and reading it as "no evidence"
42
  is how an evidenced challenge would arrive claiming to be unevidenced.
43
  """
44
  @spec validate(Ecto.Changeset.t(), atom()) :: Ecto.Changeset.t()
45
  def validate(changeset, field) do
46
    case get_change(changeset, field, get_field(changeset, field)) do
47
      nil ->
48
        changeset
49
50
      [] ->
51
        add_error(changeset, field, "must name at least one piece of evidence")
52
53
      refs when is_list(refs) and length(refs) > @maximum ->
54
        add_error(changeset, field, "names more than #{@maximum} pieces of evidence")
55
56
      refs when is_list(refs) ->
57
        if Enum.all?(refs, &ref?/1) do
58
          put_change(changeset, field, Enum.map(refs, &normalize/1))
59
        else
60
          add_error(
61
            changeset,
62
            field,
63
            "each entry needs a kind of #{Enum.join(@kinds, ", ")}, a ref, and a digest"
64
          )
65
        end
66
67
      _not_a_list ->
68
        add_error(changeset, field, "must be a list")
69
    end
70
  end
71
72
  defp ref?(ref) when is_map(ref) do
73
    kind(ref) in @kinds and present?(entry(ref, "ref", :ref)) and
74
      present?(entry(ref, "digest", :digest))
75
  end
76
77
  defp ref?(_ref), do: false
78
79
  defp normalize(ref) do
80
    %{
81
      "kind" => kind(ref),
82
      "ref" => String.trim(entry(ref, "ref", :ref)),
83
      "digest" => String.trim(entry(ref, "digest", :digest))
84
    }
85
  end
86
87
  defp kind(ref), do: entry(ref, "kind", :kind)
88
89
  # A caller writes `%{"kind" => …}` over the API and `%{kind: …}` in Elixir,
90
  # and both mean the same evidence ref.
91
  defp entry(ref, string_key, atom_key) do
92
    case Map.get(ref, string_key, Map.get(ref, atom_key)) do
93
      value when is_binary(value) -> value
94
      _absent -> nil
95
    end
96
  end
97
98
  defp present?(value) when is_binary(value), do: String.trim(value) != ""
99
  defp present?(_value), do: false
100
end
lib/openagents/memories/memory.ex modified +3 -68

@@ -46,6 +46,7 @@ defmodule OpenAgents.Memories.Memory do

46 46
  import Ecto.Changeset
47 47
48 48
  alias OpenAgents.Accounts.User
49
  alias OpenAgents.Memories.Evidence
49 50
50 51
  @primary_key {:id, :binary_id, autogenerate: true}
51 52
  @foreign_key_type :binary_id

@@ -60,8 +61,6 @@ defmodule OpenAgents.Memories.Memory do

60 61
  @slug_prefix "sys:"
61 62
  @tiers ~w(ledger glass)
62 63
  @admissions ~w(candidate admitted rejected)
63
  @evidence_kinds ~w(receipt memory url)
64
  @evidence_refs 20
65 64
66 65
  schema "memories" do
67 66
    belongs_to :user, User

@@ -113,7 +112,7 @@ defmodule OpenAgents.Memories.Memory do

113 112
114 113
  @doc "The kinds of evidence a system memory may cite."
115 114
  @spec evidence_kinds() :: [String.t()]
116
  def evidence_kinds, do: @evidence_kinds
115
  def evidence_kinds, do: Evidence.kinds()
117 116
118 117
  @doc "The prefix every system slug carries."
119 118
  @spec slug_prefix() :: String.t()

@@ -182,7 +181,7 @@ defmodule OpenAgents.Memories.Memory do

182 181
    |> validate_length(:entity, min: 1, max: @slug_characters, count: :graphemes)
183 182
    |> validate_inclusion(:tier, @tiers)
184 183
    |> validate_inclusion(:admission, @admissions)
185
    |> validate_evidence_refs()
184
    |> Evidence.validate(:evidence_refs)
186 185
  end
187 186
188 187
  defp refuse_system_fields(changeset) do

@@ -196,70 +195,6 @@ defmodule OpenAgents.Memories.Memory do

196 195
    end)
197 196
  end
198 197
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
    }
247
  end
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
263 198
  @doc "Points a memory at the memory that replaced it."
264 199
  @spec supersede_changeset(t(), t()) :: Ecto.Changeset.t()
265 200
  def supersede_changeset(memory, replacement) do
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -310,7 +310,8 @@

310 310
    20260825170000,
311 311
    20260825170100,
312 312
    20260825195623,
313
    20260825220000
313
    20260825220000,
314
    20260825230000
314 315
  ],
315 316
  "required_tables": [
316 317
    "users",
priv/repo/migrations/20260825230000_create_memory_challenges.exs added +170

@@ -0,0 +1,170 @@

1
defmodule OpenAgents.Repo.Migrations.CreateMemoryChallenges do
2
  use Ecto.Migration
3
4
  # Disagreement, as a record rather than an edit.
5
  #
6
  # A reader who finds an admitted system claim wrong needs a path other than
7
  # editing somebody else's row. That path is a **challenge**: an attributed,
8
  # dated row stating the ground, which suspends its target from recall when it
9
  # carries evidence of its own. A steward resolves it with a **refutation**,
10
  # which is another record. A reversal is a further record too; nothing here
11
  # is ever updated.
12
  #
13
  # This extends the table `20260825220000_create_system_memories.exs` created
14
  # rather than laying a second one beside it. That table already named all
15
  # three roles in its enum and wrote only `admission`; the columns below are
16
  # what the other two roles need.
17
  #
18
  # Three decisions are database predicates rather than changeset validations
19
  # (MEMORY-004):
20
  #
21
  #   * A refutation names a challenge, and a challenge on the very memory the
22
  #     refutation restores. The composite foreign key
23
  #     `(challenge_id, memory_id, challenge_role) -> (id, memory_id, role)`
24
  #     is what makes a refutation of an admission record, or of a challenge
25
  #     against some other memory, unrepresentable.
26
  #
27
  #   * Evidence belongs to a challenge and to nothing else, and a challenge's
28
  #     evidence list — when it carries one — has the same shape a system
29
  #     memory's does. An empty array is not "unevidenced": it is malformed,
30
  #     and the constraint refuses it, because reading it as absent is how an
31
  #     evidenced challenge would arrive claiming to suspend nothing.
32
  #
33
  #   * Each role's slug is the specification's: `adm:<memory>`, `chl:<memory>`,
34
  #     `ref:<challenge>`.
35
  #
36
  # Every column is asserted `IS NOT NULL` before it is compared. A check
37
  # constraint is satisfied when it evaluates to NULL, so a disjunct whose
38
  # columns are absent evaluates to NULL and carries the whole `OR` to NULL
39
  # unless some other disjunct is FALSE outright. Each branch below is guarded
40
  # by `role = '…'` first, and `role` is `NOT NULL`, so exactly one branch can
41
  # be anything but FALSE — and inside that branch nothing is compared before
42
  # it is asserted present.
43
  def up do
44
    # `steward_id` was the right name while `admission` was the only role: only
45
    # a steward admits. It is the wrong name now. Anyone may challenge, so the
46
    # column records who wrote the record, and the steward rule lives where a
47
    # refutation is written rather than in a column name that would have to lie
48
    # on two rows out of three.
49
    rename table(:memory_admissions), :steward_id, to: :author_id
50
51
    drop index(:memory_admissions, [:steward_id])
52
    create index(:memory_admissions, [:author_id])
53
54
    alter table(:memory_admissions) do
55
      # The challenge a refutation resolves. Null on every other role.
56
      add :challenge_id, :binary_id
57
58
      # Always the literal `challenge` on a refutation, and null elsewhere.
59
      # It exists so the foreign key below can insist that `challenge_id` names
60
      # a challenge; PostgreSQL will not put a literal in a foreign key, so the
61
      # literal is a column the check constraint pins.
62
      add :challenge_role, :string
63
64
      # A challenge's own evidence, and what makes it an *evidenced* challenge.
65
      # Null on an admission and on a refutation: the evidenced/unevidenced
66
      # distinction is defined for challenges only, and a column that means
67
      # nothing on the other two roles is a column nobody sets deliberately.
68
      add :evidence_refs, :jsonb
69
    end
70
71
    # What the composite foreign key points at.
72
    create unique_index(:memory_admissions, [:id, :memory_id, :role],
73
             name: :memory_admissions_id_memory_role_index
74
           )
75
76
    execute("""
77
    ALTER TABLE memory_admissions
78
    ADD CONSTRAINT memory_admissions_challenge_fkey
79
    FOREIGN KEY (challenge_id, memory_id, challenge_role)
80
    REFERENCES memory_admissions (id, memory_id, role) ON DELETE CASCADE
81
    """)
82
83
    drop constraint(:memory_admissions, :memory_admissions_shape)
84
85
    create constraint(:memory_admissions, :memory_admissions_shape,
86
             check: """
87
             memory_bucket = 'system'
88
             AND role IN ('admission','challenge','refutation')
89
             AND char_length(slug) BETWEEN 1 AND 200
90
             AND char_length(ground) BETWEEN 1 AND 2000
91
             AND (
92
               (role = 'admission'
93
                AND verdict IS NOT NULL
94
                AND verdict IN ('admitted','rejected')
95
                AND slug = 'adm:' || memory_id::text
96
                AND challenge_id IS NULL
97
                AND challenge_role IS NULL
98
                AND evidence_refs IS NULL)
99
               OR (role = 'challenge'
100
                AND verdict IS NULL
101
                AND slug = 'chl:' || memory_id::text
102
                AND challenge_id IS NULL
103
                AND challenge_role IS NULL
104
                AND (
105
                  evidence_refs IS NULL
106
                  OR (
107
                    jsonb_typeof(evidence_refs) = 'array'
108
                    AND jsonb_array_length(evidence_refs) BETWEEN 1 AND 20
109
                    AND NOT jsonb_path_exists(evidence_refs, '$[*] ? (!(@.type() == "object"
110
                          && exists(@.kind ? (@ == "receipt" || @ == "memory" || @ == "url"))
111
                          && exists(@.ref ? (@.type() == "string" && @ != ""))
112
                          && exists(@.digest ? (@.type() == "string" && @ != ""))))')
113
                  )
114
                ))
115
               OR (role = 'refutation'
116
                AND verdict IS NULL
117
                AND challenge_id IS NOT NULL
118
                AND challenge_role IS NOT NULL
119
                AND challenge_role = 'challenge'
120
                AND slug = 'ref:' || challenge_id::text
121
                AND evidence_refs IS NULL)
122
             )
123
             """
124
           )
125
126
    # Deriving a status reads every record for a memory and asks which
127
    # challenges are still open.
128
    create index(:memory_admissions, [:challenge_id])
129
    create index(:memory_admissions, [:role, :memory_id])
130
  end
131
132
  def down do
133
    drop index(:memory_admissions, [:role, :memory_id])
134
    drop index(:memory_admissions, [:challenge_id])
135
136
    drop constraint(:memory_admissions, :memory_admissions_shape)
137
138
    create constraint(:memory_admissions, :memory_admissions_shape,
139
             check: """
140
             memory_bucket = 'system'
141
             AND role IN ('admission','challenge','refutation')
142
             AND char_length(slug) BETWEEN 1 AND 200
143
             AND char_length(ground) BETWEEN 1 AND 2000
144
             AND (
145
               (role = 'admission'
146
                AND verdict IS NOT NULL
147
                AND verdict IN ('admitted','rejected')
148
                AND slug = 'adm:' || memory_id::text)
149
               OR (role <> 'admission' AND verdict IS NULL)
150
             )
151
             """
152
           )
153
154
    execute("ALTER TABLE memory_admissions DROP CONSTRAINT memory_admissions_challenge_fkey")
155
156
    drop index(:memory_admissions, [:id, :memory_id, :role],
157
           name: :memory_admissions_id_memory_role_index
158
         )
159
160
    alter table(:memory_admissions) do
161
      remove :evidence_refs
162
      remove :challenge_role
163
      remove :challenge_id
164
    end
165
166
    drop index(:memory_admissions, [:author_id])
167
    rename table(:memory_admissions), :author_id, to: :steward_id
168
    create index(:memory_admissions, [:steward_id])
169
  end
170
end
test/openagents/memories/challenge_test.exs added +938

@@ -0,0 +1,938 @@

1
defmodule OpenAgents.Memories.ChallengeTest do
2
  @moduledoc """
3
  Disagreement about a system memory: who may record it, what it does to the
4
  derived status, and what it still may not reach.
5
6
  Five properties carry the weight here.
7
8
  **A challenge is a record, not an edit.** A reader who finds an admitted
9
  claim wrong writes a row stating the ground. Nothing updates the claim, and a
10
  reversal is a further record — so these tests read the whole history back and
11
  check that every step of an argument survives it.
12
13
  **Evidence is the distinction recall turns on.** An evidenced challenge
14
  suspends its target; an unevidenced one is recorded and changes nothing. An
15
  empty evidence list is neither: the table refuses it, so these tests insert
16
  around the changeset to prove the refusal is the store's rather than the
17
  write path's.
18
19
  **Only a steward resolves.** A refutation from an ordinary account is
20
  refused, and the refusal changes nothing and reveals nothing.
21
22
  **The derivation is order-independent.** The same set of records derives the
23
  same statuses however they arrive, including a refutation dated earlier than
24
  the challenge it resolves. Every permutation is checked, not asserted.
25
26
  **And the bucket still surfaces to nobody.** MEMORY-001 and MEMORY-010 hold
27
  unchanged: a suspended memory, a challenged one, and an admitted one all
28
  reach the same number of turns, which is none.
29
  """
30
  use OpenAgents.DataCase, async: true
31
32
  alias OpenAgents.Memories
33
  alias OpenAgents.Memories.{Admission, Admissions, Memory, Recall}
34
35
  # The owner account is an operator by definition (`@owner_github_id`), so a
36
  # steward needs no configuration change and these tests stay async.
37
  @owner_github_id 14_167_547
38
39
  @ground "The receipt this cites was superseded before the claim was written."
40
41
  defp account(key) do
42
    digest = :crypto.hash(:sha256, key)
43
    github_id = digest |> binary_part(0, 7) |> :binary.decode_unsigned()
44
45
    upsert(github_id, "chl-" <> (digest |> Base.encode16(case: :lower) |> binary_part(0, 12)))
46
  end
47
48
  defp steward, do: upsert(@owner_github_id, "AtlantisPleb")
49
50
  defp upsert(github_id, login) do
51
    {:ok, user} =
52
      OpenAgents.Accounts.upsert_github_user(%{
53
        github_id: github_id,
54
        github_login: login,
55
        github_avatar_url: "https://avatars.githubusercontent.com/u/#{github_id}?v=4"
56
      })
57
58
    user
59
  end
60
61
  defp evidence(ref \\ "receipt:4f1c") do
62
    [%{"kind" => "receipt", "ref" => ref, "digest" => "sha256:9ab3"}]
63
  end
64
65
  defp candidate(overrides) do
66
    Map.merge(
67
      %{
68
        "bucket" => "system",
69
        "slug" => "sys:gateway-402-retired-model",
70
        "body" => "A 402 from the gateway means the default model was retired upstream.",
71
        "tier" => "ledger",
72
        "as_of" => ~D[2026-08-25],
73
        "admission" => "candidate",
74
        "evidence_refs" => evidence()
75
      },
76
      overrides
77
    )
78
  end
79
80
  # An admitted system memory, which is the only thing a challenge can suspend.
81
  defp admitted(author, slug) do
82
    {:ok, memory} = Memories.create(author, candidate(%{"slug" => slug}))
83
84
    {:ok, _verdict} =
85
      Admissions.record(steward(), memory.id, %{
86
        "verdict" => "admitted",
87
        "ground" => "The receipt shows the 402 and the retirement together."
88
      })
89
90
    memory
91
  end
92
93
  # A record assembled as a struct rather than through a changeset. This is the
94
  # second write path the constraints exist for, and the one that proves the
95
  # refusal belongs to the table.
96
  defp around_the_changeset(fields) do
97
    Repo.insert(struct(Admission, Map.merge(%{memory_bucket: "system"}, fields)))
98
  end
99
100
  describe "writing a challenge" do
101
    test "any account records one against an admitted claim" do
102
      author = account("write-author")
103
      reader = account("write-reader")
104
105
      memory = admitted(author, "sys:write-any-account")
106
107
      assert {:ok, challenge} =
108
               Admissions.challenge(reader, memory.id, %{
109
                 "ground" => @ground,
110
                 "evidence_refs" => evidence("receipt:0a11")
111
               })
112
113
      assert challenge.role == "challenge"
114
      assert challenge.slug == "chl:" <> memory.id
115
      assert challenge.author_id == reader.id
116
      assert challenge.memory_id == memory.id
117
      assert challenge.verdict == nil
118
      assert challenge.ground == @ground
119
      assert challenge.inserted_at != nil
120
      assert Admission.evidenced_challenge?(challenge)
121
    end
122
123
    test "the claim's own author may challenge it too" do
124
      author = account("write-self")
125
      memory = admitted(author, "sys:write-self")
126
127
      assert {:ok, challenge} =
128
               Admissions.challenge(author, memory.id, %{"ground" => @ground})
129
130
      assert challenge.author_id == author.id
131
      refute Admission.evidenced_challenge?(challenge)
132
    end
133
134
    test "a challenge needs a ground" do
135
      author = account("write-ground")
136
      memory = admitted(author, "sys:write-ground")
137
138
      assert {:error, changeset} = Admissions.challenge(author, memory.id, %{})
139
      assert %{ground: _refused} = errors_on(changeset)
140
    end
141
142
    test "an empty evidence list is refused rather than read as unevidenced" do
143
      author = account("write-empty-evidence")
144
      memory = admitted(author, "sys:write-empty-evidence")
145
146
      assert {:error, changeset} =
147
               Admissions.challenge(author, memory.id, %{
148
                 "ground" => @ground,
149
                 "evidence_refs" => []
150
               })
151
152
      assert %{evidence_refs: _refused} = errors_on(changeset)
153
    end
154
155
    test "an evidence ref with no digest is refused" do
156
      author = account("write-bad-evidence")
157
      memory = admitted(author, "sys:write-bad-evidence")
158
159
      assert {:error, changeset} =
160
               Admissions.challenge(author, memory.id, %{
161
                 "ground" => @ground,
162
                 "evidence_refs" => [%{"kind" => "url", "ref" => "https://openagents.com/"}]
163
               })
164
165
      assert %{evidence_refs: _refused} = errors_on(changeset)
166
    end
167
168
    # The composite foreign key, not a read. A memory outside the system bucket
169
    # is not challengeable, and saying so costs no query across an account.
170
    test "a challenge naming a memory outside the system bucket is refused" do
171
      author = account("write-wrong-bucket")
172
173
      {:ok, plain} = Memories.create(author, %{"body" => "I use pnpm."})
174
175
      assert {:error, :not_found} =
176
               Admissions.challenge(author, plain.id, %{"ground" => @ground})
177
178
      assert {:error, :not_found} =
179
               Admissions.challenge(author, Ecto.UUID.generate(), %{"ground" => @ground})
180
181
      assert {:error, :not_found} =
182
               Admissions.challenge(author, "not-a-uuid", %{"ground" => @ground})
183
184
      assert Repo.aggregate(Admission, :count) == 0
185
    end
186
  end
187
188
  describe "writing a refutation" do
189
    test "a steward resolves one challenge" do
190
      author = account("refute-author")
191
      reader = account("refute-reader")
192
      resolving = steward()
193
194
      memory = admitted(author, "sys:refute-steward")
195
196
      {:ok, challenge} =
197
        Admissions.challenge(reader, memory.id, %{
198
          "ground" => @ground,
199
          "evidence_refs" => evidence("receipt:0a11")
200
        })
201
202
      assert {:ok, refutation} =
203
               Admissions.refute(resolving, challenge.id, %{
204
                 "ground" => "The receipt it cites is current; the supersession named another."
205
               })
206
207
      assert refutation.role == "refutation"
208
      assert refutation.slug == "ref:" <> challenge.id
209
      assert refutation.challenge_id == challenge.id
210
      assert refutation.memory_id == memory.id
211
      assert refutation.author_id == resolving.id
212
      assert refutation.verdict == nil
213
    end
214
215
    test "an ordinary account is refused, and the refusal changes nothing" do
216
      author = account("refute-non-steward")
217
      reader = account("refute-non-steward-reader")
218
219
      memory = admitted(author, "sys:refute-non-steward")
220
221
      {:ok, challenge} =
222
        Admissions.challenge(reader, memory.id, %{
223
          "ground" => @ground,
224
          "evidence_refs" => evidence("receipt:0a11")
225
        })
226
227
      for account <- [author, reader] do
228
        assert {:error, :steward_required} =
229
                 Admissions.refute(account, challenge.id, %{"ground" => "I disagree."})
230
      end
231
232
      assert Admissions.status(memory) == "suspended"
233
      assert Enum.count(Admissions.list(memory), &(&1.role == "refutation")) == 0
234
    end
235
236
    # The role check runs before the challenge is read, so a caller with no
237
    # standing cannot tell a real challenge id from an invented one.
238
    test "an account without the role learns nothing about the challenge it named" do
239
      stranger = account("refute-stranger")
240
241
      assert {:error, :steward_required} =
242
               Admissions.refute(stranger, Ecto.UUID.generate(), %{"ground" => "No."})
243
244
      assert {:error, :steward_required} =
245
               Admissions.refute(stranger, "not-a-uuid", %{"ground" => "No."})
246
    end
247
248
    test "a refutation of something that is not a challenge is refused" do
249
      author = account("refute-not-a-challenge")
250
      resolving = steward()
251
252
      memory = admitted(author, "sys:refute-not-a-challenge")
253
      [verdict] = Admissions.list(memory)
254
255
      assert {:error, :not_found} =
256
               Admissions.refute(resolving, verdict.id, %{"ground" => "Not a challenge."})
257
258
      assert {:error, :not_found} =
259
               Admissions.refute(resolving, Ecto.UUID.generate(), %{"ground" => "Nothing."})
260
261
      assert {:error, :not_found} =
262
               Admissions.refute(resolving, "not-a-uuid", %{"ground" => "Nothing."})
263
    end
264
265
    test "a refutation needs a ground" do
266
      author = account("refute-ground")
267
      resolving = steward()
268
269
      memory = admitted(author, "sys:refute-ground")
270
271
      {:ok, challenge} =
272
        Admissions.challenge(author, memory.id, %{
273
          "ground" => @ground,
274
          "evidence_refs" => evidence("receipt:0a11")
275
        })
276
277
      assert {:error, changeset} = Admissions.refute(resolving, challenge.id, %{})
278
      assert %{ground: _refused} = errors_on(changeset)
279
    end
280
  end
281
282
  describe "derived status" do
283
    test "an evidenced challenge suspends an admitted claim" do
284
      author = account("status-suspends")
285
      reader = account("status-suspends-reader")
286
287
      memory = admitted(author, "sys:status-suspends")
288
      assert Admissions.status(memory) == "admitted"
289
290
      {:ok, _challenge} =
291
        Admissions.challenge(reader, memory.id, %{
292
          "ground" => @ground,
293
          "evidence_refs" => evidence("receipt:0a11")
294
        })
295
296
      assert Admissions.status(memory) == "suspended"
297
    end
298
299
    test "an unevidenced challenge is recorded and changes nothing" do
300
      author = account("status-unevidenced")
301
      reader = account("status-unevidenced-reader")
302
303
      memory = admitted(author, "sys:status-unevidenced")
304
305
      {:ok, challenge} = Admissions.challenge(reader, memory.id, %{"ground" => @ground})
306
307
      assert Admissions.status(memory) == "admitted"
308
      assert challenge.id in Enum.map(Admissions.list(memory), & &1.id)
309
    end
310
311
    test "a challenge against a candidate or a rejected claim changes nothing" do
312
      author = account("status-not-admitted")
313
      reader = account("status-not-admitted-reader")
314
315
      {:ok, proposed} = Memories.create(author, candidate(%{"slug" => "sys:status-candidate"}))
316
317
      {:ok, refused} = Memories.create(author, candidate(%{"slug" => "sys:status-rejected"}))
318
319
      {:ok, _verdict} =
320
        Admissions.record(steward(), refused.id, %{
321
          "verdict" => "rejected",
322
          "ground" => "The digest does not match the receipt it names."
323
        })
324
325
      for memory <- [proposed, refused] do
326
        {:ok, _challenge} =
327
          Admissions.challenge(reader, memory.id, %{
328
            "ground" => @ground,
329
            "evidence_refs" => evidence("receipt:0a11")
330
          })
331
      end
332
333
      assert Admissions.status(proposed) == "candidate"
334
      assert Admissions.status(refused) == "rejected"
335
    end
336
337
    test "a refutation restores the claim" do
338
      author = account("status-refuted")
339
      reader = account("status-refuted-reader")
340
      resolving = steward()
341
342
      memory = admitted(author, "sys:status-refuted")
343
344
      {:ok, challenge} =
345
        Admissions.challenge(reader, memory.id, %{
346
          "ground" => @ground,
347
          "evidence_refs" => evidence("receipt:0a11")
348
        })
349
350
      assert Admissions.status(memory) == "suspended"
351
352
      {:ok, _refutation} =
353
        Admissions.refute(resolving, challenge.id, %{"ground" => "The receipt is current."})
354
355
      assert Admissions.status(memory) == "admitted"
356
    end
357
358
    test "a refutation resolves the one challenge it names and no other" do
359
      author = account("status-two-challenges")
360
      first = account("status-two-challenges-first")
361
      second = account("status-two-challenges-second")
362
      resolving = steward()
363
364
      memory = admitted(author, "sys:status-two-challenges")
365
366
      {:ok, one} =
367
        Admissions.challenge(first, memory.id, %{
368
          "ground" => @ground,
369
          "evidence_refs" => evidence("receipt:0a11")
370
        })
371
372
      {:ok, two} =
373
        Admissions.challenge(second, memory.id, %{
374
          "ground" => "The claim reverses cause and effect.",
375
          "evidence_refs" => evidence("receipt:0b22")
376
        })
377
378
      {:ok, _refutation} =
379
        Admissions.refute(resolving, one.id, %{"ground" => "The first ground does not stand."})
380
381
      assert Admissions.status(memory) == "suspended"
382
383
      {:ok, _refutation} =
384
        Admissions.refute(resolving, two.id, %{"ground" => "Nor does the second."})
385
386
      assert Admissions.status(memory) == "admitted"
387
    end
388
389
    test "a second refutation of the same challenge changes nothing" do
390
      author = account("status-double-refutation")
391
      resolving = steward()
392
393
      memory = admitted(author, "sys:status-double-refutation")
394
395
      {:ok, challenge} =
396
        Admissions.challenge(author, memory.id, %{
397
          "ground" => @ground,
398
          "evidence_refs" => evidence("receipt:0a11")
399
        })
400
401
      {:ok, _first} = Admissions.refute(resolving, challenge.id, %{"ground" => "It stands."})
402
      assert Admissions.status(memory) == "admitted"
403
404
      {:ok, _second} = Admissions.refute(resolving, challenge.id, %{"ground" => "Still stands."})
405
      assert Admissions.status(memory) == "admitted"
406
    end
407
408
    # A reversal is a further record, never an edit of one that exists.
409
    test "a new challenge after a refutation suspends the claim again" do
410
      author = account("status-reversal")
411
      reader = account("status-reversal-reader")
412
      resolving = steward()
413
414
      memory = admitted(author, "sys:status-reversal")
415
416
      {:ok, first} =
417
        Admissions.challenge(reader, memory.id, %{
418
          "ground" => @ground,
419
          "evidence_refs" => evidence("receipt:0a11")
420
        })
421
422
      {:ok, _refutation} =
423
        Admissions.refute(resolving, first.id, %{"ground" => "The receipt is current."})
424
425
      assert Admissions.status(memory) == "admitted"
426
427
      {:ok, _second} =
428
        Admissions.challenge(reader, memory.id, %{
429
          "ground" => "The receipt is current and still does not say this.",
430
          "evidence_refs" => evidence("receipt:0c33")
431
        })
432
433
      assert Admissions.status(memory) == "suspended"
434
435
      # And the argument reads back whole: nothing was overwritten.
436
      assert Enum.map(Admissions.list(memory), & &1.role) ==
437
               ["admission", "challenge", "refutation", "challenge"]
438
    end
439
440
    test "re-admitting a suspended claim does not resolve the challenge" do
441
      author = account("status-readmit")
442
      reader = account("status-readmit-reader")
443
      admitting = steward()
444
445
      memory = admitted(author, "sys:status-readmit")
446
447
      {:ok, _challenge} =
448
        Admissions.challenge(reader, memory.id, %{
449
          "ground" => @ground,
450
          "evidence_refs" => evidence("receipt:0a11")
451
        })
452
453
      {:ok, _verdict} =
454
        Admissions.record(admitting, memory.id, %{
455
          "verdict" => "admitted",
456
          "ground" => "Re-read the receipt and it still holds."
457
        })
458
459
      assert Admissions.status(memory) == "suspended"
460
    end
461
462
    test "a memory outside the system bucket has no status, and an unknown id none either" do
463
      author = account("status-other-bucket")
464
465
      {:ok, plain} = Memories.create(author, %{"body" => "I use pnpm."})
466
467
      assert Admissions.status(plain) == nil
468
      assert Admissions.status("not-a-uuid") == nil
469
      assert Admissions.status(Ecto.UUID.generate()) == "candidate"
470
    end
471
  end
472
473
  describe "supersession as the other resolution path" do
474
    test "a steward's correction resolves every open challenge on the target" do
475
      author = account("supersede-resolves")
476
      reader = account("supersede-resolves-reader")
477
      correcting = steward()
478
479
      memory = admitted(author, "sys:supersede-resolves")
480
481
      {:ok, challenge} =
482
        Admissions.challenge(reader, memory.id, %{
483
          "ground" => @ground,
484
          "evidence_refs" => evidence("receipt:0a11")
485
        })
486
487
      assert Admissions.status(memory) == "suspended"
488
489
      assert {:ok, replacement} =
490
               Admissions.supersede(
491
                 correcting,
492
                 memory.id,
493
                 candidate(%{
494
                   "slug" => "sys:supersede-resolves",
495
                   "body" => "The gateway 402s when the default model is retired."
496
                 })
497
               )
498
499
      assert Repo.get!(Memory, memory.id).superseded_by_id == replacement.id
500
      assert Admissions.status(memory) == "admitted"
501
502
      # The resolution is a receipt, attributed and dated, not something a
503
      # reader has to infer from the pointer.
504
      assert [resolution] = Enum.filter(Admissions.list(memory), &(&1.role == "refutation"))
505
      assert resolution.challenge_id == challenge.id
506
      assert resolution.author_id == correcting.id
507
    end
508
509
    test "an author's correction resolves nothing" do
510
      author = account("supersede-author-resolves")
511
      reader = account("supersede-author-resolves-reader")
512
513
      memory = admitted(author, "sys:supersede-author-resolves")
514
515
      {:ok, _challenge} =
516
        Admissions.challenge(reader, memory.id, %{
517
          "ground" => @ground,
518
          "evidence_refs" => evidence("receipt:0a11")
519
        })
520
521
      assert {:ok, _replacement} =
522
               Admissions.supersede(
523
                 author,
524
                 memory.id,
525
                 candidate(%{"slug" => "sys:supersede-author-resolves", "body" => "Corrected."})
526
               )
527
528
      assert Admissions.status(memory) == "suspended"
529
      assert Enum.count(Admissions.list(memory), &(&1.role == "refutation")) == 0
530
    end
531
532
    test "a correction with nothing challenged writes no resolution" do
533
      author = account("supersede-no-challenges")
534
      correcting = steward()
535
536
      memory = admitted(author, "sys:supersede-no-challenges")
537
538
      assert {:ok, _replacement} =
539
               Admissions.supersede(
540
                 correcting,
541
                 memory.id,
542
                 candidate(%{"slug" => "sys:supersede-no-challenges", "body" => "Corrected."})
543
               )
544
545
      assert Enum.count(Admissions.list(memory), &(&1.role == "refutation")) == 0
546
    end
547
  end
548
549
  describe "the challenge-flood cap" do
550
    test "the cap is a quarter of the admitted store, rounded up" do
551
      assert Admissions.challenge_share() == 25
552
      assert Admissions.challenge_cap(0) == 0
553
      assert Admissions.challenge_cap(1) == 1
554
      assert Admissions.challenge_cap(4) == 1
555
      assert Admissions.challenge_cap(5) == 2
556
      assert Admissions.challenge_cap(8) == 2
557
      assert Admissions.challenge_cap(100) == 25
558
    end
559
560
    test "one account suspends at most its share, and the rest queue" do
561
      author = account("cap-author")
562
      prolific = account("cap-prolific")
563
564
      memories =
565
        for index <- 1..8, do: admitted(author, "sys:cap-#{index}")
566
567
      # Written latest-first, so the challenges that take effect are chosen by
568
      # the records' own dates rather than by the order they arrived in.
569
      memories
570
      |> Enum.take(4)
571
      |> Enum.with_index()
572
      |> Enum.reverse()
573
      |> Enum.each(fn {memory, index} ->
574
        challenge(prolific, memory, ~U[2026-08-25 12:00:00.000000Z] |> shift(index))
575
      end)
576
577
      statuses = Admissions.statuses()
578
      suspended = for {id, "suspended"} <- statuses, do: id
579
580
      assert Enum.sort(suspended) ==
581
               memories |> Enum.take(2) |> Enum.map(& &1.id) |> Enum.sort()
582
583
      # Equal inputs, equal outcomes.
584
      assert Admissions.statuses() == statuses
585
    end
586
587
    test "the cap is per account, not across the store" do
588
      author = account("cap-per-account-author")
589
      one = account("cap-per-account-one")
590
      two = account("cap-per-account-two")
591
592
      memories = for index <- 1..8, do: admitted(author, "sys:cap-account-#{index}")
593
594
      [first, second, third, fourth | _rest] = memories
595
596
      challenge(one, first, ~U[2026-08-25 12:00:00.000000Z])
597
      challenge(one, second, ~U[2026-08-25 12:00:01.000000Z])
598
      challenge(one, third, ~U[2026-08-25 12:00:02.000000Z])
599
      challenge(two, fourth, ~U[2026-08-25 12:00:03.000000Z])
600
601
      suspended = for {id, "suspended"} <- Admissions.statuses(), into: MapSet.new(), do: id
602
603
      assert MapSet.equal?(suspended, MapSet.new([first.id, second.id, fourth.id]))
604
    end
605
606
    test "several challenges against one memory spend one slot, not several" do
607
      author = account("cap-same-memory-author")
608
      prolific = account("cap-same-memory-prolific")
609
610
      memories = for index <- 1..8, do: admitted(author, "sys:cap-same-#{index}")
611
      [first, second | _rest] = memories
612
613
      challenge(prolific, first, ~U[2026-08-25 12:00:00.000000Z], "receipt:0a11")
614
      challenge(prolific, first, ~U[2026-08-25 12:00:01.000000Z], "receipt:0b22")
615
      challenge(prolific, second, ~U[2026-08-25 12:00:02.000000Z], "receipt:0c33")
616
617
      suspended = for {id, "suspended"} <- Admissions.statuses(), into: MapSet.new(), do: id
618
619
      assert MapSet.equal?(suspended, MapSet.new([first.id, second.id]))
620
    end
621
622
    # A challenge that suspends nothing spends nothing. Otherwise the cheapest
623
    # way past the cap would be to challenge rows nobody admitted.
624
    test "a challenge against a claim that is not admitted spends no budget" do
625
      author = account("cap-unadmitted-author")
626
      prolific = account("cap-unadmitted-prolific")
627
628
      memories = for index <- 1..8, do: admitted(author, "sys:cap-unadmitted-#{index}")
629
      [first, second | _rest] = memories
630
631
      {:ok, proposed} =
632
        Memories.create(author, candidate(%{"slug" => "sys:cap-unadmitted-candidate"}))
633
634
      challenge(prolific, proposed, ~U[2026-08-25 11:00:00.000000Z])
635
      challenge(prolific, first, ~U[2026-08-25 12:00:00.000000Z])
636
      challenge(prolific, second, ~U[2026-08-25 12:00:01.000000Z])
637
638
      suspended = for {id, "suspended"} <- Admissions.statuses(), into: MapSet.new(), do: id
639
640
      assert MapSet.equal?(suspended, MapSet.new([first.id, second.id]))
641
      assert Admissions.status(proposed) == "candidate"
642
    end
643
644
    test "a queued challenge takes effect once an earlier one is refuted" do
645
      author = account("cap-queue-author")
646
      prolific = account("cap-queue-prolific")
647
      resolving = steward()
648
649
      memories = for index <- 1..4, do: admitted(author, "sys:cap-queue-#{index}")
650
      [first, second | _rest] = memories
651
652
      one = challenge(prolific, first, ~U[2026-08-25 12:00:00.000000Z])
653
      _two = challenge(prolific, second, ~U[2026-08-25 12:00:01.000000Z])
654
655
      # Four admitted claims, so the cap is one.
656
      assert Admissions.status(first) == "suspended"
657
      assert Admissions.status(second) == "admitted"
658
659
      {:ok, _refutation} =
660
        Admissions.refute(resolving, one.id, %{"ground" => "The first ground does not stand."})
661
662
      assert Admissions.status(first) == "admitted"
663
      assert Admissions.status(second) == "suspended"
664
    end
665
  end
666
667
  # The point of the whole exercise. A backfill, an import, and a correction all
668
  # break the convenient case where records arrive in the order they happened,
669
  # so the derivation must not depend on it.
670
  describe "order independence" do
671
    test "every permutation of one record set derives the same statuses" do
672
      author = account("order-author")
673
      reader = account("order-reader")
674
      resolving = steward()
675
676
      {:ok, first} = Memories.create(author, candidate(%{"slug" => "sys:order-first"}))
677
      {:ok, second} = Memories.create(author, candidate(%{"slug" => "sys:order-second"}))
678
679
      # Timestamps deliberately at odds with any insertion order: the second
680
      # verdict on `first` is dated before nothing in particular, and the
681
      # refutation is dated *earlier* than the challenge it resolves, which is
682
      # the out-of-order case a backfill actually produces.
683
      against_first = challenge_record(reader, first, ~U[2026-08-25 11:00:00.000000Z], evidence())
684
685
      records = [
686
        verdict_record(resolving, first, "rejected", ~U[2026-08-25 10:00:00.000000Z]),
687
        verdict_record(resolving, first, "admitted", ~U[2026-08-25 10:00:05.000000Z]),
688
        verdict_record(resolving, second, "admitted", ~U[2026-08-25 10:00:02.000000Z]),
689
        against_first,
690
        challenge_record(
691
          reader,
692
          second,
693
          ~U[2026-08-25 11:00:01.000000Z],
694
          evidence("receipt:0b22")
695
        ),
696
        refutation_record(resolving, first, against_first, ~U[2026-08-25 09:00:00.000000Z])
697
      ]
698
699
      expected = %{first.id => "admitted", second.id => "suspended"}
700
701
      derived =
702
        for permutation <- permutations(records) do
703
          Repo.delete_all(Admission)
704
          insert_records(permutation)
705
          Admissions.statuses()
706
        end
707
708
      assert length(derived) == 720
709
      assert Enum.uniq(derived) == [expected]
710
    end
711
  end
712
713
  describe "the constraints hold around the changeset" do
714
    setup do
715
      author = account("constraint-author")
716
      %{author: author, memory: admitted(author, "sys:constraints")}
717
    end
718
719
    test "a challenge with an empty evidence list is refused by the database", context do
720
      assert_raise Ecto.ConstraintError, ~r/memory_admissions_shape/, fn ->
721
        around_the_changeset(%{
722
          memory_id: context.memory.id,
723
          author_id: context.author.id,
724
          role: "challenge",
725
          slug: "chl:" <> context.memory.id,
726
          ground: @ground,
727
          evidence_refs: []
728
        })
729
      end
730
    end
731
732
    test "a challenge whose evidence names no digest is refused", context do
733
      assert_raise Ecto.ConstraintError, ~r/memory_admissions_shape/, fn ->
734
        around_the_changeset(%{
735
          memory_id: context.memory.id,
736
          author_id: context.author.id,
737
          role: "challenge",
738
          slug: "chl:" <> context.memory.id,
739
          ground: @ground,
740
          evidence_refs: [%{"kind" => "url", "ref" => "https://openagents.com/"}]
741
        })
742
      end
743
    end
744
745
    test "a challenge under the wrong slug is refused", context do
746
      assert_raise Ecto.ConstraintError, ~r/memory_admissions_shape/, fn ->
747
        around_the_changeset(%{
748
          memory_id: context.memory.id,
749
          author_id: context.author.id,
750
          role: "challenge",
751
          slug: "chl:not-this-memory",
752
          ground: @ground
753
        })
754
      end
755
    end
756
757
    test "an admission carrying evidence is refused", context do
758
      assert_raise Ecto.ConstraintError, ~r/memory_admissions_shape/, fn ->
759
        around_the_changeset(%{
760
          memory_id: context.memory.id,
761
          author_id: context.author.id,
762
          role: "admission",
763
          verdict: "admitted",
764
          slug: "adm:" <> context.memory.id,
765
          ground: @ground,
766
          evidence_refs: evidence()
767
        })
768
      end
769
    end
770
771
    # `challenge_role` is what lets the foreign key insist that `challenge_id`
772
    # names a challenge. A null there would leave the foreign key unchecked, so
773
    # the shape constraint is what refuses it.
774
    test "a refutation with no challenge_role is refused", context do
775
      challenge = challenge(context.author, context.memory, ~U[2026-08-25 12:00:00.000000Z])
776
777
      assert_raise Ecto.ConstraintError, ~r/memory_admissions_shape/, fn ->
778
        around_the_changeset(%{
779
          memory_id: context.memory.id,
780
          author_id: context.author.id,
781
          role: "refutation",
782
          challenge_id: challenge.id,
783
          slug: "ref:" <> challenge.id,
784
          ground: "Resolved."
785
        })
786
      end
787
    end
788
789
    test "a refutation naming an admission record is refused", context do
790
      [verdict] = Enum.filter(Admissions.list(context.memory), &(&1.role == "admission"))
791
792
      assert_raise Ecto.ConstraintError, ~r/memory_admissions_challenge_fkey/, fn ->
793
        around_the_changeset(%{
794
          memory_id: context.memory.id,
795
          author_id: context.author.id,
796
          role: "refutation",
797
          challenge_id: verdict.id,
798
          challenge_role: "challenge",
799
          slug: "ref:" <> verdict.id,
800
          ground: "Resolved."
801
        })
802
      end
803
    end
804
805
    test "a refutation naming a challenge against another memory is refused", context do
806
      other = admitted(context.author, "sys:constraints-other")
807
      challenge = challenge(context.author, other, ~U[2026-08-25 12:00:00.000000Z])
808
809
      assert_raise Ecto.ConstraintError, ~r/memory_admissions_challenge_fkey/, fn ->
810
        around_the_changeset(%{
811
          memory_id: context.memory.id,
812
          author_id: context.author.id,
813
          role: "refutation",
814
          challenge_id: challenge.id,
815
          challenge_role: "challenge",
816
          slug: "ref:" <> challenge.id,
817
          ground: "Resolved."
818
        })
819
      end
820
    end
821
  end
822
823
  # MEMORY-001 and MEMORY-010, unchanged. Challenge and refutation are about
824
  # what a status derives to, not about what any session sees, and every one of
825
  # these states reaches the same number of turns: none.
826
  describe "the recall boundary" do
827
    test "a suspended, a challenged, and an admitted memory all surface to nobody" do
828
      author = account("recall-author")
829
      reader = account("recall-reader")
830
831
      suspended = admitted(author, "sys:recall-suspended")
832
      plain = admitted(author, "sys:recall-admitted")
833
834
      {:ok, _challenge} =
835
        Admissions.challenge(reader, suspended.id, %{
836
          "ground" => @ground,
837
          "evidence_refs" => evidence("receipt:0a11")
838
        })
839
840
      assert Admissions.status(suspended) == "suspended"
841
      assert Admissions.status(plain) == "admitted"
842
843
      for account <- [author, reader] do
844
        assert %Recall{memories: []} =
845
                 Memories.recall(account, "the inference gateway returned 402")
846
      end
847
    end
848
849
    test "and a challenge writes nothing into the buckets recall does read" do
850
      author = account("recall-buckets-author")
851
      reader = account("recall-buckets-reader")
852
853
      memory = admitted(author, "sys:recall-buckets")
854
855
      {:ok, _challenge} =
856
        Admissions.challenge(reader, memory.id, %{
857
          "ground" => @ground,
858
          "evidence_refs" => evidence("receipt:0a11")
859
        })
860
861
      {:ok, asked} = Memories.create(reader, %{"body" => "I use pnpm, not npm."})
862
863
      %Recall{memories: recalled} = Memories.recall(reader, "install the deps")
864
865
      assert Enum.map(recalled, & &1.id) == [asked.id]
866
      assert Memories.list(reader, bucket: "system") == []
867
    end
868
  end
869
870
  # ── fixtures ───────────────────────────────────────────────────────────────
871
872
  defp shift(%DateTime{} = at, seconds), do: DateTime.add(at, seconds, :second)
873
874
  # A challenge dated deliberately, which is what the cap ranks by.
875
  defp challenge(user, memory, at, ref \\ "receipt:0a11") do
876
    Repo.insert!(challenge_record(user, memory, at, evidence(ref)))
877
  end
878
879
  defp challenge_record(user, memory, at, refs) do
880
    %Admission{
881
      id: Ecto.UUID.generate(),
882
      memory_id: memory.id,
883
      memory_bucket: "system",
884
      author_id: user.id,
885
      role: "challenge",
886
      slug: "chl:" <> memory.id,
887
      ground: @ground,
888
      evidence_refs: refs,
889
      inserted_at: at
890
    }
891
  end
892
893
  defp verdict_record(user, memory, verdict, at) do
894
    %Admission{
895
      id: Ecto.UUID.generate(),
896
      memory_id: memory.id,
897
      memory_bucket: "system",
898
      author_id: user.id,
899
      role: "admission",
900
      verdict: verdict,
901
      slug: "adm:" <> memory.id,
902
      ground: "Read the receipt.",
903
      inserted_at: at
904
    }
905
  end
906
907
  defp refutation_record(user, memory, challenge, at) do
908
    %Admission{
909
      id: Ecto.UUID.generate(),
910
      memory_id: memory.id,
911
      memory_bucket: "system",
912
      author_id: user.id,
913
      role: "refutation",
914
      challenge_id: challenge.id,
915
      challenge_role: "challenge",
916
      slug: "ref:" <> challenge.id,
917
      ground: "The ground does not stand.",
918
      inserted_at: at
919
    }
920
  end
921
922
  # The foreign key forbids a refutation reaching the table before the
923
  # challenge it names, so a permutation is applied within each group rather
924
  # than across them. That is not a weakening of the property: what the
925
  # derivation must not read is the records' arrival order, and the refutation
926
  # here is *dated* two hours before its challenge, which is the disagreement
927
  # between arrival and date that a backfill actually produces.
928
  defp insert_records(records) do
929
    {independent, dependent} = Enum.split_with(records, &(&1.role != "refutation"))
930
    Enum.each(independent ++ dependent, &Repo.insert!/1)
931
  end
932
933
  defp permutations([]), do: [[]]
934
935
  defp permutations(list) do
936
    for element <- list, rest <- permutations(list -- [element]), do: [element | rest]
937
  end
938
end
test/openagents/memories/system_memory_test.exs modified +1 -1

@@ -289,7 +289,7 @@ defmodule OpenAgents.Memories.SystemMemoryTest do

289 289
290 290
      assert record.slug == "adm:" <> memory.id
291 291
      assert record.role == "admission"
292
      assert record.steward_id == admitting.id
292
      assert record.author_id == admitting.id
293 293
      assert Admissions.status(memory) == "admitted"
294 294
    end
295 295
test/openagents_web/operator_surface_test.exs modified +1 -1

@@ -84,7 +84,7 @@ defmodule OpenAgentsWeb.OperatorSurfaceTest do

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 86
    OpenAgents.Memories.Admissions =>
87
      "gates admitting a system memory and correcting an admitted one",
87
      "gates admitting a system memory, refuting a challenge against one, and correcting it",
88 88
    OpenAgents.SCV.CodexAccounts => "gates connecting and disconnecting a Codex account",
89 89
    OpenAgents.SCV.Deployments => "gates starting an SCV deployment",
90 90
    OpenAgents.StagingCleanup => "refuses to delete an operator account",

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