← Files CorezoidARCHIVED FILE
skills/corezoid-edit-bot/references/invariants.md
13.4 KB · Oct 10, 2026 · 06:12 UTC
# Invariants every change must preserve
Nine facts about how the orchestrator delivers a message. Every one of them is
**silent when broken**: `lint-process` passes, `push-process` succeeds, the
`run-task` on the command reaches `Done` with the right data on the task — and
the user gets an empty message, a duplicate message, a literal `{{var}}`, or
nothing at all.
They are grouped by what an edit typically breaks. Read §1 and §2 for any
change that touches copy, a keyboard or a send node; §3–§5 for anything a user
answers; §6–§7 for anything that calls a domain process; §8–§9 for dispatch.
---
## 1. Send Message receives only what the calling node names in `data`
The command talks to the user by copying a task into **Send Message** with
`api_copy`, `mode: "create"`, `group: ""`. `group: ""` means *send exactly the
fields in `data`* — nothing else on the calling task crosses over. Send Message
then resolves the localized text and the attachment against **its own** task
data.
So a value the command computed but did not forward is simply absent when the
text is resolved, and the placeholder reaches the chat verbatim:
```jsonc
// Localization: "balanceDone": {"en": "Your card has {{bonusAmount}} points."}
{"type":"api_copy","conv_id":<send_message_id>,"mode":"create","group":"",
"data":{"channel":"{{channel}}","chat_id":"{{chat_id}}",
"text_id":"balanceDone","attachment_id":"mainKeyboard",
"bonusAmount":"{{bonusAmount}}"}} // <-- without this line: "{{bonusAmount}} points."
```
Two families of this, and both are invisible to every test that reads the
*command's* task, because the value is present there and missing only on the far
side of the copy:
- **Values a text interpolates.** For every `text_id` a node sends, look the
string up in Localization and forward one `data` key per `{{var}}` in it.
- **`items` + `currentPage` for any dynamic attachment.** A dynamic attachment
is a *pattern*; its rows come from the calling task. Without them
`createDynamicAttachment` has nothing to render and the carousel arrives
empty.
**The empty-rows case is a trap of its own.** The gate into
`createDynamicAttachment` is `items == "" && buttons == ""` → static path. So a
command with no rows must send `items` as the **empty string**, not `[]`: an
empty array takes the dynamic path and re-renders a *static* keyboard's
`buttons` as a per-item pattern zero times, delivering a keyboard with no
buttons at all.
### What this means for a "just change the wording" request
`copy` is a Localization-only change **only while the new wording introduces no
placeholder the sending node does not already forward.** The moment it does, it
is also a process change. Find the senders before promising otherwise:
```bash
grep -rln '"text_id": *"balanceDone"' "$orch"
```
A node that sends the literal `{{text_id}}` can carry any `text_id` a Code node
assigns — union them all, and take a ternary's strings from after the `?`.
### And a placeholder must name a key the process really produces
A `{{var}}` that is forwarded but never populated renders as an empty string
rather than as a literal — quieter and just as wrong. Only keys in that
process's `success.keys` in `bot-contract.json` are safe.
---
## 2. `/end` decides whether the *Router* also sends — get it wrong and the reply arrives twice
A command ends its turn by copying into the Router with `group: "all"` (the
whole task, so `channel`/`chat_id` travel with it). The Router's `/end` handler
then branches on what it received:
| `text_id` | `attachment_id` | Router does |
|---|---|---|
| `""` | `""` | sends `mainMenu` (+ `mainKeyboard`) |
| set | `""` | sends `text_id`, adds `mainKeyboard` |
| set | set | sends `text_id` |
| `""` | set | **closes the state and sends nothing** |
There are therefore two valid conventions, and mixing them sends the reply
twice:
```jsonc
// A. The bot sends nothing; the Router renders the closing text.
// What the wizard's own samples do (/NPS, /sampleSurvey, /changeLanguage).
// Unavailable to any command that needs items/currentPage or an interpolated
// value: the Router's send passes only channel/chat_id/text_id/attachment_id.
{"group":"all","data":{"command":"/end","text_id":"surveyComplete"}}
// B. The bot sent its own message and the Router must stay silent.
// Required for every generated command.
{"group":"all","data":{"command":"/end","text_id":"","attachment_id":"mainKeyboard"}}
```
Because `group:"all"` forwards the whole task, a command that set its own
`data.text_id` leaks it into `/end` and the Router re-sends that text — and the
Router's send carries no forwarded values, so the **duplicate arrives with
literal `{{var}}` in it**. That pair of symptoms (every reply twice, the first
copy uninterpolated) is this invariant and nothing else.
`/exchangeRates`, the wizard's one dynamic-attachment sample, dodges the choice
by never copying `/end` at all and staying `active` forever. Do not imitate it:
it leaves the chat wedged.
**Every path ends this way, including error and timeout paths.** A command that
exits any other way leaves the chat's System Diagram state `active`, so the
user's next message is delivered to a finished bot. It self-heals on the System
Diagram timeout — the user sees a dead chat until it does.
---
## 3. Interpolation has exactly two forms, and both are easy to write wrongly
`Replace variables in text/attachment` runs two string-level passes over the
resolved text *and* the resolved attachment:
- **`{{someField}}`** — read from the task's own data. **Flat keys only.** The
regex is `/{{\w+}}/ig` and `\w` excludes `.`, so `{{message.text}}` or
`{{a.b}}` is never matched and reaches the chat verbatim. (`matchToReplace`
does contain a dotted-path branch, which is exactly why the wrong form looks
supported — the regex never feeds it.) Flatten into a single key in a Code
node first.
- **`{{t'someKey}}`** — read from the Localization document itself, for phrases
reused inside other phrases and inside attachment labels. **No dot.** The key
is extracted as `d[i].replace(/{{t'/, "").replace(/}}/, "")`, so `{{t'.someKey}}`
looks up `".someKey"`, misses, and leaves the placeholder in the message. The
regex that matches the token (`/{{t'.\w+}}/ig`) has an *unescaped* dot, which
is why the wrong form looks plausible. The wizard's own documents use
`{{t'/exchangeRates}}` and `{{t'formsQuestionnaire}}`.
A value that is itself an object will not interpolate at all — both passes are
string-level.
---
## 4. A reply keyboard is an answer; an inline keyboard is a command
A dialog's wait node reads `data.message.text`.
- Telegram `type: "keyboard"` (a **reply** keyboard) sends the button's
**label** as a text message — so the label arrives as the answer to the
pending question. This is what an ask or confirm step needs.
- Telegram `type: "inline_keyboard"` sends `callback_data`, which
`Main → PARSE command` interprets as a **command**. The pending question never
receives an answer, and the user's tap looks like an unknown command.
Use `keyboard` for answer and confirm steps, `inline_keyboard` for display-only
rows and menus. Keep the validation regex even where a keyboard makes an invalid
answer impossible — Viber and ABC let the user type.
---
## 5. A button payload is parsed, not free text
`Main → PARSE command` turns a plain text message into a command plus params:
```
separator "__" pair union "_" key/value "-"
"/order-status__id-42_page-2" ⇒ command "/order-status", params {id:"42", page:"2"}
```
So a literal `-` or `_` inside a value silently splits it. Never put free text
in a payload — which matters most on a `rename-command`, where every button
payload naming the old command has to be rewritten inside this grammar.
---
## 6. Every `api_rpc` into a domain process needs a `time` semaphore
An `api_rpc` timeout is not a hold — lint does not enforce any floor on it (a
sub-30 s value deploys and runs fine) — but it does not require one at all
either, and the template's own `api_rpc` nodes have none — their callees are in
the same folder and always answer. A domain process is not that: it can be
paused, hung or reply-less, and without a semaphore the task parks forever. No
message, and the chat's System Diagram state stays `active`, so the user's next
message goes into the dead command too.
Route the semaphore to a node that tells the user and **then copies `/end` into
the Router** (§2). Pick a value that reads sensibly in the timeout copy —
30 s is a reasonable default, not a required minimum.
Call with `group: ""` and an explicit `extra`: `group: "all"` would forward the
whole task — `channel`, `chat_id`, `message`, the Router's bookkeeping — into
somebody else's process. Prefer `@alias` to a numeric `conv_id`: a numeric id is
stage-specific and does not survive a `deploy-stage` promotion.
---
## 7. Two domain calls in one command collide on `{result, code}`
An `api_rpc` callee's `res_data` merges into the caller's task at **top level**,
and every process in this family replies with the same `{result, code}`
envelope. So the second callee overwrites the first's verdict and the command
branches on the wrong outcome.
With exactly one call there is nothing to overwrite — read `{{result}}` and
`{{code}}` directly. With two or more, follow **every** call with an `api_code`
that copies the reply into namespaced keys and clears the shared envelope.
`api_code`, **not `set_param`**: `lint-process` cannot see a `data.result` read
inside JavaScript and fails the file with `UNUSED SET_PARAM`.
Which makes adding a second call a change to **both** calls, not an addition at
the end of the graph.
---
## 8. The alias is the entire dispatch mechanism, and it is write-once
The Router contains no per-command branch. It computes
`commandAlias = command.replace("/","")` and calls `@{{commandAlias}}`. A
command is wired up by *existing under the right alias* — there is nothing to
register.
- The name must satisfy `^[a-z0-9][a-z0-9-]{2,}$` after the slash. `/orderStatus`
yields `orderStatus`, not a legal `short_name`, so `@orderStatus` resolves to
nothing and the user gets `commandNotFound`. Deploys fine; unreachable forever.
- `/start`, `/end` and `/exit` are consumed by Main, Router and System Diagram.
- `_ALIASES_.json` at the stage root is the authoritative list. An
`obj_to_id: null` row holds the name *and* resolves to nothing — the worst
case, because anything already dispatching there answers `commandNotFound`.
- Before *calling* something by `@alias`, match on `obj_to_id`, never on the
name. In the reference workspace `promolist`, `recovery-password` and
`registration-verify-success` all pointed at deprecated `..._old/API:` copies
rather than at the processes the user handed in.
- **`create-alias` is the only alias tool in the plugin.** No delete, no modify,
no unlink, no list. A name once taken is taken for good; a rename creates a
second alias and leaves the first pointing at the renamed process; deleting a
command does not free its name. Real alias surgery is raw Corezoid API —
`corezoid:corezoid-alias-manager`.
Follow-up messages reach a running dialog the same way:
`api_copy conv_id:"@{{commandAlias}}" mode:"modify" is_sync:true
ref:"{{channel}}_{{chat_id}}"`. Which is why a dialog command must be created
with `ref = {{channel}}_{{chat_id}}` and must park on an `api_callback` node.
---
## 9. Localization and Attachments are runtime task data, and the merge is shallow
They are `conv_type: state` diagrams whose *content is a task*, not a scheme —
so they are written with `run-task` / `modify-task`, never `push-process`.
- **`modify-task` needs `deep_merge: true`, always.** The Corezoid API merges
only top-level keys: a shallow write of one language for a `text_id` replaces
the whole language map, and a shallow write of one channel for an
`attachment_id` drops the other three. Silent, and visible only as an empty
message on the channels you did not send.
- **`show-task` before writing** — it tells you which keys and languages already
exist, and whether the key you are "changing" is a shared one.
- **The wizard seeds `en`, `ru` and `uk` regardless of its `lang` argument.**
Send Message picks the language from `User Profile.language`, which the
Telegram receiver derives from `message.from.language_code`. There is no
fallback: a `text_id` missing the user's language delivers an empty message.
- **`serviceError` is not shipped by the wizard**, although both skeletons
reference it on every error and timeout path. Check and seed it.
- **A `run-task` on a state diagram reports "parked at a non-final node".**
That is success — on a state diagram the task *is* the document, and it sits in
the state node by design. Check the echoed data; do not retry with a larger
`wait_sec`.
- **Channel-name asymmetry.** The wizard's messenger key is `fbmessenger`, but
Send Message dispatches on `channel == "facebook"`. Attachment keys and
`go_if_const` conditions use `facebook`. A per-channel check written against
`fbmessenger` passes while that channel renders nothing.
- A channel absent from an attachment task means "send no attachment there" —
legal, not an error.
---
## Shared keys have a template-sized blast radius
`mainMenu`, `mainKeyboard`, `serviceError`, `timeout`, `commandNotFound`,
`selectError`, `yes`/`no` and `carouselPattern` are referenced by every command
and by the Router itself. Editing one touches no process and still needs the
regression scope of a `template-edit`: smoke-test every command.
SHA-256: 62081c712840a847724355744096afdd685b23b1c83b861ee9ba339718c1c1ae