[English](FEATURE.md) · [Русский](FEATURE.ru.md)
# Contract registry — protocol schemas, node sanitizing and warning codes
LxBox checks every VPN node — from a VLESS or Hysteria2 share link to a WireGuard, AmneziaWG or
Tailscale body — against a contract registry it shares with the desktop launcher: protocol schemas,
field rules, warning codes and limits. The registry judges a node twice, at import and at config
build for the pinned sing-box-lx core, and every field it removes or replaces is named by a code
with a text, a reason and a "Learn more" page. The registry arrives by sync only; CI tests, a lock
file and a conformance corpus keep the copy honest.
| Field | Value |
|------|----------|
| Feature | 025-CONTRACT_REGISTRY |
| Type | Mechanism feature (shared with the launcher; executed by import, build and the node card) |
| Absorbed | `§460F` `§472F` `§480F` (registry parts of `§002` and `§021`) |
| Contract | registry `1.1.99`, copy hash `388251fc…`, synced 2026-09-27; core in `body.core` — `1.14.1-lx.4`, `1.14.2-lx.6` |
| State | ✅ written from code, 2026-09-29 |
## Purpose
The launcher and the app parse the same subscriptions. Two hand-written parsers would diverge on
the first odd link and give the user a different node set on the phone and on the desktop. The
registry is the one place where the rules live: which link schemes exist, which fields a body may
carry, which values the core accepts, what a warning says. The app executes the registry; it keeps
no copy of the rules in code.
Principles the feature protects:
- **Rules are data.** A new scheme, field or code arrives with a sync, not with a code change.
- **The sanitizer is the only judge of values.** Mappers translate, the model stores.
- **A loss is named, and named the same in both apps.** Every removed field, replaced value or
dropped entry carries a registry code; the text, the reason and the fix come from the registry.
- **The core is judged at build, not at parse.** A node does not depend on which core is running;
build tags and `min_core` are judged once, against the pinned core.
- **The copy is never edited by hand.** Copies and mirrors arrive by sync; a guard catches an edit.
## Promises
- **P1. Every input goes through mapper → sanitizer → model by the registry.** Link, `.conf`,
Xray and sing-box objects share one path and one set of value rules. **Witness:** unit tests
"an unknown key is removed with `unknown_key`", "enum outside the set → `on_invalid` with the
registry code", "the vless schema expands tls / transports / dialer". **Mutation:** a value rule
written in a mapper.
- **P2. A node the core would reject is removed at parse time.** A rule with the "remove node"
verdict (severity `error`) drops the entry and puts the code into the rejects. **Witness:** unit
tests "a method outside the core set removes the node entirely", "URI `headerType=http` → no
node, code `transport_header_unsupported`". **Mutation:** `drop_node` turned into a warning on a
live node.
- **P3. Parsing does not depend on the running core.** `min_core` and platform gates are off at
parse time. **Witness:** unit tests "core gates at parse time are off: `min_core` yields no
code", "core gates are off on the verbatim map too". **Mutation:** parsing passes the real core
version to the sanitizer.
- **P4. Warnings survive a restart.** A node is stored as source text and re-parsed on every load;
codes are recomputed, not serialized. **Witness:** unit test "warnings survive storage: the node
is re-parsed". **Mutation:** codes read from storage.
- **P5. Secrets do not leak into reasons.** The value of a field marked `secret` is `***` in the
text, in the rejects and in the log. **Witness:** unit tests "a broken secret uuid leaks neither
into the text nor into the log", "secret: the value in the warning is masked". **Mutation:**
masking in the text only, not in `value`.
- **P6. Every registry field survives the body → model → body round trip.** Fields the model does
not hold return from the cleaned map. **Witness:** unit tests "every field of the schema lands in
at least one body", "a body field survives the round trip body → model → emit", per scheme.
**Mutation:** the model silently drops a field it does not know.
- **P7. A missing registry does not break the app.** Load failure is logged, the app starts, the
build gate is a no-op, the sanitizer returns a body as is. **Witness:** unit tests "registry not
loaded — the gate is a no-op", "registry not loaded — the body is returned as is".
**Mutation:** a load error aborts startup.
- **P8. A node the pinned core cannot run stays out of the config.** A protocol, field or range
form with `on_core_unsupported: drop_node` whose `build_tag` or `min_core` the core lacks is
removed whole with a registry code; an unknown version or unknown tag set is not a refusal.
**Witness:** unit tests "tailscale: without the tag — refusal with a code; with the tag and
without knowledge of tags — accepted", "AWG range form: a range on an old core removes the node,
a number does not", "AWG 3.x field: `min_core` unmet — node removed; version unknown — kept".
**Mutation:** an unknown core version treated as too old.
- **P9. The build gate cleans bodies by the schema, and an entry without `type` never reaches the
core.** **Witness:** unit tests "naive from a JSON source: `foo` and `tls.insecure` removed,
`certificate` intact", "an entry without a required field is removed whole", "a body of a
foreign dialect is removed with a warning on the node", "empty and non-string `type` is removed
too". **Mutation:** the `type` check placed after the registry-loaded check.
- **P10. An authored body is only commented on.** A soft rule leaves the value and gives a code
with `applied: false`; a hard rule (`core_rejects`, `type`, the core gate) is applied.
**Witness:** unit tests "soft violation: body unchanged, code with `applied: false`", "hard
violation is fixed: `flow` outside the core set removed", "an invalid `encryption` removes the
authored node too". **Mutation:** a soft rule edits an authored body.
- **P11. Codes reach the user in three places.** The node row and the notifications sheet, the
Notifications section of the node screen, and the Debug API with pinned English texts.
**Witness:** widget tests "a tap on the row → a sheet with all notifications", "the row shows the
registry code TITLE, not the full text"; unit tests "registry code: code, path, value and both
texts", "3 nodes with one raw tag → 3 keys, all warnings in place". **Mutation:** the Debug API
text depends on the device locale.
- **P12. Levels are shown separately.** `error` lives in the rejects, `warning` and `info` on the
node; the row shows the top level, info-only is an icon. **Witness:** widget tests "only info —
no warning row at all", "error + warning — red text of the top one, no info icon", "`+N more`
counts only error and warning", "counters by level, no zeros". **Mutation:** info counted in
`+N more`.
- **P13. Every registry code has full texts and a "Learn more" anchor.** `en` and `ru` titles and
texts are filled; the documentation mirror has an anchor per code and names the shipped
version. **Witness:** unit tests "every code has all texts filled in en and ru", "every registry
code has an anchor in `warnings.md`", "the version in the mirror README equals the shipped
VERSION". **Mutation:** a code with an empty `title_ru` passes.
- **P14. An unknown code is shown, not hidden.** A code missing from the registry is rendered as
the code itself with severity `warning`; an unfilled placeholder stays visible. `no witness` —
the fallback is in code, no test names it.
- **P15. The copy, the mirror and the lock agree.** The vendored copy hashes to `contract.lock`,
the app mirror equals the copy file by file, the documentation mirror equals the generated
pages. **Witness:** CI step "Contract lock" (manual check: edit any file under `app/contract/`,
the check exits with the diff); unit test "the assets mirror matches the contract copy".
**Mutation:** the tree hash includes file names.
- **P16. Registry tests run on CI and are not skipped; a corpus skip is loud.** Registry tests
load the committed mirror; corpus suites without the copy print their list. **Witness:** unit
test "corpus skip guard — summary of skips" (asserts the mirror exists); CI log
"corpus skipped suites (N)". **Mutation:** a registry test gated on the vendored copy.
- **P17. A protocol arriving by sync loads without a code change.** The protocol set is read from
the bundle manifest, not from a list in code. **Witness:** unit tests "every file of
`protocols/` is read — composition from the directory", "load() takes the `protocols/`
composition from the manifest", "contract 1.1.48/49 — `amneziawg` in the set WITHOUT a code
change". **Mutation:** a hard-coded protocol list.
- **P18. Registry references into the app do not go stale silently.** Every `refs.dart` points to
an existing file or to an allowlist that cleans itself. **Witness:** unit test "`refs.dart`
point to existing files or to the allowlist". **Mutation:** an allowlist entry for a file that
exists again still passes.
- **P19. Dictionaries in code equal the registry.** uTLS fingerprints, backup codes (both
directions) and limits match. **Witness:** unit tests "utls_fingerprints", "backup_warnings ↔
app codes", "registry limits equal the code constants". **Mutation:** a one-way check.
## Controlled parameters
No user settings. The knobs are the registry and the pins it is judged against.
| Parameter | Value | Source |
|----------|----------|----------|
| Contract version | `1.1.99` | `app/contract.lock` (`source_sha`, `sha256`), `app/assets/contract/VERSION` |
| Core the build gate judges against | pinned core `v1.14.2-lx.8` + build-tag mirror | core pin — [021-CORE_CONTRACT](../021-CORE_CONTRACT/FEATURE.md) |
| Empty core version at build | fail-open: `min_core` gates do not fire | build inputs |
| Registry languages | `en`, `ru`; other locales get `en` | `warnings.json` |
| Link length | ≤ 65536, `vpn://` ≤ 524288 | `limits.json` |
| Amnezia decompression | ≤ 4 MiB | `limits.json` |
| Nodes per subscription | 3000 declared; not enforced by the app | `limits.json` (see Maintenance) |
| "Learn more" link | the documentation mirror in this repository, branch `main`, anchor = code | mirror `docs/contract/` |
Registry composition shipped in the build: `VERSION`; `registry/` — `allowlists`, `backup_warnings`,
`containers`, `dialer`, `limits`, `multiplex`, `presets`, `source_kinds`, `tls`, `transports`,
`vars`, `warnings`; `registry/protocols/` — one file per scheme.
| Scheme | Core `type` | Kind | Accepted from |
|---|---|---|---|
| `anytls` | `anytls` | outbound | link, sing-box JSON |
| `chain` | `chain` | outbound | build only (chains) |
| `group` | `selector` / `urltest` | group | sing-box JSON, Xray JSON, link |
| `http` | `http` | outbound | link, sing-box JSON, Xray JSON |
| `hysteria` | `hysteria` | outbound | launcher only — the app builds no v1 node |
| `hysteria2` | `hysteria2` | outbound | link, sing-box JSON, Xray JSON |
| `masque` | `masque` | outbound | link, sing-box JSON |
| `naive` | `naive` | outbound | link, sing-box JSON |
| `openvpn-client` | `openvpn-client` | endpoint | sing-box JSON |
| `shadowsocks` | `shadowsocks` | outbound | link, sing-box JSON, Xray JSON |
| `socks` | `socks` | outbound | link, sing-box JSON, Xray JSON |
| `ssh` | `ssh` | outbound | link, sing-box JSON |
| `tailscale` | `tailscale` | endpoint | sing-box JSON; build tag `with_tailscale` |
| `trojan` | `trojan` | outbound | link, sing-box JSON, Xray JSON |
| `tuic` | `tuic` | outbound | link, sing-box JSON |
| `vless` | `vless` | outbound | link, sing-box JSON, Xray JSON |
| `vmess` | `vmess` | outbound | link, sing-box JSON, Xray JSON |
| `wireguard` | `wireguard` | endpoint | link, sing-box JSON, WireGuard / AmneziaWG `.conf`, `vpn://`, Xray JSON |
Shared sub-schemas: `tls` (TLS, uTLS, REALITY, ECH), `transports` (by `transport.type`),
`multiplex`, `dialer`. Warning dictionary: 108 registry codes (24 `error`, 54 `warning`, 30 `info`)
plus 1 app-only code (`unknown_node_type`); the former app-only `duplicate` is replaced by the
dictionary code `duplicates_collapsed` (contract 1.1.102, task 589).
## Inputs / Outputs
**Inputs:** an entry of one kind (a link line, INI, a JSON object) with its mapper name and source
kind; at build — the emitted node bodies, the core version and its build tags; the registry files
from the app bundle.
**Outputs:** a node with a body in core form, a model and codes `{code, path, value, params}`; the
reject list `dropped[]` with a code and an owner; at build — cleaned bodies, the list of removed
entries and warnings by final config tag; for the user — the row, the card and the Debug API form.
## Data flow
```mermaid
flowchart TD
IN1["Share link"] --> SK
IN2["Subscription body"] --> SK
IN3["sing-box / Xray JSON, .conf, vpn://"] --> SK
SK["Source-kind recognition"] --> MAP["Mapper by protocol schema"]
MAP --> SAN["Sanitizer by body schema"]
SAN --> MODEL["Typed node model"]
MODEL --> STORE[("Node storage: source text")]
STORE -. "re-parse on every load" .-> MAP
STORE --> GATE["Build: core registry gate
pinned core version + build tags"]
GATE --> CFG["Core config"]
REG[("Contract registry
schemas, warnings, limits")] --> MAP
REG --> SAN
REG --> GATE
SAN -. codes .-> W
GATE -. codes .-> W
W{{"Warnings"}} --> CARD["Node row and card"]
W --> NOTIF["Notifications section"]
W --> DBG["Debug API"]
SYNC["Sync from the launcher"] --> REG
REG --- G1["CI registry tests"]
REG --- G2["contract.lock check"]
REG --- G3["Conformance corpus (local)"]
```
The same flow in text:
1. Input — a link, a subscription body or a JSON/`.conf`/`vpn://` document — is classified into one
source kind by the registry.
2. The mapper of that kind builds a raw map in core form from the protocol schema; it checks nothing.
3. The sanitizer runs the body schema: type, enum, format, `requires`, `conflicts`, `secret`.
Every removal or replacement becomes a code; a `drop_node` verdict sends the entry to the rejects.
4. The typed model is built from the cleaned map; the node is stored as its source text and
re-parsed on every load, so codes are recomputed.
5. At build, the core registry gate judges each body against the pinned core version and build tags:
what the core cannot run is removed; an authored body is only commented on.
6. The core config is written; codes go to the node row and card, the Notifications section and the
Debug API.
7. The registry itself arrives by sync from the launcher; CI registry tests, the lock check and the
local corpus guard the copy.
## Rules and guarantees
- The registry is the only source of schemes, fields, texts and limits; there is no fallback to
hand-written rules. The one fallback number is the AmneziaWG MTU ceiling.
- Stage order is fixed: mapper → raw map → sanitizer → model → second sanitizer pass over the final
body → rejects by verdict → dedup within the body ([002-NODE_IMPORT · P8](../002-NODE_IMPORT/FEATURE.md#promises)).
- Rule verdicts: remove the node (`drop_node`, `error`), remove the field with a code, replace the
value with a code, keep it with an info code.
- Core gates (`build_tag`, `min_core`, platform) are judged only at build, only against the pinned
core; an unknown version or an unknown tag set never removes a node.
- One `(code, path)` pair — one entry; on a match between the verbatim pass and the final pass the
verbatim one stays.
- The reject code is the first `error` in body field order; none — `type_invalid` with the tag.
- Codes are recomputed on every load and never written to storage or backup.
- The copy, the app mirror and the documentation mirror are written by the sync script only; the
tree hash is over file contents in byte order of paths.
## Boundaries
- Parsing of link parameters, body forms, Xray/sing-box JSON walks and `.conf` — [002-NODE_IMPORT](../002-NODE_IMPORT/FEATURE.md).
- Build stages around the gate (detour resolution, folds, chains, healing) — [003-CONFIG_BUILD](../003-CONFIG_BUILD/FEATURE.md).
- The core pin, `lx.*` keys, the core acceptance ritual and feedback to the core — [021-CORE_CONTRACT](../021-CORE_CONTRACT/FEATURE.md).
- Core rejections after start and auto-disabling — [009-NODE_HEALTH](../009-NODE_HEALTH/FEATURE.md).
- Backup warning codes are a separate dictionary (`backup_warnings.json`) — [017-BACKUP_AND_STORAGE](../017-BACKUP_AND_STORAGE/FEATURE.md).
- The registry does not decide node identity (tag), dedup across sources, or network checks.
- The dictionary is shared with the launcher, so a code is not required to have
a producer in this app: `amnezia_container_choice` and `max_nodes_exceeded` are
dictionary codes LxBox never emits (the launcher's import path does; the
`max_nodes_per_subscription = 3000` limit is not enforced here — audit
[591](../../tasks/591-spec-kit-revision-audit.md), owner's item). The one code
outside the dictionary is `unknown_node_type`: an app-own code by design
(`kWarningCodes`, its text lives in the app), see
[parse-warnings](FUNCTIONS/parse-warnings.md).
## Functions
| Function | What it does | Promises | File |
|---------|-----------|----------|------|
| Registry-driven parse pipeline | Runs every input through mapper, sanitizer and model by the contract registry and keeps node identity stable. | P1 P2 P3 P4 P6 P7 P17 | [registry-pipeline.md](FUNCTIONS/registry-pipeline.md) |
| Parse warnings | Attaches reason codes with registry texts to nodes and to the reject list; secrets are masked. | P2 P4 P5 P14 | [parse-warnings.md](FUNCTIONS/parse-warnings.md) |
| Registry gate at build | Checks every node body against the schema for the pinned core version and build tags; removes what the core cannot run, comments on authored JSON. | P7 P8 P9 P10 P11 | [registry-gate.md](FUNCTIONS/registry-gate.md) |
| Warning codes | The shared code dictionary: levels, where codes are shown, code stability and the "Learn more" page. | P11 P12 P13 P14 | [warning-codes.md](FUNCTIONS/warning-codes.md) |
| Registry sync and guards | The registry as a delivery: contract version, lock, sync script, the two mirrors and the guards that keep them honest. | P15 P16 P17 P18 P19 | [registry-sync-and-guards.md](FUNCTIONS/registry-sync-and-guards.md) |
## Related features
- [002-NODE_IMPORT](../002-NODE_IMPORT/FEATURE.md) — supplies entries to the pipeline and owns
link, body and JSON parsing; dedup within a body is [002-NODE_IMPORT · P8](../002-NODE_IMPORT/FEATURE.md#promises).
- [003-CONFIG_BUILD](../003-CONFIG_BUILD/FEATURE.md) — runs the registry gate as build stage 5.
- [007-NODE_LIST](../007-NODE_LIST/FEATURE.md) — shows codes on the node row and opens the card.
- [009-NODE_HEALTH](../009-NODE_HEALTH/FEATURE.md) — core rejections carry registry codes too.
- [027-DEBUG_API](../027-DEBUG_API/FEATURE.md) — the Debug API that returns codes with pinned texts.
- [017-BACKUP_AND_STORAGE](../017-BACKUP_AND_STORAGE/FEATURE.md) — the node storage form the
pipeline re-parses; backup codes are a separate dictionary.
- [021-CORE_CONTRACT](../021-CORE_CONTRACT/FEATURE.md) — the core pin the build gate judges against.
- [023-BUILD_CI_RELEASE](../023-BUILD_CI_RELEASE/FEATURE.md) — the CI jobs the guards run in.
## Maintenance notes
- **The dictionary mirror is byte for byte.** `docs/contract/warnings.md` is copied from the
launcher's generated pages; a hand edit is caught by the lock check and lost on the next sync.
- **App-own codes stay outside the dictionary.** `unknown_node_type` is the only
one (`kWarningCodes`, `node_warning.dart`); a new app-own code needs the same
explicit decision, not a silent addition — the launcher will never render it.
- **`max_nodes_exceeded` has no producer here.** The limit `max_nodes_per_subscription = 3000`
is declared by the dictionary; whether LxBox enforces it is an open owner's item (audit 591).
- **The corpus does not run on CI.** Red corpus cases are visible locally only
([529](../../tasks/529-contract-corpus-local-reds-triage.md)); green registry tests on CI do not
cancel that.
- **The build-tag mirror is a hand copy.** The gate judges `build_tag` by the app's tag set; a
guard pins only its version to the core pin, not the set itself.