Design rationale for qm-rs. README.md is the user-facing doc; this file is
why things are the way they are, and what is deliberately not solved.
Upstream QM is a TypeScript core with Postgres, four plugin packages, a CLI,
and adapters for four agent harnesses. This port keeps the architecture and
drops the periphery. What survived is the part that makes QM QM: scopes as the
unit of ownership, one orchestrator every surface flows through, an org floor
that narrower scopes can only tighten, and a small fixed tool surface with
execute at its centre.
The substrates that a deployment might reasonably swap sit behind traits —
Harness, Sandbox, PluginHost. The stores are concrete SQLite structs
instead, because a trait per store would be ceremony for one implementation;
they take a pool and can be swapped wholesale by rewriting store/.
sql/migrations/NNNN_<what>.sql files are include_str!'d into
db::MIGRATIONS, applied in order, each in its own transaction, tracked in
schema_migrations.
Why files plus a registry rather than a directory scan: the binary must carry
its schema, so a deployment is one file plus templates/. Why a registry
rather than pure include_str! inference: the order is explicit and reviewable,
and cargo test asserts it stays ordered and unique.
Never edit an applied migration. A migration that has run somewhere is history; changing it makes two databases claiming the same version structurally different.
/admin renders applied against registered, so drift is visible without a
shell.
- Expressions are prohibited in
PRIMARY KEY.command_approval_grantswantedCOALESCE(session_id, '')in its key; insteadsession_idisNOT NULL DEFAULT ''and an org-wide grant stores''. A nullable column in the key would have made it non-unique, becauseNULL != NULL. - WAL is set once before the pool spins up. Two connections racing on the
journal-mode change of a fresh database both fail with "database is locked".
busy_timeoutis set first in the connection customizer for the same reason.
session_entries.seq is dense and per-session. SessionStore::append reads
MAX(seq)+1 and inserts inside one IMMEDIATE transaction, so two concurrent
turns on the same session cannot be handed the same number. A deferred
transaction would let both read the same max before either writes.
Ported from upstream's command-policy.ts, with one deliberate improvement.
Rules match a normalized command, not the raw string, so quoting cannot smuggle a flag past a rule:
rm '-rf' /x → rm -rf /x
rm \-rf /x → rm -rf /x
sh -c 'rm -rf /x' → the payload is appended and scanned too
Nesting is bounded at depth 8, so a pathological sh -c "sh -c \"...\"" chain
terminates.
Upstream replaces any quoted string that is not a bare word with ''. That
keeps echo 'notes about mkfs and disks' inert — good — but it also erases
psql -c 'DROP TABLE users', so the destructive-SQL rule can essentially never
fire.
Here, quoted arguments are still dropped except for a known list of
interpreters and database clients (CODE_ARG_BINARIES: shells, psql,
mysql, sqlite3, python, node, …). For those, quoted arguments are code
and are scanned. Both properties hold:
echo 'notes about mkfs' → allowed
psql -c 'DROP TABLE users' → approval
sqlite3 app.db 'DROP TABLE x' → approval
Rules are scanned in order and the last match wins, so a scope rule appended
after the org floor can escalate. But a deny anywhere in the composed policy
wins outright — a scope cannot append an allow that overrides the floor's
mkfs denial. compose_policy also refuses to let a scope turn an allowlist
floor into a denylist.
rule:<index>:<pattern>. Approving "always" for rm -rf build covers
rm -rf dist too, because the human approved the class. Keying on the exact
string would make "always" almost useless and train people to approve blindly.
Ord on SecurityPosture is the ranking (Dangerous < Auto < Strict), so
composition is a max and a scope can only tighten. An unrecognised posture
string parses to None, which inherits — a typo must never widen the org's
posture.
Screening fails closed everywhere:
- an unparseable model verdict →
strict; - a plugin screener that traps or returns anything but
auto→strict; - a screener that is unreachable → an
unscreenedverdict, and the content is prefixed with an explicit "this was NOT security-screened" marker rather than passed off as clean. Silence and safety must not look the same.
first_json_object scans for a balanced {...} ignoring braces inside
strings, because models wrap JSON in prose and code fences.
The notebook grammar lives in src/memory.rs; persistence in
src/store/memory.rs. Keeping them apart means the fold/dedupe/cap rules are
testable without a database, which is most of what there is to get wrong.
A fact the agent concluded itself is trusted. A fact repeated from a web page, a tool result, or another person's message is not, and it is declawed before it lands:
(2020-01-01) was hired in 2020 → on 2020-01-01: was hired in 2020
key rotated (said in #ops) → key rotated [claimed source: #ops]
Only the capture path stamps a real (YYYY-MM-DD). That is what keeps "when
did I learn this" trustworthy: nothing else can write that prefix.
revision = sha256(content). Two writers producing identical content converge
on the same token, so a redundant write is a no-op rather than a spurious
conflict. The web editor compare-and-swaps on the revision it loaded; a stale
one is reported, never silently overwritten.
Recall is capped at 6000 characters and the notebook at 300 facts. Both keep
the tail — recent context beats old context. cap_tail splits on character
boundaries, so a multi-byte notebook does not panic.
HMAC-SHA256 over a canonical encoding of (scope, name, description, sorted capabilities, body, sorted files), with \x1f/\x1e separators so text
cannot be moved across a field boundary without changing the signature.
The signing secret is generated once and persisted in settings.
Regenerating it per boot would invalidate every stored signature, silently
hiding every published skill after a restart — a failure mode that looks like
data loss.
A skill whose signature no longer verifies is hidden from turns, not executed. Skill bodies are instructions to a model; a database-level attacker who can rewrite one owns the agent otherwise.
Granting a capability updates a separate column and does not re-sign, so the manifest signature stays valid while permissions change independently.
A grant on file:notes/plan.md surfaces to the agent as shared/plan.md.
Upstream derives that path from the basename alone, so two grants with the same
basename both mount at shared/plan.md and the second silently shadows the
first — the agent reads the wrong scope's file.
Here, a collision falls back to shared/<owner>-<basename> and then to the
full owner path, which is unique by definition.
A paused tool call is a row in pending_approvals, plus an approval_request
entry on the transcript. A pause therefore survives a restart, which the
in-memory alternative would not.
resolve is a conditional update (WHERE resolved_at IS NULL) returning
whether it changed a row, so a double-click cannot approve twice. Resuming
checks that the approval belongs to the session being resumed, so a guessed
request id is not usable from elsewhere.
The original question is recovered from the last user entry rather than
duplicated into the approval row. One source of truth beats two that can drift.
The unique index on (cron_id, fire_key) where fire_key is the scheduled
instant is the whole mechanism. claim_fire inserts with
ON CONFLICT DO NOTHING and reports whether it won. A second scheduler tick,
or a restart mid-fire, claims nothing.
The schedule advances whatever the outcome — failure, refusal, or a pause waiting on a human. A cron that failed must still run tomorrow, and an unattended cron waiting for approval must not block its own schedule.
max_catchup_secs skips a fire that is further behind than the window, still
advancing the schedule. Without it, a server that was down for a week fires
seven days of backlog at once.
Re-enabling a disabled cron re-arms from now, for the same reason.
Every fire of one cron shares a thread (cron:<id>), so the agent sees what it
said last time.
Hand-written, 5-field, with the standard day-of-month/day-of-week OR semantics
(when both are restricted, either matches; otherwise both must). Timezones go
through chrono-tz.
DST is handled explicitly:
- ambiguous (fall-back, the hour that happens twice) → the earlier instant, so a daily job runs once rather than twice;
- nonexistent (spring-forward, the hour that is skipped) → the candidate is rejected and the search moves on, so a 02:30 job skips that day rather than silently shifting an hour.
The search bound is 4 years, which covers every Feb-29 rule.
Confinement, not isolation. Commands run as the server's own user in a per-scope directory. This is the same trade upstream makes for its local sandbox backend; its cloud backends (Fly machines, AWS microVMs) are what provide real isolation, and they are not ported. This is why the command policy is not optional.
What is enforced:
..and absolute paths are rejected before touching the filesystem;- the resolved path is then checked against the canonicalized root, which catches a symlink planted by an earlier turn pointing out of the sandbox;
- scope directory names are sanitized, so a crafted scope ref like
personal:../../etccannot climb out of the sandbox root; env_clear()— the server's own environment does not leak into a scope. Only the materialized keychain, plusHOME/PATH/PWD/QM_SCOPE.
Output is capped keeping the tail: errors land at the end of a command's output far more often than the start.
Values are stored in the SQLite file as written — not encrypted at rest. The database file is the trust boundary: anyone who can read it can read the secrets, exactly as anyone who can read it can rewrite a skill.
That is a deliberate scoping decision, not an oversight. Encrypting at rest without a key-management story (an OS keychain, a KMS, an operator-supplied passphrase) moves the secret rather than protecting it. What is done instead:
- values are never rendered — the UI shows metadata only;
- values never reach the audit log or any tracing field;
Debugis hand-written for the types holding credentials (OpenAiHarness,TelegramConnector) so a panic or a log line cannot print a key;- keys are validated as shell-safe identifiers, since they become environment variable names.
Run the database on a filesystem you would put an .env file on.
Harness has two methods: run_turn (drives the model and the tool loop) and
one_shot (the utility calls — titles, screening). The tool loop lives in the
harness because different providers structure it differently; the tools
live outside it, in ToolContext, because they are the same everywhere.
one_shot returns Option<String>. None means "unavailable", which callers
must handle explicitly — that is what makes the unscreened path visible rather
than looking like a clean verdict.
History is rendered as plain conversation turns rather than replayed as real
tool messages: replaying tool_call/tool_result pairs needs the exact call
ids the provider issued, which are not durable across turns. Tool traffic folds
into [called execute] / [tool result] … notes instead. thinking and
approval rows never reach the next prompt.
It is not a stub. It drives the real tool surface from directives in the
message text, which is what lets tests/smoke_test.sh exercise the entire
pipeline — dispatch, policy, approvals, entry emission, persistence — with no
network and no credentials. Every security property in this document is
verified against a running server that way.
Upstream calls two different things "plugins":
- surfaces (Slack, web UI, admin, portal) — whole applications holding long-lived sockets, timers and OAuth state;
- deployment extension points (
BrokeredLayerTool, the pluggable screener, policy hooks) — pure functions over bytes.
WasmEdge fits the second and not the first. A Wasm module is a function; making it hold a socket means hand-writing a host-import surface for every syscall it needs. So the web UI and the Telegram connector are native in-process Rust, and Wasm runs the three hooks where the shape actually matches.
The ABI is cloud_ai_gateway's (allocate / run, packed
(ptr << 32) | len), so modules and authoring tooling carry across the two
projects.
Each call gets a fresh store and instance. That costs an instantiation per call and buys the property that matters for a multi-tenant agent: one scope's call cannot observe or corrupt another's module state.
A module that fails is logged and skipped — middleware is an extension point,
not a gate. The one exception is screen, which fails closed.
Without the wasm feature, configured modules are reported as inert on
/admin rather than silently ignored: an operator who configured their own
screener and got a build without WasmEdge is falling back to the model
screener, and needs to know.
CSS and JS are Tera templates served from /assets/* by render_css /
render_js. There is no static directory and no build step; deployment is the
binary plus templates/.
Tera auto-escapes HTML, including / as /. That is correct and worth
remembering when grepping rendered output in a test.
A unit test asserts every template the router renders actually parses, so a
missing {% endblock %} fails at cargo test rather than on the first request
to that page.
Session cookies, magic links and API keys are 256-bit random values shown to the holder exactly once and stored only as a SHA-256 hash. Read access to the database therefore does not yield a live credential — the same reasoning that makes the skills table store a signature rather than trusting its own rows.
OsRng, not a seeded generator: these are credentials, and a predictable one
is a session anyone can mint.
With no admin_email, allowed_emails or allowed_domains, nobody can
sign in, and the server warns at boot. Defaulting to "anyone with an email
address" would make a public deployment an open door; the safe reading is the
one that requires an explicit decision.
Domain rules match exactly, never as a suffix — acme.test does not admit
x@evil-acme.test.
[auth].admin_email is written onto the [org].admin principal on first boot.
Without that, sign-in — which resolves an address to a principal by email —
would not find the boot-created admin, and would mint a second principal
(ada-2). The configured admin could then never actually be the admin. This
was a real bug, caught by the smoke test.
An address that may not sign in gets the same response as one that may: the same redirect, the same page. Distinguishing them would turn the form into a membership oracle for the organization. Delivery failures are logged, never shown.
Requests are rate-limited per address, so the form cannot be used to mail-bomb someone who is allowed to sign in.
consume_login_token is a conditional UPDATE ... WHERE consumed_at IS NULL AND expires_at > now that reports whether it changed a row. Following the same
link twice — or racing two tabs — yields exactly one session.
Creating an API key requires a browser session. A key that could mint further keys would turn one leak into permanent access that revoking the original does not undo.
CurrentUser is an axum extractor that every page and API handler takes as an
argument. A handler cannot forget to authenticate, because it will not compile
without one. Page requests reject to a login redirect carrying next; API
requests reject with 401. next is validated as a local path, or the login
form becomes an open redirect.
The API used to take its actor from the request body. It no longer does — the actor is the authenticated principal, and only the org admin may override it, which is audited.
Socket Mode is the default because it matches the property the Telegram connector already has: outbound only, no public URL, no inbound port. The Events API path exists for deployments that already terminate HTTPS.
Both converge on handle_and_reply, so routing, dedupe and posting cannot
drift between them.
handle_event decides what to say and returns it; handle_and_reply posts
it. This is not merely tidy: it makes the whole filtering, routing and dedupe
path testable with no network round trip, and it stops a delivery failure being
mistaken for a turn failure. The first version did not split them, and the
tests silently reached out to slack.com.
HMAC-SHA256 over v0:<timestamp>:<body> with the app signing secret, compared
in constant time, plus a five-minute age bound. Both halves matter: the HMAC
proves Slack sent it, the timestamp stops a captured request being replayed.
Nothing in the body is parsed until it verifies.
Slack retries anything unacked within three seconds, and a turn takes longer.
So the connector acks immediately and does the work after — which is only safe
because of the dedupe table. slack_event_dedupe claims each event id with
ON CONFLICT DO NOTHING, exactly like cron_fires.
A channel maps to one scope but each Slack thread maps to its own session, so two conversations in the same channel do not interleave into one transcript.
The mock harness proves the pipeline works. It cannot prove a real model can drive it, because the mock is told exactly which tool to call. Two things only showed up against a live model:
Asked to schedule "09:00 every weekday", the model called cron with both a
cron expression and an every_secs interval. normalize correctly refuses
that — but the tool bounced the error back, the model produced identical
arguments, and the turn burned its entire 12-step budget without scheduling
anything.
Two fixes, both needed. The schema now states the exclusivity where the model reads it, and the tool resolves the ambiguity rather than erroring: a cron expression is the more specific statement of intent, so it wins and the redundant interval is dropped. An error a model cannot act on is a bug in the tool, not in the model.
The general rule: a tool result that a model will answer identically is a loop, not a message. Prefer resolving an ambiguity deterministically over returning an error the caller cannot fix.
The browser journey opened a session in channel:eng and failed — because the
scope picker only offers scopes the signed-in principal can actually reach, and
Ada is not a member of that channel. The app was right; the test was wrong. It
now uses org:acme, the shared scope every internal principal reaches, which
demonstrates the identical property.
That did surface a real gap, recorded below: there is no UI for creating a channel or managing its membership.
The first version of this was going to be an invites table and an admin form.
Reading upstream's identity-service.ts and onboarding.ts first changed three
decisions:
Membership is a deny-list there, not an allow-list. classify() returns
internal for anyone who is not explicitly deactivated, because being in the
Slack workspace is the membership. A web-first deployment has no workspace to
inherit that from, so allowlist is the default here — but denylist is
offered too, because upstream's model is right whenever something else already
bounds the perimeter. Deactivation is the offboarding verb in both.
There is no invites table. An invited person is a principal with an email
address, so principals already models it. Sign-in admits a known active
principal by email; the admin form just creates one. That collapses "invite",
"exists" and "offboard" onto rows that already had to exist.
Onboarding is a conversation, not a form. Upstream tracks it as a marker
line in the person's own memory notebook — Onboarding: completed v2 on 2026-01-01. — and prompts the agent to walk them through setup while no marker
is present. Ported verbatim in src/onboarding.rs. Memory as the source of
truth means no second table, and the person can see and edit their own state.
Version-matching is whole-word, so v1 does not match v10 and bumping the
version genuinely re-runs onboarding.
Groups are keyed by participants. Upstream's groupParticipantsKey is the
sorted, deduped, joined participant list, so the same people resolve to the same
group whichever order they are listed in and whichever surface they arrive on.
upsert_group returns any existing group over exactly that set rather than
creating a second one.
axum::Form deserializes with serde_urlencoded, which cannot put a
repeated key into a Vec. A member picker posts members=ada&members=dana, and
the extractor rejected the whole body with invalid type: string "ada", expected a sequence — a 422 the browser showed as a bare error page.
The group form now reads its body through RawForm and parses it with
form_urlencoded. Anything else that posts a checkbox group must do the same;
Form is only safe for flat, single-valued fields.
Worth noting how this was found: the tutorial e2e caught it, because the
tutorial is generated by actually performing it. A unit test over the handler
would have passed a constructed GroupForm and proved nothing.
Beyond the "not ported" list in README.md:
- Single instance. No leader election and no distributed lock. Two processes against one SQLite file will contend; cron claiming is safe, but nothing else is designed for it.
- Keychain secrets are not encrypted at rest — see above.
- The sandbox is not isolated — see above.
- No streaming replies. SSE reports turn progress (a tool started, a reply is being written); the reply itself lands when the turn completes.
- Entitlement is checked at the surface, not in the store.
scopes_forandrequire_scope_accessgate the web handlers; a store method called with an arbitrary scope will happily read it. Fine while the orchestrator is the only caller, worth revisiting if that stops being true. - No CSRF tokens. Session cookies are
SameSite=Lax, which blocks cross-site form posts in current browsers, and every mutating route is a POST. That is the whole defence; an explicit per-form token would be stronger. - Sign-in is magic-link only. Upstream can defer to an external identity provider; there is no OIDC path here.
- Admin is a single principal.
[org].adminnames one id. There are no admin roles, and no per-scope delegated administration. - Groups are managed, channels are not.
/admin/groupscreates groups and binds external conversations to them. Channels (channel:<id>) are still created only by the connectors, from the rooms they see. Two shared-scope kinds with one management surface is one too many; groups should probably absorb channels. - No directory sync. Upstream replaces the whole directory from Slack, and deactivates people who left the workspace. Here the admin is the source, so offboarding is manual.