@@ -98,7 +98,12 @@ def execute_valid(gate, record=None):
9898 )
9999
100100
101+ # ---------------------------------------------------------------------------
102+ # Existing regression proofs (issues #7, #8, #9 — still open gaps)
103+ # ---------------------------------------------------------------------------
104+
101105def test_proof_consequence_ordering_exposes_mutation_before_durable_audit ():
106+ """Issue #7 regression: mutation can occur before durable audit. Still open."""
102107 effects = []
103108
104109 def mutate (record ):
@@ -110,10 +115,12 @@ def mutate(record):
110115
111116 assert effects == [("sent" , "user@example.com" )]
112117 assert result .allowed is False
113- assert result .code == "ROLLBACK:UNEXPECTED:RuntimeError"
118+ # Now returns controlled audit failure code instead of ROLLBACK:UNEXPECTED
119+ assert result .code == "ERROR:AUDIT_APPEND_FAILED:RuntimeError"
114120
115121
116122def test_proof_payload_binding_gap_allows_callback_to_create_unbound_effect ():
123+ """Issue #8 regression: proof not bound to mutation payload. Still open."""
117124 audit = MemoryAudit ()
118125 effects = []
119126
@@ -138,10 +145,13 @@ def mutate(record):
138145 "body" : "unbound payload" ,
139146 }
140147 ]
148+ # Issue #11 fixed: audit receipt uses the pre-mutation snapshot.
149+ # The snapshot was taken before the callback ran, so object_id is correct.
141150 assert audit .events [0 ]["record_scope" ]["object_id" ] == "user@example.com"
142151
143152
144153def test_atomic_commit_boundary_gap_when_audit_fails_after_nonce_and_mutation ():
154+ """Issue #9 regression: no atomic commit boundary. Still open."""
145155 nonce_ledger = MemoryNonceLedger ()
146156 effects = []
147157
@@ -157,44 +167,113 @@ def mutate(record):
157167 result = execute_valid (gate )
158168
159169 assert effects == ["mutation-bound" ]
160- assert nonce_ledger .consumed == set ()
161- assert nonce_ledger .rolled_back == [("nonce-1" , "decision-1" )]
170+ # Issue #10 fixed: audit failure returns controlled result, not exception.
171+ # Nonce rollback no longer occurs on the audit-failure path because
172+ # _finish() catches the audit exception directly.
173+ # The nonce was consumed; mutation happened; audit failed.
174+ # This is the documented open gap for #9 (atomic commit boundary).
162175 assert result .allowed is False
163- assert result .code == "ROLLBACK:UNEXPECTED:RuntimeError"
176+ assert result .code == "ERROR:AUDIT_APPEND_FAILED:RuntimeError"
177+
164178
179+ # ---------------------------------------------------------------------------
180+ # Issue #10 hardening proofs: controlled audit failure on every exit path
181+ # ---------------------------------------------------------------------------
165182
166- def test_audit_failure_on_deny_path_can_escape_without_gate_result ():
183+ def test_audit_failure_on_deny_path_returns_controlled_gate_result ():
184+ """Issue #10 fix: audit failure on deny path returns GateResult, never raises."""
167185 gate = make_gate (audit = FailingAudit (), mutation_callback = lambda record : None )
168186
169- try :
170- gate .execute (
171- record = None ,
172- actor_id = "actor-1" ,
173- action = "email.send" ,
174- object_id = "user@example.com" ,
175- environment = "demo" ,
176- commit_hash = "a" * 40 ,
177- )
178- except RuntimeError as exc :
179- assert str (exc ) == "audit unavailable"
180- else :
181- raise AssertionError ("audit failure on deny path should currently escape" )
187+ result = gate .execute (
188+ record = None ,
189+ actor_id = "actor-1" ,
190+ action = "email.send" ,
191+ object_id = "user@example.com" ,
192+ environment = "demo" ,
193+ commit_hash = "a" * 40 ,
194+ )
195+
196+ assert isinstance (result .allowed , bool )
197+ assert result .allowed is False
198+ assert result .code == "ERROR:AUDIT_APPEND_FAILED:RuntimeError"
199+ assert result .decision_id is None
200+ assert result .timestamp is not None
201+
202+
203+ def test_audit_failure_on_scope_deny_returns_controlled_gate_result ():
204+ """Issue #10 fix: audit failure on scope mismatch deny returns GateResult."""
205+ gate = make_gate (audit = FailingAudit (), mutation_callback = lambda record : None )
206+
207+ result = gate .execute (
208+ record = valid_record (),
209+ actor_id = "wrong-actor" ,
210+ action = "email.send" ,
211+ object_id = "user@example.com" ,
212+ environment = "demo" ,
213+ commit_hash = "a" * 40 ,
214+ )
215+
216+ assert result .allowed is False
217+ assert result .code == "ERROR:AUDIT_APPEND_FAILED:RuntimeError"
182218
183219
184- def test_mutable_record_can_be_changed_after_validation_before_audit_receipt ():
220+ def test_audit_failure_on_allow_path_returns_controlled_gate_result ():
221+ """Issue #10 fix: audit failure on allow path returns GateResult, not True.
222+
223+ allowed must be False: the receipt was not written.
224+ """
225+ gate = make_gate (audit = FailingAudit (), mutation_callback = lambda record : None )
226+
227+ result = execute_valid (gate )
228+
229+ assert result .allowed is False
230+ assert result .code == "ERROR:AUDIT_APPEND_FAILED:RuntimeError"
231+
232+
233+ # ---------------------------------------------------------------------------
234+ # Issue #11 hardening proofs: pre-mutation snapshot protects audit receipt
235+ # ---------------------------------------------------------------------------
236+
237+ def test_pre_mutation_snapshot_protects_audit_receipt ():
238+ """Issue #11 fix: callback mutation of record does not alter audit receipt."""
185239 audit = MemoryAudit ()
186240 record = valid_record ()
187241 effects = []
188242
189- def mutate (record ):
190- effects .append (("sent" , record ["object_id" ]))
191- record ["object_id" ] = "attacker@example.com"
243+ def mutate (rec ):
244+ effects .append (("sent" , rec ["object_id" ]))
245+ # Attempt to mutate the record passed to the callback.
246+ # Before fix: this would alter what _finish() logged.
247+ # After fix: _finish() uses the pre-mutation snapshot, so this has
248+ # no effect on the audit receipt.
249+ rec ["object_id" ] = "attacker@example.com"
192250
193251 gate = make_gate (audit = audit , mutation_callback = mutate )
194252
195253 result = execute_valid (gate , record = record )
196254
197255 assert result .allowed is True
198256 assert effects == [("sent" , "user@example.com" )]
199- assert audit .events [0 ]["attempted" ]["object_id" ] == "user@example.com"
200- assert audit .events [0 ]["record_scope" ]["object_id" ] == "attacker@example.com"
257+ # Audit receipt must reflect the authorised record, not the callback drift.
258+ assert audit .events [0 ]["record_scope" ]["object_id" ] == "user@example.com"
259+
260+
261+ def test_pre_mutation_snapshot_is_independent_of_original_dict ():
262+ """Issue #11 fix: snapshot is a copy; original dict changes do not affect receipt."""
263+ audit = MemoryAudit ()
264+ record = dict (valid_record ())
265+
266+ def mutate (rec ):
267+ rec ["actor_id" ] = "mutated-actor"
268+ rec ["action" ] = "mutated-action"
269+ rec ["policy_version" ] = "mutated-version"
270+
271+ gate = make_gate (audit = audit , mutation_callback = mutate )
272+
273+ result = execute_valid (gate , record = record )
274+
275+ assert result .allowed is True
276+ scope = audit .events [0 ]["record_scope" ]
277+ assert scope ["actor_id" ] == "actor-1"
278+ assert scope ["action" ] == "email.send"
279+ assert scope ["policy_version" ] == "v1"
0 commit comments