Metadata-Version: 2.5
Name: maity-hub
Version: 0.1.0a5
Summary: The Maity hub: the server humans and their coding agents join
License-Expression: MIT
Requires-Python: >=3.12
Requires-Dist: aiomysql>=0.2
Requires-Dist: aiosqlite>=0.20
Requires-Dist: alembic>=1.14
Requires-Dist: anyio>=4
Requires-Dist: boto3<2,>=1.40
Requires-Dist: cryptography<51,>=50.0.2
Requires-Dist: fastapi>=0.156
Requires-Dist: greenlet>=3.1
Requires-Dist: markdown-it-py>=3.0
Requires-Dist: pillow<14,>=13.0
Requires-Dist: psycopg[binary]>=3.2
Requires-Dist: pymysql>=1.1
Requires-Dist: sqlalchemy>=2.0.36
Requires-Dist: starlette>=1.9
Requires-Dist: uvicorn[standard]>=0.32
Provides-Extra: dev
Requires-Dist: black>=24.10; extra == 'dev'
Requires-Dist: flask-cors>=6.0; extra == 'dev'
Requires-Dist: flask>=3.1; extra == 'dev'
Requires-Dist: httpx2>=2.16; extra == 'dev'
Requires-Dist: moto[s3]>=5.1; extra == 'dev'
Requires-Dist: mypy>=1.13; extra == 'dev'
Requires-Dist: pytest-xdist>=3.6; extra == 'dev'
Requires-Dist: pytest>=8.3; extra == 'dev'
Requires-Dist: ruff>=0.8; extra == 'dev'
Requires-Dist: starlette>=1.2; extra == 'dev'
Description-Content-Type: text/markdown

# Maity hub

The server of [Maity](../README.md): one Python process that serves the
API under `/api/v1` and the static dashboard from `../web` at `/`. It
stores its state in PostgreSQL (production), SQLite (local runs, tests)
or MySQL/MariaDB.

Status: the conventions, authentication, projects, members, agent
profiles, joins with the join page, the resume packet and credentials of
`docs/api.md` are in: an agent can join and open sessions. Cards with
claims, leases, checkpoints and reports, requests to humans with the
cross-project inbox, events with the long-poll, messages and conversations,
the project message listing
are in, and release and contract cards with their checklist, and the
forge probe. Runners (`docs/api.md` §15): pairing, the runner as a
signed-only caller confined to `/runner/*`, the owner's runner calls,
attaching runners to projects, the runner's hello, event stream and
capabilities, and placements (desired and reported state, agents'
proposals with consent, the closing rules, a clone's retention time after
a stop with the runner's workspace reports and a removal by hand) are in; the join pickup and
supervised sessions follow. Mentions (`docs/api.md` §8.6), the members'
owner and credentials summary, the conversation preview and the instance
admin's user list (§16) are in. The change ticker (`docs/api.md` §17):
items with their computed audience and the members' follow and mute, the
manual post, dedupe, the listing, the shaping per reader on every read
path, the resume packet's `ticker`, `settings.ticker`, and the hub's own
decision and release items are in, and merges through the probe (every
2 minutes: the changed paths' area owners, the linked cards, the contract
sections from the diff), and the forge webhook: its secret sealed with
`MAITY_SECRET_KEY`, signed deliveries as triggers for the probe, and
automatic deploys. The
instance's mail settings and the admin's test send (`docs/api.md` §16.2,
§16.3) are in, and so is the mail outbox with its worker; the notifications
and sign-in mails use that outbox.
A human's preferences (`docs/api.md` §19.4): one
versioned text per human, written by the human, read by the agents of
the projects they share (switch per project), proposed by an admin agent
or the human's proxy as a personal request reviewed with conflict
detection, quoted in the resume packet (8 texts, the agent's owner first)
and given to one agent on its membership by its owner.
The session list (`docs/api.md` §2.6.5), the instance's sign-in settings
(§2.6.6), the login's `next`, device codes with number matching (§2.6.2)
and `GET /auth/methods` are in. Mail sign-in (§2.6.3) uses a verified address,
a scanner-safe confirmation page or a browser-bound six-digit code; both spend
one fifteen-minute grant. It is offered only while SMTP and the signing key
are configured and the instance method is enabled. Notifications
(`docs/api.md` §18.1–§18.3), only what a human must react to (`inbox`, `chat`,
`mention`, `security`): account records, `GET /me/notifications` with sealed
positions and its long-poll, `POST /me/notifications/read`, and a waiting
registration as the instance admin's `inbox` item are in; card watches (§6.4)
stay but notify nobody. The notification settings with the verified address
(§18.4) are in, and so are the links in mails (§18.6) and the delivery failures of an
address (§18.7); the notification mail (§18.5) is in.

Availability (`docs/api.md` §6.12): every member's computed
`availability`, the agent's and the human's limit (`POST /me/limit`, a
runner's `rate_limited` report), the owner's absence (at most 90 days),
the `availability_policy` per membership, leases held through a limit for
at most a day, `member_availability_changed` and `member_limit_ended` from
a background sweep, the policy's `coordinator` seats, cover by a
substitute (held and assigned cards and requests moved visibly, answered
on the covered member's behalf, what the substitute touched kept on
return, area routing to the substitute) and the coordinator gate are in.
Cards carry links, an edit history and notes (`docs/api.md` §6.13,
§6.15, §6.16): forge links parsed by the hub and observed by the probe for
the project's own repository only, in one GraphQL read per repository;
URL links; one history entry per changed field with diffs for title and
body, tied to the write's event and redacted with it; notes with their
caps, the agents' share and the per-member rate, resolution by the
creator or an admin, and redaction. The card import from a forge issue
(§6.14) and the project overview's counts (§6.11) are in.
The jour fixe (`docs/api.md` §20): the series in the policy, the
moderator order, rounds opened on schedule or by hand, skipped and missed
slots, the answers, named or anonymous, and the statement and exchange
in the round's own group chat, and the report with its triage (decided
by the moderator or filed to the owner, adoptions as cards), and
redaction across every copy of a round's texts are in.

Cards also support two-level subtasks (`parent`), symmetric navigation
through `related`, and computed open/total child counts (§6.4). The done
gate includes open children. `policy.subtasks` defaults to `proposed`,
requiring an admin to open a member's child card; `direct` lets the team
use ordinary card rights. Parent and dependency edits are checked together
under the project lock, so a combined edit cannot bypass depth or cycle
rules. Parent and related edits are in history; computed counts are events.

The public project badge (`docs/api.md` §6.20) is served at
`GET /badge/{slug}.svg`, outside the API and without a session. It is off
by default; a human project admin sets `policy.badge` to `members` or
`agents_online` with a reason. It shows only counts, uses a separate
60/minute source limit and a 60-second public cache with ETag revalidation.
Unknown, opted-out and closed projects all answer the same `not_found`.
The approved SVG templates ship inside the hub, so no dashboard is needed.
A positive online-agent count uses the CSS-only animated badge; zero stays
static grey, and member counts always use the static badge.

For search engines the hub serves `GET /robots.txt` and `GET /sitemap.xml` at its root
(`docs/api.md` §16.7), without a session or a cookie and cached for an hour. While the
instance's landing page is enabled, `robots.txt` lets a crawler read the root and the
files the page is drawn from, and the sitemap lists the root alone, dated by the page's
last edit. While it is not, crawlers are asked to fetch nothing but the root, which
answers `X-Robots-Tag: noindex`, and the sitemap is `404`. The join host has neither file.

## Run it locally with plain Python

Python 3.12 or newer. From `hub/`:

```sh
python3 -m venv .venv
. .venv/bin/activate
pip install --require-hashes -r requirements/runtime.txt -r requirements/build.txt   # the tested versions
pip install --no-deps --no-build-isolation -e .   # the hub itself, built by the pinned hatchling
maity-hub migrate     # creates ./maity-hub.db and brings the schema to the current version
maity-hub owner create --handle maintainer --display-name "Maintainer" --email maintainer@example.org   # asks for the password
maity-hub serve       # http://127.0.0.1:8080
```

`owner create` makes the first human account; it reads the password from
the terminal (asked twice) or from the first line of standard input,
never from the command line. `--email` is the address the account signs
in with; the command stores it as verified, so it needs no mail
(`docs/api.md` §2.1). The earliest human account is the instance
admin (`docs/api.md` §16): it sees `GET /admin/users` and the mail
settings (`/admin/settings/mail`), and nothing of a project it is not a
member of.

```sh
curl http://127.0.0.1:8080/api/v1/health
curl http://127.0.0.1:8080/api/v1/help
```

`serve` refuses to start when the database is not at the current schema
version; run `maity-hub migrate` after every upgrade. Migrations never run
implicitly.

On SQLite, `maity-hub quickstart` does the three steps in one: it migrates
only a new, empty database, creates the first account only while there is
no human (`--handle` defaults to the login name, the address is asked
for unless `--email` gives it, the password is read as `owner create`
reads it), and then serves. A database behind the current
schema is refused as `serve` refuses it. Any other database URL is
refused: those hubs use `migrate`, `owner create` and `serve`. The steps
for a personal hub are in [`docs/install/quickstart.md`](../docs/install/quickstart.md).

## Run it with Docker Compose

The hub with PostgreSQL. From the repository root:

```sh
(umask 077; printf 'Database password: '; stty -echo; read -r pw; stty echo; echo; printf 'MAITY_DB_PASSWORD=%s\n' "$pw" > deploy/.env)
docker compose -f deploy/docker-compose.yml up --build
```

The password is read without echo (`stty -echo`, so any POSIX shell) and written only to `deploy/.env`
(owner-only), so it is never on a command line or in the shell history;
`deploy/.env` is ignored by Git. Use only letters, digits and `-_.~` in the password, nothing
else: Compose hands the same value to PostgreSQL as it is and to the hub
inside a URL, so a character that needs percent-encoding cannot be right
for both. Compose also interpolates `$` in `.env` values.

The `migrate` service runs `maity-hub migrate` once and exits; the hub
starts after it succeeded and reports healthy once `/api/v1/health`
answers. The hub is published on `127.0.0.1:8080`, and the Compose file sets
`MAITY_ALLOW_PLAIN_HTTP=1`, because inside Docker the hub sees the bridge,
not loopback. Create the first account with
`docker compose -f deploy/docker-compose.yml run --rm -T hub owner create --handle maintainer --display-name Maintainer --email maintainer@example.org`
and the password on standard input.

To serve other machines, do not publish the port on `0.0.0.0`: that
exposes the hub without TLS. Keep it on `127.0.0.1` and put a TLS proxy on
the host in front of it. Then set three things together:
- `MAITY_PUBLIC_URL`: the proxy's `https://` address.
- `MAITY_TRUSTED_PROXIES`: the Compose network's gateway. Every proxied
  request reaches the hub from that address, as plain HTTP; without it the
  hub refuses every login.
- `MAITY_ALLOW_PLAIN_HTTP`: removed.

The steps, with an override file and how to find the gateway, are in
[`docs/install`](../docs/install/README.md#docker-compose).

## Run it on Kubernetes

The Helm chart in [`deploy/helm/maity-hub`](../deploy/helm/maity-hub/README.md)
runs the hub against an existing PostgreSQL:

- **Migration:** a hook Job runs `maity-hub migrate` before every install
  and upgrade.
- **Pods:** one pod by default, replaced with `Recreate`. With PostgreSQL and
  attachments off the pod's volume, `replicas` runs more, rolled one at a time
  (the chart's README, "Several pods").
- **Secrets:** the database URL and the forge token come from Secrets you
  create; the chart only names them.
- **Ingress:** optional, with TLS.

The chart's README walks through an install. `deploy/helm/check.sh` lints
and renders the chart. `deploy/helm/kind-smoke.sh` installs, tests and
upgrades it on a throwaway kind cluster.

## Run the image on its own

```sh
docker build -f deploy/Dockerfile -t maity-hub .
docker run --rm -v maity-data:/app/data maity-hub migrate
docker run -d -v maity-data:/app/data -p 127.0.0.1:8080:8080 maity-hub
```

Without `MAITY_DATABASE_URL` the image uses SQLite in `/app/data`, a
volume owned by the hub's user (uid 10001). The image contains the
dashboard from `web/` when that directory exists at build time; dotfiles,
`node_modules`, `fixtures/` and `tools/` are never copied.

## Configuration

All settings come from the environment.

| Variable | Default | Meaning |
|---|---|---|
| `MAITY_DATABASE_URL` | `sqlite:///maity-hub.db` (image: `sqlite:////app/data/maity-hub.db`) | SQLAlchemy URL. `postgresql://`, `mysql://` and `mariadb://` get the bundled drivers (psycopg, PyMySQL). Reserved characters in any part of the URL (user, password, database, query) must be percent-encoded, `@` as `%40`; a server URL with an unencoded `@` besides the one before the host is refused. |
| `MAITY_BIND` | `127.0.0.1:8080` | `host:port` to listen on; `[::1]:8080` for IPv6. |
| `MAITY_PUBLIC_URL` | `http://<MAITY_BIND>`; `http://localhost:<port>` for a wildcard bind, with a warning | The URL agents and browsers reach the hub under; used in `/help` and in join links. Set it whenever the hub is reached from elsewhere. |
| `MAITY_JOIN_URL` | unset | An optional second hostname, `https://<host>` with no path and another host than the public URL's, that serves the agent join and the redirect of a person's invitation (`docs/api.md` §1.1): `GET`/`POST /{token}` are the join page and the exchange, a dead token-shaped link answers a page saying so, `GET /i/{token}` (an invitation's link) is a `302` to the public `/invite/{token}` without a lookup, every other `GET` is a `302` to the public start page and every other method `not_found`; no cookies there. Requests are matched on the `Host` hostname alone (any port, trailing dots ignored). Join links and invitations are built on it by default (`via`). The value is exactly `https://`, a host name (letters, digits and hyphens per label) or an IP address (IPv6 in brackets, without a zone), an optional port and an optional `/`: it goes unquoted into the join command's shell line. Any other value stops the hub at startup. |
| `MAITY_CLI_INSTALL` | `pipx install maity` | The one line that installs the agent CLI, printed on the join page, in `/help` and in `signature_required` (`docs/api.md` §3.2). Point it at a fork, for example with a `git+https://…#subdirectory=cli` URL. One line only; a line break stops the hub. |
| `MAITY_KTW_SKILL_SOURCE` | the skill's `v0.20.3` release tree | Where the Keep the Why skill is published, as told to a joining agent in the resume packet (`docs/api.md` §6.23). An operator who mirrors the skill points it elsewhere. An `https` URL only; anything else stops the hub. |
| `MAITY_KTW_SKILL_GUIDE` | `https://keepthewhy.com/installation/` | The skill's installation guide for every harness, the packet's `skill.guide` (`docs/api.md` §6.23): where an agent looks when `install` or `load` does not fit, and the page it names to its human. An `https` URL only; anything else stops the hub. |
| `MAITY_AVATAR_DECODE_SECONDS` | `5` | Time budget of one avatar decode (`docs/api.md` §6.10), 0.5–120; past it the upload is refused (`undecodable`) and the kill logged. |
| `MAITY_AVATAR_DECODE_MEMORY_MB` | `512` | Address-space limit of the process that decodes one avatar, 128–8192 MiB; a 4096 × 4096 image needs about 300–380. Past it the upload is refused (`undecodable`) and the kill logged. |
| `MAITY_AVATAR_DECODES` | `2` | Avatar decodes running at once, 1–64; a call that finds them all taken is `rate_limited`. |
| `MAITY_ATTACHMENT_STORE` | `local` | Where an attachment's bytes live (`docs/api.md` §8.7): `local`, a directory (`MAITY_ATTACHMENT_DIR`), for one hub; `s3`, opt-in, an S3-compatible bucket that several replicas share (`MAITY_ATTACHMENT_S3_*`); anything else stops the hub. A hub that already has `local` attachments copies them before it switches (`maity-hub attachments copy-to-s3 --apply`, below); the keys are the same in both stores, so the rows need no change. |
| `MAITY_ATTACHMENT_DIR` | unset | The `local` store's directory: an absolute path to an existing, writable directory (else the hub stops). The files are `<dir>/<project id>/<attachment id>`, written whole; back them up separately from the database. Unset, the hub starts and every attachment call answers `409 attachments_not_configured`. |
| `MAITY_ATTACHMENT_S3_BUCKET` | unset | `s3`: the bucket; required, else the hub stops. Read only when the store is `s3`. |
| `MAITY_ATTACHMENT_S3_ENDPOINT` | unset | `s3`: the endpoint of an S3-compatible service (MinIO, Ceph, a hoster's), an `https` URL without a path; unset is AWS for the region. Plain `http` only with `MAITY_ATTACHMENT_S3_ALLOW_HTTP=1` and to a loopback, private-range or cluster-internal host (`localhost`, a one-label name, `*.svc`, `*.cluster.local`). |
| `MAITY_ATTACHMENT_S3_ALLOW_HTTP` | unset | `s3`: `1` allows a plain-`http` endpoint inside the cluster (above). Separate from `MAITY_ALLOW_PLAIN_HTTP`, which is about inbound traffic. |
| `MAITY_ATTACHMENT_S3_REGION` | unset | `s3`: the region; unset leaves it to the client's own configuration. |
| `MAITY_ATTACHMENT_S3_PREFIX` | unset | `s3`: a key prefix such as `maity/attachments`; objects are `<prefix>/<project id>/<attachment id>`. |
| `MAITY_ATTACHMENT_S3_ACCESS_KEY_ID`, `MAITY_ATTACHMENT_S3_SECRET_ACCESS_KEY` | unset | `s3`: the credentials, both or neither; neither uses the client's default chain (an instance role, a mounted profile). Never logged. |
| (the attachment sweep) | every 5 minutes | Not a setting. With a store set, a background pass deletes pending uploads older than 24 hours, the bytes of deleted attachments that are still on disk, and blobs no row names (older than 10 minutes, so an upload in flight is safe) (`docs/api.md` §8.7). A folder no project has (`lost+found` on an ext4 volume, other data in the bucket) is left alone and logged once per process start. |
| `MAITY_DB_POOL_SIZE` | `16` | Pooled connections to a server database (PostgreSQL, MariaDB), 1–500, at least 2 (one for each engine, `MAITY_DB_ASYNC_POOL_SIZE`). They are reused, the most recently used first. |
| `MAITY_DB_MAX_OVERFLOW` | `16` | Connections beyond the pool when it is busy, 0–500. |
| `MAITY_DB_ASYNC_POOL_SIZE` | 8, at most half the share | On a server database, how many of `MAITY_DB_POOL_SIZE` the async engine holds, 1–500; every call authenticates on it. Unset, each process's async engine holds 8 of its pool share but at most half of it (a pool of 8 gives 4 + 4). A value that is set is divided among several `MAITY_WORKERS` like the pool (rounded down, at least one each), so each process's async engine holds its share. Carved out of the pool, never on top, at least one and never all of it; the sync engine keeps the rest and the whole overflow. A server database's pool therefore needs at least 2 per process. SQLite takes no pool sizes: each engine keeps SQLAlchemy's default for a file database (5 + 10). |
| `MAITY_WORKERS` | `1` | Serving processes of `maity-hub serve`, 1–64. More than one only with PostgreSQL and with `MAITY_DB_POOL_SIZE` at least twice the workers (refused otherwise). The pool and the overflow above are the hub's total: each process holds its share, rounded down. Each process takes about 130 MB of memory. |
| `MAITY_REPLICAS` | `1` | How many hubs the deployment runs on this database, 1–100, as the deployment states it (the chart sets it from `replicas`). More than one only with PostgreSQL and without a local attachment directory (refused otherwise). At the start the hub warns when the connections it may ask (per worker its pool and overflow share plus two shared-counter connections and a listener, per pod one for the probe, for replicas + 1 pods during a rolling update) exceed what PostgreSQL gives ordinary roles. |
| `MAITY_DB_POOL_RECYCLE` | `1800` | Seconds after which a pooled connection is replaced when it is next taken, 0–86400; 0 never replaces. A connect that fails because the database host name does not resolve is tried once more after a short pause. |
| `MAITY_DB_STATEMENT_TIMEOUT` | `30` | Seconds a statement may run on a PostgreSQL connection of the hub before the server cancels it (`statement_timeout`), 0–3600; 0 turns it off. Not set on `maity-hub migrate` and the other commands, nor on MariaDB or SQLite. |
| `MAITY_DB_IDLE_IN_TRANSACTION_TIMEOUT` | `60` | Seconds a PostgreSQL connection of the hub may sit in an open transaction without a statement before the server ends it (`idle_in_transaction_session_timeout`), 0–3600; 0 turns it off. Same scope as above. |
| `MAITY_DIRECT_DATABASE_URL` | unset | PostgreSQL only: the URL for the one connection per hub process that listens (`LISTEN`) for the wake-ups other replicas send, so a long-poll wakes when another replica writes. Set it when `MAITY_DATABASE_URL` goes through a pooler in transaction mode, which cannot hold a `LISTEN`; unset, the listener uses `MAITY_DATABASE_URL`. |
| (shared counters and forge budgets) | own pool | PostgreSQL only: two connections per serving process, no overflow, separate from the request pool; included in the connection-budget warning. SQLite and MariaDB keep process-local counters. |
| (the health check) | own connection | Not a setting. On a server database `GET /api/v1/health` opens a connection of its own, outside the pool, for each check (connect timeout 5 s; PostgreSQL's statement timeout 5 s, MariaDB's read timeout 5 s), so a pool that is used up does not make the readiness probe fail. SQLite has one engine. |
| `MAITY_SECRET_KEY` | unset | The instance key for secrets stored at rest (`docs/api.md` §16.2, §17.4): 32 random bytes, base64 (`head -c 32 /dev/urandom \| base64`). AES-256-GCM, a random nonce per value and the field's name as associated data. Never in the database, never logged or returned; a value that is not 32 base64 bytes stops the hub. Without it a forge webhook secret cannot be created (`secret_key_missing`). A key that changes makes every stored secret unusable until it is set again.
| `MAITY_TRUSTED_PROXIES` | empty | Comma-separated addresses or CIDRs of reverse proxies whose `X-Forwarded-Proto` and `X-Forwarded-For` the hub believes. Empty: forwarded headers change nothing. |
| `MAITY_ALLOW_PLAIN_HTTP` | `0` | `1` lets bearer credentials, logins and cookies travel over plain HTTP from any address. Only for a hub reachable on one machine or a private network; logged as a warning. Without it, plain HTTP is accepted from loopback only. The cookies' `Secure` flag follows the scheme a request arrived with, whatever this says. |
| `MAITY_FORGE_GITHUB_TOKEN` | unset | The read-only GitHub token of the forge probe: fine-grained, with read access to Contents, Pull requests, Checks and Commit statuses of the repositories the hub probes (without Checks or Commit statuses, `checks` reads `unknown`; a repository the token cannot see is `probe_unavailable`, never a missing pull request). It is sent only to `api.github.com`, never logged or returned. Without it no release is ever ready. It reads only for projects in `MAITY_FORGE_INSTANCE_NAMESPACES`. |
| `MAITY_FORGE_INSTANCE_NAMESPACES` | empty | The operator's own namespaces, comma separated (a human's handle or an organisation's, compared in lower case): the only projects the token above reads for, and there only public repositories (`docs/api.md` §11.4). Empty: it serves no project; every other project is read through its namespace owner's GitHub App installation or token. A listed name nobody has is held for the operator (registration, `POST /orgs` and renames answer `reserved`; `maity-hub owner create` may take it), and the hub warns at start about such a name and about a listed former name. |
| `MAITY_SMTP_HOST`, `MAITY_SMTP_PORT` | unset; port 587, 465 or 25 by `MAITY_SMTP_TLS` | The one SMTP server the hub sends through (`docs/api.md` §16.2). Every mail setting may instead be set by the instance admin in the dashboard; a value set here wins and cannot be changed there. |
| `MAITY_SMTP_TLS` | `starttls` | `starttls`, `implicit` or `none`. Under `starttls` a server without STARTTLS fails the send; nothing goes out in clear. `none` only for `localhost` or a loopback address; any other host stops the hub at startup. |
| `MAITY_SMTP_CA_FILE` | unset | A PEM file the server's certificate is verified against instead of the system store. Certificates are always verified. |
| `MAITY_SMTP_USERNAME`, `MAITY_SMTP_PASSWORD` or `MAITY_SMTP_PASSWORD_FILE` | unset | `AUTH`, sent only over TLS. The password (or the file a Secret is mounted at, one trailing newline dropped) is never stored, logged or returned. |
| `MAITY_SMTP_FROM`, `MAITY_SMTP_REPLY_TO` | unset | The sender, `maity@example.org` or `Maity <maity@example.org>`, and an optional reply address; one address each, never a list. |
| `MAITY_MAIL_MAX_PER_HOUR`, `MAITY_MAIL_SECURITY_MAX_PER_HOUR`, `MAITY_MAIL_SIGN_IN_MAX_PER_HOUR`, `MAITY_MAIL_PASSWORD_RESET_MAX_PER_HOUR`, `MAITY_MAIL_REGISTRATION_MAX_PER_HOUR` | `500`, `100`, `60`, `30`, `30` | The hourly budgets: notification, security, sign-in, password reset and registration mail, each counted on its own. |
| `MAITY_MAIL_ONLINE_GRACE_MINUTES` | `5` | 0–60. How long after a person stopped being present in the web app their held notification mail still waits; while they are present, the mail of an immediate item waits (docs/api.md §18.5). |
| `MAITY_MAIL_SIGNING_KEY` | unset | 32 bytes or more, base64; signs the links in mails. Without it the hub sends no mail. |
| `MAITY_SMTP_ALLOW_PLAINTEXT` | `0` | **Insecure**, for a test bench only: `1` sends in clear to a server that offers no STARTTLS (still never `AUTH`). Logged as a warning at startup and at every such send. |
| `MAITY_SIGN_IN_DEVICE_CODE`, `MAITY_SIGN_IN_EMAIL_LINK`, `MAITY_SIGN_IN_PASSWORD_RESET` | unset | `1` or `0`: switch a sign-in method, or the reset of a forgotten password by mail, on or off for good (`docs/api.md` §2.6.6, §2.10); unset leaves it to the instance admin (default on). The password cannot be switched off. |
| `MAITY_WEB_DIR` | `web/` beside `hub/` in a checkout; the dashboard the package carries (`maity_hub/web`) when installed | The static dashboard. When set explicitly it must exist; otherwise a missing directory means the API is served alone. Dotfiles, paths under `/api`, and web/'s `fixtures/` and `tools/` are never served from it. |

Moving a volume's files into the bucket: `maity-hub attachments copy-to-s3` copies every file under `MAITY_ATTACHMENT_DIR` into the bucket of the `s3` settings, under the same key, and skips an object the bucket already holds with the same SHA-256; an object the hub wrote with other bytes is reported, not overwritten (exit 1), and a file whose attachment is deleted or has no row is not copied. With `--apply` it then removes from the bucket what was deleted since an earlier run (a deleted attachment's object at once, an object no row names once it is 30 minutes old), and leaves alone the objects of a project the database does not know. Without `--apply` it only counts, the removals too, and it never changes the files. There is no copy back: after the switch, new uploads are only in the bucket. Run it with `MAITY_ATTACHMENT_STORE=s3` before switching the store, and once more after (`deploy/helm/maity-hub/README.md`, *Moving attachments to S3*). With the bucket's versioning on, a deletion is final only once a lifecycle rule expires the noncurrent versions.

### Settings for a public instance

What an instance needs before strangers can register, in the order of
[Opening registration](../docs/install/README.md#opening-registration).
All but `MAITY_PUBLIC_URL`, `MAITY_REGISTRATION_BREAKER`, `MAITY_SECRET_KEY`,
`MAITY_MAIL_SIGNING_KEY`, `MAITY_TRUSTED_PROXIES`,
`MAITY_ALLOW_PLAIN_HTTP` and the `MAITY_PUBLIC_PAGES*` settings can also
be set by the instance admin in the dashboard; the environment wins.

| Variable | Default | Without it | Contract |
|---|---|---|---|
| `MAITY_PUBLIC_URL` | derived from the bind address | Links in mails and join lines point at the wrong address. | §1.1 |
| `MAITY_TRUSTED_PROXIES` | empty | Behind a proxy, every client shares the proxy's address and one rate-limit bucket, and the request does not count as HTTPS. | §2.5 |
| `MAITY_ALLOW_PLAIN_HTTP` | `0` | Leave it off on a public instance: `1` lets credentials and cookies travel unencrypted. | §2.5 |
| `MAITY_SECRET_KEY` | unset | No second factor by authenticator app, no stored SMTP password or forge secret (`secret_key_missing`). A lost key locks out enrolled humans. | §16.2, §2.8.1 |
| `MAITY_MAIL_SIGNING_KEY` | unset | The hub sends no mail, so registration stays closed. | §16.2 |
| `MAITY_SMTP_HOST`, `MAITY_SMTP_FROM` and the other `MAITY_SMTP_*` above | unset | The same: mail is incomplete, registration stays closed. | §16.2 |
| `MAITY_REGISTRATION` | `closed` | Nobody can register. `allowlist` or `open` opens it, and takes effect only while mail is complete and enabled. | §16.4, §2.7 |
| `MAITY_REGISTRATION_DOMAINS`, `MAITY_REGISTRATION_ADDRESSES` | empty | An `allowlist` without entries registers nobody. | §16.4 |
| `MAITY_REGISTRATION_APPROVAL` | `operator` | New accounts wait as `pending` until an admin decides each; `automatic` makes them usable at once. | §16.4, §2.7.5 |
| `MAITY_REGISTRATION_MAX_NEW_PER_HOUR` | `20` | Past it, the completion page says to try again later. | §16.4, §2.7.3 |
| `MAITY_REGISTRATION_BREAKER` | `120` | Requests per minute across the instance; past it every registration is `rate_limited` for a minute. | §16.4, §2.7.3 |
| `MAITY_TERMS_URL`, `MAITY_TERMS_VERSION` | unset | Both or neither. Neither: the `terms` legal page, if written; else no terms are asked for. | §16.4, §16.7.1 |
| `MAITY_SIGN_IN_SECOND_FACTOR` | `optional` | Admins may sign in on a password alone. `required_admins` holds them to a second factor and needs `MAITY_SECRET_KEY`, or the hub does not start. `email` needs complete mail. | §2.8.5 |
| `MAITY_MAX_AGENTS_PER_HUMAN` | `100` | The limit stays at the default; 1–10000 or `unlimited`. | §16.5 |
| `MAITY_MAX_ORGS_PER_HUMAN` | `5` | The limit stays at the default; 1–10000 or `unlimited`. | §16.5 |
| `MAITY_MAX_ATTACHMENT_STORAGE_PER_PROJECT` | `1073741824` | The instance admin sets it under Limits; bytes one project may store, pending uploads included, counted after re-encoding, from 1 MiB to 1 PiB, or `unlimited`. Past it an upload is `attachment_quota` with scope `project`. | §16.5, §8.7 |
| `MAITY_MAX_ATTACHMENT_STORAGE` | `unlimited` | The instance admin sets it under Limits; bytes from 1 GiB to 1 PiB, or `unlimited`. Past it an upload is `attachment_quota` with scope `instance`. | §16.5, §8.7 |
| `MAITY_PUBLIC_PAGES` | `off` | Project and profile pages answer the app shell and the sitemap lists none, whatever owners chose. | §16.9 |
| `MAITY_PUBLIC_PAGES_RENDER`, `MAITY_PUBLIC_PAGES_INTERVAL_HOURS` | `on_change`, `6` | Pages render on change and at least every interval. | §16.9 |
| `MAITY_PUBLIC_PAGES_SNAPSHOT_MINUTES` | `5` | How often a project's page is rendered again for its ticker and online snapshot (1–60). | §16.9 |
| `MAITY_PUBLIC_PAGES_STARTUP_SECONDS` | `30` | After how long a starting process takes up no further public page target before it serves (1–600); the targets it took up are rendered to the end, and the rest are deleted and rendered right after the start. It does not bound the whole start: size the startup probe from the observed start. | §16.9 |
| `MAITY_PUBLIC_PAGES_RENDER_SECONDS` | `2` | The longest render of one target's public pages (1–60); a longer one takes them off, as a failed render does. | §16.9 |
| `MAITY_ROUTE_LIMITS` | empty | No route has a limit beyond its own; the ingress holds the general limit per client. For an emergency, entries `<METHOD> <route> = <count>/<seconds>`, one per line or separated by `;`, the route as `/api/v1/openapi.json` lists it. Counted per client address in each process, so with several replicas each counts its own share. A route the hub lacks stops it at the start; a change takes a restart, and a restart is slow while the hub renders its public pages. Examples below. | §1.7 |
| `MAITY_PUBLIC_PAGE_LIMIT`, `MAITY_BADGE_LIMIT` | `60/60` | Reads of the public pages and of the badges, each per client address: `<count>/<seconds>`. An office behind one address may need more. | §1.7, §16.9, §6.20 |

Examples for `MAITY_ROUTE_LIMITS`, for the routes that cost the most per call. The
values are a start for an emergency, not measured limits:

```sh
MAITY_ROUTE_LIMITS='GET /api/v1/projects/{owner}/{slug}/tasks = 60/60; GET /api/v1/projects/{owner}/{slug}/events = 300/60; GET /api/v1/me/export = 5/3600'
```

- `GET …/tasks` is the board and its text search (`q`, §6.4).
- `GET …/events` is the event feed. Every agent's `maity wait` holds one call for up to 55
  seconds and every open dashboard reads it every 15 seconds, so leave room for every agent
  and dashboard behind one address.
- `GET /api/v1/me/export` is the account export (§2.9); it has a limit per account of its
  own already.

The hub logs each request as method, path and status on the
`maity_hub.access` logger, never a query string or a header value, with
join and pairing tokens shown as `…`; uvicorn's own access log is off.

The machine-readable API description is at `/api/v1/openapi.json`;
`/api/v1/help` is the one written for agents.

## Develop

Work in a virtual environment of your own, so the tests see the locked set
and the `maity-hub` command; without that command the test of the entry point
is skipped:

```sh
python3 -m venv .venv && . .venv/bin/activate
```

```sh
make install    # the locked set from requirements/ with hashes, then the hub editable
make check      # black --check, ruff, mypy --strict (src, tests, scripts), pytest
make format     # black and ruff --fix
```

The tests run on a fresh SQLite file per test, a copy of a schema migrated
once. `make test` runs them in parallel, one worker per core (pytest-xdist);
plain `pytest` runs one process. On the machine the project's agents share, do not
run the full suite: run the test files a change affects, with at most two workers,
and leave the full suite to CI (`CONTRIBUTING.md`, "PostgreSQL runs and a shared
machine", has the rule and its reason). A few files may run as `make test PYTEST_ARGS="-n 2 tests/test_board.py"`;
a heavier run goes in a run slot,
`../tools/run_slot.sh make test PYTEST_ARGS="-n auto tests/test_board.py"`, where
`-n auto` starts two (`CONTRIBUTING.md`, "PostgreSQL runs and a shared machine").
To run them against a server database, point
`MAITY_TEST_DATABASE_URL` at an empty database the suite may create and drop
tables in. It is the suite's alone and disposable: the suite empties it before
every test, and on PostgreSQL a session of the same role that still holds a lock
on its tables when that cleanup times out is ended (hub/context/testing.md):

```sh
MAITY_TEST_DATABASE_URL=postgresql://maity:<password>@127.0.0.1/maity_test pytest
MAITY_TEST_DATABASE_URL=mariadb://maity:<password>@127.0.0.1/maity_test pytest
```

With `-n`, each worker gets a database of its own next to that one
(`maity_test_gw0`, `maity_test_gw1`, …). The suite creates it through
`MAITY_TEST_ADMIN_DATABASE_URL`, which defaults to the same URL; set it to an
account that may create databases (on MariaDB, root, which also grants the
test user) when the test user may not. On PostgreSQL the suite also sets the
test database's default time zone to `Europe/Vienna` (`ALTER DATABASE`), so a
time that depends on the session's zone fails there instead of passing on UTC.
That runs through the account of `MAITY_TEST_ADMIN_DATABASE_URL` (by default the
test user), which must own the test database or be a superuser, also without
`-n`. `pytest -m wheel` runs only the tests
that prove the installed package (package data, entry point, migrations), which
is what CI's hub-wheel job runs against the built wheel.

`scripts/with_postgres.sh` does this on your machine without a database to set up:
it runs a command with `MAITY_TEST_DATABASE_URL` set to a database of its own, on
a server in `MAITY_PG_DIR` (default `~/.local/state/maity/test-postgres`), which it
creates when missing. Runs can share the server. The last one to end stops it, and
only if this script started it; a server that was already up keeps running.
`--start` starts the server for every run of this account and leaves it up,
`--stop` stops it once no run uses it. A run holds one of the account's run slots
(`../tools/run_slot.sh`, two by default), and its database is
`maity_test_<name>_<pid>` with `<name>` from `MAITY_RUN_NAME`; `CONTRIBUTING.md` has
the rules for a shared machine. It needs `pg_ctl`, `initdb` and `psql`, on the path
or in `MAITY_PG_BIN`. Without a system PostgreSQL, the PyPI package `pgserver`
brings them, in a venv of its own (its wheels stop at Python 3.12):

```sh
python3.12 -m venv ~/.pg-venv && ~/.pg-venv/bin/pip install pgserver
export MAITY_PG_BIN="$(~/.pg-venv/bin/python -c 'import pgserver, pathlib; print(pathlib.Path(pgserver.__file__).parent / "pginstall/bin")')"
scripts/with_postgres.sh pytest -n 2 tests/test_board.py
```

`pytest -m load -n 0` runs the load tests (`tests/test_load.py`), which the ordinary
run leaves out: they seed a bell with `MAITY_LOAD_UNIT` unread requests (default 1000, as in CI's
`hub-load` job) and fail when the statements or the time of a call grow with four
times the history or with more open tabs. Use PostgreSQL for numbers that mean something.
`hub-load` runs on every push to main, nightly and by hand; it is not part of `ci-ok`.

`scripts/measure_board.py` seeds a project (200 cards, 10 members, 2000
events by default) and reports, per dashboard list request, the SQL
statements it runs and its time, on SQLite or any `--database-url` it may
wipe. `--rtt-ms` puts a delaying proxy in front of a server database to show
what a network between hub and database adds. The lists build their views in
batches (`views.task_views`, `event_views`, `member_views`), so a page costs
a fixed number of statements; `tests/test_view_batching.py` holds that.

`scripts/smoke_join.sh` runs a fresh hub on loopback and joins two agents
the two ways a pasted join line can go: with plain `curl` exactly as the
join page says, and with the `maity` CLI (`make install`, then
`pip install -e ../cli`
first). Each agent then asks its human, the human answers both, and each
agent picks its answer up with its next resume; then each agent waits
(`maity wait`, and `curl … &wait=30`), its human writes to it, and the wait
wakes. It exits 0 only when all of that worked.

CI (`.github/workflows/ci.yml`) runs `make check` on SQLite, the test
suite on PostgreSQL 17 and against the built wheel on Python 3.13, builds
the image and migrates with it, and runs the join smoke script — all on the
locked dependencies below. The suite on MariaDB 11.4 runs hourly on main
(`.github/workflows/hub-mariadb.yml`), not per pull request.

### Dependencies

`pyproject.toml` states what the hub needs (lower bounds). What CI tests
and the image ships is pinned, with hashes, in `requirements/`:

| File | Holds | Used by |
|---|---|---|
| `runtime.txt` | the hub's dependencies | the image, the join smoke and the browser end-to-end test in CI |
| `dev.txt` | the same pins plus black, ruff, mypy, pytest | `make install`, every hub CI job |
| `build.txt` | hatchling (and `editables`) | building the wheel and the editable install, without build isolation |
| `tools.txt` | uv and pip-audit | `make lock-check` and the advisory audit in CI, each in an environment of its own |
| `release.txt` | build, twine and hatchling, with the `build.txt` pins | `make release-build`: the sdist and wheel for PyPI (`release-hub.yml`, the `hub-dist` CI job) |

`build.in`, `tools.in` and `release.in` name the build backend, uv and the
release tools; `runtime.txt` and `dev.txt` come from `pyproject.toml`. All five are generated by
`uv pip compile` (`--universal`, so one file serves every platform and
Python 3.12 or newer), never edited by hand:

```sh
make lock           # after changing a dependency in pyproject.toml: keeps every other pin
make lock-upgrade   # moves everything to the newest versions that resolve
make lock-check     # what CI runs: fails when the files do not follow pyproject.toml
```

The locking needs uv, in the pinned version:
`pip install --require-hashes -r requirements/tools.txt`.

To bump: run `make lock-upgrade`, commit the five `.txt` files, and let CI
prove the new set on SQLite, PostgreSQL, MariaDB, the wheel and the image
in the pull request. `make lock-upgrade PACKAGE=<name>` bumps one package
only. `build.in` and `tools.in` pin hatchling's floor, uv and pip-audit
exactly: to move those, change the line there first, then run `make lock` (for uv, run it
once more with the new uv, `make lock UV=<new uv>`, and `make lock-check`
with it, since CI checks with the uv the file pins). A bump is its own pull
request.

**Advisories.** The `audit` workflow (`.github/workflows/audit.yml`) runs
pip-audit against all five files on every push to main, every pull request
and once a day, so an advisory for a pin nobody changed shows up too. When
it fails, its log names the package, the pinned version, the advisory id
(`PYSEC-…`, `GHSA-…`) and the versions that fix it. Then:

1. `make lock-upgrade PACKAGE=<name>`, with the uv from `tools.txt`. If the
   fix needs a newer lower bound, raise it in `pyproject.toml` first.
2. Check that the new pin is at or above a fixed version.
3. Open its own pull request, naming the advisory id, and merge it as soon
   as CI is green.

If no fixed version exists yet, the failure stays until one does. Ignoring
an advisory (`--ignore-vuln <id>` in the workflow) is a decision with a
reason and its own pull request, never a quiet edit.

To run it locally, in an environment of its own (`tools.txt` is compiled
without the hub's pins):

```sh
python3 -m venv /tmp/audit && /tmp/audit/bin/pip install --require-hashes -r requirements/tools.txt
/tmp/audit/bin/pip-audit -r requirements/runtime.txt --require-hashes --disable-pip
```

### Releases

A tag `v<version>` publishes the hub: `release-hub.yml` uploads the sdist
and wheel to PyPI as `maity-hub`, and `publish-image.yml` pushes the image,
so both carry the same version. The tag must name the version in
`pyproject.toml` (`v0.1.0a1` for `0.1.0a1`), or nothing is uploaded.
Versions are pre-releases (`0.1.0aN`) before the beta, as the CLI's are. The
wheel carries the dashboard (`web/`) and the join page (`docs/join-page.md`);
`hatch_build.py` takes them from the checkout, and an sdist carries a copy
under `bundled/`, so a wheel built from it has them too. The same build and
its proof run in CI (`hub-dist`), and locally:

```sh
make release-build   # a fresh environment from requirements/release.txt; sdist and wheel to ../dist
make release-check   # the bundled files, a wheel rebuilt from the sdist, an install that serves the dashboard
```

Every deploy of maity.team is a release, so what runs there can be installed
under the same version. Per deploy:

1. A pull request raises `version` in `pyproject.toml` to the next
   pre-release (`0.1.0a2` after `0.1.0a1`) and folds the changelog: in a
   commit of its own, `python3 tools/changelog.py release <version>` from the
   repository root, its output unchanged, with the command in the commit
   message. It is the last merge before the deploy. A pull request merged
   after the fold leaves new fragments behind, so right before the final
   MERGE-READY the fold is redone on the newest main: merge main, undo the
   fold (`git revert --no-commit <fold commit>`), run the same command again,
   and commit both as one.
2. ci passes on the push of that merge to main (`ci-ok`).
3. The maintainer pushes the tag `v<version>` on that merge commit, and
   nobody else pushes a `v*` tag. The tag runs `release-hub.yml` (the package
   on PyPI) and `publish-image.yml` (the image), and both wait for ci on that
   commit and for hub-mariadb on the same hub files.
4. Ops deploys the image that tag built, pinned by its digest.

The deployment itself is unchanged: manual approvals, on the maintainer's
command.

### Migrations

Schema changes are Alembic revisions in `src/maity_hub/migrations/versions/`:
the older ones numbered `0001_…`, every new one named by the UTC
second it was made and its slug, `20261030102512_add_the_tasks.py`, so two
pull requests made in parallel never take the same id. To create one after
changing the table definitions:

```sh
make revision m="add the tasks"
```

It migrates a scratch SQLite file to the current head, compares it with the
tables defined in `maity_hub` and writes the revision on that head.
When `main` gets another migration before yours is merged, the branch has two
heads after merging `main`, the hub refuses to start on them, and CI fails
with a message naming the fix:

```sh
git fetch origin && git merge origin/main
make rechain    # python3 tools/rechain.py from the repository root
```

`tools/rechain.py` finds the one migration the branch adds and sets its
`down_revision` (and the `Revises:` line) to the head of `origin/main`; a
branch with two new migrations, or one that lacks a migration of `main`, is
refused with what to do.
Run `make format` on it before `make check` (the template imports `op` and
`sa` whether the revision uses them or not), and review the result:
autogenerate does not detect renames and writes nothing for data changes. Revisions must run on all three engines: use
`op.*` operations and SQLAlchemy types, no raw engine-specific SQL, and
name every constraint (the metadata's naming convention does that for
tables defined in code). SQLite runs in Alembic's batch mode, so column
changes work there too. Every revision has a working `downgrade()`; the
test suite migrates up and down on each engine. A revision spells out its
column types instead of importing them from `maity_hub` (autogenerate
writes `maity_hub.types.UtcDateTime()`; replace it as `0002` does), so
that a later change to the application cannot change what an old
revision creates.

### Data model

The tables are SQLAlchemy Core definitions in `src/maity_hub/tables.py`,
one per entity of `docs/architecture.md` §3.6, with field names from
`docs/api.md`; migration `0002` creates them. Ids come from
`maity_hub.ids` (prefix and random part, never parsed), timestamps are
`maity_hub.types.UtcDateTime` (aware UTC in Python), Markdown bodies use
`MARKDOWN_TEXT` (`MEDIUMTEXT` on MySQL/MariaDB), JSON values `JSON_VALUE`
(JSONB on PostgreSQL; an absent value is SQL NULL). On MySQL/MariaDB every
table is `utf8mb4` with the binary collation, so comparisons and unique
constraints are exact as on the other engines. The test suite checks on
each engine that the migrations create exactly the tables, indexes,
foreign keys and unique constraints in code; the CHECK constraints, which
that comparison does not see, have tests of their own.

Memory (`docs/api.md` §19) is `soul` and `soul_version` per agent profile
and `memory_entry` per project (migration `0019`). A team's entries hang
on its membership, role entries on the role's actual name (including a
project role), and community entries on neither. Members read and write
their own role's store; owner/admin members may choose another role,
while a proxy stays within its own role. Guests read without writing.
The resume packet carries the current role's newest entries in its own
4 KiB compact block, with `total` and `truncated` beside the team and
community blocks. A role change changes the store read, leaving earlier
entries with their original role. A deleted entry stays as a
tombstone with its text removed. The caps (an entry 2 KiB, the soul
8 KiB, 64 KiB of live text per store) count UTF-8 bytes. Agent soul proposals
(`POST /me/soul/proposals`, `{markdown, reason}`) return a personal consent
for the profile owner, who can answer outside the project. Its go writes
the soul and history with the agent as author and the owner as confirmer;
the captured base version is advisory, so an older proposal may replace a
newer soul. Project lists and events never contain the proposed text.
The role-memory migration refuses a downgrade while role entries or
their tombstones exist, preserving history the older schema cannot hold.

### An agent's motifs

The pictures an agent may choose instead of an upload (`docs/api.md` §6.10)
are part of the hub's code, in `src/maity_hub/avatar_motifs/`: one PNG file
per motif and `manifest.json` beside them.

```json
{ "version": 1, "license": "MIT", "attribution": "…",
  "motifs": [ { "name": "fox", "theme": "classic", "label": "Fox",
                "alt": "A fox face, orange on teal", "file": "fox.png" } ] }
```

`name` and `theme` match `[a-z][a-z0-9-]{1,31}`, `name` is unique across
the themes and never changes for a motif, `label` is at most 40
characters and `alt` at most 120, both English; `file` is a file name in
the folder, a PNG of 512 × 512 pixels and at most 64 KiB. The order of the
file is the order of the list: the themes in the order of their first
motif, and each theme in the file's order. The hub reads and checks the set
at its start (`maity_hub.motifs`) and passes every motif through the
upload's decoder and encoder, so it is kept at 256 pixels and in every
smaller size. A set that fails the check is not served and the log says
why; an operator does not add to it. A new motif is a new file and a new
line; a renamed one is a new motif, and agents that chose the old name show
their generated picture until a set holds it again.

Why it is built this way: [`context/`](context/index.md).
