Skip to content

Commit 446f8d6

Browse files
committed
Scan memory promotion gates
1 parent 4db8576 commit 446f8d6

5 files changed

Lines changed: 116 additions & 15 deletions

File tree

examples/adaptive_control_roundtrip/lib/stack_lab/examples/adaptive_control_roundtrip.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ defmodule StackLab.Examples.AdaptiveControlRoundtrip do
120120
shadow_gate_refs: ["shadow://candidate/worker/v2"],
121121
canary_gate_refs: ["canary://candidate/worker/v2"],
122122
approval_refs: ["approval://operator/worker/v2"],
123+
citadel_authority_refs: ["authority://citadel/promotion/a"],
123124
promotion_refs: ["promotion://candidate/worker/v2"],
124125
rollback_refs: ["rollback://candidate/worker/v1"],
125126
stale_artifact_rejection_refs: ["stale-rejection://candidate/worker/v1"],

support/adaptive_control_scanner/lib/stack_lab/adaptive_control_scanner.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ defmodule StackLab.AdaptiveControlScanner do
4242
:shadow_gate_refs,
4343
:canary_gate_refs,
4444
:approval_refs,
45+
:citadel_authority_refs,
4546
:promotion_refs,
4647
:rollback_refs,
4748
:stale_artifact_rejection_refs,

support/adaptive_control_scanner/test/stack_lab/adaptive_control_scanner_test.exs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ defmodule StackLab.AdaptiveControlScannerTest do
5454
:shadow_gate_refs,
5555
:canary_gate_refs,
5656
:approval_refs,
57+
:citadel_authority_refs,
5758
:promotion_refs,
5859
:rollback_refs,
5960
:stale_artifact_rejection_refs,
@@ -79,6 +80,24 @@ defmodule StackLab.AdaptiveControlScannerTest do
7980
assert has_finding?(receipt, :raw_payload, {:forbidden_raw_field, :provider_payload})
8081
end
8182

83+
test "rejects promotion facts that lack eval or Citadel gates" do
84+
fact =
85+
complete_fact()
86+
|> Map.delete(:eval_dataset_refs)
87+
|> Map.delete(:citadel_authority_refs)
88+
89+
assert {:ok, receipt} =
90+
AdaptiveControlScanner.scan(%{
91+
owner_repo: "mezzanine",
92+
package_path: "core/adaptive_control_engine",
93+
adaptive_control_facts: [fact]
94+
})
95+
96+
assert receipt.status == :open_defect
97+
assert has_finding?(receipt, :eval_dataset_refs, :missing_required_refs)
98+
assert has_finding?(receipt, :citadel_authority_refs, :missing_required_refs)
99+
end
100+
82101
defp has_finding?(receipt, rule, reason) do
83102
Enum.any?(receipt.findings, fn finding ->
84103
finding.rule == rule and finding.reason == reason
@@ -95,6 +114,7 @@ defmodule StackLab.AdaptiveControlScannerTest do
95114
shadow_gate_refs: ["shadow://candidate/worker/v2"],
96115
canary_gate_refs: ["canary://candidate/worker/v2"],
97116
approval_refs: ["approval://operator/worker/v2"],
117+
citadel_authority_refs: ["authority://citadel/promotion/a"],
98118
promotion_refs: ["promotion://candidate/worker/v2"],
99119
rollback_refs: ["rollback://candidate/worker/v1"],
100120
stale_artifact_rejection_refs: ["stale-rejection://candidate/worker/v1"],

support/memory_fabric_scanner/lib/stack_lab/memory_fabric_scanner.ex

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,17 @@ defmodule StackLab.MemoryFabricScanner do
3737
:idempotency_key,
3838
:trace_ref
3939
]
40+
@promotion_gate_refs [
41+
:candidate_ref,
42+
:promotion_ref,
43+
:rollback_ref,
44+
:eval_evidence_refs,
45+
:citadel_authority_ref,
46+
:appkit_projection_ref
47+
]
4048
@direct_adapter_module "OuterBrain.MemoryEngine.Store"
4149
@owner_path "outer_brain/core/memory_engine"
42-
@rules [:direct_adapter_calls, :required_runtime_refs]
50+
@rules [:direct_adapter_calls, :required_runtime_refs, :silent_memory_candidate_mutation]
4351

4452
@spec scan(map()) :: {:ok, Receipt.t()} | {:error, term()}
4553
def scan(attrs) when is_map(attrs) do
@@ -132,32 +140,68 @@ defmodule StackLab.MemoryFabricScanner do
132140
@required_runtime_refs
133141
|> Enum.reject(&present_string?(fact, &1))
134142

135-
case missing do
136-
[] ->
137-
[]
138-
139-
fields ->
140-
[
141-
finding(
142-
:required_runtime_refs,
143-
:missing_required_memory_refs,
144-
"runtime_fact:#{index}",
145-
%{fields: fields}
146-
)
147-
]
148-
end
143+
required_runtime_ref_findings(missing, index) ++ promotion_gate_findings(fact, index)
149144
end
150145

151146
defp runtime_fact_findings(_fact, index),
152147
do: [finding(:required_runtime_refs, :invalid_runtime_fact, "runtime_fact:#{index}", %{})]
153148

149+
defp required_runtime_ref_findings([], _index), do: []
150+
151+
defp required_runtime_ref_findings(fields, index) do
152+
[
153+
finding(
154+
:required_runtime_refs,
155+
:missing_required_memory_refs,
156+
"runtime_fact:#{index}",
157+
%{fields: fields}
158+
)
159+
]
160+
end
161+
162+
defp promotion_gate_findings(fact, index) do
163+
if promotion_fact?(fact) and missing_promotion_gate_refs(fact) != [] do
164+
[
165+
finding(
166+
:silent_memory_candidate_mutation,
167+
:silent_memory_candidate_mutation,
168+
"runtime_fact:#{index}",
169+
%{fields: missing_promotion_gate_refs(fact)}
170+
)
171+
]
172+
else
173+
[]
174+
end
175+
end
176+
177+
defp promotion_fact?(fact) do
178+
present_string?(fact, :candidate_ref) or present_string?(fact, :promotion_ref)
179+
end
180+
181+
defp missing_promotion_gate_refs(fact) do
182+
Enum.reject(@promotion_gate_refs, fn
183+
:eval_evidence_refs -> non_empty_string_list?(fact, :eval_evidence_refs)
184+
field -> present_string?(fact, field)
185+
end)
186+
end
187+
154188
defp present_string?(fact, field) do
155189
case Map.get(fact, field) || Map.get(fact, Atom.to_string(field)) do
156190
value when is_binary(value) -> String.trim(value) != ""
157191
_other -> false
158192
end
159193
end
160194

195+
defp non_empty_string_list?(fact, field) do
196+
case Map.get(fact, field) || Map.get(fact, Atom.to_string(field)) do
197+
values when is_list(values) and values != [] ->
198+
Enum.all?(values, &(is_binary(&1) and String.trim(&1) != ""))
199+
200+
_other ->
201+
false
202+
end
203+
end
204+
161205
defp owner_path?(path), do: String.contains?(path, @owner_path)
162206

163207
defp status([]), do: :pass

support/memory_fabric_scanner/test/stack_lab/memory_fabric_scanner_test.exs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,29 @@ defmodule StackLab.MemoryFabricScannerTest do
2222
assert [%{reason: :missing_required_memory_refs}] = receipt.findings
2323
end
2424

25+
test "runtime facts reject silent memory candidate mutation" do
26+
assert {:ok, receipt} =
27+
MemoryFabricScanner.scan(%{
28+
owner_repo: "outer_brain",
29+
runtime_facts: [promotion_fact()]
30+
})
31+
32+
assert receipt.status == :pass
33+
34+
assert {:ok, receipt} =
35+
MemoryFabricScanner.scan(%{
36+
owner_repo: "outer_brain",
37+
runtime_facts: [
38+
promotion_fact()
39+
|> Map.delete(:eval_evidence_refs)
40+
|> Map.delete(:citadel_authority_ref)
41+
]
42+
})
43+
44+
assert receipt.status == :open_defect
45+
assert [%{reason: :silent_memory_candidate_mutation}] = receipt.findings
46+
end
47+
2548
test "static scanner catches direct adapter references outside memory engine owner path" do
2649
path = Path.expand("../fixtures/direct_adapter_reference.source", __DIR__)
2750

@@ -61,4 +84,16 @@ defmodule StackLab.MemoryFabricScannerTest do
6184
trace_ref: "trace://a"
6285
}
6386
end
87+
88+
defp promotion_fact do
89+
runtime_fact()
90+
|> Map.merge(%{
91+
candidate_ref: "memory-candidate://tenant-a/a",
92+
promotion_ref: "memory-promotion://tenant-a/a",
93+
rollback_ref: "memory-rollback://tenant-a/a",
94+
eval_evidence_refs: ["eval://memory/a"],
95+
citadel_authority_ref: "authority://citadel/promotion/a",
96+
appkit_projection_ref: "appkit://memory/promotion/a"
97+
})
98+
end
6499
end

0 commit comments

Comments
 (0)