arizuko › reference › Store schema
schema
Every table arizuko keeps is an entity. One row is one item, addressed by a (partition, sort) coordinate, and the rest of the row is string attributes. One DynamoDB-protocol server per instance serves them, named by DYNAMODB_URL, and nothing above the driver knows which server it is.
How the layers stack, from a daemon down to PostgreSQL, is drawn on the store. The coordinate is what a read costs, so each entity's coordinate is argued in the file that writes it. store/entities.go names the set each owner holds.
Four daemons own the entities
An owner is a daemon and the tables it alone creates. On the server a table is named <owner>.<entity>, and each daemon signs with its own access key, so the server’s policy bounds what it reaches. store/owner_reach.go declares which owners each daemon opens, and a test fails when the code opens one the table does not list. A daemon reads another owner’s tables only where this page says so.
| owner | declared in | holds |
|---|---|---|
routd | store/entities.go | messages and their indexes, routing, grants, secrets, scheduling, packages |
runed | runed/runstore | spawns, session log, circuit breaker |
authd | authd/authstore | signing keys, accounts, refresh tokens |
onbod | store/entities.go | onboarding, gates, invites |
audit_log is in all four. One declaration lives in audit, every owner creates it, and each serves its own read-only at GET /v1/audit; /dash/audit/ merges those four answers rather than reading anybody's tables.
Beside the four, each channel adapter keeps four small tables under its own name, declared in chanlib/chanstore: outbound_dedup, one item per delivery routd asked it to make; inbound_pending, one per inbound routd has not yet accepted; file_urls, one per attachment behind its /files/<id> proxy; ack_marks, one per chat under the acknowledgement reaction an adapter with no typing indicator puts on a turn’s first message, so a restarted adapter can still take it off. emaid adds two of its own, declared in emaid/emaistore: email_threads, one per conversation, holding the reply address and the Message-ID every reply threads under; email_msg_ids, one per Message-ID seen, naming its thread, which is how an inbound In-Reply-To finds its conversation. Both take the id itself as the partition, because every read is a point read and the index grows with every mail.
routd owns the messages
| entity | addressed by | carries |
|---|---|---|
messages | the chat, then the instant plus the message id | sender, content, verb, source, attachments, topic, routed_to, errored, is_observed, turn id. The id is in the key because two messages can share an instant inside one chat. |
message_folders | the folder, then the message's own position | a pointer to the message. Routing is dynamic, so the folder a message reached is written when routed_to is stamped and never recomputed. |
message_folder_counts | the folder | that folder's message count, so the usage page reads items instead of partitions |
message_activity | one constant partition, then the message's position | the newest messages across every chat — the one read that has no coordinate of its own |
message_delivery | the delivery status, then the message's position | attempts. sent is the absence of an item, so the entity holds only what still owes a delivery. |
chats | the chat jid | agent_cursor (the dispatch watermark), sticky group and topic |
sessions | folder and topic | session id, parent topic, fork instant, observed_cursor |
chat_reply_state | jid and topic | reply anchor, engagement window, owner and explicit stop time |
chat_proactive | the chat jid | the instant that chat last spoke unprompted — the cooldown |
system_messages | the folder, then the instant | one queued system event |
Ranked search is not a table. routd keeps an in-memory BM25 index over the messages entity, walked from the store at boot and fed by its own writes; see find_messages.
routd owns the configuration resources
These fourteen are the registered resources: each has a REST face, an MCP face, and a row in arizuko dump.
| entity | addressed by | carries |
|---|---|---|
groups | the folder | name, product, model, container_config (mounts, timeout), daily cost cap, open flag, observe window |
routes | sequence, target and match | the rule. The sequence is padded so a read comes back in precedence order; the id an operator deletes by is minted through counters. |
route_tokens | the sha256 of the token | jid, owning folder, context, kind (route delivery bearer or pair identity link). The digest is the key; the raw token is never stored. |
web_routes | the path prefix | access (public / auth / deny / redirect), redirect target, owning folder |
proxyd_routes | the path | proxyd's reverse-proxy table. proxyd owns every write and serves it on its own /v1. |
acl | principal, action, scope, params, predicate and effect | who granted it, when, and grant_option. The six key fields are the grant. |
acl_membership | child and parent | who added the edge and when. Roles and jid→sub claims share it. |
secrets | scope kind, scope id and key | the value, sealed AES-256-GCM under SECRETS_KEY |
network_rules | folder and target | the per-folder egress allowlist. The folder leads because it is also the purge scope. |
scheduled_tasks | the task id | owner, chat, prompt, cron, next run, status, context mode |
pending_actions | the call id | one tool call held for a human verdict |
installed_packages | folder and name (empty folder = instance-wide) | source, revision, the identities the package owns, per-asset hashes |
github_sources | the source key | gitd's webhook delivery secret and the folder it delivers to |
gdrive_files | the Drive file id | the bound Drive file and the folder that its comments go to |
routd also owns the runtime state
| entity | addressed by | carries |
|---|---|---|
secret_use_log | the instant a tool call resolved one | spawn, caller, folder, tool, key, outcome, latency |
turn_context | the turn id | folder, topic, chat, trigger sender, run id, state, the hash of the gateway key that turn was metered with |
turn_results | folder and turn id | session, status, when it was recorded. Create-if-absent is what makes one report one delivery. |
task_run_logs | the instant a task ran | task, duration, status, result, error |
user_profiles | the person's sub | the username their folder is named after, their daily spend cap |
group_watchers | observer and source | nothing — the pair is the fact |
pane_sessions | team, user and thread | the Slack assistant pane's channel and context jid |
counters | the name of what it numbers | the count. There is one, routes.id, moved by a compare-and-swap. |
runed, authd and onbod own their own entities
| entity | addressed by | carries |
|---|---|---|
spawns | one constant partition, then the run id | folder, topic, container, session, kind, state, outcome, exit code, timestamps. A guarded write names one item, which is why the run id is the whole address. |
session_log | the folder, then the instant it started | session id, end, result, error, message count |
circuit_breaker | the folder, both halves | consecutive failures and the last one. Every operation is a point operation on one folder. |
signing_keys | one partition per daemon, then the kid | the ES256 keypair and its retirement |
signing_key_slot | a single item | which kid signs now. One item is what makes two signers unrepresentable. |
auth_users | the sub | name, created |
oauth_identities | <provider>:<provider sub> | the account that login belongs to |
refresh_tokens | the token hash | family, sub, scope, audience, issue, expiry, use and revocation instants |
refresh_families | the family id | one login lineage |
onboarding | the chat jid | status (awaiting_message → queued / approved / refused), gate, sub, timestamps. No credential: the link is a pair row in route_tokens. |
onboarding_gates | the gate | daily limit, enabled |
invites | the invite ref | target glob, issuer, expiry, max uses, used count |
invite_redemptions | the person, then the instant | one unspent subgroup redemption. The point delete is the claim. |
audit_log | the instant, at fixed width plus a suffix | category, action, actor, resource, scope, surface, outcome, duration, turn, folder, instance, request, source ip. The id is the sort key and is the instant, so ordering and the page cursor are one value. |
An entity is not a SQL table
- No columns. Every value is a string and an absent attribute is the only kind of missing, so a comparison means the same thing on every engine.
- No index but the coordinate. A selective read belongs on the sort key; a filter still reads what it filters.
- No foreign key, so nothing cascades. Deleting a group purges every entity that confers on that folder or routes into it, explicitly, by one containment predicate — and a table in neither list fails a completeness test.
- No autoincrement. An item has no server-minted identity; the one number arizuko hands out,
routes.id, comes from thecountersitem through a compare-and-swap, so two concurrent adds cannot mint the same handle. - No full-text index. Ranked message search is derived state in routd's memory, rebuilt from the
messagesentity at boot.
Four tables share the word “route” and nothing else. routes sends a message to a folder; web_routes decides who may load a URL path; proxyd_routes sends an HTTP path to a backend daemon; route_tokens holds a bearer that lets a stranger post into one chat. They answer four different questions, so they stay four tables.
Only the owner writes an entity
The owner daemon writes its own tables. A few daemons open routd’s tables with keys of their own, each for one job: onbod and dashd for onboarding and the operator’s admin writes, webd to resolve a route token and read a chat, gitd for a webhook’s sealed secret, slakd for the pane context, and proxyd to read acl on every request, because a login-time snapshot would keep a revoked grant working. dashd opens onbod’s too. Everything else goes through the owner's /v1. A mutation carries its audit_log row in the same batch, so a refused write records nothing and a recorded row happened.