Kins & Co Matrix Stack // developer architecture
What you need in your head before touching the backend: repo boundaries, service topology, the deploy pipeline, the invariants CI enforces, and where the sharp edges are.
Two repos, one hard boundary
Everything server-side lives in kins-co-matrix. The public web edge in front of it lives in josiekins. Almost every confusing question about this system resolves to knowing which side of that line a thing is on.
kins-co-matrix — the buildingService definitions (Dockerfiles, configs, ops scripts) for all 7 Railway app services, DoseBot's full source, ops contracts + validation, health/recovery workflows. Railway deploys straight from this repo. Granting a sysadmin access = adding a collaborator here.
josiekins — the front doorThe Matrix Routing Layer only: Vercel rewrites in vercel.json, .well-known/matrix/* discovery generation, the /matrix-info onboarding page + server-map generator, and routing-parity tests. No server code.
default.conf (owned in kins-co-matrix) must agree with josiekins' discovery contract (matrix/contracts/matrix-discovery.json) on the homeserver, auth, and RTC endpoints. There is no automated check across the repos — it's a documented manual invariant. If you change either side, check the other.matrix/*.local.md files.System topology
Three layers: a Vercel edge that owns the public hostnames, seven Railway app services, and two managed Postgres instances. Federation traffic from other homeservers enters at the same public endpoints.
matrix/contracts/matrix-upstreams-and-routes.json, rendered into both root vercel.json and the generated dist/vercel.json (kept equivalent by the Routing Parity check). Don't reverse-engineer routes from behavior — read the contract.Where things live in kins-co-matrix
DoseBot's dist/ is committed on purpose: Railway runs exactly what CI verified, and verify:dist-sync fails the build if dist/ drifts from a clean tsc build. Rebuild and commit dist/ with every source change.
Push-to-deploy: git push is the deploy button
Every app service is GitHub-connected to main with a scoped root directory (its Docker build context — Railway builds with the service dir as root, so a Dockerfile cannot COPY from sibling dirs) and watch patterns (only a matching diff triggers that service's rebuild). Push once; only affected services rebuild, health-check, and swap in.
| Railway service | Root directory | Rebuilds when you touch |
|---|---|---|
synapse | matrix/synapse-service | that dir |
mas | matrix/mas-service | that dir |
cinny | matrix/cinny-service | that dir incl. nginx default.conf — check discovery contract on change |
livekit | matrix/livekit-service | that dir |
matrix-rtc-focus | matrix/matrix-rtc-focus-service | that dir |
emoji-pack-sync-job | matrix/emoji-pack-sync-job-service | that dir job service — builds image, runs on schedule/demand |
dosebot-matrix | services/dosebot | that dir healthcheck /healthz, 300s timeout |
postgres ×2 | managed registry images — not built from the repo | |
railway up is an emergency fallback only. Manual uploads bypass CI and desync the repo from production. If you must use it (documented in matrix/SKILL.md), reconcile the repo immediately after.Contracts & guards — what CI will hold you to
Operational invariants are written down as JSON contracts under matrix/contracts/ and enforced by npm run matrix:validate (locally and in CI). The ones you'll actually hit:
- Railway service manifest — the declared service set, roots, and roles must match the service dirs that exist.
- Duplicate script guard — four maintenance shell scripts must be byte-identical between
synapse-service/(canonical) andemoji-pack-sync-job-service/(copy, required by the build-root constraint). Edit the canonical copy only, thennpm run matrix:scripts:sync. Never hand-edit the copies. - DoseBot adapter contract — required env names must stay documented in
services/dosebot/docs/matrix-runtime.md. - Bootstrap invariants — literal content checks on
bootstrap-synapse.sh/bootstrap-mas.sh(feature flags, env wiring), folded into a declarative contract in place of the old inline grep wall inrun.sh. - dist-sync —
services/dosebot/dist/must equal a clean build (npm run verify:dist-sync). - DoseBot test suite — 251 tests via
tsx --test, run serially (--test-concurrency=1). CI runs them on every push.
Every contract is one entry in the Contract Registry (matrix/validation/scripts/lib/registry.mjs): 8 contracts total, each an ordered { name, path, assert } entry. matrix/validation/scripts/validate-contracts.mjs itself is a ~30-line dispatcher that just loads the registry and runs it — adding a new Ops Contract is a one-entry registration, not new dispatcher code.
npm run check at the repo root = matrix:validate + DoseBot tests + build. If that's green, CI will almost certainly be green.Invite-gated access, mechanically
The whole community hangs off one root space. Its join rule is invite; every community room is restricted with allow = [root space]. Space membership is therefore the single access credential — no per-room invites.
- Authoritative room set = the live hierarchy. DoseBot's scope resolver pages through
/_matrix/client/v1/rooms/{rootSpaceId}/hierarchy. TheMATRIX_ALLOWED_ROOM_IDSenv is a degraded-mode fallback used only when that fetch fails — it must be non-empty in production (startup assertion) but does not gate anything while the hierarchy is reachable, and it doesn't need to enumerate every room. - The join-rule gate self-heals. Each reconcile cycle compares every room's actual join rule to the expected one; with
MATRIX_GATE_ENFORCE=true(production since 2026-07-04) drifted rules are rewritten automatically. Watch forJoin-rule gate checklog lines:{"enforce":true,"compliant":37,"drifted":0}is the healthy signature. - The old approach is dead. A force-join cron (
space-membership-sync-job) used to stuff users into rooms; it's retired and frozen undermatrix/archive/. Don't resurrect it.
DoseBot internals — the 5-minute mental model
DoseBot is Matrix-only — the legacy Discord runtime (bot.ts, command-system.ts, the discord.js dependency) has been deleted; commands/*.ts now hold only platform-neutral command definitions consumed by src/core/command-registry.ts. matrix/runtime.ts is a thin ~435-line orchestrator: it wires the Matrix client and starts the loops, delegating env parsing to matrix/runtime-config.ts (RuntimeConfig, including Fallback Allowlist parsing). Interesting modules under src/:
admin/reconciler.tsA ~380-line scheduler, not the convergence logic itself. Reads Admin State (persisted approvals/bans/admins/dedup markers) as intent and runs each Enforcement Pass in turn against it, on a timer and after admin commands.
join-rule-gate.ts · ban-enforcement.ts · approval-enforcement.ts · admin-sync.ts · user-removal.ts · private-invite-revocation.ts, plus shared helpers in reconcile-support.ts. Each converges one aspect of live room state through its own narrowed client slice.
admin/scope-resolver.tsResolves the room Scope from the live hierarchy (source:'hierarchy'), falling back to the env allowlist (source:'fallback') only on fetch failure. Everything downstream — reconciler, map, commands — operates on this snapshot.
admin/admin-command.ts + admin-subcommands.tsadmin-command.ts is a ~90-line dispatcher over a 19-row registry in admin-subcommands.ts; help text and usage strings derive from the registry rows instead of being hand-written per command.
matrix/matrix-send-client.ts defines MatrixSendClient — the one interface + structural adapter every onboarding-side Matrix call routes through (a fake adapter stands in for tests). matrix/onboarding-flow.ts's OnboardingFlow owns the newcomer path end to end — claim-greeting dedup → greeting → Scope Map DM — behind that seam; runtime event handlers just delegate to it.
matrix/account-data-state-store.tsOne generic AccountDataStateStore backs both persisted stores: Admin State (dev.dosebot.admin.v1) and onboarding dedup state (dev.dosebot.space_onboarding.v1). The keys are intentionally kept separate — see docs/adr/0001-greeting-dedup-separate-account-data-key.md — because cross-process writes are last-write-wins.
services/dosebot/ — npm test (tsx, 251 tests, no build needed), npx tsc --noEmit, then npm run build and commit the regenerated dist/. Bot account is @dosebotmx:josiekins.xyz. Read services/dosebot/AGENTS.md before nontrivial changes.Ops automation
- Health probe every 10 min —
matrix-health.ymlhits the public endpoints (Synapse versions, MAS, Cinny, discovery, the/matrix-inforedirect). On failure it opens a deduplicated GitHub issue in this repo; with recovery enabled it can restart the failed service using the scoped Railway project token. - Emoji pack sync scheduled + on-demand — runs as both a GitHub workflow and a Railway job, authenticated as
@maintenance-botviaMATRIX_EMOTE_SYNC_TOKEN. - Live validation suite —
matrix/validation/run.sh live-health | live-auth | live-smokeprobes production read-only; the smoke test is also part of the josiekins deploy workflow. - Server map drift manual only — josiekins'
npm run matrix:map:check(needs the maintenance-bot token) verifies the /matrix-info server map against the live hierarchy. It is NOT wired into CI; run it after changing the space layout.
Dev quickstart & sharp edges
| Command | What it does |
|---|---|
npm ci && npm ci --prefix services/dosebot | install root tooling + bot deps |
npm run check | full pre-push gate: contracts + bot tests + build |
npm run matrix:validate | ops contracts + duplicate-script guard |
npm run matrix:scripts:sync | re-copy canonical maintenance scripts over the per-service duplicates |
npm run dosebot:test / dosebot:build | bot tests / tsc build from the repo root |
npm run railway:status | read-only production status summary |
Sharp edges that bite newcomers:
- Forgetting to rebuild + commit
services/dosebot/dist/— CI's dist-sync check will fail your push. - Editing the copy of a maintenance script in
emoji-pack-sync-job-service/— the guard fails; edit the canonical one insynapse-service/and sync. - Treating
MATRIX_ALLOWED_ROOM_IDSas the room allowlist — it's fallback-only; the hierarchy is authoritative. - Changing Cinny's nginx conf or josiekins' discovery contract without checking the other side of the seam.
- Running production-mutating Railway/Vercel commands without being explicitly asked — read-only inspection is fine, mutations are not.
- Vocabulary drift — "map", "matrix/", "allowlist", and "sync job" are all overloaded; CONTEXT.md at the repo root is the tiebreaker.