Stdlib per-tenant service: trusted-header identity (router/relay/worker, constant-time keys, slot pinned to the pod), fail-closed card flags with capability negotiation, tenant-scoped store (atomic writes, revisions, append-only ledgers, content-addressed blobs, manifest), audit outcome for every request, and the /hux/v1 pipeline that maps errors to hux.error.v1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
22 KiB
HUX foundation: on-PVC data model
hux-foundation is a Python stdlib service that runs inside every tenant pod
and owns the records defined in services/hermes/contracts/hux/. It has no
database and no storage shared between tenants. Everything it keeps lives on
the tenant's own home PVC (mounted at /opt/data, beside webui/,
workspace/, home/ and the Telegram media roots cache/images and
workspace). The Go router in services/hermes/router/ fronts it: the router
resolves Keycloak identity to a tenant slot and forwards the
common.schema.json#/$defs/identity tuple (tenant_slot, subject,
surface, trust) as headers, and enforces hux.foundation before any
request reaches this service. The service trusts nothing else about the
caller.
The conventions below are lifted from what already works in this repo:
hermes_model_routing._atomic_write (temp file + os.replace),
cli_lane_records.atomic_json (journal first, act second), and
execution_pool_store (idempotent add by digest, terminal rows only are ever
garbage-collected). SQLite is deliberately not used: the WebUI already owns
state.db on this volume and a second writer with its own WAL is one more
thing to recover; JSON and JSONL are greppable during an incident.
1. Directory layout
Tenant root is HUX_DATA_ROOT, default /opt/data/hux. The layout version is
a directory (v1), so a v2 layout can be built beside v1 and swapped by
MANIFEST.json, never by rewriting v1 in place.
/opt/data/hux/
MANIFEST.json hux.manifest.v1 (identity.schema.json): contract and layout versions
v1/
.lock advisory lock file (flock) proving single writer per tenant
users/<usr_hash>/ one subtree per hashed subject; nothing lives above it
profile.json {schema:"hux.user_profile.v1", memory_enabled, default_mode, revision}
events/
<conv_id>/
events.jsonl append-only hux.event.v1, one per line, ordered by seq
seq.json {next_seq, last_event_id, bytes, checkpointed_at}
idempotency.jsonl {idempotency_key, event_id, seq, at}; last 10k keys kept
memory/
ledger.jsonl append-only hux.memory.v1 snapshots; a status change appends a full record
tombstones.jsonl {memory_id, at, reason, purged:bool}; written on forget and on decay
index.json retrieval index (section 3), rebuilt from the ledger on demand
exports/<ts>.json GET /memory/export snapshots, audited, pruned after 7 days
projects/
<prj_id>.json hux.project.v1 + revision
index.json {items:[{id,name,tags,pinned,archived,updated_at}], revision}
conversations/
<conv_id>.json hux.conversation.v1 + revision
index.json {items:[{id,project_id,title,tags,pinned,archived,mode,branch,last_message_at}]}
search/
<conv_id>.json per-conversation term postings for message_text and artifact_titles
artifacts/
<art_id>.json hux.artifact.v1 + revision (versions[] is the version list)
index.json {items:[{id,type,title,conversation_id,project_id,current_version}]}
blobs/sha256/<aa>/<hash> content-addressed, immutable, 0400, fan-out on first two hex chars
blobs/refs.json {hash: [ "<art_id>@<version>", ...]} for safe purge
research/
sources/<src_id>.json hux.source.v1
passages/<psg_id>.json hux.passage.v1 (text + locator; hash is the dedupe key)
citations/<msg_id>.jsonl hux.citation.v1 per message, append-only
notebooks/<nb_id>.json hux.research_notebook.v1 + revision
index.json {by_conversation:{conv_id:[nb_id]}, by_message:{msg_id:count}, passage_hashes:{hash:psg_id}}
policy/
global.json hux.policy.v1 for scope level global
project/<prj_id>.json hux.policy.v1 per project
conversation/<conv_id>.json hux.policy.v1 per conversation
approvals/<apr_id>.json hux.approval.v1; terminal records never change again
approvals/pending.json {items:[apr_id], revision}; the queue the UI polls
receipts/<rcpt_id>.json hux.cancel_receipt.v1
receipts/by_run.json {run_id: rcpt_id}
suggestions/
state.json {schema:"hux.suggestion_states.v1", items:{sug_id: hux.suggestion_state.v1}, revision}
privacy/
notices.jsonl hux.privacy_notice.v1 as shown, append-only
conversation_topics.json {conv_id:{topic, first_seen, decay_at, memory_disabled:bool}}
forgotten.jsonl {conv_id, requested_at, purged_at, counts}
audit/
outcomes/<YYYY-MM-DD>.jsonl one hux.audit_outcome.v1 per read or mutation (section 6)
retention/<YYYY-MM-DD>.json hux.retention_audit.v1 per run
catalog/
suggestions.json hux.suggestion.v1 catalog shipped with the image (read-only copy)
privacy_policy.json hux.privacy_policy.v1 as served, with the policy version
The pod is single-tenant, so the users/ level is not multi-tenancy; it is
the guarantee that every path contains the hashed subject the router asserted
and that a second subject on the same pod (operator break-glass, future
household sharing) can never see another's tree without a distinct path.
Record ids are minted by the service: <prefix>_<ts36><6 random base32>, e.g.
evt_m0k3xq9a2bcd7f, so they sort by creation time and satisfy the
common.schema.json id pattern. hux.audit_outcome.v1 and
hux.user_profile.v1 are foundation-internal records; they follow the same
provenance and versioning rules but are not contracts the UI codes against.
2. Write semantics
Atomic document write. Serialise with json.dumps(sort_keys=True, ensure_ascii=False) plus a trailing newline, write to
.<name>.<pid>.<counter>.tmp in the same directory, flush(), os.fsync(fd),
os.replace(tmp, final), then os.fsync(dir_fd) so the rename itself is
durable. Temp files that survive a crash are deleted on open. Blobs are
written the same way under their hash; an existing blob is never rewritten
(compare size and hash; on mismatch refuse and audit).
Append. JSONL families open with O_APPEND, write the whole line in one
os.write, and fsync the file before the HTTP response is sent. The
controlling checkpoint (seq.json, index.json) is written atomically after
the append; a checkpoint may lag the log, never lead it.
Crash recovery. On first access to a family the store validates the log:
each line must parse and, for events, seq must equal the previous seq + 1.
A trailing partial line (no newline, or JSON error on the last line only) is
truncated to the last good newline; a bad line anywhere else is a hard error
(the family is marked read-only, an audit outcome is written, /healthz
reports degraded). The checkpoint is then reconciled from the log:
next_seq = last good seq + 1, indexes rebuilt if checkpointed_at is
older than the log mtime.
Idempotency. Every mutating request may carry Idempotency-Key
(common.schema.json#/$defs/idempotency_key, ^[A-Za-z0-9._:-]{8,120}$). For
events the key is stored in idempotency.jsonl; a replay returns the original
event (same id, same seq) with HTTP 200 and HUX-Replayed: true. For
documents the key is stored in the record's _meta.idempotency_keys (last 16)
and a replay returns the current record without bumping revision. Event
id is unique per tenant; an append whose id already exists in the last
checkpoint window is treated as a replay, not a duplicate.
Seq allocation. The HTTP server is a single process,
ThreadingHTTPServer. Locks are per path, held in a process-wide
dict[str, threading.RLock] guarded by one threading.Lock. The unit of
locking is the family directory for JSONL (events/<conv>, memory/) and the
document path for JSON. Seq is allocated inside the conversation lock:
read next_seq from memory (loaded from seq.json once per open), assign,
append, fsync, write seq.json, release. Since one process owns the PVC,
v1/.lock is taken with fcntl.flock(LOCK_EX|LOCK_NB) at start-up so an
accidental second replica fails fast instead of interleaving appends.
Optimistic concurrency. Every JSON document carries an integer
revision (starts at 1) beside the contract fields; it is served as the
ETag. PATCH/PUT require If-Match: <revision>; a mismatch returns
409 with a hux.error.v1 body (code: conflict, the current revision in
details) as common.schema.json#/$defs/revision specifies. A missing If-Match is accepted only when the
request carries an Idempotency-Key, and then the write is last-writer-wins
with the outcome audited as why: "unconditional_write". Indexes carry their
own revision and are rewritten under the family lock after the document.
Caps. Rejected with hux.error.v1 too_large (413, size) or conflict
(409, count):
- event line 64 KiB,
detail32 KiB; 50 000 events per conversation, then the conversation isarchivedand further appends need a branch - memory content 2 000 chars (schema), 5 000 live entries, ledger 64 MiB before compaction (section 4)
- conversation and project documents 256 KiB; 2 000 conversations, 200
projects,
artifact_ids500 (schema) - artifact blob 25 MiB (
tenantMediaLimitis 50 MiB; half leaves room for the Telegram path), 200 versions per artifact, 2 000 artifacts, blob store 2 GiB per tenant - passage text 4 000 chars, 10 000 sources, 50 000 passages
- audit outcomes are never capped by count; they rotate daily and age out
3. Indexes
Nothing is indexed that a scan cannot rebuild; every index.json is a cache
of its family and carries built_from (log bytes or document count) so a
stale index is detected and rebuilt rather than trusted.
Conversation search (GET /hux/v1/search?q=) covers exactly
project.schema.json#/$defs/search_index: title, tags, project_name
come from conversations/index.json joined with projects/index.json in
memory (a few thousand rows, scanned per query); message_text and
artifact_titles come from conversations/search/<conv_id>.json, a per
conversation bag of lowercased, punctuation-stripped terms with positions,
updated when a message.* event or artifact.* event is appended. Query
terms are ANDed; ranking is title hit > tag hit > term frequency > recency.
Events with sensitivity: restricted or redaction.level: full never enter
the search bag, so a search result can never leak what the timeline hides.
Memory retrieval (memory/index.json) is a term index over content of
active entries only, keyed by scope.level/scope_id and topic, plus
expires sorted by the effective expiry (expires_at or created_at + decay_days) for the retention job. Entries are added on approved
(automatic or user), removed on rejected, expired, forgotten. Retrieval
reads the index, then loads the newest ledger snapshot for each id and drops
any whose status is no longer active, so a lagging index fails safe.
Tombstones remove from retrieval before content is gone. forget is two
writes under the memory lock: append a ledger snapshot with
status: forgotten, content: "", audit forgotten; then append
tombstones.jsonl {memory_id, at, reason, purged:false}. The index write
follows. Both the retrieval path and the export path consult the tombstone
set (loaded once, appended in memory) before returning anything, which is
what makes "do not remember" hold even if the index rebuild is interrupted.
The earlier ledger lines that still contain content are what the purge job
in section 4 rewrites. The same tombstone file records disable_memory_here
per conversation via privacy/conversation_topics.json, which retrieval also
checks: a conversation with memory_disabled contributes no entries and
receives none.
Research indexes by hash: passage_hashes dedupes passages across
sources, by_message lets GET /messages/{id}/citations open one JSONL
without listing a directory, by_conversation backs the notebook drawer.
Approvals index only the pending queue; terminal approvals are found by id.
4. Retention
One thread runs the retention job every retention_audit.interval_days
(1 day) at a jittered hour, and on demand via POST /hux/v1/admin/retention
(worker surface only). Each run writes audit/retention/<date>.json as
hux.retention_audit.v1 with the counts the schema names, so a day without
the record is itself a finding.
expire_memory: entries whose effective expiry has passed get a new ledger snapshotstatus: expiredand leave the index. Decay means the entry expiresdecay_daysaftercreated_atunless a later ledger snapshot carries a newerupdated_atfrom anapprovedaudit action, which resets the clock once.decay_topic_context:privacy/conversation_topics.jsonrows pastdecay_at(PRIVACY_TOPICS[topic].decay_daysafterfirst_seen) cause the conversation's events with thatsensitivityto be rewritten withredaction.level: fullanddetailremoved;summaryis replaced by the topic notice text. Seq, ids and provenance are kept, so the timeline stays contiguous.purge_forgotten_content: for every tombstone withpurged:false, rewriteledger.jsonl(temp + replace, under the memory lock) replacingcontentwith""on every snapshot of that id, keepingaudit[]; then setpurged:true. Forgotten conversations (POST /conversations/{id}/forget) are handled the same way:events.jsonlis rewritten withdetaildropped andredaction.level: full,search/<conv>.jsonis deleted, artifacts owned only by that conversation lose their blobs (viablobs/refs.json), andprivacy/forgotten.jsonlrecords the counts. The conversation document stays witharchived: trueso branches still resolve their parent.report: bounds check. Per-family bounds for a home cluster (10 Gi PVC shared with the WebUI): events 1 GiB total and 180 days for archived conversations, memory ledger compacted when over 64 MiB (rewrite keeping only the newest snapshot per id plus every snapshot of ids with a tombstone), blobs 2 GiB with unreferenced blobs deleted 7 days after their last ref disappears, research 512 MiB and sources unreferenced by any notebook or citation for 90 days deleted, audit outcomes 90 days, retention audits 400 days, memory exports 7 days, idempotency keys 10 000 per conversation. Terminal approvals and receipts are kept 180 days. Nothing inaudit/is ever removed by a forget or purge; only age.
Private mode conversations (retention: ephemeral) are not written to
events/ at all; the service returns 204 to appends and the router's
session memory is the only copy.
5. Migrations
MANIFEST.json:
{"schema":"hux.manifest.v1","contract_version":"1.0.0","data_layout_version":1,
"min_reader_contract_version":"1.0.0","created_at":"...","updated_at":"..."}
contract_version is services/hermes/contracts/hux/VERSION; the service
that last opened the tree writes its own version there and bumps
updated_at. data_layout_version names the v<N>/ directory in use.
Rules:
- Additive only within a layout. A release may add optional fields to a
record, add a new file name, add a new directory, add an enum value that a
reader can ignore. It may not rename or remove a field, change a field's
type, change the meaning of an existing enum value, change the id pattern,
change
seqsemantics, or move a family to a different path. - Every record keeps its contract
schemavalue (hux.event.v1). Internal bookkeeping is under_meta(revision,idempotency_keys,built_from), which is stripped before a record is served and which readers must ignore. - Readers ignore unknown fields and never fail a family because one record has an unknown optional key. A reader from the previous release therefore reads records from the next one; a rollback of the service leaves every file readable because the older code sees the same required fields.
- A record whose
schemais a version the running code does not know is skipped on list and returned409 hux.schema_unknownon direct fetch, and logged once per family per process. - The service refuses to open a tree whose
data_layout_versionis greater than the one it was built for, or whosemin_reader_contract_versionis above its ownVERSION, and it never bumps either on its own; a v2 layout is a separate migration tool that buildsv2/besidev1/and flips the manifest last. A release that only adds optional fields leavesmin_reader_contract_versionalone, which is exactly what lets the previous release read after a rollback. - Index files are never migrated; they are deleted and rebuilt.
6. Authorization
The router asserts the identity tuple: tenant_slot (^slot-[0-9]{1,3}$),
subject (usr_<hash>), surface and trust (router|relay|worker); the
service checks the slot equals its own HERMES_TENANT_SLOT and refuses
otherwise. Every filesystem path is then built by store.path_for(user, family, *ids), where user must match ^usr_[0-9a-f]{16,64}$ and each id
must match the common.schema.json id pattern
^[a-z]{2,6}_[A-Za-z0-9._-]{4,80}$ (dates in audit/ match
^\d{4}-\d{2}-\d{2}$, versions ^\d+$, hashes ^[0-9a-f]{64}$). The
pattern admits . but not /, so .. alone is impossible in an id; the
resolved path is still checked with os.path.commonpath against the user's
subtree, the same belt-and-braces the router applies in
normalizeTenantMediaPath. No caller-supplied string is ever joined into a
path without going through path_for.
Ownership: records with an owner field must equal the asserted user on
read and write; records without one (events, approvals, receipts, research)
are owned through their conversation_id, whose document is loaded and
checked first. A lookup that fails ownership returns 404, not 403, so
ids cannot be probed.
Every read and mutation appends one line to audit/outcomes/<date>.jsonl
shaped as common.schema.json#/$defs/audit_outcome, also for denials. The
line is the contract object plus an _meta envelope the store owns:
{"at":"...","identity":{"tenant_slot":"slot-3","subject":"usr_...","surface":"chat","trust":"router"},
"action":"memory.forget","resource":"memory/mem_...","outcome":"allow","reason":"owner_match",
"_meta":{"schema":"hux.audit_outcome.v1","id":"aud_...","request_id":"...","idempotency_key":"...",
"revision_before":4,"revision_after":5,"build":{"commit":"...","image_digest":"sha256:..."}}}
action is <family>.<op>; resource is the family-relative record path;
outcome is allow|deny|not_found|conflict|flag_off; reason is a short
fixed vocabulary (owner_match, owner_mismatch, invalid_id,
revision_conflict, cap_exceeded, policy_violation,
unconditional_write, replayed, family_readonly). Audit lines never
contain record content.
7. Module map
All modules live in the hux package under dockerfiles/hermes-hux-foundation/
(where contracts.py and rules.py, the former hux_contracts.py and
hux_policy.py, already sit), stdlib only, each at most 500 lines, and each
tested in testing/tests/test_hermes_hux_foundation_*.py.
| Module | Responsibility |
|---|---|
identity.py |
Parse and validate router headers (slot, usr_ hash, request id); id and hash regexes from common.schema.json; id minting; ownership check helpers. |
flags.py |
Wrap rules.flag_enabled with per-request evaluation of HUX_FLAGS; map each route to its card flag; 404 when the chain is off. |
store.py |
path_for, MANIFEST.json, .lock, per-path lock registry, atomic document write, fsync'd append, tmp-file cleanup, crash validation and truncation, revision/If-Match, idempotency-key storage, size caps. No record semantics. |
events.py |
Per-conversation JSONL log: seq allocation, seq.json, after_seq reads, SSE cursor, search-bag updates, redaction on read, ephemeral-mode short circuit. |
memory.py |
Ledger append, state machine via rules.MEMORY_TRANSITIONS, memory_policy_violations, tombstones, retrieval index, export snapshots, compaction. |
privacy.py |
Topic detection hooks, conversation_topics.json, notices log, forget for a conversation, retention job (expire_memory, decay_topic_context, purge_forgotten_content, report) and hux.retention_audit.v1. |
artifacts.py |
Artifact documents, version list, content-addressed blobs and refs.json, diff between versions, promotion, lineage, blob GC. |
research.py |
Sources, passages (hash dedupe), per-message citation logs, notebooks, research index. |
policy.py |
Policy documents per scope, effective_decision, approvals queue and terminal transitions, cancellation receipts and by_run.json. |
organization.py |
Projects and conversations, indexes, branch lineage, search over the search_index fields, suggestion state and suggestion_allowed gating. |
audit.py |
hux.audit_outcome.v1 writer with daily rotation, the why vocabulary, structured logging of degraded families, age-based pruning. |
http.py |
ThreadingHTTPServer on the tenant loopback port, route table for /hux/v1, JSON and SSE responses, {"items":[],"next":cursor} list wrapping, error mapping (404/409/412/413), /healthz. |
Dependency direction is one way: http -> family modules -> store, with
identity, flags and audit used by everyone and importing only store
and rules/contracts. Family modules never touch the filesystem directly.