Locked decisions
Decisions we've committed to (see Open questions for what's not).
| Topic | Decision |
|---|---|
| Posture | Greenfield — do not port 1.x; the old impl is reference only |
| Cloud strategy | Hetzner-first; AWS by trigger. Launch 100% on Hetzner; adopt AWS managed services (S3/SES/RDS/KMS) one at a time only on a real scale/reliability signal. Compute never leaves Hetzner. See Cloud strategy |
| Cluster topology | Three node pools: dedicated tainted control-plane (1 dev / 3 prod-HA) · core (always-on, HPA) · workers (ephemeral Jobs, scale-to-zero). See Cluster & nodes |
| Autoscaling | HPA (api/app/admin replicas) + VPA (right-size requests, in-place on k3s ≥1.33) + cluster-autoscaler (nodes per pool; workers 0→N). No KEDA for MVP |
| Tenant | = team (billing subject, owns agents/keys; solo user = team of one) |
| App topology | 4 apps: api · app · admin · worker. No runner, no separate runtime app — ephemeral agent = the api image; worker = on-demand hands |
| Layering | 8 groups, deps point only downward (strict invariant) |
| L1 group name | platform → renamed system (= usage · setting · notification · llm · file) |
| Auth | Drop Cognito → own JWT (issuer = Core) in user/auth; the same scheme mints short-lived runtime session tokens for the worker (one mechanism) |
| k8s primitive | Native k8s Jobs, ttlSecondsAfterFinished for scale-to-zero. No KEDA/Knative for MVP |
| Browser execution | In-worker — the worker image bundles Playwright + Chromium and launches the browser in-pod per task. No separate Browserless pool (simpler: one app, full isolation; trade-off = heavier image + per-task Chrome cold start) |
| Queue | BullMQ on Redis |
| Data store | DynamoDB → Postgres |
| LLM | Bedrock → Claude API (system/llm); retire openai |
Resend for MVP → AWS SES at scale (swappable behind notification/email) | |
| Knowledge | LightRAG library, run as a separate Python service; agent/knowledge = thin gateway |
| Files | system/file (managed files over infra/storage); absorbs runtime artifacts |
| billing position | Top sink; setting reads team.planId (inversion), nothing imports billing |
| publicApi | Dissolved → rate-limit to core, webhook delivery to notification, keys in user/apiKey |
| orchestrator | The brain — lives in agent, not runtime |
| mcp | Stays in setup (exposure transport, not a system capability) |
| Worker tool channel | = MCP. The worker is an ephemeral MCP server; api is the MCP host that registers it on ready, detaches on release. Transport = MCP over a worker-initiated (reverse) WebSocket (option A — preserves "no pod IP / no inbound to the worker"; trade-off = a small custom MCP transport vs stock Streamable-HTTP). MCP sampling disabled; one tool model for brain-MCP · worker-MCP · connector-MCP. See Use → the protocol · Tool channel (the wire contract) |
| Short memory | Working context rehydrated from Postgres each turn (brain is ephemeral) + a running summary persisted on the chat + head/tail-protected window; compaction = cheap tool-result pre-prune → memory-flush to agent/memory → summarize the middle with a cheap auxiliary model. system-prompt snapshot keeps the prefix cache warm. Short ≠ long memory. See Short memory |
| Agent type | An agent has a type (standard / concierge) and one declaration says what that type may do; every write path and the interface read that same declaration, never a copy each. The type is server-assigned only — never from a DTO, never from a package manifest. See Anatomy |
| System prompt | Layered: platform base + tool guidance + the agent's soul, verbatim. The soul is one layer, not the whole prompt; anything per-turn is composed elsewhere. See Anatomy |
| Secret values | Never leave the system. No endpoint returns one, and a package carries key names only. A write merges into its scope rather than replacing it. See Secrets |
| Autostart | One global 60 s sweeper for every agent — never a timer per agent — so a due tick fires exactly once across the fleet. A tick is a full turn, so the interval has a 10-minute floor enforced on the write path — until consumption accounting exists, that floor is the only bound on spend. A busy agent is skipped, never interrupted; a NO_REPLY tick writes nothing. See Autostart |
| Turn cancellation | The socket closing cancels a turn (tab close, reload, the stop button); SPA navigation deliberately does not. A stopped turn keeps what was delivered, marked interrupted; a failed one persists nothing. See Runtime model |
| Chat history | Keyset pagination only — opaque cursor, limit ≤ 200; no call returns a whole thread. One thread per (agent, channel, person) — "start over" draws a boundary inside it rather than opening a second one, and the agent's notes survive that boundary. The concierge is an ordinary Agent row, so its conversation is stored like any other. See Storage |
| Chat markup | A model's answer is rendered from an allow-list of node types, never "HTML plus a scrubber": no v-html, no images, no raw HTML, links restricted to http/https/mailto and declared in-app paths |
| The agent's own autostart | Content always; cadence and on/off only in a turn a person opened, never in the agent's own tick — the danger was the loop, not the edit. A boost is bounded (≤ 60 min, ≤ 3 per 24 h), never goes under the floor, and expires by arithmetic rather than by anything having to run. A one-off line is struck out after it runs; emptying the list restores the cadence and leaves the agent on. See Autostart |
| Hands | Whether an agent may raise a worker is a column on the agent, none by default, and a release never widens it. What tools that worker may be given is a grant, empty by default — a worker with no tools, not one with all of them. Nothing is provisioned until the model actually calls a worker tool; one errand asks once; and the tools land in the next turn, not the one that raised them. See Hands |
| Archive vs delete | The concierge may archive an agent by conversation and take it back out; it may never delete one. The line is reversibility: archiving is undone by one movement, deleting takes the agent's conversations, notes, skills and secrets with it. Deleting is a person's, in the cabinet, behind a confirmation that names the agent. See Anatomy |
| What a turn remembers | One composer, called once a turn by both brains, returning prompt blocks and the message prefix and what they cost — because only something that sees the sum can trade one layer against another. One ceiling over the lot; a fixed eviction order (persona → last exchange → summary → notes → passages → deeper window) in which the first two are never cut. Counted in characters, since the block rides on every turn. See Memory |
| Memory writes | A note is capped and a longer one is refused, not trimmed. Anything that writes a note must reindex it — a note that cannot be recalled is the worst kind of fault, because nothing fails — and deleting one removes its vector, so a person who deletes a note is not left with an agent that still remembers it. Recall by meaning is built and has never run: no environment sets an embedding key, so notes fall back to date order. See Memory |
| The founder | The earliest account becomes the platform administrator once, recorded by a row that says the installation has been founded — never "promote the oldest whenever nobody is an administrator", which would silently hand the role away the day the last administrator is revoked. Every grant after that is a shell command; there is no endpoint and no button. See Platform role |
| Recording prompts | Off by default, per agent, switched on by the agent's owner — the prompt carries somebody's conversation and the notes written about them, so the person consenting has to be the person whose text it is. Kept 7 days. Read only in the panel. The agent's activity is the opposite trade: on by default, cheap, kept 30 days, and it never contains a prompt. See What the agent did |
| Controllers and gateways | A controller depends on a service; it may not import a *Gateway or anything from data/. Once left to review, it spread to six controllers with a green gate; the boundary check now refuses it. Group order comes from each app's config, so one script serves all of them. See How we build it |
| Package intake | Bounded by declared and measured size (25 MB upload · 20 000 entries · 128 MB unpacked), STORE/DEFLATE only, every refusal a code rather than a sentence, and the whole import atomic. See Import & export |
| Channels | No separate gateway app — channels fold into the api, behind a transport-agnostic seam. agent/channel/channel owns the binding table, the embed credential and the bridge onto the orchestrator and knows no protocol; one sub-slice per transport (agent/channel/bridle) holds the wire. Since AGNT2-229 the api can be either half. BRIDLE_ROLE=hub (the default) makes it the hub itself — the visitor's browser connects to /ws/client on a SECOND listener (BRIDLE_PUBLIC_PORT) that carries one Socket.IO namespace and no routes, and the same process runs the turn, so no second application and nothing to route between replicas; BRIDLE_ROLE=runtime keeps AGNT2-222's shape, dialling OUT to somebody else's hub. The public listener is separate from PORT on purpose: everything that reaches the database, the secrets key and the cluster stays on a port nobody publishes. An external conversation is the same conversation with a different channel — same table, same window, same compaction. A visitor is not a principal: the turn runs as the agent's owner, in the agent's team, through the same membership read a member goes through, and the visitor id is only a channelUserId. Such a turn gets no hands and writes nothing to durable memory — unless the owner of that agent deliberately says otherwise (AGNT2-231). Four separate rights, named on the agent's «Сайт» screen and stored per binding, every one of them false on every row: hands (the worker’s toolset and a worker to run it), memory (the durable notes), schedule (the heartbeat tools) and visibility (the reasoning and the tool steps reach the visitor as separate notes, never the tool's output). They are four and not one because they cost different things, and opening schedule — the only one whose damage outlives the visit, since a stranger can leave a standing job behind — additionally requires echoing the agent's own id, so it cannot be turned on by a client that merely forgot to think. Nothing about the SCOPE changes: the turn still runs as the owner, in the agent's team, through the same membership read; no switch mints a scope from a visitor's id, and an opened right never exceeds the agent's own grant and egress list. A channel with hands open is held to a tighter flow limit (4/visitor, 15/agent per minute, against 12/60) because a turn that raises a pod is not priced like a turn that answers a question; a real spend cap is AGNT2-227 and does not exist. The browser token is minted only by a member, expires (15 min default, 24 h ceiling), and carries the agent id in its sub — the hub does not check which agent a token was for, so we do — at the handshake and on every message, from one function. As the hub there is no token-less public flow, so embedTicket.ts no longer depends on somebody else's hub being configured JWT-only. And a visitor is now a PERSON rather than a page (AGNT2-233). The embed ticket gained an optional fifth field — a visitor key this api mints and signs — so the credential in a page's HTML names nobody and each browser trades it, at POST /visitor on the same public port, for one that names them alone; the key is trusted only because it arrives inside our signature, never because it is well-formed, which is the defect the ranch project has. That key is the conversation (ChatThread.channelUserId), the flow bucket (twelve a minute is one person's, and a re-mint is no longer a fresh allowance) and a Lead row — a record of a person with no account, hanging off the TEAM and not the agent, written on their first admitted message and carrying no membership, no scope and no rights whatsoever. The tag the cabinet produces hands the stock widget a token FUNCTION and keeps the credential in sessionStorage, so a reload lands in the same conversation and no cookie is involved. Merging an anonymous lead with a person who signs in is decided in its own row below (AGNT2-236). And a visitor may attach a PICTURE (AGNT2-286). The inbound message used to be text and nothing else, so a photograph the widget had already drawn in the visitor's own bubble was dropped before the turn started and the agent — correctly, and to the person unbelievably — answered that it could see no image. It now reaches the turn through the SAME path the cabinet's own attachments take (AGNT2-262): a ChatAttachment row in the visitor's own thread, over a system/file blob, over infra/storage — no second store and no new table. It is not a fifth right: the four rights hand a visitor something of the AGENT's, and a picture is part of what the visitor is saying; a default-closed switch would have kept the bug behind a settings screen for every agent already published. What bounds it is the same kind of cap the text has: the cabinet's own four image types (CHANNEL_ATTACHMENT_TYPES is agent/chat's list), 1 MiB per picture against the cabinet's 3, five per message, and the ordinary flow limit on top. Anything outside is a refusal naming the limit, refusing the WHOLE message and running no turn — «молчаливое отбрасывание недопустимо в любом случае». The team sees it on the lead's card through the read path that already existed; the bytes are reachable only through system/file's signed expiring link. Deleting the lead keeps it, exactly as it keeps the words — a file belongs to the conversation, and erasing the PERSON has never taken their conversations; deleting the conversation or the agent takes both. Both adapters learned the wire (image parts carry bytes, not a URL; the file part is deliberately not followed), and the hub's socket frame ceiling was raised to 8 MiB so the promise and the transport agree — Socket.IO's 1 MB default DISCONNECTS rather than refusing, which is the same silent failure one layer down. And a redeploy no longer cuts an answer in half (AGNT2-228). A widget's socket lives as long as the tab does, so closing the listener used to end every conversation on it at the same instant — the visitor watched the typing indicator spin over half a sentence and was never told why. A shutdown is now a chain: the door shuts in the earliest hook (no new socket, POST /visitor answers 503), turns already running are waited for up to 15 s, whoever is still mid-turn is told in one complete message — the shape that clears the widget's indicator — and only then does the listener close, at the TRANSPORT, which is the close the widget reconnects from. The turn is not replayed: finishing it elsewhere means moving a half-done turn between replicas, which is exactly the routing this topology exists without. The grace is a link in a chain that crosses a slice, so main.ts's SHUTDOWN_DEADLINE_MS rose 10 s → 20 s — two waits sharing an end are one wait, the shorter, and a 15 s grace under a 10 s ceiling never fires. And long connections are led away rather than left where they were put: an ordinary balancer places NEW connections only, so the oldest replica accumulates every socket opened since the last rollout; past BRIDLE_CONNECTION_MAX_AGE_MS (an hour by default, 0 to disable) an IDLE connection is closed at the transport and placed afresh, each with its own deadline in [0.75, 1.25] × maxAge so a fleet that connected together does not reconnect together. And since AGNT2-354 an agent can be reached by ANOTHER COMPANY'S AGENT, over A2A — as a THIRD ADAPTER and not a new subsystem. agent/channel/a2a is a sibling of bridle and hub: one JSON-RPC route (POST /a2a/v1/agents/:id) answering message/send/tasks/get and their 1.0 spellings SendMessage/GetTask, bridged onto the SAME ChannelTurnService.answer a visitor's browser reaches. The four dangerous questions about an endpoint open to the outside were already answered by IExternalTurn and are unchanged: the turn runs as the agent's OWNER in the agent's team, gets no hands, writes nothing to durable memory, and lands in its own conversation — and on an a2a binding there is no route that opens the four rights at all, so a partner's message is held to the closed default whatever the owner opened on the website channel. A binding is the same row (AgentChannel with type = 'a2a'), so opening an agent to the world is the same action, the same audit line and the same unpublish as putting it on a website; no table and no column was added. The A2A task id IS our turn id — the orchestrator already writes every row of a turn under it — so tasks/get is one indexed read of the conversation and there is no mapping table that could disagree with it; a deleted conversation takes its tasks with it. The credential is the existing agent/embedKey, which is what A2A's APIKey scheme describes, so no OAuth server was built and the count of routes behind EmbedKeyGuard deliberately went from one to two (AGNT2-242 is still untaken, and the argument for keeping one credential is written where the count is pinned). A missing or bad key is a 401 naming the scheme in the body, never a bare one. The key's public half is the partner's identity: the conversation, the flow bucket and the Lead row are all keyed on it, so spend has a name against it. Say it out loud: this opens an agent to NAMED partners, not to the internet — a key means «мы уже знакомы», and opening it to the unnamed is a separate decision with a separate price. The card becomes callable by itself: agent/card builds its document at read time, so an address, a transport, a protocol version and a security scheme appear on the card of an agent with a live binding and vanish when it is switched off — the rule «a card with a dead address is worse than no card» now keeps itself. The core paid three declared lines: one enum value, one entry in a per-transport table of address variables (a2a is reached at API_PUBLIC_ORIGIN), and the turn's own id leaving the core as a reply frame — which the two Bridle adapters still put nowhere on the wire. Declared missing in step 1, rather than half-built: webhooks (capabilities.pushNotifications: false — an outbound call to a stranger's address is its own surface), streaming, tasks/cancel, the authenticated extended card, a gRPC binding, any access without a key, and a cabinet screen; input-required and auth-required map to nothing here and are declared unused rather than faked. See Anatomy, AGNT2-222, AGNT2-228, AGNT2-229, AGNT2-231, AGNT2-233, AGNT2-286 and AGNT2-354 |
| Anonymous → recognised | MERGE, on the browser's own presented credential, with a trace and an undo. The owner's decision, 28 August 2026, taken with its risk named and accepted: somebody who wrote anonymously and later arrives recognised gets their past conversations back, with no confirmation asked of anybody. The evidence is a signature of ours and nothing else — the merge happens only where a browser presents, in ONE exchange, both the credential it is taking up and the one it was holding, and both came out of a JWT this api signed. Never on an email typed into the chat (a stranger can type anybody's address), and never on a name or a telephone: the only thing that makes somebody recognised is the externalId their own site sent us server-to-server with its embed key (AGNT2-234). Four refusals, all of them silent to the visitor: the same person twice, two different teams, nobody recognised, and a past the site had already named. The accepted risk is that the anonymous identifier lives in a browser — a shared computer or a guest window attaches a stranger's conversation, which is showing one customer another's messages — so the merge is bought back on two conditions the decision is not separable from: it leaves a TRACE (LeadMerge: who, whom, which conversations, when), shown on the team's people screen in words; and it is UNDOABLE by a member of the team, moving back exactly the conversations it moved and nothing said since. The undo marks the record rather than deleting it, so «this happened and was reversed» and «this never happened» are different answers. No route merges two leads by id, and there must never be one: a route cannot present a signature. See AGNT2-236 | | The agent's own skills | The agent may WRITE one; only a person may switch it on. A skill is an instruction, so a wrong one changes how the agent behaves for everyone it talks to, with no sentence anywhere to point at — which is why the argument that settled memory notes («write freely, a queue nobody empties does nothing») comes out the other way here. It is not a queue: the skill lands in the team's shared list saying which agent wrote it, connected to no agent, its author included, which is the same state a skill a PERSON writes starts in. It changes no answer and is not exported until somebody connects it, and the agent's own Skills screen shows what it wrote, so the decision stands in front of the person who has to make it. Written inside the turn, never as a pass afterwards — a pass costs a model call per conversation to conclude «nothing», and unlike a durable note a skill is normally asked for out loud, with the person present to be told what it was called. Refused in the agent's own tick, where nobody is there to be told, and never offered to a website visitor — enforced by the capability the tool is registered under, not by a check inside it. The agent may not rewrite or delete one. See AGNT2-270 |
Placement principles
infra= adapters ·setup= exposure/framework (no capability) ·system= capability services.systemvsruntime= state/capability vs mechanism/action.systemmembership = an L1 leaf used by multiple higher layers (keep strict).- Break back-edges by inversion (port, event, or read-via-Prisma), never by breaking the layer.