Refuse a memory claim consent cannot authorize

82e7ebb30f7f · AtlantisPleb · · parent eb8ea8fe5161

Refuse a memory claim consent cannot authorize

MEMORY-005 says model arguments cannot substitute for consent. The
tool said otherwise: every Consent failure was swallowed and the write
proceeded as a conversation_context entry, so the model's own
arguments became the write authority — a claim nobody confirmed, and a
claim contradicting what was confirmed, both stored and reported as
success.

The fallback is gone. A consent failure is now the tool's answer:
memory_consent_required, nothing written. The two tests that asserted
the old behavior assert refusal instead, and the case the audit found
missing — a fabricated claim no consent record authorizes — is now
covered, along with a multi-claim call where one claim is
unauthorized.

Found by the invariant proof audit (#229,
docs/2026-08-24-invariant-proof-audit.md), which ranked it second and
noted the sentence and the code could not both stand.

Built by a Devin child through the openagents coder's delegate tool;
213 memory and tool tests re-run before landing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
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 337 · 2026-08-25T05:20:49.301682Z

Changed files

  • modified lib/openagents/tools/memory_remember.ex
  • modified test/openagents/tools/profile_memory_tools_test.exs

Diff

2 files changed, +81 -65

lib/openagents/tools/memory_remember.ex modified +34 -32

@@ -68,36 +68,41 @@ defmodule OpenAgents.Tools.MemoryRemember do

68 68
  defp store(owner, message, context_consent, %{"category" => category, "claim" => claim})
69 69
       when is_binary(category) and is_binary(claim) do
70 70
    category = MemoryContract.normalize_category(category)
71
    consent = consent_evidence(message, claim, context_consent)
72
73
    case ProfileMemory.remember_explicit(owner, %{
74
           category: category,
75
           claim: claim,
76
           creator: "user_explicit",
77
           provenance: %{
78
             "consent_kind" => consent.kind,
79
             "operation" => "remember",
80
             "source_ref" => "message:#{message.id}"
81
           },
82
           sources: [
83
             %{
84
               source_ref: "message:#{message.id}",
85
               kind:
86
                 if(consent.kind in ["current_message", "conversation_context"],
87
                   do: "owner_statement",
88
                   else: "owner_confirmation"
89
                 )
90
             }
91
           ]
92
         }) do
93
      {:ok, remembered} ->
94
        {:ok, Map.put(remembered, :category, category)}
95 71
96
      {:error, reason} when is_atom(reason) ->
72
    case consent_evidence(message, claim, context_consent) do
73
      {:ok, consent} ->
74
        case ProfileMemory.remember_explicit(owner, %{
75
               category: category,
76
               claim: claim,
77
               creator: "user_explicit",
78
               provenance: %{
79
                 "consent_kind" => consent.kind,
80
                 "operation" => "remember",
81
                 "source_ref" => "message:#{message.id}"
82
               },
83
               sources: [
84
                 %{
85
                   source_ref: "message:#{message.id}",
86
                   kind:
87
                     if(consent.kind == "current_message",
88
                       do: "owner_statement",
89
                       else: "owner_confirmation"
90
                     )
91
                 }
92
               ]
93
             }) do
94
          {:ok, remembered} ->
95
            {:ok, Map.put(remembered, :category, category)}
96
97
          {:error, reason} when is_atom(reason) ->
98
            {:error, category, reason}
99
100
          {:error, _bounded_reason} ->
101
            {:error, category, :memory_policy_refused}
102
        end
103
104
      {:error, reason} ->
97 105
        {:error, category, reason}
98
99
      {:error, _bounded_reason} ->
100
        {:error, category, :memory_policy_refused}
101 106
    end
102 107
  end
103 108

@@ -128,10 +133,7 @@ defmodule OpenAgents.Tools.MemoryRemember do

128 133
  end
129 134
130 135
  defp consent_evidence(message, claim, context_consent) do
131
    case Consent.remember(message.content, claim, context_consent) do
132
      {:ok, consent} -> consent
133
      {:error, _no_explicit_request} -> %{kind: "conversation_context"}
134
    end
136
    Consent.remember(message.content, claim, context_consent)
135 137
  end
136 138
137 139
  defp input_schema do
test/openagents/tools/profile_memory_tools_test.exs modified +47 -33

@@ -141,7 +141,7 @@ defmodule OpenAgents.Tools.ProfileMemoryToolsTest do

141 141
    assert {:ok, []} = ProfileMemory.list_current(scope.owner)
142 142
  end
143 143
144
  test "remember is durable and idempotent for explicit and plain statements", %{
144
  test "remember is durable and idempotent for explicit statements", %{
145 145
    snapshot: snapshot
146 146
  } do
147 147
    scope = browser("memory-explicit")

@@ -164,7 +164,7 @@ defmodule OpenAgents.Tools.ProfileMemoryToolsTest do

164 164
    assert {:ok, repeated} = Runner.run(snapshot, %{request | call_id: "call-repeat"}, context)
165 165
    assert repeated["result"]["receipt"]["disposition"] == "already_active"
166 166
167
    plain = user_message(scope.conversation, "I like detailed answers.")
167
    plain = user_message(scope.conversation, "Remember that I like detailed answers.")
168 168
169 169
    assert {:ok, automatic} =
170 170
             Runner.run(

@@ -182,13 +182,13 @@ defmodule OpenAgents.Tools.ProfileMemoryToolsTest do

182 182
    assert {:ok, [_first, _second]} = ProfileMemory.list_current(scope.owner)
183 183
  end
184 184
185
  test "a single call stores several facts across categories with one receipt", %{
185
  test "a single call with multiple unauthorized claims is refused", %{
186 186
    snapshot: snapshot
187 187
  } do
188 188
    scope = browser("memory-batch")
189 189
    message = user_message(scope.conversation, "I'm Chris, I run OpenAgents, and I like Elixir.")
190 190
191
    assert {:ok, batch} =
191
    assert {:ok, refused} =
192 192
             Runner.run(
193 193
               snapshot,
194 194
               call("memory_remember", %{

@@ -201,49 +201,43 @@ defmodule OpenAgents.Tools.ProfileMemoryToolsTest do

201 201
               context(scope, message)
202 202
             )
203 203
204
    assert batch["status"] == "succeeded"
205
    assert batch["result"]["receipt"]["disposition"] == "stored"
206
    assert length(batch["result"]["receipt"]["record_refs"]) == 3
207
208
    assert Enum.map(batch["result"]["results"], & &1["disposition"]) ==
209
             ["stored", "stored", "stored"]
210
211
    assert {:ok, records} = ProfileMemory.list_current(scope.owner)
212
    assert Enum.sort(Enum.map(records, & &1.category)) == ["name", "preference", "role"]
204
    assert refused["status"] == "refused"
205
    assert refused["error"]["code"] == "memory_consent_required"
206
    assert {:ok, []} = ProfileMemory.list_current(scope.owner)
213 207
  end
214 208
215
  test "an unrecognized category is coerced to other instead of refusing the batch", %{
209
  test "an unrecognized category is coerced to other when the claim is explicitly authorized", %{
216 210
    snapshot: snapshot
217 211
  } do
218 212
    scope = browser("memory-coerced-category")
219
    message = user_message(scope.conversation, "I use a split keyboard and live in Austin.")
213
    message = user_message(scope.conversation, "Remember that I use a split keyboard.")
220 214
221 215
    assert {:ok, stored} =
222 216
             Runner.run(
223 217
               snapshot,
224 218
               call("memory_remember", %{
225 219
                 "memories" => [
226
                   %{"category" => "Equipment", "claim" => "Uses a split keyboard"},
227
                   %{"category" => "location", "claim" => "Lives in Austin"}
220
                   %{"category" => "Equipment", "claim" => "I use a split keyboard"}
228 221
                 ]
229 222
               }),
230 223
               context(scope, message)
231 224
             )
232 225
233 226
    assert stored["status"] == "succeeded"
234
    assert Enum.map(stored["result"]["results"], & &1["disposition"]) == ["stored", "stored"]
235
    assert Enum.map(stored["result"]["results"], & &1["category"]) == ["other", "other"]
236
    assert {:ok, records} = ProfileMemory.list_current(scope.owner)
237
    assert Enum.all?(records, &(&1.category == "other"))
227
    assert [entry] = stored["result"]["results"]
228
    assert entry["disposition"] == "stored"
229
    assert entry["category"] == "other"
230
    assert {:ok, [record]} = ProfileMemory.list_current(scope.owner)
231
    assert record.category == "other"
238 232
  end
239 233
240
  test "automatic storage keeps the current message as its owner-scoped source", %{
234
  test "a paraphrased claim without exact current or host consent is refused", %{
241 235
    snapshot: snapshot
242 236
  } do
243 237
    scope = browser("memory-automatic")
244 238
    statement = user_message(scope.conversation, "I'm mostly working on the One repo lately.")
245 239
246
    assert {:ok, stored} =
240
    assert {:ok, refused} =
247 241
             Runner.run(
248 242
               snapshot,
249 243
               call("memory_remember", %{

@@ -254,14 +248,12 @@ defmodule OpenAgents.Tools.ProfileMemoryToolsTest do

254 248
               context(scope, statement)
255 249
             )
256 250
257
    assert stored["status"] == "succeeded"
258
    assert [entry] = stored["result"]["results"]
259
    assert entry["memory"]["source_refs"] == ["message:#{statement.id}"]
260
    assert {:ok, [record]} = ProfileMemory.list_current(scope.owner)
261
    assert record.provenance["consent_kind"] == "conversation_context"
251
    assert refused["status"] == "refused"
252
    assert refused["error"]["code"] == "memory_consent_required"
253
    assert {:ok, []} = ProfileMemory.list_current(scope.owner)
262 254
  end
263 255
264
  test "a host-recorded confirmation covers its candidate and other claims store automatically",
256
  test "a host-recorded confirmation covers its candidate and refuses a mismatch",
265 257
       %{
266 258
         snapshot: snapshot
267 259
       } do

@@ -289,7 +281,7 @@ defmodule OpenAgents.Tools.ProfileMemoryToolsTest do

289 281
      )
290 282
    )
291 283
292
    assert {:ok, fallback} =
284
    assert {:ok, refused} =
293 285
             Runner.run(
294 286
               snapshot,
295 287
               call("memory_remember", %{

@@ -300,10 +292,32 @@ defmodule OpenAgents.Tools.ProfileMemoryToolsTest do

300 292
               confirmed_context
301 293
             )
302 294
303
    assert fallback["status"] == "succeeded"
295
    assert refused["status"] == "refused"
296
    assert refused["error"]["code"] == "memory_consent_mismatch"
304 297
    assert {:ok, records} = ProfileMemory.list_current(scope.owner)
305
    record = Enum.find(records, &(&1.claim == "I prefer detailed answers"))
306
    assert record.provenance["consent_kind"] == "conversation_context"
298
    refute Enum.any?(records, &(&1.claim == "I prefer detailed answers"))
299
  end
300
301
  test "a fabricated claim with no consent record authorizing it is refused", %{
302
    snapshot: snapshot
303
  } do
304
    scope = browser("memory-fabricated")
305
    message = user_message(scope.conversation, "Discussing project plans.")
306
307
    assert {:ok, refused} =
308
             Runner.run(
309
               snapshot,
310
               call("memory_remember", %{
311
                 "memories" => [
312
                   %{"category" => "preference", "claim" => "I prefer detailed answers"}
313
                 ]
314
               }),
315
               context(scope, message)
316
             )
317
318
    assert refused["status"] == "refused"
319
    assert refused["error"]["code"] == "memory_consent_required"
320
    assert {:ok, []} = ProfileMemory.list_current(scope.owner)
307 321
  end
308 322
309 323
  test "correct supersedes a conflicting record with the new claim", %{snapshot: snapshot} do

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