Skip to main content
Glama

Kairos

CI

Website · ▶ Try it in your browser — the actual Python server on Pyodide/WASM, nothing leaves your machine.

when2meet-style scheduling polls — self-hostable, reverse-proxy-auth friendly, agent-first API (OpenAPI + llms.txt + MCP).

Quickstart

uvx --from kairos-scheduler kairos          # SQLite + demo auth on :8003

or with Docker / a real database:

KAIROS_DB_URL=mysql://user:pass@host:3306/db \
KAIROS_AUTH=header SESSION_SECRET=$(openssl rand -hex 32) \
uvx --from 'kairos-scheduler[mysql]' kairos --host 0.0.0.0

Related MCP server: timergy

Container

Dockerfile + compose.yaml in this repo — SQLite on a named volume, non-root, no build toolchain in the runtime layer.

podman compose up -d                  # build, run, http://127.0.0.1:8003/
podman compose logs -f kairos

The database is on the volume, so down and up keep every poll (down -v deletes them). It publishes on loopback only and runs in demo auth, which means one shared owner and no authentication — fine locally, never on a public interface.

Real deployments add a TLS terminator and either Kairos-managed OIDC or an OIDC proxy in front:

cp .env.example .env && $EDITOR .env   # secrets; compose refuses to start without them
podman compose -f compose.oidc.yaml up -d     # Kairos is the OIDC client (no auth proxy)
podman compose -f compose.proxy.yaml up -d    # oauth2-proxy in front, Kairos reads headers

MariaDB instead of SQLite, on the same image: podman compose -f compose.yaml -f compose.mysql.yaml up -d.

Read docs/design/self-host-hardening.md before exposing this to anyone — TLS, the trusted-proxy allowlist, backups, and the choices this image deliberately leaves to you (base image, registry, retention).

Features

  • Full-day or time-slot polls, when2meet drag grids, heatmaps

  • Public share links + personal email invites (required/optional participants)

  • Convergence light: collecting → ready / partial / blocked

  • Idempotent smart reminders + per-participant contact audit trail

  • Decide a final date → .ics download + "email everyone" with the file attached

  • Light/dark colorblind-friendly theme (Dalton)

  • Agents: REST API (Bearer), /llms.txt, OpenAPI, Swagger UI, MCP server

  • Reverse-calendar (optional): push candidate slots into the respondent's calendar instead of reading their free/busy — subscribe-able feeds + native iMIP Accept/Maybe/Decline. Off by default (see below).

Reverse the calendar (iMIP) — optional

Instead of asking for calendar access, Kairos can push the candidate slots into the respondent's calendar and let them Accept/Maybe/Decline — capturing what's truly blocking, not mechanical free/busy. Two layers, both opt-in:

  • Candidate feed (KAIROS_FEED=on) — a subscribe-able, disposable .ics calendar of the poll's slots. The per-invite feed (/p/i/<token>/feed.ics) embeds deep-link Accept/Maybe/Decline URLs; tapping one records that slot and lands on the poll page (the instant surface — subscribed feeds refresh slowly, Google ~daily, so never rely on the calendar reflecting a vote quickly).

  • Native iMIP invitations (KAIROS_IMIP=on) — real METHOD:REQUEST invites with Accept/Maybe/Decline buttons. KAIROS_IMIP_ORGANIZER is the mailbox replies route to; it must equal the IMAP mailbox Kairos polls (KAIROS_IMAP_HOST/PORT/USER/PASSWORD/MAILBOX). Schedule POST /api/imip/poll (cron / systemd timer, Bearer auth) to ingest replies; POST /api/polls/{id}/imip-decision sends the decided slot as a native invite.

Cross-client reality (verified live): Outlook and Apple render native Accept/Maybe/Decline; Gmail does not for a Gmail-organized event (Google policy) — the deep-link Accept/Maybe/Decline in the event description covers it, so every client gets one-click RSVP. For native Gmail RSVP, use a non-Gmail (custom-domain) organizer.

Agent-native (no API key): the invite link self-describes — GET /p/i/<token>/agent.json returns the options, your current vote, and the one-click vote URLs (also feed.ics, and /s/<slot>/<yes|maybe|no> to vote). Hand your assistant the link and it RSVPs for you. See /llms.txt.

See docs/design/reverse-calendar-imip.md and issue #23 for the full design.

Deployment model

Kairos supports three families of owner identity, and they are layers, not alternatives — pick the owner one, and the capability one applies regardless.

Respondents never need accounts in any of them: share links and invite tokens are self-contained.

Owner auth

Wiring

Use it when

demo (default)

zero

local trial; one shared owner, no identity check

oidc

~4 env vars

anything real. Kairos terminates OIDC itself; no auth proxy

header

an infrastructure project

you already run Shibboleth, OpenAthens, oauth2-proxy, Authelia, Cloudflare Access or Tailscale

none

zero

public/respondent-only; the management UI is disabled

capability

zero + working SMTP

hosted accountless. No account and no proxy: we mail the creator a manage link, and holding it is the credential. The link works once — opening it trades itself for a private browser session — so keep it.

The oidc mode is the primary multi-user self-host path because it is the only one that is both multi-user and cheap — nginx cannot terminate OIDC, which is why oauth2-proxy and Authelia exist as separate boxes. See docs/design/oidc-login.md for the whole picture. header mode is unchanged and still the right answer inside an institution that already has a broker; see compose.proxy.yaml for that wiring.

KAIROS_AUTH=oidc \
KAIROS_OIDC_ISSUER=https://id.example.org/realms/main \
KAIROS_OIDC_CLIENT_ID=kairos KAIROS_OIDC_CLIENT_SECRET=… \
KAIROS_OIDC_ALLOWED_SUBJECTS=<sub-from-your-IdP> \
SESSION_SECRET=$(openssl rand -hex 32) \
uvx --from kairos-scheduler kairos --host 0.0.0.0

In capability mode there is nothing to wire: KAIROS_AUTH=capability, a SESSION_SECRET (required — the deployment refuses to boot without it, because that secret signs the cookie which is the entire management credential) and working outbound mail, because the link is the credential — plus KAIROS_TRUSTED_PROXY_CIDRS and KAIROS_PUBLIC_URL, and KAIROS_RATE_LIMIT=on, without which anonymous poll creation and link re-requests are an open mail relay from your own domain. Every boot says which of these is missing. Creation asks for an email address, mails {prefix}/manage/<link>, and opening it exchanges for a signed session (KAIROS_CAPABILITY_SESSION_HOURS, default 12). Four consequences worth knowing before you enable it:

  • it needs mail that works — Kairos refuses to create a poll it cannot send a link for, rather than minting a poll nobody can reach;

  • the link is single use — a second device, or a link eaten by a mail prefetcher, needs POST {prefix}/manage/link with the creator's address, which Kairos serves off every /manage page;

  • the live token lands in your access log. GET {prefix}/manage/<link> puts the capability in the request line, so anything logging requests — uvicorn, nginx, a CDN — has it. Kairos never writes a token or a creator address to its own logs, and the token is retired the moment it is exchanged, but for a link nobody ever opens it is valid forever and logged forever. Redact that path or keep those logs short-lived;

  • KAIROS_PUBLIC_URL is load-bearing here in a way it is not elsewhere. A wrong origin means a broken link in other modes; here it means a phishing mail sent from your own sender and branding, carrying a manage-link URL on the attacker's host.

Anti-spam in capability mode: two gates, one knob

Anonymous poll creation is the only unauthenticated write surface in Kairos, and in this mode a created poll sends mail immediately, so two gates sit on it. Both are off by default and inert in every other auth mode — header, demo, oidc and none are unchanged, which the test suite asserts on the rendered bytes rather than on intent.

A human check on creation (A1). Set KAIROS_TURNSTILE=on with KAIROS_TURNSTILE_SITE_KEY and KAIROS_TURNSTILE_SECRET from a Cloudflare dashboard widget. POST {prefix}/new then requires a Turnstile token, which Kairos verifies server-side against Cloudflare's siteverify — the token in the form is a claim, not a fact. It is a click-to-load facade: nothing is fetched from Cloudflare until the visitor presses the button, so your privacy page stays true and no consent banner is needed. KAIROS_TURNSTILE is on by default once KAIROS_HOSTED is on, an unrecognised spelling reads as on (with a boot warning) rather than quietly removing the gate, and a deployment that turns it on without both keys refuses to boot.

Do not use Cloudflare's public testing keys here. They are the obvious way to try this locally, and they cannot work: 1x…AA verifies any token string and reports no action, so the check is decorative and every submission is refused.

These variables must be added to the container, not just to .env. The shipped compose.yaml passes exactly four KAIROS_* variables and has no env_file:, so KAIROS_TURNSTILE, its two keys, KAIROS_HOSTED and KAIROS_AUTH do not reach the app unless you put them in the kairos service's environment: yourself — the same way compose.proxy.yaml and compose.oidc.yaml add theirs. The trap is that podman compose reads .env for interpolation (${KAIROS_TURNSTILE:-}), which only substitutes values already listed under environment:; a key that exists in .env and nowhere else is silently dropped. If you would rather pass everything through, add env_file: .env to the service instead. Boot says so explicitly if it sees one.

No mail to strangers before the creator has opened their link (A2). Nobody may send mail on a poll's behalf — reminders, the final date, an invite, a native iMIP request — until someone has opened the manage link that was mailed to the address that created it. That is what stops an API key, or a leaked one, from turning the deployment into a mail cannon: the poll can be created, but it cannot send. The two mails that go to the creator's own address are deliberately not gated, because they are how a creator becomes verified in the first place.

Both are ceilings, not limiters: what bounds how much mail one verified creator can send is KAIROS_RATE_LIMIT=on, which is a required step for this mode and not an optional one.

There is no logout route in this mode: a shared browser ends its session by expiry or by opening a fresh link. See docs/design/multitenancy-hosting.md.

KAIROS_OIDC_ALLOWED_SUBJECTS is required — an empty allowlist refuses to boot, because Kairos will not admit "anyone the IdP vouched for". A successful exchange is necessary and not sufficient; the identity has to resolve to a known subject, and the allowlist is re-checked on every request, so removing a subject signs that person out immediately. The pattern is the ETH deployment's own directory check. (An email-domain allowlist is available and coarser; see the guide.)

Before exposing a hosted instance in header mode, set KAIROS_TRUSTED_PROXY_CIDRS. Header mode trusts whoever sets the identity headers, so anything that can reach the app port directly can assert any identity — including becoming any poll owner. Set it to the proxy's address(es), e.g. KAIROS_TRUSTED_PROXY_CIDRS=10.0.0.0/8,127.0.0.1. Requests from any other peer are refused with 403 and logged. Unset means trust everyone, which is only safe while the port is unreachable except through your proxy. The check uses the real transport peer, never X-Forwarded-For; if you run uvicorn yourself rather than via the kairos entrypoint, pass proxy_headers=False so it does not rewrite that address before Kairos sees it. In oidc mode the CIDR list still gates that edge, but it is no longer the identity boundary — the subject allowlist is. Every boot logs which is in force.

Before exposing a hosted instance, set KAIROS_RATE_LIMIT=on (and KAIROS_TRUSTED_PROXY_CIDRS, which it depends on), and consider handing out scoped API keys rather than the single all-power one. See Rate limiting below — the public token routes let anyone with a link create rows, and the owner routes can fan out mail to every address on a poll's participants table. Outbound mail (operators): SMTP_FROM is the address every message is sent from, and the poll owner appears only as the display name and in Reply-To — a poll owner's own address cannot be authenticated from your domain. Self-hosting? Stop here: point SMTP_FROM at your own mailbox, leave KAIROS_HOSTED unset, and nothing else changes. Set KAIROS_HOSTED=1 only when you send from your domain and therefore own its reputation — then KAIROS_FROM_DOMAIN is required, and Kairos refuses to send (rather than sending unauthenticated or from a personal mailbox) unless SMTP_FROM and KAIROS_IMIP_ORGANIZER are mailboxes on it. SPF/DKIM/DMARC are DNS records Kairos cannot publish or read, so it cannot confirm they exist — every boot logs the identity it is about to send as. docs/design/mail-auth.md has the exact records, the staged p=none → quarantine → reject plan, and how to verify them.

Cookie note for operators: Kairos itself sets only strictly-necessary cookies (session, signed response-edit token, theme preference) — disclosed on /privacy, no consent banner required (ePrivacy Art. 5(3) / Swiss TCA 45c exemptions). If you add analytics or any third-party embeds to your deployment, that changes — you'll need consent management.

If you turn on KAIROS_TURNSTILE: /privacy gains a third-party paragraph naming Cloudflare, and it is still true that no consent banner is required — because Kairos embeds the widget behind a click-to-load facade, so nothing is fetched from Cloudflare until the visitor presses the button. After that click the widget may set a cookie of its own, which is why /privacy says "no third-party cookie is set unless you press a button that says so" rather than claiming Kairos sets none. There is deliberately no setting to load it eagerly; if you proxy or rewrite Kairos to do that yourself, this exemption no longer applies to you and you need consent management.

Rate limiting (optional)

Off unless you switch it on, so self-host and the ETH/duplet deployment behave exactly as before. KAIROS_RATE_LIMIT=on puts a budget on the abuse-sensitive surface:

Rule

Applies to

Ships at

read

token pages, agent.json, feeds, .ics

120/min

respond

POST /p/<token>, POST /p/i/<token>

20/min

deeplink_vote

GET …/s/<slot>/<yes|maybe|no> — one per poll slot

300/min

create

POST /new

10/min

invite

POST /p/<id>/invite

30/min

send

remind, remind-selected, email-decision — actual SMTP

10/hour

login

GET …/oidc/start, GET …/oidc/callback — unauthenticated, one outbound call each

30/min

api

every authenticated /api call, charged to the bearer key

600/min

api_write

mutating /api routes

60/min

mail

/api routes that can send mail

20/hour

mail_force

/api/.../nudge with force=true — see scoped API keys

5/hour

The first six are charged to the caller's transport address; the last four to the bearer key, which an API client cannot vary — see the scoped-key section below for why that is the same limiter rather than a second one.

Override any of them with KAIROS_RATE_LIMIT_<RULE>="<count>/<window>" (second/minute/hour/day), e.g. KAIROS_RATE_LIMIT_SEND="30/hour". 0 switches that one rule off. An unparseable value — or a rule name that does not exist — refuses to boot rather than silently running unlimited.

deeplink_vote is sized against what the UI offers: agent.json gives an agent one vote URL per slot, 15 minutes is the smallest increment offered, and the default window is 09:00–17:00, so a full week of 15-minute slots is 224 slots = 224 requests, which fits in one 60s window. That is a realistic worst case, not a maximum — the date picker is an infinite-scroll calendar with no span cap. A poll larger than the budget needs either a raised KAIROS_RATE_LIMIT_DEEPLINK_VOTE or a sweep spread over more than one window.

Behind a reverse proxy: set KAIROS_TRUSTED_PROXY_CIDRS too

Budgets are charged to the real transport peer, never X-Forwarded-For: that header is caller-supplied, so a budget keyed on it would be one the caller sets for themselves.

But behind a proxy the peer is the proxy's socket address for every request — so if you front Kairos with a reverse proxy and have not set KAIROS_TRUSTED_PROXY_CIDRS, the whole deployment shares one budget, and create 10/min becomes the entire instance's allowance. Set it (you should anyway — see above), and Kairos then charges each request to the nearest hop in the forwarded chain that is not one of your proxies, which is the real client.

That resolution walks the chain right to left, skipping addresses inside the allowlist. It has to: X-Forwarded-For is built by appending, so a caller's own contribution is always at the left and the entry appended by your nearest proxy — about a socket it actually held — is always reached first. A caller rotating the left-hand value gets nowhere.

It also makes proxy_headers=False load-bearing: if you launch uvicorn yourself rather than via the kairos entrypoint, pass it, or uvicorn rewrites the peer from that header before Kairos sees it and the whole mechanism collapses. Kairos logs a warning at startup when limits are on.

Over-budget requests get a 429 with a Retry-After, as an HTML page in a browser and as JSON otherwise.

One instance, one counter set. The counters live in the serving process, so N app instances means an attacker gets N × the budget. That is the one limit to know about before scaling out; #36 chose SQLite on the same reasoning (its operational ceiling is a single writer). Move the counters to a shared store before running more than one instance.

What this does not stop: address rotation. A budget keyed on an address is evaded by never reusing one, and an attacker with an IPv6 /64 has ~2^64 of them. What the cap does bound is the ceiling on memory and CPU regardless of how many addresses arrive. Beating deliberate rotation needs an identity the client cannot vary — a cookie, or an API key — which is the bearer key #51 already introduces.

API keys: scopes and mail budgets

KAIROS_API_KEY is a single all-power key: one bearer credential that reaches every poll, every route, and every third party's inbox. That is fine for a self-hoster with one agent, and it is what the ETH/duplet adapter sets, so it stays exactly as it is. It is not fine for a key that ends up in an agent's environment, gets pasted into a dotfile, and sends mail from your domain.

So there is a second, opt-in way to hand out keys:

KAIROS_API_KEYS="k1:polls:read,respond;k2:mail:send;k3:mail:force"
  • ; separates keys, , separates the scopes of one key.

  • An entry is <key>:<scopes> or <key>@<tier> — never both, never neither. A bare key is refused, because that is the one typo that would silently mean full power; KAIROS_API_KEY is the only way to ask for that.

  • An unparseable entry refuses the boot rather than being skipped, so you are never running with a scope you believe in and do not have.

  • KAIROS_API_KEY keeps working alongside the keyring, and scoping wins if a key appears in both.

A scope is a capability, not a tenant: it says what a key may do, not which polls it may see. Any key holding polls:read sees every poll, because GET /api/polls is not scoped per poll — one deployment, one set of polls, which is the single-team model this API has always had. Per-poll isolation is #29.

Scope

Routes it unlocks

polls:read

GET poll, responses, invites, contacts, event.ics

polls:write

create / update / delete poll, add dates, decide, edit invitees and responses — implies polls:read

respond

POST .../respond — submit availability

mail:send

invite, nudge, email-decision, imip-decision, and add_dates(notify=true)

mail:force

nudge with force=true, the 24h cooldown bypass — implies mail:send

imip:poll

POST /imip/poll, the inbound mailbox poll

Default deny: a route a key has no scope for answers 403 naming the missing scope. GET /api/whoami reports what the presenting key holds, so an agent can find out instead of guessing — and the MCP server exposes it as whoami().

Two routes reach mail:send through a scope that is nominally something else (add_dates(notify=true), nudge(force=true)), because that is exactly how a send path hides inside a harmless-looking route. Both are checked.

force is not a licence to spam

force=true bypasses the reminder cooldown. It is an operator affordance for a human in the UI, so from the API it takes mail:force and its own mail_force budget of 5/hour. The web UI's remind-selected is unchanged: one click for a person, and the 24h cooldown still applies to everyone else.

Mail budgets

Knob

Default

What it bounds

On exceeding it

KAIROS_MAIL_MAX_RECIPIENTS

100

the recipient list a single request supplies — in practice invite, the only route whose list comes from the caller rather than from the poll

400, naming the knob. The caller still holds every address, so batching costs it nothing

KAIROS_MAIL_PER_POLL

2000/day

recipients one poll may mail, counted across every send path — API and web UI alike — and charged to the poll, so it holds whatever key asks

429 with Retry-After

The two failure modes are deliberately different. A caller-supplied list is refused outright; a fan-out over a poll's own participants is only budgeted, because a poll with more participants than the ceiling is a real meeting rather than an attack — and because the surfaces that reach it (the API's nudge, the UI's remind-selected) have no way to batch, so a 400 there would be a dead end.

Everything else that fans out — nudge, email-decision, imip-decision, remind, remind-selected — is bounded by the per-poll budget, whatever surface asks and however the calls are split. That budget is the only one of these controls a rotated or stolen key cannot escape.

Unlike the rate limits, these two ship on: they are keyed on nothing, so nobody legitimate is punished, and at the shipped numbers they are inert for a real workflow (2000/day is 500 participants × the ~4 messages a poll sends each). Raise them for a bigger meeting; 0 disables either one.

What these do not bound — read this before exposing an instance

With the shipped defaults, the total number of third-party recipients one key can reach is unbounded. Both mail budgets are per request and per poll, so a key that mails the per-request ceiling to a fresh poll each time is refused nothing: 40 new polls × 100 recipients is 4 000 recipients mailed and not one 429. Each poll gets its own allowance; there is no ceiling on the number of polls.

This is deliberate, and it is not an oversight:

  • It is not a regression. Before this change there was no per-request cap and no per-poll budget either, so the aggregate was unbounded then too.

  • ADR-0001/0002 require a deployment with nothing configured to behave exactly as it did, and the ETH/duplet adapter sets no rate-limit variable. A rate limit that fires by default is a behaviour change for it.

What closes the aggregate is KAIROS_RATE_LIMIT=on, which enables the per-key api / api_write / mail / mail_force budgets above — charged to the key rather than to the poll, so N polls do not mean N allowances. Set it, plus KAIROS_TRUSTED_PROXY_CIDRS, before exposing a hosted instance. Kairos logs which of the two states it is in at every boot, so you do not have to remember:

per-key rate limits OFF -> the TOTAL across polls is UNBOUNDED: each poll gets its
own allowance, so N fresh polls get N of them. Set KAIROS_RATE_LIMIT=on before
exposing this deployment.

Poll reach: which polls a caller may read

A scope says what a key may do; reach says which polls it may do it to. Until now a scope was the only answer, so polls:read meant every poll on the instance and GET /api/polls enumerated it — while the web UI, three routes away, let only the owner manage a poll. Same rows, two rules, the weaker one on the machine-facing surface.

KAIROS_POLL_REACH chooses the rule, on both surfaces:

KAIROS_POLL_REACH

web UI (/polls/{id}, event.ics)

API (/api/polls…)

unset / open (default)

any signed-in user

any authenticated key, every poll

scoped

the poll's owner — or anyone invited to it or already answered on it

only the polls the key's ~ claim names

Unset means scoped when KAIROS_HOSTED=on (a deployment we operate is the multi-tenant case, where open is an IDOR) and open otherwise, so a self-hoster and the ETH group deployment keep the behaviour they have today. An unrecognised KAIROS_POLL_REACH refuses the boot.

An unrecognised KAIROS_HOSTED (Y, enabled, 2, …) reads as scoped, and the boot log says so at WARNING. The mail gate still reads that value as "not hosted", which is right for mail — but this knob now decides who may read which poll, so a misspelling must not quietly select the permissive policy. Fix the spelling, or say KAIROS_POLL_REACH=open if the deployment really is self-hosted.

open is a deprecation, not a recommendation. It stays the default because every read that is legal today has to stay legal today (ADR-0001/0002), but it is the pre-#63 rule and it still reproduces #63/#64 in full: any authenticated caller reads every poll. Moving a deployment to scoped is three steps and no code:

  1. Audit. GET /api/whoami reports each key's reach claim; list the keys and what they should reach.

  2. Grant. Add ~<poll-id> per key, or ~* for the deployment's own service key and cron jobs. KAIROS_API_KEY already holds ~*, so the ETH/duplet adapter keeps the instance either way.

  3. Flip. KAIROS_POLL_REACH=scoped (or KAIROS_HOSTED=on) and read the boot line: it states which rule is in force, and the warnings name anything that is still off.

Under scoped, a key says which polls it reaches:

KAIROS_API_KEYS="k1:polls:read,respond~*;k2:mail:send~<poll-uuid>"

~* is the explicit instance-wide grant (also what KAIROS_API_KEY holds, so scoping a deployment never costs it the instance); ~<poll-id>+<poll-id> names some; omitting the clause reaches no poll — default deny. GET /api/polls returns what the caller reaches (an empty list, not a 403, for a key granted nothing), and GET /api/whoami reports the reach next to the scopes.

Flat owner-gating was the alternative, and it would have broken the group deployment: in header mode the authenticating proxy is the tenant boundary, so scoped admits everyone named on the poll rather than only its creator. Full reasoning, and the cases this does not cover yet, in docs/design/poll-reach.md.

On the web surface, scoped is only as strong as the identity it trusts. In KAIROS_AUTH=header mode the caller's identity is a request header, so with no KAIROS_TRUSTED_PROXY_CIDRS set, anyone who can reach the port can assert X-User: <a creator uid> and reach that creator's polls. scoped there is decorative until the app sits behind a proxy whose CIDRs are named — the boot log warns when it is not.

Two things scoped does not do, both pinned by tests so a later change is deliberate:

  • a refusal on the web surface is the missing poll's own 404, byte for byte — same status, same page, same empty detail, one function for both — because that surface has to read the poll in order to decide and must therefore not also tell a caller which of the two it was. The first version of this matched the status and not the body, and a 60-byte sentence on the refusal only was still an oracle. Bytes are the channel that was closed, not every channel: deciding reach costs two statements a missing poll never reaches, so a caller who times the two can still separate them. That is inherent to "or anyone named on the poll" and is written up as residual 6 in docs/design/poll-reach.md, which also carries the one existence oracle this leaves open (edit_poll_page, #29's surface);

  • a key that creates a poll it cannot reach is not auto-granted reach over it (nothing in the schema says which key made the row, issue #32) — the creation response carries a reach_warning naming the grant it needs instead.

Related MCP Connectors

Related MCP Servers