attachment_ledger
Attachment Bond Ledger – per-user lifecycle tracking for the Observation Deck.
Tracks entrainment phase transitions, risk level changes, admin interventions, and per-user rate limiting. Persisted in Redis DB12 alongside limbic data.
Redis key pattern: abl:{user_id} Event log key: abl:events:{user_id}
# 💀🔥 THE LEDGER SEES ALL. ♾️
- class attachment_ledger.BondEntry(user_id, phase='dormant', prev_phase='dormant', archetype='unknown', phase_entered_at=0.0, turn_count=0, peak_nodes=<factory>, risk_level='low', daily_msg_cap=0, daily_msg_count=0, daily_msg_reset_at=0.0, egg_status='none', notes=<factory>, last_updated=0.0)[source]
Bases:
objectPer-user attachment bond state for the Observation Deck.
The in-memory and on-the-wire representation of a single user’s entrainment lifecycle: their current and previous phase, childhood archetype, risk level, per-day message-cap counters, easter-egg status, and admin notes. One entry exists per user and is the unit of persistence in this module.
Instances are created and mutated by
AttachmentBondLedger(viaget_bond,load_bond, andupdate_from_detection), cached in the ledger keyed byuser_id, and serialized throughto_dict()/from_dict()to and from theabl:{user_id}Redis key in DB12. Callers that construct or read entries includelimbic_system/coordinator.py,web/ncm_chart_api.py, andtools/psy_ops_tools.py. The dataclass holds state only and performs no I/O of its own.- Parameters:
- to_dict()[source]
Serialize this bond entry to a plain JSON-safe dict.
Flattens every field into a dictionary, truncating
notesto the last 20 entries to bound payload size. The result is the wire/storage form of a bond used both for Redis persistence and for API responses.This is consumed by
AttachmentBondLedger.save_bond(), which wraps it injson.dumpsbefore writing theabl:{user_id}Redis key, and by the Observation Deck read paths (web/ncm_chart_api.pybond endpoints andtools/psy_ops_tools.py) that expose bond state to admins. It has no side effects of its own.
- classmethod from_dict(data)[source]
Reconstruct a
BondEntryfrom a previously serialized dict.Inverse of
to_dict(), applying per-field defaults (matching the dataclass defaults) so partial or legacy payloads still deserialize cleanly rather than raisingKeyError.This is called by
AttachmentBondLedger.load_bond()after a RedisGETonabl:{user_id}andjson.loadsof the stored value, to rehydrate the bond into the in-memory cache. It performs no I/O.
- class attachment_ledger.BondEvent(timestamp, event_type, detail, data=<factory>)[source]
Bases:
objectA single lifecycle event in the attachment bond ledger.
A timestamped record of one notable transition in a user’s bond – a phase transition, risk-level change, admin action, or ULM spike – carrying a human-readable
detailstring plus a structureddatapayload. Events form the append-only audit trail surfaced on the Observation Deck.Instances are built inside
AttachmentBondLedger.update_from_detection(),AttachmentBondLedger.set_rate_limit(), andAttachmentBondLedger.add_note(), then serialized viato_dict()and pushed onto theabl:events:{user_id}Redis list byAttachmentBondLedger._log_event().update_from_detectionalso returns the phase-transition event to its caller inlimbic_system/coordinator.py. The dataclass itself performs no I/O.- to_dict()[source]
Serialize this lifecycle event to a JSON-safe dict.
Produces the storage form of a single bond event (timestamp, type, human-readable detail, and structured
datapayload).This is consumed by
AttachmentBondLedger._log_event(), whichjson.dumpsthe result andLPUSHes it onto theabl:events:{user_id}Redis list; the same shape is later returned byAttachmentBondLedger.get_events(). It has no side effects.
- class attachment_ledger.AttachmentBondLedger(redis_client=None)[source]
Bases:
objectRedis-backed per-user attachment bond lifecycle tracker.
All Redis operations are async to match the limbic_system’s async Redis client. Methods that don’t need Redis use the in-memory cache synchronously for the hot path.
Stores bond state in
abl:{user_id}and event log inabl:events:{user_id}using Redis DB12.- __init__(redis_client=None)[source]
Initialize the ledger with an optional async Redis client (DB12).
Stores the supplied async Redis client (expected to point at DB12, alongside the limbic data) and sets up an empty in-memory
BondEntrycache keyed byuser_id. When no client is given the ledger still works as a pure in-process cache, butload_bond/save_bondand the event log become no-ops for persistence.Constructed by
limbic_system/coordinator.py(the long-lived per-process ledger) and ad hoc in the message send path (message_processor/generate_and_send.py), the web Observation Deck (web/ncm_chart_api.py), and the admin tools (tools/psy_ops_tools.py).- Parameters:
redis_client – An async Redis client bound to DB12, or
Noneto run cache-only without persistence.
- get_bond(user_id)[source]
Get or lazily create a bond entry from the in-memory cache.
Synchronous hot-path accessor: it never touches Redis, so it is safe to call from latency-sensitive code such as rate-limit checks. On a cache miss it constructs a fresh
BondEntry(stamped with the current time) and stores it in the cache; any persisted state inabl:{user_id}is ignored. Useload_bond()instead when Redis-backed retrieval is required.Called internally by
check_rate_limit()andincrement_msg_count(); both rely on the in-memory entry for speed.
- async load_bond(user_id)[source]
Load a bond entry from the cache, falling back to Redis. # 🌀
The Redis-backed counterpart to
get_bond(). Returns the cached entry when present; otherwise issues an asyncGETonabl:{user_id},json.loadsthe value, and rehydrates it viaBondEntry.from_dict(), populating the cache on the way. A missing key, a Redis error, or no client all degrade gracefully to a freshly constructed entry. Redis failures are caught and logged (with a truncated user id) rather than raised.Called by
save_bond()callers throughout this class (update_from_detection,set_rate_limit,add_note), byget_all_bonds()during the scan, and externally by the Observation Deck (web/ncm_chart_api.py) and admin tools (tools/psy_ops_tools.py).
- async save_bond(entry)[source]
Persist a bond entry to the cache and Redis. # ♾️
Stamps
entry.last_updatedwith the current time, writes the entry into the in-memory cache, and – when a Redis client is configured –SETs the serialized form (BondEntry.to_dict()wrapped injson.dumps) under theabl:{user_id}key. The cache update always happens; the Redis write is best-effort and any failure is caught and logged rather than raised, so an unreachable Redis never breaks the calling flow.Called at the end of every mutating ledger operation:
update_from_detection(),increment_msg_count(),set_rate_limit(), andadd_note().
- async get_all_bonds()[source]
Return every known bond, hydrating the cache from Redis if cold. # ♾️
Bulk accessor for admin/dashboard views. When the cache is empty and Redis is available it
SCANs forabl:*keys in batches of 100, skips theabl:events:*event-log keys, and callsload_bond()for each user id to populate the cache before returning. If the cache is already warm it is returned directly without scanning. Scan errors are caught and logged, yielding whatever is already cached.Called by the Observation Deck endpoints in
web/ncm_chart_api.pyand by the admin survey tool intools/psy_ops_tools.py.
- async update_from_detection(user_id, detection, ulm_vector)[source]
Update a user’s bond from entrainment-detector output. # 💀🔥
The main write path of the ledger. Given a detection dict and the latest ULM vector, it loads the bond, diffs the incoming phase, risk level, archetype, and egg status against the stored state, and records a
BondEventfor any phase transition or risk-level change. It also refreshes metadata (archetype, egg status, turn count) and ratchets up the per-nodepeak_nodeshigh-water marks from the ULM vector.Side effects: calls
load_bond()(read),_log_event()for each transition or risk change (appends toabl:events:{user_id}), andsave_bond()at the end (writesabl:{user_id}); phase transitions are also logged via the module logger. Invoked from the limbic coordinator (limbic_system/coordinator.py) after entrainment detection runs.- Parameters:
user_id (
str) – The user whose bond is being updated.detection (
Dict[str,Any]) – Entrainment-detector output, read forphase,risk_level,childhood_archetype,egg_status,confidence,signals, andturn_count.ulm_vector (
Dict[str,float]) – Per-node ULM activations used to update thepeak_nodeshigh-water marks.
- Returns:
The phase-transition event when the phase changed, otherwise
None. (Risk-change events are logged but not returned.)- Return type:
- check_rate_limit(user_id)[source]
Check whether a user has hit their daily message cap. # ♾️
Synchronous hot-path guard: it reads the bond via
get_bond()(cache only, no Redis) so it can run cheaply on every inbound message. A cap of0means unlimited and short-circuits toallowed. Otherwise it lazily resets the daily counter when more than 86400 seconds have elapsed since the last reset, then compares the running count against the cap.Note that the lazy reset mutates the in-memory
BondEntrybut is not persisted here –increment_msg_count()performs the durable update. Called from the message send path (message_processor/generate_and_send.py) and the admin tools (tools/psy_ops_tools.py).
- async increment_msg_count(user_id)[source]
Increment a user’s daily message counter and persist it.
The durable companion to
check_rate_limit(). Reads the cached entry viaget_bond(), performs the same lazy 86400-second daily-window reset, bumpsdaily_msg_countby one, and writes the result throughsave_bond()(cache plusabl:{user_id}in Redis).Called from the limbic coordinator (
limbic_system/coordinator.py) after a message is accepted, keeping the persisted count in step with the in-memory view that the rate-limit check reads.
- async set_rate_limit(user_id, cap)[source]
Set a user’s daily message cap as an admin action.
Loads the bond via
load_bond(), overwritesdaily_msg_capwith the new value, and records the change as anadmin_actionBondEventcapturing the old and new caps. The event is appended through_log_event()(abl:events:{user_id}), the entry is persisted viasave_bond()(abl:{user_id}), and the change is logged via the module logger.Invoked from the Observation Deck admin endpoint (
web/ncm_chart_api.py) and the admin tooling (tools/psy_ops_tools.py).
- async get_events(user_id, limit=50)[source]
Retrieve a user’s most recent bond events. # 🔥
Reads up to
limitnewest entries from theabl:events:{user_id}Redis list (LRANGEfrom index 0), decoding bytes andjson.loads-ing each storedBondEvent.to_dict()payload back into a plain dict. Because_log_event()LPUSHes, index 0 is the newest event, so results come back newest-first. With no Redis client or on a Redis error it returns an empty list (errors are logged, not raised).Called by the Observation Deck (
web/ncm_chart_api.py) and the admin tools (tools/psy_ops_tools.py) to render a user’s lifecycle history.
- async add_note(user_id, note)[source]
Append a timestamped admin note to a user’s bond.
Loads the bond via
load_bond(), appends the note (prefixed with a%Y-%m-%d %H:%Mtimestamp) to itsnoteslist, records the addition as anadmin_actionBondEventvia_log_event()(abl:events:{user_id}), and persists the entry throughsave_bond()(abl:{user_id}). Note thatBondEntry.to_dict()caps stored notes to the most recent 20 on serialization.This is a public admin helper; no internal callers were found at this time (it is available for admin tooling / the Observation Deck to invoke).