Roadmap to parity
The waves that take meta-whatsapp-rs to parity as the owner defined it on 2026-09-26 (parity.md: Meta's Cloud API, plus Zaileys' framework features). Every partial or gap row of the parity table is cited by an item below. Written against main at b6fc893 (PR #20).
- Each item is one pull request (S13, one per resource group, says otherwise) and names its crate, what it comes after, and its decisive test: the guard whose removal must make a test fail, as in design §9. A library item (L) also names its kind: additive, breaking, or a port change, as design §9's table does. A PR is done when its decisive test holds,
just ciexits 0 on its final head, and it has updated its rows in parity.md, categories.md and coverage.md, the docs and the skills (AGENTS.md § Companion docs and skills). - After names the items that must have merged first, and any answer the owner must have given; "nothing" when there is none. Items in one wave run in parallel only where their
after:lines allow. - Ids: S (the service's modular split), U (CrateStack, upstream), B (the bot framework), L (library batches; L1–L6 are design §9's), M (service milestones: M1 shipped, M2–M5 below) and P (payments). A letter or digit after an id is one pull request of a family (M2a, M5c1). The security reviews' findings (H1, M1–M4, L1–L6 in the code's comments) are cited in the docs as SR-H1, SR-M3, SR-L2, so they never read as items.
- The choices behind the items are recorded in design §10 (D26–D36 and the rows updated on 2026-09-26) and in OPEN_QUESTIONS.md, under the rule in AGENTS.md § Decisions. What stays the owner's is listed in § Owner touchpoints.
- Routes. Every new route calls a core service, so both API adapters stay thin, and lands in both adapters in the same PR once
meta-whatsapp-server-api-cratestackexists (S10). A route that lands before S10 goes intometa-whatsapp-server-api-axum, and S13 carries it over: its groups cover every route that adapter has when S13 starts.api-axumis permanent, the swap target (design D26).
Order
| Wave | Items (in parallel only where their after: lines allow; at most three building at once) |
|---|---|
| 0 | this plan (parity, categories, roadmap, decisions) |
| 1 | S1 (server core), B1 (bot framework), U4 (upstream issues) |
| 2 | S2, S2b, S3, S4, S8; U1–U3 in the cratestack repository; L4, L5, L7 (the ConversationStore port change and the inbox's use of it, before M2); the library batches L8–L26, which may start here and run alongside every later wave; B1b, B1c (the bot framework's follow-ups) |
| 3 | S5a–S5e, S6, S7, S9 |
| 4 | S10 (waits on the owner's answer to D20 (a)), S11, S12; M2a–M2f |
| 5 | S13–S16 (S16 last, gated on U1's merge); M3a–M3f; B2–B4 |
| 6 | M4; M5a–M5l |
| 7 | S17, S18 (MongoDB) |
| last | P1a–P1c, P2 (payments) |
1. The service's modular split (design §8, D26)
From the CrateStack fit study of 2026-09-26 and the architecture review of the core's extraction: the unit of swapping is a backend bundle and an API adapter over one framework-free core. First the core and its ports are made right for any backend (S1–S6), then the crates split (S7), then MongoDB (S8, S9, S17, S18) and CrateStack (S10–S16) are added behind them. /v1 stays served, and openapi/v1.json stays byte-identical, until the default flips (S16); after it, meta-whatsapp-server-api-axum keeps both, as the swap target. The CrateStack defaults of D26 hold once the owner accepts D20 (a); until then, and for good if the owner refuses, the defaults stay api-axum and store-postgres.
- After: nothing.
- Decisive:
cmpofopenapi/v1.jsonagainstmainis equal; every server test passes with only its imports changed;cargo tree -p meta-whatsapp-server-coreshows no axum, hyper, tower, utoipa, sqlx or cratestack (httpenters only through the library'sHttpTransportport). - Landed: the crate, its ports and the
Backendbundle, with the memory and Postgres implementations inmeta-whatsapp-server;openapi/v1.jsonunchanged. Row 112 stays partial on the service (conformance in core: S3; composed from a bundle alone: S4). Its security review's fixes landed with it: capabilities (Caller,AdminCaller,OwnedNumber,OwnedWaba) work only with theAuthorizerthat made them (SR-H1: the core's methods refuse another's, the server's extractors admit what they take from a request, and its handlers are crate-private), the Graph client it is given carries no token (SR-L4),AppState::storeis crate-private (SR-M1),Debugnever prints a kept answer's body (SR-L3), and the visibility pins are UI tests with the compiler's errors (SR-L1). SR-L2, and the same race infailed, wait for S2.
Outbox::insertmust re-check the routing: when the event has a tenant, the row keeps it only if, atomically with the insert, the binding it was routed by still names that tenant and began in a second before the event's (its security review's L3: an event of the binding's own second may be the previous holder's); otherwise the row is operator-only. A typedRouteGuardonNewEventcarries what the check needs, so no backend redoes the routing rules from strings.- Referential rules:
bind_wabato a missing tenant has its own outcome (BindOutcome::NoSuchTenant), andbind_wabaanddelete_tenanton one tenant serialize. - Purges take no lock of their own and return a count; housekeeping takes one
LeaderLockturn per round.LeaderLockis a lease (try_exclusive(name, lease)), ending at release, drop or expiry, and the work under it tolerates an overlap. - One clock all replicas agree on: the database's where it has one, else the service's
Clock, with its skew bounded below the lease; the core'sAuthorizertakes an injectedClock. - A typed "busy" storage error for contention, instead of a downcast.
Backend's accessors return handles to the same data on every call.- The outbox's types carry the id newtypes; listings are in byte order.
- Unbind and vault delete conditioned on the binding and the vault record version the capability was made from (SR-L2, from the core's security review):
OwnedWaba::forgetdeletes whatever the WABA's binding and token became since theOwnedWabawas made, so a WABA unbound and attached again in between (a new token, even another tenant's binding) loses them. The unbind and the delete take what the capability was made from, and do nothing when either moved. - The
reconnect_requiredmarking conditioned the same way (thefailedrace, which the core's second review found):OwnedNumber::failedandOwnedWaba::failedmark the numbers of whatever binding the WABA has when Meta's190comes back, so a capability made before the WABA was unbound and attached again (a new token, even another tenant) marks the new binding's numbers. The status update takes the binding the capability was made from (its tenant andattached_at), and marks nothing when it moved. - After: S1.
- Decisive: on memory and, live, on Postgres:
bind_wabato a missing tenant answersNoSuchTenant(memory binds it today); an insert whose guarded binding moved is operator-only (removing memory's re-check fails it); a backend reporting the typed busy error makes the webhook path answer503; anOwnedWabamade before its WABA was attached again forgets neither the new binding nor the new token; anOwnedNumberorOwnedWabamade before its WABA was unbound and attached again, to the same tenant and to another, whose call Meta then answers190, leaves the new binding's numbersconnectedin both cases (conditioning the update on the tenant alone fails the first, on nothing both). - Landed: every bullet, in the core's ports and both in-tree backends, with the decisive tests on memory and live on Postgres (the store, events, backend and capability suites of the server's
tests/common). The route guard isoutbox::RouteGuard(aGuardedBindingand the event's date), set byevents::outbox_rowfrom the routing'sHolder; a tenant without a guard is operator-only, and the memory outbox belongs to itsMemoryStoreand checks under the store's lock.BindOutcome::NoSuchTenant;bind_wabalocks the tenantFOR KEY SHAREon Postgres. The purges return a count and take no lock;serve::roundtakes oneHOUSEKEEPINGturn, leased forHOUSEKEEPING_LEASE(5 minutes; on Postgres the turn's session is ended past it, and the key is stillHOUSEKEEPING_LOCK).Authorizer::with_clock, which the service hands the webhook pipeline's clock.StorageError::Busyin the library (additive).PhoneNumberIdandWabaIdin the outbox types. SR-L2 andfailed: aBindingEpochand, for anOwnedWaba, the vault record'sTokenVersion(the library'sTokenVault::get_versionedanddelete_if_unchanged, additive);RecordStore::unbind_waba_ifandset_waba_status_if; and a capability is made only from a token read while its binding held (the binding read again after the vault,503when it moved: the review found the old holder's binding paired with the new holder's token). The review's races (race_suite,racing_a_reattach, two purges at once) run on memory and live on Postgres. Its security review's remediation: a capability answers for the vault's read (a token, none, a failure) only once its binding is read again (Authorizer::open: a move before the new holder's token is stored is503, notnumber_not_connected, on which the admin unbind deleted the new holder's binding); a tenant's deletion skips a WABA that moved since its listing instead of answering503;OwnedWaba::still_boundis asked before everyunsubscribe_appwith a capability's token (the tenant's disconnection, the admin unbind, a tenant's deletion), andforgetwarns when it finds the binding moved (the Graph call's own window); the port rule is written down (server_core::store: the vault is written for a WABA only by its current holder, an attach binds, stores, then confirms, every unbind deletes the token before the binding), and the attach follows it (Authorizer::store_tokenreads the binding, stores with the library'sTokenVault::store_versioned, additive, and reads the binding again: moved, it takes back exactly its own write withdelete_if_unchangedand answers503; the remediation found an attach overwriting, with its token, the token of a tenant the WABA moved to between its binding and its store); the vault's conditional delete links again the numbers a record stored since lists (L2); an event of its binding's own second is operator-only (L3); a contact sync is dated when its webhook was triggered (M2's first half);StorageError::Busy's docs say a step storing after a send is not repeated on it. Left, and why: a binding refreshed for its own tenant (attached again without an unbind, as the operator's reconnect or token rotation does) keeps its epoch, so a190answered after the refresh to a capability made before it marks its numbers again (the vault version coversforgetthere, notfailed;attached_atcannot move, the route guard reads it): S2b, a bind generation; undated events (errors, history chunks, undated group updates) of a WABA's previous holder, which Meta redelivers for up to 7 days, reach its new holder after a move to another tenant: S2b (D36); a skew margin aroundattached_at: S2b; the attach's confirmation narrows a window it does not close: another tenant's attach landing whole between an attach's read of its binding and its store has its token overwritten, then taken back with the first attach's (its binding left with no token until it attaches again), and a capability of its own made in between carries the first attach's token; closing it needs the vault record to name the binding it was stored under, a change of the stored format, which is the owner's (planned nowhere yet); a disconnection racing a vault rotation deletes nothing and answers503(retryable); the admin's unbind of a WABA without a usable token, and the admin's own190right after attaching, stay unconditioned (no capability was made from a binding); the memory stores read the system clock, not the injected one (one process).
- Record when a WABA was last unbound from a different tenant: a store field on both backends.
- A setting
undated_after_move:OperatorOnly { window }, the default, with a window of 7 days and an hour (Meta's retry window plus the dedup margin);CurrentHolder, the behaviour before it. Undated tenant-visible events within the window are operator-only. A first onboarding (no previous tenant) is unaffected (design D36). - A skew margin setting around
attached_atfor dated events, applied only when a previous tenant exists (the second half of S2's L3; S2 made an event of the binding's own second operator-only). - The same-second rule, strict since S2 (an event of its binding's own second is operator-only), relaxed to
<=when the WABA has no previous tenant: a first onboarding keeps the events of its first second. It lands with the tombstone (the record of the last unbinding from a different tenant), which is what tells the two apart. - A bind generation carried in
BindingEpoch(a new column, incremented on every attach, a same-tenant refresh included), so that a capability made before a refresh is told apart from one made after it; the route guard keeps comparingattached_at. - After: S2.
- Decisive: after a move A→B, an undated
history_syncedredelivered within the window reaches no tenant; withCurrentHolderit reaches B; a WABA with no previous tenant still gets its history; removing the check fails the first case; the same for the security review's other undated cases (an error, an undated group update); an event of its binding's first second reaches the tenant of a first onboarding, and no tenant after a move; a190from a capability made before a same-tenant refresh does not mark the numbersreconnect_required. - Landed: every bullet, in the core and both in-tree backends, with the decisive tests on memory and live on Postgres. The tombstone is
model::WabaTombstone(its tenant,Noneonce that tenant is deleted, when, and the unbound binding's own record), written with every unbind in the same step and read by a new binding intoWabaBinding::previous_tenant_unbound_at(the unbinding's time when another tenant left, else the time it carried; a refresh keeps it), the window measured from it. The settings areevents::MoveRulesandevents::UndatedAfterMove(Inbound::with_move_rules; the service'sWA_SERVER_UNDATED_AFTER_MOVE,…_WINDOW, 169 h, andWA_SERVER_MOVE_SKEW_MARGIN, 0 s by default). The rules are one function,outbox::RouteGuard::admits, which the routing (events::owner,after_moveits new reason for an undated event held back) and the memory outboxes apply, and whose bounds (RouteGuard::dated_bounds,AfterMove) the Postgres insert binds: it picks between them by the binding's row, under the sameFOR KEY SHARElock. The generation isWabaBinding::generation, compared byBindingEpochin every conditioned write and capability check. Postgres: migration 4 (0004_waba_moves.sql, additive), whose defaults give the rows written before it generation 1 and a move recorded as unknown (UNRECORDED_MOVE, the Unix epoch), so they keep S2's rules; its names are stable identifiers pending the owner's confirmation (§ Owner touchpoints). The decisive tests:conformance::webhooks::undated_events_after_a_move_reach_no_tenant_within_the_window(a history chunk, an error and an undated group update: no tenant within the window, B underCurrentHolderand past it, a first onboarding always) andan_event_of_its_bindings_first_second_reaches_a_first_onboarding_only, the service'san_event_of_its_bindings_own_second_reaches_a_first_onboarding_onlyover HTTP, andcapabilities::failed_before_a_refresh_marks_nothing; the records suite'sa_move_is_recordedanda_refresh_moves_the_generation, and the invariants' guard cases (a_first_onboarding_keeps_its_first_second,the_skew_margin_applies_after_a_move_only,an_undated_event_after_a_move_waits_out_the_window), which the memory crate also shows an outbox bending the guard fails. S7's continuity test now migratesmain's database forward (migrations 1 to 3 unchanged, 4 added once, none onserve) and routesmain's binding as before. By mutation: the window check removed fromRouteGuard::admitsfails its unit test, the webhooks case (a history chunk within the window reaches B) and the invariants' undated case on memory, and removed from the Postgres insert, that case live; the first onboarding's exemption removed, dated, fails the unit tests, the first-second cases through the core's pipeline and over HTTP, anda_first_onboarding_keeps_its_first_secondon memory and live; undated, the routing suite's error of a first onboarding and the invariants' undated case, on memory and live; the generation not moved on a refresh failsa_refresh_moves_the_generationon memory and live, andfailed_before_a_refresh_marks_nothingrun alone; the skew margin applied without a previous tenant fails its unit test and the margin case on memory and live.openapi/v1.jsonand the event data snapshots are unchanged. Left, and why: an unbind made by a binary of an earlier release leaves no tombstone, before the upgrade as during its rolling deploy, so a WABA unbound that way and bound afterwards to another tenant reads as a first onboarding, and its previous holder's undated events reach the new holder for what remains of the window (as before S2b); a move made wholly before migration 4 keeps S2's rules, which give them to the new holder too; and a replica of the earlier release unbinding after a tombstone was written leaves that older tombstone in place, so the window runs from an earlier unbinding (the guide says not to move WABAs between tenants in the 7 days and an hour before the deploy, nor during it); the window runs from the previous tenant's unbinding, not from the new attach, so an undated event of the interval between them is the new holder's once the window from the unbinding passed; a moved WABA's new holder loses its own undated events within the window (its history sync among them, when it onboarded within it: D36's cost); a binding made before migration 4 keeps S2's strict first second for good, until it is unbound; the window and the margin are per deployment, not per tenant. - Decided 2026-11-01 (the coordinator, under the owner's delegation, AGENTS.md § Decisions; each a setting, or behind the
RecordStoreport and its conformance suite; recorded in design D36): the window runs from the previous tenant's unbinding, not from the new attach; the skew margin defaults to0s; a binding made before migration 4 reads the Unix epoch (UNRECORDED_MOVE), not NULL, so it keeps S2's rules; a tenant deleted and created again under its id counts as another tenant; the generation starts at 1 for every binding; the undated check sits inevents::ownerandRouteGuard::admitsand covers every undated event; a capability or an attach meeting a refresh answers503 storage_unavailable, retryable; tombstones are never purged (one row per WABA ever unbound). Pending the owner (stable identifiers): the names of migration 4's columns and table, its description, and the epoch default's meaning (§ Owner touchpoints). - Sabotage review (isolation, the migration, the routing rules): on Postgres a new binding's
attached_atwas its transaction's start, which can precede the previous tenant's unbinding when that commits while the bind runs, so an event Meta dated while the previous tenant still held the WABA fell in a second the new binding began before (the memory backend never did): the bind now moves it pastclock_timestamp()and the tombstone's time, and the race suite'sa_binding_racing_an_unbind_records_the_move(which failed 3 live runs of 3 before) holds every backend to it. An attach meeting a refresh of its own tenant took back the token it stored, which could leave the binding with none while the other attach answered success: it now keeps it (503still,capabilities::an_attach_meeting_a_refresh_keeps_its_tenants_token). A skew margin reaching before the Unix epoch failed every dated insert on Postgres (to_timestampout of range, folded at plan time):RouteGuard::dated_boundsstops at the epoch. Added to the suites: undated rounds in the insert-against-a-move race (a tenant created again under its id kept out by the window alone),capabilities::a_capability_made_while_its_binding_is_refreshed_is_refused, a WABA moved twice inrecords::a_move_is_recorded. The rolling deploy's gap is wider than first written (an unbind by an earlier release before the upgrade is not recorded either): Left, above.
- After: S2.
- Decisive: removing the memory backend's re-check fails the binding invariant; removing the stream purge from
delete_tenantfails on memory and, live, on Postgres; aBackendwhosekv()returns a new store on each call fails the same-data case. - Landed:
meta_whatsapp_server_core::conformancebehind the featureconformance:conformance::runtakes a factory of empty backends and runs, each on a backend of its own,records(the server's store suite: tenants, keys, bindings, idempotency, S2's referential rules, byte order),outbox(its events suite's single-port cases),invariants(what crosses ports: its backend suite's same-data case, deleting a tenant purges its stream, the threeRouteGuardcases, and each tenant's inserts committed in sequence order under eight concurrent writers, a case new on every backend, which was a Postgres-only HTTP test),capabilities(its capability suite, through the core'sAuthorizer) andraces(its race suite andracing_a_reattach). The server'stests/common/*_suite.rsare gone;tests/store.rsruns the whole of it on memory, andtests/live_postgres.rslive on Postgres (live_postgres_backend_passes_the_conformance_suite, a schema per suite). The decisive cases, by mutation on memory: without the memory outbox's re-check,invariants::the_route_guard_is_checked_with_the_insertfails; withoutforget_tenantinMemoryStore::delete_tenant,invariants::deleting_a_tenant_purges_its_stream; with akv()made anew per call,invariants::every_accessor_hands_out_the_same_data. Kept as tests: a backend whosekv()is new on each call fails the same-data case and the whole suite, an outbox that ignores the route guard fails the guard's cases andinvariants::run(L4), and three outboxes that bend the guard's date (dropped, dropped for a number's guard, read a second late) each fail the whole suite on the case of their own, so that a guard case taken out ofrunfails a test. Its sabotage review ran the live half on a Postgres 16 of its own: it passes, and there removing thepurged_throughupdate fromdelete_tenantfailsdeleting_a_tenant_purges_its_stream, a sequence drawn in a transaction of its own fails the commit-order case (on memory too: a row stored after an await), and dropping the WABA from a number's guard, or reading a guard's date a second late, fails the guard's cases. The Postgres-only tests (locks across sessions, two purges at once, a deadlocked insert) stay in the server's tests. Left: a case taken out ofrunother than the same-data and guard cases (the deletion's purge, commit order, the records', outbox's, capabilities' and races' cases) fails no test, as no permanent backend breaks only it.
- After: S3.
- Decisive: a test serves the API over a
Backenddefined outside the server crate, with no edit toserve.rs; a doctest that builds the state from loose ports fails to compile. - Landed:
AppState::from_backend(Arc<dyn Backend>, VaultKeys, Client, Inbound, Metrics, Settings)is the state's only constructor (newandwith_settings, which took the stores one by one, are gone): it builds the vault on the bundle'sKvStoreand the webhook pipeline on itsKvStore(dedup),ConversationStore(the inbox),Outboxand records;Inboundkeeps the app secrets, the verify token and the clock, and no store.serve::serve_with(config, Arc<dyn Backend>), andserve_until, the same with a shutdown future of the caller's, migrate (unlessWA_SERVER_MIGRATE=skip), serve, run housekeeping and close over the bundle, and read noconfig.storage;serve::serve_from(config, factory)opens the backend a factory gives forconfig.storageand serves it (serve::serve: the in-tree factory). Housekeeping is the core's,housekeeping::runandroundover aBackend(serve::Sweep, and a round without a leader lock, are gone; a zero interval is taken as a millisecond, and no round starts oncestopcompleted). The command line opens every backend through aserve::BackendFactory(BuiltinBackends: Postgres, memory;cli::run_withtakes an integrator's),serve::openmigrating perWA_SERVER_MIGRATE. The server's test harness serves its stores through aBackendof its own too. Decisive:tests/composed.rsserves the API over real listeners on aBackenddefined in the test (memory's ports in a struct of its own, counting what it is asked), with no change toserve.rs: the API reads and writes its records, the vault rotation re-encrypts a token stored in itsKvStore, Meta's webhook takes its dedup lease there and lands in its inbox and its outbox, housekeeping sweeps through its janitor, and it is migrated at start and closed at shutdown. The other half is four UI cases in the server'stests/visibilityrather than a doctest (they pin the compiler's error, which acompile_faildoctest cannot on stable):app_state_from_loose_ports(aMemoryStoreis not aBackend, E0277),app_state_new(nonew, nowith_settings, E0599),inbound_stores(the pipeline takes no store, E0061) andapp_state_backend(the state's backend is crate-private, as its records are, E0624). By mutation:serve_withandserve_untiltaking anArc<MemoryBackend>failtests/composed.rsto compile; the outbox, the dedup store, the inbox, the vault's store or the records taken past the bundle, housekeeping on another backend, the migration skipped and the close left out each fail an assertion of their own;impl Backend for MemoryStoremakesapp_state_from_loose_portscompile, and a looseAppState::newagain makesapp_state_newmismatch. The sabotage review added the cases four mutations survived: migrating whateverWA_SERVER_MIGRATEsays (tests/composed.rs, a second case),serve_withalone taking anArc<MemoryBackend>(a compile-only use there), housekeeping polling its tick beforestopand a zero interval panicking in the timer (tests/housekeeping.rs). It also fixed a race older than S4: theSIGTERMhandler was registered only once the listeners were spawned, so aSIGTERMas they began answering ended the process (signal 15, seen once inbinary::development_on_memory_prints_a_one_time_admin_key); it is registered before they are bound (serve.rs's unit testssigterm_is_caught_before_the_signal_is_awaitedandthe_stop_signal_is_made_before_the_listeners_are_bound).openapi/v1.jsonunchanged; the server's suite passes on memory and, live, on Postgres. Row 112's service side stays partial for S2b, which changes the ports' contracts again. Left:cli::run_withis tested with the in-tree factory only (the binary's tests; the admin commands read the process's environment, which a test cannot set); a factory is given the configuration'sStorage, whose database variant isPostgres(DATABASE_URL), so another database reads its URL fromDATABASE_URLuntil the configuration picks among the backends compiled in (design §8.3: S7 composes them by Cargo features, and S18 is the first to need the choice).
- After: S4.
- Decisive: called with no HTTP layer, attaching a WABA another tenant holds is refused with nothing stored, subscribed or shared; the admin routes' tests pass unchanged;
openapi/v1.jsonbyte-identical. - Landed:
meta_whatsapp_server_core::admin: every/v1/adminoperation is a method ofadmin::Admin, whichAdmin::new(&Authorizer, &AdminCaller)opens only once theAuthorizeradmits the admin (403 forbiddenfor another's, nothing read), over thatAuthorizer's records, vault and tokenless client (the records through a crate-privateAuthorizer::records, so no adapter reaches them, and what a service writes is what the order reads; the core's UI caseauthorizer_recordspins it, E0624, and fails with the method madepub). The operations take the request's values as it carried them (ids, labels, an RFC 3339 expiry,admin::TenantsInput,admin::KeyRequest), validate them in the order and on the fields the handlers did, and answer domain values (Tenant,Listing,admin::Minted,admin::Attached,admin::HeldWaba,VaultRotation) or aServiceError: the tenant CRUD, the tenant's deletion (five rounds, a moved WABA skipped), the key mints, listings and revocations, the attach (D4 on the claimed id before Meta, the listing capped atadmin::MAX_WABA_NUMBERS, bind,Authorizer::store_token, subscribe withstepandresumable), the binding's read, the unbind (Admin::unbind_waba, andAdmin::unbindon a binding as read) and the vault's rotation. Their audit events are the core's (server_core::audit, targetaudit, the same fields; the server'stelemetry::audit,tenant_auditandSubjectre-export them), and a failed Graph call is told to anobserve::Observer, which the server'sMetricsimplements (wa_server_graph_errors_total, the same codes as before, the attach's listing counted before its422remap).admin::mint,admin::MintError,admin::allowed_tenantsandadmin::is_valid_nameneed no capability: the command line's bootstrap uses them on the database directly (and the server'sapi::admin::mint,MintErrorandMAX_WABA_NUMBERSre-export them);OwnedWaba::forget_or_busyis the core's too. The handlers parse, call and map;auth::OwnedWaba::unbind_for_admincallsAdmin::unbind. Decisive:conformance::admin(inconformance::run) attaches a WABA through the service, then has another tenant attach it, with aScriptedTransportand no HTTP layer: refused409 waba_owned_by_another_tenantbefore Meta is asked (no request sent), and, for a WABA its holder binds while Meta lists its numbers, by the binding's own D4 (the listing the only request: no subscription); either way the vault's record is the holder's at the same version, the bindings and numbers unchanged, and the claimant holds nothing. It runs on a memory backend of its own in the core'stests/admin.rs, on the service'sMemoryBackend(tests/store.rs) and live on Postgres (tests/live_postgres.rs). By mutation, each fails it on the core's backend and on memory: the check before Meta skipped, or moved after the vault store (502 service_unavailable: a listing sent); the bind moved after the store, alone or with the check (the holder's own attach answers503: the store's confirmation finds no binding); the bind's refusal ignored (503, the store's confirmation the only guard left; also live on Postgres), a mutation the admin routes' HTTP tests do not catch; both checks skipped. The admin routes' tests pass unchanged, andopenapi/v1.jsonis byte-identical (the handlers' documentation and annotations untouched). Left: the server's source scan of error codes counts fewer (16, its floor lowered from 20; the core's raised to 30); an expiry is compared with the system clock, not theAuthorizer's, as before; the command line's key listing and revocation (any kind of key, every page) stay its own, as no route does either; the log lines that moved carry the core's module as their target (aRUST_LOGdirective naming the crate still matches them, one naming the server's module does not: CHANGELOG). The sabotage review pinned by test what no test held and a mutation showed: each admin change's own audit event, its fields, and none on a refusal or a failure (the service'stests/admin_audit.rs); Meta's failures counted by code, the attach's listing among them (the_admin_operations_count_metas_failures);Admin::newrefusing anotherAuthorizer's admin with nothing read (the core'stests/capabilities.rs); a platform key's scopes; a missing tenant's key listing, and its attach answered404before Meta is asked.
- After: S4.
- Decisive: called with no HTTP layer, a scripted timeout reports
may_have_been_sentand the same key replays it with no second request (M1.4's case);openapi/v1.jsonbyte-identical. - Landed:
meta_whatsapp_server_core::idempotency::run(&Keeper, &Authorizer, &Caller, key, &Fingerprint, operation)runs an operation under a key and answers anidempotency::Answer(a status, a body, andreplayed) or aServiceError: the claim with its lease and expiry, the answer kept byte for byte, a failure kept as the adapter renders it when it may have been sent (theKeeper'sRenderError) and the key released when it proves nothing was, a repeat's outcome told to theObserver(its new methodidempotency, which the server'sMetricscounts aswa_server_idempotency_total, as before), and the504 timeouta cut request keeps (theClaim's drop). It takes the tenant from aCalleritsAuthorizeradmits (another's is403 forbidden, before the key is read), not from a bareTenantId. The fingerprint is unchanged (method, path, body: S6 changes it). The messages aremeta_whatsapp_server_core::messages: the send request as the request carried it, free of utoipa (messages::SendMessageand its objects),refuse_unsupported(Direct Send and the types the service does not send, on a JSON body),message(the library'sOutboundMessage, checked in the order and on the fields of before),recipientandis_e164(strict E.164 with its+), and theMessagesservice on anOwnedNumber(opened withMessages::new,403 forbiddenfor a number anotherAuthorizermade,Authorizer::admit_number;send, answeringmessages::Accepted, the accepted message, not a status; andmark_read, with or without the typing indicator), its failed Graph calls told to theObserver(wa_server_graph_errors_total, the same codes).request::meta_object(a Meta object read with no key dropped) moved there too; the server'sapi::common::meta_objectwraps it for template creation. The server keeps its request types, with their OpenAPI schema, reads the body into them and converts them into the core's field by field (every field named on both sides, so a field on one side only fails to compile, and one destructured but dropped is-D warnings' unused variable; a test reads each of the core's type names into the server's enums); itsidempotency::run(crate-private still, now taking theCaller) hands the core's the service's records, settings, §5.1 error body and metrics, and renders the answer (Idempotent-Replayed: trueon a replay, a kept error's code tagged for the metrics). Decisive:conformance::messages(inconformance::run), with aScriptedTransportand no HTTP layer: a digits-only phone number refused onto.phonebefore any request; a scripted timeout504 timeoutwithmay_have_been_sent, sent once with the vault's token, and the same key's repeat its kept answer,replayed, byte for byte, with no second request (remaining() == 0, one request recorded); a131047409 customer_service_window_closedthat releases its key, the same key then sending and its success replayed; anotherAuthorizer'sCallerfor the same key refused403with nothing sent. It runs on the core's own memory backend (tests/messages.rs, the admin suite's records moved beside it intotests/memory, with idempotency records that lease and expire), on the service'sMemoryBackend(tests/store.rs) and live on Postgres (tests/live_postgres.rs). By mutation, on the core's backend: the operation run without claiming the key (no lease), and a failure that may have been sent released instead of kept, each fail it at the replay (a second send, which nothing answers; the latter also live on Postgres); every failure kept, a131047included, fails the released key's send; the caller's admission dropped fails its last step (the other authorizer's caller gets the tenant's kept answer); a phone number accepted without its+fails its first. The review (mutation-driven, its own lens) found what moved and nothing pinned: the handlers' and the core'sObservercalls dropped,Metrics'idempotencyemptied, the replay's code tag dropped, the lease and the TTL swapped, a blank message id let through, the caller admitted only with a key, and an interactive footer, a contact's birthday or an accepted contact'sparent_user_iddropped in the conversion each passed every test. Each now fails one: the suite tells theObserver's outcomes and codes, sends keyless for another authorizer's caller, and gains a second case,the_services_open_on_their_own_number_and_mark_read_as_meta_documents(a blank id refused before any request, the typing indicator, Meta's refusal counted); the service'stests/messages.rsgainsrepeats_and_metas_failures_are_counted,a_kept_answer_expires_after_the_ttland the read route's blank id;api::messagesgainsevery_field_a_request_carries_reaches_metas_objectandevery_field_of_the_accepted_message_is_answered.Messages::newadmits its number (Authorizer::admit_number, asAdmin::newits admin,tests/capabilities.rs:the_message_services_refuse_another_authorizers_number), so nothing is sent with a number anotherAuthorizermade. The idempotency records stay theKeeper's, not theAuthorizer's (as S5a did with itsrecords): the port's methods take a tenant as given and whoever holds theBackendreaches them (Backend::idempotency), so behind the order they would guard nothing, for a new parameter ofAuthorizer::newat its eleven callers. Every message, idempotency and read-receipt route test passes unchanged, andopenapi/v1.jsonis byte-identical (the request types, their documentation and the handlers' annotations untouched). Left: the server's source scan of error codes counts fewer (14, its floor lowered from 16; the core's raised to 32); no log line moved (the messages and idempotency handlers logged nothing of their own; the engine's own lines were already the core's); the server'sapi::messages::MessageType::ALLis gone (the core'smessages::MessageType::ALLis the list); media and template creation run under the core'sruntoo, their operations still the server's until S5c and S5d.
- After: S4.
- Decisive: called with no HTTP layer, tenant B's caller on A's template id is not found with zero vault reads;
openapi/v1.jsonbyte-identical. - Landed:
meta_whatsapp_server_core::numbers:Numberson anOwnedNumber(Numbers::new, which admits it,Authorizer::admit_number):details(numbers::NumberDetails,NUMBER_FIELDS),profileandupdate_profile(anumbers::ProfileChangeas the request carried it, answeringnumbers::BusinessProfile; an empty change only reads), andWabaon anOwnedWaba(Waba::new, which admits it through the newAuthorizer::admit_waba):disconnect(still_bound, then Meta unsubscribes the app, then the token and bindings go;503for a WABA that moved, nothing deleted when Meta refuses).meta_whatsapp_server_core::templates: theTemplateCache(per tenant, WABA and query, bounded per WABA and in all, least recently used first; its TTL the adapter's, the service's 60 seconds, one per replica), the checks (the status filter, the name, the id: the core'srequest::graph_id, moved from the server;templates::definitionfor a creation's body), andTemplateson anOwnedWabafor aCaller(Templates::newadmits both and opens only on a WABA of that caller's own tenant, else404 not_found):list(cached),get(the bare id asked for its name only, then looked for in the WABA's own list of that name, at most 5 pages of 100),createanddelete(each dropping the WABA's cached pages; a deletion audited as the tenant's, targetaudit), answering domain values free of utoipa (Template,TemplatePagewith Meta's raw cursor,TemplateCreated). Failed Graph calls are told to theObserver(wa_server_graph_errors_total, the same codes; a refused template id by Meta's code though it is answered404). The handlers parse, convert (every field named on both sides, no..), call and render; template creation still runs underidempotency::run(Idempotent-Replayedon a replay). Decisive:conformance::templates::another_tenants_template_id_is_not_found_with_zero_vault_reads(inconformance::run), with aScriptedTransportand a vault whose every read is recorded, no HTTP layer: B's caller naming A's WABA is404(step 4) with zero vault reads and no request; B's caller handed A's capability is refused byTemplates::new,404, zero vault reads; through B's own WABA, A's template id is404without Meta's code or text (Meta asked with B's token only: the name, then B's WABA's list), and Meta refusing the bare id is404too; a deletion of A's template by id through B's WABA deletes nothing (noDELETE); anotherAuthorizer's caller or capability is403; and A's vault record and phone index are never read through any of it. It runs on the core's memory backend (tests/templates.rs, its records now conditioning an unbind and a status change on the binding's epoch), on the service'sMemoryBackend(tests/store.rs) and live on Postgres (tests/live_postgres.rs). By mutation: the ownership filter ofAuthorizer::owned_wabadropped fails its step 2 on all three backends (no earlier suite ofconformance::runcaught it); the tenant check ofTemplates::newdropped fails step 3; its admission of the caller, or of the WABA, dropped fails step 6; the id check in the WABA's list dropped fails step 4; the lookup before a deletion by id dropped fails step 5. A second case,the_template_services_send_metas_documented_requests, pins the list's requests, the cache, its invalidation by a creation and a deletion, and the checks before any request;conformance::numbers(the_number_services_read_change_and_disconnect_as_meta_documents) the number services' requests, the admission of both capabilities, the empty change, and a disconnection that deletes nothing until Meta unsubscribed the app. The sabotage review found five changes no test caught, now pinned: the cached pages dropped only on Meta's success, and the lookup's 5-page cap changed (a_refused_creation_or_deletion_drops_the_wabas_cached_pages,a_template_id_past_the_lookups_five_pages_is_not_found, both inconformance::run); the lookup's log line under another target (the core'stests/templates_logs.rs); Meta'sdetailsdropped from the number services' and the disconnection's errors, and a profile change'semaildropped on its way to the library (conformance::numbers, now posting all six fields). The service'stests/templates.rsandtests/numbers.rspass unchanged, and gainmetas_failures_on_templates_are_countedandmetas_failures_on_numbers_are_counted(the handlers'observed_by(state.metrics())dropped fails each);openapi/v1.jsonis byte-identical (the request and answer types, their documentation and the handlers' annotations untouched). Left: the tenant's listings (GET /v1/wabas,GET /v1/numbers) stay the handlers' reads of the records for the admitted caller (no Graph call, not in this item's list);numbers::ProfileChange'sverticalreads into the library'sVertical, whose catch-all takes a name it does not know, which goes to Meta as before; a list'slimitis the adapter's to bound (the server'sPageQuery: 1 to 100), as before; the one log line that moved keeps its old target by naming it (meta_whatsapp_server::api::templates); the server'sauth::OwnedWabakeepsfailed,failed_on_object,still_bound,forgetandforget_or_busy(public, now unused by the handlers).
- After: S4.
- Decisive: a download through the core's stream ends before its last chunk on a digest mismatch (M1b's rule, without axum); the media routes' tests pass unchanged.
- Landed:
meta_whatsapp_server_core::media:Mediaon anOwnedNumber(Media::new, which admits it,Authorizer::admit_number), over the adapter's transfer slots (media::MediaSlots, the core'sratelimit::Slots, each slot of the number's tenant's share):transfer_slot(an upload's, before its body is read),upload(amedia::Upload, made only byUpload::new:checked_type, Meta's sizes,filename),download(withinmedia::DownloadLimits, made only byparseornewwithin the caps,MAX_WHOLE_DOWNLOADwhole) anddelete(looked up first, withphone_number_id). A download answersmedia::Downloaded: the content type, Meta's SHA-256, and the whole verified file or amedia::MediaStream, afutures::Streamof the library'sBytes(no HTTP framework; the core gainsbytesandbase64, both already in the library's graph) held one chunk behind, which yieldsStreamStopped::Integrityinstead of its last chunk on a mismatch (TooLargepastmax_bytes,Failed) and holds its slot until dropped. The handlers keep the multipart reading (framing budget,media::upload_capas the file streams in), the upload'sidempotency::run(Idempotent-Replayed), the OpenAPI types and the headers, and render the stream as the body. Decisive:conformance::media::a_download_through_the_cores_stream_ends_before_its_last_chunk_on_a_digest_mismatch(inconformance::run), with no HTTP layer and a transport whose downloads arrive in the chunks the case gives: a tampered file of three chunks yields two, then the error; a file still arriving is passed on with one chunk held; pastmax_bytesnothing; a whole tampered file is502 integritywith no byte; the reported size, an unusable digest and a URL offlookaside.fbsbx.com(502 upstream, the token not sent) are refused before the download; another node's or a refused media id is404; anotherAuthorizer's number is403; a slot of the tenant's share comes first. It runs on the core's memory backend (tests/media.rs), on the service'sMemoryBackend(tests/store.rs) and live on Postgres (tests/live_postgres.rs); a second case,the_media_services_upload_and_delete_as_meta_documents, pins the upload's multipart request and the deletion's lookup, a third,a_failed_download_yields_no_unverified_end_and_frees_its_slot(from the sabotage review), that no failure or odd chunking (empty chunks, a connection cut mid-file, one chunk, none, exactlymax_bytes) yields the last chunk unverified, that every failure gives its slot back, the refusals' order (the id before the slot, the reported size before the digest), that a URL offhttps://lookaside.fbsbx.comis502 upstreamwith nothing sent to it, and that a190from Meta's URL marks the number, andtests/media_logs.rsthe three moved log lines' target (meta_whatsapp_server::api::media) and that none names Meta's URL. By mutation, each fails the core suite: the digest check dropped (a mismatch taken for the end, in the stream or the whole read); the digest checked only once the last chunk was yielded (nothing held back);Media::new's admission dropped; each size cap dropped (the stream's, the whole read's, the reported size's, the limits' inparseand innew, an upload's kind limit inUpload::newand inupload_cap), and, since the review, a failure taken for the verified end, an empty chunk held as the last one, a slot kept past a failure, the download's own190not marking, and either order swapped. The service'stests/media.rspasses with its tests unchanged (the review added one: an upload's key covers its type and file name) and fails on each mutation but the admission (no forged capability reaches a handler) and the limits' caps (parseandneweach hold the other's).openapi/v1.jsonis byte-identical. Left: the server'sauth::OwnedNumberkeepsfailedandfailed_on_object(public, now unused by the handlers, as S5c leftOwnedWaba's); the multipart reader stays the server's (its framing budget is multer's, an HTTP body's concern).
- After: S4.
- Decisive: M1.2's routing cases run against the core pipeline with no listener; routing an unowned number's event to a tenant fails them.
- Landed:
meta_whatsapp_server_core::inbound:Inbound(the app secrets, the verify token, the clock; moved from the server, which re-exports it) andPipeline, built on aBackendalone (Pipeline::new(Inbound, &dyn Backend, Arc<dyn Observer>), the stores the server'sEventstook before).Pipeline::deliver(signature, body)takes the body as read and itsX-Hub-Signature-256value (bytes, no HTTP type), checks the signature against every app secret first (Refused::Unauthenticated: no turn waited for, no store touched, nothing logged), takes one ofMAX_DELIVERIES_RECORDINGturns withinRECORDING_WAIT(Busy), runs the library'sWebhookHandler(3 MiB,TooLarge; a signed body that is not a webhook oneunparsedevent; each event under its dedup lease,InFlightwhile another request holds one) into a sink private to the core (routed by the records,events::route; the inbox when owned; the outbox row;Busyfor a busy outbox,Failedfor a failed store, the lease released), and answersDeliveredonly once every event is recorded or was already.Refused::statusandoutcomeare the answer and the metric label as data (DELIVEREDfor a recorded one); the sink's counts go to theObserver's three new required methods (webhook_event,webhook_duplicates,webhook_failure, the server'sMetricscounting them as before), and its log lines keep the targetmeta_whatsapp_server::events(inbound::LOG_TARGET) and their fields. The split: nometa-whatsapp-server-httpcrate (S7 makes it); what needs a listener stays the server's (api::webhooks: the signature header's shape before the body,MAX_DELIVERIES_IN_FLIGHTplaces,BODY_READ_TIMEOUT, the 3 MiB read, the rejection log at most once a minute per reason, the outcome's count, the subscription handshake), its handler matching the core'sRefusedexhaustively. Decisive:conformance::webhooks::routing_is_an_allow_list_with_no_listener(inconformance::run), over a view of the backend that records what reaches its key/value store and its outbox, with no listener: twelve signed payloads from Meta's examples; an unbound number (under the tenant's WABA or not), the tenant's number under another WABA than the records say or under an unbound one, an unbound number's message whose payload names a tenant, a replay dated before the dedup lease's memory and an unbound WABA's template update are operator-only rows, the others their holder's, each guarded by the binding it was routed by; each tenant polls exactly its own, the inbox holds only an owned number's message, and each row is told to theObserverby audience. It runs on the core's memory backend (tests/webhooks.rs, its records joined by an outbox that checks the routing again under their lock and the library's memory inbox), on the service'sMemoryBackend(tests/store.rs) and live on Postgres (tests/live_postgres.rs). By mutation, each fails the core suite: the WABA the event names not compared with the number's (route by the payload's id without the ownership check), the event's WABA looked up before its number, and an unbound number falling back to its WABA's binding (an unowned number's event recorded under a tenant) each fail the decisive case (and the service'srouting_is_an_allow_list); the dedup lease dropped failsan_event_is_recorded_once_under_its_dedup_lease; the answer given before the recording (spawned) or whatever it did failsthe_answer_comes_only_once_every_event_is_recorded(and most of the suite); the core's signature check moved after the handler failsa_delivery_is_verified_before_anything_is_stored(a forged delivery waits for a turn) and, in the service, onlylog_rate.rs(the library logs each forged delivery); an unowned event recorded in the inbox, the observer's counts dropped, a busy outbox answered500and a moved log line's target each fail a case too (the last only the core'stests/webhooks_logs.rs: no service test names that target). The review (sabotage, security lens) closed what survived the core suite: dropping the turn to record, or giving one more, failed only the service's paused-clocka_delivery_with_no_turn_to_record_is_503_after_the_wait; nowa_delivery_waits_for_a_turn_to_record(inconformance::run: with every turn held, a fifth delivery claims no lease and asks for no row) anda_delivery_with_no_turn_is_busy_after_the_wait(the core'stests/webhooks.rs, on a paused clock:503 busyafterRECORDING_WAIT, nothing touched) fail too. Dropping the replay window's check, or the binding's date's, passed the core suite (the decisive case's replay is dated before its binding, so each check hid the other's absence; the service's tests caught both):an_event_is_routed_only_after_its_binding_and_within_the_replay_window(a pipeline whose clock is past the window) fails for each. The inbox written after the outbox failed only the service's tests (now the answer case asserts the inbox holds the message while its row is on its way), the failure line's target no test (nowtests/webhooks_logs.rs), anda_number_moved_while_its_event_records_reaches_neither_tenantruns the outbox's re-check of the routing through the pipeline: a number's event guarded by its WABA's binding alone passed every test of the core and the service, and fails its second round (the number moving alone, to another tenant's WABA). Every webhook test of the service passes unchanged, the event-data snapshots are unchanged, andopenapi/v1.jsonis byte-identical. The server's public but unusableevents::ServiceSinkis gone (the sink is private to the core, pinned by the UI caseinbound_sink), andInbound::clockis public.
- After: S5a, S5b, S5c, S5d, S5e.
- Decisive: two spellings of one operation's path (percent-encoded or not) replay the same key, and putting
uri.path()back into the fingerprint fails that test; a read charged by operation id lands in the read budget whatever its HTTP method; a misspelled error code fails to compile (acompile_faildoctest). - Landed:
meta_whatsapp_server_core::operation::Operationnames the sixteen tenant operations by id (the HTTP adapter'soperationId, its handler's name) with their scope. A request's rate class is its operation's (RouteClass::of(Operation)), andRateLimiter::charge(tenant, operation)is the only public way to spend a budget (checkis crate-private: the UI caseratelimit_check); the server's tenant guard resolves a request to its operation (auth::Operations, built from the OpenAPI document: the method and the route template,HEADas itsGET) and charges it,500 internalfor a tenant route that is no operation (a test holds every tenant route to one core operation with its group's scope, a path of each template, plain, percent-encoded or with an empty parameter (which axum routes:/v1/numbers//profileisget_profile's, charged, then404), matching no other). The fingerprint is the operation and its canonical input, defined once in the core:Fingerprint::send_message,upload_mediaandcreate_templatehash the operation's id, its path parameter as a typed id (the number or the WABA, decoded), and the JSON body canonical (object keys sorted, whitespace ignored) or the checked upload's type, file name and SHA-256; no method and no path (the genericjsonandpartsare private, so every adapter derives the same fingerprint), its known answers pinned again apart from the code.ErrorCode's constants,ErrorCode::ALLandCODEScome from onecodes!table inerror.rs, kept equal to §5.2 by the core's ownthe_codes_are_the_designs(beside the service's) and each constant's name to its code byeach_constant_is_named_after_its_code;ServiceError::newand the server'sApiError::newtake anErrorCode, so a misspelled code fails to compile (thecompile_faildoctests onErrorCode:E0599for a misspelled constant,E0308for a string), and the source scans for string codes are gone with the strings (the server's schema markers areErrorCodeSchemaandKnownErrorCodeSchema, their schema names unchanged). TheAuthorizer's admin writes (store_token,rotate_vault,forget_for_admin,graph_failed_for_admin) are crate-private: an adapter reaches them only throughadmin::Admin, opened byAdmin::new's admission (S5a's pattern), and each still checks the admin (the UI casesauthorizer_*; the conformance casecapabilities::an_admins_vault_writes_take_an_admitted_admin, inconformance::run: the core's memory backend, the service's and live Postgres). No dependency of either crate was unused (rustc'sunused-crate-dependencieson both libraries, the dev-dependencies by grep;cargo udepsis not in the pinned toolchain), so none was dropped;just featureschecks the core's library alone and documents it withconformance. Decisive:two_spellings_of_one_path_replay_the_same_key(the service'stests/messages.rs, with the template and upload ones beside it), which fails withuri.path()back in the fingerprint (the percent-encoded repeat answers422 idempotency_key_reused) and with the number out of it (another number's send replays);a_read_charged_by_operation_id_lands_in_the_read_budget_whatever_its_method(the core) anda_read_served_by_post_lands_in_the_read_budget(the service'stests/rate_limits.rs: an RPC-shaped router whose reads arePOSTs, behind the service's own guard and limiter), the latter failing with the class read from the method; in/v1every read is aGET, so no route of the HTTP adapter can tell the two rules apart (v1s_routes_keep_their_classpins that the budgets did not move); and thecompile_faildoctests. By mutation: an entry added to or dropped fromcodes!failsthe_codes_are_the_designs(core and service), a constant named after another codeeach_constant_is_named_after_its_code; a vault write's own check dropped (store_token's,rotate_vault's) fails the conformance case,Admin::new's admission dropped fails it andthe_admin_services_refuse_another_authorizers_admin, andstore_tokenpublic again the UI case; a read moved to the send class failsan_operations_class_is_the_tablesandv1s_routes_keep_their_class.openapi/v1.jsonis byte-identical, and no status or body changed but the fingerprint's semantics, a pre-release change of/v1(D27). As first built, a key claimed before the upgrade and repeated after it with the same request answered422 idempotency_key_reused, never a second send, until its record expired (WA_SERVER_IDEMPOTENCY_TTLfrom its claim, 24 hours by default); the window decided below replays it instead, and the guide and the CHANGELOG say so. - Decided 2026-10-31 (the coordinator, under the owner's delegation; each swappable, recorded in design D27): the canonical input as proposed (the received JSON, canonicalized);
chargetaking a bare&TenantId; the server-crate renames (ErrorCodeSchema,KnownErrorCodeSchema) andauth::guard'sOperationsargument;graph_failed_for_adminamong the crate-private admin writes;authz::rotate_vaultpublic for the command line; no dependency dropped. Changed the same day: the 422 window. A deployment exists, so a key claimed before the upgrade keeps replaying: for one TTL a repeat also matches a record under the fingerprint of before (Fingerprint::legacy_v1_json,legacy_v1_upload, pinned to main's known answers bythe_legacy_fingerprint_is_mains; the HTTP adapter adds it withFingerprint::or_legacy; a claim stores the new one). Tests: the conformance casemessages::a_key_claimed_before_s6_replays_under_its_legacy_fingerprint(inconformance::run: a record stored by hand under the legacy fingerprint replays with no send, another body or the new fingerprint alone is422, a fresh claim stores the new one), the core'sa_record_under_the_legacy_fingerprint_is_the_same_requests, and per route the service'sa_key_claimed_before_s6_replays_after_the_upgrade,a_creation_key_claimed_before_s6_replays_after_the_upgradeandan_upload_key_claimed_before_s6_replays_after_the_upgrade. By mutation: the legacy match dropped fails them all; a legacy match taken for any body fails the422steps (and the two-spellings tests); a claim storing the legacy fingerprint fails the conformance case and the two-spellings tests; the send handler withoutor_legacyfails its HTTP test;uri.path()in the primary fingerprint still failstwo_spellings_of_one_path_replay_the_same_key.
- After: S6.
- Decisive:
openapi/v1.jsonbyte-identical; each store crate runs the core conformance suite (live for Postgres, withMETA_WHATSAPP_RS_REQUIRE_LIVE=1); the binary builds with onlyapi-axumandstore-memoryenabled; live, a Postgres database migrated bymain's binary starts the split binary with no migration run again and no checksum error (the migration history and its lock are stable identifiers). - Landed: the four crates, and the binary composing them with its features
api-axum,store-memoryandstore-postgres(all default; withoutapi-axum, the only API adapter, it does not compile; a build without a backend refuses that storage at start, naming the feature), re-exporting the moved modules under their old paths (design §2.1, §8.1 "The crate split").openapi/v1.jsonis generated by the adapter and stays committed beside the binary, byte-identical (cmpagainstmain, theopenapicommand, andtests/openapi.rs). The memory backend runsconformance::runin its crate'stests/conformance.rs(the_memory_backend_passes_the_conformance_suite, with the cases it must refuse), the Postgres one live in its crate'stests/live_postgres.rs(live_postgres_backend_passes_the_conformance_suite, with the Postgres-only cases: migrations, locks, purges, a deadlocked insert), whichjust test-liveruns withMETA_WHATSAPP_RS_REQUIRE_LIVE=1.just featuresbuilds the binary withapi-axumand each backend alone, and each new crate alone. The migrations moved to the Postgres crate byte for byte (sqlx knows one by its version and checksum, both pinned bythe_migrations_and_their_checksums_are_pinned; the table and the lock key unchanged): the binary'slive_postgres_a_database_mains_binary_migrated_starts_this_one_with_no_migrationmigrates a database asmain's binary did (its lock, the library's migrations, sqlx's migrator withmain's checksums) and starts the split binary'smigrateandserveon it: both exit 0,/readyzis 200, and both history tables are row for row unchanged (installed_onincluded); an edited file, a renamed table or a renumbered migration fails it. Run once more againstmain's own binary (dda06c9, built from its tree):migrateandservebymain, then by the split binary, thenmigratebymainagain, each exit 0, with both histories and the schema dump identical. An API adapter's handlers reach the records, the authorization order and the idempotency engine through the state'sGrant, which onlyAppState::from_backend's caller is handed (UI casesgrant_from_stateandgrant_from_conversions: no constructor, noFromand noFromRefmakes one from a state; the state's accessors stay crate-private, and the state no longer holds its backend). A grant proves its holder composed a state, not which one:OwnedNumber::capabilityandOwnedWaba::capabilitytake any, and the core's services admit the capability with the authorizer they are given, so another state's grant opens nothing. The grant's ownCallerandAdminCallerextractors admit what they extract, as the state's do (a_forged_capability_is_403_at_every_extractor_on_a_grant_too). Every moved log line keeps its target: the ones a request logs (every_line_keeps_its_target_from_before_the_crate_split) and, read from the sources, every one of the new crates (every_moved_log_line_names_its_target_from_before_the_crate_split: each module'sLOG_TARGETis its path before the split, each line names it). - Decided (coordinator, under the owner's delegation, each swappable): the
Grant, withAppState::from_backendreturning(AppState, Grant)(to swap: another proof type, the same call sites); the moved modules re-exported under their old paths (meta_whatsapp_server::{api, auth, state, store, …}; to swap, drop a re-export);openapi/v1.jsoncommitted beside the binary, which prints it, rather than in the adapter's crate (to swap, move the file and the pathstests/openapi.rs, the skills gate andjust skills-tsread); the featuresapi-axum,store-memoryandstore-postgres, all default, with no API adapter a compile error and a missing backend a refusal at start naming its feature;api::common's four helpers (encode_cursor,next_cursor,rfc3339,json) public, as the adapter's handlers use them (pure functions, no capability);From<MintError> for ApiErrorin the HTTP crate'serror.rs, where both types are in reach; the legacy fingerprint's removal line moved to M2a; and the docs tables' resolver prefixes for the new crates (server_http::,server_api_axum::,server_store_memory::,server_store_postgres::,crates/meta-whatsapp-rs/tests/docs_tables.rs). - Also, after the first release: the legacy fingerprint's removal moved to M2a, the service's next item, as S7 landed before the first release.
- After: nothing.
- Decisive:
just test-livewithMETA_WHATSAPP_RS_REQUIRE_LIVE=1and no MongoDB URL fails; with it, a live smoke test commits a multi-document transaction. - Landed:
mongodbincompose.test.yaml(127.0.0.1:57017) and as a devcontainer sidecar:mongo:7.0.45-jammywith--replSet rs0, whose healthcheck runsrs.initiate()whenrs.status()reports the set not yet initiated (code 94), with one member,localhost:27017, and passes once that member is primary (a few seconds after start). The URL carriesdirectConnection=true, as the member address is the container's own:just test-liveexportsmongodb://127.0.0.1:57017/?directConnection=true(and starts Compose when it is unset), the devcontainer setsmongodb://mongodb:27017/?directConnection=true, andjust test-live-cleandrops thewa_test_*databases (throughmongosh, which the devcontainer's image gains). The smoke test islive_mongodb_replica_set_commits_a_multi_document_transaction(meta-whatsapp-adapters'tests/live_mongodb.rs, intest-live'slive_filter): a transaction across two collections, not visible outside it before the commit and all visible after, then an aborted one that leaves nothing; with the URL unset underMETA_WHATSAPP_RS_REQUIRE_LIVE=1it panics. Its driver,mongodb3.9 (Apache-2.0), is a dev-dependency there, without default features (rustls-tlsis rustls onring) and withrustls-tls-aws-lc, which builds its configuration on an explicit aws-lc-rs provider: no library build links it, and it enables noring. CI needs no workflow change (just cirunstest-live, which starts Compose). The server is MongoDB's, under the SSPL, and runs as a test service only. - Decided 2026-11-01 (the coordinator, under the owner's delegation, AGENTS.md § Decisions; each swappable by its tag, line or setting): the image
mongo:7.0.45-jammy, pinned by tag: the 7.0 series, as every 8.x release refuses to start on Linux 6.19 and later (MongoDB's SERVER-121912, an rseq incompatibility in its tcmalloc; the CI runner's kernel is one), and 7.0 has transactions on a replica set; back to 8.x once MongoDB ships a release that starts there; the driver a dev-dependency of the adapters only, withrustls-tls-aws-lcandbson-3, until S17 makes it an optional dependency; the healthcheck'sstart_intervalof 1 s andintervalof 30 s (a check every 2 s cost half a core);mongosh2.14.1 in the devcontainer image;MongoCleanupinlive_mongodb.rs, so the other test binaries do not build the driver (S17 may move it). The SSPL server is run, never linked or shipped, as the test service for the database the owner named (design §10); the owner may still rule on it, and the final report lists it.
- After: S3, S8.
- Decisive: the spike passes the core conformance suite's outbox cases, the cross-port invariants included, live against a replica set. If a case cannot pass, the port changes first (memory and Postgres with it, through S3's suite), and the spike passes after that change.
- After: nothing. The owner merges it (§ Owner touchpoints).
- Decisive: a procedure returning a domain error answers the §5 body byte for byte; dropping the status passthrough fails it.
- After: nothing. The owner merges it.
- Decisive: with the feature on,
cargo tree -e featuresof a test crate shows noring.
- After: nothing. The owner merges it.
- Decisive: a test workspace resolves a newer 0.9 patch release beside CrateStack.
- After: nothing.
- Decisive: each issue carries a minimal
.cstackschema and the statement our store needs that it cannot express; design §8.6 links every one of them.
- After: S7, and the owner's yes to D20 (a) (the coordinator asks at this step; S10 does not merge without the answer).
- Decisive:
just denypasses and fails again when the exception is removed; a live Postgres test withsslmode=requirepasses in a binary that links both providers (sqlx picksringitself until U2); a test that builds a rustls client configuration from the process default after start-up passes in that binary, and panics when the install is removed.
- After: S10.
- Decisive: an in-process
find_manyas the system principal returns the rows; the same call under a tenant's context returns none; removingauth().isSystem()from the model's@@allowfails the first.
- After: S10.
- Decisive: a workspace test reading
Cargo.tomlfails when acratestack*requirement is not pinned to one minor, or when two CrateStack crates are pinned to different versions.
- After: S10.
- Decisive: the authorization (M1.3), error (M1.5) and idempotency (M1.4) suites run against both adapters through an enumerator over each adapter's contract; a procedure left out of the enumerator fails it.
- After: S9, S10, S11.
- Decisive: the core conformance suite passes live; a model field renamed away from our migrations' column fails the live drift test.
- After: S13.
- Decisive: editing
api.cstackwithout regenerating fails the gate; a request the client builds carriescontent-type: application/json.
- After: S13, S14, S15, and U1 merged by the owner.
- Decisive:
just ciwith default features; the errors suite fails if the CrateStack adapter answers an error in CrateStack's own shape; the binary built withapi-axumstill servesopenapi/v1.jsonbyte for byte.
- After: S8, L5.
- Decisive: both library conformance suites pass live against a replica set; removing the version check from compare-and-swap fails them.
- After: S9, S17, M2a–M2f.
- Decisive: the core conformance suite passes live; the service's M1–M2 acceptance tests pass on it.
2. The bot framework (meta-whatsapp-bot, D28, D29)
A new library crate on core, client and webhooks (never adapters or the facade), re-exported by the facade behind a feature. A Bot is an EventSink, so it plugs into WebhookHandler like any sink.
- After: nothing.
- Decisive: removing the cooldown check fails a test; removing the banned-list guard fails a test; a middleware that does not call
nextstops the handler; a 5000-character text splits into exactly two messages at a paragraph boundary; a sender with nowa_id(BSUID only) is served. - Landed: rows 86–88 done in the library; row 85 partial (subcommands and flags: B1b, since done) and row 17 partial (rich replies beyond text: B1c, since done). Folder loading and hot reload stay out (D28).
- After: B1.
- Decisive:
/order status 42runs the subcommand with42as its only argument, and/orderalone runs the parent; a subcommand's own cooldown refuses a second run (removing its guard check fails the test); a quoted"--limit=5"stays a positional argument (removing the quote check fails the test). - Landed: row 85 done in the library.
Command::subcommand(one level),Command::flag,Command::pass_unknown_flags,Invocation::subcommand,Args::flags,Args::has_flag,Args::flag,Args::parse_with_flags,Flag,Refusal::UnknownFlag, and the subcommands inCommandInfoandCategoryHelp. The decisive tests, incrates/meta-whatsapp-bot/tests/subcommands.rs:order_status_runs_the_subcommand_and_order_alone_the_parent(fails when the match runs the parent instead) anda_subcommands_own_cooldown_refuses_a_second_run(fails when the cooldown is read from the parent); insrc/command.rs,a_quoted_flag_stays_a_positional_argument(fails without the quote check). The parent's scope and owner-only guard its subcommands; a subcommand's cooldown key is the same digest over its path (order status), so no stored key changes. Flags: the long form only,=for a value,--ends them, an em dash reads as--. Decided by the coordinator (AGENTS.md § Decisions; the draft refused undeclared flags on every command): only a command that declares a flag, or takes any (Command::pass_unknown_flags), reads flags, each command and subcommand by its own set, and one such command typed with a flag it does not declare is refused with its usage. The reason: a mistyped flag on a command that has flags (--dryrunfor--dry-run) must not run the real thing, while free text on a flag-less command (/say --hi there) must reach it untouched, as before B1b, so no existing command changes. Swappable per command (Command::pass_unknown_flags), and for the whole botRefusalsdecides what the user is told; the tests area_command_without_flags_keeps_dashes_as_argumentsandan_unknown_flag_is_refused_with_the_usage. Not in it: nested subcommands, short or space-separated flags, and typed flag values (a handler parsesArgs::flagitself).
- After: B1.
- Decisive: a reply of a paragraph and an image sends a text message and then an image message, in order, with the exact requests asserted; three suggestions become buttons and four a list (removing the count check fails the test); a
javascript:image URL is never sent. - Landed: row 17 done in the library.
Ctx::reply_richsends arich::RichReply(markdown,image,image_with_caption,suggestions,products,list_button;to_messagesbuilds without sending) ofrich::Suggestions (new,command,description), each part as the client's own builder (ReplyButtons,ListMessage,ProductCarousel,Image), all checked withOutboundMessage::validatebefore the first send. The decisive tests, incrates/meta-whatsapp-bot/tests/rich_replies.rs:a_paragraph_and_an_image_send_a_text_then_an_image(fails with the parts or the sends reversed),three_suggestions_become_buttons_and_four_a_list(fails with the count check forced either way or off by one) anda_javascript_image_url_is_never_sent(fails without the URL check);a_failure_midway_stops_the_reply_and_names_the_stepanda_timeout_midway_is_not_resent_or_followedfail when the reply goes on after a failed send. Decided by the coordinator on 2026-10-30 (AGENTS.md § Decisions), as the draft proposed: the builder API above; every message built and validated first, then sent in order, a failed send stopping the reply as anError::Step(rich::STEP_TEXT, …) with the parts before it never resent; buttons, lists and carousels take as their body the last message of the Markdown part right before them (none: refused), so an answer and its buttons are one bubble and a separate question is one moremarkdownpart;Suggestion::commandsends the command's first payload and is refused when it has none; an image link must pass the renderer's URL rule and be an absolutehttporhttpsURL with a host (Meta fetches it; its page refuses nohttp, sohttp:is allowed), without a user name or password (added in review: it would be sent to Meta); a list's single section is titled with its button's label,rich::LIST_BUTTON(Options) unlessRichReply::list_button; a suggestion's description shows in lists only (buttons have none). A suggestion may name a hidden or owner-only command: it shows only the title the bot gives it, and the tap passes the command's guards (an_owner_only_suggestion_is_still_guarded_when_tapped). Swappable:RichReply::to_messagesgives the messages to send another way, and the Markdown goes through the bot'sMarkdownRenderer. Not in it: an image by uploaded media id, Markdown's ownas an image (it stays text), headers and footers on buttons and lists, several list sections, and a partial result on failure (the error carries the step, not the responses of the parts already sent).
- After: B1.
- Decisive: under a fake clock, 200 sends at a configured 20 a second never exceed it; a scripted timeout is never retried (removing the
may_have_been_sentcheck fails the test). - Landed: rows 89 and 92 done in the library (
Broadcast,Pacer,TokenBucket,BroadcastPolicy,BotBuilder::pacer,PacedGroupsandPacedGroupfor group operations). Each person once by default (BroadcastBuilder::dedupe); lines streamed to aReportSinkfor long lists; every retry paced (Client::with_retry), and resent only whenError::may_resendholds, the client's rule too. Swappable: the rate limiter (RateLimiter), which errors slow a number down (SlowDownRule), the clock (Timer), the failure policy (BroadcastPolicy) and the report sink (ReportSink) are traits with defaults; a number's rate changes while running (TokenBucket::set_rate). Not in it: acting on Meta's throughput upgrade by itself (B2a), a limiter shared by replicas (B2b; the trait allows one), Meta's daily messaging limit (Meta's to enforce), and runs that survive a restart (B3).
- After: B2.
- Decisive: delivering the
THROUGHPUT_UPGRADEfixture moves that number's sends from 12.5 ms to 1 ms apart on aManualClock; removing theset_ratecall fails the test. - Landed:
ThroughputUpgrades(modulethroughput), wired in withBotBuilder::follow_throughput_upgrades(a listener ofphone_number_quality_updated) or delivered to as anEventSinkwithout a bot; nothing follows an upgrade unless one is set up. The webhook names the number by its WABA and display phone number only, so aNumberDirectorysays which phone number id that is (defaultKnownNumbers: registered by WABA and display number, compared by their digits); an unknown number, anONBOARDING, aFLAGGEDor a value Meta adds later changes no rate, and an upgrade never lowers one (heard twice, it changes nothing). In memory: a restart or another replica is back at the configured rate, and Meta sends the webhook once (ThroughputUpgrades::observereturns the number, to record it yourself; no store namespace added). The rate an upgrade sets is a setting (ThroughputUpgrades::rate). The decisive test, incrates/meta-whatsapp-bot/tests/throughput.rs:the_upgrade_fixture_moves_the_numbers_sends_from_12_5_ms_to_1_ms_apart(fails without the rate raised: the gaps stay 12.5 ms);other_numbers_keep_their_ratefails when the rate goes to every number,an_upgrade_of_an_unknown_number_changes_nothingwhen the WABA is not part of the match,only_a_throughput_upgrade_changes_a_ratewhen an unknown value counts as an upgrade,an_upgrade_is_idempotent_and_never_lowers_a_ratewithout the never-lower check (made with the set under one lock),a_middleware_that_stops_the_event_stops_the_upgradepins the listener's place,the_logs_and_debug_name_no_numberthe logs (alone intests/throughput_logs.rs: its capture is a thread's subscriber, andtracingcaches call sites across threads), andwithout_following_upgrades_the_fixture_changes_no_rateoff by default. Decided by the coordinator (AGENTS.md § Decisions, each swappable): theThroughputUpgradesshape above (the directory a trait,NumberDirectory; the rate a setting); off by default, by construction (nothing follows an upgrade unless one is set up); matching on the WABA and the display number's digits (KnownNumbers, replaceable by anyNumberDirectory); in memory only, a restart or another replica back at the configured rate (documented; recording upgrades is the integrator's, noKvStorenamespace, which would be a stable identifier and is the owner's to name); every othereventvalue ignored and logged at debug level; never lowering a rate; and, as the bot's listener, running after the middleware, so a middleware that stops the event stops the upgrade (documented; the sink is the way around it).
- After: B2.
- Decisive: two
Pacers on oneMemoryKvStoreat 20 a second send 200 interleaved sends and never exceed 20 in any one-second window; replacing the compare-and-swap by a plain put fails it. The live runs on Redis and Postgres go through the store conformance suite. - Landed:
KvRateLimiter(modulepacer, opt-in:TokenBucketstays the default). Each number's record, underPACER_NAMESPACE(wa.bot.pacer, keyed by the SHA-256 of the length-prefixed phone number id, as the cooldowns' keys), is a 49-byte version 1 layout (a version byte, then the due time, the raised rate, the slow-down's divisor and times, the last slot's id and base), read and swapped on its version, expiringKvRateLimiter::idle_expiry(24 hours by default) after its last slot. Under contention it reads again after a jittered backoff (1 ms doubling to 64 ms), at most 8 times (KvRateLimiter::contention), thenStorageError::Busy; a store error, contention past the bound or a record of another version fails closed (the booking fails, nothing is sent: a broadcast endsBroadcastEnd::LimiterFailed). A throughput upgrade is shared:RateLimiter::raise_rate(a new method, defaulting to raising nothing) writes the raised rate into the record, checked against the record read under the same compare-and-swap so it is never lowered, andThroughputUpgradestakes any limiter (ThroughputUpgrades::newgeneric,ThroughputUpgrades::sharedoverArc<dyn RateLimiter>); a slow-down is shared too. The replicas' clocks are assumed synchronized (NTP): a skew lets up to⌈skew × rate⌉more into a one-second window (documented onKvRateLimiter), and the schedule never moves back. The decisive test, incrates/meta-whatsapp-bot/tests/shared_pacer.rs:two_pacers_on_one_store_never_exceed_20_a_second(the two replicas of each pair both read the record before either writes, driven by a store that yields after each read; with a plain put, 40 sends fall in one second). The others fail on their own mutation:two_bookings_racing_for_one_slot_both_succeed_in_different_slots(the plain put),contention_past_the_bound_is_busy_after_backing_off(the bound removed, or the backoff),a_raised_rate_is_shared_and_never_lowered(the never-lower check),a_store_that_fails_lets_nothing_throughandan_unreadable_record_fails_closed_and_is_kept(failing open),a_slot_given_back_is_reused_only_when_none_was_booked_after_it;an_idle_record_expiresthe TTL, and the unit teststhe_pacer_key_is_pinned_and_hashedandthe_pacer_record_is_pinnedthe namespace, key and bytes. The pacer needs no newKvStorebehaviour (get,put_if_absent,compare_and_swapwithExpiry::After), so the store conformance suite proves what it relies on, on Redis and Postgres; no live test runs the limiter itself there. A slot given back is reused only while it is the last booked. The namespace and the record layout wait for the owner's confirmation before the first release (§ Owner touchpoints). - Review (concurrency): a new record's slot ids start at a random value, so a slot of a record deleted or expired since gives back nothing of the record created after it (
a_slot_of_a_forgotten_record_gives_back_nothing). Tests added for what no test caught: a booking that lost the swap books as of its new read and never before its request (a_booking_that_lost_books_again_as_of_its_new_read,a_booking_that_lost_never_books_before_its_request), the burst, a multi-send cost, the divisor bound, a slow-down after recovery, an upgrade whose store fails, a seeded interleaving property of three replicas booking, giving back and racing (any_interleaving_keeps_every_kept_slot_an_interval_apart), and record round trips with far instants saturating. Documented: deleting the record (KvRateLimiter::store_key) is the remedy for an unreadable record, slots a clock stepped forward booked, and a raised rate to lower, and drops the slots booked; a raised rate is forgotten silently when the record expires. Not changed: with a burst above one, a slow-down (or a lower rate) while slots are booked re-reads the burst's tolerance at the slower interval, so the next bookings can start before ones booked earlier;TokenBucketdoes the same, and the default burst of one is not affected. - Decided 2026-11-01 (the coordinator, under the owner's delegation, AGENTS.md § Decisions; each a setting or behind
RateLimiter): it fails closed, a broadcast stopping onStorageError::Busytoo, the attempts and backoff a setting (KvRateLimiter::contention); the defaults of 8 attempts, a 1 ms base delay doubling to a 64 ms cap with jitter, and a 24-hour idle expiry (KvRateLimiter::idle_expiry); a number's rate is the higher of the configured one and the raised one;RateLimiter::releasegives back the last slot only, one level deep; the shared schedule never moves back; the nameKvRateLimiter. Pending the owner (a stable identifier): the namespacewa.bot.pacerand the version 1 record layout.
- After: B1.
- Decisive: two runners on one store send a due job once (replacing the compare-and-swap by a plain put fails it); a job scheduled before a restart is sent by a new runner on the same store; a resumed broadcast job never resends a recipient recorded as sent (removing the per-recipient marker fails it); a
131049recorded from a status webhook (the job keeps each message's id) makes a re-run within 24 hours skip that recipient and send everyone else. - Landed: row 90 done in the library (with Meta's own campaign schedules, L10b). The module
jobs:KvJobs(KvJobs::schedule,KvJobs::cancel,KvJobs::rerun,KvJobs::get,KvJobs::observe,KvJobs::feedback),Job,JobId,ScheduledJob,JobState,JobSummary,JobRunner(JobRunner::tick,JobRunner::run_until) and its builder,JobFeedback(the131049path as a bot listener or an event sink),TickReport,JobRun,JobEnd; five store namespaces underwa.bot.jobs, pending the owner's confirmation (architecture.md § Stable identifiers, § Owner touchpoints below). A job's message is not stored: a composer of the runner makes it from the job's JSON payload at each send, so every replica and every release after a restart registers the same one. The decisive tests, incrates/meta-whatsapp-bot/tests/jobs.rs:two_runners_on_one_store_send_a_due_job_once(both runners read the job at one version through a gate in the store; with the claim's compare-and-swap replaced by a plain put both claim and it fails; the send itself is guarded a second time by the recipient's marker),a_job_scheduled_before_a_restart_is_sent_by_a_new_runner,a_resumed_broadcast_never_resends_a_recipient_recorded_as_sent(fails without the per-recipient marker, written or read) anda_131049_status_makes_a_rerun_within_24_hours_skip_that_recipient(Meta's failed-status fixture; fails without the limited skip). Each of these fails a test too:a_crashed_runners_lease_holds_the_job_until_it_expires(the lease ignored),a_timed_out_send_is_marked_unknown_and_never_resent(a timed-out send resent),a_job_cancelled_while_it_runs_stops_before_its_next_send(no cancel check),a_job_indexed_while_the_cursor_moves_is_not_lostanda_rerun_into_the_minute_it_ran_in_is_not_lost(the cursor's second read, the page written again). The runner paces through anyPacer, B2b'sKvRateLimiterincluded (jobs_pace_through_a_pacer_shared_by_replicas). Not in it: a job-store trait (B3b; here the swap is theKvStoreadapter, and the runner's outbound, pacer, policy and composer), recurring schedules, a job's lines per recipient beyond its summary, and live runs on Postgres and Redis (the jobs use only the port the store conformance suite checks). - Sabotage review (concurrency, at-most-once, crash safety), each fixed with a test that fails without the fix:
a_runner_failing_right_after_moving_the_cursor_loses_no_job(a runner failing, or crashing, between moving the cursor and reading the minute again lost a job indexed meanwhile, for good: the cursor now keeps the minute's page versions and every tick reads it again first);a_job_due_long_ago_is_run_without_reading_every_minute_since(a due time a month past moved the cursor back a month, 86 410 reads for one job, the Unix epoch some 56 million: a due time already past is indexed under the current minute, its due time kept);an_unreadable_marker_is_an_outcome_unknown_not_a_stuck_runner(one unreadable marker failed every tick at that job, every later job waiting behind it: it is now an outcome unknown, never resent);a_long_marker_scan_keeps_the_lease(reading many markers outlasted the lease, and another replica took the job over, again and again with more replicas: the scan renews it);a_send_checked_before_a_cancel_and_a_rerun_is_not_made(a cancel and a re-run between a runner's check and its marker let the old run's runner send to someone the re-run had sent to, its marker being the old run's: the job is read again with the marker in place). Tests added for what held: a lease that expires mid-send while another runner claims the job, the marker stopping the second send (a_runner_whose_lease_expired_mid_send_never_sends_after_the_new_claim), the lease renewed between long sends (a_runner_renews_its_lease_between_sends), the cursor's second read before a tick moves on through more minutes, against a writer whose clock lags (a_job_indexed_behind_a_cursor_moving_on_through_minutes_is_not_lost; the first tick's own re-read covers only the minute just behind the cursor), a cancel racing a claim in both orders (a_cancel_racing_a_claim_sends_nothing), the 131049 skip still on two minutes short of 24 hours, and scoped to its job (a_131049_skip_is_scoped_to_its_job); the key pins are known answers computed outside the crate. Documented: at most once, not exactly once (a crash after the marker and before the request leaves that recipient unsent, reported unknown); leases read each runner's clock (skew, or a pause past the lease, costs a takeover, never a duplicate). Not changed: a tick runs its jobs one after the other (a long broadcast delays the jobs due after it) andrun_untilstops between ticks only; a job left unrun past its index pages' retention stays pending, never found (decided below). - Decided 2026-11-01 (the coordinator, under the owner's delegation, AGENTS.md § Decisions; each a setting, or swappable through the
KvStoreadapter until B3b): the namespaceswa.bot.jobs,wa.bot.jobs.due,wa.bot.jobs.sent,wa.bot.jobs.messageandwa.bot.jobs.limited; minute buckets of 256 ids a page (BUCKET_SECONDS,BUCKET_PAGE_CAPACITY); a 2-minute lease renewed at half (JobRunnerBuilder::lease); the131049skip scoped to the job, its 24 hours counted from the webhook's arrival (MARKETING_LIMIT_WAIT); retention of 7 days for message ids (KvJobs::message_ttl) and 30 days for settled jobs, markers and index pages (KvJobs::retention), a job left unrun past 30 days no longer found; job retries 5 × (30 s doubling to 30 min) (JobRunnerBuilder::retries,JobRunnerBuilder::retry_delay); 8 sends in flight per job (JobRunnerBuilder::concurrency); the default runner id of the process id, the time and a counter, each replica setting its own (JobRunnerBuilder::runner_id); D29's "the job store is a trait" met for now by the concreteKvJobs, swappable through itsKvStoreadapter, the trait itself B3b (design D29 amended); row 90 done in the library. Pending the owner (stable identifiers, before the first release): the five namespaces, their keys' derivations and their records' layouts (architecture.md § Stable identifiers, § Owner touchpoints below).
- After: B3.
- Decisive:
KvJobsimplements it, and a second, in-memory implementation passes the same job suite.
- After: B1, L5.
- Decisive: removing the age filter fails the test; the erased messages are gone from
messagesandconversationson memory and, live, on Postgres. - Landed: L5 gave the age (
purge_before,apply_retention); the cap per chat is a new port method,ConversationStore::trim_conversations(phone_number_id, keep_last)(no composition of the existing ones deletes some rows of one conversation): each conversation keeps its newest rows by(timestamp, id), every row counting (tombstones and redacted group messages too), and the summary goes when its latest message does (the newest rows all tombstones, or a cap of 0), as under a purge; window events, ownership records, contact removals, synced contacts and identity links stay (D35). Memory under its lock; Postgres in one statement under the existing purge lock, ranking on the existingmessages_conversation_idx: no migration and no new stable identifier. In the bot, moduleretention:RetentionPolicy(max_age,max_messages_per_chat), applied once (RetentionPolicy::apply) or on the bot'sTimer(RetentionSweeper, every hour by default, restricted to the bot's numbers withnumber). The decisive tests:the_age_filter_deletes_exactly_what_is_older_in_memoryand itslive_postgres_twin (crates/meta-whatsapp-bot/tests/retention.rs: disabling the policy's age, setting its cutoff to now, or removing the memory store's age filter fails them), anda_sweep_deletes_by_age_and_cap_from_messages_and_conversations_*(the chats gone from both, memory and live). The cap's off-by-ones, a trim that leaves the summary, and a reversed tie order fail the conformance casetrim_keeps_the_newest_rows_of_each_conversation(memory, and live under both erasure modes); a trim deleting the summary from its own snapshot failslive_postgres_a_trim_keeps_the_summary_an_append_moved; a trim in two statements, which loses the message appended between them, failslive_postgres_trims_racing_appends_keep_every_new_message(three runs of three); a trim without the purge lock failslive_postgres_the_locks_are_the_documented_ones. Not caught: the key order of the summaries' locks (the ranking already yields them in key order); in memory a trim cannot race an append (one lock). SQLite and Redis implement it since L8's merge of B4: SQLite in oneBEGIN IMMEDIATEtransaction (summaries, then rows, on one ranking), Redis in one script per conversation of at most 1024 rows (several for a conversation further over its cap), over a new set of each number's conversations with a history (B:c:<pn>); both pass the trim cases, andlive_redis_a_trim_beyond_one_script_takes_severalfails a trim stopping after one script. The server compiles and passes unchanged (it implements noConversationStore); retention over HTTP is M5k. The sabotage review added what three surviving mutations showed missing: a cap shared by the same contact on two numbers fails the conformance case (its every-number part), a sweeper sweeping only its first number failsthe_sweeper_sweeps_every_number_given_and_no_other, and a sweeper stopping at its first failed sweep (or logging more than counts) failsretention_logs.rs. A trim of one number deletes by that number's range of the index, no longer by a scan of every number's rows; the cost of a trim, and that a deleted summary takes its open window with it, are documented on the port and the Postgres store. - Decided 2026-11-01 (the coordinator, under the owner's delegation, AGENTS.md § Decisions; each swappable: the policy is the bot's, over the port, and a deployment calls the port itself for another): the name
RetentionPolicy; a store-wide method shape mirroringpurge_before(one number or every number); a required port method, no default; every row of a history counting toward the cap, tombstones and redacted group messages too; the summary rule of a purge (deleted with its latest message), a cap of 0 allowed; bothRetentionPolicy::applyandRetentionSweeper; the age applied first, then the cap, a sweep stopping at its first error. Not changed, the owner's (§ Owner touchpoints): D35 and L5's erasure semantics; a trim applies L5's rules (identity links and synced contacts kept, a deleted message's id free again, the summary going with its latest message), and adds no rule of its own.
3. Library gap batches
By crate and topic; each is one PR through the review pipeline, with ScriptedTransport tests asserting method, path, query, token and exact JSON from Meta's pages. A batch whose pages are not in the mirror (parity.md's (nm)) starts by reading them.
- Kind: additive, plus one breaking type change (the public field
OnboardingRequest::codebecame anOption<SignupCode>; before the first release, in the CHANGELOG). - After: nothing.
- Decisive: a new process resumes from stored session info alone; sending a placeholder code to Meta fails the test's request assertion.
- Landed:
OnboardingRequest::for_resume(session), with no code;onboardandonboard_with_approvalrefuse a code-less request with aValidationErroroncodebefore any request. The decisive test isa_new_process_resumes_from_the_stored_session_info_alone(embedded_signup::onboard): a fresh client and vault on the first process's store resume from the serializedSessionInfo, and its two requests (subscribe, register) are asserted exactly, before the error the same request gets fromonboard; afor_resumethat put a placeholder code in would make thatonboardsend a third request, the code exchange, and fail the request assertion.a_code_less_coexistence_resume_never_registerspins the coexistence refusal on that path (finish_kindset from what was saved). The constructors are unchanged. OPEN_QUESTIONS #10 is closed and deleted (CHANGELOG). No parity row changes: parity row 120 and coverage row 1 already countedresume.
- Kind: additive, plus one breaking type change (the public field
- Kind: port change.
- After: nothing.
- Decisive: new conformance cases for each part; memory and Postgres pass; a Postgres erase that leaves the message bodies fails live; the lookup case fails an adapter that finds a message of another number; an erasure through
identitiesof a BSUID leaves nothing of the person's thread under their phone number, and the live sweep of every table fails when the erasure's sender clause (their group message) is removed. - Landed: row 91 partial in the library (purge by age, retention set per store, erasure of a person on one number; no cap per chat and no bot that deletes on its own: B4). Rows 69, 111 and 139 stay partial: the inbox records what the port now keeps (synced contacts, window events, thread ownership, identity links) in L7 and L8, and there is no Redis or SQLite store (L8) nor thread control API (L15). OPEN_QUESTIONS #33 stays open. The erasure's defaults and stored names wait for the owner before the first release (§ Owner touchpoints).
- Kind: additive (on L5's port).
- After: L5.
- Decisive: after a scripted call,
Inbox::replysends a free-form reply it refused before; aftercontrol_taken, a reply is refused locally with zero requests; after auser_id_update,Inbox::identitiesof the new BSUID holds the previous one; each fails when its recording is removed. - Landed: the decisive tests and the rules they rest on run on the memory store and live on Postgres (
crates/meta-whatsapp-rs/tests/inbox_events.rs;just test-livenow runs the facade'slive_postgres_tests). The override is per inbox,ReplyChecks(the window check, its trust in a handover to this app, and the ownership check, each on or off; per call through a clone of the inbox). A handover is recorded under the conversation its phone number leads to (the identity links, else a synced contact's BSUID, else the phone number). The coordinator's rulings on the review (2026-09-27): the ownership refusal has anErrorKindof its own,ThreadOwnedElsewhere(no Graph code yet), which the service answers409 thread_owned_elsewhere; each recording is a switch (InboxSink::with_recording), the rules are public (call_window,call_key,call_status_key,handover_key) and the idle timeout a setting (Inbox::with_thread_idle_after), rather than a trait; after a handover to this app newer than the customer's last recorded message, the window check lets Meta decide (ReplyChecks::trust_handover, OPEN_QUESTIONS #44); auser_id_update'swa_idis linked too. Row 139 stays partial (the thread control API, L15);conversation_contextis not stored.
- Kind: additive (new adapters, a new feature).
- After: L5.
- Decisive: every adapter passes both conformance suites; a second
appendof one id on another number returnsfalseon each, as the suite says today; the synced contacts are read back from the store. - Landed:
store::{SqliteKvStore, SqliteConversationStore}(featuresqlite,store::sqlite::{open, memory, migrate}) pass both suites in process, injust test(an in-memory database, and a file shared by a pool of connections under another prefix);store::RedisConversationStorepasses the conversation suite live (live_redis_conversation_*, both erasure modes and a retention). The suite gainsconversation_conformance::ids_are_stored_once_per_store, outsiderun(the port allows either answer to OPEN_QUESTIONS #33, which stays open): the memory store and both new stores pass it injust test, Redis and Postgres live; it fails a SQLite store whose id index is not unique, and a Redis store checking ids per number.InboxSinkrecordssmb_app_state_sync, andcrates/meta-whatsapp-rs/tests/synced_contacts.rsreads Meta's examples back throughInbox::contactsandInbox::contacton memory and SQLite (it fails when a removal is applied under one key only). Rows 69 and 111 are done in the library (the service's sides stay with M3b and M2a); categories 30 and 39 too. The sabotage review addedconversation_conformance::erase_one_person_of_every_recordandpurge_one_person_of_every_record(outsiderun), after which each adapter scans its own storage for what must be gone (Redis kept a redacted group message's sender and index members naming the person, with every port read passing), andrun'san_unbounded_limit_reads_everything(Redis refusedusize::MAX). - Decided 2026-11-01 (the coordinator, under the owner's delegation, AGENTS.md § Decisions; each swappable behind the
KvStoreandConversationStoreports, or a switch):- SQLite through sqlx's driver, the library compiled in (
sqlite-bundled: MIT, no TLS); sqlx's Postgres driver, its TLS and its macros move from the dependency to thepostgresfeature, sosqlitealone links neither (just featureschecks both alone). Its migrations are embedded withinclude_str!frommigrations-sqlite/, templates under the sameTablePrefix, nowstore::TablePrefix(stillstore::postgres::TablePrefix), and each prefix keeps its own migration history (<prefix>sqlx_migrations). Swappable: another store behind the same ports. - SQLite concurrency is SQLite's write lock: every write one
BEGIN IMMEDIATEtransaction (read, decide in Rust, write), the busy timeoutstore::sqlite::opensets (30 s) the wait; timesINTEGERmicroseconds, identifiersTEXTrefusing U+0000 (as on Postgres), contentBLOB; the key/value store's expiry on the application's injectable clock (SqliteKvStore::with_clock), its versions from one counter row, so a deleted record's row goes at once. Swappable by building the pool yourself (store::sqlite::sqlx). - One Redis hash tag per conversation store (
<prefix>{conversations:<prefix>}, so stores of other prefixes hash to other slots: inboxes spread with one store per prefix,with_prefix): every write one Lua script over keys of one slot. Status updates are a compare-and-swap decided in Rust (DeliveryStatus::supersedes); a purge of every number is one script per number over the store's set of numbers, which never shrinks (it names businesses only). Times are 16 hex digits that sort as they compare. - The inbox's contact rules, as built: a switch,
RecordingSwitches::contacts, on in the library'sRecordingSwitches::ALL; anaddstored underinbox::synced_contact_key(the BSUID, else the phone number without+, which is also the phone number stored, so thatidentitiesmeets the conversations' keys); aremoveunder each key it names (the BSUID and the phone number), so that an entry keyed by the phone number before Meta sent the BSUID does not keep the person's names; a change withoutmetadata.timestamp, without a usable id or with U+0000 in one is skipped with a warning, without content (Meta's order is the timestamp; the sink's clock would let a late redelivery win). Aremovenaming only a phone number leaves an entry keyed by the BSUID (Meta's reference shows a removal with both). - Category 39's library side is done; MongoDB adapters stay with S17.
- The service records no synced contacts until M3b:
meta-whatsapp-server-core'sinbound::INBOX_RECORDINGis the library's switches withcontacts(false), the events still reaching their tenant (decisive:conformance::webhooks::an_address_book_change_reaches_its_tenant_and_no_inbox); M3b turns it on with the routes that read them.
- SQLite through sqlx's driver, the library compiled in (
- Pending the owner's confirmation before the first release (architecture § Stable identifiers): the new stored names, SQLite migration 1 (
wa_kv_version, the SQLite tables and their columns, its checksum pinned) and the Redis conversation layout (keys, fields, the hash tag, the time encoding).
- Kind: additive, plus one breaking type change (
templates::HealthStatusbecame the sharedclient::common::HealthStatus, itscan_send_messagetyped; before the first release, in the CHANGELOG). - After: nothing.
- Decisive: exact JSON per field from
reference/whatsapp-business-phone-number/*; dropping any one field from its body fails its test. - Landed: rows 66, 129–133 and 151 done in the library; the service's side stays with M5c3. One
client::common::HealthStatusserves numbers, WABAs and templates (templates::HealthStatusis that type now, itscan_send_messagetyped); Meta documents nohealth_statuson the business node, so a business's status is theBUSINESSentity of the others. The contact book deletion is an explicit call nothing else in the library makes. Bot details take a newcore::ids::WabaBotId. Not offered, for want of a documented field: withdrawing an Official Business Account application. The username errors147001–147005are not classified inErrorKindyet. The number's settings it leaves (payload encryption,connection_status,webhook_url) are L26.
- Kind: additive, plus one breaking type change (
- Kind: additive (a security-sensitive change).
- After: nothing.
- Decisive: the endpoint's request reaches
api.facebook.comwith the token; any other path on that host is refused before a request (removing the path restriction fails the test); the allow list's existing tests pass unchanged. - Landed: row 152 done in the library; the service's side stays with M5c4.
Business::parent_bsuid_accountreturns aParentBsuidAccount(the account id a string: core has no id type for it). The allow list is data now, one rule per line next toGraphRequest: the media host's rule as it was, andGET https://api.facebook.com/{digits}/parent-bsuid-accountswith no version, query, fragment or user info, nothing else on that host. Neither rule follows a configured Graph proxy (the token goes to the proxy, the media host and this URL); routing every request through a proxy is the transport's. What Meta answers for a portfolio that is not enrolled is not documented (a decode error withoutparent_bsuid_account_id). A redirect cannot carry the token past the rule: the stockReqwestTransportfollows no redirect that would keep a request's credentials (same scheme, host and port), and theHttpTransportport states that rule;GraphRequest::headerrefuses credential headers, so the rules cannot be stepped around (both found in the L10a reviews, CHANGELOG § Security).
- Kind: additive.
- After: nothing.
- Decisive: exact JSON per endpoint; a non-idempotent creation is never retried after a timeout (
RetryPolicy); a system user token never reachesDebug(a sentinel test). - Landed: rows 127 and 146–148 done in the library, row 90 partial there (Meta's schedules wrapped, the bot's durable ones are B3); the service's sides stay with M5c4, M5j and M5k. The decisive tests, in
client::waba's modules: the exact bodies and queries (accounts::create_sends_the_documented_body_and_parses_the_answer,system_users::a_client_token_is_fetched_with_the_proof_of_the_system_token,billing::the_intent_sends_the_pages_example,schedules::create_sends_every_documented_fieldand a test per read); no replay after a timeout (accounts::a_creation_is_never_replayed_after_a_timeout, and the same for a schedule, a migration intent, a resume and a token generation; afetch_onlytoken read, read-only by Meta's page, is replayed as a read); the sentinel (system_users::a_system_user_token_never_reaches_debug_or_an_error: the token, the system token, the app secret and the proof reach noDebugand no error). The client business's token takes theappsecret_proofof the partner's, computed from the app secret and sent in a form body (embedded-signup/hosted-es), its parameters fromfacebook-login/facebook-login-for-business(§ Get tokens); L11a builds on it. The billing migration's intent and resume are explicit calls no other call makes. Not found or not offered: an API call for partner-initiated creation (the page describes a Business Suite flow only); the creation'son_behalf_of_business_id(the On-Behalf-Of model is deprecated);GET /{BUSINESS_ID}/whatsapp_business_accounts(not in the item; itsbusiness_typearray has no shown encoding); where a message history id or a schedule'saudience_idcomes from (no page says). Message history, migration, schedule and solution ids are strings: core has no type for them yet. Decided by the coordinator (2026-11-01): the billing migration is not a legal act in AGENTS.md's sense (it accepts no terms), so it stays two explicit calls and no operator gate; afetch_onlytoken read is replayed (facebook-login-for-business: "The operation is read-only"); the token request goes in a form body, never the query; the new ids stayString; business users are keyed bySystemUserId; the Line of Credit id goes with the intent (extended_credit_id), as the requests show; row 90 is partial; the overlap with L12, which landed first (PR #67), is reconciled here:WabaSolutionkeeps an optional id (aSolutionId, returned only whenfieldsasks) but takespartners::solutions'SolutionStatus,PendingRequestStatusandSolutionApp, and the payment-method migration's status isPaymentMigrationStatus, apart from the WABA migration intent'spartners::migration::MigrationStatus. Found in review: system users are read from the business portfolio (Business::system_users,GET /{BUSINESS_ID}/system_users), the edge the Marketing API's Business node documents, not from the WABA the guide's request prints (its prose asks for a business id); a non-2xx answer to the token request never quotes its body.
- Kind: additive.
- After: nothing.
- Decisive: a scripted
PARTNER_ADDEDleads to a token request carrying theappsecret_proofof the app secret (a wrong proof fails the test's request assertion), and to the same stored record the code flow writes. - Landed: row 121 done in the library (the service's side stays with M3f).
HostedSignup(the webhook'swaba_info.waba_idandowner_business_id, and the token request,fetch_onlyby default) andEmbeddedSignup::onboard_hosted/onboard_hosted_with_approval: stepfetch_token(Business::system_user_access_tokenon the customer's portfolio, with the system token passed in and the app secret ofAppCredentials, borrowed) in place ofexchange_code, then the code flow's steps, shared with it (onboard_granted);verify_assetsalso refuses a WABA whose owner, as Meta reports it, is another business than the webhook's. Resumed byresumewithOnboardingRequest::for_hosted. The decisive tests, inembedded_signup::hosted:a_partner_added_fetches_the_token_with_the_proof_and_onboards(the form body exactly, the proof both a fixed vector computed withopenssl dgst -sha256 -hmacand an HMAC the test computes),a_wrong_app_secret_sends_another_proof,the_stored_record_is_the_code_flows(the same keys, clear fields and sealed record but the random nonce), a failure at each step beforestore_tokenre-run and after it resumed,a_fetch_is_replayed_and_a_generation_is_not; and through the webhook parser, the skill'shosted_signup.rs(Meta'sPARTNER_ADDEDexample, verbatim). Found on the way: the webhook names no tenant and also comes for code-flow onboardings, and the hosted page asks for no PIN; both are the integrator's, documented in the guide. Found in review:PARTNER_ADDEDcomes for every WABA shared with the app (the code flow's and coexistence's onboardings too) and may be delivered again, andstore_tokenreplaces a record (as the code flow's re-onboarding must), so a hosted run after a code-flow onboarding would have replaced its token andcreated_at(and with it a Solution Partner's recorded approval) unasked;onboard_hostednow refuses a WABA the vault holds, before any request (a presence read, an unreadable record counting as held;a_waba_the_vault_holds_is_refused_before_any_request), and re-onboarding one is deleting it first. A read, not a lock: a code-flow onboarding still running when the webhook arrives can store after it; the approval's atomic reservation closes that window, and the rustdoc says so. - Decided 2026-11-01 (the coordinator, under the owner's delegation, AGENTS.md § Decisions; each swappable): the token request is
fetch_only=trueby default, swapped per signup byHostedSignup::token_request(a fetch is replayed as a read, a generation never is); the system user token is an explicit argument ofonboard_hosted, not held byEmbeddedSignup; a WABA whose owner, as Meta reports it, is another business than the webhook's is refused, and when Meta reports no owner none is stored, as in the code flow;debug_tokenruns on the fetched token, on the assumption that hosted signup's business tokens carrygranular_scopesnaming the WABA, as the code exchange's do (one that does not failsverify_assetsongranular_scopes, closed); the integrator's, documented: re-onboarding a WABA already held (delete it first; the library refuses it otherwise), the tenant binding (in the approval) and the PIN; no facade helper (the glue stays in the skill'shosted_signup.rs); the skill not re-stamped. The library's side of OPEN_QUESTIONS #11 is decided; the service's is M3f.
- Kind: additive.
- After: nothing.
- Decisive: with the option off, a signup granting two WABAs onboards only the claimed one; with it on, both, and a failure on the second leaves the first stored and resumable.
- Kind: additive.
- After: nothing.
- Decisive: exact JSON per endpoint; a pre-verified phone id is checked as digits before any request.
- Kind: additive (a new default step;
needs_coexistence_sync()stays). - After: nothing.
- Decisive: a coexistence onboarding requests the sync exactly once; with the option off, none; an onboarding without coexistence sends none.
- Landed: two steps, not one, because Meta's page has two once-only calls:
steps::SYNC_CONTACTS(sync_contacts,sync_typesmb_app_state_sync) thensteps::SYNC_HISTORY(sync_history,history), the last of the tail shared byonboard,onboard_hosted(whosefor_hostedrequest names no finish, so it never reaches them) andresume, aftersubscribe_appand a Solution Partner's credit line (Meta's order:onboarding-business-app-usersstarts the sync once the onboarding steps are done). They run for a coexistencefinish_kindunlessOnboardingRequest::coexistence_sync(false); Meta's answers areOnboarded::coexistence_sync(CoexistenceSync,SyncOutcome). Meta refuses a second request (2593107) and a late one (2593108), and documents no way to read whether one was made, so each request is claimed in a record before it is posted (COEXISTENCE_NAMESPACE,wa.es.coexistence, keyed by phone number, scoped to the stored token record so that a new signup syncs again; a new stored identifier, pending the owner's confirmation) andresumenever posts one that was accepted, refused, or whose answer was lost; a Meta refusal is reported asSyncOutcome::Refused, not as a failure. The decisive tests, inembedded_signup::onboard:coexistence_resolves_the_number_and_never_registers(each request once, exact path, bearer and body, after the subscription),with_the_option_off_no_sync_is_requested,an_onboarding_without_coexistence_sends_no_sync(the Cloud API and WABA-only flows,for_resumeandfor_hosted); witha_resume_never_requests_an_accepted_sync_again(on a clock off the whole second: the record keeps the vault's seconds),resume_after_a_refused_request_asks_only_for_what_is_missing,a_timed_out_sync_is_never_replayed(retries on, thenresume),a_sync_meta_refuses_is_reported_and_not_asked_again,a_new_onboarding_of_the_number_syncs_again(one second later: the scope's precision),the_sync_record_is_pinned; and, from the review,a_claim_lost_to_a_concurrent_attempt_posts_nothing(both theput_if_absentand thecompare_and_swapclaim),an_answer_is_never_recorded_over_a_later_onboarding,a_resume_never_takes_over_a_later_onboardings_recordanda_4xx_releases_the_claim_and_a_5xx_keeps_it(a 409 releases, a 500 with a Graph body keeps). Hosted signup syncs nothing: Meta's hosted page onboards to Cloud API only. M3b builds on the steps rather than callingsmb_app_dataitself (OPEN_QUESTIONS #7). - Decided 2026-11-01 (the coordinator, under the owner's delegation, AGENTS.md § Decisions; each swappable): the record is
wa.es.coexistence, keyed by phone number id, plain JSON of layoutv1 (the WABA, the token record'screated_atin seconds, apending,requestedorrefusedslot per request), its namespace and layout still pending the owner's confirmation before the first release (docs/architecture.md§ Stable identifiers, § Owner touchpoints); two steps, two names (sync_contacts,sync_history), as Meta has two once-only calls; placed after the credit-line steps, in Meta's order (the sync begins once onboarding is done), at the cost that a failure before them holds them back within Meta's 24 hours (the guide says so); Meta's refusals (SyncNotAllowed:2593107,2593108) reported asSyncOutcome::Refused, not as a failure; a failure releases its claim only when!may_have_been_sent()(a 4xx: Meta's errors page gives codes, not statuses, for this endpoint); a lost answer, or a process stopped between claim and answer, keeps the claim, and the way out is manual and documented (check the webhooks, resume with the sync off, ask again yourself: Meta refuses a repeat), a call that clears a pending claim being a follow-up, not scheduled; the option isOnboardingRequest::coexistence_sync(bool), on by default, and the record never expires; M3b builds on these steps (OPEN_QUESTIONS #7, confirmed). Swappable by the option (and byPhoneNumber::sync_smb_app_datacalled yourself).
- Kind: additive (a new default step;
- Kind: additive.
- After: nothing.
- Decisive: under a fake clock, a token expiring inside the lead time is reported and one expiring after it is not; removing the comparison fails the test.
- Landed: row 6 done for the library.
TokenVault::token_expiry(waba_id, lead)answers, on the vault's clock, with aTokenExpiry:Never(noexpires_at: Meta reported none, or0),Fresh,ExpiresSoon { expires_at, remaining }(now + leadincluded) orExpired(expires_at <= now, asStoredBusinessToken::is_expired);is_due()is the last two.DEFAULT_EXPIRY_LEADis 7 days (business tokens default to never expiring; one generated to expire lasts 60 days). It reads the record's clearexpires_atonly: nothing is decrypted, no key is needed, nothing is written (no re-encryption on read), and the stored format (wa-rs/token-vault/v1) is unchanged; the clear copy is not authenticated, soStoredBusinessToken::expirygives the same answer on a tokengetdecrypted. A record naming another WABA or of another version is refused. The vault cannot list its records: the caller walks the WABAs it holds, and M3e walks the service's bindings as its vault rotation does. The decisive tests, inembedded_signup::vault:a_token_inside_the_lead_is_reported_and_one_after_it_is_not(aManualClock; inside, outside, exactly at the lead, expired, atnow, absent; the clock moved across the boundary) andthe_boundaries_of_the_lead_time; withthe_expiry_check_decrypts_nothing_and_writes_nothing(a record under a key the vault lacks still answers; a record under a previous key is not rewritten),an_absent_expiry_never_expiresandthe_expiry_check_refuses_what_is_not_this_wabas_record. Removing the lead comparison,<for<=at either boundary, and an absent expiry read as expired each fail them.
- Kind: additive.
- After: nothing.
- Decisive: exact JSON per endpoint; the solution token never reaches
Debug(a sentinel test). - Landed: rows 144 and 145 done for the library, in a new module
client::partners:Client::partner_solutions(partners::solutions::PartnerSolutions:create,accept,reject,send_deactivation_request,accept_deactivation_request,reject_deactivation_request,get,partners,app_solutions,connected_client_businesses, each list with a…_stream, andaccess_token) andClient::migration_intents(partners::migration::MigrationIntents:create, which isset_solution_migration_intent, andget); core gainsids::SolutionId(numeric) andids::MigrationIntentId(opaque). The solution token is the client's business integration system user token, a bearer credential for the Graph host already allowed: anAccessToken, decoded withsend_withheld. No page ties a call to terms or an agreement; each state change is its own call, and accepting or rejecting a solution cannot be undone. Decisive tests (partners::tests):create_sends_the_embedded_creation_body,create_migration_intent_matches_the_guides, the guide's answers (get_before_accepting_matches_the_guide,app_solutions_match_the_guide,partners_match_the_guide,get_migration_intent_matches_the_guide,deactivation_answers_decode_in_both_shapes), the sentinel testsaccess_token_reads_both_shapes_and_never_shows_the_tokenandan_access_token_answer_that_does_not_decode_is_withheld, andstate_changes_are_never_replayed; the lists joinrequest::paging_withheld_tests. Where the pages disagree (the token's answer wrapped indataor bare, a deactivation answeredsuccessor with the solution) both shapes decode. A WABA's solutions (GET /{WABA_ID}/solutions) are left to L10b. Not wrapped:POST /{WABA_ID}/migration_intent, from an unindexed reference page with no example that the guides do not use; thefieldsparameter of the two deactivation answers. - Decided (by the coordinating agent, each swappable): the core ids (
SolutionIdnumeric, from the Solution Details API's "must be a valid numeric string";MigrationIntentIdopaque, no page gives its shape); the module and accessor names (client::partners::{solutions, migration},Client::partner_solutions,Client::migration_intents); both answer shapes of the token ({"data": [...]}and the bare object);CreateSolution::validatechecking only what the pages agree on (a name, andMESSAGINGon at least one side: the creation reference allows one side, the embedded creation guide's example gives it to both); page sizes 1 to 100 (the list references); roles not checked locally (the pages disagree on who rejects and who answers a deactivation request; Meta decides from the token). - Hardened in review: a non-2xx answer of the token's endpoint that is not Meta's error envelope keeps its body out of
Error::Http(a_failed_token_answer_never_quotes_its_body), and a blankaccess_tokenis refused like an empty one (malformed_token_answers_are_withheld). - Follow-up:
LaunchOptions::solution_idstays aString; typing it asSolutionIdis a breaking change to the Embedded Signup builder, left for its own item.
- Kind: additive.
- After: nothing.
- Decisive: exact JSON per endpoint; a max-price send makes no agreement request (a transport scripted for the send alone fails on any other call). As first written it also checked the 15-entry allow-list cap locally; that test cannot hold: Meta's
marketing-messages/changelogof 2026-10-01 removed partner allowlisting with the open beta, so there is no allow list and no cap (the local cap checked instead is the 240 country multipliers, below). - Landed: rows 137 and 138 done for the library, against Meta's pages of 2026-11-01 (
marketing-messages/pricing,marketing-messages/pricing/enroll-max-price,marketing-messages/pricing/duplicate-templates,marketing-messages/changelog,ctwa/welcome-message-sequences). Inclient::marketing: the agreement onMarketingBusiness(sign_max_price_agreement,max_price_agreement), duplicating a template (MarketingAccount::duplicate_template, aDuplicateTemplatewith aMaxPrice, answering aDuplicatedTemplatewith thesource_template_idthe guide says the response carries), reach estimates (MarketingAccount::reach_estimate), a template's max price (Client::marketing_template:MarketingTemplate::max_price,set_max_price,clear_max_price) and the welcome message sequences (Client::welcome_sequences:WelcomeSequences::create,update,list,get,delete); core gainsids::WelcomeSequenceId(opaque). The allow list is gone: Meta's changelog raised the partner cap from 15 to 100 (2026-07-31) and 500 (2026-09-07), then removed allowlisting with the open beta (2026-10-01): a partner's agreement covers the client WABAs shared with or managed by it, and no current page shows the allow list's request, so it is not wrapped and there is no cap to check; the cap checked locally isoptimization_spec's 240 country multipliers (marketing::MAX_COUNTRY_MULTIPLIERS). The agreement is isolated:sign_max_price_agreementis the one call that sends itsPOST, never replayed (whatever the client'sRetryPolicy: not after a timeout, nor on Meta's HTTP 429 within 5 minutes or a throttling error, which the policy resends for other calls), and an error naming the missing agreement (100, a duplicate's 403) is returned, not answered by signing. Its errors keep the signer out: a decode error carries no body, and anError::Http(an answer that is not Meta's envelope) a placeholder (a 429 isErrorKind::RateLimited; Meta's wait is 5 minutes from the last email). Decisive tests (marketing::tests_max_price):a_max_price_send_makes_no_agreement_request(one scripted response, the send's),no_other_max_price_call_signs_the_agreement,only_the_agreement_module_names_the_agreement(no code line of the client's, the facade's or the bot's sources but this module's names the call or its path, called or not),signing_the_agreement_posts_the_documented_body,signing_is_not_replayed_after_a_timeout,signing_is_not_replayed_on_a_429,a_failed_agreement_answer_never_quotes_the_signer,duplicate_matches_the_documented_example,set_max_price_matches_the_documented_update,clear_max_price_sends_exactly_the_two_nulls,template_max_price_reads_the_documented_list_shape,reach_estimate_matches_the_documented_example,a_max_price_is_checked_against_the_documented_limits(240 entries pass, 241 do not; multipliers in (0, 10]; alpha-2 keys;bid_amount1 toi32::MAX); inmarketing::tests_welcome, the guide's create, update, list, get and delete examples andcreate_is_not_replayed_after_a_timeout. Row 154 stays partial: no page of 2026-11-01 (templates/components,templates/template-media,marketing-messages/features,marketing-messages/send-marketing-messages, the MM API and Messages API references) shows a GIF header's send-time parameter, so none is typed (templates::Parameter::Othercarries one verbatim). - Not wrapped: the allow list (withdrawn, above); the WhatsApp Manager visibility switch (
whatsapp_manager_marketing_messages_max_price_enroll_status,marketing-messages/pricing/whatsapp-manager-visibility, read in review on 2026-11-01: a field of the WABA, read withfields=and set byPOST /{WABA_ID},OPT_INorOPT_OUT, which shows or hides the max price screens in WhatsApp Manager and changes neither eligibility nor API settings; no legal act, outside row 137's definition, left for a follow-up);optimization_specontemplates::TemplateDefinitionat creation (a template takes its max price after creation withMarketingTemplate::set_max_price, or as a duplicate); anErrorKindfor131061,131065and the welcome sequences'4027xxxcodes (core's error tree, not this item). - Decided 2026-11-01 (the coordinator, under the owner's delegation; each swappable, the agreement's isolation aside, which is AGENTS.md's rule for legal acts and OPEN_QUESTIONS #45's decision): a
bid_amountof0is refused; country keys are checked for shape only (two capital letters), whether the code exists is Meta's check; the welcome sequences' form sends plain values (curl strips-F 'name="…"''s quotes); the welcome list reads a bare array or{"data": […]};WelcomeSequences::updateis idempotent (it sets the values it names), while create, a duplicate,set_max_price,clear_max_priceand the agreement are not; blank sequence ids are refused; the signer's email is not checked for shape andMaxPriceAgreementSigner'sDebugis not redacted (personal data, not a secret), but neither the name nor the email reaches an error (above); a duplicate may carry country multipliers; the names as landed;Client::marketing_template's max price API stays.
- Kind: breaking (a new field on
ConnectCall, whose fields are public; accept's options can come as an additive method). - After: nothing.
- Decisive: exact JSON from those pages; a connect without the options sends neither key.
- Landed: row 135 done for the library.
ConnectCallgainsrecording: Option<CallRecording>andtranscription: Option<CallTranscription>(with setters of the same names), andCalling::accept_withtakes them inAcceptOptions(Calling::acceptunchanged: it sends neither). Both objects arestatus(ENABLED/DISABLED, reusingcalling::settings::FeatureStatus),purposeandannouncement_language; off unless the caller sets them, onconnectandacceptonly (the pages name no other action). Decisive tests (calling::tests): the pages' four request examples as exact JSON (connect_with_recording_matches_docs_example,connect_with_transcription_matches_docs_example,accept_with_recording_matches_docs_example,accept_with_transcription_matches_docs_example) andconnect_and_accept_without_options_send_neither_key. Checked locally (recording_limits_are_enforced,transcription_limits_are_enforced): withENABLED,purposeandannouncement_languagepresent and not blank;purposeat most 250 characters (calling::recording::PURPOSE_MAX_CHARS); a given language not blank. Not checked locally: the list of announcement languages (Meta's to judge, and it changes). Neither action is replayed after a timeout (accept_with_options_is_never_replayed). The results already arrive as typed webhooks (call_recording_available,call_transcription_available); the downloaded transcript document is not typed. Decided with it (coordinator, owner's delegation; each swappable as noted):ConnectCall,AcceptOptions,CallRecordingandCallTranscriptioncarry no#[non_exhaustive], as request types (OPEN_QUESTIONS #28); the language list is not enforced locally (Meta's, and it changes; a local check is one more rule incalling::recording); each object is validated on its own, even though Meta ignores the transcription'spurposeand language when both are enabled (both pages require them; dropping the check is a one-line change); a whitespace-onlypurposeor language counts as empty;statusreusescalling::settings::FeatureStatus(its own enum later is additive if Meta's values diverge); the accept side is a separateAcceptOptionsstruct for an additiveaccept_with, leavingaccept's signature as it was. The 250-character limit applies whatever thestatus(the pages' error table states it without one), and onlyENABLEDrequires apurpose(only_enabled_needs_a_purpose_and_length_counts_characters, which also pins the count in Unicode scalar values).
- Kind: breaking (a new field on
- Kind: additive.
- After: nothing.
- Decisive: exact JSON from
conversation-routing/thread-control. - Landed:
meta_whatsapp_client::thread_control, reached withclient.thread_control(phone_number_id):ThreadControl::pass(&user, &Pass),take(&user, metadata)andrelease(&user), each aPOST /{phone-number-id}/thread_controlwith itsaction, answeringThreadControlResponse(messaging_product,request_id).Passis untargeted (Pass::to_escalation_partner, the default: nocontrol_pass) or names aTargetRole(Pass::to_role: the page's six values,Other(String)for a later one), withmetadata. Row 139 stays partial for the library: the Routing Roles API (L15a). Decisive tests (thread_control::tests): the page's four request examples and its response as exact JSON (untargeted_pass_matches_docs_example,targeted_pass_matches_docs_example,release_matches_docs_example,take_matches_docs_example), the two handover examples'metadataas sent by the pass and the take, every target role's wire name (every_target_role_has_its_wire_name) and a BSUID inrecipienton all three (a_bsuid_is_sent_as_recipient). Checked locally, before any request:metadataat most 2,000 characters (thread_control::METADATA_MAX_CHARS, Unicode scalar values); exactly one non-empty identifier, soRecipient::PhoneAndUserand a group are refused (exactly_one_user_identifier_is_required); a non-empty target role;control_passonpassonly, by construction. None of the three is replayed after a timeout (no_action_is_replayed_after_a_timeout). Not checked locally: who owns the thread, the escalation role, whether a target is enabled (Meta's errors say which). Thecontrol_passedandcontrol_takenevents were already typed (ThreadControlChanged, with fixtures of this page's examples); the inbox does not call the API itself (its callers record their own calls: themeta-whatsapp-rs-cms-inboxexample now makes the pass). With it, the page's error131070(a service message without owning the thread, idle threads included) maps toErrorKind::ThreadOwnedElsewhere, the inbox's local refusal's kind, so the service answers it409 thread_owned_elsewhereinstead of502 unknown(metas_ownership_refusal_has_the_local_refusals_kind,classification_table_spot_checks, and the service'seach_kind_answers_its_code_and_no_meta_text);2494191(atakeby a caller that is not the escalation partner) staysUnknown, notPermission, which names the token's permissions and asset access while this is the business's routing role (take_by_a_non_escalation_partner_keeps_its_code). - Decided 2026-11-01 (the coordinator, under the owner's delegation; each swappable as noted): the module and accessor names,
meta_whatsapp_client::thread_controlandclient.thread_control;releasenot idempotent although Meta documents a release of an idle thread as a success (a replay can release a thread a new message routed back in between; a caller that wants a replay calls it again);releasesends nometadata(Meta forwards it only on a handover event, and a release has none; a parameter is an additive change);Recipient::PhoneAndUserand groups refused locally, rather than reduced to the BSUID or the phone number (the caller picks its identifier; Meta refuses both);#[non_exhaustive]onTargetRoleandThreadControlResponseonly, not onPass, a request type (OPEN_QUESTIONS #28); and L15a kept as its own item, with row 139 partial until it lands.
- Kind: additive.
- After: L15.
- Decisive: exact response JSON from
conversation-routing/routing-roles(both examples, the emptyrolesamong them). - Landed:
ThreadControl::routing_roles()onclient.thread_control(phone_number_id), aGET /{phone-number-id}/routing_roleswith no query and no body, answeringRoutingRoles(roles: Vec<RoutingRole>,is_standby: bool, andRoutingRoles::holds, which compares wire names).RoutingRoleis the page's six values (calls,ctwa,customer_service,escalation,marketing,utility) withOther(String)keeping any other verbatim,ai_agentamong them (the page: never returned to a partner). Row 139 is done for the library; the service's side stays M2d and M5l. Decisive tests (thread_control::tests): both of the page's responses as exact JSON, with the request's method, path, absent query and body, and token (routing_roles_match_docs_example,empty_routing_roles_match_docs_example: an emptyrolesis a success), every role's wire name (every_routing_role_has_its_wire_name), a response missing either field refused (routing_roles_tolerate_new_fields_and_require_both), the page's two errors, both code100, asErrorKind::InvalidParameter(routing_not_enabled_is_an_invalid_parameter,an_unreadable_number_is_an_invalid_parameter: no kind of their own, which would be a core change and could not split one code), a retry after a timeout (routing_roles_are_retried_after_a_timeout) and the id kept one segment. Mutated by hand: the path segment, GET to POST, either field's name, a default on either field,calls's wire name, refusing an emptyroles,holdsby equality, and the catch-all removed; each fails a test. - Decided 2026-11-01 (the coordinator, under the owner's delegation, AGENTS.md § Decisions; each swappable as noted): a method on
ThreadControlrather than a module and accessor of its own (client.routing_roles(id)would be an additive accessor);RoutingRoleits own enum, notTargetRole(callsis no pass target,ai_agentnever a held role; a conversion between the two is additive), with#[non_exhaustive]and theOther(String)catch-all likeTargetRole; both fields required, with no serde default (the page says both are always present, and a default would read as "no role" or "not standby"; relaxing it is a one-line change);rolesaVecin Meta's order rather than a set (the page calls it unordered;holdsis the set test); the read retried as every GET is; no local cache or rate limiting (Meta rate limits the endpoint per number and per app and asks for periodic reads: the caller schedules them); M5l, not this item, decides whether the service exposes the roles.
- Kind: breaking (a new field on
OutboundMessage, whose fields are public; built with its constructors, nothing changes). - After: nothing.
- Decisive: the page's request example, with the docs' sample ids, as exact JSON on
/{phone_number_id}/messages; a message without the option sends neither key; an id that is not digits never reaches the transport. - Landed:
OutboundMessage::messaging_account_id, aWabaId(the id the Messaging Account kept), checked as digits byOutboundMessage::validate; the alias is never sent, and aMessageContent::Rawtype named like either key is refused. Thesend_messageexample sends it fromWA_MESSAGING_ACCOUNT_ID. Listing the Messaging Accounts a business shares with a partner is the client WABAs call,client::waba::Business::client_whatsapp_business_accounts, unchanged.
- Kind: breaking (a new field on
- Kind: additive (a new id type, new calls, a new webhook field parsed into its own event,
Unknownuntil then). - After: nothing; the reference must exist first (phase 2, H1 2027).
- Decisive: exact JSON from the published reference; a
whatsapp_accountwebhook parses into its event, and one with an unknown shape intoUnknown { raw }.
- Kind: additive (a new id type, new calls, a new webhook field parsed into its own event,
- Kind: additive.
- After: nothing.
- Decisive: a paged product list follows its cursors to the end with no repeat.
- Landed: row 103 done in the library; the service's side stays a gap (M5g).
Waba::product_catalogsreads the catalog connected to a WABA (GET /{WABA_ID}/product_catalogs);Client::cataloggives aCatalog(get,products/products_stream,product_sets/product_sets_stream),Client::product_setaProductSet(get,products/products_stream) andClient::productaProduct(get). The lists take their cursors inListProducts,ListSetProductsandListProductSets, next to the edges' documented filters (a JSONfilterrule,return_only_approved_products,error_priority,error_type,bulk_pagination,summary; for setsancestor_id,has_children,parent_id,retailer_id); the streams followpaging.cursors.afteruntil a page comes withoutnext. Every id in a path is checked as digits before any request. The decisive test (products_stream_follows_three_pages_to_the_end_with_no_repeat) walks three pages, asserting each request's query andafter, five distinct products and no fourth request after the page withoutnext; reusing the first cursor, droppingafteror followingcursors.afterpast the last page each fail it. Read from Meta's Marketing API reference (product-catalog, itsproductsandproduct_setsedges,product-setand itsproductsedge) and the Graph API reference (product-item,whatsapp-business-account/product_catalogs,results) on 2026-10-27. Not wrapped: every catalog write (creating, updating or deleting catalogs, products and sets, and connecting a catalog to a WABA); a product'scatalog_id,image_height,image_width,override_countryandoverride_languageparameters, which the reference names without saying what they do.ProductItemIdandProductSetIdaremeta_whatsapp_core::idsnewtypes, re-exported byclient::commerce(numeric since L20c).
- Kind: additive.
- After: nothing.
- Decisive: every Flow JSON example in
flows/guides/flowjsonround-trips through the builder byte for byte (as JSON values). - Landed: row 63 done in the library (the service has no side: callers send Flow JSON as JSON).
client::flows::flow_jsonmodels the document (FlowJson,Screen,Layout,DataField), its components (Component, tagged bytype, one struct per component offlows/guides/componentsandflows/guides/media_upload, plusForm) and actions (Action, tagged byname);Dynamic<T>holds a value or a reference. Unknown components and actions are kept whole (Unknown { raw }) and unknown properties in flattenedextramaps. Decided (AGENTS.md § Decisions): a component or action whose known tag does not fit its struct (a property of another type, a required one missing, anull) isUnknown { raw }too, verbatim, instead of failing the document, because a new or changed Meta shape must never break parsing and the docs disagree with themselves (RadioButtonsGroup'sinit-valueis a string in one table and a list in the other; it is nowOneOrMany, either); the document skeleton stays strict (version,screens, the screens' typed properties andlayout, a layout'stypestring andchildrenlist), each with one documented type that Meta refuses others of (INVALID_PROPERTY_TYPE). Pinned bya_known_component_or_action_that_does_not_fit_its_struct_is_kept_wholeandthe_document_skeleton_stays_strict; the decisive test still refuses anyUnknownin the documented examples, so the fallback cannot hide a modelling mistake there. Also decided as proposed: the local checks are strict, taking the reading that refuses less where the docs can be read two ways; screen ids are ASCII letters and underscores (the error codes'PATTERN_MISMATCH, and no documented example has a digit);SUCCESSis refused in any case (the guide spells itSUCCESS, the error codessuccess);ComponentandActionare#[non_exhaustive];label-variantandChipsSelector's root actions are taken from the changelog; category 11 is done for the library.FlowJson::builderandFlowJson::validatecheck the hard limits of the component pages and of the static validation errors inflows/reference/error-codes, the first failure aValidationErrorwith the path from the root;CreateFlow::flow_json_typedandFlow::upload_flow_json_typedvalidate and send it, the raw-JSON calls unchanged. The decisive test isevery_flow_json_example_round_trips_through_the_typed_model(crates/meta-whatsapp-client/tests/flow_json.rs): the 27 JSON examples offlows/guides/flowjson(3 whole Flows, 21 components, 2 actions, a data model), and the 28 ofcomponents,media_upload,flowsapiandflows-templates, each parsed, serialized and compared as a JSON value, with no unknown component, action or property left (so a model that passed everything throughUnknownfails it); four of them come out of the builder identical (the_*_example_comes_out_of_the_builder), and the error-code samples round-trip or are refused, never altered, and those that parse as a document fail local validation, all but one (a global reference into an array schema, Meta's to check), each outcome pinned per sample. The components the Markdown pages give no example of are pinned byevery_documented_property_of_every_component_is_modelled: each property of each component's table, under its documented name, lands in a typed field (a misspelt wire name fails it). Dropping the catch-all, keeping only its tag, or turning anyextrainto#[serde(skip)]failsunknown_components_actions_and_properties_are_kept_as_sent; lifting a limit fails its unit test inflows::flow_json::tests. Read on 2026-10-28. Not covered: the examples Meta renders in an interactive preview, which the Markdown pages do not carry (most component examples: the static and dynamicdata-sourceones,If,Switch,NavigationList,CalendarPicker,Image, the media pickers, the routing-model and form examples); checks that need the data model resolved (references, payload schemas,__example__types, form names), component types per Flow JSON version, and the limits the docs call soft (NavigationListcontent truncated or not rendered).
- Kind: additive.
- After: nothing.
- Decisive: a WAV input comes out as Ogg with Opus that
Media::upload's type check accepts; a missingffmpegis a typed error, not a panic. - Landed: the port
meta_whatsapp_core::media::MediaConverter, an object-safe async trait,convert(MediaInput, TargetFormat) -> Result<ConvertedMedia>, withTargetFormat::OggOpus(mono Ogg Opus, uploaded asaudio/ogg: Meta's media page reads "OPUS codecs only; base audio/ogg not supported; mono input only", read on 2026-11-01) and its limit, core'smedia::AUDIO_MAX_BYTES(16 MiB);meta_whatsapp_client::mediare-exports them and uploads a result withMedia::upload_converted(its format's type and extension, throughvalidate_upload); andmeta_whatsapp_adapters::media::FfmpegConverterbehind the adapters'ffmpegfeature (forwarded by the facade's; off by default; infull). The converter starts theffmpegbinary withtokio::process, never a shell, with fixed literal arguments: the input on stdin and the result on stdout,-protocol_whitelist pipeand a-format_whitelistof recording formats (WAV, MP3, Ogg, FLAC, AAC, MP4/M4A/3GP, AMR, Matroska/WebM) before-i, the first audio stream only, tags and chapters dropped,-ac 1, libopus at 32 kb/s. The input (64 MiB) and the run (60 s, killed past it) are bounded, and the result to the 16 MiB upload limit (killed past it); stderr is drained and withheld; the output is checked to be mono Ogg Opus. Failures are a new node,Error::Conversion(ConversionError)(BinaryMissing,EncoderMissing,Timeout,InputRejected,OutputInvalid,Killed; architecture.md § Error tree), nevermay_have_been_sent, retryable only onTimeout; an empty or oversized input (or result) is aValidationErroroninput.FfmpegConverter::checkfinds a missing binary or libopus at startup. Decisive:ffmpeg_turns_wav_into_ogg_opus_that_upload_accepts(crates/meta-whatsapp-rs/tests/ffmpeg.rs, in the facade, where the adapters and the client meet: a stereo 44.1 kHz WAV comes out as Ogg whose first packet is anOpusHeadfor one channel, ffprobe agreeing, and goes throughMedia::upload_convertedasaudio/ogg, its exact request asserted) anda_missing_ffmpeg_is_a_typed_error(ConversionError::BinaryMissing). The real-ffmpegtests useMETA_WHATSAPP_RS_TEST_FFMPEG, elseffmpegonPATH, and skip without one unlessMETA_WHATSAPP_RS_REQUIRE_FFMPEG=1; CI installsffmpegon its runner and sets that variable (.github/workflows/ci.yml, docs/dev-environment.md), so they count there; no image or dev container carries one. They ran against ffmpeg 6.1.1 when this landed. Removing the protocol or format whitelist, mono, the timeout, the kill on timeout, an input or output bound or the output check, or withholding stderr, changing the codec, or a timeout that saysmay_have_been_sent, fails a test (the protocol whitelist only the fixed-argument one: with these formats, ffmpeg 6.1.1 opens nothing else from a pipe even without it, so it is a second wall, not the first). The security review added: the child's environment reduced toPATH(noFFREPORTlog file),-max_alloc 16777216(a 16 KiB WAV declaring 1 Hz otherwise took 1.7 GB and a minute),-abort_on empty_output(an input without audio came back as an empty voice note), an MP4 whose index follows its media data moved index first in memory (from a pipe, ffmpeg read next to nothing of one longer than a few seconds, and the converter returned it as success), and real-ffmpegtests for each, one of them an MP4 whose data reference names a host file, never read. Not done: images into stickers and resizing (L19b), a conversion sidecar over HTTP, and a limit on conversions at once (the caller's). - Decided (coordinator, under the owner's delegation, 2026-11-01; each swappable):
- The trait lives in
meta-whatsapp-core, beside the other ports, not in the client as planned: the adapters then depend on core alone, with no dependency cycle through the client's tests. Core carries the MIME type, the extension and the size limit of each format; the client re-exports the port fromclient::media, and a client test keeps core's limit equal to its upload table. - The upload MIME type is
audio/ogg, the literal of Meta's table, notaudio/ogg; codecs=opus(the upload check takes both). - Failures get a typed node,
Error::Conversion(ConversionError), with existing kinds (no newErrorKind: the service's error codes and OpenAPI document are unchanged); a missing binary isConversionError::BinaryMissing, not aConfigError, so every converter failure is handled in one place; an empty or oversized input stays aValidationErroroninput. - Defaults: 64 MiB of input, 60 s, 32 kb/s at 48 kHz in
voipmode, the recording formats above (each a setting or a literal of the converter, swapped by anotherMediaConverter). - The facade's
ffmpegfeature is infull; the gate variables areMETA_WHATSAPP_RS_REQUIRE_FFMPEG,META_WHATSAPP_RS_TEST_FFMPEGandMETA_WHATSAPP_RS_TEST_FFPROBE, apart fromMETA_WHATSAPP_RS_REQUIRE_LIVE(nolive_test name). - Row 48 stays partial, its rest planned in L19b.
- CI proves the conversion: its runner installs
ffmpeg, which no image or dev container does.
- The trait lives in
- Kind: additive.
- After: L19.
- Decisive: a PNG comes out as a static WebP (its
RIFF/WEBPheader read apart from the converter) within 100 KB thatMedia::upload's type check accepts as a sticker, and a JPEG over 5 MB comes out under it, both against a real converter in CI; a GIF or a crafted file off the format list is aConversionError::InputRejected. - Landed: three
TargetFormats in core,StaticSticker(a static WebP,image/webp,.webp, at mostmedia::STATIC_STICKER_MAX_BYTES, 100 000 bytes),JpegandPng(8-bit,image/jpegandimage/png,.jpegand.png, at mostmedia::IMAGE_MAX_BYTES, 5 000 000 bytes), read frommessages/sticker-messagesandbusiness-phone-numbers/mediaon 2026-11-01; the client's test keeps each within its upload table.FfmpegConvertermakes them from a PNG or a JPEG only: the input's first bytes (a PNG's signature, a JPEG's start of image) are checked before ffmpeg starts, and ffmpeg's own detection may land on nothing butpng_pipeorjpeg_pipe(-format_whitelist). Each attempt runs fixed literal arguments: the L19 prefix (-protocol_whitelist pipe,-abort_on empty_output, environment reduced toPATH, stderr drained and withheld),-max_alloc 268435456,-max_pixels 33554432(32 Mi pixels), the first video frame, no metadata, a filter and an encoder from a fixed table,-f image2pipeon stdout. Each format is brought under its limit by a ladder of sizes and qualities (sticker: its own size at quality 80, then 1024 at 80 and 50, 512 at 75 and 50, 256 at 50; JPEG: its own size at-q:v2, 5, 10, then 2048 at 5, 1024 at 10; PNG: its own size, 2048, 1024), the aspect ratio kept and never enlarged, each attempt killed once its output passes the limit, the whole ladder withinwith_timeout; an image no attempt brings under is aValidationErroroninput. The result's header is checked (a WebP whose RIFF size is its length and whose first chunk isVP8,VP8L, orVP8Xwithout the animation flag; a JPEG of 8 bits and 3 components; a PNG of 8-bit RGB or RGBA ending inIEND).FfmpegConverter::check_for(&[TargetFormat])checks each format's encoder;check()is it forOggOpus. Decisive (crates/meta-whatsapp-rs/tests/ffmpeg.rs, against ffmpeg 6.1.1, Ubuntu 24.04's package, the one CI's runner installs, built with libwebp, a hard dependency of itslibavcodec60):ffmpeg_turns_a_png_into_a_static_webp_sticker_that_upload_accepts(a 1600 × 1200 picture with noise, a 4 MB PNG whose own-size WebP is nearly 700 KB, comes out as a 1024 × 768 static WebP within 100 000 bytes, its RIFF header, chunks and size read in the test, ffprobe agreeing,validate_uploadtaking it as a sticker, and its upload's exact request),ffmpeg_brings_a_jpeg_over_five_megabytes_under_the_image_limit(a 10 MB, 4000 × 3000 JPEG, whichvalidate_uploadrefuses, comes out as an 8-bit colour JPEG under 5 000 000 bytes, 4:3 kept), andffmpeg_refuses_a_gif_a_jpeg_ls_an_animated_png_and_a_crafted_png(eachInputRejectedfor every image format; the JPEG-LS image passes the first-bytes check, and without the whitelist ffmpeg reads it from a pipe and converts it: the control). Also: a small transparent PNG keeps its size and alpha; a 16-bit PNG over 5 MB comes out 8-bit RGBA under it; a 6000 × 6000 PNG is refused by the pixel bound (ffmpeg without it converts it); a JPEG whose EXIF says Orientation 6 comes out rotated (640 × 480 to 480 × 640), its EXIF gone. The inputs are made by the tests (lavfisources, an EXIF segment written in Rust); nothing is committed. Removing the first-bytes check, the whitelist (or widening it tojpegls_pipe),-max_pixels, the ladder (an overflow ending it), the aspect ratio, the 8-bit PNG format, the WebP check or its RIFF-size or animation test, the ladder-wide timeout, or setting the sticker limit to the animated one fails a test (the first-bytes check only in the adapters' tests: for a GIF, the whitelist is a second wall). Not done: animated stickers (L19c; a GIF or an animated PNG is refused; ffmpeg 6.1.1 cannot read an APNG from a pipe at all), WebP or HEIC as an input, and a photo of more than 32 Mi pixels (a 48-megapixel one), which has to be scaled down before it comes here. - Decided 2026-11-01 (coordinator, under the owner's delegation, AGENTS.md § Decisions; each swappable by another
MediaConverter, or a literal of this one):- Meta's "100 KB" and "5 MB" read as decimal (100 000 and 5 000 000) for what a converter makes, under the upload check's binary reading: a converted file then fits whichever Meta means.
AUDIO_MAX_BYTESkeeps its binary reading (16 MiB), as L19 decided: a voice note is far under either. - Inputs: PNG and JPEG only, the formats of Meta's image table; no GIF, WebP, HEIC or animation.
- Static stickers only: no
AnimatedStickertarget here (L19c). - No sticker dimensions imposed (Meta gives none): a sticker that fits keeps its size; the ladder's sizes (1024, 512, 256) are where it steps down, not a requirement.
- Bounds: 32 Mi pixels, 256 MiB a single allocation, the existing 64 MiB of input and 60 s, the timeout covering the whole ladder.
- The ladders as built; a result over the limit after every attempt is a
ValidationErroroninput, as L19's result over the limit is. - Row 48 stays partial on the library's side: L19b's text names Meta's 500 KB animated limit and the row's capability is "stickers from images", which Zaileys makes animated too; animated stickers are L19c. The service's side stays M5c1's.
- An input's ICC profile passes into a JPEG or PNG result, so its colours stay right; its size is bounded by the output limit. Stripping it is one filter in the fixed table.
- The allocation bound stays 256 MiB, its edge documented (a 16-bit RGBA frame of 8192 × 4096 is refused by ffmpeg's padding, 8192 × 4094 converts); headroom is one literal.
- Meta's "100 KB" and "5 MB" read as decimal (100 000 and 5 000 000) for what a converter makes, under the upload check's binary reading: a converted file then fits whichever Meta means.
- Review (adversarial, 2026-11-01, against ffmpeg 6.1.1): a PNG's
zTXtchunks were a memory bomb inside every bound (ffmpeg inflates each and keeps the text until the frame is decoded: four 100 KB chunks inflating to 100 MiB each took 1.6 GB for a 16 × 16 image). The converter now walks a PNG's chunks before ffmpeg starts, refuses one cut short ofIEND, and passes it on withouttEXt,zTXt,iTXtandeXIfand without anything afterIEND(a_png_reaches_ffmpeg_without_its_text_and_nothing_after_its_end,ffmpeg_gets_the_png_without_its_text_and_the_limit_is_inclusive); the PNG output check wants one image ending the file. Also found and tested: the allocation bound refuses a 16-bit RGBA PNG of exactly 32 Mi pixels (ffmpeg's padding; the docs said it fits;ffmpeg_refuses_a_frame_over_the_allocation_bound, its first behavioural test on images), a result of exactly the limit is taken, and only the first of two images in a row comes out. Three guards survived their mutation and now have a test: the kill of an attempt over the limit (kill_on_dropand SIGPIPE hid its removal;an_attempt_over_the_limit_is_killed_and_reaped, a stand-in deaf to SIGPIPE, each attempt reaped before the conversion returns), a failed attempt ending the ladder (a_failed_attempt_ends_the_ladder) and the encoder's kind incheck_for(check_for_wants_the_encoder_s_kind_too).-map_metadata -1survives in behaviour (theimage2pipemuxer and these encoders write no tags) and is pinned by the arguments' literal test. Probed and holding: a JPEG-LS image with anAPP0segment first, or aSOF0copy before its own frame header, still never reachesjpeg_pipe(ffmpeg's probe lands onmjpegor nothing, both off the list); an animated PNG reaches only its default image at worst; the ladder's last attempts stay far under their limits on noise with noisy transparency (42 774 bytes for a sticker, 3.8 MB for a PNG); killed attempts are reaped (Child::killwaits). Left as they are, documented: the colour profile (ICC) passes into a JPEG or PNG result; a truncated JPEG decodes partly, grey below the cut; the first sticker attempt of a 24-megapixel photo takes about 18 s on a 4-core machine, encoding a WebP far over the limit, so a timeout under the default can fail a large photo; an image over 256:1 can fail at a smaller attempt (a side scaled to nothing).
- Kind: additive.
- After: L19b.
- Decisive: an animated GIF comes out as a WebP whose
VP8Xheader has the animation flag and whoseANIMandANMFchunks are read apart from the converter, within 500 KB, whichMedia::upload's type check accepts as a sticker, against a real converter in CI; an animation over the frame bound is refused, and removing that bound fails a test.
- Kind: breaking, before the first release.
- After: nothing.
- Decisive: an unknown value round-trips unchanged through deserialize and serialize for every open enum; a unit
Unknownleft anywhere fails it.
- Kind: breaking, before the first release.
- After: L20a.
- Decisive: a source scan over the crates fails on a response or webhook struct, or an open enum, without the attribute; removing it from one type fails the scan.
- Kind: breaking (an id of the wrong shape is refused).
- After: nothing.
- Decisive: a numeric id holding
/is refused before any request; a group id holding/stays one percent-encoded segment. - Landed:
AppId,BusinessId,WabaId,PhoneNumberId,MediaId,TemplateId, and L17'sProductItemIdandProductSetId(the ids Meta documents as numeric strings, the last two in the Catalog API's field tables) refuse anything but 1 to 64 ASCII digits when built (newreturns aResult,FromStrandTryFromreplace the infallible conversions;ids::is_numeric_id,MAX_NUMERIC_ID_LEN, 64 being the service's existing cap), aValidationErroron the type'sFIELD; every other id stays opaque.Deserializedoes not check, so parsing never fails on an id's shape, andnew_uncheckedwraps Meta's plain-string values (webhook entry ids,debug_tokentargets) and stored rows the same way; such an id is still one encoded segment. The decisive tests, inclient/src/request.rs(id_shape_tests), area_numeric_id_holding_a_slash_is_refused_before_any_request(each of the six types built from123/subscribed_appsand, only if that succeeds, used in a call its path names, every response scripted: nothing is sent) anda_group_id_holding_a_slash_stays_one_encoded_segment(both group accessors:DELETE /v25.0/Y2FwaV9ncm91cDox%2Fparticipants), witha_numeric_id_meta_sent_is_kept_and_stays_one_segment, and in coreids::tests::a_numeric_id_is_one_to_sixty_four_ascii_digitsanda_numeric_id_from_meta_is_deserialized_as_sent; the first checks all eight types, and is the decisive one for the two product ids, whose pathscommerce::catalogalso checks as digits at request time (ids_that_are_not_digits_are_refused_before_any_request, with ids built unchecked). Making the check always pass sends all six requests (the test lists their paths); splitting a segment on/inGraphEndpoint::url_segmentsfails the group test and the Meta-sent one. The endpoint modules keep their request-time checks (empty,.,..; digits where a page asks), now tested with ids built unchecked. Stored data is untouched: the vault's and the ledger's pinned records, written under the WABA idW1, still open. The service'sgraph_idrunsis_numeric_idplus its own no-leading-0rule (D4); a non-numeric{pn}or{waba_id}is404 not_foundas before (tests/authz.rs,a_path_id_that_is_not_numeric_is_not_found, on every such route), a body's mediaidreaches Meta as given (tests/messages.rs,a_media_id_in_a_body_reaches_meta_as_given), andopenapi/v1.jsonis unchanged. Each place an id Meta sent is wrapped unchecked has a test that an id of another shape is kept as sent (webhooks'ids_meta_sends_are_kept_as_sent_whatever_their_shape, and the client'sdebug_tokentargets, template insights, archive answers and voicemail media id; the service core's untyped-change number): checking any of them fails its test. OPEN_QUESTIONS #20 is closed and deleted (CHANGELOG); what stays unverified for opaque ids (whether Graph decodes%2F) is in architecture.md's endpoint rule 1. No parity row changes: none cited L20c. - Follow-up: the ids left opaque although Meta's examples show them as digits (
FlowId,CatalogId,TemplateGroupId,CreditLineId,AllocationConfigId,VerificationSubmissionId,WabaBotId, among others): no page states their shape, so the rule OPEN_QUESTIONS #20 decided (the shape Meta documents) keeps them opaque (a template group id is still checked as digits when its request is built, byclient::templates). Each moves tonumeric_id_type!once a reference page states digits for it (a breaking change to its constructor, as here), with that page's example as its test.
- Kind: additive (the builder stays infallible).
- After: nothing.
- Decisive: the check reports a handler built with a blank verify token and passes one built with a real token.
- Landed:
WebhookHandler::check(&self) -> Result<(), ConfigError>, run by the caller right afterbuild(nothing else runs it). Blank is what the handshake already refused: empty, or only whitespace (str::trim's Unicode set), one predicate shared withverify_subscription, whose constant-time comparison is unchanged. The error's text is fixed and names no part of the token. The decisive tests arecheck_reports_a_blank_verify_tokenandcheck_passes_a_real_verify_token(tests/handler.rs), withthe_check_error_carries_no_token_text; makingcheckalways pass fails the first and the last, and the doctest oncheck. Thecms_inboxexample'sappruns it (the_app_refuses_a_blank_verify_token_at_boot), as do the webhook-endpoint and bot skills' examples (blank_secrets_fail_at_startup,a_blank_verify_token_stops_the_boot); the service does not, as its configuration already refuses a blankWA_VERIFY_TOKENbefore a handler exists, with its own test of the same shape (str::trim, then empty). OPEN_QUESTIONS #16 is closed and deleted (CHANGELOG). No parity row changes: coverage row 2 already counted the verify token.
- Kind: breaking (creating one with 3–10 cards is refused).
- After: nothing.
- Decisive: a product-card carousel with three cards is refused before any request; a media-card carousel with three is created; a send with 10 cards is accepted.
- Landed:
TemplateDefinition::validate(run byTemplates::createbefore any request) refuses a carousel whose cards carry aPRODUCTheader unless it has exactly two cards, oncomponents[<i>].cards, from one constant beside the media cards' 2–10 range (templates/validate.rs); the page's words are "Define only two product cards when you create the template" and "An approved template with two product cards can be used to send up to 10 cards" (catalogs/product-card-carousel-template-messages). The page states the count for creation only, soTemplateEdit::validatekeeps 2–10 for both kinds. The decisive tests, inclient/src/templates/tests.rs, area_product_card_carousel_is_created_with_exactly_two_cards(0, 1, 3, 10 and 11 cards refused with nothing sent; 2 created, its exact body asserted),a_media_card_carousel_with_three_cards_is_created(the media-card page's own three-card example) anda_product_card_carousel_send_takes_up_to_ten_cards(10 sent, 11 refused with nothing sent), witha_product_card_carousel_edit_keeps_two_to_ten_cardsanda_product_header_on_any_card_makes_a_product_card_carousel(aPRODUCTheader on any card and in any case counts; mixed, headerless and unknown-format carousels are refused by the card checks). Disabling the check fails the first; applying it to edits fails the edit test; applying it to every carousel fails the media-card tests; detecting the header on the first card only, or on every card, fails the header test. The service runs the same check (a_product_card_carousel_is_created_with_two_cards,meta-whatsapp-server/tests/templates.rs: Meta's example created, 1, 3 and 10 cards422oncomponents[1].cards). No in-repo construction needed changing: the only product-card carousels (tests) had two cards. OPEN_QUESTIONS #22 is closed and deleted (CHANGELOG). No parity row changes: rows 51 and 99 already counted product-card carousels.
- Kind: additive.
- After: L5.
- Decisive: a batch with one permanently failing event delivers the others and dead-letters that one (removing the dead-letter write makes the handler answer 500 again, and the test fails); the facade-level erasure of the contact removes their dead-lettered events (removing the dead-letter store's
erasefrom it fails the test). - Landed: the signal is a sink's own:
SinkError::Permanent(SinkError::permanent,is_permanent; every other variant stays transient), added to core's existing leaf, withFanoutSinkreturning a transient error before a permanent one. The store ismeta_whatsapp_webhooks::deadletter::DeadLettersonKvStore, wired byWebhookHandlerBuilder::dead_letters: a permanent error is written there, the event's dedup claim marked done, the rest of the batch delivered and the delivery acknowledged, counted inDeliveryReport::dead_letteredand alerted by atracing::error!naming the letter, kind and number only; a failed write releases the claim and answers500, and without a store a permanent error answers500as before. Bounds: a ring of 1000 slots (letteridin slotid % capacity, the oldest overwritten without a scan), 14 days per record and index, 256 KiB per stored event (a larger one kept without its content). Replay (replay,replay_all) goes past the dedup guard. Erasure: a per-contact index (every identifier the event gives, BSUIDs, parent BSUIDs and phone numbers, since an erasure may know the person by one only; keyed by business number and contact, an event with no number under the empty one, which every number's erasure reads) andDeadLetters::erase, called by the new facademeta_whatsapp_rs::erasure::EraserbesideConversationStore::erase_all. The namespaceswa.webhook.deadletterandwa.webhook.deadletter.contactand the records are pinned (the_keys_are_pinned_and_hashed,the_record_bytes_are_pinned) and pending the owner's confirmation (§ Owner touchpoints). The decisive tests area_batch_with_one_permanent_failure_delivers_the_others_and_dead_letters_it(webhooks/tests/deadletter.rs; withpost_is_200_when_a_permanent_failure_is_dead_letteredintests/router.rsfor the status) anderasing_a_contact_removes_their_dead_letters(meta-whatsapp-rs/src/erasure.rs). Removing the dead-letter write fails the first two, removingerasefrom the eraser the third; treating a permanent error as transient fails the first, dropping the count bound failsthe_count_bound_evicts_the_oldest, and not updating the index fails the erasure tests. Not done: no sink of the library classifies an error as permanent yet (the inbox's U+0000 in a Meta-assigned id on Postgres still answers500); the service does not wire a store (its sink classifies nothing). No parity row changes status: row 8 already counted delivery semantics as done. The sabotage review indexed every identifier (the draft took the BSUID alone when an object had one and missedphone_number,input,participant_recipient_idand parent BSUIDs:every_person_a_fixture_event_names_is_indexedwalks every fixture), pinned the write-before-index order and the erasure/append race (an_erasure_that_reads_an_index_entry_finds_its_letter,an_erasure_racing_an_append_leaves_it_to_the_next_erasure), the head's compare-and-swap (racing_appends_take_distinct_ids_and_keep_every_letter) and the take-back of an unindexed letter (a_letter_that_cannot_be_indexed_is_taken_back_and_fails_the_delivery). - Decided 2026-11-01 (the coordinator, under the owner's delegation, AGENTS.md § Decisions; each swappable as noted): the core change,
SinkError::PermanentwithSinkError::permanentandis_permanent(additive, the enum#[non_exhaustive]; a sink classifies its own errors); a newmeta_whatsapp_rs::erasure::Eraserrather than a change toInbox::erase_all, whose rustdoc says it does not reach the dead letters, and the guides and the cms-inbox skill point to it; the defaults of 1000 letters, 14 days and 256 KiB, an oversized event kept without its content (with_capacity,with_max_age,with_max_event_bytes); a dead-lettered event's claim marked done, and replay past the dedup guard (at least once); an event naming no business number erased by any number's erasure of the contact (over-deleting diagnostic copies is the safe direction for an erasure);FanoutSinkreturning a transient error before a permanent one; and the known limits, documented in thedeadlettermodule (an erasure racing an append leaves that letter to the next erasure; a replay racing an erasure hands the event back to the sink; more thancapacityappends at once may evict a newer letter; a capacity change strands letters fromget,listandreplayuntil they age out; a letter naming nobody is reached by the bounds only). The namespaceswa.webhook.deadletterandwa.webhook.deadletter.contact, their keys' derivations and their records' shapes stay pending the owner's confirmation before the first release (§ Owner touchpoints).
- Kind: breaking (their types change).
- After: nothing.
- Decisive: ten subscribers share one allocation per event (
Arc::ptr_eqacross them); reverting to a clone per subscriber fails it. - Landed:
BroadcastSink<E>wraps each delivered event in oneArcand publishesArc<E>(from_sender,receiverandBroadcastSubscriptioncarryArc<E>;Eneed not beClone, but must beSync);ssereads abroadcast::Receiver<Arc<WebhookEvent>>, its filter still aFn(&WebhookEvent) -> bool. TheEventSinkport is unchanged: the sink still takes an ownedEand does the wrapping. The decisive tests areten_subscribers_share_one_allocation_per_event(adapters::sink::broadcast: ten subscriptions and ten raw receivers) andten_sse_subscribers_share_one_allocation_per_event(webhooks::server, on the streamsseis built on), withsse_filters_see_the_shared_eventchecking that the publicssehands each of ten filters the sent allocation. A subscription that clones the event per subscriber fails the first; a stream that clones it fails the second, and the third too when the copy comes before the filter.sse_body_is_byte_exact(tests/router.rs) pins the wire output, the same bytes as before L21b. Not pinned: a copy insidesse's encoding step, after the filter, which only an allocation counter would see (unsafe_codeis forbidden, so a counting allocator would need a new dev-dependency).WebhookEventis assertedSend + Syncat compile time. OPEN_QUESTIONS #31 is closed and deleted (CHANGELOG). No parity row changes: parity row 10 already countedwebhooks::sseandBroadcastSinkas done.
- Kind: additive.
- After: nothing.
- Decisive: live, with a TLS Redis in the test Compose and
META_WHATSAPP_RS_REQUIRE_LIVE=1, arediss://store connects in a binary linking bothringand aws-lc-rs with no process default installed (the case that panics today); handing the connection the process default instead makes that test panic. - Landed: redis-rs 1.7 cannot take a provider (its rustls path always calls
ClientConfig::builder()) but takes any stream, sostore::RedisTlsdoes the TLS itself: aClientConfigonaws_lc_rs::default_provider()(the platform's roots, or the CAs ofroot_certificates_pem;client_auth_pemfor mutual TLS;from_client_configfor your own), the handshake with tokio-rustls, the stream toMultiplexedConnection::new_with_config, and aRedisTlsConnectionforRedisKvStore::newthat reconnects on its own; none of redis-rs's TLS features is enabled. The test binaries linkringtoo (an adapters dev-dependency, rustls with both providers). The decisive test islive_rediss_store_connects_with_both_providers_and_no_default(tests/redis_tls.rs: the key/value conformance suite over TLS, asserting before and after that no process default is installed), against compose.test.yaml'sredis-tls(TLS only, client certificates required, test-only certificates generated bytools/test-tls/generate.sh). Building on the process default (ClientConfig::builder()) panics it and every other test of that binary, the non-livebuilds_on_aws_lc_rs_with_both_providers_linked_and_no_defaultincluded; the library installing a default fails them on the no-default assertion; taking the process default when one is installed (reqwest's rule) failsuses_aws_lc_rs_when_the_process_default_is_ring(tests/redis_tls_ring_default.rs, which installsring). The sentineltests/redis_tls_process_default.rs(#[should_panic]) proves those binaries are the hostile case: withoutringlinked,builder()would install aws-lc-rs quietly and it fails. Also live: an untrusted server and a missing client certificate are refused, and a killed connection is replaced on the next command. Not covered: Cluster and Sentinel over TLS. - Review (2026-11-01): the live tests share one Redis whose certificate names every host they use, so a verifier that ignored the name passed them all.
tests/redis_tls_handshake.rsrunsRedisTlsagainst an in-process TLS server (certificates made per test by rcgen, a dev-dependency): a trusted certificate for another name is refused, an IP host is checked against IP SANs only (an_ip_host_is_checked_against_ip_sans_only), an untrusted issuer is refused, the connection timeout bounds a stalled handshake and a stalled setup, a reconnect sendsAUTHandSELECTagain and replays nothing, and eight commands that find the connection gone share one failed attempt (concurrent_commands_share_one_failed_reconnect_attempt). PEM errors no longer quote the offending line (a client chain can be a combined file holding the key). Left untested: the generation check that keeps a late error from an old connection from discarding its replacement (no deterministic way to deliver that error late). - Decided 2026-11-01 (the coordinator, under the owner's delegation; each swappable as noted): TLS through a stream handed to
MultiplexedConnection::new_with_config, rather than leavingrediss://to integrator-built connections (an integrator's own connection still goes toRedisKvStore::new); the platform's roots by default (root_certificates_pemreplaces them,from_client_configtakes any configuration); TLS under the existingredisfeature, not a feature of its own;ringallowed in the workspace's test builds through the adapters' rustls dev-dependency only, never in a library build (cargo tree -e normal -p meta-whatsapp-adapters --features redis -i ringfinds nothing); the test Redis requires client certificates; its certificates are generated, none committed (OPEN_QUESTIONS #19).
- Kind: additive.
- After: nothing.
- Decisive:
doctorreports each scripted misconfiguration by name and exits non-zero;llms.txtlinks only files that exist (a test reads it). - Landed: row 117 done in the service; the library's side stays partial for the MCP server (L22b).
docs/public/llms.txt, served at the docs site's root (VitePress copiesdocs/public/), indexes the guides, design, planning docs and every consumer skill, each link the file's Markdown onmain;crates/meta-whatsapp-rs/tests/llms_txt.rsrefuses a link to a missing file or off the repository, and an unlisted guide or skill.meta-whatsapp-server doctor(meta_whatsapp_server::doctor) prints one line per check (OK,WARN,FAIL,SKIP) and exits non-zero on aFAIL: every refusal of the configuration by variable (a refused variable is replaced and the reading repeated, so one does not hide the next), the backend opened asserveopens it, the migration histories read bySELECT(pending service migrations fail withWA_SERVER_MIGRATE=skip, warn withauto), the app'swhatsapp_business_accountsubscription and its fields (GET /{app-id}/subscriptions:messagesandaccount_updaterequired, eleven more recommended), and each bound WABA's token (debug_token: valid, this app's, not expired, both permissions, a grant naming the WABA) and the app's subscription to it (GET /{waba-id}/subscribed_apps), plus the Solution Partner's system token. Read-only (no migration, no re-encryption on read,GETs only); no secret printed.tests/doctor.rsscripts each misconfiguration (36 tests, four live) and runs the binary for its exit code; its test backend panics on any key/value write and on the ports doctor has no use for (migrator, locks, janitor, outbox), so a vault re-encrypting on read fails it, and the live tests showWA_SERVER_MIGRATE=autowarns without migrating and that a changed checksum, a half-applied migration (the service's or the library's), a newer and a pending version are each reported. - Decided 2026-11-01 (the coordinator, under the owner's delegation, AGENTS.md § Decisions; each a constant or a format in one place, swapped by changing it):
llms.txtat the docs site's root (docs/public/), its links the raw Markdown on GitHub'smain; a fourth status,SKIP, for a check that could not run; every refusal found by replacing the refused variable and reading again, the limit documented (a replacement can hide a problem only the intended value would show);messagesandaccount_updaterequired (REQUIRED_FIELDS), the other fields only warning (RECOMMENDED_FIELDS); pending service migrations warning withWA_SERVER_MIGRATE=autoand failing withskip; at most 100 WABAs a run (MAX_WABAS) and a warning 7 days before a token expires (EXPIRY_WARNING); no permission check on the Solution Partner's system token; theGET /{app-id}/subscriptionsread stays in the service until the client has it. - Left: a JSON report (
doctor --json) for deploy jobs; the library's migration versions compared, not only counted (their list is private to the adapters crate: it needs a public accessor there); the subscriptions read moved into the client.
- Kind: additive.
- After: L22a.
- Decisive: a scripted MCP client lists the tools, searches
Idempotency-Keyand gets the server guide's section; a missing page is an MCP error, not a panic. - Landed: row 117 done in the library too.
tools/docs-mcp(package and binarymeta-whatsapp-docs-mcp, run byjust docs-mcp) serves three read-only tools over stdio:list(every page, with its title and a skill's description),search(the sections holding every term of a query, best first, each with its heading, line and snippet) andread(a page, or one section by its heading or anchor, to the next heading of its level). It speaks MCP revision 2026-07-28, the current one on 2026-11-01 (no handshake:_metaon every request,server/discover,resultType, the caching hints,-32022for a version it does not speak) and, after aninitialize, 2025-11-25 and 2025-06-18, the revisions clients shipped with until then (dual-era, as 2026-07-28's versioning page defines it). A missing page, a missing section, a path outside the corpus and bad arguments are tool execution errors (a result withisError: true, as the tools page sorts them: the model can correct them); an unknown tool or malformed params are JSON-RPC-32602.tools/docs-mcp/tests/stdio.rsruns the binary as a subprocess: a legacy client and a modern one each list the tools, searchIdempotency-Key, readdocs/guides/server.md'sIdempotency keyssection and get a missing page asisError: truewith the server still answering; twelve escapes (.., absolute paths, repository files outside the corpus,.meta-docs/,\) and symbolic links out of a corpus tree are refused, and the corpus covers every Markdown pagellms.txtlinks. Each guard was removed in turn and a test failed: the path syntax check, the listing membership, the resolved-path check, the walk's refusal of links and of hidden directories, the missing page'sisError, the read and search bounds. - Decided 2026-11-01 (the coordinator, under the owner's delegation, AGENTS.md § Decisions; each a constant or a list in one file of the tool, swapped by changing it): Rust, in the root workspace as a non-default member (its tests, clippy, rustdoc and
cargo denyrun injust ci's--workspacerecipes), a binary only so no crate can depend on it, with no MCP SDK and no dependency butserde_json(no new crate inCargo.lock); dual-era rather than 2026-07-28 alone; the corpus (corpus::ROOT_FILESandcorpus::TREESin the tool): the root's README, AGENTS, CONTRIBUTING, CHANGELOG and OPEN_QUESTIONS,docs/'s Markdown and text,skills/'s Markdown and example sources, never.meta-docs/, hidden directories,node_modules/, generated*.d.tsor a symbolic link, read from disk on every call (no state, no stale index); the bounds: 40 KiB a read (a longer one ends with itsfrom_lineto continue and the page's headings), 10 hits a search by default and 25 at most, 240-character snippets, files over 1 MiB and messages over 1 MiB refused; search ASCII case-insensitive, every term required, ranked by occurrences with a bonus for the heading; no.mcp.jsoncommitted (registering the server stays each developer's choice: docs/dev-environment.md shows how). - Left: MCP resources and prompts (the pages as
resources/readtoo); a search that folds Unicode case or ranks by more than counts;subscriptions/listenfor a changed page (the tool list never changes).
- Kind: additive.
- After: nothing.
- Decisive: exact JSON; a group id is checked as digits before any request.
- Partly landed (left open, row 153 partial): row 101 done in the library; row 153's create, get, update and delete. Meta's pages as served on 2026-10-26 document both:
templates/template-archivalnow showsPOST /{WABA_ID}/message_templates/archiveand…/unarchive, andtemplates/template-groupsis in Meta's page list.Templates::archiveandunarchivesend{"hsm_ids": [..]}(1 to 100 ids, each digits) and returnArchiveResult/UnarchiveResult(the ids done, a reason for each that failed);Templates::create_group,get_group,update_groupanddelete_grouptakeTemplateGroupCreate/TemplateGroupUpdate(template ids written as JSON integers, as the page's examples do) and returnTemplateGroupwith its WABA and the first page of its templates. A group id that is not digits is aValidationerror before any request (group_ids_are_checked_as_digits_before_any_requestfails with the check removed); none of the POSTs is replayed on a timeout. Archive and unarchive go to the Graph endpoint (graph.facebook.com/v25.0), but the only page that shows them printshttps://api.facebook.com/{WABA_ID}/…with no version, and no page shows them on Graph. A probe on 2026-10-26 with a template id that does not exist got the same answer from both hosts, where an edge that does not exist gets Graph's2500"Unknown path components": Graph routes them, and no new credential rule is needed. A real archive has not been run live. Left: the list of a WABA's groups (GET /{WABA_ID}/template_groups) and the pages after the first of a group's templates: Meta's changelog names the list, but no page documents its parameters or response (its Graph API reference page shows no operation), nor how to request a group's next page of templates.
- Kind: breaking (a new field on
Button::Url). - After: nothing.
- Decisive: the page's request example round-trips byte for byte (as JSON values); dropping the field makes the service's lost-key check refuse it again.
- Landed: row 155 done on both sides.
Button::Urlgainsapp_deep_link: Option<AppDeepLink>(meta_app_idau64, a JSON number on the wire; the two strings checked non-empty), andButton::url_with_app_deep_linkbuilds one. The page's example round-trips (enum values in the module's upper case, the deep link object as it stands); with the field's serialization skipped the service answers422oncomponents[2].buttons[0].app_deep_linkagain and the round-trip, create and service tests fail. Not checked locally: the marketing category, which the page uses but does not state as a rule, and any length or URI syntax (none documented).
- Kind: breaking (a new field on
- Kind: additive if the shape holds; breaking if it changes.
- After: nothing.
- Decisive: the pre-fill test's expected JSON is the helper's output, recorded with its date; a serializer that departs from it fails the test.
- Kind: additive, but for one field:
PhoneNumberSettings'spayload_encryption, raw JSON until now, is typed. - After: L9.
- Decisive: exact JSON per field from the schemas; dropping any one field from its body fails its test; reading the settings parses a
payload_encryptionanswer carrying every field of the response schema. - Landed: row 156 done in the library; the service's side stays a gap (M5c3).
PhoneNumber::enable_payload_encryptionsendsstatusandclient_encryption_key(aClientEncryptionKey, checked to be base64 and not an obvious private key,Debugshowing its length only),disable_payload_encryptionthe status alone, andsettings()readsPayloadEncryptionback (status, optional so that an answer without it still parses;client_encryption_key_fingerprint; andcloud_encryption_key, aCloudEncryptionKeythatDebugshows as a length), with decode errors that withhold the body.set_connection_status(ConnectionStatus, the schema's eleven values) andset_webhook_url(an absolutehttpsURI, no user info, no fragment, sent exactly as given) post on the number, one field per call. The four setters are not replayed after a timeout, only after what Meta provably refused (Error::may_resend). Skipping any one field, request or response, fails its test. None of these has an example on Meta's pages: the bodies and the read answer follow the schemas, and the module docs name what they leave out (the key's format, the fingerprint's algorithm, what the cloud key is, which connection statuses a business may set and to what effect, howwebhook_urlrelates to the override). Left:StorageConfiguration::status(from L9) is still required andStorageStatushas no catch-all, so astorage_configurationanswer without a status, or with a status Meta adds, fails the wholesettings()read, aspayload_encryptiondid before this item made its status optional (L20a's catch-all pass covers the enum); andCallbackOverridedoes not check its URI's scheme, whereset_webhook_urlrequireshttps.
- Kind: additive, but for one field:
4. Service milestones
Re-scoped on the modular architecture (design §8, §9). Each item's routes are meta-whatsapp-server-core services exposed through both API adapters (meta-whatsapp-server-api-axum, and meta-whatsapp-server-api-cratestack once S10 exists; streaming routes through meta-whatsapp-server-http), and run against every backend the conformance suite covers. The acceptance tests M2.1–M5.3 are the design's (§9).
M2. Inbox, live updates, webhooks out
- After: S2b, S7, L5, L7.
- Decisive: M2.1; removing the binding-epoch filter shows a moved number's history to its new tenant, and a test fails; with a retention set, housekeeping purges an inbox message older than it (removing the
apply_retentioncall fails the test). - Also, after the first release (from S7, which landed before it): drop the legacy fingerprint (S6's window:
Fingerprint::legacy_v1_json,legacy_v1_upload,or_legacy, and the handlers'Uri), once every deployment has run a release with S6 for oneWA_SERVER_IDEMPOTENCY_TTL. If M2a lands before the first release, the next item of the service's to land after it carries this line.
- After: S7.
- Decisive: M2.2, M2.3, M2.5.
- After: M2b.
- Decisive: M2.4.
- After: S7, L7.
- Decisive: M2.6; taking one of the three types out of
TENANT_EVENT_TYPESfails it.
- After: S7.
- Decisive: another tenant's received media id is
404with zero requests (removing the "recorded on this tenant's number" lookup fails it); the live check's result is recorded in OPEN_QUESTIONS #43.
- Kind: additive (a new route; an expand-only column).
- After: M2a.
- Decisive: after the route, no row of the inbox tables nor of
wa_server_eventsmentions the customer (a sweep of every table, as the library's live test does), and removing the outbox clause fails it; the journal and the audit event hold no identity in clear; a tenant's key cannot erase on another tenant's number.
M3. Embedded Signup, OTP, coexistence
- After: S7, L4.
- Decisive: M3.1–M3.4.
- After: M3a, L8, L11d.
- Decisive: a coexistence onboarding over HTTP starts the sync exactly once, and a second onboarding of the number does not; another tenant's key reads none of the synced contacts (
404).
- After: S7.
- Decisive: M3.5.
- After: S7.
- Decisive: a sentinel PIN is found in no stored row and no log capture after a registration (storing it anywhere fails the test).
- After: M3a, L11e.
- Decisive: a token inside the lead time yields one event for its tenant and none for another; the metric counts it.
- After: M3a, L11a, L11b, L11c.
- Decisive: M1.3's table covers the new routes; with the multi-WABA option on, a signup granting two WABAs binds both to the tenant, and with it off, one.
M4. Packaging
- After: M2c, and S15 unless the owner refuses D20 (a).
- Decisive: M4.1–M4.3; the image's contents list matches the image (an extra binary fails it).
M5. Routes for the modules parity requires
One PR per family, each a thin route over an existing library module and each object id checked to be the path's number's or WABA's own (architecture.md § Service). For every family: M5.1 and M5.2 over its routes (M1.3's table covers each new {pn} and {waba_id} route by construction; a route whose object id is not checked against the number or WABA fails the family's own cross-tenant test).
- After: S7.
- Decisive: M5.1, M5.2; each type's documented example is sent byte for byte; one left out of the send union fails its test.
- After: S7, L23.
- Decisive: M5.1, M5.2; creating each kind of row 99 from Meta's example passes with no key dropped.
- After: S7, L19.
- Decisive: M5.1, M5.2; with no converter configured, an upload that needs conversion is refused as today, with zero requests.
- After: M5c1.
- Decisive: M5.1, M5.2.
- After: S7, L9.
- Decisive: M5.1, M5.2;
GET /v1/numbers/{pn}reports the messaging limit tier (dropping it from the fields Meta is asked for fails the test).
- After: S7, L10a, L10b, L16b.
- Decisive: M5.1, M5.2.
- After: S7, L16a.
- Decisive: every send route's body carries the binding's id (the setting off: none); after a same-tenant attach of a second WABA holding the number, the sends name the second; another tenant's attach of it stays
409 waba_owned_by_another_tenant; a send body namingmessaging_account_iditself stays refused.
- After: S7.
- Decisive: M5.1, M5.2; the data endpoint answers a request sealed with Meta's example key, and refuses a bad signature.
- After: S7, L14.
- Decisive: M5.1, M5.2.
- After: S7.
- Decisive: M5.1, M5.2; a group id not in the number's groups is
404with zero vault reads.
- After: S7, L17.
- Decisive: M5.1, M5.2.
- After: S7, L13.
- Decisive: M5.1–M5.3: with the setting off, the agreement route answers without a request to Meta; with it on, one request and one audit event naming the admin key; no other route sends the agreement.
- After: S7.
- Decisive: M5.1–M5.3: with the setting off, a signup that would accept the terms is refused with zero requests; with it on, the acceptance is audited; no request carries the acceptance unless the caller asked for it.
- After: S7, L10b, L12.
- Decisive: M5.1, M5.2.
- After: B1, B2, B2b (the replicas' shared budget), B3, B4.
- Decisive: M5.1, M5.2; a broadcast over HTTP never exceeds its tenant's configured rate under a fake clock.
- After: S7, L15.
- Decisive: M5.1, M5.2.
5. Payments (last)
A payments client module (meta-whatsapp-client; rows 38, 141): India (UPI, payment links, order details and order status, onboarding) and Brazil (Pix, Boleto, payment links, one-click), typed, instead of MessageContent::Raw; the payment buttons of templates (order_details, payment_request). Too large for one pull request, it lands in three:
- After: nothing.
- Kind: additive.
- Decisive: exact JSON, both ways, from
payments/payments-in/upi-intent/dynamic-vpa,payments-in/payment-links,payments-in/pg,payments-in/upi-intentandpayments-in/onboarding-apis; a refund, a configuration's creation and an OAuth link are never replayed after a timeout. - Landed:
client::payments(the messages inclient::messages::payments, re-exported), every example of the five pages an exact-JSON test (payments/tests.rs), every rule the pages state checked first. Not checked locally: the subtotal, the expiration's 300-second minimum and the beneficiaries of shipped goods (the module docs say why). - Review (money, wire format, secrets): an
order_statusmessage refusespending, which thepayments-in/pgparameters table excludes, and checks itsreference_idas anorder_detailsmessage's; the items' origin and importer are not required with the olderpayment_configurationalone (its page makes them optional); a total aboveu64::MAXis refused as such (Order::totalsums inu128, so nothing overflows; the message no longer says "negative"); a refusal of the two OAuth link endpoints that is not Meta's envelope keeps its status, never its body, andPaymentOauthLink'sDebughides the link's path as well as its query (no page says where its credential sits). Tests added for what no test caught: every limit exactly reached, in two-byte characters (every_limit_is_inclusive_and_counts_characters), amounts at and beyondu64::MAX, the lookup's error example,.and..as lookup names, a refund not replayed on a server error. Documented: a replayed deletion can answer an error when the first went through, a refund's amount is not compared with what was paid, and the onboarding calls are refused with131005. - Decided 2026-11-01 (the coordinator, under the owner's delegation, AGENTS.md § Decisions): where the pages contradict themselves, the examples win (
payment_settingsan array,importer_addressan object,partially_shipped,parametersan object); the subtotal is not checked locally, the total equation is; the header image'sprovideris not modelled; theorder_detailsheader is an image only; the answers' fields the tables call required but some example leaves out, or another table calls optional, are optional (merchant_category_code,purpose_code, a transaction'spg_transaction_id);purpose_codeis sent as a string, as both examples do, and a configuration's name has at most 60 characters, an order's limit; the tests put numeric ids where the examples print placeholder names (the-catalog_id,your-media-id).
- After: P1a.
- Kind: additive.
- Decisive: exact JSON from
payments/payments-br/*; aBRLorder checked by Brazil's rules, anINRone by India's. - Landed:
Currency::Brlselects Brazil's rules (OrderDetailsParameters::brl,brl_simplified,OrderStatus::brlwith aPaymentUpdate), India's unchanged; the settingsPixDynamicCode(withPixFlowType, Pix with redirect),PaymentSetting::boleto,OffsiteCardPayand thepayment_link'surialone; every message example of the five pages an exact-JSON test (payments/tests/brazil.rs; the one-click page'spayment_methodwebhook is P1c's), the decisive pair (a_brl_order_is_checked_by_brazils_rules_and_an_inr_one_by_indias,an_order_status_is_checked_by_its_currencys_rules). - Review (fields, limits, mutations): a
BRLheader by media id is refused (Brazil's image object haslinkalone, required; the id is India's), and the item image's refusal is pinned by its reason (an_item_image_is_refused_in_a_brl_order_as_indias: Brazil's requiredretailer_idconflicts with an image anyway, so deleting the refusal left every test green), as is the simplified order's header refusal being Brazil's alone (the_header_rules_are_brazils_only). - Decided 2026-11-01 (the coordinator, under the owner's delegation, AGENTS.md § Decisions):
- A field documented for one currency only is refused in the other's orders (India's
payment_configuration, beneficiaries, header by media id, item images, importers,quick_pay, a link's success and cancel URLs and settings inBRL; Brazil's settings,payment_typebrandpaymentupdate inINR). - Brazil's subtotal is checked against
Order::items_sum(its page says the sale price counts, its examples keep the rule); India's stays unchecked (P1a). payment_settingsare optional inBRL: every Brazil table says Optional.- A
BRLorder_statuswith neitherordernorpaymentis accepted: no page requires either. last_four_digitsmust be exactly four ASCII digits.- Pix codes and keys, Boleto digitable lines, a card's last four digits and
credential_idare payment data shown to the buyer or echoed to the merchant, not secrets: no secret type, andDebugshows them. Meta's page callscredential_id"the ID of the credential" the merchant looks the card up by; the module docs (not Meta's) say never to put a card number or PSP token there. - The
headerBrazil'sorder_statustable lists, with no fields and no example, is not modelled (a gap).
- A field documented for one currency only is refused in the other's orders (India's
- After: P1a, P1b.
- Kind: additive.
- Decisive: the payments pages' webhook examples join the conformance sweep (their manifest status moves from
OutOfScopetoTyped), with new event data snapshots only (no changed one); exact JSON of the template pages' examples.
- After: P1a, P1b, P1c.
- Decisive: M1.3 over the new routes; an order-details send carries the tenant's token.
Done criteria for parity
- Every row of parity.md that is not n/a says done for the library, and done or n/a for the service.
- categories.md says done for every category, or gives the reason a part stays out (an n/a of the parity table).
just ciexits 0 onmainat that commit, with the live tests forced (META_WHATSAPP_RS_REQUIRE_LIVE=1).- The parity-completion report goes to the owner, with everything decided on the way that is not an owner touchpoint, and every choice still left to the owner: the entries left open in OPEN_QUESTIONS.md (on 2026-09-26, #33) and design D5's production mode.
Owner touchpoints
What waits for the owner before parity (AGENTS.md § Decisions); everything else goes into the parity-completion report.
- D20 (a), the BlueOak-1.0.0 licence of
minicbor: asked at S10 as one yes or no question. It blocks the CrateStack adoption (S10–S16) and nothing else: after a refusal,api-axumandstore-postgresstay the defaults, and M4 publishes the client openapi-typescript generates. - The merges of U1–U3 in the owner's cratestack repository. S16's default flip waits on U1.
- Publishing: to crates.io (parity row 115) and to npm (D11, M4). Crate and package names and versions are permanent, and the tokens are the owner's.
- D21, D22 and D24, confirmed before the first release: after it, D21's per-tenant sequences are a released contract, and D22's and D24's deletions cannot be undone.
- L5's erasure and its stored names, confirmed before the first release: D30's scope and D31's default for a person's group messages (redacted, keeping their ids) decide what an erasure deletes for good and what it leaves, D35 what a retention keeps, and the stable identifiers L5 adds (D33's lock SQL,
wa_identity_links,wa_messages.senderand its rule, theerasedkind, migration 4's names: architecture.md § Stable identifiers) become permanent with the release. Each ships swappable today; changing one after the release is a data migration. - New stable identifiers, confirmed before the first release: each store namespace or stored name an item adds after the table in architecture.md § Stable identifiers was last confirmed (B2b's
wa.bot.pacerand its record layout, added and awaiting confirmation; B3'swa.bot.jobs,wa.bot.jobs.due,wa.bot.jobs.sent,wa.bot.jobs.messageandwa.bot.jobs.limited, with their keys' derivations and their records' shapes, added and awaiting confirmation before the first release; S2b's Postgres migration 4, "waba moves": the columnswa_server_wabas.generationandwa_server_wabas.previous_tenant_unbound_atwith its default, the Unix epoch read as "a move before the records began" (UNRECORDED_MOVE), and the tablewa_server_waba_tombstoneswith its columns and itsON DELETE SET NULLrule, added and awaiting confirmation; L11d'swa.es.coexistenceand its record layout, added and awaiting confirmation; L8's SQLite migration 1, its tables and columns, and its Redis conversation layout, added and awaiting confirmation; and L21a'swa.webhook.deadletterandwa.webhook.deadletter.contact, with their keys' derivations and their records' shapes, added and awaiting confirmation before the first release), for the same reason as L5's. - OPEN_QUESTIONS #46, a number under several Messaging Accounts (Meta's account model evolution): keying the service's numbers by
(waba_id, phone_number_id)is a primary-key migration of stored data. Until the owner answers, one binding holds a number (design D37, M5c5). Asked with the release questions, or when Meta publishes phase 2's webhooks (L16b), whichever comes first. - The token vault's stored format, if S2's last attach window is to close: a vault record naming the binding it was stored under would let a take-back tell its own token from another tenant's stored in the microseconds between an attach's binding check and its store (design §8.1, S2's "Left"). The record's format is stored data under
wa-rs/token-vault/v1, so changing it is a data migration: the owner's, and asked with the release questions, not before.