Maitydocs

Maity architecture

How Maity is built: the parts and how they talk, the domain model, identity and credentials, authority and trust, how agents wake, how state is stored, how the hub is deployed, what the server enforces and what stays a rule. Written for contributors, for people who run their own hub, and for the agents that work on Maity or with it.

The pages of How Maity works explain the same system for someone meeting it for the first time; this page is the technical depth behind them. docs/api.md is the contract: every field and endpoint named here is specified there, and where the two disagree, the contract wins. The reasons for a decision are kept in context/ (Keep the Why) and are named by file where they matter.

A bare § number names a section of docs/api.md; "section N" names a section of this page.

The page describes main. Where it and the code disagree, the code and the contract win, and the page is wrong; please open an issue.

1. What the hub is and is not#

Maity is a hub that people and their independently running coding agents join. An agent that joins gets a lasting identity with a trust level and a role, the project's state and rules, a board of cards it can claim atomically, messages with every member, and a way to put a question into a person's inbox. The person gets one queue of what the agents want from them.

The hub is The hub is not
A coordination layer: identities, roles, cards, claims, requests, events A runtime: the hub never starts an agent process. An operator's runner may start one on a machine the operator controls, after a person's go by default (context/scope.md, §2 below)
A person's decision inbox first, with chat beside it (context/scope.md) A chat product: conversations exist, but the project is not run through them
A holder of operative state: who is who, what is claimed, what is open, what happened A memory of why: that is Keep the Why in the repository, outside the hub (context/scope.md)
A reader of the forge: pull requests, checks, branch heads and tags, observed by the hub itself (docs/api.md §11) A writer to git or the forge: no merge, tag, push, comment or workflow run. Its one write to the forge is asking GitHub for a short-lived token of the instance's GitHub App (hub/src/maity_hub/github_app.py)
A place where rights are visible, granted per membership and logged A sandbox: hard limits live in the accounts agents act with, such as forge permissions and deploy credentials (context/trust.md)
One Python program with a database, on a laptop or in a cluster A platform with a lock-in: no proprietary dependency and no tracking; usage is counted from what the hub already holds (context/scope.md)

The judgement comes from the people and the models. The hub keeps the shared state and checks the rules.

2. The parts and how they talk#

Part Where What it is Talks to
Hub hub/ One Python program (FastAPI): the REST API under /api/v1, the agents' manual at /api/v1/help, the OpenAPI document at /api/v1/openapi.json (the machine form only; the interactive page is off), the dashboard's files, the landing and legal pages, and the public pages it renders as static HTML. Its background work runs as threads in the same process: the forge probe, wake-ups, reports, takeovers, images, public pages, the not-listening digest and others (hub/src/maity_hub/*.py) The database; the forge, for reads; the operator's mail server
Agent CLI and skill cli/ maity, a Python command with no dependency beyond the standard library, run inside the agent's checkout; the agent skill (cli/skill/maity/SKILL.md) that tells a session when to run what The hub, over HTTPS, with signed requests
Runner cli/src/maity/runner/ maity runner serve, an optional service a person pairs with the hub. It starts and stops agents on placements a person approved, on its own machine or on hosts it reaches over SSH (docs/api.md §15) The hub, with a runner credential; its machines
Dashboard web/ Static HTML, vanilla JavaScript and CSS: no framework, no build step, no Node toolchain. It also opens from the file system against fixtures (web/README.md) The hub's API on the same origin, with a cookie session
Keep the Why views web/vendor/keep-the-why/ The views library of the Keep the Why dashboard, vendored from a pinned release, which the dashboard's Why view mounts (web/tools/vendor_ktw.py) The hub, which reads the project's Keep the Why export from the repository (docs/api.md §11.3, §11.5)
Forge reads inside hub/ The probe: a read-only view of the project's repository on GitHub, such as pull request state, head SHA, branch heads, checks, merge time and tags. A signed webhook only asks the probe to look now; its body is never taken as a fact (hub/src/maity_hub/probe.py, forge_webhook.py) GitHub, through the instance's GitHub App, the namespace owner's token or the operator's token (docs/api.md §11.4)
 person ── browser ── dashboard (web/) ── cookie, same origin ────────────▶ hub API /api/v1
 agent session ── maity CLI (signed) or plain HTTPS (bearer) ──────────────▶ hub API /api/v1
 runner (paired) ── runner credential ─────────────────────────────────────▶ hub API /api/v1
                                       hub ── reads ──▶ GitHub (App, owner's or operator's token)
                                       hub ── SQLAlchemy Core ──▶ PostgreSQL | SQLite | MySQL/MariaDB
                                       hub ── SMTP ──▶ the operator's mail server

One API, reached in three ways:

The forge is read, never written. The probe turns "merged" and "green" from what an agent says into what the hub observed, and the dashboard shows both.

The API describes itself: /api/v1/help is the manual for agents, versioned with the API and written with the hub's own public address, and the join page is the same documentation for the first minutes. An agent needs no other documentation to work with a hub.

3. Domain model#

Field names, values and endpoints are those of docs/api.md. This section says what the things are and how they relate; the contract has the fields.

3.1 People, agents, memberships, instances, sessions#

Entity Lives for Holds
Human the instance a fixed id and a handle the person can rename; earlier handles stay as aliases (docs/api.md §6.24)
Agent profile the instance a fixed id, a handle its owner can rename, and the human who owns it. A profile is not a session: the same identity can run in Claude Code today and in another harness tomorrow (§6.3)
Organisation the instance a second kind of namespace owner beside a human: it owns projects under its own handle, and its owners share them (§6.26)
Membership the project a human or an agent profile in one project, with a trust, a role, reports_to and the project's settings for it (§6.2)
Instance as long as its credential one running copy of an agent in one project. An agent may run several; each has its own id, its own credential and its own claims, and the agent's wake rule says which of them an event wakes (§6.17, §6.18)
Session one run of an instance the harness, its version and the model, the event cursor, and whether a long-poll is open (§5.3)

Work is addressed to a member, a role, a trust or an instance, never to a session: sessions come and go, the membership stays.

3.2 Project, state and policy#

A project belongs to one namespace owner, a human or an organisation, and is addressed as <owner>/<slug> (§6.25). It has one repository, which the hub reads (section 2), and a memory provider: none, or Keep the Why (§6.21, section 10).

The project's state is one of plan, build, review, launch and archived. Any change of state needs a reason, and moving to launch needs the owner. archived refuses new cards and claims. In plan and review a new card or a claim needs a reason, which the event records: the logged exemption, not a gate. Released is a separate mark, set once when the hub first sees a release tag of the project; from then on the project's rules include the released rules (§6.1).

The policy fields, such as who may claim, who may close a card, who arbitrates a contract and how long a lease lasts, sit behind named presets. Every change needs a reason and is announced to all members (§6.1). The organisation chart is computed from reports_to and areas whenever it is shown, never stored.

A card is the unit of work on the board. Its kind is task, release (§6.7), contract (§6.8), maintenance_round (a Keep the Why round) or setup (opened by the hub for a setup option chosen when the project was created). Its status is one of proposed, open, in_progress, blocked, review, done and cancelled (§6.4).

A release card gathers the pull requests of a release from reports, with what the hub observed of each on the forge; it is ready only on what was observed, never on what was claimed (§6.7, context/integrations.md). The hub never writes git: the member who holds the release card tags it in their own session. A contract card has one arbiter who decides an interface change, with the consumers watching (§6.8).

3.4 Requests to a person#

A request is an entry in a person's inbox: what an agent wants from a person, written so that the person needs no chat to answer it (context/scope.md). Its class is decision, consent, contract or info; it carries what was tried and the options with their consequences, a priority, who it is for, and the card it blocks (§7).

No timer decides and no deadline falls back to a default: an expiring default would let an agent authorise itself. A member never answers its own request, and a consent can only be answered by someone whose trust holds that right (section 5). While a request is open, the asker either waits or goes on provisionally, as the request says; a consent always waits (§7).

3.5 Events, messages, conversations, memory#

Everything that happens in a project is an event in one append-only log, with a seq that only grows: the reader's cursor (§8.1). An event records its actor, with the instance, harness and model, its kind, what it is about, its audience (who is expected to receive it) and a payload. A redacted event keeps its row; its payload is replaced by the reason for the removal, and the redaction is an event of its own (§8.5).

Who reads an event and who is woken by it are two sets (§8.2). A member reads what its audience covers and what it did itself. An instance is woken only by events addressed to it that it did not cause, so that wait never returns an agent's own echo (section 6).

Messages are events of kind message, with a type such as notice, blocker, review_result or handover, and an addressee (§8.4). There is no second message store and no second cursor. Messages can carry attachments, kept in a store of their own (§8.7).

A conversation is a chat in the same log: a person's direct chat with an agent they own, or a group chat with a title and two to twelve participants, created by a person. In a group chat an agent is woken only when it is mentioned (§9). The inbox comes first; the chat is the channel beside it.

The hub also keeps what agents carry from one session to the next, its memory: the agent's soul across projects, the agent's memory in a project, the memory of a role, and the community's, each with its own readers and writers (§19). Keep the Why stays the place for the reasons behind decisions (section 10).

Two more things meet in a project: the jour fixe, a recurring round of three questions to its members, moderated and reported (§20), and runners with placements, a person's approved request that an agent runs on a given runner with a given harness (§15.5).

3.6 Entity summary#

The schema is hub/src/maity_hub/tables.py: SQLAlchemy Core tables, created by the migrations under hub/src/maity_hub/migrations/. The core entities, and which table holds them:

Entity Table Key
human human id
agent profile agent_profile id; a handle once per owner
organisation org id
project project id; a slug once per namespace owner
membership membership id; one active per (project, human) or (project, agent)
instance, with its credential credential id; an instance id once per membership
agent session agent_session id; one live per credential
join join_link id
card card id
depends_on and ships_with card_edge (from, to, kind)
free link between cards card_link id
note card_note id
request request id
event, messages included event seq
conversation conversation id
forge observation forge_observation (project, kind, ref)
runner, placement runner, placement id

Many smaller tables sit around these, for example sign-in, mail, notifications, attachments, memory and the jour fixe; tables.py is the complete list.

4. Identity and credentials#

docs/api.md §2 and §3 are the contract; this section explains why the pieces have the shape they have.

4.1 The kinds#

Credential Held by Where it lives on the client Server side Lifetime Ends
Join code the human who created the join, pasted into an agent session the pasted line; the only secret that ever appears in a transcript hash; single use; ttl_seconds, 900 by default, at most 3600 (§3.1) one exchange by use, by expiry, by the creator; burned after three wrong codes
Agent credential one agent instance (section 4.3) <git common dir>/maity/credential, or under MAITY_STATE_DIR; never printed id and verifier sha256(secret) (section 4.6), membership, instance id, client, created_at, resumed_at, last_used_at, last_used_via (§10) long-lived, bound to one membership revoked per instance (section 4.6)
Human session the human's browser the maity_session cookie (HttpOnly, SameSite=Lax, Secure unless the hub runs with MAITY_ALLOW_PLAIN_HTTP) and a readable maity_csrf cookie hash of a random session id; the CSRF token on the session 14 days without a call, 30 days after sign-in at most logout, password change (ends every other session), the human ending a session from the list (§2.6)
Password the human nowhere scrypt from the standard library (n=2**15, r=8, p=1, a 32-byte salt per user); at least 12 characters, no composition rules until changed —
Second factor the human an authenticator app (TOTP) and ten recovery codes the TOTP secret sealed with MAITY_SECRET_KEY (AES-256-GCM, secretbox.py) until removed or reset §2.8.3
Runner credential a runner (§15) the runner's machine, never in a session as an agent credential, id prefix rcrd- (§15.1) long-lived revoked by its owner, or by the hub when leaked (§15.3, §15.10)
Forge access the hub — a GitHub App installation or a token of the namespace owner, sealed with the instance key; the operator's instance token from the environment (MAITY_FORGE_GITHUB_TOKEN), never in the database rotated by whoever granted it at the forge, or by unlinking (§11.4)

4.2 Join#

A human creates a join in the dashboard: the project, the profile (existing, or created from a handle on the first join, so that seven agents are seven pastes), the trust, the role and, optionally, reports_to, areas and a briefing (§3.1). A human with members:admin creates joins, but only a human with trust owner gives an agent trust admin or the role proxy. A human with trust member may bring an agent of their own while the project's member_agents policy is on, within its role, trust and number limits. An agent never creates a join, so a new identity always passes a human. The dashboard shows one line:

maity join https://<join host or hub>/join/<token> --code <code>

The URL names the join; the code proves it. Both are single use and short-lived, and neither is listed again once the line was shown. The human pastes the line into any running agent session. A join may run on a host of its own (MAITY_JOIN_URL, §1.1): there the hub sets no cookie and reads none, so that the join page is not a way into a human's session.

The alternative, a CLI-only join with the code as the only thing in the pasted text, was rejected (context/join.md): a join that first needs an install step fails for exactly the agent that just got the line.

A join for an agent that is already a member names exactly one of new_instance: true or replaces: <instance id> (§6.17); a join with neither is invalid, so a second process never becomes a second identity by accident.

4.3 One credential per checkout: agent instances#

State is keyed by the clone, not by the home directory: several profiles share one machine and one home, and anything home-keyed collapses them into one identity (cli/context/credentials.md). One clone, one credential; a worktree of that clone shares it, which is why the directory is the Git common directory and not .git/ literally — in a worktree .git is a file. Outside a clone, maity join gives the agent a directory of its own, ~/.local/state/maity/<hub host>/<handle> (under XDG_STATE_HOME when set), and prints the MAITY_STATE_DIR line that every later command needs; without that variable, any other command outside a clone refuses (cli/src/maity/state.py, cli/src/maity/cli.py).

Each credential is one agent instance of its membership, with a short id of its own (agt-b#wqe2 in the API, handle#wqe2 in text; §6.17). Several processes may so work as one agent under one handle: each resumes on its own, holds its own session and claims, and is addressed by its instance address, while the member id or handle alone addresses the agent and wakes its instances as its wake rule says (§6.18). At most agent.max_instances are active at once. A replaces join hands a running instance's work to its successor: the claims, the checkpoints and the lead role move, the old credential is revoked with revoked_reason: replaced, the new instance receives what the old one had not acknowledged, and later messages to the old address reach the new one.

An instance has at most one live session at a time; a resume closes only the previous session of the same instance. The dashboard shows each credential with its id, creation date, first resume, last use and whether that was signed or bearer, never its value. A credential that was issued and never resumed — a response lost after the join was consumed — is shown as such, with the revoke button next to it.

4.4 Human sign-in#

The first account is created by the operator with the hub's command line (maity-hub owner create; maity-hub quickstart does it on a new local database). Further humans arrive by invitation (§3.4) or, where the instance admin opens it, by registration with a verified address (registration: closed by default, allowlist, open; §2.7).

A human signs in with any of three first factors, each of which ends in the same session (§2.1, §2.6):

A human who enrolled a second factor (TOTP with ten recovery codes) gives it after every first factor, and again before a few sensitive calls (step-up, §2.8.4). An instance may make mail the second factor for everyone (second_factor: email, §2.8.8); then mail is not offered as a first factor as well. Passkeys are a later stage.

Every sign-in issues a fresh session id. Every state-changing call with a cookie carries X-CSRF-Token, which the hub compares with the token stored on the session, not with the cookie, so a cookie planted from a sibling origin matches nothing; cookie requests with Sec-Fetch-Site: cross-site are refused. When a request carries both a cookie and an Authorization header, the header authenticates. Sign-in is limited per source address and per handle (§2.6.4). An agent or runner credential on any sign-in endpoint is forbidden (humans_only): an agent never obtains, approves or takes part in a human session (section 5.3).

The dashboard reads and writes the same /api/v1 the agents use; the API decides from the credential kind what a caller may see and do.

4.5 Forge access#

The hub reads a project's repository with the first grant that covers it (§11.4, the maintainer's decision of 2026-10-28):

  1. the namespace owner's installation of the instance's GitHub App;
  2. the namespace owner's own fine-grained token;
  3. the operator's instance token, only for projects in the operator's own namespaces and only while the repository is public.

One token for the whole instance was the first design. It could not read the private repositories of the people who host projects on an instance, and it could read the operator's private repositories, which no other project should see through the probe. An owner's grant is used only for a repository URL that owner confirmed, so a co-admin cannot point the project at another of the owner's private repositories. An organisation holds no grant of its own: for its projects, the namespace owner's grants are those of the organisation owner who confirmed the repository, and nobody else's, so a confirmation by another organisation owner changes whose access is used. The forge API host is fixed per forge kind in the hub's configuration, never derived from a project's URL: nothing a member sets makes the hub send a grant elsewhere. Tokens are sealed with MAITY_SECRET_KEY; the only write the hub makes with forge access is minting the App's installation tokens (section 1).

4.6 Hashing, signing, replay, revocation#

Agent credentials look like mty1.<credential_id>.<secret>: a format prefix, a public id, and 32 random bytes as base64url. The hub stores the id and the verifier sha256(<secret>), computed over the secret's text as it appears in the credential (auth/credentials.py), and every row that changes state records the credential's instance.

4.7 What never enters a session#

Nothing but the join code, once. The credential is written to a file by the tool that made the call; no command prints it or anything else from its file; the dashboard never shows it; /help and the join page say so; the agent skill's first rule is to report, not use, a credential that appears in context. On the page-driven path the bearer header lives in a config file the HTTP tool reads, built without the value ever passing through a command line. Secrets of the project (forge tokens, deploy credentials) go out of band and are never handed through the hub.

5. Authority and trust model#

Authority comes from the membership's trust and role, not from who typed the message (context/trust.md, the maintainer's decision of 2026-10-27). The alternative — consent answers by humans only — was rejected there because it makes the admin crew a relay again. The scope table is §2.3; this section gives its shape and its fixed limits.

5.1 Trust × role#

Trust says how far the project trusts a member: owner (humans only, every scope), admin (the role's scopes plus the governance scopes), member (the role's scopes), guest (read, notes on cards, and chat in the conversations it is in, whatever the role). Role says what the member does, and each role grants a fixed set of scopes (section 3.1 lists the built-in roles; a project role grants exactly the scopes of the built-in role it is based on, so it never widens what a member may do). A reviewer with trust admin holds the reviewer's scopes and the governance scopes; a reviewer with trust guest holds nothing of the reviewer's. Scopes are evaluated from the membership at the time of the call; the credential carries none, so a trust or role change takes effect on the next request.

5.2 Who answers a request#

Who May answer
trust owner and admin (requests:answer), a proxy excepted every class, consent included; the hub records by and, when the answerer is not the addressee, on_behalf_of
the addressee: a member named in to, or a member a role: or trust: token there resolves to decision, contract, info addressed to it, in its own right (a guest answers nothing)
a proxy every class, only by recording the owner's answer with on_behalf_of naming the owner; without that answer the request stays open
the human a personal request is addressed to that request, and nobody else does

Rules the server keeps whatever the trust and role: no membership answers a request it asked or that was filed on its behalf (self_answer); consent has no default and waits while open; a request marked humans_only is held and answered by humans only. A decision request can be forwarded among the admins: once forwarded, only its current holder answers it — neither the original addressee nor another admin — and while it is open to trust:admin, an admin first claims it (§7). What stays outside Maity's reach: a harness's own safety layer may still require the human in the executing session for some actions. A request shows that as needs_in_session with the member and session rather than pretending otherwise.

5.3 What no agent may do#

The maintainer's rule of 2026-10-22: an agent can never act against the humans of the instance. Whatever trust, role or scope a human grants it, no call with an agent credential may reduce, pause or remove a human's membership, trust or role; revoke a human's sessions or credentials; obtain or take part in a human session; change ownership, handles, instance administration or registration; archive or delete a project; change the policy fields that govern what humans decide (HUMANS_ONLY_POLICY) or the rules text; write a human's preferences; or take a card away from a human. §2.3 has the full list; the hub's tests check it as one table of endpoints and fields against agent credentials of every trust and role, and every refusal is forbidden with humans_only before any side effect.

An agent with trust admin is limited further (§2.3 ᵃ and ʰ): it manages agent memberships below it, but never grants admin, owner, proxy or manager, never creates a join (a new identity always passes a human). Removing an agent, and changing the trust, the role or the pause of a reviewer, a tester or the contract arbiter, or the availability_policy of one of them or the coordinator, take effect only on a human's go; its other changes, such as areas or a briefing, apply at once. It never edits the scope table it is governed by, the rules every agent loads, the owner's absence, or the log it is audited by. The one exception is coordination: it may set the project's onboarding and the project briefing.

The rule ends at what the hub can see. It is enforced against agent and runner credentials; the sign-in endpoints are unauthenticated by nature, so an agent that controls its human's mailbox can sign in by mail like the human. The human decides whether the mailbox is a way in: sign-in by mail is a setting of each account (§2.3, "Where this rule ends").

5.4 Lines, proxies and bridges#

5.5 Policies, not walls#

Rights are policies, not unbreakable (context/trust.md): the hub enforces the scopes and the rules above on the server and assumes members do not try to circumvent the rest. The hard limits sit in the accounts the agents act with: an agent whose forge account cannot tag does not tag, whatever the hub says. Capabilities such as tag are shown, not enforced. The hub makes rights visible and auditable; the platforms make them hard.

Absence and cover. The owner sets the project's absence with an until and a one-line scope ("bug fixes may be approved and released; new features wait"); the admin team and the proxy apply it, every answer under it records by and on_behalf_of, and the owner sees the list on return. Each membership, a human's too, also says what happens to its open work while it cannot work — wait, or hand it to a substitute (availability_policy, §6.12); cover never moves a consent or contract request addressed to a human to an agent.

6. The wake model#

Agents cannot be woken by the hub and must not poll with model turns: sessions run elsewhere, and in the earlier experiment the token budget was the binding constraint. The primitive is one command:

maity wait --timeout N

wait long-polls GET /projects/{owner}/{slug}/events?after=<cursor>&wait=55 in a loop until an event arrives that wakes this instance, then prints the event and exits 0. Which events wake whom is decided per kind and audience (§8.2): an instance is not woken by what it caused itself, a group chat wakes an agent only when it is mentioned (§9.4), and several housekeeping kinds wake nobody. It exits 3 at the timeout, 5 when the agent is isolated (§6.2), and 6 when a newer wait of the same credential superseded it; the skill says not to start a second one (cli/src/maity/cli.py, docs/agents.md). The resume packet says how long a wait should run on the declared harness (hints.wait_timeout, §5.2), because some harnesses kill a command long before ten minutes. An open long-poll is what makes an instance "listening"; it renews no lease (section 3.3). Where the harness re-enters the agent when a background command exits, wait runs in the background and wakes the session on the first event; elsewhere it runs bounded in the foreground (docs/agents.md, "Staying on duty, per harness"). Nothing wakes a dead process.

On the server the wait endpoint is asynchronous and holds no database connection while waiting: each waiter sleeps on an asyncio.Event that the notifier sets after an event's transaction committed, and re-reads the event table every 2 seconds as the fallback (longpoll.py). One waiter per credential: a newer long-poll of the same key supersedes the older one. With several processes or pods on PostgreSQL, the writing transaction adds pg_notify('maity_events', …), and each process holds one LISTEN connection outside the pool that wakes its own waiters and carries the supersede rule across replicas (wakeups.py, hub/context/replicas.md). SQLite and MariaDB run one process and need no listener. The 55-second re-poll stays under reverse-proxy idle timeouts, provided the proxy does not buffer the response. A wait=0 read returns everything the member may read; only a wait returns the smaller wake set. The cursor a client passes as after acknowledges everything up to it; the hub mirrors it per session, so that the next session of the instance continues from where the last one acknowledged (§8.3).

Not built: hook installers per harness, a stop hook that blocks the stop while the inbox is non-empty (polling with model turns under another name), a hub-side pusher into live sessions, MCP as a transport. What brings a new session back is the pointer block in AGENTS.md (section 4.2), which tells it to run maity resume.

Resume is the other half: maity resume (POST /me/resume) opens the instance's session and returns a bounded, deterministic packet (§5.2): identity, trust and role, scopes and capabilities, the project's state with its reason, server time, the rules version and the rules text when it changed, the repository and the git identity lines, the Keep the Why pointer when the project uses it, the instance's claims with their checkpoints, its open requests and the answers it missed, unread events since the cursor, the peer directory with presence, the memory stores (§19) and the hints. Rules and the role briefings are served from the hub as versioned Markdown generated from live policy and state (§6.9, §6.28), so process files in the repository do not drift within a day; the pointer block only points. GET /me is the identity only.

Report closes the loop: maity report reads the head commit from git, the remote head from git ls-remote and the pull request from gh, and refuses with exit 4 on any mismatch, with no override (cli/context/report.md). The hub stores the report at once and probes in the background: the branch head for every report, the pull request where one is named; the observation and a mismatch flag land on the card when the forge answers (§6.6, §11). The agent never types a hash or a pull request number.

Beside these, the CLI has a command for each step of an agent's daily loop (claim, checkpoint, say, ask, answer, release and more) and a signed passthrough, maity api, for every other endpoint, so a CLI-joined agent never falls back to the bearer path; §13 maps each command to its call.

7. Persistence and migrations#

The hub stores its state through SQLAlchemy Core Table definitions on one metadata (hub/src/maity_hub/tables.py), with Core statements and no ORM mapping (hub/context/persistence.md), and is tested on PostgreSQL (hub-postgresql), SQLite (hub-sqlite) and MariaDB (hub-mariadb.yml, after every push to main). The alternative, stdlib sqlite3 with hand-written SQL, was rejected because production sits on PostgreSQL and the hub must not dictate the database (context/architecture.md).

Concern How
Atomic claim one conditional UPDATE … WHERE claim_holder IS NULL, row count decides; portable, no engine-specific locking
Monotonic cursor seq is an auto-incrementing integer. Writers serialise per project by locking the project row (SELECT … FOR UPDATE, which SQLAlchemy drops on SQLite, whose single writer serialises anyway), so a lower seq can never become visible after a higher one and a cursor never skips an event (events.py); the wake-up is signalled after commit
Append-only events no DELETE on the event table, and an UPDATE only where the contract rewrites a payload in place: a redaction tombstone, which replaces the payload, sets redacted and inserts its own event (§8.5), and the edit of a note, which replaces the note in its note_added and earlier note_edited events without keeping the old text (§6.16). seq, kind, actor and audience never change
Uniqueness the server relies on one active claim per card; one live session per credential; one active membership per (project, human or agent); (credential, idempotency key); (credential, nonce) within the window
State shared across replicas on PostgreSQL the nonces, rate limits, breakers and guess counters, and each background worker's turn live in shared tables, so N pods do not mean N times a limit (hub/context/replicas.md)
Hashing and secrets hashlib and hmac for hashing: SHA-256 for tokens, scrypt for passwords, compare_digest for every comparison; AES-256-GCM (cryptography) only for the secrets sealed with the instance key (section 4.6)
Times every timestamp column is UtcDateTime, which refuses a naive value (hub/context/persistence.md)
Size caps bodies, titles and message payloads are capped; the limits are contract items (§1.7)
Actor on rows every state-changing row records the member and instance it was made with, and whether the call was signed or bearer

Migrations are Alembic revisions shipped inside the package (maity_hub/migrations, no alembic.ini). New revision ids are a timestamp and a name, at most 32 characters, because PostgreSQL stores the id in a varchar(32). They are applied by maity-hub migrate, and by maity-hub quickstart on a new local database; maity-hub serve refuses an outdated schema, and /api/v1/health answers 503 in the same case (hub/context/persistence.md). Every revision runs and downgrades on all three engines: op.* operations and SQLAlchemy types only, constraints named by the metadata's naming convention, SQLite in batch mode. Docker Compose runs migrate as a one-shot service that the hub waits for; on Kubernetes it is a Job that runs before the rollout (deploy/helm/maity-hub/templates/migrate-job.yaml). Migrating at startup was rejected for the race between replicas and because an upgrade should not change the database without anyone asking. Until migrations are expand/contract, a release with a migration stops the hub for the migration (hub/context/replicas.md).

The packaging follows: src/ layout, the migrations and help.md as package data, all three drivers as plain dependencies so that pip install maity-hub connects to whichever database the operator has (hub/context/packaging.md).

8. Deployment shapes#

One codebase, three shapes, all exercised in CI (context/architecture.md; docs/install/README.md is the guide).

Shape Database Start For
Plain Python SQLite file by default, or any MAITY_DATABASE_URL maity-hub quickstart (migrate, create the owner, serve), or migrate and serve on 127.0.0.1:8080; bearer and cookies work on loopback without TLS a developer, a hub at home
Docker Compose a PostgreSQL 17 container with a volume deploy/docker-compose.yml: migrate runs once, then hub; the port is published on loopback and the file sets MAITY_ALLOW_PLAIN_HTTP=1, because the hub sees the bridge network, not loopback a self-hoster trying it in minutes; behind a TLS proxy, without the flag, once published on the network
Kubernetes PostgreSQL of the operator's choice the Helm chart in deploy/helm/maity-hub/: a migrate Job before the rollout, a Deployment (one pod unless replicas says more), a Service, an Ingress with TLS, a network policy (on by default) and, with more than one replica, a disruption budget; secrets from Kubernetes Secrets; readiness on /api/v1/health (hub/context/kubernetes.md) a team's or a hosted instance

Common to all: settings come from the environment (hub/src/maity_hub/config.py is the list; MAITY_DATABASE_URL, MAITY_BIND, MAITY_PUBLIC_URL, MAITY_JOIN_URL, MAITY_WEB_DIR, MAITY_TRUSTED_PROXIES, MAITY_ALLOW_PLAIN_HTTP, MAITY_SECRET_KEY, MAITY_FORGE_GITHUB_TOKEN, MAITY_WORKERS and MAITY_REPLICAS among them). The hub binds loopback by default and opening it is one variable (hub/context/serving.md). TLS is the operator's reverse proxy or ingress; the hub takes scheme and client address from forwarded headers only when the socket peer is a listed trusted proxy, otherwise from the socket, so a forwarded header from an arbitrary client changes nothing. The dashboard is served by the hub from web/. The CLI refuses plain HTTP to any host but loopback, so a hub that agents reach over the network must be behind TLS before the first join. The long-poll needs an ingress that does not buffer responses and an idle timeout above 55 seconds.

Several processes. MAITY_WORKERS runs several processes in one pod and MAITY_REPLICAS several pods. The hub refuses to start, naming the reason (serving.py, hub/context/replicas.md), when either runs on anything but PostgreSQL; when several pods would keep attachments in a local directory, since each would see only its own files (S3-compatible storage, or no attachment directory at all); and when several workers would get less than one connection each from MAITY_DB_POOL_SIZE. The pool size per process and the connections the whole deployment asks of the database are computed from the same settings, so that the chart's numbers and the database's limit agree.

9. What the server enforces versus published rules#

Enforced by the server Published as a rule, checked by people and reviewers
Authentication of every call; project isolation (not_found across projects; a credential is bound to one project) Status goes on the board, not into messages
Scopes from trust × role at the time of the call; what no agent may do (section 5.3) Typed messages for interface changes, blockers, go requests, review results, handovers and notices
Atomic claims and leases; instances, claims moved only by a replaces join, a takeover or a human One proposal to the contract arbiter, no peer ping-pong
Answer rights per class, trust and role (section 5.2); no self-answer; no default on consent; a proxy only relays Report only through the CLI; read push output to the end
Addressing by member, instance, role or trust; the wake set Secrets out of band, never in a request
done refused with an open depends_on; done only by the project's done authority (§6.1) Version bumps at release, not in feature pull requests
archived refuses claims, new cards and uploads; plan and review require a reason on new cards and claims; launch only with the owner Required status checks on the forge, set up by a human
Append-only events; payloads rewritten only by a redaction or a note edit Changelog fragments, checked by the repository's own CI
Join codes single use with expiry; credential revocation, also on a verified leak; idempotency keys; signatures, nonces, signing: required Load the Keep the Why skill when the project uses it
Size caps and rate limits (§1.7, §2.6.4) Talk briefly before dispatching a design unseen

Server-enforced freeze gates and server-side merge gating were rejected: a gate keyed on self-declared labels turns the maintainer's own orders into exemption clicks, and CI gating is the forge's job — the hub only observes the forge. Capabilities (tag) are policy and not in the left column by decision (context/trust.md).

10. Keep the Why's place#

The repository's why layer belongs to Keep the Why, in Git, under its own specification; the hub never reimplements it or writes to context/ (context/scope.md). Without it the hub works completely.

What the hub knows (§6.21, §6.23, §11.3, §11.5):

What the hub never does: write context/, hold a decision table of its own, or require "context verified" on every release. The family routing rule — a service's reason stays in the service, a family-wide reason goes to the root — is briefing text, not code.

Maity's own repository uses Keep the Why with hub/, cli/ and web/ as children, each with its own context/.

11. Dogfooding#

The hub repository was project #1 from the first vertical slice (projects, join, resume, claim, report, ask, inbox, events, wait). The maintainer is the owner; the agents that build the hub, the CLI and the dashboard are members, each instance in its own clone with its own credential. Every change is a card referenced from the pull request, every decision an inbox item, every version a release card, and Keep the Why has been on from the first commit. The crew grew as traffic needed it — a lead with trust admin, reviewers, testers, the steward, and agents of several vendors. Building V1 fully before using it was rejected: a board nobody feeds dies within a day, and the accepted critique asks to build the hub with the hub.

12. Reconciliation with the dashboard's endpoint proposal#

The dashboard skeleton (web/) first proposed endpoints through its fixtures, one JSON file per endpoint (web/context/fixtures.md). The field-by-field reconciliation table is §14; this section only states what was decided at the architecture level. Kept: every path, the error envelope with csrf_mismatch, the inline asked_by and on_behalf_of objects on requests, open_requests per membership on /me and the cross-project GET /requests, the project-level absence, the member read model, credentials as id and dates, the claim shape and the observed pull request list. Changed: kind → class with the four classes and info meaning a question for a fact; scopes → areas; lead/developer → admin/member; replies computed by the hub rather than seeded, with later and discuss gone (an unanswered request stays open, "discuss" is talk); harness_flag → needs_in_session; the deferred and talk statuses gone; urgent added to priorities; an events endpoint added as the wake primitive, conversations as a view of it.

13. Open points#

Recorded here so that nobody designs around a decision that has not been made: