2026-08-24 00:46:34 -03:00
""" HUX-02 memory review repairs: edit shaping (F3), stale-write resurrection (F7), idempotency race (F13b).
Security obligations exercised : SO - 21 ( deny topics never reach memory , even
through an edit ) , SO - 22 ( no_store and forget are content - free and final ) ,
SO - 23 ( a forgotten entry never retrieves again ) , SO - 28 ( private mode refuses
memory writes ) , SO - 44 ( revision checks hold under concurrency ) .
"""
from __future__ import annotations
import json
import sys
import threading
from pathlib import Path
ROOT = Path ( __file__ ) . resolve ( ) . parents [ 2 ]
FOUNDATION = ROOT / " dockerfiles " / " hermes-hux-foundation "
if str ( FOUNDATION ) not in sys . path :
sys . path . insert ( 0 , str ( FOUNDATION ) )
from hux import contracts , identity , memory , privacy , store # noqa: E402
from hux . errors import Conflict # noqa: E402
from hux . server import build_router # noqa: E402
SCHEMAS = contracts . load_all ( )
2026-08-24 01:24:14 -03:00
HEADERS = { " X-Hermes-Tenant-Identity " : " slot-3 " , " X-Hux-Subject " : " usr_0123456789abcdef " , " X-Hux-Surface " : " chat " , " X-Hux-Relay-Key " : " rk " }
2026-08-24 00:46:34 -03:00
ALL_ON = " , " . join ( card [ " flag " ] for card in contracts . load_flags ( ) [ " cards " ] )
USER = { " proposed_by " : " user " , " approval_mode " : " automatic " }
def ident ( ) - > identity . Identity :
return identity . Identity ( tenant_slot = " slot-3 " , subject = " usr_0123456789abcdef " , surface = " chat " , trust = " router " )
def router_for ( tmp_path ) :
2026-08-24 01:24:14 -03:00
return build_router ( tmp_path , { " HUX_FLAGS " : ALL_ON , " HUX_ROUTER_KEY " : " rk " } )
2026-08-24 00:46:34 -03:00
def call ( router , method , path , body = None , headers = None ) :
raw = json . dumps ( body ) . encode ( ) if body is not None else b " "
response = router . dispatch ( method , path , { * * HEADERS , * * ( headers or { } ) } , raw )
return response . status , response . body
def tenant ( tmp_path ) - > store . TenantStore :
return store . TenantStore ( tmp_path , ident ( ) )
def conversation ( router ) - > str :
status , body = call ( router , " POST " , " /hux/v1/conversations " , { " title " : " tea " } )
assert status == 201
return body [ " id " ]
def active ( router , conv , content = " I like tea " ) - > dict :
status , body = call ( router , " POST " , " /hux/v1/memory " , { " content " : content , " conversation_id " : conv , * * USER } )
assert ( status , body [ " status " ] ) == ( 201 , " active " ) , body
return body
def valid ( record ) - > dict :
assert contracts . validate_record ( record , SCHEMAS ) == [ ] , record
return record
# --- F3: edit goes through the same shaping as a proposal ---------------------------
def test_f3_edit_with_deny_topic_becomes_no_store_and_leaves_the_original ( tmp_path ) :
""" F3 / SO-21 / SO-22: a location + minors edit is refused content-free; the old entry is not superseded. """
router = router_for ( tmp_path )
conv = conversation ( router )
original = active ( router , conv )
status , body = call ( router , " POST " , f " /hux/v1/memory/ { original [ ' id ' ] } /edit " , { " content " : " my home address is 12 Main Street; my daughter is 6 " } , { " If-Match " : " 1 " } )
assert status == 202 and valid ( body ) [ " status " ] == " no_store " and body [ " content " ] == " " and " topic " not in body
assert body [ " sensitivity " ] == " restricted " and body [ " retrievable " ] is False and " supersedes " not in body
assert body [ " audit " ] [ 0 ] [ " note " ] == " restricted; topic=location " and " Main Street " not in json . dumps ( body )
assert body [ " id " ] in memory . tombstoned ( tenant ( tmp_path ) )
status , again = call ( router , " GET " , f " /hux/v1/memory/ { original [ ' id ' ] } " )
assert status == 200 and again [ " status " ] == " active " and again [ " revision " ] == 1 and again [ " content " ] == " I like tea "
assert [ hit [ " content " ] for hit in memory . retrieve ( tenant ( tmp_path ) , [ " address " ] ) ] == [ ]
assert [ hit [ " id " ] for hit in memory . retrieve ( tenant ( tmp_path ) , [ " tea " ] ) ] == [ original [ " id " ] ]
kinds = [ row [ " kind " ] for row in tenant ( tmp_path ) . read ( " events " , conv ) ]
assert kinds [ - 1 ] == " memory.suppressed "
def test_f3_edit_with_sensitive_content_is_proposed_with_ask ( tmp_path ) :
""" F3 / SO-21: the sensitivity floor from detected topics applies to edits, so a health edit waits for approval. """
router = router_for ( tmp_path )
conv = conversation ( router )
original = active ( router , conv )
status , body = call ( router , " POST " , f " /hux/v1/memory/ { original [ ' id ' ] } /edit " , { " content " : " I take lithium for my diagnosis " } )
assert status == 200 and valid ( body ) [ " status " ] == " proposed " and body [ " approval_mode " ] == " ask "
assert body [ " sensitivity " ] == " sensitive " and body [ " topic " ] == " health " and body [ " retrievable " ] is False
assert body [ " supersedes " ] == original [ " id " ] and [ row [ " action " ] for row in body [ " audit " ] ] == [ " edited " ]
assert call ( router , " GET " , f " /hux/v1/memory/ { original [ ' id ' ] } " ) [ 1 ] [ " status " ] == " forgotten "
def test_f3_edit_scrubs_secrets_and_floors_credentials ( tmp_path ) :
""" F3 / SO-07: a secret in edited content is scrubbed and the credentials topic makes the edit no_store. """
router = router_for ( tmp_path )
conv = conversation ( router )
original = active ( router , conv )
status , body = call ( router , " POST " , f " /hux/v1/memory/ { original [ ' id ' ] } /edit " , { " content " : " token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 " } )
assert status == 202 and body [ " status " ] == " no_store " and body [ " sensitivity " ] == " restricted "
leaked = [ path for path in tmp_path . rglob ( " *.json* " ) if " ghp_ABCDEFGHIJ " in path . read_text ( ) ]
assert leaked == [ ]
def test_f3_edit_refuses_private_mode_and_declines_when_memory_disabled ( tmp_path ) :
""" F3 / SO-28: private mode is a 403 for edits too; disable_memory_here turns the edit into no_store. """
router = router_for ( tmp_path )
conv = conversation ( router )
original = active ( router , conv )
privacy . set_flag ( tenant ( tmp_path ) , conv , " memory_disabled " , True )
status , body = call ( router , " POST " , f " /hux/v1/memory/ { original [ ' id ' ] } /edit " , { " content " : " I like coffee " } )
assert status == 202 and body [ " status " ] == " no_store " and body [ " audit " ] [ 0 ] [ " note " ] . startswith ( " memory_disabled " )
assert call ( router , " GET " , f " /hux/v1/memory/ { original [ ' id ' ] } " ) [ 1 ] [ " status " ] == " active "
privacy . set_flag ( tenant ( tmp_path ) , conv , " memory_disabled " , False )
assert call ( router , " PATCH " , f " /hux/v1/conversations/ { conv } " , { " mode " : " private " } ) [ 0 ] == 200
status , body = call ( router , " POST " , f " /hux/v1/memory/ { original [ ' id ' ] } /edit " , { " content " : " I like coffee " } )
assert status == 403 and body [ " code " ] == " forbidden "
assert call ( router , " GET " , f " /hux/v1/memory/ { original [ ' id ' ] } " ) [ 1 ] [ " status " ] == " active "
def test_f3_edit_still_needs_content ( tmp_path ) :
router = router_for ( tmp_path )
original = active ( router , conversation ( router ) )
assert call ( router , " POST " , f " /hux/v1/memory/ { original [ ' id ' ] } /edit " , { " content " : " " } ) [ 0 ] == 400
assert call ( router , " POST " , f " /hux/v1/memory/ { original [ ' id ' ] } /edit " , { " content " : " x " * 2001 } ) [ 0 ] == 400
# --- F7: a stale record cannot resurrect a forgotten entry ------------------------
def test_f7_stale_approve_after_forget_is_a_conflict ( tmp_path , monkeypatch ) :
""" F7 / SO-22 / SO-44: approve holding a pre-reject snapshot (the a2-B interleaving) is refused and the entry stays rejected. """
router = router_for ( tmp_path )
conv = conversation ( router )
status , proposed = call ( router , " POST " , " /hux/v1/memory " , { " content " : " I take lithium daily " , " conversation_id " : conv , " proposed_by " : " user " , " approval_mode " : " ask " } )
assert ( status , proposed [ " status " ] ) == ( 201 , " proposed " )
stale = dict ( proposed )
assert call ( router , " POST " , f " /hux/v1/memory/ { proposed [ ' id ' ] } /reject " ) [ 1 ] [ " status " ] == " rejected "
monkeypatch . setattr ( memory , " load " , lambda store , memory_id , now = None : stale )
status , body = call ( router , " POST " , f " /hux/v1/memory/ { proposed [ ' id ' ] } /approve " )
assert status == 409 and body [ " code " ] == " conflict "
monkeypatch . undo ( )
status , after = call ( router , " GET " , f " /hux/v1/memory/ { proposed [ ' id ' ] } " )
assert after [ " status " ] == " rejected " and after [ " content " ] == " " and after [ " revision " ] == 2
assert memory . retrieve ( tenant ( tmp_path ) , [ " lithium " ] ) == [ ]
assert call ( router , " GET " , " /hux/v1/memory/export " ) [ 1 ] [ " items " ] == [ ]
def test_f7_stale_remove_retrieval_cannot_overwrite_a_forget ( tmp_path , monkeypatch ) :
""" F7: the unconditional retrieval branch re-reads the entry under the lock, so the a5 race ends in 409 and no restore works. """
router = router_for ( tmp_path )
conv = conversation ( router )
entry = active ( router , conv , " I take lithium daily " )
stale = dict ( entry )
assert call ( router , " POST " , f " /hux/v1/memory/ { entry [ ' id ' ] } /forget " ) [ 1 ] [ " status " ] == " forgotten "
monkeypatch . setattr ( memory , " load " , lambda store , memory_id , now = None : stale )
assert call ( router , " POST " , f " /hux/v1/memory/ { entry [ ' id ' ] } /remove_retrieval " ) [ 0 ] == 409
monkeypatch . undo ( )
assert call ( router , " POST " , f " /hux/v1/memory/ { entry [ ' id ' ] } /restore_retrieval " ) [ 0 ] == 409
assert call ( router , " GET " , f " /hux/v1/memory/ { entry [ ' id ' ] } " ) [ 1 ] [ " status " ] == " forgotten "
assert memory . retrieve ( tenant ( tmp_path ) , [ " lithium " ] ) == [ ]
def test_f7_transition_checks_if_match_against_the_fresh_copy ( tmp_path ) :
""" F7 / SO-44: If-Match is compared with the stored revision, not the caller ' s stale snapshot. """
router = router_for ( tmp_path )
entry = active ( router , conversation ( router ) )
s = tenant ( tmp_path )
memory . _set_retrievable ( s , entry , False , { " type " : " user " , " id " : " usr_0123456789abcdef " } , 1 )
try :
memory . _transition ( s , ident ( ) , entry , " forgotten " , " forgotten " , { " type " : " user " , " id " : " usr_0123456789abcdef " } , expected = 1 )
except Conflict as error :
assert " revision 1 does not match current revision 2 " in str ( error )
else :
raise AssertionError ( " stale If-Match must conflict " )
assert memory . load ( s , entry [ " id " ] ) [ " status " ] == " active "
stored = memory . _transition ( s , ident ( ) , entry , " forgotten " , " forgotten " , { " type " : " user " , " id " : " usr_0123456789abcdef " } )
assert stored [ " status " ] == " forgotten " and stored [ " revision " ] == 3
# --- F13b: Idempotency-Key is checked under the lock ------------------------------
def test_f13b_concurrent_proposals_with_one_key_create_exactly_one_record ( tmp_path ) :
""" F13b: eight threads racing one Idempotency-Key produce one memory entry; seven are 200 replays. """
router = router_for ( tmp_path )
results : list [ tuple [ int , str ] ] = [ ]
barrier = threading . Barrier ( 8 )
def propose ( ) - > None :
barrier . wait ( 5 )
status , body = call ( router , " POST " , " /hux/v1/memory " , { " content " : " I like tea " , * * USER } , { " Idempotency-Key " : " same-key-0001 " } )
results . append ( ( status , body [ " id " ] ) )
threads = [ threading . Thread ( target = propose ) for _ in range ( 8 ) ]
for thread in threads :
thread . start ( )
for thread in threads :
thread . join ( 10 )
assert sorted ( status for status , _ in results ) == [ 200 ] * 7 + [ 201 ]
assert len ( { memory_id for _ , memory_id in results } ) == 1
assert len ( call ( router , " GET " , " /hux/v1/memory " ) [ 1 ] [ " items " ] ) == 1
assert len ( tenant ( tmp_path ) . read ( memory . IDEM_FAMILY , " keys " ) ) == 1
def test_memory_module_stays_under_500_lines ( ) :
assert len ( ( FOUNDATION / " hux " / " memory.py " ) . read_text ( ) . splitlines ( ) ) < = 500