Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
ced8322
ir: extract the validation constraints WA Web enforces, not just fiel…
jlucaso1 Jul 27, 2026
5500115
scan: enforce that a reference echo reads the request, not just any node
jlucaso1 Jul 27, 2026
08c01ed
review: fix the constraint-layer defects found in review
jlucaso1 Jul 27, 2026
28b8b14
review: pair the error vocabulary, and fix the losses round 2 surfaced
jlucaso1 Jul 27, 2026
bd585b6
review: inherit outer error pins, and fix the accessor/scope gaps rou…
jlucaso1 Jul 27, 2026
1bf091c
review: per-return scopes, and call the schema bump what it is (2.0.0)
jlucaso1 Jul 27, 2026
f71f94a
review: branch-merge value-position helpers, and stop `||` weakening …
jlucaso1 Jul 27, 2026
3552d0d
review: classify wire errors by the root discriminator, merge mapped-…
jlucaso1 Jul 27, 2026
f529bd7
review: drop phantom actions, model the two-level error, and stop ove…
jlucaso1 Jul 27, 2026
5830753
ir: model the error vocabulary correctly, breaking 1.x rather than wo…
jlucaso1 Jul 27, 2026
058a959
review: place presence-flag pins on their node, and stop three ambigu…
jlucaso1 Jul 27, 2026
632eb30
wap: derive `maybe*` accessor types instead of enumerating them
jlucaso1 Jul 27, 2026
90cd7d6
docs: note the accessor-type classification in the constraints section
jlucaso1 Jul 27, 2026
accb9b9
review: refuse conflicting field sources, and key drops per parser site
jlucaso1 Jul 27, 2026
768a453
wap: derive the attr-accessor predicate too, and give JID reads their…
jlucaso1 Jul 28, 2026
07ddaa2
review: end the fall-through chain at a break, and honour last-write …
jlucaso1 Jul 28, 2026
2ab37e2
notif: one implementation of the branch-merge rule; one accessor clas…
jlucaso1 Jul 28, 2026
b02e251
scan: resolve response enums on the legacy path too; last-write for e…
jlucaso1 Jul 28, 2026
770e801
scan: type every content accessor, and stop the pending-enum sentinel…
jlucaso1 Jul 28, 2026
b111ce0
scan: count what the non-IQ domains lose, and fix the paths round 15 …
jlucaso1 Jul 28, 2026
f7458c1
codegen: stop dropping the content fields the scanners recover
jlucaso1 Jul 28, 2026
891c9a3
codegen: ask the classifier for the bytes branch too
jlucaso1 Jul 28, 2026
7219c32
notif: read the error paths of a try, and let a try terminate a case
jlucaso1 Jul 28, 2026
42c93c5
notif: decide the finally override by control flow, and refuse two-so…
jlucaso1 Jul 28, 2026
21815bd
codegen: decode contentUint as big-endian bytes, not decimal text
jlucaso1 Jul 28, 2026
2ec36a9
scan: honour type="result", and stop counting losses the artifact nev…
jlucaso1 Jul 28, 2026
5f10088
scan: keep content enums reading content, and stop a rejected module …
jlucaso1 Jul 28, 2026
cb533c8
notif: recover the fields a value-normalising helper was eating
jlucaso1 Jul 28, 2026
276b255
notif: emit the payload action unions in the reference codegen
jlucaso1 Jul 28, 2026
e2e01b4
notif: keep field and constant types in the generated action table
jlucaso1 Jul 28, 2026
a841908
scan: sort the fallback candidates, and fix three scope rules the blo…
jlucaso1 Jul 28, 2026
2c09ff9
scan: count the two enum/marker constraints that were vanishing silently
jlucaso1 Jul 28, 2026
25ad520
scan: stop two invented constraints, and pick the response root by st…
jlucaso1 Jul 28, 2026
37698d9
codegen: derive optionality from the IR, not only from the accessor name
jlucaso1 Jul 28, 2026
c4d4c76
notif: bind helper parameters, and stop three shapes the extractor wa…
jlucaso1 Jul 28, 2026
93ae213
notif: drop a redundant closure clippy flagged
jlucaso1 Jul 28, 2026
bca400b
notif: five more JS-semantics corrections in the action reader
jlucaso1 Jul 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,24 @@ WhatsApp Web ships its whole protocol (IQ stanzas, protobuf schemas, GraphQL ope
| **appstate** | `appstate/index.json` (+ Rust) | app-state (syncd) action schemas + indexing |
| **abprops** | `abprops/index.json` (+ Rust) | the A/B-props feature-flag registry (~1.7k flags: code, type, default) |
| **enums** | `enums/index.json` (+ Rust) | the wire-enum catalog (nack codes, chat/receipt types, …) |
| **notif** | `notif/index.json` (+ Rust) | the incoming stanza-dispatch catalog: `<notification type="…">` kinds + handlers + typed content shapes |
| **notif** | `notif/index.json` (+ Rust) | the incoming stanza-dispatch catalog: `<notification type="…">` kinds + handlers + typed content shapes + the payload **action unions** (`w:gp2`'s 40+ group actions) |
| **tokens** | `tokens/index.json` (+ `tokens.json`) | the binary-protocol token dictionaries (single-byte + 4 double-byte), wire-indexable |
| **wasm** | `wasm/index.json` | the WebAssembly surface: emscripten payload names (`liboqs_wasm_wrapper.wasm`, …) + the bootloader (`bx`) handles the JS resolves their bytes through, with consuming modules |

Every domain ships a JSON Schema under `generated/schema/`, and a top-level `generated/manifest.json` stamps the WhatsApp version, per-domain counts, content hashes, and extraction diagnostics.

### Validation constraints, not just field shapes

Field shapes tell you how to *read* a stanza. They are not enough to *produce* one, nor to explain why a real client rejected the one you produced. So the IR also carries the rules WA Web's own parsers enforce — symmetric by construction, so they serve a parser and an emitter equally:

- **Echo rules** (`assertions[].kind == "reference"`, and `referencePath` on a field) — an answer's `from` must equal the **request's** `to`, its `id` the request's `id`. `referencePath` is the argument list of WA's `attrStringFromReference`, so `["to"]` means "the request's `to`" and `["account","action"]` means "the `action` attribute of the request's `<account>` child" — no name-matching required. An emitter that hardcodes `from="s.whatsapp.net"` makes every answer to a `g.us` request unparseable.
- **Pinned values** (`literalValue` on a field) — `type="admin"` on a successful promote, `matched="true"`/`"false"` on a blocklist update, `code=429` on a rate-limit error. `required` separates the two forms: a required pin is a hard discriminator (must be present and exact); an optional one is pinned only when present (may be omitted, must never be contradicted).
- **The per-RPC error vocabulary** (`errorClass`, `errorCodes`, `errorTexts`, `errorCodeMin`/`Max` on a response variant) — a **closed** set, and it differs per RPC: `BatchGetGroupInfo` accepts `400 bad-request` and `429 rate-overlimit` and **rejects `404 item-not-found`**, even though that mixin exists and other RPCs use it. `errorCodeMin`/`Max` cover the open-ended fallback arms (any text, code in 400–499 / 500–599).
- **Response enums** (`enumRef` on a field) — the legal values behind an `attrStringEnum`, resolved the same way the request side already resolves them, instead of a bare `"type": "enum"`.
- **Notification action unions** (`notifications[].actions`) — the payload inside the envelope. The `wireTag → actionType` mapping is **many-to-one** (`not_ephemeral` normalises into `ephemeral` with `duration: 0`, so branching on `not_ephemeral` is dead code) and field names are rebound (the disappearing-message timer arrives in `expiration`, but the action field is `duration`). Neither is derivable from the wire.

Anything the extractor sees but cannot resolve structurally is counted under `manifest.diagnostics.iq.dropsByReason` rather than omitted, so "no constraint here" and "a constraint we failed to extract" never look alike. `manifest.diagnostics.iq.constraints` and `diagnostics.notif.actions` are floor-guarded: a WA refactor that hides one of these constructs fails the update instead of silently emptying a field.

## Quick start

```sh
Expand Down
3 changes: 3 additions & 0 deletions crates/wa-codegen/src/notif_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ mod tests {
kind: AssertionKind::Tag,
name: Some("notification".into()),
value: None,
reference_path: None,
}],
fields: vec![ParsedField {
method: "attrString".into(),
Expand All @@ -305,6 +306,7 @@ mod tests {
}],
..Default::default()
}),
actions: Vec::new(),
},
NotificationDef {
notif_type: "encrypt".into(),
Expand All @@ -319,6 +321,7 @@ mod tests {
}],
}],
content: None,
actions: Vec::new(),
},
],
}
Expand Down
14 changes: 13 additions & 1 deletion crates/wa-codegen/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,10 @@ fn emit_success_guards(op: &IqStanzaDef, indent: &str) -> Vec<String> {
}
}
// Tag (the `<iq>` root) / FromServer are not success-vs-error discriminators.
AssertionKind::Tag | AssertionKind::FromServer => {}
// Neither is a Reference echo (`from` == the request's `to`): every outcome
// of the same request satisfies it identically, so it separates nothing —
// it is a request-correlation rule, enforced by whoever holds the request.
AssertionKind::Tag | AssertionKind::FromServer | AssertionKind::Reference => {}
Comment on lines 349 to +353

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce reference assertions in generated response parsers

When a response has the newly recovered echo constraints (id equal to the request's id, or from equal to its to), the generated IqSpec::parse_response discards them here and emits neither a check nor metadata through which its caller can apply one. A correctly shaped response carrying another request's id or target therefore passes the reference parser despite violating the parser contract represented in the IR; emit a common request-correlation guard outside the success/error variant dispatch.

Useful? React with 👍 / 👎.

}
}
lines
Expand Down Expand Up @@ -836,8 +839,10 @@ mod tests {
kind: AssertionKind::Attr,
name: Some("type".into()),
value: Some("result".into()),
reference_path: None,
}],
fields: vec![],
..Default::default()
}];
let guards = emit_success_guards(&s, " ");
assert_eq!(guards.len(), 1);
Expand Down Expand Up @@ -874,13 +879,15 @@ mod tests {
kind: ResponseVariantKind::Success,
assertions: vec![],
fields: vec![attr("token")],
..Default::default()
},
ResponseVariant {
tag: "GetThingResponseError".into(),
module_name: "m".into(),
kind: ResponseVariantKind::Error,
assertions: vec![],
fields: vec![attr("code")],
..Default::default()
},
],
..Default::default()
Expand Down Expand Up @@ -930,6 +937,7 @@ mod tests {
kind: AssertionKind::Attr,
name: Some("type".into()),
value: Some(value.into()),
reference_path: None,
}
}
// Success and error read the same field (`type`) — a bare subset that WOULD be
Expand All @@ -945,13 +953,15 @@ mod tests {
kind: ResponseVariantKind::Success,
assertions: vec![type_assert("result")],
fields: vec![attr("type")],
..Default::default()
},
ResponseVariant {
tag: "GetThingResponseError".into(),
module_name: "m".into(),
kind: ResponseVariantKind::Error,
assertions: vec![type_assert("error")],
fields: vec![attr("type")],
..Default::default()
},
],
..Default::default()
Expand Down Expand Up @@ -1000,13 +1010,15 @@ mod tests {
kind: ResponseVariantKind::Success,
assertions: vec![],
fields: vec![attr("type")],
..Default::default()
},
ResponseVariant {
tag: "GetThingResponseError".into(),
module_name: "m".into(),
kind: ResponseVariantKind::Error,
assertions: vec![],
fields: vec![attr("type")],
..Default::default()
},
],
..Default::default()
Expand Down
116 changes: 116 additions & 0 deletions crates/wa-ir/src/iq.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,17 @@ pub enum AssertionKind {
/// node, "admin_add")`) — a discriminator for marker union variants. The value is
/// in [`ResponseAssertion::value`]; `name` is unused.
Content,
/// The attribute named by [`ResponseAssertion::name`] must **echo a value taken
/// from the request** — `literal(attrString, node, "from",
/// attrStringFromReference(request, ["to"]))`. The expected value is not a
/// constant, so [`ResponseAssertion::value`] is absent; where it comes from is in
/// [`ResponseAssertion::reference_path`].
///
/// This is the rule 17 of the 26 namespace `IQError…Mixin`s (and every success
/// parser) enforce: an answer's `id` must equal the request's `id` and its `from`
/// the request's `to`. An emitter that hardcodes `from="s.whatsapp.net"` produces
/// stanzas a real client cannot parse whenever the request went to `g.us`.
Reference,
}

/// A single guard a response parser applies.
Expand All @@ -267,6 +278,17 @@ pub struct ResponseAssertion {
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
/// Where the expected value is read from **in the request**, for
/// [`AssertionKind::Reference`]. It is the argument list of WA's
/// `attrStringFromReference(request, path)` helper: every element but the last is a
/// child tag to descend into, and the last is the attribute name. So `["to"]` means
/// "the request's `to` attribute" and `["account", "action"]` means "the `action`
/// attribute of the request's `<account>` child".
///
/// Carried structurally (rather than left to be inferred from `name`) so a consumer
/// never has to pattern-match attribute names to know an echo rule applies.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reference_path: Option<Vec<String>>,
}

/// Scalar type a parsed response field decodes to.
Expand Down Expand Up @@ -423,6 +445,45 @@ pub struct ParsedField {
pub int_max: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enum_keys: Option<Vec<String>>,
/// The wire enum this field's value is drawn from, when the parser reads it with an
/// enum accessor (`attrStringEnum(node, "state", o("Mod").ENUM_OFF_ON)`). Resolved
/// the same way the request side resolves [`WapAttrDef::enum_ref`], so a consumer
/// can type the field as that enum instead of a bare string — and an emitter knows
/// which values are legal instead of guessing from the generated `ENUM_OFF_ON` name.
/// Absent when the enum argument is not a structurally resolvable module export, or
/// resolves to a non-string enum (never guessed).
///
/// [`WapAttrDef::enum_ref`]: WapAttrDef::enum_ref
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enum_ref: Option<AttrEnumRef>,
/// The fixed value the parser pins this field to — `literal(attrString, participant,
/// "type", "admin")` → `"admin"`, `literal(attrInt, error, "code", 429)` → `"429"`.
/// Always the string form of the literal; [`field_type`] says how to read it (a
/// `code` field with `literalValue: "429"` is the integer 429 on the wire).
///
/// [`required`] tells the two pinning forms apart, and the difference matters to an
/// emitter: a **required** literal (`literal`) is a hard discriminator — the
/// attribute MUST be present and equal to this value or the variant does not match;
/// an **optional** one (`optionalLiteral`) is pinned only when present — the emitter
/// may omit the attribute, but must never send a contradicting value.
///
/// [`field_type`]: ParsedField::field_type
/// [`required`]: ParsedField::required
#[serde(default, skip_serializing_if = "Option::is_none")]
pub literal_value: Option<String>,
/// The field is pinned not to a constant but to **a value taken from the request** —
/// the same echo rule [`AssertionKind::Reference`] carries, in the same
/// `attrStringFromReference` path form, but for a pin that is *not* a variant guard.
///
/// A required echo (`literal(…, "from", ref.to)`) is recorded as an assertion, since
/// it must hold for the variant to match at all. An **optional** one
/// (`optionalLiteral(…, "c_dhash", ref.item.dhash)`) is not a guard — the attribute
/// may be absent — so it lives here instead: an emitter may omit it, but if it sends
/// it, the value must be the request's. Mutually exclusive with [`literal_value`].
///
/// [`literal_value`]: ParsedField::literal_value
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reference_path: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
Expand Down Expand Up @@ -456,6 +517,25 @@ pub enum ResponseVariantKind {
Alternative,
}

/// Which side an error variant blames, for a variant whose [`ResponseVariantKind`] is
/// [`Error`]. WA models the two as separate parsers with disjoint code ranges: a client
/// error carries a 4xx code (`bad-request` 400, `rate-overlimit` 429, fallback 400–499)
/// and a server error a 5xx one (`internal-server-error` 500, fallback 500–599).
///
/// Emitted alongside — not instead of — [`ResponseVariant::kind`], so a consumer that
/// only understands `success`/`error` keeps working.
///
/// [`Error`]: ResponseVariantKind::Error
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum ErrorClass {
/// The request was wrong (4xx) — retrying it unchanged will fail again.
Client,
/// The server failed (5xx) — the same request may succeed later.
Server,
}

/// One alternative of a response-root discriminated union (an `WASmaxIn<X>Response<V>`
/// variant aggregated by the `WASmax<X>RPC` orchestrator).
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
Expand All @@ -467,6 +547,42 @@ pub struct ResponseVariant {
pub tag: String,
pub module_name: String,
pub kind: ResponseVariantKind,
/// For an error variant, whether it is the client-error (4xx) or server-error (5xx)
/// arm. Absent for a success variant, and for an error variant whose parser carries
/// no recoverable code evidence.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_class: Option<ErrorClass>,
/// The **closed set** of `<error code>` values this variant accepts, ascending.
///
/// The vocabulary is per-RPC, not global: `BatchGetGroupInfo`'s client-error arm
/// takes only `400` and `429` and **rejects `404`**, even though an
/// `IQErrorItemNotFoundMixin` exists and other RPCs use it. Answering with a code
/// outside this set matches no branch, so the client reports a parse failure rather
/// than the error. Empty when the variant pins no exact code (see [`error_code_min`]).
///
/// [`error_code_min`]: ResponseVariant::error_code_min
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub error_codes: Vec<i64>,
/// The `<error text>` values this variant accepts, sorted — the closed vocabulary
/// paired with [`error_codes`]. Empty when only a fallback (any-text) arm applies.
///
/// [`error_codes`]: ResponseVariant::error_codes
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub error_texts: Vec<String>,
/// Lower bound of the open-ended fallback arm's code range, when the variant has one
/// (`IQErrorFallbackServer` accepts any `code` in 500–599 with any `text`; its
/// client twin accepts 400–499). Together with [`error_code_max`] this says "any
/// code in this range is also accepted", which [`error_codes`] alone cannot express.
///
/// [`error_code_max`]: ResponseVariant::error_code_max
/// [`error_codes`]: ResponseVariant::error_codes
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_code_min: Option<i64>,
/// Upper bound of the fallback arm's code range (see [`error_code_min`]).
///
/// [`error_code_min`]: ResponseVariant::error_code_min
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_code_max: Option<i64>,
pub assertions: Vec<ResponseAssertion>,
pub fields: Vec<ParsedField>,
}
Expand Down
2 changes: 1 addition & 1 deletion crates/wa-ir/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ pub enum Scalar {
/// `waVersion` changes on every WhatsApp rollout; `schemaVersion` changes only
/// when the *structure* of the IR changes. Consumers pin this to stay stable
/// across WhatsApp updates. Bump on any breaking change to the IR shape.
pub const SCHEMA_VERSION: &str = "1.0.0";
pub const SCHEMA_VERSION: &str = "1.1.0";
Comment thread
jlucaso1 marked this conversation as resolved.
Outdated

/// Envelope that stamps a domain IR document with [`SCHEMA_VERSION`] at emit
/// time, without altering the inner document's shape.
Expand Down
Loading
Loading