Show the memory audit trail, and let an operator retract from it

6d3bd90aa42e · AtlantisPleb · · parent bcf589630a71

Show the memory audit trail, and let an operator retract from it

The /memory surface listed what memory holds now and nothing about how
it got there. It renders the supersession trail per entry — the prior
claims, when each was superseded, and the consent kind that authorized
it — so a correction is visible as a correction rather than as a value
that quietly changed.

Retraction is a control on the same surface, and it appends. It goes
through the profile memory context's existing forget path, which
supersedes rather than deletes, so a retraction becomes another entry
in the trail instead of a row that stops existing. Every write
rechecks the operator on the event and not only on mount, the way the
admin surfaces already do.

ADMIN-001 gains the write: the enumeration names the memory retraction
and what makes it append-only, and the operator-surface proof declares
MemoryLive alongside the other modules that consult operator
authority — so the new authority is counted rather than arriving
unnamed.

Built by a Devin child through the openagents coder's delegate tool;
the invariant amendment was added in review, and 543 LiveView and data
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 347 · 2026-08-25T06:23:44.523097Z

Changed files

  • modified INVARIANTS.md
  • modified lib/openagents_web/live/memory_live.ex
  • modified test/openagents_web/live/memory_live_test.exs
  • modified test/openagents_web/operator_surface_test.exs

Diff

4 files changed, +220 -23

INVARIANTS.md modified +6

@@ -2896,6 +2896,12 @@ sentence:

2896 2896
  `/api/operator/artifact-listings`.
2897 2897
- Creating, cancelling, resuming, and replaying continual-learning jobs under
2898 2898
  `/api/operator/continual-learning/jobs`.
2899
- Retracting a memory record or an engram from `/memory`
2900
  (`OpenAgentsWeb.MemoryLive`, which rechecks the operator on the event, not
2901
  only on mount). The write appends: it goes through
2902
  `OpenAgents.ProfileMemory.forget_active/2`, which supersedes rather than
2903
  deletes, so a retraction is another entry in the audit trail the same
2904
  surface renders and never a row that quietly stops existing.
2899 2905
- Recording a graded Gym run under `POST /api/v3/gym/runs`
2900 2906
  (`OpenAgentsWeb.GymRunController`, which rechecks the operator on every
2901 2907
  request over the bearer scope), and reading the scoreboard from `/gym`
lib/openagents_web/live/memory_live.ex modified +161 -22

@@ -52,6 +52,7 @@ defmodule OpenAgentsWeb.MemoryLive do

52 52
     |> assign(:recording_config, Recordings.config())
53 53
     |> assign(:privacy_delete_form, to_form(%{"confirmation" => ""}, as: :privacy))
54 54
     |> assign(:work_scope, "conversation:#{conversation.id}")
55
     |> assign(:operator?, Accounts.admin?(current_user))
55 56
     |> load_dashboard()}
56 57
  end
57 58

@@ -77,6 +78,7 @@ defmodule OpenAgentsWeb.MemoryLive do

77 78
        engram_timeline={@engram_timeline}
78 79
        derived_heuristics={@derived_heuristics}
79 80
        supersession_trail={@supersession_trail}
81
        operator?={@operator?}
80 82
      />
81 83
    </Layouts.app>
82 84
    """

@@ -191,7 +193,60 @@ defmodule OpenAgentsWeb.MemoryLive do

191 193
     |> load_dashboard()}
192 194
  end
193 195
196
  def handle_event(
197
        "retract_record",
198
        %{"record_id" => record_id, "expected_generation" => generation},
199
        socket
200
      ) do
201
    if Accounts.admin?(socket.assigns.current_user) do
202
      case Integer.parse(generation) do
203
        {expected_generation, ""} -> do_retract_record(record_id, expected_generation, socket)
204
        _ -> {:noreply, assign(socket, :memory_status, {:error, "Invalid retraction request."})}
205
      end
206
    else
207
      {:noreply, redirect(socket, to: ~p"/")}
208
    end
209
  end
210
194 211
  def handle_event("retract_engram", %{"record_ref" => ref}, socket) do
212
    if Accounts.admin?(socket.assigns.current_user) do
213
      do_retract_engram(ref, socket)
214
    else
215
      {:noreply, redirect(socket, to: ~p"/")}
216
    end
217
  end
218
219
  defp do_retract_record(record_id, expected_generation, socket) do
220
    owner = socket.assigns.memory_owner
221
222
    result =
223
      ProfileMemory.forget_active(owner, %{
224
        "mode" => "record",
225
        "record_id" => record_id,
226
        "expected_generation" => expected_generation
227
      })
228
229
    message =
230
      case result do
231
        {:ok, %{disposition: "forgotten", records: records}} ->
232
          "Retracted #{length(records)} memory record(s)."
233
234
        {:ok, %{disposition: "already_absent"}} ->
235
          "Those records were already absent."
236
237
        {:error, reason} ->
238
          "Could not retract record: #{format_reason(reason)}"
239
      end
240
241
    status = if match?({:ok, _}, result), do: :ok, else: :error
242
243
    {:noreply,
244
     socket
245
     |> assign(:memory_status, {status, message})
246
     |> load_dashboard()}
247
  end
248
249
  defp do_retract_engram(ref, socket) do
195 250
    owner = socket.assigns.memory_owner
196 251
197 252
    context_consent = %{

@@ -277,21 +332,24 @@ defmodule OpenAgentsWeb.MemoryLive do

277 332
        {:error, _reason} -> {[], []}
278 333
      end
279 334
280
    records_by_id = Map.new(profile_export["records"] || [], &{&1["id"], &1})
335
    records = profile_export["records"] || []
336
    records_by_id = Map.new(records, &{&1["id"], &1})
337
338
    superseded_ids =
339
      for r <- records,
340
          r["supersedes_record_id"],
341
          into: MapSet.new(),
342
          do: r["supersedes_record_id"]
281 343
282 344
    supersession_trail =
283
      Enum.reduce(profile_export["records"] || [], [], fn record, acc ->
284
        if record["supersedes_record_id"] do
285
          previous = Map.get(records_by_id, record["supersedes_record_id"])
286
          [%{previous: previous, replacement: record} | acc]
287
        else
288
          acc
289
        end
290
      end)
291
      |> Enum.reverse()
345
      records
346
      |> Enum.reject(&MapSet.member?(superseded_ids, &1["id"]))
347
      |> Enum.map(&build_supersession_chain(&1, records_by_id))
348
      |> Enum.filter(&(&1.chain != []))
349
      |> Enum.sort_by(& &1.current["inserted_at"], :desc)
292 350
293 351
    socket
294
    |> assign(:memory_records, profile_export["records"] || [])
352
    |> assign(:memory_records, records)
295 353
    |> assign(:memory_status, memory_status)
296 354
    |> assign(:graph_entities, graph_entities)
297 355
    |> assign(:engram_timeline, engram_timeline)

@@ -377,6 +435,7 @@ defmodule OpenAgentsWeb.MemoryLive do

377 435
  attr :engram_timeline, :list, default: []
378 436
  attr :derived_heuristics, :list, default: []
379 437
  attr :supersession_trail, :list, default: []
438
  attr :operator?, :boolean, default: false
380 439
381 440
  defp memory_manager(assigns) do
382 441
    ~H"""

@@ -497,7 +556,11 @@ defmodule OpenAgentsWeb.MemoryLive do

497 556
      </.empty>
498 557
499 558
      <div :if={@memory_records != []} id="memory-records" class="memory-records">
500
        <.memory_record :for={record <- @memory_records} record={record} />
559
        <.memory_record
560
          :for={record <- @memory_records}
561
          record={record}
562
          operator?={@operator?}
563
        />
501 564
      </div>
502 565
503 566
      <.memory_dashboard

@@ -506,6 +569,7 @@ defmodule OpenAgentsWeb.MemoryLive do

506 569
        engram_timeline={@engram_timeline}
507 570
        derived_heuristics={@derived_heuristics}
508 571
        supersession_trail={@supersession_trail}
572
        operator?={@operator?}
509 573
      />
510 574
511 575
      <.card id="leaderboard-preference" aria-labelledby="leaderboard-preference-heading">

@@ -601,6 +665,7 @@ defmodule OpenAgentsWeb.MemoryLive do

601 665
  end
602 666
603 667
  attr :record, :map, required: true
668
  attr :operator?, :boolean, default: false
604 669
605 670
  defp memory_record(assigns) do
606 671
    ~H"""

@@ -680,6 +745,18 @@ defmodule OpenAgentsWeb.MemoryLive do

680 745
          >
681 746
            <.icon name="trash" /> FORGET {String.upcase(@record["category"])} CATEGORY
682 747
          </.text_button>
748
          <.button
749
            :if={@operator?}
750
            id={"retract-record-#{@record["id"]}"}
751
            variant={:link}
752
            tone={:danger}
753
            size={:sm}
754
            phx-click="retract_record"
755
            phx-value-record_id={@record["id"]}
756
            phx-value-expected_generation={to_string(@record["generation"])}
757
          >
758
            <.icon name="trash" /> RETRACT
759
          </.button>
683 760
        </div>
684 761
      </div>
685 762
    </.card>

@@ -691,6 +768,7 @@ defmodule OpenAgentsWeb.MemoryLive do

691 768
  attr :engram_timeline, :list, default: []
692 769
  attr :derived_heuristics, :list, default: []
693 770
  attr :supersession_trail, :list, default: []
771
  attr :operator?, :boolean, default: false
694 772
695 773
  defp memory_dashboard(assigns) do
696 774
    ~H"""

@@ -745,6 +823,7 @@ defmodule OpenAgentsWeb.MemoryLive do

745 823
              {engram["outcome"]}
746 824
            </p>
747 825
            <.button
826
              :if={@operator?}
748 827
              id={"retract-engram-#{engram_id(engram["record_ref"])}"}
749 828
              variant={:link}
750 829
              tone={:danger}

@@ -789,17 +868,38 @@ defmodule OpenAgentsWeb.MemoryLive do

789 868
        </.empty>
790 869
        <.list :if={@supersession_trail != []}>
791 870
          <:item
792
            :for={event <- @supersession_trail}
793
            title={event.replacement["claim"] || "WITHHELD"}
871
            :for={chain <- @supersession_trail}
872
            title={chain.current["claim"] || "WITHHELD"}
794 873
          >
795
            <p>
796
              Superseded
797
              <time :if={event.previous["inserted_at"]} datetime={event.previous["inserted_at"]}>
798
                {memory_date(event.previous["inserted_at"])}
799
              </time>
800
              : {event.previous["claim"] || "WITHHELD"}
801
            </p>
802
            <.badge variant={:warning}>Superseded by {event.replacement["id"]}</.badge>
874
            <div class="memory-supersession__meta">
875
              <.badge>{String.upcase(chain.current["category"])}</.badge>
876
              <.badge variant={memory_badge_variant(chain.current["status"])}>
877
                {String.upcase(chain.current["status"])}
878
              </.badge>
879
            </div>
880
            <ol :if={chain.chain != []} class="memory-supersession__chain">
881
              <li
882
                :for={prior <- chain.chain}
883
                id={"superseded-#{chain.current["id"]}-#{if prior.record, do: prior.record["id"], else: "missing"}"}
884
              >
885
                <%= if prior.record do %>
886
                  <p>{prior.record["claim"] || "WITHHELD"}</p>
887
                  <p class="memory-supersession__detail">
888
                    Superseded
889
                    <time :if={prior.superseded_at} datetime={prior.superseded_at}>
890
                      {memory_date(prior.superseded_at)}
891
                    </time>
892
                    by {prior.superseded_by_id}
893
                    <span :if={prior.consent_kind}>with consent {prior.consent_kind}</span>
894
                  </p>
895
                <% else %>
896
                  <p>Prior value unavailable</p>
897
                  <p class="memory-supersession__detail">
898
                    Superseded record is no longer in the exported scope.
899
                  </p>
900
                <% end %>
901
              </li>
902
            </ol>
803 903
          </:item>
804 904
        </.list>
805 905
      </.card>

@@ -827,6 +927,45 @@ defmodule OpenAgentsWeb.MemoryLive do

827 927
    end
828 928
  end
829 929
930
  defp build_supersession_chain(current, records_by_id) do
931
    build_supersession_chain(current, records_by_id, [])
932
  end
933
934
  defp build_supersession_chain(current, records_by_id, chain) do
935
    case current["supersedes_record_id"] do
936
      nil ->
937
        %{current: current, chain: chain}
938
939
      previous_id ->
940
        previous = Map.get(records_by_id, previous_id)
941
942
        prior = %{
943
          record: previous,
944
          superseded_at: current["inserted_at"],
945
          superseded_by_id: current["id"],
946
          consent_kind: if(previous, do: consent_kind(current), else: nil)
947
        }
948
949
        if previous do
950
          build_supersession_chain(previous, records_by_id, chain ++ [prior])
951
        else
952
          %{current: current, chain: chain ++ [prior]}
953
        end
954
    end
955
  end
956
957
  defp consent_kind(record) do
958
    provenance = record["provenance"] || %{}
959
    basis = provenance["basis"]
960
    creator = provenance["creator"] || record["creator"] || "unknown"
961
962
    if basis do
963
      "#{basis} (#{creator})"
964
    else
965
      creator
966
    end
967
  end
968
830 969
  defp engram_state_variant("succeeded"), do: :success
831 970
  defp engram_state_variant("failed"), do: :danger
832 971
  defp engram_state_variant("corrected"), do: :warning
test/openagents_web/live/memory_live_test.exs modified +50

@@ -149,6 +149,56 @@ defmodule OpenAgentsWeb.MemoryLiveTest do

149 149
    assert {:ok, []} = ProfileMemory.list_current(owner)
150 150
  end
151 151
152
  test "supersession audit renders the chain of prior claims, superseded time, and consent kind",
153
       %{
154
         conn: conn
155
       } do
156
    token = "memory-audit-browser-credential-00000000000000"
157
    %{record: record} = create_profile_memory(token, "I prefer concise answers")
158
    conn = log_in_github_user(conn, token)
159
    assert {:ok, view, _html} = live(conn, ~p"/memory")
160
161
    view
162
    |> form("#memory-record-#{record.id} form", %{
163
      "claim" => "I prefer concise, direct answers"
164
    })
165
    |> render_submit()
166
167
    html = render(view)
168
169
    assert has_element?(view, "#supersession-audit ol li")
170
    assert html =~ "I prefer concise answers"
171
    assert html =~ "I prefer concise, direct answers"
172
    assert html =~ "owner_assertion"
173
  end
174
175
  test "retract appends a tombstone and does not delete the row", %{conn: conn} do
176
    token = "memory-retract-browser-credential-000000000000"
177
    %{record: record} = create_profile_memory(token, "Retract me")
178
    conn = log_in_admin_user(conn, token)
179
    assert {:ok, view, _html} = live(conn, ~p"/memory")
180
181
    view |> element("#retract-record-#{record.id}") |> render_click()
182
183
    assert has_element?(view, "#memory-record-#{record.id}[data-status=forgotten]")
184
    assert OpenAgents.Repo.get!(OpenAgents.ProfileMemory.Record, record.id).status == "forgotten"
185
  end
186
187
  test "unauthorized retraction redirects and does not mutate the record", %{conn: conn} do
188
    token = "memory-unauthorized-retract-browser-0000000000"
189
    %{record: record} = create_profile_memory(token, "No retract")
190
    conn = log_in_github_user(conn, token)
191
    assert {:ok, view, _html} = live(conn, ~p"/memory")
192
193
    assert {:error, {:redirect, %{to: "/"}}} =
194
             render_click(view, "retract_record", %{
195
               "record_id" => record.id,
196
               "expected_generation" => to_string(record.generation)
197
             })
198
199
    assert OpenAgents.Repo.get!(OpenAgents.ProfileMemory.Record, record.id).status == "active"
200
  end
201
152 202
  defp create_profile_memory(token, claim, category \\ "preference") do
153 203
    assert {:ok, conversation} = Conversations.ensure_conversation(github_user(token))
154 204
    owner = Conversations.get_conversation_owner!(conversation)
test/openagents_web/operator_surface_test.exs modified +3 -1

@@ -106,7 +106,9 @@ defmodule OpenAgentsWeb.OperatorSurfaceTest do

106 106
    OpenAgentsWeb.Layouts => "shows the operator entries in the sidebar",
107 107
    OpenAgentsWeb.Plugs.OperatorApiTokenAuth => "rechecks the operator behind /api/operator",
108 108
    OpenAgentsWeb.ReputationController => "gates reputation subject-claim review over the API",
109
    OpenAgentsWeb.UserAuth => "gates the /admin scope as a plug and as an on_mount hook"
109
    OpenAgentsWeb.UserAuth => "gates the /admin scope as a plug and as an on_mount hook",
110
    OpenAgentsWeb.MemoryLive =>
111
      "rechecks the operator before retracting memory records and engrams"
110 112
  }
111 113
112 114
  test "the operator-classified routes are exactly the set ADMIN-001 enumerates" do

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