Skip to content

Commit f18cde3

Browse files
committed
Add governed memory candidates
1 parent b1a78ca commit f18cde3

8 files changed

Lines changed: 333 additions & 0 deletions

File tree

core/context_abi/lib/outer_brain/context_abi/context_packet.ex

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ defmodule OuterBrain.ContextABI.ContextPacket do
4949
{:ok, system_instruction_ref} <-
5050
Validator.required_string(attrs, :system_instruction_ref),
5151
{:ok, memory_refs} <- Validator.string_list(attrs, :memory_refs),
52+
:ok <- reject_unpromoted_memory_refs(memory_refs),
5253
{:ok, budget_ref} <- Validator.required_string(attrs, :budget_ref),
5354
{:ok, model_class_allowlist} <- Validator.string_list(attrs, :model_class_allowlist),
5455
:ok <- require_nonempty(model_class_allowlist, :model_class_allowlist),
@@ -126,4 +127,20 @@ defmodule OuterBrain.ContextABI.ContextPacket do
126127
end
127128

128129
defp require_nonempty(_values, _field), do: :ok
130+
131+
defp reject_unpromoted_memory_refs(memory_refs) do
132+
case Enum.find(memory_refs, &unpromoted_memory_ref?/1) do
133+
nil ->
134+
:ok
135+
136+
ref ->
137+
Validator.failure(:outer_brain, "outer_brain.context.unpromoted_memory_candidate.v1",
138+
safe_message: "unpromoted memory candidates cannot enter production context packets",
139+
evidence_refs: [ref]
140+
)
141+
end
142+
end
143+
144+
defp unpromoted_memory_ref?("memory-candidate://" <> _suffix), do: true
145+
defp unpromoted_memory_ref?(ref), do: String.contains?(ref, "/candidate/")
129146
end

core/context_abi/test/outer_brain/context_abi_test.exs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,16 @@ defmodule OuterBrain.ContextABITest do
6161
assert nested_failure.reason_code == "outer_brain.context.raw_payload_rejected.v1"
6262
end
6363

64+
test "compiler rejects unpromoted memory candidate refs in production packets" do
65+
assert {:error, %Failure{} = failure} =
66+
compile_request()
67+
|> Map.put(:memory_refs, ["memory-candidate://tenant-a/unpromoted/a"])
68+
|> ContextABI.compile()
69+
70+
assert failure.reason_code == "outer_brain.context.unpromoted_memory_candidate.v1"
71+
assert failure.evidence_refs == ["memory-candidate://tenant-a/unpromoted/a"]
72+
end
73+
6474
test "context units validate vocabulary and reject raw metadata payloads" do
6575
assert {:ok, %ContextUnit{} = unit} =
6676
ContextUnit.new(%{

core/memory_contracts/lib/outer_brain/memory_contracts.ex

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ defmodule OuterBrain.MemoryContracts do
1212
ContextBudgetDecision,
1313
ContextBudgetRef,
1414
MemoryAccessReason,
15+
MemoryCandidate,
1516
MemoryEvidenceRef,
1617
MemoryQueryIntent,
1718
MemoryRedactionPolicy,
@@ -39,6 +40,9 @@ defmodule OuterBrain.MemoryContracts do
3940
@spec budget_exhaustion_reasons() :: [atom()]
4041
def budget_exhaustion_reasons, do: Vocabulary.budget_exhaustion_reasons()
4142

43+
@spec memory_candidate_statuses() :: [atom()]
44+
def memory_candidate_statuses, do: Vocabulary.memory_candidate_statuses()
45+
4246
@spec scope_key(map() | MemoryScopeKey.t()) :: {:ok, MemoryScopeKey.t()} | error()
4347
def scope_key(attrs), do: MemoryScopeKey.new(attrs)
4448

@@ -69,6 +73,9 @@ defmodule OuterBrain.MemoryContracts do
6973
@spec query_intent(map() | MemoryQueryIntent.t()) :: {:ok, MemoryQueryIntent.t()} | error()
7074
def query_intent(attrs), do: MemoryQueryIntent.new(attrs)
7175

76+
@spec memory_candidate(map() | MemoryCandidate.t()) :: {:ok, MemoryCandidate.t()} | error()
77+
def memory_candidate(attrs), do: MemoryCandidate.new(attrs)
78+
7279
@spec fetch_value(map(), atom()) :: term()
7380
def fetch_value(attrs, field), do: Validator.fetch_value(attrs, field)
7481
end
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
defmodule OuterBrain.MemoryContracts.MemoryCandidate do
2+
@moduledoc """
3+
Ref-only memory candidate contract for governed promotion.
4+
5+
A candidate is not production memory. It records the memory/evidence refs and
6+
eval/Citadel facts required before a promoted memory ref can participate in a
7+
production Context ABI packet.
8+
"""
9+
10+
alias OuterBrain.MemoryContracts.{MemoryEvidenceRef, MemoryRef, Validator, Vocabulary}
11+
12+
@enforce_keys [
13+
:candidate_ref,
14+
:tenant_ref,
15+
:memory_ref,
16+
:evidence_ref,
17+
:eval_evidence_refs,
18+
:authority_ref,
19+
:trace_ref,
20+
:redaction_policy_ref,
21+
:status
22+
]
23+
defstruct [:promotion_ref, :rollback_ref | @enforce_keys]
24+
25+
@type status :: :candidate | :promoted | :rolled_back
26+
27+
@type t :: %__MODULE__{
28+
candidate_ref: String.t(),
29+
tenant_ref: String.t(),
30+
memory_ref: MemoryRef.t(),
31+
evidence_ref: MemoryEvidenceRef.t(),
32+
eval_evidence_refs: [String.t()],
33+
authority_ref: String.t(),
34+
trace_ref: String.t(),
35+
redaction_policy_ref: String.t(),
36+
status: status(),
37+
promotion_ref: String.t() | nil,
38+
rollback_ref: String.t() | nil
39+
}
40+
41+
@spec new(map() | t()) :: {:ok, t()} | {:error, term()}
42+
def new(%__MODULE__{} = candidate), do: {:ok, candidate}
43+
44+
def new(attrs) when is_map(attrs) do
45+
with :ok <- Validator.reject_raw_payload(attrs),
46+
{:ok, candidate_ref} <- required_string(attrs, :candidate_ref),
47+
{:ok, tenant_ref} <- required_string(attrs, :tenant_ref),
48+
{:ok, memory_ref} <- attrs |> Validator.fetch_value(:memory_ref) |> MemoryRef.new(),
49+
{:ok, evidence_ref} <-
50+
attrs |> Validator.fetch_value(:evidence_ref) |> MemoryEvidenceRef.new(),
51+
{:ok, eval_evidence_refs} <- required_strings(attrs, :eval_evidence_refs),
52+
{:ok, authority_ref} <- required_string(attrs, :authority_ref),
53+
{:ok, trace_ref} <- required_string(attrs, :trace_ref),
54+
{:ok, redaction_policy_ref} <- required_string(attrs, :redaction_policy_ref),
55+
{:ok, status} <- candidate_status(attrs),
56+
:ok <- status_refs_present(attrs, status) do
57+
{:ok,
58+
%__MODULE__{
59+
candidate_ref: candidate_ref,
60+
tenant_ref: tenant_ref,
61+
memory_ref: memory_ref,
62+
evidence_ref: evidence_ref,
63+
eval_evidence_refs: eval_evidence_refs,
64+
authority_ref: authority_ref,
65+
trace_ref: trace_ref,
66+
redaction_policy_ref: redaction_policy_ref,
67+
status: status,
68+
promotion_ref: optional_string(attrs, :promotion_ref),
69+
rollback_ref: optional_string(attrs, :rollback_ref)
70+
}}
71+
end
72+
end
73+
74+
def new(_attrs), do: {:error, :invalid_memory_candidate}
75+
76+
defp candidate_status(attrs) do
77+
status = Validator.fetch_value(attrs, :status) || :candidate
78+
79+
if status in Vocabulary.memory_candidate_statuses() do
80+
{:ok, status}
81+
else
82+
{:error, {:invalid_candidate_status, status}}
83+
end
84+
end
85+
86+
defp status_refs_present(attrs, :promoted) do
87+
case required_string(attrs, :promotion_ref) do
88+
{:ok, _ref} -> :ok
89+
{:error, _reason} -> {:error, {:missing_candidate_ref, :promotion_ref}}
90+
end
91+
end
92+
93+
defp status_refs_present(attrs, :rolled_back) do
94+
case required_string(attrs, :rollback_ref) do
95+
{:ok, _ref} -> :ok
96+
{:error, _reason} -> {:error, {:missing_candidate_ref, :rollback_ref}}
97+
end
98+
end
99+
100+
defp status_refs_present(_attrs, :candidate), do: :ok
101+
102+
defp required_string(attrs, field) do
103+
case Validator.required_string(attrs, field) do
104+
{:ok, value} -> {:ok, value}
105+
{:error, _reason} -> {:error, {:missing_candidate_ref, field}}
106+
end
107+
end
108+
109+
defp optional_string(attrs, field), do: Validator.optional_string(attrs, field)
110+
111+
defp required_strings(attrs, field) do
112+
case Validator.fetch_value(attrs, field) do
113+
values when is_list(values) and values != [] ->
114+
if Enum.all?(values, &(is_binary(&1) and String.trim(&1) != "")) do
115+
{:ok, Enum.uniq(values)}
116+
else
117+
{:error, {:missing_candidate_ref, field}}
118+
end
119+
120+
_other ->
121+
{:error, {:missing_candidate_ref, field}}
122+
end
123+
end
124+
end

core/memory_contracts/lib/outer_brain/memory_contracts/vocabulary.ex

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,19 @@ defmodule OuterBrain.MemoryContracts.Vocabulary do
55
:body,
66
:raw_body,
77
:content,
8+
:memory_body,
9+
:model_output,
810
:raw_content,
11+
:raw_memory,
912
:payload,
1013
:raw_payload,
1114
"body",
1215
"raw_body",
1316
"content",
17+
"memory_body",
18+
"model_output",
1419
"raw_content",
20+
"raw_memory",
1521
"payload",
1622
"raw_payload"
1723
]
@@ -60,6 +66,8 @@ defmodule OuterBrain.MemoryContracts.Vocabulary do
6066
:operator_override_denied
6167
]
6268

69+
@memory_candidate_statuses [:candidate, :promoted, :rolled_back]
70+
6371
@spec raw_payload_keys() :: [atom() | String.t()]
6472
def raw_payload_keys, do: @raw_payload_keys
6573

@@ -80,4 +88,7 @@ defmodule OuterBrain.MemoryContracts.Vocabulary do
8088

8189
@spec budget_exhaustion_reasons() :: [atom()]
8290
def budget_exhaustion_reasons, do: @budget_reasons
91+
92+
@spec memory_candidate_statuses() :: [atom()]
93+
def memory_candidate_statuses, do: @memory_candidate_statuses
8394
end

core/memory_contracts/test/outer_brain/memory_contracts_test.exs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,34 @@ defmodule OuterBrain.MemoryContractsTest do
136136
|> MemoryContracts.query_intent()
137137
end
138138

139+
test "memory candidates are ref-only and require bounded eval evidence" do
140+
assert {:ok, candidate} = MemoryContracts.memory_candidate(valid_memory_candidate())
141+
142+
assert candidate.status == :candidate
143+
assert candidate.memory_ref.memory_id == "mem-1"
144+
assert candidate.evidence_ref.memory_id == "mem-1"
145+
assert candidate.eval_evidence_refs == ["eval://memory/a"]
146+
147+
assert {:error, {:missing_candidate_ref, :eval_evidence_refs}} =
148+
valid_memory_candidate()
149+
|> Map.put(:eval_evidence_refs, [])
150+
|> MemoryContracts.memory_candidate()
151+
152+
assert {:error, {:raw_memory_body_forbidden, :memory_body}} =
153+
valid_memory_candidate()
154+
|> Map.put(:memory_body, "raw")
155+
|> MemoryContracts.memory_candidate()
156+
end
157+
158+
test "candidate status vocabulary distinguishes candidate, promoted, and rolled back" do
159+
assert MemoryContracts.memory_candidate_statuses() == [:candidate, :promoted, :rolled_back]
160+
161+
assert {:error, {:invalid_candidate_status, :unknown}} =
162+
valid_memory_candidate()
163+
|> Map.put(:status, :unknown)
164+
|> MemoryContracts.memory_candidate()
165+
end
166+
139167
defp valid_write_intent do
140168
%{
141169
tenant_ref: "tenant://a",
@@ -210,4 +238,18 @@ defmodule OuterBrain.MemoryContractsTest do
210238
trace_ref: "trace://a"
211239
}
212240
end
241+
242+
defp valid_memory_candidate do
243+
%{
244+
candidate_ref: "memory-candidate://tenant-a/a",
245+
tenant_ref: "tenant://a",
246+
memory_ref: valid_memory_ref(),
247+
evidence_ref: valid_evidence_ref(),
248+
eval_evidence_refs: ["eval://memory/a"],
249+
authority_ref: "authority://citadel/memory/a",
250+
trace_ref: "trace://a",
251+
redaction_policy_ref: "policy://redact",
252+
status: :candidate
253+
}
254+
end
213255
end

core/memory_engine/lib/outer_brain/memory_engine.ex

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,61 @@ defmodule OuterBrain.MemoryEngine do
170170
end
171171
end
172172

173+
@spec candidate_from_evidence(map()) ::
174+
{:ok, MemoryContracts.MemoryCandidate.t()} | {:error, term()}
175+
def candidate_from_evidence(attrs) when is_map(attrs) do
176+
attrs
177+
|> Map.put_new(:status, :candidate)
178+
|> MemoryContracts.memory_candidate()
179+
end
180+
181+
@spec promote_candidate(MemoryContracts.MemoryCandidate.t() | map(), map()) ::
182+
{:ok, MemoryContracts.MemoryCandidate.t()} | {:error, term()}
183+
def promote_candidate(candidate_or_attrs, attrs) when is_map(attrs) do
184+
with {:ok, candidate} <- MemoryContracts.memory_candidate(candidate_or_attrs),
185+
{:ok, promotion_ref} <- required_string(attrs, :promotion_ref),
186+
{:ok, citadel_authority_ref} <- required_string(attrs, :citadel_authority_ref),
187+
{:ok, eval_refs} <- non_empty_strings(attrs, :eval_evidence_refs) do
188+
MemoryContracts.memory_candidate(%{
189+
candidate
190+
| status: :promoted,
191+
promotion_ref: promotion_ref,
192+
authority_ref: citadel_authority_ref,
193+
eval_evidence_refs: eval_refs
194+
})
195+
else
196+
{:error, {:missing_ref, :eval_evidence_refs}} ->
197+
{:error, :memory_candidate_eval_gate_required}
198+
199+
{:error, {:missing_ref, :citadel_authority_ref}} ->
200+
{:error, :memory_candidate_citadel_gate_required}
201+
202+
{:error, reason} ->
203+
{:error, reason}
204+
end
205+
end
206+
207+
@spec rollback_candidate(MemoryContracts.MemoryCandidate.t() | map(), map()) ::
208+
{:ok, MemoryContracts.MemoryCandidate.t()} | {:error, term()}
209+
def rollback_candidate(candidate_or_attrs, attrs) when is_map(attrs) do
210+
with {:ok, candidate} <- MemoryContracts.memory_candidate(candidate_or_attrs),
211+
{:ok, rollback_ref} <- required_string(attrs, :rollback_ref),
212+
{:ok, citadel_authority_ref} <- required_string(attrs, :citadel_authority_ref) do
213+
MemoryContracts.memory_candidate(%{
214+
candidate
215+
| status: :rolled_back,
216+
rollback_ref: rollback_ref,
217+
authority_ref: citadel_authority_ref
218+
})
219+
else
220+
{:error, {:missing_ref, :citadel_authority_ref}} ->
221+
{:error, :memory_candidate_citadel_gate_required}
222+
223+
{:error, reason} ->
224+
{:error, reason}
225+
end
226+
end
227+
173228
defp evidence(raw_body, max_export_bytes) when byte_size(raw_body) > max_export_bytes do
174229
{:ok, sha256(raw_body), "body_oversize_replaced_by_hash_ref"}
175230
end
@@ -244,4 +299,25 @@ defmodule OuterBrain.MemoryEngine do
244299
defp scope_tier(%MemoryContracts.MemoryScopeKey{run_ref: nil, agent_ref: nil}), do: :semantic
245300
defp scope_tier(%MemoryContracts.MemoryScopeKey{skill_ref: nil}), do: :episodic
246301
defp scope_tier(%MemoryContracts.MemoryScopeKey{}), do: :working
302+
303+
defp required_string(attrs, field) do
304+
case Map.get(attrs, field) || Map.get(attrs, Atom.to_string(field)) do
305+
value when is_binary(value) and value != "" -> {:ok, value}
306+
_other -> {:error, {:missing_ref, field}}
307+
end
308+
end
309+
310+
defp non_empty_strings(attrs, field) do
311+
case Map.get(attrs, field) || Map.get(attrs, Atom.to_string(field)) do
312+
values when is_list(values) and values != [] ->
313+
if Enum.all?(values, &(is_binary(&1) and String.trim(&1) != "")) do
314+
{:ok, Enum.uniq(values)}
315+
else
316+
{:error, {:missing_ref, field}}
317+
end
318+
319+
_other ->
320+
{:error, {:missing_ref, field}}
321+
end
322+
end
247323
end

0 commit comments

Comments
 (0)