defmodule OpenAgents.ProfileMemoryTest do
use OpenAgents.DataCase
alias OpenAgents.{Conversations, ProfileMemory, Repo}
alias OpenAgents.Conversations.{Message, Visitor}
alias OpenAgents.ProfileMemory.Source
test "conversation repetition never creates or activates a profile record" do
{owner, conversation} = owner("profile-no-passive-learning")
for index <- 1..3 do
source_message(conversation, "I like concise answers repetition #{index}.")
end
assert {:ok, []} = ProfileMemory.list_current(owner)
assert {:ok, export} = ProfileMemory.export(owner)
assert export["records"] == []
source = source_message(conversation, "Please remember that I like concise answers.")
assert {:ok, candidate} = ProfileMemory.create_candidate(owner, attributes(source))
assert candidate.status == "candidate"
assert candidate.claim == "I prefer concise answers."
assert {:ok, []} = ProfileMemory.list_current(owner)
assert {:ok, active} =
ProfileMemory.transition(owner, candidate.id, candidate.generation, "active")
assert active.status == "active"
assert active.generation == candidate.generation + 1
assert {:ok, [listed]} = ProfileMemory.list_current(owner)
assert listed.id == active.id
assert Enum.map(listed.sources, & &1.message_id) == [source.id]
end
test "owner scope is mandatory for reads, sources, mutations, snapshots, and export" do
{first_owner, first_conversation} = owner("profile-first-browser")
{second_owner, second_conversation} = owner("profile-second-browser")
first_source = source_message(first_conversation, "Remember my private project is Atlas.")
second_source = source_message(second_conversation, "Remember my private project is Beacon.")
{:ok, first_candidate} =
ProfileMemory.create_candidate(
first_owner,
attributes(first_source, claim: "My project is Atlas.")
)
{:ok, first_active} =
ProfileMemory.transition(
first_owner,
first_candidate.id,
first_candidate.generation,
"active"
)
assert {:error, :not_found} = ProfileMemory.get(second_owner, first_active.id)
assert {:error, :not_found} =
ProfileMemory.transition(
second_owner,
first_active.id,
first_active.generation,
"forgotten"
)
assert {:error, :invalid_source} =
ProfileMemory.create_candidate(second_owner, attributes(first_source))
assert {:ok, second_candidate} =
ProfileMemory.create_candidate(
second_owner,
attributes(second_source, claim: "My project is Beacon.")
)
assert {:ok, first_snapshot} = ProfileMemory.capture_snapshot(first_owner)
assert {:ok, ^first_snapshot} = ProfileMemory.load_snapshot(first_owner, first_snapshot.ref)
assert {:error, :scope_refused} =
ProfileMemory.load_snapshot(second_owner, first_snapshot.ref)
assert {:error, :scope_refused} = ProfileMemory.list_active(second_owner, first_snapshot)
forged = %{first_snapshot | captured_at: DateTime.add(first_snapshot.captured_at, 1, :second)}
assert {:error, :scope_refused} = ProfileMemory.list_active(first_owner, forged)
assert {:ok, second_export} = ProfileMemory.export(second_owner)
assert Enum.map(second_export["records"], & &1["id"]) == [second_candidate.id]
refute inspect(second_export) =~ first_active.id
refute inspect(second_export) =~ "Atlas"
end
test "the lifecycle matrix is explicit and optimistic generations reject stale writes" do
assert ProfileMemory.transitions() == %{
"candidate" => ~w(active forgotten expired),
"active" => ~w(superseded forgotten expired),
"superseded" => [],
"forgotten" => [],
"expired" => []
}
{owner, _conversation} = owner("profile-lifecycle-browser")
{:ok, candidate} = ProfileMemory.create_candidate(owner, asserted_attributes())
assert {:error, :invalid_memory_transition} =
ProfileMemory.transition(owner, candidate.id, candidate.generation, "superseded")
assert {:ok, active} =
ProfileMemory.transition(owner, candidate.id, candidate.generation, "active")
assert {:error, :stale_generation} =
ProfileMemory.transition(owner, active.id, candidate.generation, "forgotten")
assert {:ok, forgotten} =
ProfileMemory.transition(owner, active.id, active.generation, "forgotten")
assert {:error, :invalid_memory_transition} =
ProfileMemory.transition(owner, forgotten.id, forgotten.generation, "active")
end
test "correction supersedes immutable history while a frozen snapshot stays stable" do
{owner, conversation} = owner("profile-correction-browser")
old_source = source_message(conversation, "Remember that my project is called Old Name.")
new_source = source_message(conversation, "Correction: remember that it is called One.")
{:ok, candidate} =
ProfileMemory.create_candidate(
owner,
attributes(old_source, category: "project", claim: "My project is called Old Name.")
)
{:ok, old_active} =
ProfileMemory.transition(owner, candidate.id, candidate.generation, "active")
{:ok, frozen} = ProfileMemory.capture_snapshot(owner)
correction =
Task.async(fn ->
ProfileMemory.correct(
owner,
old_active.id,
old_active.generation,
attributes(new_source, category: "project", claim: "My project is called One.")
)
end)
assert {:ok, %{superseded: superseded, replacement: replacement}} = Task.await(correction)
assert superseded.status == "superseded"
assert superseded.claim == "My project is called Old Name."
assert replacement.status == "active"
assert replacement.supersedes_record_id == superseded.id
assert {:ok, [historical_view]} = ProfileMemory.list_active(owner, frozen)
assert historical_view.id == superseded.id
assert historical_view.claim == "My project is called Old Name."
assert {:ok, [current_view]} = ProfileMemory.list_current(owner)
assert current_view.id == replacement.id
assert current_view.claim == "My project is called One."
end
test "deduplication and singleton conflicts require explicit correction" do
{owner, _conversation} = owner("profile-conflict-browser")
{:ok, first} = ProfileMemory.create_candidate(owner, asserted_attributes())
{:ok, _active} = ProfileMemory.transition(owner, first.id, first.generation, "active")
{:ok, duplicate} = ProfileMemory.create_candidate(owner, asserted_attributes())
assert {:error, :duplicate_memory} =
ProfileMemory.transition(owner, duplicate.id, duplicate.generation, "active")
{:ok, name_one} =
ProfileMemory.create_candidate(
owner,
asserted_attributes(category: "name", claim: "My name is Ada.")
)
{:ok, _active_name} =
ProfileMemory.transition(owner, name_one.id, name_one.generation, "active")
{:ok, name_two} =
ProfileMemory.create_candidate(
owner,
asserted_attributes(category: "name", claim: "My name is Grace.")
)
assert {:error, :memory_conflict_requires_correction} =
ProfileMemory.transition(owner, name_two.id, name_two.generation, "active")
end
test "non-singleton active category limits are deterministic" do
{owner, _conversation} = owner("profile-category-limit-browser")
for index <- 1..25 do
{:ok, candidate} =
ProfileMemory.create_candidate(
owner,
asserted_attributes(category: "project", claim: "Project memory #{index}.")
)
assert {:ok, _active} =
ProfileMemory.transition(owner, candidate.id, candidate.generation, "active")
end
{:ok, overflow} =
ProfileMemory.create_candidate(
owner,
asserted_attributes(category: "project", claim: "Project memory overflow.")
)
assert {:error, :memory_category_limit_reached} =
ProfileMemory.transition(owner, overflow.id, overflow.generation, "active")
end
test "expiry is explicit, old snapshots remain stable, and purge removes all derived links" do
{owner, conversation} = owner("profile-expiry-browser")
source = source_message(conversation, "Remember the temporary launch preference.")
expires_at = DateTime.add(DateTime.utc_now(), 3_600, :second)
{:ok, candidate} =
ProfileMemory.create_candidate(owner, attributes(source, expires_at: expires_at))
{:ok, active} =
ProfileMemory.transition(owner, candidate.id, candidate.generation, "active")
{:ok, frozen} = ProfileMemory.capture_snapshot(owner)
assert {:ok, 1} = ProfileMemory.expire_due(owner, DateTime.add(expires_at, 1, :second))
assert {:ok, [historical]} = ProfileMemory.list_active(owner, frozen)
assert historical.id == active.id
assert historical.status == "active"
assert {:ok, []} = ProfileMemory.list_current(owner)
{:ok, expired} = ProfileMemory.get(owner, active.id)
assert expired.status == "expired"
assert Repo.aggregate(
from(link in Source, where: link.memory_record_id == ^active.id),
:count
) == 1
assert {:ok, :purged} = ProfileMemory.purge(owner, expired.id, expired.generation)
assert {:error, :not_found} = ProfileMemory.get(owner, expired.id)
assert Repo.aggregate(
from(link in Source, where: link.memory_record_id == ^active.id),
:count
) == 0
assert Repo.get(Message, source.id)
end
test "bounds and model provenance are enforced before persistence" do
{owner, _conversation} = owner("profile-bounds-browser")
assert {:error, :invalid_claim} =
ProfileMemory.create_candidate(
owner,
asserted_attributes(claim: String.duplicate("x", 501))
)
assert {:error, :invalid_memory_attributes} =
ProfileMemory.create_candidate(owner, asserted_attributes(category: "secret"))
assert {:error, :missing_model_provenance} =
ProfileMemory.create_candidate(owner, %{
category: "preference",
claim: "A model-proposed candidate.",
creator: "model_proposal",
provenance: %{},
sources: []
})
assert {:ok, model_candidate} =
ProfileMemory.create_candidate(owner, %{
category: "preference",
claim: "A model-proposed candidate.",
creator: "model_proposal",
provenance: %{"model_id" => "eval-model"},
creator_artifact_id: "artifact-v1",
creator_artifact_digest: String.duplicate("a", 64),
sources: []
})
assert model_candidate.status == "candidate"
assert {:error, :active_memory_requires_support} =
ProfileMemory.transition(
owner,
model_candidate.id,
model_candidate.generation,
"active"
)
end
test "deletion, correction, and purge refuse another owner's record and leak no existence" do
{first_owner, first_conversation} = owner("profile-delete-first-browser")
{second_owner, second_conversation} = owner("profile-delete-second-browser")
first_source = source_message(first_conversation, "Remember my private project is Atlas.")
second_source = source_message(second_conversation, "Remember my private project is Beacon.")
{:ok, candidate} =
ProfileMemory.create_candidate(
first_owner,
attributes(first_source, category: "project", claim: "My project is Atlas.")
)
{:ok, active} =
ProfileMemory.transition(first_owner, candidate.id, candidate.generation, "active")
foreign_selector = %{
"mode" => "record",
"record_id" => active.id,
"expected_generation" => active.generation
}
absent_selector = %{
"mode" => "record",
"record_id" => Ecto.UUID.generate(),
"expected_generation" => active.generation
}
# Identical answers: the second owner cannot tell a record it may not touch
# from one that was never stored.
assert {:ok, %{disposition: "already_absent", records: []}} =
ProfileMemory.forget_active(second_owner, foreign_selector)
assert {:ok, %{disposition: "already_absent", records: []}} =
ProfileMemory.forget_active(second_owner, absent_selector)
assert {:error, :not_found} =
ProfileMemory.correct(
second_owner,
active.id,
active.generation,
attributes(second_source, category: "project", claim: "My project is Beacon.")
)
assert {:error, :not_found} =
ProfileMemory.purge(second_owner, active.id, active.generation)
# A stale generation is no shortcut around ownership, and forgetting in one
# scope leaves the other scope's record active.
assert {:error, :not_found} =
ProfileMemory.transition(second_owner, active.id, active.generation + 1, "forgotten")
assert {:ok, [listed]} = ProfileMemory.list_current(first_owner)
assert listed.id == active.id
assert listed.status == "active"
assert {:ok, %{disposition: "forgotten", records: [forgotten]}} =
ProfileMemory.forget_active(first_owner, foreign_selector)
assert forgotten.id == active.id
assert {:ok, []} = ProfileMemory.list_current(first_owner)
# Purge is the only irreversible path, so it stays owner-scoped and terminal.
assert {:error, :not_found} =
ProfileMemory.purge(second_owner, forgotten.id, forgotten.generation)
assert {:ok, :purged} =
ProfileMemory.purge(first_owner, forgotten.id, forgotten.generation)
assert {:error, :not_found} = ProfileMemory.get(first_owner, forgotten.id)
end
test "database backstops reject orphan activation, source deletion, and in-place claim edits" do
{owner, conversation} = owner("profile-database-constraints")
{_foreign_owner, foreign_conversation} = owner("profile-database-foreign-source")
source = source_message(conversation, "Remember this database-backed preference.")
foreign_source = source_message(foreign_conversation, "A different browser's private source.")
{:ok, candidate} = ProfileMemory.create_candidate(owner, attributes(source))
{:ok, active} = ProfileMemory.transition(owner, candidate.id, candidate.generation, "active")
{:ok, snapshot} = ProfileMemory.capture_snapshot(owner)
assert_raise Postgrex.Error, fn ->
Repo.transaction(fn ->
Ecto.Adapters.SQL.query!(
Repo,
"UPDATE profile_memory_records SET claim = 'silently edited' WHERE id::text = $1",
[active.id]
)
end)
end
assert_raise Postgrex.Error, fn ->
Repo.transaction(fn ->
Repo.insert!(
Source.changeset(%Source{}, %{
memory_record_id: active.id,
message_id: foreign_source.id,
source_kind: "owner_statement",
inserted_at: DateTime.utc_now()
})
)
end)
end
snapshot_id = String.replace_prefix(snapshot.ref, "profile-memory-snapshot:v1:", "")
assert_raise Postgrex.Error, fn ->
Repo.transaction(fn ->
Ecto.Adapters.SQL.query!(
Repo,
"UPDATE profile_memory_snapshots SET captured_at = now() WHERE id::text = $1",
[snapshot_id]
)
end)
end
assert_raise Postgrex.Error, fn ->
Repo.transaction(fn ->
Repo.delete_all(from(link in Source, where: link.memory_record_id == ^active.id))
Ecto.Adapters.SQL.query!(
Repo,
"SET CONSTRAINTS profile_memory_source_delete_support_trigger IMMEDIATE",
[]
)
end)
end
assert_raise Postgrex.Error, fn ->
Repo.transaction(fn ->
insert_orphan_active!(owner.id)
Ecto.Adapters.SQL.query!(
Repo,
"SET CONSTRAINTS profile_memory_record_support_trigger IMMEDIATE",
[]
)
end)
end
end
defp owner(browser_key) do
{:ok, conversation} = Conversations.ensure_conversation(browser_key)
{Repo.get!(Visitor, conversation.visitor_id), conversation}
end
defp source_message(conversation, content) do
Repo.insert!(%Message{
conversation_id: conversation.id,
role: "user",
content: content,
status: "complete"
})
end
defp attributes(source, overrides \\ []) do
Map.merge(
%{
category: "preference",
claim: " I prefer concise answers. ",
creator: "user_explicit",
provenance: %{"intent" => "remember"},
sources: [%{source_ref: "message:#{source.id}", kind: "owner_statement"}]
},
Map.new(overrides)
)
end
defp asserted_attributes(overrides \\ []) do
Map.merge(
%{
category: "preference",
claim: "I prefer concise answers.",
creator: "user_explicit",
provenance: %{"intent" => "remember"},
owner_asserted: true,
sources: []
},
Map.new(overrides)
)
end
defp insert_orphan_active!(owner_id) do
Ecto.Adapters.SQL.query!(
Repo,
"""
INSERT INTO profile_memory_records (
id, owner_visitor_id, schema_version, category, claim, claim_fingerprint,
status, provenance, confidence, redaction_policy, creator, generation,
created_generation, active_generation, inserted_at, updated_at
) VALUES ($1, $2, 1, 'other', 'Orphan active claim.', $3, 'active', '{}',
1, 'sarah.memory.redaction.pending.v1', 'user_explicit', 1, 1, 1, $4, $4)
""",
[
Ecto.UUID.dump!(Ecto.UUID.generate()),
Ecto.UUID.dump!(owner_id),
:crypto.hash(:sha256, "orphan"),
DateTime.utc_now()
]
)
end
end