跳转到内容

Interface languages

此内容尚不支持你的语言。

How the interface’s words are held, chosen and checked (Blueprint §29, ADR 46). English (en) is the source. Simplified Chinese (zh-Hans, 简体中文) is offered beside it since 2 October 2026, when it left preview (The preview flag, below): a browser that asks for Simplified Chinese gets it, Settings > Language lists it, and the pages before sign-in carry a switch between the two.

What stays English whatever the viewer chooses, permanently: everything a program reads. The API’s bodies and error messages, mailda and its --json, doctor’s text, MCP, the Agent Skill, the SDK, the operational log, audit keys and SMTP replies are byte-stable for the agents and gates that parse them (mailda deploy reads doctor’s verdict from its first line). The interface shows the Node’s own English inside <NodeWords>, marked lang="en". The CLI’s interactive prose is deferred rather than forbidden: it is translated when an operator asks for it, chosen by MAILDA_LANG, then LC_ALL, then LANG.

The sender’s content is never translated, and neither the message frame nor a plain-text body takes the interface’s language. Each keeps the message’s own: the sanitiser keeps a sender’s lang and dir on every element it keeps (bdo and bdi among them), and the frame’s root carries data-script (sc, tc, jp: which glyph forms Han is drawn in, never lang) from what the message says, in order the rendered body’s charset (GB2312, GBK and GB18030 are sc, Big5 tc, ISO-2022-JP, Shift_JIS and EUC-JP jp; never an attachment’s), then its Content-Language, then the HTML’s own root lang, and only then the reader’s locale (zh-Hans guesses sc, English guesses nothing). A sender’s lang on an inner element picks that element’s forms over the frame’s ([lang]:lang(ja) and its siblings in frame.css). A plain-text body is drawn in the shell’s document, so its <pre> carries lang="" and the same data-script, with the frame’s families (scriptFamily in apps/node/worker/src/theme.ts). The body route returns the message’s answer as script (apps/node/worker/src/render/script.ts), a string on the wire that the reader narrows to BODY_SCRIPTS. A UTF-8 message that says none of these says nothing, and an English reader then gets the platform’s own choice.

apps/node/worker/src/i18n/
locales.ts LOCALES: tag, direction, endonym, preview. OFFERED: the non-preview ones (offeredOf).
format.ts the runtime, pure: Message, Plural, Params, Text, parts(), text(), negotiate(), resolveLocale()
areas.ts AREAS: each catalog area and the key prefixes it owns
en/<area>.ts the source words, one module per area; their literals are the type
zh-Hans/<area>.ts each area's twin, typed Twin<typeof en area>
en/index.ts preauth, and app = every other area joined (and zh-Hans/index.ts)
catalog.ts Catalog, Key, Source, Twin, CATALOGS
preauth.ts every locale's preauth table, and nothing else (what /app/locale.js bundles)
served.ts each locale's app table as a module, and its content-tagged URL
glossary.ts CONCEPTS, NEVER, NEGATES
apps/node/worker/src/client/locale.ts the browser half: bootLocale, install, loadApp, t, rich
apps/node/worker/src/client/app/format.ts every date, time and number the interface formats
apps/node/worker/src/client/app/words.tsx <NodeWords>, marked(), sentence()
apps/node/worker/src/client/app/title.ts the per-route document title
apps/node/worker/src/client/app/ui/ime.ts isComposingKey
apps/node/worker/src/client/app/delivery-words.ts the words for the tokens /app/delivery.js returns

How the words reach a page. scripts/build-client.mjs builds src/client/locale.ts into /app/locale.js, a second entry point beside the shell, with every locale’s preauth table in it. The framework-free script imports it first and calls bootLocale(), which picks the locale and sets <html lang>, dir, the title and the wordmark before anything renders. When somebody signs in, handOverToShell() preloads the shell and fetches the locale’s app table from /app/messages/<locale>.<tag>.js inside the same try, so a table that cannot be fetched shows the “could not be loaded” notice. The tag is computed from the table’s text by the build and by the Worker from the same catalogs, so the URL is immutable. An old tag of a known locale, which a sign-in tab left open across a deploy still names, is a no-store 307 to the current table: the old address is never handed new words, and the sign-in does not fail on a 404. The shell leaves /app/locale.js external, so one module instance holds the one table.

UNMIGRATED in apps/node/worker/test/node/untranslated.registry.ts is the list of what is not; a file leaves it when its count reaches zero.

  • Layer 1 (30 September 2026): the navigation and the rail, the command palette, the health popover and notices, the Inbox’s message list, Settings with its Language section, every screen’s title, and the interface’s own failure sentences.
  • Layer 2a (1 October 2026): the reader (reader.tsx), the composer and Drafts (composer.tsx, drafts.tsx), the Queue (queue.tsx), and the ledgers (ledgers.tsx: Outbox, Audit, Log, Doctor), with the sending vocabulary. delivery.client.js keeps its decisions (which state, reason and summary a reader is shown, worst first) and now returns the contract’s tokens; the words are the catalog’s send.state.*, send.reason.*, delivery.state.* and delivery.reason.*, each with a .note. Its areas are reader, composer, drafts, queue, ledgers and delivery.
  • Layer 2b (1 October 2026): every other React screen. Setup and its progress (setup.tsx, onboarding.tsx), People, Agents and Approvals (people.tsx, agents.tsx, approvals.tsx), Matters and Limits (matters.tsx, limits.tsx), Butlers and Rules (butlers.tsx, policies.tsx), the first-run screen and the next steps (first-run.tsx, next-steps.tsx), and the menu, popover and section tabs (ui/), which had no words, only selectors. Its areas are setup, onboarding, people, agents, approvals, matters, limits, butlers, policies and ui.
  • Layer 3 (2 October 2026): the framework-free scripts before sign-in, app.client.js, session.client.js and theme.client.js (the claim and its recovery codes, sign-in with a password or a passkey, the invitation, the session’s endings and its timer in the status strip, the theme), so UNMIGRATED is empty. Their words are the preauth area, bundled into /app/locale.js. There is no React before sign-in, so the Node’s English is marked by nodeWords() in app.client.js, the DOM twin of <NodeWords>: a <span lang="en">. A refusal before sign-in is keyed by the contract’s closed list PREAUTH_ERRORS (packages/contract/src/preauth-errors.ts, eighteen codes, no imports so /app/locale.js can bundle it): a listed code shows a headline in the viewer’s language, preauth.refusal.<code>, with the Node’s English beside it, marked (D34); an unlisted code shows the Node’s English alone, marked, so a new refusal is never hidden or mistranslated. The headline is left out where the Node’s sentence begins with it, so English never says one sentence twice. The <noscript> notice in src/ui.ts is one block per locale, English first, each with its lang. The status strip has a language switch (below, Choosing a language), and the sign-in and claim pages draw the lockup (below, Brand). The same layer keyed three things the Node sends with a closed code beside them: a Doctor finding’s check (doctor.check.*, keyed by DOCTOR_CHECKS, a title above the check’s name, which stays marked), a capability’s description on Agents (capability.*, keyed by CAPABILITY_IDS, English held equal to the contract’s says by apps/node/worker/test/node/capability-words.test.ts), and a body’s problem (reader.body.problem.*, keyed by BODY_PROBLEMS, English held equal to the Node’s problemSentence by apps/node/worker/test/render.test.ts). A Doctor finding’s detail and fix and the other sentences the Node writes without a code beside them stay the Node’s English, marked: one check writes several sentences, so keying them needs a code per sentence. A mint’s notice is the exception, said by the screen as agents.mint.token from the minted agent’s expiresAt (10 October 2026), because the Node’s sentence carried the expiry as a raw ISO instant.

Every migration in layer 2a was held to its English by goldens written from the file before its first edit (apps/node/worker/test/client/golden/), so a changed English word is a deliberate one, listed below the D table. Layer 2b’s goldens were written the same way, on 1 October 2026 before any of its screens moved a word, and its areas (onboarding, people, agents, approvals, matters, limits, butlers, policies, ui) were registered empty first, so the screens migrate in parallel without two people editing one module.

Kept forwards (ADR 47, 3 October 2026) added words in two areas, each in en and zh-Hans: people.forward.* (the line under an address that keeps a forward) and, in setup, setup.verified.listed, .showAddresses, .addresses, .state.*, setup.destination.* and setup.rules.keptForward, plus the doctor title doctor.check.kept_forwards. They use confirmed words where a glossary row exists: 已验证的目标地址 for verified destination (delivery.verified_destination), and 转发 as the composer and the reader already say it. Three words had no row: kept forward (保留的转发), waiting for verification (等待验证) and register a destination (登记). The owner accepted them in the working session on 3 October 2026 (round five, OWNER_ROUND_FIVE), and they are bound to their keys. The take-over’s own choice and its consequence (“receive here and keep forwarding to X”) are the Node’s words, shown marked, as the other take-over choices are. A refusal Cloudflare gave a forward is its own English, marked.

Copies when a kept forward cannot be used (ADR 47 amended, 3 October 2026) added people.forward.copy.*, setup.rules.copy, .copyAbout and .copyOn, and the Outbox’s ledgers.outbox.copy, .copy.note and .copy.noResend, each in en and zh-Hans. They use confirmed words where a row exists: 定稿 for seal (never 封存), 隔离 for quarantine, 发件箱 for the Outbox, 管理员 for administrator, 转发 for the forward, and the send’s own state from send.state.*. Copy (副本) is the new concept, a sealed send of a stored message that a refused kept forward asked for, never 同步 and not the e-discovery 外发; via in ” via ” stays English in both locales, because it is the From display name a recipient sees, written by the Node into the header, not interface text (the zh-Hans sentence quotes it as “<发件人> via {mailbox}”). The owner accepted both in the working session on 3 October 2026 (round six, OWNER_ROUND_SIX); 副本 is a row bound to its keys. Why a copy was not sealed is the Node’s English, marked.

Forwards to several destinations (ADR 47 amended, 7 October 2026) added people.forward.edit* (the Change forwards box), setup.rules.forwardTo, .foundIn, .foundNothing and .forwardsTo, each in en and zh-Hans, and changed three: doctor.check.kept_forwards is Forwards (转发), since the check now counts destinations an administrator chose as well as kept ones; people.forward.none says since it was added (添加之后), not since it was kept; and setup.destination.about says a forward (转发). Forward (转发) is the new row, the concept a kept forward is one case of; the owner accepted it in the working session on 7 October 2026 (round seven, OWNER_ROUND_SEVEN). Kept forward (保留的转发) keeps its row, now bound to no key. The Worker rule’s choice (“receive here and forward to addresses you choose”) is the Node’s words, marked, as the other take-over choices are.

People’s permission grid (7 October 2026) added people.relation.* (each permission’s short name, a column header), people.grant.box, people.grant.legend and people.forward.copy.what, each in en and zh-Hans. They use confirmed words where a row exists: 审批 for approval, 导出 for export, 管理员 for administrator, 邮件 and 发送 as the composer says them. No new concept: each short name is a label for a relation whose id and full sentence (api.grant.*) stand beside it in the legend.

  1. Put the English in the area module of the file you are migrating (en/inbox.ts for inbox.tsx), under that area’s prefix: "inbox.empty": "Nothing here yet.". A key outside the prefix does not compile.
  2. Put its translation under the same key in zh-Hans/inbox.ts. A missing or extra key does not compile.
  3. Render it: t("inbox.empty"), or t("inbox.count", { n }) for a plural. A misspelt key or parameter does not compile. A sentence with an element inside it uses sentence("key", { who: <strong>{name}</strong> }), so the translation moves the element and cannot add one.
  4. Delete the literal, then lower the file’s count in UNMIGRATED (apps/node/worker/test/node/untranslated.registry.ts) by what you removed. The test fails until the number matches, in either direction.
  5. A literal the scan flags that is not words (an API path, a storage key, a media query) goes in NOT_PROSE, under its file, with its reason.

Rules a check cannot see:

  • A key is a concept, not an English phrase. A change of meaning renames the key, so the translation stops compiling and is redone. A wording tweak under the same key is caught only in review.
  • n is always the count, and the measure word is inside the message: {n} 封邮件, never n + " 封". Two counts are two messages, each a plural on its own n, filled into the sentence as parameters (ledgers.collect.done). 50+ stays 50+, never 共 50 封 (a page is never a total). A noun passed into a shared template brings its own measure word, because the word depends on the noun: chrome.truncated is 仅显示最新的 {n} {noun} (a plural on the count shown), and drafts.noun is 份草稿, chrome.notices.noun 条通知 (F5, below).
  • Closed vocabularies are keyed by their tokens: route.${AppRoute} must have a key for every route, by type. A wire token outside the union renders raw, in <code> (shown() in delivery-words.ts). The sending vocabulary is the model: the contract declares SEND_STATES, SEND_REASONS, DELIVERY_STATES and DELIVERY_REASONS as as const lists while the wire stays z.string(); en/delivery.ts satisfies Record<DeliveryKey, string> over them, so a token without words, or words for no token, does not compile; /app/delivery.js decides which token a reader is shown and returns it, and delivery-words.ts narrows it with oneOf before building the key (label at send.state.<token>, note at send.state.<token>.note). Closed vocabularies (round three, G12): the contract also declares BUTLER_RUN_STATES, BUTLER_VERSION_STATES, BUTLER_PAUSE_REASONS, MATTER_TYPES, EXPORT_STATES and RATE_BREAKER_NAMES, and the Node’s modules that write them use those lists rather than copies of their own (apps/node/worker/src/butler/record.ts, apps/node/worker/src/butlers.ts, apps/node/worker/src/butler/pause.ts, apps/node/worker/src/matters.ts, apps/node/worker/src/exports.ts, apps/node/worker/src/breakers.ts). Five are keyed the same way (matters.type.*, matters.export.*, butlers.version.*, butlers.pauseReason.*, limits.breaker.*), English unchanged; a token outside the list renders raw in <NodeWords>. matters.type.legal_hold is bound to the legal-hold row. A Butler run’s state is keyed since round four (H7, below) as butlers.run.*, in the runs table, the dry run’s verdict and the line naming the run a dry run reads (its trigger event in <code>): English the token with an underscore read as a space (“awaiting release”), zh 运行中, 等待放行, 已完成, 已终止, 被拒绝, 失败. stopped is 已终止, never 已停止, because 停止 is the pause row’s avoided word, and the butler-run.stopped row holds it. Layer 3 added PREAUTH_ERRORS, CAPABILITY_IDS, BODY_PROBLEMS, AUDIT_OUTCOMES and APPROVAL_SUBJECT_KINDS to the contract’s lists, keyed the same way, and keyed DOCTOR_CHECKS, which the contract already had. The fifteen Chinese words of round three’s five lists (法律保全, 安全事件, 离职交接, 监管要求; 已申请, 运行中, 已完成, 已中止; 草稿, 已发布, 已被取代; 检测到循环; 发信量, 退信率, 投诉率) are keys, not glossary rows (only 法律保全 is bound to one, legal-hold, confirmed in the first review), and the owner accepted them as written in round four (below).
  • Never persist a t() string as data (critic L10). A default a viewer’s interface writes into the Node (a mailbox name, a label) would become a word every other viewer reads in the writer’s language. One exception, by design: a reply’s quote line (composer.quote, “On {date}, {from} wrote:” / “{date},{from} 写道:”) is written into the mail in its author’s interface language, as Gmail and Outlook write theirs, because it is part of what the author wrote (ADR 46: persisted prose stays in its writer’s language). Its date is format.zonedTime, which carries the offset from UTC (GMT+08:00), so a correspondent in another zone does not misread the hour. The subject prefixes are not the interface’s words: Re: and Fwd: stay English on the wire in every locale, and replySubject / forwardSubject in src/client/app/screens/composer.tsx do not add one after a Chinese client’s own (回复:, 答复:, 转发:, either colon).
  • The Node’s own English goes through <NodeWords>: an error message, a doctor detail, a breaker’s sentence. Only the Node’s: a failure’s words may instead be this interface’s fallback (“this Node answered 503”, translated), so api.ts carries who wrote them (Said: message and fromNode, on ReadFailure and on every refusal) and a screen shows a failure with marked(failure), which wraps only the Node’s. A toast’s text and <Nothing detail> take a node for this reason; a sentence with the Node’s words inside it is sentence() with marked() as the argument, never t() with the words as a string. Before sign-in, a refusal’s code is keyed by PREAUTH_ERRORS (layer 3); after it, per-code interface text for an error still waits for an ERROR_CODES registry in the contract.
  • A token is an identifier or a word, never bare (layer 3). A token the Node sends that names something a reader needs in their language is keyed from a contract list (an approval’s subject, APPROVAL_SUBJECT_KINDS; an audit entry’s outcome, AUDIT_OUTCOMES; a matter’s type). One that is an identifier (an audit action, a log event, a grant’s scope, an SPF, DKIM or DMARC result, a DMARC policy, a Butler’s yaml or json, a provider’s bounce type) is shown as itself in <code>, the Register’s rule, never bare beside Chinese. A token inside a title cannot be marked, so it moves into visible text (the bounce type beside its state). A unit after a figure a screen rounds itself is a catalog word too (composer.size.kb, composer.size.mb).
  • Formatting goes through src/client/app/format.ts. For an English viewer it formats exactly as before locales existed (the browser’s default locale). The one table is English’s month names (MONTHS, for shortTime, monthDay and recordDay: 15:09, 26 Sep, 26 Sep 2025), because Intl’s short month for September changed to “Sept” across ICU versions; another locale asks Intl for its own numeric forms (15:09, 9月26日, 2025/9/26). Durations and counts are plurals keyed by the count (queue.duration.*), and nothing compares formatted text: the Queue’s “just now” is decided on the number of milliseconds, never on the words.
  • Two sentences side by side are joined with t("join.sentence"), never a literal {" "}: a space in English, nothing in Chinese, whose 。 and ( carry their own spacing (G14). The gap after a control or a Latin token is not a sentence join and stays a space.
  • Every Enter handler asks isComposingKey(event.nativeEvent) first. An IME’s Enter commits a candidate. apps/node/worker/test/node/ime.test.ts parses the client for every comparison of a key with "Enter" and fails on one whose function does not ask.
  1. Add it to LOCALES with preview: true, its direction and its endonym.
  2. Create <tag>/<area>.ts for every area and <tag>/index.ts; add it to CATALOGS and PREAUTH. Each is a compile error until it exists.
  3. Add its column to every glossary row. Rows start proposed.
  4. Review it with ?locale=<tag>.
  5. Take preview off when UNMIGRATED is empty and every row its keys use is confirmed (below).

A preview locale is reachable only through ?locale=<tag> in the address: browser negotiation never picks it, a stored choice naming it is ignored, and Settings > Language does not list it. The flag applies to that page load, is never stored, and works where the browser refuses storage. Settings says, when it is in force, that the page is in that language only because its address asks for it. So no viewer who did not ask is shown a screen that is partly English, and the lang="en" wrapping of untranslated components that a shipped partial locale would need is not built.

preview comes off when UNMIGRATED is empty, a pseudo-locale render of every route including its error states finds no English outside lang="en" (T4, below), every string the Node supplies on the locale’s screens renders through marked() or <NodeWords> (the reader, the composer, the Queue and the ledgers since layer 2a, every React screen since layer 2b, and the pages before sign-in through nodeWords() since layer 3), and the glossary rows the locale’s keys use are confirmed.

zh-Hans left preview on 2 October 2026. The owner asked for the interface’s languages to be completed and then merged “once things are ready”, with every glossary row confirmed in rounds one to four. The four conditions were checked by running them that day, before the flag changed:

ConditionEvidence on 2 October 2026
UNMIGRATED is emptyUNMIGRATED is {} in apps/node/worker/test/node/untranslated.registry.ts; T3’s 18 tests green
T4 finds no bare English on any route or page before sign-inpseudo-locale.test.tsx and pseudo-preauth.test.ts, 101 tests green: every route in APP_ROUTES populated, empty, every read failed, every button refused, and each refusal before sign-in
Every string the Node supplies is markedT4 fails on the Node’s English outside lang="en", and the G goldens hold each screen’s marking (layer 2a, 2b and 3 rows of The checks); the whole client suite, 848 tests, green
Every glossary row the keys use is confirmed130 rows, 111 of them binding a key, none proposed; T2’s confirmedBeforeShipping finds nothing with zh-Hans released

What changed with it: negotiation picks zh-Hans for a browser asking for zh, zh-CN, zh-SG or zh-MY (any tag that maximizes to Hans); zh-TW, zh-HK and zh-MO maximize to Hant and continue down the viewer’s own list. A stored zh-Hans is honoured. Settings > Language lists English and 简体中文. The switch before sign-in is drawn for every viewer, so the English goldens of the pages before sign-in gained it (preauth-words.test.ts’s header; no English word changed). ?locale= works as before, for one page load. The mechanism stays for the next locale, held with a registry that has a preview in it: apps/node/worker/test/node/locale-preview.test.ts (the decision) and apps/node/worker/test/client/locale-preview.test.tsx (Settings and the switch).

The page without scripting is server-rendered and cannot read ?locale=, so <noscript> carries one block per locale, English first, each marked with its lang. The owner kept it bilingual in round four (H3, below), and with zh-Hans offered there is no preview left for it to show unasked.

Settings > Language lists the offered locales, each in its own language (endonym, data rather than Intl.DisplayNames): English and 简体中文. Were English the only one, it would say so (the preview mechanism’s test). Choosing flushes an open draft, stores the choice in this browser (mailda.locale) and reloads; a draft the Node will not save stops the reload. Where the browser will not keep the choice, the page reloads with ?locale= instead and says it will not last. Boot order: ?locale=, the stored choice (offered locales only), navigator.languages negotiated on language and script over the offered locales (zh-TW is Traditional and matches nothing), then English.

Before sign-in (layer 3), Settings is out of reach, so a reader whose browser asks for another language could not choose one. The status strip carries a switch: the offered locales, each by its endonym, and the active one when the review flag chose a preview, so a preview page can be switched back. It reloads with ?locale=, never storage, so it works where storage is refused. With one offered locale a page shows no switch, since one choice is no choice; since zh-Hans left preview it appears for everyone, as it was built to, with no code change.

Not built, and the trigger for each: a per-person language on the Node (users.locale) and an organisation default, when the Node first writes a message to a person or somebody asks for the choice to follow them across browsers. The first frame shows lang="en" until the module runs, as the theme does; a cookie mirrored into a server-rendered lang is the upgrade.

glossary.ts has one row per concept: the word each locale uses, the words it avoids, and the keys bound to it. “held” is five concepts with five Chinese words; “Mailda” is one (prose: true), so any English that names it must say 淼达 in Chinese. A key bound in keys must equal the term (a label, a route name); a key bound in sentences must contain it (Released to the queue, 已放回队列), for a concept whose English word means other things elsewhere and so cannot be prose. An avoid list applies only to its concept’s keys. NEVER is for phrases wrong in every position (发送成功, 投递成功, 送达, 必达, 使命必达, 秒达, 您, 废纸篓); a key whose text honestly negates one declares it in NEGATES, and a person reads that key. 送达 joined in layer 2b and the owner confirmed it in round three.

Every row is proposed until the owner confirms it. A confirmed row carries { confirmedBy, record }, where record says where and when the owner confirmed it: a link to the owner’s own written confirmation (a pull request review, an issue comment, or a decision the owner recorded on a review page), or, where there is none, what the owner said and where (round four, below), and nobody writes one for somebody else. A locale that is not a preview may not ship a key bound to a proposed row. The check cannot tell who typed the field, so the real gate is the owner’s approval of the pull request.

The owner’s review of 30 September 2026 answered every row and every reviewer flag on a review page, one recorded decision each. 79 of the 81 rows were confirmed there (CONFIRMED in glossary.ts). Two words changed, on three rows: Butler and Butlers are 管家 (the Latin Butler is now an avoided form in zh-Hans; the CLI noun butler stays Latin in mono), and Trash is 回收站 (废纸篓 is in NEVER, so no sentence keeps it). Two rows were held and answered on 1 October 2026 (OWNER_ANSWER). passkey stays 通行密钥: the owner first asked for 密钥, which already names cryptographic keys here (密钥恢复副本 for escrow, the key vault) where Apple and Google say 通行密钥, was asked once, and chose 通行密钥. place.delete is 移到回收站, which the new Trash word made of the confirmed 移到废纸篓, and which the owner then confirmed. Every row of that review is now confirmed. Flags adopted with it: the rail’s count says {mine} 个由你处理; Health’s outbound area is “Outbound mail” in English (it covers handing over as well as delivery) and 出站邮件; the org.admin grant names the holds that relation governs, 法律保全和隔离 (legal holds, and quarantine’s list and release; releasing a rule’s hold on a send is send.propose, not org.admin); and 淼达 is confirmed as the mark.

The owner’s review, round two (1 October 2026), on the same review page, answered the three rows layer 2a added and the reviewer’s ten flags on its 400 new strings, one recorded decision each, all confirming what was proposed. The rows are confirmed with that round’s own record (ROUND_TWO in glossary.ts):

  • send.denied, 否决, for “denied” in the send reasons (send.reason.policy_denied, send.reason.approval_denied): a rule or an approver decided against a send. Avoids 拒绝, which is the refusal family of somebody else’s server.
  • recall, 撤回, in composer.sendNote and composer.how.body: taking a message back after it left, named only to say no Node can. Avoids 召回. It is the word the revoke (吊销), withdraw (收回), undo (撤销) and held rows all keep clear of.
  • vault, 密钥库, in composer.bodyUnavailable.unreadable: the key generations that open this Node’s sealed content, restored with the recovery codes. Avoids 保险库 and 金库.

The owner’s review, round three, on the same review page, answered layer 2b’s twenty rows, 送达 in NEVER, and the reviewer’s twenty flags on its 542 new strings (G1 to G20), one recorded decision each, all confirming what was proposed (decisions stamped 1 October 2026, 15:50 to 15:52 UTC). The rows below carry that round’s own record (ROUND_THREE in glossary.ts), so every row up to layer 2b is confirmed: apps/node/worker/test/node/catalog.test.ts holds that each round’s rows carry that round’s record, that no row is proposed since round four (below), and that a confirmed row put back to proposed is reported on every key it governs (breaker, recall, passkey, brand, and round four’s evidence, claim-secret, butler-run.stopped).

Layer 2b’s rows, confirmed in round three. Twenty concepts its screens needed and no row had, each bound to the keys that use it:

rowenzh-Hansavoidswhat it names
teamteam团队a group an approval stage can require a decision from
mintmint签发issuing a credential handed to someone (an invitation, an agent); re-minting 重新签发. Recovery codes are generated, 生成 (ledgers.codes.mint; G7)
capabilitycapability能力an agent’s ceiling unit; unbound (its plural defeats a binding), held by people-words.test.tsx
approveApprove批准the decision, as approval (审批) is the request; Matters’ “two people have to approve” now says 批准 too
routing-rulerouting rule路由规则策略Cloudflare Email Routing’s rule for an address, not this Node’s Rules (规则)
pausepause暂停stop, restart; 停止, 封禁D3’s one verb, for a domain and a Butler
supervised-readsupervised read受监督查阅监控, 监视a time-boxed grant to read a mailbox one holds nothing on
exportexport导出外发an e-discovery copy that leaves this Node’s controls
breakerBreaker熔断器断路器a rate this Node applies to itself (armed 已启用, unarmed 未启用, tripped 正在阻止发信)
vouchvouch作保担保an administrator’s reasoned lift of a suppression
liftlift解除释放ending a legal hold or a domain pause
administratoradministrator管理员whoever holds org.admin
receipt.measuredreceipt测量记录回执, 接收记录the measurement behind a budget, not an arrival’s record (接收记录)
onboardonboard接入Cloudflare’s word for letting an account send as a domain; 为发信接入 where the English says for sending
zonezone区域a Cloudflare zone
apexapex根域名顶级域名a zone’s own name; 顶级域名 is a top-level domain
tokentoken令牌an API token
delivery-eventdelivery event投递事件Cloudflare’s report of what a receiving server did with a send
subscriptionsubscription订阅the subscription that publishes those events into this Node’s queue
consumerconsumer消费者the Worker a queue hands its messages to

Round three’s flags, each adopted as suggested (the first option where one was offered):

  • Words. G1 was already decided (D30). G2: a deny rule’s outcome says “is denied by this rule” / 会被此规则否决, bound to send.denied (D32). G3: Setup’s delivery outcomes say what this Node does with Cloudflare’s delivered, accepted (D31). G7: 签发 only for a credential handed to someone, 生成 for recovery codes; a live agent is 生效中 as a live Butler is; the consent wrangler holds is 已获得的授权 on the first-run screen as on Setup. G10: that consent stays 授权, and “Forget this token” 移除. G11: Cloudflare’s delivery-events queue is Cloudflare 队列, so 队列 alone is always the shared Queue. G13: the matter pickers’ first option stays 暂不关联事项. G15: an approval’s count is of people deciding, 批准, not of requests: {n} 人批准, and staged 第 {stage} 阶段,共 {stages} 个阶段 · 共需 {n} 人批准 (the count is the total, so 还需 does not apply; the English “Needs / 2 approvals needed” is unchanged). G17: the dry run’s button is 试运行:{at} 的那次运行. G19: limits.pauses.list “Domains paused now” / 当前暂停的域名, as shipped.
  • Dates. G4: Setup’s verified-destinations read time, Agents’ Expires and Limits’ suppression Since go through format.ts’s dateTime, in the viewer’s zone (D33).
  • A failed read. G5: Limits’ paused domains show a failed read as failed (<Nothing kind="failed">, the Node’s words marked), not as “No domain is paused.”, and show loading while the read is pending. Held by the refused-read golden and its assertion in apps/node/worker/test/client/matters-words.test.tsx.
  • The Node’s tokens. G8: where a routing rule sends mail, a routing status, the records already there and a routing outcome’s rule are passed into their sentences as elements in <NodeWords> (sentence()), and the relation and capability tokens on People and Agents are marked lang="en" inside their mono spans. English is byte-identical.
  • Joins. G14: a screen that sets two sentences side by side joins them with join.sentence, a space in English and nothing in Chinese (below, Adding a string). Used at Setup’s outcome and rule seams, the progress line’s optional step, the first-run screen’s note and a gone mailbox’s aside. Three listed seams keep their space, because what comes before is not a sentence: the invitation’s mailbox box (a checkbox), an unpublished draft’s note (after the Publish button) and an agent’s partial ceiling (a Latin capability id, then 3/4), where the Register’s Han-Latin space applies.
  • Layout. G16: a table cell under Chinese is at least four Han characters wide (Register, below).
  • Bindings. G20: the butler row binds butlers.notAdmin, butlers.runs.none and butlers.dry.notRun, and the rules row binds policies.notAdmin and policies.empty; a bound sentence’s English now names its concept whatever the case (“No rules here” names Rules). butlers.runs.explain names no Butler, and policies.caption and policies.new say “rule”, another form of the row’s word, so the Rules screen’s rendered zh test holds them to 规则, never 策略.
  • Kept. G9: a new Butler’s and a new rule’s persisted defaults stay English (L10, data). G18: nothing to decide.
  • G12: the closed vocabularies a Butler, a matter, an export and a breaker render are contract lists, five of them keyed (Adding a string, Closed vocabularies).

Round four, accepted without review (2 October 2026). The owner did not review layer 3’s rows or its questions on a review page. In the working session on 2 October 2026 the owner said “just accept them”, and so accepted every proposed row and every question (H1 to H14) as proposed. That is what OWNER_ROUND_FOUR in glossary.ts records, with no link, because there is no page to link: its record says the owner accepted every proposed row and question as proposed in the working session on 2 October 2026, without a review page. The pull request carrying it is the only written trace, so the owner’s approval of it is the gate, as for every round. catalog.test.ts holds that no row is proposed and that each round’s rows carry that round’s record.

Layer 3’s rows, accepted in round four. Twenty-five concepts the pages before sign-in, the Doctor’s check titles, a capability’s description, a body’s problem and an audit entry’s outcome needed and no row had, each bound to the keys that use it, and one more for H7, butler-run.stopped, which holds the word the owner took for a run that a stop node ended (已终止, never 已停止). The claim-secret row now binds the claim’s field, preauth.claim.secret (H1). Layer 3 also bound its keys to confirmed rows where the English agrees: session, passkey, claim, recovery-codes and invitation-secret before sign-in, consumer and delivery-event on two Doctor titles, and legal-hold, lift, supervised-read, export and pause in the notices band’s approval subjects.

rowenzh-Hansavoidswhat it names
node.listeninglistening接收中a claimed Node’s state in the strip before sign-in
organizationOrganization组织what the claim creates
ownerowner所有者the person who claims the Node
operatoroperator运维人员whoever reads this Node’s log
renewrenew续期a session’s token renewal
passkey-sign-inpasskey sign-in通行密钥登录one passkey ceremony, which can expire or be spent
doctor.checkcheck检查项a Doctor finding’s subject
credential-keycredential key凭据密钥
signing-keysigning key签名密钥
key-generationkey generation密钥代版本one generation of the keys that seal evidence
evidenceevidence证据the stored bytes a send or an arrival is proven by
evidence-bucketevidence bucket证据存储桶
catalog-dbCatalog database目录数据库the D1 database bound as CATALOG
migrationmigration迁移
backlogbacklog积压
strandedstranded滞留
orphanedorphaned孤立
supervision-noticesupervision notice受监督查阅通知the notice §7 owes the person whose mail was read
transportsending transport发信通道适配器what hands a send to the mail service
reduced-reportreduced report精简报告
sanitisesanitising安全清理净化, 消毒removing what is unsafe from a message’s HTML
classifierclassifier分类器
triagetriage分拣
audit.refusedrefused已拒绝拒收, 否决an audit entry’s outcome: this Node refused the act
workers-paidWorkers PaidWorkers Paid 套餐付费版Cloudflare’s plan
butler-run.stoppedstopped已终止停止a Butler run a stop node ended, or whose release gate timed out (H7)

Layer 3’s questions, as round four answered them (each with its keys in the review data), each as the working session decided:

  • H1 (D35): the claim’s field says “Claim secret” in English, as the CLI mailda claim-secret and the confirmed row do; zh was already 认领码. The Node’s own bad_secret and not_installed messages said “bootstrap secret” until an accepted follow-up of their own (below).
  • H2: the Doctor’s check titles stay, in both languages. H3: the <noscript> notice stays bilingual.
  • H4, H9, H10, H11: confirmed as written (preauth.codes.lede, preauth.signin.title; D34’s headlines and “This Node refused a request that did not come from its own page.”; the notices band’s five approval subjects; 成功, 已拒绝 and 失败 for an audit outcome).
  • H5: kept as written: 证据密钥代, 发件箱出队, 与静默解释相矛盾的证据, 分拣, and “Delivery outcomes” (投递结果).
  • H7: a Butler run’s state is keyed (butlers.run.*; Closed vocabularies, above).

Accepted follow-ups, each its own change (the owner accepted them in round four, and said “go close them all”; each was made in a commit of its own on 3 October 2026, docs/history.md “The round-four follow-ups”):

  • The Node’s bootstrap messages (H1): bad_secret and not_installed said “bootstrap secret” where the field now says claim secret. Done: both say claim secret, the codes unchanged, with a note for the agents, as G6 has. The two headlines are bound to the claim-secret row, and apps/node/worker/test/claim.test.ts holds the Node’s sentence to the row’s word and to the headline, word for word.
  • H6: the notices band’s supervised headline, “was granted a supervised content of Support”, reworded in English around the Node’s scope token (“a supervised read (content) of Support”). Done, and a grant with no recorded scope has a sentence of its own (chrome.notice.supervised.unscoped), so neither locale prints “read (read)” or (范围:查阅). Both are bound to the supervised-read row; held by chrome-words.test.tsx and chrome-zh.test.tsx.
  • H8 (D36): the Node’s locked_out sentence gets a real plural for its minutes, with a note for the agents. Done: “Try again in 1 minute.” and ”… in 5 minutes.” (lockedOutMessage in apps/node/worker/src/routes/support.ts), held at one and at many, and through the route, by apps/node/worker/test/auth.test.ts.
  • H12: a text-only body with a problem shows its problem sentence, and a message with no body says so (§5C), where today one shows no notice and the other an empty <pre>. Done: above the plain text, reader.body.fallback.*, the Node’s sentence for that case (“Its plain-text alternative is shown instead.”, held equal to problemSentence by apps/node/worker/test/render.test.ts); in place of the empty <pre>, reader.body.none (“This message has no body.” / 此邮件没有正文。). No row was added: the Chinese uses words already shipped (纯文本版本 as preauth.noscript.doctor has it, 正文 as reader.body.frame), and both are listed for the owner below.
  • H13: People, Rules, Butlers and Matters say “not an administrator” on a 404 only, and show any other failed read as failed, as G5 did for Limits. Done, by what the Node answers (verified in its routes): GET /api/people, /api/policies, /api/butlers and /api/holds answer a non-administrator 404, so their sentence shows on a 404 only (answeredNotFound in apps/node/worker/src/client/app/api.ts); GET /api/matters never refuses one (it lists their own matters), so every failure there is a failed read, and matters.refused is gone from both catalogs. Matters’ two lists show loading while pending. No other screen had the pattern. Held by the not-admin and failed-read cases in people-words.test.tsx, butlers-words.test.tsx and matters-words.test.tsx.
  • H14: the backticked CLI commands inside catalog sentences (mailda claim-secret, mailda deploy, mailda recovery-codes confirm) render in <code>, an English markup change. Done: the page before sign-in draws a catalog sentence’s backticked span as <code>, backticks dropped, in every locale: the claim’s secret hint, the recovery codes’ next step, and the bad_secret and not_installed headlines. The text is unchanged in both catalogs. The Node’s own English beside a headline is shown as it was sent. Held by the pre-sign-in goldens and preauth-zh.test.ts.

For the owner, from the follow-ups (no row proposed, since zh-Hans is released): H12’s two new sentences use 纯文本版本 for a message’s plain-text alternative (改为显示它的纯文本版本。) and 正文 for its body (此邮件没有正文。). Neither is a glossary row. Both words already ship elsewhere, but the owner has not seen them used for these concepts. If either should be a row, it is a round of its own.

Not decided in round four: ending the preview. zh-Hans then met every condition of The preview flag (above), but nobody had asked for preview to come off. The owner asked later the same day, and a change of its own ended it (The preview flag, above).

G6, done in a change of its own (2 October 2026). The Node’s own English for a domain pause said stop and restart where every screen says pause and lift. It now says pause and lift too: the refusals (apps/node/worker/src/domain-pause.ts), the withheld send’s sentence (apps/node/worker/src/outbound/recheck.ts), doctor’s domain_paused finding, the audit vocabulary’s two domain.pause_* entries, and the three /api/domain-pauses summaries in the contract, which the SDK, the Agent Skill and MCP repeat, with the Skill’s reason for withholding them from agents. Every code and field name is unchanged; only prose moved, so an agent that matches a code reads the same, and one that matched the old words has the note in docs/history.md. The summaries had two errors beside the verb: they said sending to a domain, where a pause holds mail from one, and that lifting “takes more than one person”, where one administrator lifts alone. apps/node/worker/test/domain-pause.test.ts holds this prose to the pause row’s avoided words. A tripped breaker’s “stopping mail” is a breaker’s state, not a pause, and stays. A Butler’s pause was its own follow-up, done on 3 October 2026 (below).

A Butler’s pause in the Node’s own English, done in a change of its own (3 October 2026). G6’s follow-up for a Butler: the Node said a machine “stopped” a Butler and an administrator “restarted” it, where every screen says paused and resume (D26). It now says pause and resume: the two /api/butler-pauses summaries in the contract (and so the SDK’s comments), the audit vocabulary’s butler.paused and butler.resumed, the resume’s refusals (apps/node/worker/src/butler/pause-acts.ts, where a resume also no longer “releases”, a held send’s and a gate’s verb), doctor’s unreadable butler_paused finding and the Butler capability’s sentence on the loop. Every code and field name is unchanged. apps/node/worker/test/butler-pause.test.ts holds this prose to the pause row’s avoided words. Not a pause, and kept: a revoked relation “stops it on the next node”, a run a stop node ended is stopped (butler-run.stopped, 已终止), and doctor’s silence check tells “a Butler that stopped running” from one nothing has triggered.

Flags confirmed as they ship, no change: F1 the composer’s dock head says 正在回复:/ 正在转发:, so a Chinese subject’s own 回复: is not read as a doubled prefix; F2 放行 for the three gates (a rule’s hold, a Butler’s gate, a quarantined delivery), 放回队列 only for a case given back; F3 “held” as a case state is 他人处理中 and a held delivery is 隔离; F4 queue.held.reason.dmarc_fail_reject says 要求收件方拒收, the receiving server’s verb; F6 the composer’s “your node” stays 你的节点, the English’s own framing, not 本节点; F7 “policy” in the send notes is 规则, and since D1 (layer 2b) the English says rule too, and “the mail service” is 邮件服务商; F8 DMARC’s own policy stays 策略 (reader.auth.noPolicy); F10 a zh-Hans reader still guesses sc for a message that names no script, so an English message’s Latin is drawn with the SC face’s. Two flags changed words: F5, the measure word moved into each noun passed to chrome.truncated (drafts.noun 份草稿, chrome.notices.noun 条通知, ledgers.noun.entries 条记录, ledgers.outbox.noun 条发件记录, queue.held.noun 封本节点上的隔离来信, limits.suppressed.noun 个邮件地址), and F9, the English plurals and the held notes (D10 to D13 below).

  • 你, not 您; 本节点, not 我们. The English speaks as the Node, not as a vendor.
  • Full-width punctuation in sentences (,。:;?!) and “ ” quotes. Checked.
  • One space between Han and a Latin letter or digit: 3 封邮件, Worker 与密钥. Checked in the catalog (T2), and made by the runtime where a filled value meets a Han template ({name}已发布 with lead-response is lead-response 已发布), which no catalog check can see; never inside the value.
  • Identifiers stay Latin and in mono: relation tokens, E_ codes, budget names, CLI verbs, header names, .eml.
  • Han is not broken mid-word where a column is narrow: under :root:lang(zh), thead th (layer 2a) and the buttons and links in a table cell (td button, td a, layer 2b) keep words whole (word-break: keep-all), since a phone width otherwise stacked 发布 and 恢复发信 one character per line. Text cells still wrap per character, so every table cell is at least 4em wide under :root:lang(zh) (G16, round three): four Han characters, a presentation constant, intended so that 已启用 or 生效中 · v1 holds one line and a sentence wraps inside its column rather than down one character’s width.

The brand is 淼达 in the zh-Hans interface: the shell’s wordmark and the title ({screen} · 淼达). English shows Mailda only. The sign-in and claim pages draw the lockup, 淼达 with a secondary <span lang="en">Mailda</span> after it, quieter (bootLocale(), built in layer 3; .wordmark > span[lang] in src/shell-css.ts). mailda stays in every identifier: the CLI, @mailda/*, mailda.site, MAILDA_*, headers, error codes. 淼达 is a homophone of 秒达, “arrives in seconds”, a delivery slogan this product must never make; 秒达 is in NEVER. src/brand.ts records it.

English defects the translation must not copy

Section titled “English defects the translation must not copy”

Fixed in English in the layer that migrates the file, before it is translated, or in the owner’s review that follows it (D10 to D13). Layer 2b fixed D1, D3 and D9 and found D14 to D28 while migrating, its verification D29, and the owner’s decisions on it D30 to D33:

#EnglishFix
D1Rules (screen) vs “policy” in the send-reason notes, and Approvals’ “A policy asked for a second pair of eyes”“A rule holds this send”, “A rule asked …” (fixed, layer 2b: the notes and Approvals; the labels keep “policy hold”, the glossary’s confirmed English, and “policy denied”, “policy is stricter now”, for the owner)
D2Automate / Automations / Butlers: one stem three timeszh keeps three words (自动化 / 自动化流程 / 管家)
D3Stopped vs paused domains; “three administrators” vs “two”one verb (pause), and every sentence counts as apps/node/worker/src/domain-pause.ts does: two administrators besides whoever asks (“Pausing a domain takes the agreement of two other administrators; whoever asks is never one of them”). An export’s approvers hold approval.decide on the mailbox (apps/node/worker/src/exports.ts), so it says “Two other people”, not administrators. Limits’ “Stopped domains”, “Ask to stop”, “Restart” and Approvals’ “Stopping a domain’s mail” say pause and lift; the domain_paused note says “paused every send … can lift the pause”, not “stopped … restart the domain” (fixed, layer 2b)
D4Bootstrap secret vs install secret vs claim-secretone word (认领码)
D5Outbox empty state “Nothing has been sent” (ADR 39)“The Outbox is empty.” (fixed, layer 2a)
D8handed_over’s note says “Accepted by the mail service”, the delivery word“Taken by the mail service.” (fixed, layer 2a)
D9Matters’ empty state “Nothing is under investigation”, and its lead “An investigation, and what it authorises”“No matters you can see. An administrator sees every matter; anybody else sees the ones they opened.”, “A matter, and what it authorises.” (事项; fixed, layer 2b). It first said “No matters have been opened.”, which a member who is not an administrator reads as a fact about the organization; the Node filters their list rather than refusing it (3 October 2026)
D10“1 sends” (ledgers.outbox.count)a plural on n: “1 send”, “6 sends” (fixed, round two)
D11“1 message(s) moved” (queue.merged); “within 1 minutes” (queue.target.set, queue.target.promised)plurals on n (fixed, round two)
D12“(s)” in the Doctor’s remedies: ledgers.collect.done, ledgers.search.requeue, ledgers.evidence.*one plural per count (ledgers.collect.orphans, .drafts, .exports; ledgers.evidence.checked, .checkedIn, .faultCount) filled into the sentence; “Requeue 1 message” (fixed, round two)
D13The held, awaiting and withheld notes say “Not sent (yet).”this scale’s word: “Not handed over yet.” (held, awaiting), “Not handed over:” (withheld, which is final); 尚未移交, 未移交 (fixed, round two)
D14“1 record(s) still required” (Setup’s progress, routed and sending)a plural on n (onboarding.required) (fixed, layer 2b)
D15“example.com now has 2 confirmed record(s)” (Setup, receiving)a plural on n (setup.receiving.done.routed, .notRouted) (fixed, layer 2b)
D16“1 of the 2 address(es) this Node has handed mail to” (Setup, verified destinations)two plurals: the sentence on the verified count (is/are), setup.verified.recipients on the other (fixed, layer 2b)
D17“Would publish 1 event types” (Setup, delivery outcomes)a plural on n (setup.outcomes.publish) (fixed, layer 2b)
D18“next: mail routed to this node.” (a label lowercased, so Node became node)each step has a .phrase, its in-sentence form: “next: mail routed to this Node.” It is also what the first-run screen’s “Next: …” and the shell’s notice say (fixed, layer 2b)
D19“This agent will hold 1 capability(s) across 1 mailbox relation(s)”one plural per count (agents.review.capabilities, .relations) (fixed, layer 2b)
D20“2 pinned route(s) this Node no longer names”a plural on n (agents.unnamed) (fixed, layer 2b)
D21“Showing the newest 1 drafts” (chrome.truncated)a plural on the count shown: one is “Showing the newest one.” (fixed, layer 2b)
D22“message(s)” in chrome.notice.queries and chrome.notice.rawplurals, the second count its own (chrome.notice.listed) (fixed, layer 2b)
D23Limits’ window “1 minutes”limits.window.days, .hours, .minutes, each a plural (fixed, layer 2b)
D24Matters’ export object “1 bytes”a plural on n (matters.exports.bytes), so the count is grouped: 1,024 bytes (fixed, layer 2b)
D25The dry run’s “4 node(s)”a plural on n (butlers.dry.nodes) (fixed, layer 2b)
D26A paused Butler’s standing “stopped — loop detected”, beside a Resume button“paused — loop detected” (D3’s verb; held by the pause row, which avoids stop) (fixed, layer 2b)
D27A rule’s volume clause “once this Node has sent 500 today”, whose counter is the hand-overs (apps/node/worker/src/policy.ts)“has handed over 500 today” (ADR 39; held by the send.handed_over row) (fixed, layer 2b)
D28Setup’s record day “24 Sept 2026”, the browser’s ICU month“24 Sep 2026”, from MONTHS as every other English date (recordDay) (fixed, layer 2b)
D29Limits’ paused-domains table carried its section’s name, which axe reported as a duplicateits own name, limits.pauses.list, “Domains paused now” (fixed, layer 2b verification)
D30The send-reason labels “policy hold”, “policy denied” and “policy is stricter now”, where the notes say rule (D1)“rule hold”, “rule denied”, “rules are stricter now”; the policy-hold row’s English with them (the owner’s decision, 1 October 2026)
D31Setup’s delivery outcomes list Cloudflare’s event kinds “delivered, bounced, complained”, while this Node reads delivered as accepted and the Chinese said 已受理“accepted (Cloudflare’s delivered), bounced, complained”, the token in mono; zh 已受理(Cloudflare 的 delivered)、退信或投诉 (round three, G3)
D32A deny rule’s outcome “is refused”, the provider’s word (send.refused), for this Node’s own rule deciding against a send“is denied by this rule” / 会被此规则否决, bound to send.denied (round three, G2)
D33Setup’s verified-destinations read time and Agents’ Expires printed the wire’s ISO stamp, and Limits’ suppression Since sliced it to UTC with no zoneeach is format.ts’s dateTime, in the viewer’s zone and locale (round three, G4)
D34A refusal before sign-in showed the Node’s four-part English alone (E_PASSKEY_REJECTED that passkey did not verify …): a code first, then prose for an operatora headline in the viewer’s language above it, keyed by PREAUTH_ERRORS, the Node’s English kept beside it and marked; English gains the headline (fixed, layer 3)
D35The claim’s field says “Bootstrap secret”, the CLI mailda claim-secret, the glossary row “claim secret” (D4)“Claim secret”, bound to the claim-secret row; zh was already 认领码 (fixed, round four, H1). The Node’s own two refusals say claim secret since their follow-up (3 October 2026)
D36locked_out’s “Try again in 5 minute(s).”the headline carries no count, so the plural was not copied into the catalog; the Node’s own sentence has a real plural since H8’s follow-up (“5 minutes”, “1 minute”; 3 October 2026), the code and retry-after unchanged
D37The notices band printed an approval’s subject and a matter’s type as raw tokens (send_manifest, legal_hold) and its instants as raw ISO stampsthe subject in words (chrome.notice.kind.*, keyed by APPROVAL_SUBJECT_KINDS), the type in matters.type.*, each instant format.ts’s dateTime; the grant’s scope stays the Node’s token, in <code> (fixed, layer 3)

Approvals now shows a domain pause’s domain and reason, in both locales: the card read pause while the Node sends domainPause, and the test fixture had the screen’s shape rather than the Node’s, so it never showed them (found on screen in layer 2b’s verification; the fixture now has the Node’s shape).

D5, D8 and D13 are held by apps/node/worker/test/node/delivery-summary.test.ts (“the Outbox’s words keep to their scale”): no send.state.* word says accepted, bounced or delivered (受理, 退信, 送达), only never_submitted’s note says sent, and only to say nothing was, the held, awaiting and withheld notes say not handed over, and the empty Outbox does not say sent. D10 to D12 are held by the English tests of the screens that show them, at one and at many (apps/node/worker/test/client/ledgers-words.test.tsx, queue-words.test.tsx, doctor-actions.test.tsx). The evidence verdict’s {bytes} bytes read keeps its ungrouped string count and is not a plural, so a batch of exactly one byte would read “1 bytes read”; a plural there would group the count the goldens pin as 1024.

D14 to D17 are held by apps/node/worker/test/client/setup-words.test.tsx and onboarding.test.ts at one and at many; D19 and D20 by people-words.test.tsx; D21 and D22 by chrome-words.test.tsx, drafts.test.tsx and truncated.test.tsx; D23 and D24 by the Limits and Matters goldens (matters-words.test.tsx); D25 by butlers-words.test.tsx; D28 by onboarding.test.ts. D3’s pause, D26 and D27 are bound in the glossary, so the English going back fails T2; D1’s notes are held by butlers-words.test.tsx (“calls a rule a rule in every send note”). D31 is held by the Setup golden and its zh test in setup-words.test.tsx; D34 by the pre-sign-in goldens and apps/node/worker/test/client/preauth-zh.test.ts; D37 by chrome-words.test.tsx; D32 by the Rules goldens and the send.denied row; D33 by the Setup, Agents and Limits goldens, where each instant is the viewer’s toLocaleString(), and by setup-screen.test.tsx.

Other English changed on purpose in layer 2a, none of it a D row:

  • The reader’s thread row named a send’s state by its raw token (handed_over); it now says the catalog’s label (“handed over”).
  • A reply’s quote line carries the offset from UTC: “On 8/21/2026, 17:00:00 GMT+08:00, alice@… wrote:” (critic L5).
  • Numbers are grouped: an attachment of 1234 KB reads 1,234 KB, and a Queue deadline in 26438 days reads 26,438d. A count whose English was pinned ungrouped (“1024 bytes read”) is passed as a string and keeps its form.
  • The Outbox’s Retry no longer trails a · when no .eml link follows it (a send never submitted has none).

And in layer 3 (the pre-sign-in goldens, preauth.*.en.html under apps/node/worker/test/client/golden/, were written from the scripts before their first edit):

  • A refusal before sign-in gains a headline above the Node’s sentence where the Node’s sentence does not already begin with it (D34): “That password is too short.”, “This Node refused a request that did not come from its own page.” (a new sentence), “That invitation could not be used.”, “That passkey was not accepted.”, “This passkey sign-in can no longer be finished. Start again.”, “This passkey sign-in was already used. Start again.”. Every other word before sign-in is text-identical, and the <noscript> English is identical per paragraph.
  • The Doctor’s Check column shows a title above each check’s name (H2, kept in round four).
  • The notices band says an approval’s subject and a matter’s type in words, and its instants in the viewer’s zone (D37).
  • A bounced recipient’s bounce type is shown beside its state, in <code>, where it was the end of the state’s title.
  • Tokens that were bare text are in <code>, text unchanged: an audit action, a log event, a grant’s scope on Approvals, the SPF, DKIM and DMARC results and a DMARC policy, a Butler’s yaml and json.
  • Round four: the claim’s field says “Claim secret” (D35, H1); a Butler run’s state is the catalog’s word, so awaiting_release reads “awaiting release”, and the run a dry run reads names its trigger event in <code> (H7).
  • Round four’s follow-ups (3 October 2026): the Node’s claim refusals say claim secret (D35); the commands in the claim’s secret hint and the recovery codes’ next step are in <code>, backticks dropped (H14); the Node’s lockout sentence says “5 minutes” (H8); the notices band says “was granted a supervised read (content) of Support” (H6); a text-only body with a problem says so above the text, and a message with no body says “This message has no body.” where the panel was empty (H12); People, Rules, Butlers and Matters’ holds say “not an administrator” on a 404 only, and Matters’ list never does (H13). Outside the screens, the Node’s English for a Butler’s pause says paused and resume (G6’s Butler half).

And in layer 2b:

  • Numbers are grouped: a Butler run’s counts, a rule’s daily volume, a dry run’s spend and an export object’s size (1,024 bytes). No golden held a value of 1,000 or more except the export’s.
  • An unarmed breaker with no reason says “unarmed”, not “unarmed — ”.
  • Setup’s delivery outcomes named Cloudflare’s event kinds “delivered, bounced, complained” in English while the Chinese said 已受理, because this Node reads Cloudflare’s delivered as accepted (apps/node/worker/src/outbound/events.ts) and 送达 is in NEVER. Round three made the English say so too (D31).

Since 3 October 2026 a few of the repository’s docs have Simplified Chinese translations, for mailda.site’s /zh-cn/ (the owner’s approval of that day). The interface’s catalog is the authority for its words; for a doc the English file is, and the translation says so at the top of its page.

Where they live. docs/zh-cn/<name>.md, one per English file, with frontmatter naming what it was made from:

translated_from: docs/disaster-recovery.md # the English file, from the repository root
source_sha256: f7f3980d… # the hash of the English it covers, when the translation was made
source_commit: bb5f130 # the commit it was read at, for git diff
translated_until: "## What this runbook …" # optional: the translation covers the English before this line

Two are translated, chosen as what an operator needs first and changes least: docs/cloudflare-settings.md, whole (what the account needs, and how either path puts it there), and docs/disaster-recovery.md up to its drill reports (the runbook itself). The README’s opening part was translated too and then dropped: it changes about twice a day (71 commits in the month before), so its translation would have stopped every unrelated pull request until somebody re-translated it, and the /zh-cn/ landing page already says its pitch and its status in Chinese. docs/onboarding-journey.md, also proposed, is not translated: it is an analysis of decisions, says so in its first line, and its operator steps are the README’s. A translation with translated_until stops before the English’s history; the page says the rest is English and links it. apps/site/scripts/generate-docs.mjs renders each at its English page’s path under /zh-cn/, in place of Starlight’s fallback, with its links kept inside /zh-cn/; every other /zh-cn/docs/* page is still the English under the notice that it is not yet in your language.

The hash rule. apps/site/test/translations.test.mjs hashes the English each translation covers and fails when that hash differs from the translation’s source_sha256, naming both files and saying to update the translation or remove it. For a whole-file translation that is the whole file. For one with translated_until it is the English from the top to the line before that heading, so an edit further down (a new drill report) passes, and an edit to the translated part, or to the heading where it stops, fails. A translation nobody looked at after its English moved is the stale doc AGENTS.md calls a landmine, and a red test is cheaper than a Chinese page saying what the English no longer does. The same test fails a translation whose code blocks are not its English’s byte for byte, whose links (resolved to repository paths), inline code, table rows or heading levels differ from the part of the English it covers, that uses a NEVER phrase or 同步, or that breaks the register (the Han-Latin space and full-width punctuation, as catalog.test.ts holds the catalog to).

Updating one. When the test names a translation:

  1. git diff <source_commit> -- <translated_from> shows what the English did.
  2. Change the translation to match, in the glossary’s words.
  3. Record the new hash as source_sha256 (node apps/site/scripts/translations.mjs prints each translation’s current one; for a whole-file translation it is sha256sum <translated_from>) and the current commit as source_commit. If the heading where a partial translation stops was renamed, update translated_until first.

Or remove the translation: /zh-cn/ then shows that page’s English fallback again, which is honest. Never update the hash alone without reading the diff: the hash is the record that somebody did.

#WhatWhere
T1Missing or extra keys, keys outside an area, wrong parameters, n as a string, a literal or glued string where Text goes, an unnarrowed wire token, a plural where a string belongs: compile errorsapps/node/worker/test/client/catalog-types.test.ts
T2Placeholder parity, plural categories, Chinese punctuation and spacing, glossary terms, scoped avoid, NEVER and NEGATES, no proposed word in a released locale, areas disjointapps/node/worker/test/node/catalog.test.ts
T3Default-deny scan with the TypeScript checker: any literal with a letter whose contextual type is not a token type and whose position is not structural; Intl outside format.ts. The UNMIGRATED ratchet and NOT_PROSEapps/node/worker/test/node/untranslated.test.ts
T6bootLocale(): lang, dir, title and wordmark in one step; refused storage; the flag; a Simplified Chinese browser and a stored choice; Settings lists both languages; the frame never takes the languageapps/node/worker/test/client/locale-boot.test.tsx
T7Negotiation and the boot decision (zh, zh-CN, zh-SG, zh-MY get zh-Hans; zh-TW, zh-HK, zh-MO do not); filling, plural selection and the Han-Latin space at a placeholderapps/node/worker/test/node/locale-format.test.ts
T11The preview mechanism, in a registry with a preview in it: not offered, not negotiated, not honoured from storage, reached by the flag; not in Settings; no switch before sign-in with one offered localeapps/node/worker/test/node/locale-preview.test.ts, apps/node/worker/test/client/locale-preview.test.tsx
T8The served tables equal the catalogs at content-tagged immutable URLs; an old tag redirects, uncached, to the current one; /app/locale.js names them and carries no app wordsapps/node/worker/test/catalog-served.test.ts
T9The SC font stack’s order, tracking only through zeroable tokens, the rail under Chineseapps/node/worker/test/node/locale-typography.test.ts
T10isComposingKeyapps/node/worker/test/node/ime.test.ts
T12A doc translation’s English has not moved since it was made (hash), and the translation keeps its code blocks, links, inline code, tables and headings, no NEVER phrase, and the register (Doc translations)apps/site/test/translations.test.mjs
GA migrated screen’s English equals the goldens taken from it before the migration, and its zh-Hans marks the Node’s words lang="en"apps/node/worker/test/client/reader-words.test.tsx, composer-words.test.tsx, queue-words.test.tsx, ledgers-words.test.tsx (layer 2a); setup-words.test.tsx, people-words.test.tsx, matters-words.test.tsx, butlers-words.test.tsx (layer 2b)
M6The shell is preloaded, imported and its words loaded only inside handOverToShell’s tryapps/node/worker/test/node/shell-preload.test.ts
G (layer 3)The pages before sign-in: English equals the goldens taken before the migration; zh-Hans shows the lockup, the switch, every refusal’s headline with the Node’s English marked, the session’s endings; <noscript> is one block per locale, English first, English word for wordapps/node/worker/test/client/preauth-words.test.ts, apps/node/worker/test/client/preauth-zh.test.ts (driver: apps/node/worker/test/client/preauth-driver.ts), apps/node/worker/test/noscript.test.ts
T4The pseudo-locale en-XA (every catalog letter accented, {placeholders} kept, +40%, in ⟦ ⟧; built from en in the test, never in LOCALES, a catalog, PREAUTH or a served table) renders every route in APP_ROUTES in the whole shell, populated, empty, with every read failed (the Node’s words, and none), the first-run gate and the notices band, then presses every button against a Node that refuses every act; and the pages before sign-in with each of their refusals. Any Latin letter outside lang="en", <code>, <kbd>, <samp>, T3’s NOT_PROSE and the fixtures’ Latin data fails. Fixture data is Greek, the Node’s own words English. Installed in zh-Hans’s slot, so <NodeWords> marks and Intl formats as for a translated locale; plurals show other onlyapps/node/worker/test/client/pseudo-locale.test.tsx, pseudo-preauth.test.ts (builder and reading: apps/node/worker/test/client/pseudo-locale.ts; the Node: apps/node/worker/test/client/pseudo-node.ts)

T4 was built in layer 3 and is green: its first run found fifteen places a screen still showed English bare (raw tokens, raw ISO stamps, a unit, a recipient’s error unmarked, a token in a title), each fixed as the token rule above says, and three defects that were not language (a Verify refusal read as a broken chain, an Acknowledge that threw on an empty field, a gate that re-read the provider in a loop for a member), each fixed with its own test. Its fixtures follow the Node’s shapes, not the screens’: the Node’s scope tokens are metadata and content, an approval’s subject send_manifest, an audit outcome ok, refused or failed. T5, the browser sweeps under a locale, is by hand and not in CI, like the sweeps themselves: scripts/axe.mjs and scripts/spacing.mjs take --locale <tag> before the origin, which puts the review flag on every address and reads each migrated control’s name from the catalog the page renders from (scripts/sweep.mjs). Screenshots are by hand.