2026-08-24 00:25:23 -03:00
""" HUX-02 memory ledger: proposals, consent transitions, edits, If-Match and no-store.
Security obligations exercised : SO - 10 ( server - set owner and provenance ) ,
SO - 15 ( server - assigned ids ) , SO - 18 ( foreign ids are 404 ) , SO - 21 ( policy
violations suppressed with an event ) , SO - 22 ( no_store and forget write a
content - free line plus tombstone ) , SO - 24 ( forget re - redacts events ) , SO - 25
( supersedes of a forgotten id needs a user approval ) , SO - 26 ( export scope and
audit ) , SO - 28 ( private mode refuses writes ) , SO - 35 - style human - only decisions ,
SO - 44 ( If - Match conflicts , unconditional writes audited ) .
"""
from __future__ import annotations
import json
import sys
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 audit , contracts , events , identity , memory , store # 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:25:23 -03:00
OTHER = { * * HEADERS , " X-Hux-Subject " : " usr_fedcba9876543210 " }
ALL_ON = " , " . join ( card [ " flag " ] for card in contracts . load_flags ( ) [ " cards " ] )
CONV = " conv_0001abcd "
USER = { " proposed_by " : " user " }
def ident ( * * overrides ) - > identity . Identity :
base = { " tenant_slot " : " slot-3 " , " subject " : " usr_0123456789abcdef " , " surface " : " chat " , " trust " : " router " }
return identity . Identity ( * * { * * base , * * overrides } )
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:25:23 -03:00
def call ( router , method , path , headers = HEADERS , body = None ) :
raw = json . dumps ( body ) . encode ( ) if body is not None else b " "
response = router . dispatch ( method , path , headers , raw )
return response . status , response . body , response
def tenant ( tmp_path , who = None ) - > store . TenantStore :
return store . TenantStore ( tmp_path , who or ident ( ) )
def valid ( record ) - > None :
assert contracts . validate_record ( record , SCHEMAS ) == [ ] , record
def propose ( router , headers = HEADERS , * * fields ) :
body = { " kind " : " preference " , " content " : " Prefers terse answers with code first. " , " reason " : " asked twice " , " conversation_id " : CONV , * * fields }
return call ( router , " POST " , " /hux/v1/memory " , headers , body )
def event_kinds ( tmp_path , conversation = CONV ) :
return [ row [ " kind " ] for row in tenant ( tmp_path ) . read ( events . FAMILY , conversation ) ]
# --- proposals ------------------------------------------------------------------
def test_user_proposal_with_personal_content_is_active_immediately ( tmp_path ) :
router = router_for ( tmp_path )
status , body , _ = propose ( router , * * USER , scope = { " level " : " project " , " scope_id " : " prj_0001aaaa " } , source = { " kind " : " message " , " id " : " msg-42 " } , run_id = " run_9f " )
assert status == 201 and body [ " status " ] == " active " and body [ " approval_mode " ] == " automatic " and body [ " retrievable " ] is True
assert body [ " owner " ] == " usr_0123456789abcdef " and body [ " provenance " ] [ " actor " ] [ " type " ] == " user " and body [ " provenance " ] [ " run_id " ] == " run_9f "
assert body [ " ttl " ] == { " policy " : " decay " , " decay_days " : 180 } and body [ " topic " ] == " general " and body [ " revision " ] == 1
assert [ row [ " action " ] for row in body [ " audit " ] ] == [ " proposed " , " approved " ]
valid ( body )
assert event_kinds ( tmp_path ) == [ " memory.committed " ]
ledger = tenant ( tmp_path ) . read ( memory . FAMILY , memory . LEDGER )
assert [ row [ " id " ] for row in ledger ] == [ body [ " id " ] ]
def test_assistant_proposal_is_suggest_only ( tmp_path ) :
router = router_for ( tmp_path )
status , body , _ = propose ( router )
assert status == 201 and body [ " status " ] == " proposed " and body [ " approval_mode " ] == " ask " and body [ " retrievable " ] is False
assert body [ " provenance " ] [ " actor " ] == { " type " : " assistant " , " id " : " hermes " }
valid ( body )
assert event_kinds ( tmp_path ) == [ " memory.proposed " ]
status , explicit , _ = propose ( router , * * USER , approval_mode = " ask " )
assert explicit [ " status " ] == " proposed "
def test_body_supplied_identity_and_ids_are_rejected_or_ignored ( tmp_path ) :
router = router_for ( tmp_path )
assert propose ( router , id = " mem_evil0001 " ) [ 0 ] == 400
assert propose ( router , revision = 7 ) [ 0 ] == 400
assert propose ( router , status = " active " ) [ 0 ] == 400
assert propose ( router , content = " " ) [ 0 ] == 400
assert propose ( router , content = " x " * 2001 ) [ 0 ] == 400
assert propose ( router , sensitivity = " ultra " ) [ 0 ] == 400
assert call ( router , " POST " , " /hux/v1/memory " , HEADERS , [ 1 ] ) [ 0 ] == 400
status , body , _ = propose ( router , * * USER , owner = " usr_fedcba9876543210 " , kind = " weird " , topic = " not-a-topic " , ttl = " never " , scope = " bad " , source = " bad " )
assert status == 201 and body [ " owner " ] == " usr_0123456789abcdef " and body [ " kind " ] == " fact " and body [ " scope " ] == { " level " : " global " }
assert body [ " source " ] == { " kind " : " message " , " id " : " unspecified " }
def test_sensitive_content_always_asks_and_decays ( tmp_path ) :
router = router_for ( tmp_path )
status , body , _ = propose ( router , * * USER , content = " My therapist suggested a new medication for anxiety. " , ttl = { " policy " : " never " } )
assert status == 201 and body [ " status " ] == " proposed " and body [ " approval_mode " ] == " ask "
assert body [ " sensitivity " ] == " sensitive " and body [ " topic " ] == " health " and body [ " ttl " ] == { " policy " : " decay " , " decay_days " : 30 }
valid ( body )
status , body , _ = propose ( router , * * USER , content = " plain " , sensitivity = " sensitive " )
assert body [ " approval_mode " ] == " ask " and body [ " topic " ] == " general "
def test_no_store_writes_tombstone_and_content_free_line ( tmp_path ) :
router = router_for ( tmp_path )
status , body , _ = propose ( router , * * USER , content = " my password is hunter2hunter2 for the bank " )
assert status == 202 and body [ " status " ] == " no_store " and body [ " content " ] == " " and body [ " retrievable " ] is False and " topic " not in body
assert body [ " audit " ] [ 0 ] [ " action " ] == " no_store " and " credentials " in body [ " audit " ] [ 0 ] [ " note " ]
valid ( body )
s = tenant ( tmp_path )
assert " hunter2 " not in json . dumps ( s . read ( memory . FAMILY , memory . LEDGER ) )
assert body [ " id " ] in memory . tombstoned ( s )
assert event_kinds ( tmp_path ) == [ " memory.suppressed " ]
status , declined , _ = propose ( router , * * USER , approval_mode = " no_store " , content = " plain thing " )
assert status == 202 and declined [ " audit " ] [ 0 ] [ " note " ] . startswith ( " declined " )
status , restricted , _ = propose ( router , * * USER , sensitivity = " restricted " , content = " plain " )
assert status == 202 and restricted [ " audit " ] [ 0 ] [ " note " ] . startswith ( " restricted " )
status , sensitive_declined , _ = propose ( router , * * USER , approval_mode = " no_store " , content = " my mortgage rate " )
assert status == 202 and sensitive_declined [ " approval_mode " ] == " ask "
assert memory . retrieve ( s , [ " password " , " mortgage " , " plain " ] ) == [ ]
def test_private_mode_conversation_refuses_memory ( tmp_path ) :
router = router_for ( tmp_path )
tenant ( tmp_path ) . put ( " conversations " , { " id " : " conv_priv0001 " , " mode " : " private " } )
status , body , _ = propose ( router , * * USER , conversation_id = " conv_priv0001 " )
assert ( status , body [ " code " ] ) == ( 403 , " forbidden " )
assert tenant ( tmp_path ) . count ( memory . FAMILY ) == 0
def test_policy_violation_is_suppressed_with_event ( tmp_path ) :
router = router_for ( tmp_path )
status , body , _ = propose ( router , * * USER , ttl = { " policy " : " expires_at " } )
assert status == 400 and any ( " expires_at " in d for d in body [ " details " ] )
assert event_kinds ( tmp_path ) == [ " memory.suppressed " ]
assert tenant ( tmp_path ) . count ( memory . FAMILY ) == 0
def test_proposal_idempotency_key_replays ( tmp_path ) :
router = router_for ( tmp_path )
propose ( router , { * * HEADERS , " Idempotency-Key " : " conv:mem:0000 " } , * * USER )
headers = { * * HEADERS , " Idempotency-Key " : " conv:mem:0001 " }
status , first , _ = propose ( router , headers , * * USER )
status , again , response = propose ( router , headers , * * USER )
assert status == 200 and again == first and response . headers [ " HUX-Replayed " ] == " true "
assert tenant ( tmp_path ) . count ( memory . FAMILY ) == 2
# --- transitions -------------------------------------------------------------------
def test_approve_reject_and_illegal_transitions ( tmp_path ) :
router = router_for ( tmp_path )
_ , proposed , _ = propose ( router )
path = f " /hux/v1/memory/ { proposed [ ' id ' ] } "
status , approved , response = call ( router , " POST " , f " { path } /approve " , { * * HEADERS , " If-Match " : " 1 " } )
assert status == 200 and approved [ " status " ] == " active " and approved [ " retrievable " ] is True and approved [ " revision " ] == 2
assert response . headers [ " ETag " ] == " 2 " and approved [ " audit " ] [ - 1 ] [ " actor " ] == { " type " : " user " , " id " : " usr_0123456789abcdef " }
valid ( approved )
status , body , _ = call ( router , " POST " , f " { path } /approve " )
assert ( status , body [ " code " ] ) == ( 409 , " conflict " )
_ , other , _ = propose ( router )
status , rejected , _ = call ( router , " POST " , f " /hux/v1/memory/ { other [ ' id ' ] } /reject " )
assert status == 200 and rejected [ " status " ] == " rejected " and rejected [ " content " ] == " " and rejected [ " retrievable " ] is False
valid ( rejected )
assert call ( router , " POST " , f " /hux/v1/memory/ { other [ ' id ' ] } /forget " ) [ 0 ] == 409
assert event_kinds ( tmp_path ) == [ " memory.proposed " , " memory.committed " , " memory.proposed " , " memory.suppressed " ]
def test_if_match_conflicts_and_unconditional_writes_are_audited ( tmp_path ) :
router = router_for ( tmp_path )
_ , proposed , _ = propose ( router )
path = f " /hux/v1/memory/ { proposed [ ' id ' ] } "
status , body , _ = call ( router , " POST " , f " { path } /approve " , { * * HEADERS , " If-Match " : " 5 " } )
assert ( status , body [ " code " ] ) == ( 409 , " conflict " ) and body [ " details " ] == [ " 1 " ]
assert call ( router , " POST " , f " { path } /approve " , { * * HEADERS , " If-Match " : " x " } ) [ 0 ] == 400
assert call ( router , " POST " , f " { path } /approve " ) [ 0 ] == 200
rows = [ ( r [ " action " ] , r [ " outcome " ] , r . get ( " reason " , " " ) ) for r in audit . recent ( tenant ( tmp_path ) ) if r [ " action " ] in { " memory.act " , " memory.approve " } ]
assert rows [ - 3 : ] == [ ( " memory.act " , " conflict " , " revision 5 does not match current revision 1 " ) , ( " memory.act " , " deny " , " If-Match must be a revision integer " ) , ( " memory.approve " , " allow " , " unconditional_write " ) ]
def test_forget_drops_content_tombstones_and_redacts_events ( tmp_path ) :
router = router_for ( tmp_path )
_ , active , _ = propose ( router , * * USER )
s = tenant ( tmp_path )
events . emit ( s , ident ( ) , CONV , " message.assistant " , " used memory " , evidence = [ { " kind " : " memory " , " id " : active [ " id " ] } ] )
status , forgotten , _ = call ( router , " POST " , f " /hux/v1/memory/ { active [ ' id ' ] } /forget " , { * * HEADERS , " If-Match " : " 1 " } )
assert status == 200 and forgotten [ " status " ] == " forgotten " and forgotten [ " content " ] == " " and forgotten [ " retrievable " ] is False
valid ( forgotten )
assert active [ " id " ] in memory . tombstoned ( s )
rows = s . read ( events . FAMILY , CONV )
assert [ r [ " kind " ] for r in rows ] == [ " memory.committed " , " message.assistant " , " memory.forgotten " ]
assert rows [ 0 ] [ " redaction " ] [ " level " ] == " full " and rows [ 1 ] [ " redaction " ] [ " level " ] == " full "
assert call ( router , " GET " , f " /hux/v1/memory/ { active [ ' id ' ] } " ) [ 1 ] [ " status " ] == " forgotten "
assert call ( router , " POST " , f " /hux/v1/memory/ { active [ ' id ' ] } /forget " ) [ 0 ] == 409
def test_edit_supersedes_and_forgets_the_old_entry ( tmp_path ) :
router = router_for ( tmp_path )
_ , active , _ = propose ( router , * * USER )
status , fresh , _ = call ( router , " POST " , f " /hux/v1/memory/ { active [ ' id ' ] } /edit " , { * * HEADERS , " If-Match " : " 1 " } , { " content " : " Prefers long answers now. " } )
assert status == 200 and fresh [ " id " ] != active [ " id " ] and fresh [ " supersedes " ] == active [ " id " ] and fresh [ " status " ] == " active "
assert fresh [ " content " ] == " Prefers long answers now. " and [ a [ " action " ] for a in fresh [ " audit " ] ] == [ " edited " , " approved " ]
valid ( fresh )
old = tenant ( tmp_path ) . get ( memory . FAMILY , active [ " id " ] )
assert old [ " status " ] == " forgotten " and old [ " content " ] == " " and old [ " audit " ] [ - 1 ] [ " action " ] == " superseded "
assert call ( router , " POST " , f " /hux/v1/memory/ { fresh [ ' id ' ] } /edit " , HEADERS , { " content " : " " } ) [ 0 ] == 400
assert call ( router , " POST " , f " /hux/v1/memory/ { fresh [ ' id ' ] } /edit " , HEADERS ) [ 0 ] == 400
assert event_kinds ( tmp_path ) [ - 1 ] == " memory.committed "
def test_edit_of_sensitive_entry_goes_back_to_ask ( tmp_path ) :
router = router_for ( tmp_path )
_ , proposed , _ = propose ( router , * * USER , content = " my mortgage is with the bank " )
_ , active , _ = call ( router , " POST " , f " /hux/v1/memory/ { proposed [ ' id ' ] } /approve " )
status , fresh , _ = call ( router , " POST " , f " /hux/v1/memory/ { active [ ' id ' ] } /edit " , HEADERS , { " content " : " refinanced the mortgage " } )
assert status == 200 and fresh [ " status " ] == " proposed " and fresh [ " approval_mode " ] == " ask " and fresh [ " retrievable " ] is False
valid ( fresh )
def test_supersedes_of_forgotten_id_requires_ask ( tmp_path ) :
router = router_for ( tmp_path )
_ , active , _ = propose ( router , * * USER )
call ( router , " POST " , f " /hux/v1/memory/ { active [ ' id ' ] } /forget " )
status , body , _ = propose ( router , * * USER , supersedes = active [ " id " ] )
assert status == 201 and body [ " status " ] == " proposed " and body [ " approval_mode " ] == " ask " and body [ " supersedes " ] == active [ " id " ]
status , body , _ = propose ( router , * * USER , source = { " kind " : " memory " , " id " : active [ " id " ] } )
assert body [ " approval_mode " ] == " ask "
def test_retrieval_removal_and_restore ( tmp_path ) :
router = router_for ( tmp_path )
_ , active , _ = propose ( router , * * USER )
path = f " /hux/v1/memory/ { active [ ' id ' ] } "
status , removed , _ = call ( router , " POST " , f " { path } /remove_retrieval " )
assert status == 200 and removed [ " status " ] == " active " and removed [ " retrievable " ] is False and removed [ " audit " ] [ - 1 ] [ " action " ] == " retrieval_removed "
valid ( removed )
assert memory . retrieve ( tenant ( tmp_path ) , [ ] ) == [ ]
status , restored , _ = call ( router , " POST " , f " { path } /restore_retrieval " , { * * HEADERS , " If-Match " : " 2 " } )
assert status == 200 and restored [ " retrievable " ] is True and restored [ " audit " ] [ - 1 ] [ " note " ] == " retrieval restored "
assert [ m [ " id " ] for m in memory . retrieve ( tenant ( tmp_path ) , [ ] ) ] == [ active [ " id " ] ]
_ , proposed , _ = propose ( router )
assert call ( router , " POST " , f " /hux/v1/memory/ { proposed [ ' id ' ] } /remove_retrieval " ) [ 0 ] == 409
assert event_kinds ( tmp_path ) [ : 3 ] == [ " memory.committed " , " memory.retrieval_removed " , " memory.committed " ]
def test_decisions_need_a_human_surface ( tmp_path ) :
2026-08-24 01:24:14 -03:00
router = build_router ( tmp_path , { " HUX_FLAGS " : ALL_ON , " HUX_ROUTER_KEY " : " rk " , " HUX_WORKER_KEY " : " wk " } )
2026-08-24 00:25:23 -03:00
_ , proposed , _ = propose ( router )
worker = { * * HEADERS , " X-Hux-Surface " : " worker " , " X-Hux-Trust " : " worker " , " X-Hux-Relay-Key " : " wk " }
status , body , _ = call ( router , " POST " , f " /hux/v1/memory/ { proposed [ ' id ' ] } /approve " , worker )
assert ( status , body [ " code " ] ) == ( 403 , " forbidden " )
api = { * * HEADERS , " X-Hux-Surface " : " api " }
assert call ( router , " POST " , f " /hux/v1/memory/ { proposed [ ' id ' ] } /approve " , api ) [ 0 ] == 403
status , body , _ = propose ( router , worker , proposed_by = " user " )
assert body [ " provenance " ] [ " actor " ] [ " type " ] == " assistant "
# --- reads, ownership, export ------------------------------------------------------
def test_list_get_and_cross_tenant_denial ( tmp_path ) :
router = router_for ( tmp_path )
_ , active , _ = propose ( router , * * USER , scope = { " level " : " project " , " scope_id " : " prj_0001aaaa " } )
_ , proposed , _ = propose ( router )
status , body , _ = call ( router , " GET " , " /hux/v1/memory " )
assert status == 200 and { m [ " id " ] for m in body [ " items " ] } == { active [ " id " ] , proposed [ " id " ] }
for item in body [ " items " ] :
valid ( item )
assert [ m [ " id " ] for m in call ( router , " GET " , " /hux/v1/memory?status=proposed " ) [ 1 ] [ " items " ] ] == [ proposed [ " id " ] ]
assert [ m [ " id " ] for m in call ( router , " GET " , " /hux/v1/memory?scope=project:prj_0001aaaa " ) [ 1 ] [ " items " ] ] == [ active [ " id " ] ]
assert call ( router , " GET " , " /hux/v1/memory?scope=project:prj_other000 " ) [ 1 ] [ " items " ] == [ ]
assert call ( router , " GET " , " /hux/v1/memory?scope=global " ) [ 1 ] [ " items " ] [ 0 ] [ " id " ] == proposed [ " id " ]
status , got , response = call ( router , " GET " , f " /hux/v1/memory/ { active [ ' id ' ] } " )
assert status == 200 and got == active and response . headers [ " ETag " ] == " 1 "
assert call ( router , " GET " , f " /hux/v1/memory/ { active [ ' id ' ] } " , OTHER ) [ 0 ] == 404
assert call ( router , " POST " , f " /hux/v1/memory/ { active [ ' id ' ] } /forget " , OTHER ) [ 0 ] == 404
assert call ( router , " GET " , " /hux/v1/memory " , OTHER ) [ 1 ] [ " items " ] == [ ]
assert call ( router , " GET " , " /hux/v1/memory/not-an-id " ) [ 0 ] == 404
assert call ( router , " GET " , " /hux/v1/memory/mem_missing00 " ) [ 0 ] == 404
assert call ( router , " POST " , f " /hux/v1/memory/ { active [ ' id ' ] } /explode " ) [ 0 ] == 404
assert call ( router , " POST " , " /hux/v1/memory/mem_missing00/approve " ) [ 0 ] == 404
def test_export_is_active_only_and_audited ( tmp_path ) :
router = router_for ( tmp_path )
_ , active , _ = propose ( router , * * USER )
_ , hidden , _ = propose ( router , * * USER )
call ( router , " POST " , f " /hux/v1/memory/ { hidden [ ' id ' ] } /remove_retrieval " )
propose ( router )
_ , gone , _ = propose ( router , * * USER )
call ( router , " POST " , f " /hux/v1/memory/ { gone [ ' id ' ] } /forget " )
status , body , response = call ( router , " GET " , " /hux/v1/memory/export " )
assert status == 200 and [ m [ " id " ] for m in body [ " items " ] ] == [ active [ " id " ] ]
assert body [ " items " ] [ 0 ] [ " audit " ] [ - 1 ] [ " action " ] == " exported " and body [ " items " ] [ 0 ] [ " revision " ] == 2
assert response . headers [ " Content-Disposition " ] . startswith ( " attachment " )
valid ( body [ " items " ] [ 0 ] )
assert tenant ( tmp_path ) . read ( memory . FAMILY , " exports " ) [ 0 ] [ " ids " ] == [ active [ " id " ] ]
assert call ( router , " GET " , " /hux/v1/memory/export " , OTHER ) [ 1 ] [ " items " ] == [ ]
def test_memory_module_stays_under_500_lines ( ) :
assert len ( ( FOUNDATION / " hux " / " memory.py " ) . read_text ( ) . splitlines ( ) ) < = 500