Skip to content

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 ci exits 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-cratestack exists (S10). A route that lands before S10 goes into meta-whatsapp-server-api-axum, and S13 carries it over: its groups cover every route that adapter has when S13 starts. api-axum is permanent, the swap target (design D26).

Order ​

WaveItems (in parallel only where their after: lines allow; at most three building at once)
0this plan (parity, categories, roadmap, decisions)
1S1 (server core), B1 (bot framework), U4 (upstream issues)
2S2, 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)
3S5a–S5e, S6, S7, S9
4S10 (waits on the owner's answer to D20 (a)), S11, S12; M2a–M2f
5S13–S16 (S16 last, gated on U1's merge); M3a–M3f; B2–B4
6M4; M5a–M5l
7S17, S18 (MongoDB)
lastP1a–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: cmp of openapi/v1.json against main is equal; every server test passes with only its imports changed; cargo tree -p meta-whatsapp-server-core shows no axum, hyper, tower, utoipa, sqlx or cratestack (http enters only through the library's HttpTransport port).
    • Landed: the crate, its ports and the Backend bundle, with the memory and Postgres implementations in meta-whatsapp-server; openapi/v1.json unchanged. 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 the Authorizer that 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::store is crate-private (SR-M1), Debug never 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 in failed, wait for S2.
    • Outbox::insert must 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 typed RouteGuard on NewEvent carries what the check needs, so no backend redoes the routing rules from strings.
    • Referential rules: bind_waba to a missing tenant has its own outcome (BindOutcome::NoSuchTenant), and bind_waba and delete_tenant on one tenant serialize.
    • Purges take no lock of their own and return a count; housekeeping takes one LeaderLock turn per round. LeaderLock is 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's Authorizer takes an injected Clock.
    • 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::forget deletes whatever the WABA's binding and token became since the OwnedWaba was 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_required marking conditioned the same way (the failed race, which the core's second review found): OwnedNumber::failed and OwnedWaba::failed mark the numbers of whatever binding the WABA has when Meta's 190 comes 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 and attached_at), and marks nothing when it moved.
    • After: S1.
    • Decisive: on memory and, live, on Postgres: bind_waba to a missing tenant answers NoSuchTenant (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 answer 503; an OwnedWaba made before its WABA was attached again forgets neither the new binding nor the new token; an OwnedNumber or OwnedWaba made before its WABA was unbound and attached again, to the same tenant and to another, whose call Meta then answers 190, leaves the new binding's numbers connected in 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 is outbox::RouteGuard (a GuardedBinding and the event's date), set by events::outbox_row from the routing's Holder; a tenant without a guard is operator-only, and the memory outbox belongs to its MemoryStore and checks under the store's lock. BindOutcome::NoSuchTenant; bind_waba locks the tenant FOR KEY SHARE on Postgres. The purges return a count and take no lock; serve::round takes one HOUSEKEEPING turn, leased for HOUSEKEEPING_LEASE (5 minutes; on Postgres the turn's session is ended past it, and the key is still HOUSEKEEPING_LOCK). Authorizer::with_clock, which the service hands the webhook pipeline's clock. StorageError::Busy in the library (additive). PhoneNumberId and WabaId in the outbox types. SR-L2 and failed: a BindingEpoch and, for an OwnedWaba, the vault record's TokenVersion (the library's TokenVault::get_versioned and delete_if_unchanged, additive); RecordStore::unbind_waba_if and set_waba_status_if; and a capability is made only from a token read while its binding held (the binding read again after the vault, 503 when 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 is 503, not number_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 answering 503; OwnedWaba::still_bound is asked before every unsubscribe_app with a capability's token (the tenant's disconnection, the admin unbind, a tenant's deletion), and forget warns 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_token reads the binding, stores with the library's TokenVault::store_versioned, additive, and reads the binding again: moved, it takes back exactly its own write with delete_if_unchanged and answers 503; 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 a 190 answered after the refresh to a capability made before it marks its numbers again (the vault version covers forget there, not failed; attached_at cannot 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 around attached_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 answers 503 (retryable); the admin's unbind of a WABA without a usable token, and the admin's own 190 right 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_at for 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 comparing attached_at.
    • After: S2.
    • Decisive: after a move A→B, an undated history_synced redelivered within the window reaches no tenant; with CurrentHolder it 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; a 190 from a capability made before a same-tenant refresh does not mark the numbers reconnect_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, None once 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 into WabaBinding::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 are events::MoveRules and events::UndatedAfterMove (Inbound::with_move_rules; the service's WA_SERVER_UNDATED_AFTER_MOVE, …_WINDOW, 169 h, and WA_SERVER_MOVE_SKEW_MARGIN, 0 s by default). The rules are one function, outbox::RouteGuard::admits, which the routing (events::owner, after_move its 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 same FOR KEY SHARE lock. The generation is WabaBinding::generation, compared by BindingEpoch in 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 under CurrentHolder and past it, a first onboarding always) and an_event_of_its_bindings_first_second_reaches_a_first_onboarding_only, the service's an_event_of_its_bindings_own_second_reaches_a_first_onboarding_only over HTTP, and capabilities::failed_before_a_refresh_marks_nothing; the records suite's a_move_is_recorded and a_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 migrates main's database forward (migrations 1 to 3 unchanged, 4 added once, none on serve) and routes main's binding as before. By mutation: the window check removed from RouteGuard::admits fails 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, and a_first_onboarding_keeps_its_first_second on 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 fails a_refresh_moves_the_generation on memory and live, and failed_before_a_refresh_marks_nothing run alone; the skew margin applied without a previous tenant fails its unit test and the margin case on memory and live. openapi/v1.json and 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 RecordStore port 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 to 0s; 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 in events::owner and RouteGuard::admits and covers every undated event; a capability or an attach meeting a refresh answers 503 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_at was 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 past clock_timestamp() and the tombstone's time, and the race suite's a_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 (503 still, 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_timestamp out of range, folded at plan time): RouteGuard::dated_bounds stops 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 in records::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_tenant fails on memory and, live, on Postgres; a Backend whose kv() returns a new store on each call fails the same-data case.
    • Landed: meta_whatsapp_server_core::conformance behind the feature conformance: conformance::run takes 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 three RouteGuard cases, 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's Authorizer) and races (its race suite and racing_a_reattach). The server's tests/common/*_suite.rs are gone; tests/store.rs runs the whole of it on memory, and tests/live_postgres.rs live 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_insert fails; without forget_tenant in MemoryStore::delete_tenant, invariants::deleting_a_tenant_purges_its_stream; with a kv() made anew per call, invariants::every_accessor_hands_out_the_same_data. Kept as tests: a backend whose kv() 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 and invariants::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 of run fails a test. Its sabotage review ran the live half on a Postgres 16 of its own: it passes, and there removing the purged_through update from delete_tenant fails deleting_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 of run other 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 Backend defined outside the server crate, with no edit to serve.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 (new and with_settings, which took the stores one by one, are gone): it builds the vault on the bundle's KvStore and the webhook pipeline on its KvStore (dedup), ConversationStore (the inbox), Outbox and records; Inbound keeps the app secrets, the verify token and the clock, and no store. serve::serve_with(config, Arc<dyn Backend>), and serve_until, the same with a shutdown future of the caller's, migrate (unless WA_SERVER_MIGRATE=skip), serve, run housekeeping and close over the bundle, and read no config.storage; serve::serve_from(config, factory) opens the backend a factory gives for config.storage and serves it (serve::serve: the in-tree factory). Housekeeping is the core's, housekeeping::run and round over a Backend (serve::Sweep, and a round without a leader lock, are gone; a zero interval is taken as a millisecond, and no round starts once stop completed). The command line opens every backend through a serve::BackendFactory (BuiltinBackends: Postgres, memory; cli::run_with takes an integrator's), serve::open migrating per WA_SERVER_MIGRATE. The server's test harness serves its stores through a Backend of its own too. Decisive: tests/composed.rs serves the API over real listeners on a Backend defined in the test (memory's ports in a struct of its own, counting what it is asked), with no change to serve.rs: the API reads and writes its records, the vault rotation re-encrypts a token stored in its KvStore, 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's tests/visibility rather than a doctest (they pin the compiler's error, which a compile_fail doctest cannot on stable): app_state_from_loose_ports (a MemoryStore is not a Backend, E0277), app_state_new (no new, no with_settings, E0599), inbound_stores (the pipeline takes no store, E0061) and app_state_backend (the state's backend is crate-private, as its records are, E0624). By mutation: serve_with and serve_until taking an Arc<MemoryBackend> fail tests/composed.rs to 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 MemoryStore makes app_state_from_loose_ports compile, and a loose AppState::new again makes app_state_new mismatch. The sabotage review added the cases four mutations survived: migrating whatever WA_SERVER_MIGRATE says (tests/composed.rs, a second case), serve_with alone taking an Arc<MemoryBackend> (a compile-only use there), housekeeping polling its tick before stop and a zero interval panicking in the timer (tests/housekeeping.rs). It also fixed a race older than S4: the SIGTERM handler was registered only once the listeners were spawned, so a SIGTERM as they began answering ended the process (signal 15, seen once in binary::development_on_memory_prints_a_one_time_admin_key); it is registered before they are bound (serve.rs's unit tests sigterm_is_caught_before_the_signal_is_awaited and the_stop_signal_is_made_before_the_listeners_are_bound). openapi/v1.json unchanged; 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_with is 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's Storage, whose database variant is Postgres(DATABASE_URL), so another database reads its URL from DATABASE_URL until 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.json byte-identical.
    • Landed: meta_whatsapp_server_core::admin: every /v1/admin operation is a method of admin::Admin, which Admin::new(&Authorizer, &AdminCaller) opens only once the Authorizer admits the admin (403 forbidden for another's, nothing read), over that Authorizer's records, vault and tokenless client (the records through a crate-private Authorizer::records, so no adapter reaches them, and what a service writes is what the order reads; the core's UI case authorizer_records pins it, E0624, and fails with the method made pub). 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 a ServiceError: 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 at admin::MAX_WABA_NUMBERS, bind, Authorizer::store_token, subscribe with step and resumable), the binding's read, the unbind (Admin::unbind_waba, and Admin::unbind on a binding as read) and the vault's rotation. Their audit events are the core's (server_core::audit, target audit, the same fields; the server's telemetry::audit, tenant_audit and Subject re-export them), and a failed Graph call is told to an observe::Observer, which the server's Metrics implements (wa_server_graph_errors_total, the same codes as before, the attach's listing counted before its 422 remap). admin::mint, admin::MintError, admin::allowed_tenants and admin::is_valid_name need no capability: the command line's bootstrap uses them on the database directly (and the server's api::admin::mint, MintError and MAX_WABA_NUMBERS re-export them); OwnedWaba::forget_or_busy is the core's too. The handlers parse, call and map; auth::OwnedWaba::unbind_for_admin calls Admin::unbind. Decisive: conformance::admin (in conformance::run) attaches a WABA through the service, then has another tenant attach it, with a ScriptedTransport and no HTTP layer: refused 409 waba_owned_by_another_tenant before 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's tests/admin.rs, on the service's MemoryBackend (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 answers 503: 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, and openapi/v1.json is 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 the Authorizer'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 (a RUST_LOG directive 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's tests/admin_audit.rs); Meta's failures counted by code, the attach's listing among them (the_admin_operations_count_metas_failures); Admin::new refusing another Authorizer's admin with nothing read (the core's tests/capabilities.rs); a platform key's scopes; a missing tenant's key listing, and its attach answered 404 before Meta is asked.
    • After: S4.
    • Decisive: called with no HTTP layer, a scripted timeout reports may_have_been_sent and the same key replays it with no second request (M1.4's case); openapi/v1.json byte-identical.
    • Landed: meta_whatsapp_server_core::idempotency::run(&Keeper, &Authorizer, &Caller, key, &Fingerprint, operation) runs an operation under a key and answers an idempotency::Answer (a status, a body, and replayed) or a ServiceError: 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 (the Keeper's RenderError) and the key released when it proves nothing was, a repeat's outcome told to the Observer (its new method idempotency, which the server's Metrics counts as wa_server_idempotency_total, as before), and the 504 timeout a cut request keeps (the Claim's drop). It takes the tenant from a Caller its Authorizer admits (another's is 403 forbidden, before the key is read), not from a bare TenantId. The fingerprint is unchanged (method, path, body: S6 changes it). The messages are meta_whatsapp_server_core::messages: the send request as the request carried it, free of utoipa (messages::SendMessage and its objects), refuse_unsupported (Direct Send and the types the service does not send, on a JSON body), message (the library's OutboundMessage, checked in the order and on the fields of before), recipient and is_e164 (strict E.164 with its +), and the Messages service on an OwnedNumber (opened with Messages::new, 403 forbidden for a number another Authorizer made, Authorizer::admit_number; send, answering messages::Accepted, the accepted message, not a status; and mark_read, with or without the typing indicator), its failed Graph calls told to the Observer (wa_server_graph_errors_total, the same codes). request::meta_object (a Meta object read with no key dropped) moved there too; the server's api::common::meta_object wraps 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); its idempotency::run (crate-private still, now taking the Caller) hands the core's the service's records, settings, §5.1 error body and metrics, and renders the answer (Idempotent-Replayed: true on a replay, a kept error's code tagged for the metrics). Decisive: conformance::messages (in conformance::run), with a ScriptedTransport and no HTTP layer: a digits-only phone number refused on to.phone before any request; a scripted timeout 504 timeout with may_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); a 131047 409 customer_service_window_closed that releases its key, the same key then sending and its success replayed; another Authorizer's Caller for the same key refused 403 with nothing sent. It runs on the core's own memory backend (tests/messages.rs, the admin suite's records moved beside it into tests/memory, with idempotency records that lease and expire), on the service's MemoryBackend (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, a 131047 included, 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's Observer calls dropped, Metrics' idempotency emptied, 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's parent_user_id dropped in the conversion each passed every test. Each now fails one: the suite tells the Observer'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's tests/messages.rs gains repeats_and_metas_failures_are_counted, a_kept_answer_expires_after_the_ttl and the read route's blank id; api::messages gains every_field_a_request_carries_reaches_metas_object and every_field_of_the_accepted_message_is_answered. Messages::new admits its number (Authorizer::admit_number, as Admin::new its admin, tests/capabilities.rs: the_message_services_refuse_another_authorizers_number), so nothing is sent with a number another Authorizer made. The idempotency records stay the Keeper's, not the Authorizer's (as S5a did with its records): the port's methods take a tenant as given and whoever holds the Backend reaches them (Backend::idempotency), so behind the order they would guard nothing, for a new parameter of Authorizer::new at its eleven callers. Every message, idempotency and read-receipt route test passes unchanged, and openapi/v1.json is 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's api::messages::MessageType::ALL is gone (the core's messages::MessageType::ALL is the list); media and template creation run under the core's run too, 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.json byte-identical.
    • Landed: meta_whatsapp_server_core::numbers: Numbers on an OwnedNumber (Numbers::new, which admits it, Authorizer::admit_number): details (numbers::NumberDetails, NUMBER_FIELDS), profile and update_profile (a numbers::ProfileChange as the request carried it, answering numbers::BusinessProfile; an empty change only reads), and Waba on an OwnedWaba (Waba::new, which admits it through the new Authorizer::admit_waba): disconnect (still_bound, then Meta unsubscribes the app, then the token and bindings go; 503 for a WABA that moved, nothing deleted when Meta refuses). meta_whatsapp_server_core::templates: the TemplateCache (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's request::graph_id, moved from the server; templates::definition for a creation's body), and Templates on an OwnedWaba for a Caller (Templates::new admits both and opens only on a WABA of that caller's own tenant, else 404 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), create and delete (each dropping the WABA's cached pages; a deletion audited as the tenant's, target audit), answering domain values free of utoipa (Template, TemplatePage with Meta's raw cursor, TemplateCreated). Failed Graph calls are told to the Observer (wa_server_graph_errors_total, the same codes; a refused template id by Meta's code though it is answered 404). The handlers parse, convert (every field named on both sides, no ..), call and render; template creation still runs under idempotency::run (Idempotent-Replayed on a replay). Decisive: conformance::templates::another_tenants_template_id_is_not_found_with_zero_vault_reads (in conformance::run), with a ScriptedTransport and a vault whose every read is recorded, no HTTP layer: B's caller naming A's WABA is 404 (step 4) with zero vault reads and no request; B's caller handed A's capability is refused by Templates::new, 404, zero vault reads; through B's own WABA, A's template id is 404 without 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 is 404 too; a deletion of A's template by id through B's WABA deletes nothing (no DELETE); another Authorizer's caller or capability is 403; 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's MemoryBackend (tests/store.rs) and live on Postgres (tests/live_postgres.rs). By mutation: the ownership filter of Authorizer::owned_waba dropped fails its step 2 on all three backends (no earlier suite of conformance::run caught it); the tenant check of Templates::new dropped 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 in conformance::run); the lookup's log line under another target (the core's tests/templates_logs.rs); Meta's details dropped from the number services' and the disconnection's errors, and a profile change's email dropped on its way to the library (conformance::numbers, now posting all six fields). The service's tests/templates.rs and tests/numbers.rs pass unchanged, and gain metas_failures_on_templates_are_counted and metas_failures_on_numbers_are_counted (the handlers' observed_by(state.metrics()) dropped fails each); openapi/v1.json is 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's vertical reads into the library's Vertical, whose catch-all takes a name it does not know, which goes to Meta as before; a list's limit is the adapter's to bound (the server's PageQuery: 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's auth::OwnedWaba keeps failed, failed_on_object, still_bound, forget and forget_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: Media on an OwnedNumber (Media::new, which admits it, Authorizer::admit_number), over the adapter's transfer slots (media::MediaSlots, the core's ratelimit::Slots, each slot of the number's tenant's share): transfer_slot (an upload's, before its body is read), upload (a media::Upload, made only by Upload::new: checked_type, Meta's sizes, filename), download (within media::DownloadLimits, made only by parse or new within the caps, MAX_WHOLE_DOWNLOAD whole) and delete (looked up first, with phone_number_id). A download answers media::Downloaded: the content type, Meta's SHA-256, and the whole verified file or a media::MediaStream, a futures::Stream of the library's Bytes (no HTTP framework; the core gains bytes and base64, both already in the library's graph) held one chunk behind, which yields StreamStopped::Integrity instead of its last chunk on a mismatch (TooLarge past max_bytes, Failed) and holds its slot until dropped. The handlers keep the multipart reading (framing budget, media::upload_cap as the file streams in), the upload's idempotency::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 (in conformance::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; past max_bytes nothing; a whole tampered file is 502 integrity with no byte; the reported size, an unusable digest and a URL off lookaside.fbsbx.com (502 upstream, the token not sent) are refused before the download; another node's or a refused media id is 404; another Authorizer's number is 403; a slot of the tenant's share comes first. It runs on the core's memory backend (tests/media.rs), on the service's MemoryBackend (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, exactly max_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 off https://lookaside.fbsbx.com is 502 upstream with nothing sent to it, and that a 190 from Meta's URL marks the number, and tests/media_logs.rs the 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' in parse and in new, an upload's kind limit in Upload::new and in upload_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 own 190 not marking, and either order swapped. The service's tests/media.rs passes 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 (parse and new each hold the other's). openapi/v1.json is byte-identical. Left: the server's auth::OwnedNumber keeps failed and failed_on_object (public, now unused by the handlers, as S5c left OwnedWaba'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) and Pipeline, built on a Backend alone (Pipeline::new(Inbound, &dyn Backend, Arc<dyn Observer>), the stores the server's Events took before). Pipeline::deliver(signature, body) takes the body as read and its X-Hub-Signature-256 value (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 of MAX_DELIVERIES_RECORDING turns within RECORDING_WAIT (Busy), runs the library's WebhookHandler (3 MiB, TooLarge; a signed body that is not a webhook one unparsed event; each event under its dedup lease, InFlight while another request holds one) into a sink private to the core (routed by the records, events::route; the inbox when owned; the outbox row; Busy for a busy outbox, Failed for a failed store, the lease released), and answers Delivered only once every event is recorded or was already. Refused::status and outcome are the answer and the metric label as data (DELIVERED for a recorded one); the sink's counts go to the Observer's three new required methods (webhook_event, webhook_duplicates, webhook_failure, the server's Metrics counting them as before), and its log lines keep the target meta_whatsapp_server::events (inbound::LOG_TARGET) and their fields. The split: no meta-whatsapp-server-http crate (S7 makes it); what needs a listener stays the server's (api::webhooks: the signature header's shape before the body, MAX_DELIVERIES_IN_FLIGHT places, 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's Refused exhaustively. Decisive: conformance::webhooks::routing_is_an_allow_list_with_no_listener (in conformance::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 the Observer by 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's MemoryBackend (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's routing_is_an_allow_list); the dedup lease dropped fails an_event_is_recorded_once_under_its_dedup_lease; the answer given before the recording (spawned) or whatever it did fails the_answer_comes_only_once_every_event_is_recorded (and most of the suite); the core's signature check moved after the handler fails a_delivery_is_verified_before_anything_is_stored (a forged delivery waits for a turn) and, in the service, only log_rate.rs (the library logs each forged delivery); an unowned event recorded in the inbox, the observer's counts dropped, a busy outbox answered 500 and a moved log line's target each fail a case too (the last only the core's tests/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-clock a_delivery_with_no_turn_to_record_is_503_after_the_wait; now a_delivery_waits_for_a_turn_to_record (in conformance::run: with every turn held, a fifth delivery claims no lease and asks for no row) and a_delivery_with_no_turn_is_busy_after_the_wait (the core's tests/webhooks.rs, on a paused clock: 503 busy after RECORDING_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 (now tests/webhooks_logs.rs), and a_number_moved_while_its_event_records_reaches_neither_tenant runs 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, and openapi/v1.json is byte-identical. The server's public but unusable events::ServiceSink is gone (the sink is private to the core, pinned by the UI case inbound_sink), and Inbound::clock is 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 (a compile_fail doctest).
    • Landed: meta_whatsapp_server_core::operation::Operation names the sixteen tenant operations by id (the HTTP adapter's operationId, its handler's name) with their scope. A request's rate class is its operation's (RouteClass::of(Operation)), and RateLimiter::charge(tenant, operation) is the only public way to spend a budget (check is crate-private: the UI case ratelimit_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, HEAD as its GET) and charges it, 500 internal for 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//profile is get_profile's, charged, then 404), matching no other). The fingerprint is the operation and its canonical input, defined once in the core: Fingerprint::send_message, upload_media and create_template hash 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 generic json and parts are private, so every adapter derives the same fingerprint), its known answers pinned again apart from the code. ErrorCode's constants, ErrorCode::ALL and CODES come from one codes! table in error.rs, kept equal to §5.2 by the core's own the_codes_are_the_designs (beside the service's) and each constant's name to its code by each_constant_is_named_after_its_code; ServiceError::new and the server's ApiError::new take an ErrorCode, so a misspelled code fails to compile (the compile_fail doctests on ErrorCode: E0599 for a misspelled constant, E0308 for a string), and the source scans for string codes are gone with the strings (the server's schema markers are ErrorCodeSchema and KnownErrorCodeSchema, their schema names unchanged). The Authorizer's admin writes (store_token, rotate_vault, forget_for_admin, graph_failed_for_admin) are crate-private: an adapter reaches them only through admin::Admin, opened by Admin::new's admission (S5a's pattern), and each still checks the admin (the UI cases authorizer_*; the conformance case capabilities::an_admins_vault_writes_take_an_admitted_admin, in conformance::run: the core's memory backend, the service's and live Postgres). No dependency of either crate was unused (rustc's unused-crate-dependencies on both libraries, the dev-dependencies by grep; cargo udeps is not in the pinned toolchain), so none was dropped; just features checks the core's library alone and documents it with conformance. Decisive: two_spellings_of_one_path_replay_the_same_key (the service's tests/messages.rs, with the template and upload ones beside it), which fails with uri.path() back in the fingerprint (the percent-encoded repeat answers 422 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) and a_read_served_by_post_lands_in_the_read_budget (the service's tests/rate_limits.rs: an RPC-shaped router whose reads are POSTs, behind the service's own guard and limiter), the latter failing with the class read from the method; in /v1 every read is a GET, so no route of the HTTP adapter can tell the two rules apart (v1s_routes_keep_their_class pins that the budgets did not move); and the compile_fail doctests. By mutation: an entry added to or dropped from codes! fails the_codes_are_the_designs (core and service), a constant named after another code each_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 and the_admin_services_refuse_another_authorizers_admin, and store_token public again the UI case; a read moved to the send class fails an_operations_class_is_the_tables and v1s_routes_keep_their_class. openapi/v1.json is 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 answered 422 idempotency_key_reused, never a second send, until its record expired (WA_SERVER_IDEMPOTENCY_TTL from 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); charge taking a bare &TenantId; the server-crate renames (ErrorCodeSchema, KnownErrorCodeSchema) and auth::guard's Operations argument; graph_failed_for_admin among the crate-private admin writes; authz::rotate_vault public 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 by the_legacy_fingerprint_is_mains; the HTTP adapter adds it with Fingerprint::or_legacy; a claim stores the new one). Tests: the conformance case messages::a_key_claimed_before_s6_replays_under_its_legacy_fingerprint (in conformance::run: a record stored by hand under the legacy fingerprint replays with no send, another body or the new fingerprint alone is 422, a fresh claim stores the new one), the core's a_record_under_the_legacy_fingerprint_is_the_same_requests, and per route the service's a_key_claimed_before_s6_replays_after_the_upgrade, a_creation_key_claimed_before_s6_replays_after_the_upgrade and an_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 the 422 steps (and the two-spellings tests); a claim storing the legacy fingerprint fails the conformance case and the two-spellings tests; the send handler without or_legacy fails its HTTP test; uri.path() in the primary fingerprint still fails two_spellings_of_one_path_replay_the_same_key.
    • After: S6.
    • Decisive: openapi/v1.json byte-identical; each store crate runs the core conformance suite (live for Postgres, with META_WHATSAPP_RS_REQUIRE_LIVE=1); the binary builds with only api-axum and store-memory enabled; live, a Postgres database migrated by main'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-memory and store-postgres (all default; without api-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.json is generated by the adapter and stays committed beside the binary, byte-identical (cmp against main, the openapi command, and tests/openapi.rs). The memory backend runs conformance::run in its crate's tests/conformance.rs (the_memory_backend_passes_the_conformance_suite, with the cases it must refuse), the Postgres one live in its crate's tests/live_postgres.rs (live_postgres_backend_passes_the_conformance_suite, with the Postgres-only cases: migrations, locks, purges, a deadlocked insert), which just test-live runs with META_WHATSAPP_RS_REQUIRE_LIVE=1. just features builds the binary with api-axum and 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 by the_migrations_and_their_checksums_are_pinned; the table and the lock key unchanged): the binary's live_postgres_a_database_mains_binary_migrated_starts_this_one_with_no_migration migrates a database as main's binary did (its lock, the library's migrations, sqlx's migrator with main's checksums) and starts the split binary's migrate and serve on it: both exit 0, /readyz is 200, and both history tables are row for row unchanged (installed_on included); an edited file, a renamed table or a renumbered migration fails it. Run once more against main's own binary (dda06c9, built from its tree): migrate and serve by main, then by the split binary, then migrate by main again, 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's Grant, which only AppState::from_backend's caller is handed (UI cases grant_from_state and grant_from_conversions: no constructor, no From and no FromRef makes 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::capability and OwnedWaba::capability take 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 own Caller and AdminCaller extractors 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's LOG_TARGET is its path before the split, each line names it).
    • Decided (coordinator, under the owner's delegation, each swappable): the Grant, with AppState::from_backend returning (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.json committed beside the binary, which prints it, rather than in the adapter's crate (to swap, move the file and the paths tests/openapi.rs, the skills gate and just skills-ts read); the features api-axum, store-memory and store-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 ApiError in the HTTP crate's error.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-live with META_WHATSAPP_RS_REQUIRE_LIVE=1 and no MongoDB URL fails; with it, a live smoke test commits a multi-document transaction.
    • Landed: mongodb in compose.test.yaml (127.0.0.1:57017) and as a devcontainer sidecar: mongo:7.0.45-jammy with --replSet rs0, whose healthcheck runs rs.initiate() when rs.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 carries directConnection=true, as the member address is the container's own: just test-live exports mongodb://127.0.0.1:57017/?directConnection=true (and starts Compose when it is unset), the devcontainer sets mongodb://mongodb:27017/?directConnection=true, and just test-live-clean drops the wa_test_* databases (through mongosh, which the devcontainer's image gains). The smoke test is live_mongodb_replica_set_commits_a_multi_document_transaction (meta-whatsapp-adapters' tests/live_mongodb.rs, in test-live's live_ 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 under META_WHATSAPP_RS_REQUIRE_LIVE=1 it panics. Its driver, mongodb 3.9 (Apache-2.0), is a dev-dependency there, without default features (rustls-tls is rustls on ring) and with rustls-tls-aws-lc, which builds its configuration on an explicit aws-lc-rs provider: no library build links it, and it enables no ring. CI needs no workflow change (just ci runs test-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, with rustls-tls-aws-lc and bson-3, until S17 makes it an optional dependency; the healthcheck's start_interval of 1 s and interval of 30 s (a check every 2 s cost half a core); mongosh 2.14.1 in the devcontainer image; MongoCleanup in live_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 features of a test crate shows no ring.
    • 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 .cstack schema 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 deny passes and fails again when the exception is removed; a live Postgres test with sslmode=require passes in a binary that links both providers (sqlx picks ring itself 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_many as the system principal returns the rows; the same call under a tenant's context returns none; removing auth().isSystem() from the model's @@allow fails the first.
    • After: S10.
    • Decisive: a workspace test reading Cargo.toml fails when a cratestack* 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.cstack without regenerating fails the gate; a request the client builds carries content-type: application/json.
    • After: S13, S14, S15, and U1 merged by the owner.
    • Decisive: just ci with default features; the errors suite fails if the CrateStack adapter answers an error in CrateStack's own shape; the binary built with api-axum still serves openapi/v1.json byte 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 next stops the handler; a 5000-character text splits into exactly two messages at a paragraph boundary; a sender with no wa_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 42 runs the subcommand with 42 as its only argument, and /order alone 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 in CommandInfo and CategoryHelp. The decisive tests, in crates/meta-whatsapp-bot/tests/subcommands.rs: order_status_runs_the_subcommand_and_order_alone_the_parent (fails when the match runs the parent instead) and a_subcommands_own_cooldown_refuses_a_second_run (fails when the cooldown is read from the parent); in src/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 (--dryrun for --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 bot Refusals decides what the user is told; the tests are a_command_without_flags_keeps_dashes_as_arguments and an_unknown_flag_is_refused_with_the_usage. Not in it: nested subcommands, short or space-separated flags, and typed flag values (a handler parses Args::flag itself).
    • 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_rich sends a rich::RichReply (markdown, image, image_with_caption, suggestions, products, list_button; to_messages builds without sending) of rich::Suggestions (new, command, description), each part as the client's own builder (ReplyButtons, ListMessage, ProductCarousel, Image), all checked with OutboundMessage::validate before the first send. The decisive tests, in crates/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) and a_javascript_image_url_is_never_sent (fails without the URL check); a_failure_midway_stops_the_reply_and_names_the_step and a_timeout_midway_is_not_resent_or_followed fail 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 an Error::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 more markdown part; Suggestion::command sends 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 absolute http or https URL with a host (Meta fetches it; its page refuses no http, so http: 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) unless RichReply::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_messages gives the messages to send another way, and the Markdown goes through the bot's MarkdownRenderer. Not in it: an image by uploaded media id, Markdown's own ![alt](url) as 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_sent check fails the test).
    • Landed: rows 89 and 92 done in the library (Broadcast, Pacer, TokenBucket, BroadcastPolicy, BotBuilder::pacer, PacedGroups and PacedGroup for group operations). Each person once by default (BroadcastBuilder::dedupe); lines streamed to a ReportSink for long lists; every retry paced (Client::with_retry), and resent only when Error::may_resend holds, 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_UPGRADE fixture moves that number's sends from 12.5 ms to 1 ms apart on a ManualClock; removing the set_rate call fails the test.
    • Landed: ThroughputUpgrades (module throughput), wired in with BotBuilder::follow_throughput_upgrades (a listener of phone_number_quality_updated) or delivered to as an EventSink without 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 a NumberDirectory says which phone number id that is (default KnownNumbers: registered by WABA and display number, compared by their digits); an unknown number, an ONBOARDING, a FLAGGED or 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::observe returns the number, to record it yourself; no store namespace added). The rate an upgrade sets is a setting (ThroughputUpgrades::rate). The decisive test, in crates/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_rate fails when the rate goes to every number, an_upgrade_of_an_unknown_number_changes_nothing when the WABA is not part of the match, only_a_throughput_upgrade_changes_a_rate when an unknown value counts as an upgrade, an_upgrade_is_idempotent_and_never_lowers_a_rate without the never-lower check (made with the set under one lock), a_middleware_that_stops_the_event_stops_the_upgrade pins the listener's place, the_logs_and_debug_name_no_number the logs (alone in tests/throughput_logs.rs: its capture is a thread's subscriber, and tracing caches call sites across threads), and without_following_upgrades_the_fixture_changes_no_rate off by default. Decided by the coordinator (AGENTS.md § Decisions, each swappable): the ThroughputUpgrades shape 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 any NumberDirectory); in memory only, a restart or another replica back at the configured rate (documented; recording upgrades is the integrator's, no KvStore namespace, which would be a stable identifier and is the owner's to name); every other event value 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 one MemoryKvStore at 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 (module pacer, opt-in: TokenBucket stays the default). Each number's record, under PACER_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, expiring KvRateLimiter::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), then StorageError::Busy; a store error, contention past the bound or a record of another version fails closed (the booking fails, nothing is sent: a broadcast ends BroadcastEnd::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, and ThroughputUpgrades takes any limiter (ThroughputUpgrades::new generic, ThroughputUpgrades::shared over Arc<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 on KvRateLimiter), and the schedule never moves back. The decisive test, in crates/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_through and an_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_expires the TTL, and the unit tests the_pacer_key_is_pinned_and_hashed and the_pacer_record_is_pinned the namespace, key and bytes. The pacer needs no new KvStore behaviour (get, put_if_absent, compare_and_swap with Expiry::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; TokenBucket does 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 on StorageError::Busy too, 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::release gives back the last slot only, one level deep; the shared schedule never moves back; the name KvRateLimiter. Pending the owner (a stable identifier): the namespace wa.bot.pacer and 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 131049 recorded 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 (the 131049 path as a bot listener or an event sink), TickReport, JobRun, JobEnd; five store namespaces under wa.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, in crates/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) and a_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_lost and a_rerun_into_the_minute_it_ran_in_is_not_lost (the cursor's second read, the page written again). The runner paces through any Pacer, B2b's KvRateLimiter included (jobs_pace_through_a_pacer_shared_by_replicas). Not in it: a job-store trait (B3b; here the swap is the KvStore adapter, 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) and run_until stops 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 KvStore adapter until B3b): the namespaces wa.bot.jobs, wa.bot.jobs.due, wa.bot.jobs.sent, wa.bot.jobs.message and wa.bot.jobs.limited; minute buckets of 256 ids a page (BUCKET_SECONDS, BUCKET_PAGE_CAPACITY); a 2-minute lease renewed at half (JobRunnerBuilder::lease); the 131049 skip 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 concrete KvJobs, swappable through its KvStore adapter, 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: KvJobs implements 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 messages and conversations on 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 existing messages_conversation_idx: no migration and no new stable identifier. In the bot, module retention: RetentionPolicy (max_age, max_messages_per_chat), applied once (RetentionPolicy::apply) or on the bot's Timer (RetentionSweeper, every hour by default, restricted to the bot's numbers with number). The decisive tests: the_age_filter_deletes_exactly_what_is_older_in_memory and its live_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), and a_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 case trim_keeps_the_newest_rows_of_each_conversation (memory, and live under both erasure modes); a trim deleting the summary from its own snapshot fails live_postgres_a_trim_keeps_the_summary_an_append_moved; a trim in two statements, which loses the message appended between them, fails live_postgres_trims_racing_appends_keep_every_new_message (three runs of three); a trim without the purge lock fails live_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 one BEGIN IMMEDIATE transaction (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, and live_redis_a_trim_beyond_one_script_takes_several fails a trim stopping after one script. The server compiles and passes unchanged (it implements no ConversationStore); 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 fails the_sweeper_sweeps_every_number_given_and_no_other, and a sweeper stopping at its first failed sweep (or logging more than counts) fails retention_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 mirroring purge_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; both RetentionPolicy::apply and RetentionSweeper; 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::code became an Option<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; onboard and onboard_with_approval refuse a code-less request with a ValidationError on code before any request. The decisive test is a_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 serialized SessionInfo, and its two requests (subscribe, register) are asserted exactly, before the error the same request gets from onboard; a for_resume that put a placeholder code in would make that onboard send a third request, the code exchange, and fail the request assertion. a_code_less_coexistence_resume_never_registers pins the coexistence refusal on that path (finish_kind set 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 counted resume.
    • 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 identities of 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::reply sends a free-form reply it refused before; after control_taken, a reply is refused locally with zero requests; after a user_id_update, Inbox::identities of 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-live now runs the facade's live_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 an ErrorKind of its own, ThreadOwnedElsewhere (no Graph code yet), which the service answers 409 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); a user_id_update's wa_id is linked too. Row 139 stays partial (the thread control API, L15); conversation_context is not stored.
    • Kind: additive (new adapters, a new feature).
    • After: L5.
    • Decisive: every adapter passes both conformance suites; a second append of one id on another number returns false on each, as the suite says today; the synced contacts are read back from the store.
    • Landed: store::{SqliteKvStore, SqliteConversationStore} (feature sqlite, store::sqlite::{open, memory, migrate}) pass both suites in process, in just test (an in-memory database, and a file shared by a pool of connections under another prefix); store::RedisConversationStore passes the conversation suite live (live_redis_conversation_*, both erasure modes and a retention). The suite gains conversation_conformance::ids_are_stored_once_per_store, outside run (the port allows either answer to OPEN_QUESTIONS #33, which stays open): the memory store and both new stores pass it in just test, Redis and Postgres live; it fails a SQLite store whose id index is not unique, and a Redis store checking ids per number. InboxSink records smb_app_state_sync, and crates/meta-whatsapp-rs/tests/synced_contacts.rs reads Meta's examples back through Inbox::contacts and Inbox::contact on 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 added conversation_conformance::erase_one_person_of_every_record and purge_one_person_of_every_record (outside run), 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), and run's an_unbounded_limit_reads_everything (Redis refused usize::MAX).
    • Decided 2026-11-01 (the coordinator, under the owner's delegation, AGENTS.md § Decisions; each swappable behind the KvStore and ConversationStore ports, or a switch):
      1. 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 the postgres feature, so sqlite alone links neither (just features checks both alone). Its migrations are embedded with include_str! from migrations-sqlite/, templates under the same TablePrefix, now store::TablePrefix (still store::postgres::TablePrefix), and each prefix keeps its own migration history (<prefix>sqlx_migrations). Swappable: another store behind the same ports.
      2. SQLite concurrency is SQLite's write lock: every write one BEGIN IMMEDIATE transaction (read, decide in Rust, write), the busy timeout store::sqlite::open sets (30 s) the wait; times INTEGER microseconds, identifiers TEXT refusing U+0000 (as on Postgres), content BLOB; 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).
      3. 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.
      4. The inbox's contact rules, as built: a switch, RecordingSwitches::contacts, on in the library's RecordingSwitches::ALL; an add stored under inbox::synced_contact_key (the BSUID, else the phone number without +, which is also the phone number stored, so that identities meets the conversations' keys); a remove under 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 without metadata.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). A remove naming only a phone number leaves an entry keyed by the BSUID (Meta's reference shows a removal with both).
      5. Category 39's library side is done; MongoDB adapters stay with S17.
      6. The service records no synced contacts until M3b: meta-whatsapp-server-core's inbound::INBOX_RECORDING is the library's switches with contacts(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.
    • 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::HealthStatus became the shared client::common::HealthStatus, its can_send_message typed; 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::HealthStatus serves numbers, WABAs and templates (templates::HealthStatus is that type now, its can_send_message typed); Meta documents no health_status on the business node, so a business's status is the BUSINESS entity of the others. The contact book deletion is an explicit call nothing else in the library makes. Bot details take a new core::ids::WabaBotId. Not offered, for want of a documented field: withdrawing an Official Business Account application. The username errors 147001–147005 are not classified in ErrorKind yet. The number's settings it leaves (payload encryption, connection_status, webhook_url) are L26.
    • Kind: additive (a security-sensitive change).
    • After: nothing.
    • Decisive: the endpoint's request reaches api.facebook.com with 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_account returns a ParentBsuidAccount (the account id a string: core has no id type for it). The allow list is data now, one rule per line next to GraphRequest: the media host's rule as it was, and GET https://api.facebook.com/{digits}/parent-bsuid-accounts with 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 without parent_bsuid_account_id). A redirect cannot carry the token past the rule: the stock ReqwestTransport follows no redirect that would keep a request's credentials (same scheme, host and port), and the HttpTransport port states that rule; GraphRequest::header refuses 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 reaches Debug (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_field and 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; a fetch_only token 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 no Debug and no error). The client business's token takes the appsecret_proof of the partner's, computed from the app secret and sent in a form body (embedded-signup/hosted-es), its parameters from facebook-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's on_behalf_of_business_id (the On-Behalf-Of model is deprecated); GET /{BUSINESS_ID}/whatsapp_business_accounts (not in the item; its business_type array has no shown encoding); where a message history id or a schedule's audience_id comes 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; a fetch_only token 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 stay String; business users are keyed by SystemUserId; 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: WabaSolution keeps an optional id (a SolutionId, returned only when fields asks) but takes partners::solutions' SolutionStatus, PendingRequestStatus and SolutionApp, and the payment-method migration's status is PaymentMigrationStatus, apart from the WABA migration intent's partners::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_ADDED leads to a token request carrying the appsecret_proof of 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's waba_info.waba_id and owner_business_id, and the token request, fetch_only by default) and EmbeddedSignup::onboard_hosted / onboard_hosted_with_approval: step fetch_token (Business::system_user_access_token on the customer's portfolio, with the system token passed in and the app secret of AppCredentials, borrowed) in place of exchange_code, then the code flow's steps, shared with it (onboard_granted); verify_assets also refuses a WABA whose owner, as Meta reports it, is another business than the webhook's. Resumed by resume with OnboardingRequest::for_hosted. The decisive tests, in embedded_signup::hosted: a_partner_added_fetches_the_token_with_the_proof_and_onboards (the form body exactly, the proof both a fixed vector computed with openssl dgst -sha256 -hmac and 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 before store_token re-run and after it resumed, a_fetch_is_replayed_and_a_generation_is_not; and through the webhook parser, the skill's hosted_signup.rs (Meta's PARTNER_ADDED example, 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_ADDED comes for every WABA shared with the app (the code flow's and coexistence's onboardings too) and may be delivered again, and store_token replaces a record (as the code flow's re-onboarding must), so a hosted run after a code-flow onboarding would have replaced its token and created_at (and with it a Solution Partner's recorded approval) unasked; onboard_hosted now 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=true by default, swapped per signup by HostedSignup::token_request (a fetch is replayed as a read, a generation never is); the system user token is an explicit argument of onboard_hosted, not held by EmbeddedSignup; 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_token runs on the fetched token, on the assumption that hosted signup's business tokens carry granular_scopes naming the WABA, as the code exchange's do (one that does not fails verify_assets on granular_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's hosted_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_type smb_app_state_sync) then steps::SYNC_HISTORY (sync_history, history), the last of the tail shared by onboard, onboard_hosted (whose for_hosted request names no finish, so it never reaches them) and resume, after subscribe_app and a Solution Partner's credit line (Meta's order: onboarding-business-app-users starts the sync once the onboarding steps are done). They run for a coexistence finish_kind unless OnboardingRequest::coexistence_sync(false); Meta's answers are Onboarded::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) and resume never posts one that was accepted, refused, or whose answer was lost; a Meta refusal is reported as SyncOutcome::Refused, not as a failure. The decisive tests, in embedded_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_resume and for_hosted); with a_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, then resume), 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 the put_if_absent and the compare_and_swap claim), an_answer_is_never_recorded_over_a_later_onboarding, a_resume_never_takes_over_a_later_onboardings_record and a_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 calling smb_app_data itself (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 layout v 1 (the WABA, the token record's created_at in seconds, a pending, requested or refused slot 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 as SyncOutcome::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 is OnboardingRequest::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 by PhoneNumber::sync_smb_app_data called yourself).
    • 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 a TokenExpiry: Never (no expires_at: Meta reported none, or 0), Fresh, ExpiresSoon { expires_at, remaining } (now + lead included) or Expired (expires_at <= now, as StoredBusinessToken::is_expired); is_due() is the last two. DEFAULT_EXPIRY_LEAD is 7 days (business tokens default to never expiring; one generated to expire lasts 60 days). It reads the record's clear expires_at only: 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, so StoredBusinessToken::expiry gives the same answer on a token get decrypted. 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, in embedded_signup::vault: a_token_inside_the_lead_is_reported_and_one_after_it_is_not (a ManualClock; inside, outside, exactly at the lead, expired, at now, absent; the clock moved across the boundary) and the_boundaries_of_the_lead_time; with the_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_expires and the_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, and access_token) and Client::migration_intents (partners::migration::MigrationIntents: create, which is set_solution_migration_intent, and get); core gains ids::SolutionId (numeric) and ids::MigrationIntentId (opaque). The solution token is the client's business integration system user token, a bearer credential for the Graph host already allowed: an AccessToken, decoded with send_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 tests access_token_reads_both_shapes_and_never_shows_the_token and an_access_token_answer_that_does_not_decode_is_withheld, and state_changes_are_never_replayed; the lists join request::paging_withheld_tests. Where the pages disagree (the token's answer wrapped in data or bare, a deactivation answered success or 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; the fields parameter of the two deactivation answers.
    • Decided (by the coordinating agent, each swappable): the core ids (SolutionId numeric, from the Solution Details API's "must be a valid numeric string"; MigrationIntentId opaque, 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::validate checking only what the pages agree on (a name, and MESSAGING on 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 blank access_token is refused like an empty one (malformed_token_answers_are_withheld).
    • Follow-up: LaunchOptions::solution_id stays a String; typing it as SolutionId is 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/changelog of 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). In client::marketing: the agreement on MarketingBusiness (sign_max_price_agreement, max_price_agreement), duplicating a template (MarketingAccount::duplicate_template, a DuplicateTemplate with a MaxPrice, answering a DuplicatedTemplate with the source_template_id the 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 gains ids::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 is optimization_spec's 240 country multipliers (marketing::MAX_COUNTRY_MULTIPLIERS). The agreement is isolated: sign_max_price_agreement is the one call that sends its POST, never replayed (whatever the client's RetryPolicy: 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 an Error::Http (an answer that is not Meta's envelope) a placeholder (a 429 is ErrorKind::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_amount 1 to i32::MAX); in marketing::tests_welcome, the guide's create, update, list, get and delete examples and create_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::Other carries 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 with fields= and set by POST /{WABA_ID}, OPT_IN or OPT_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_spec on templates::TemplateDefinition at creation (a template takes its max price after creation with MarketingTemplate::set_max_price, or as a duplicate); an ErrorKind for 131061, 131065 and the welcome sequences' 4027xxx codes (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_amount of 0 is 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::update is idempotent (it sets the values it names), while create, a duplicate, set_max_price, clear_max_price and the agreement are not; blank sequence ids are refused; the signer's email is not checked for shape and MaxPriceAgreementSigner's Debug is 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. ConnectCall gains recording: Option<CallRecording> and transcription: Option<CallTranscription> (with setters of the same names), and Calling::accept_with takes them in AcceptOptions (Calling::accept unchanged: it sends neither). Both objects are status (ENABLED/DISABLED, reusing calling::settings::FeatureStatus), purpose and announcement_language; off unless the caller sets them, on connect and accept only (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) and connect_and_accept_without_options_send_neither_key. Checked locally (recording_limits_are_enforced, transcription_limits_are_enforced): with ENABLED, purpose and announcement_language present and not blank; purpose at 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, CallRecording and CallTranscription carry 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 in calling::recording); each object is validated on its own, even though Meta ignores the transcription's purpose and language when both are enabled (both pages require them; dropping the check is a one-line change); a whitespace-only purpose or language counts as empty; status reuses calling::settings::FeatureStatus (its own enum later is additive if Meta's values diverge); the accept side is a separate AcceptOptions struct for an additive accept_with, leaving accept's signature as it was. The 250-character limit applies whatever the status (the pages' error table states it without one), and only ENABLED requires a purpose (only_enabled_needs_a_purpose_and_length_counts_characters, which also pins the count in Unicode scalar values).
    • Kind: additive.
    • After: nothing.
    • Decisive: exact JSON from conversation-routing/thread-control.
    • Landed: meta_whatsapp_client::thread_control, reached with client.thread_control(phone_number_id): ThreadControl::pass(&user, &Pass), take(&user, metadata) and release(&user), each a POST /{phone-number-id}/thread_control with its action, answering ThreadControlResponse (messaging_product, request_id). Pass is untargeted (Pass::to_escalation_partner, the default: no control_pass) or names a TargetRole (Pass::to_role: the page's six values, Other(String) for a later one), with metadata. 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' metadata as sent by the pass and the take, every target role's wire name (every_target_role_has_its_wire_name) and a BSUID in recipient on all three (a_bsuid_is_sent_as_recipient). Checked locally, before any request: metadata at most 2,000 characters (thread_control::METADATA_MAX_CHARS, Unicode scalar values); exactly one non-empty identifier, so Recipient::PhoneAndUser and a group are refused (exactly_one_user_identifier_is_required); a non-empty target role; control_pass on pass only, 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). The control_passed and control_taken events 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: the meta-whatsapp-rs-cms-inbox example now makes the pass). With it, the page's error 131070 (a service message without owning the thread, idle threads included) maps to ErrorKind::ThreadOwnedElsewhere, the inbox's local refusal's kind, so the service answers it 409 thread_owned_elsewhere instead of 502 unknown (metas_ownership_refusal_has_the_local_refusals_kind, classification_table_spot_checks, and the service's each_kind_answers_its_code_and_no_meta_text); 2494191 (a take by a caller that is not the escalation partner) stays Unknown, not Permission, 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_control and client.thread_control; release not 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); release sends no metadata (Meta forwards it only on a handover event, and a release has none; a parameter is an additive change); Recipient::PhoneAndUser and groups refused locally, rather than reduced to the BSUID or the phone number (the caller picks its identifier; Meta refuses both); #[non_exhaustive] on TargetRole and ThreadControlResponse only, not on Pass, 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 empty roles among them).
    • Landed: ThreadControl::routing_roles() on client.thread_control(phone_number_id), a GET /{phone-number-id}/routing_roles with no query and no body, answering RoutingRoles (roles: Vec<RoutingRole>, is_standby: bool, and RoutingRoles::holds, which compares wire names). RoutingRole is the page's six values (calls, ctwa, customer_service, escalation, marketing, utility) with Other(String) keeping any other verbatim, ai_agent among 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 empty roles is 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 code 100, as ErrorKind::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 empty roles, holds by 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 ThreadControl rather than a module and accessor of its own (client.routing_roles(id) would be an additive accessor); RoutingRole its own enum, not TargetRole (calls is no pass target, ai_agent never a held role; a conversion between the two is additive), with #[non_exhaustive] and the Other(String) catch-all like TargetRole; 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); roles a Vec in Meta's order rather than a set (the page calls it unordered; holds is 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, a WabaId (the id the Messaging Account kept), checked as digits by OutboundMessage::validate; the alias is never sent, and a MessageContent::Raw type named like either key is refused. The send_message example sends it from WA_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: additive (a new id type, new calls, a new webhook field parsed into its own event, Unknown until then).
    • After: nothing; the reference must exist first (phase 2, H1 2027).
    • Decisive: exact JSON from the published reference; a whatsapp_account webhook parses into its event, and one with an unknown shape into Unknown { raw }.
    • 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_catalogs reads the catalog connected to a WABA (GET /{WABA_ID}/product_catalogs); Client::catalog gives a Catalog (get, products/products_stream, product_sets/product_sets_stream), Client::product_set a ProductSet (get, products/products_stream) and Client::product a Product (get). The lists take their cursors in ListProducts, ListSetProducts and ListProductSets, next to the edges' documented filters (a JSON filter rule, return_only_approved_products, error_priority, error_type, bulk_pagination, summary; for sets ancestor_id, has_children, parent_id, retailer_id); the streams follow paging.cursors.after until a page comes without next. 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 and after, five distinct products and no fourth request after the page without next; reusing the first cursor, dropping after or following cursors.after past the last page each fail it. Read from Meta's Marketing API reference (product-catalog, its products and product_sets edges, product-set and its products edge) 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's catalog_id, image_height, image_width, override_country and override_language parameters, which the reference names without saying what they do. ProductItemId and ProductSetId are meta_whatsapp_core::ids newtypes, re-exported by client::commerce (numeric since L20c).
    • Kind: additive.
    • After: nothing.
    • Decisive: every Flow JSON example in flows/guides/flowjson round-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_json models the document (FlowJson, Screen, Layout, DataField), its components (Component, tagged by type, one struct per component of flows/guides/components and flows/guides/media_upload, plus Form) and actions (Action, tagged by name); Dynamic<T> holds a value or a reference. Unknown components and actions are kept whole (Unknown { raw }) and unknown properties in flattened extra maps. 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, a null) is Unknown { 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's init-value is a string in one table and a list in the other; it is now OneOrMany, either); the document skeleton stays strict (version, screens, the screens' typed properties and layout, a layout's type string and children list), each with one documented type that Meta refuses others of (INVALID_PROPERTY_TYPE). Pinned by a_known_component_or_action_that_does_not_fit_its_struct_is_kept_whole and the_document_skeleton_stays_strict; the decisive test still refuses any Unknown in 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); SUCCESS is refused in any case (the guide spells it SUCCESS, the error codes success); Component and Action are #[non_exhaustive]; label-variant and ChipsSelector's root actions are taken from the changelog; category 11 is done for the library. FlowJson::builder and FlowJson::validate check the hard limits of the component pages and of the static validation errors in flows/reference/error-codes, the first failure a ValidationError with the path from the root; CreateFlow::flow_json_typed and Flow::upload_flow_json_typed validate and send it, the raw-JSON calls unchanged. The decisive test is every_flow_json_example_round_trips_through_the_typed_model (crates/meta-whatsapp-client/tests/flow_json.rs): the 27 JSON examples of flows/guides/flowjson (3 whole Flows, 21 components, 2 actions, a data model), and the 28 of components, media_upload, flowsapi and flows-templates, each parsed, serialized and compared as a JSON value, with no unknown component, action or property left (so a model that passed everything through Unknown fails 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 by every_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 any extra into #[serde(skip)] fails unknown_components_actions_and_properties_are_kept_as_sent; lifting a limit fails its unit test in flows::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 dynamic data-source ones, 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 (NavigationList content 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 missing ffmpeg is a typed error, not a panic.
    • Landed: the port meta_whatsapp_core::media::MediaConverter, an object-safe async trait, convert(MediaInput, TargetFormat) -> Result<ConvertedMedia>, with TargetFormat::OggOpus (mono Ogg Opus, uploaded as audio/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's media::AUDIO_MAX_BYTES (16 MiB); meta_whatsapp_client::media re-exports them and uploads a result with Media::upload_converted (its format's type and extension, through validate_upload); and meta_whatsapp_adapters::media::FfmpegConverter behind the adapters' ffmpeg feature (forwarded by the facade's; off by default; in full). The converter starts the ffmpeg binary with tokio::process, never a shell, with fixed literal arguments: the input on stdin and the result on stdout, -protocol_whitelist pipe and a -format_whitelist of 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), never may_have_been_sent, retryable only on Timeout; an empty or oversized input (or result) is a ValidationError on input. FfmpegConverter::check finds 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 an OpusHead for one channel, ffprobe agreeing, and goes through Media::upload_converted as audio/ogg, its exact request asserted) and a_missing_ffmpeg_is_a_typed_error (ConversionError::BinaryMissing). The real-ffmpeg tests use META_WHATSAPP_RS_TEST_FFMPEG, else ffmpeg on PATH, and skip without one unless META_WHATSAPP_RS_REQUIRE_FFMPEG=1; CI installs ffmpeg on 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 says may_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 to PATH (no FFREPORT log 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-ffmpeg tests 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 from client::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, not audio/ogg; codecs=opus (the upload check takes both).
      • Failures get a typed node, Error::Conversion(ConversionError), with existing kinds (no new ErrorKind: the service's error codes and OpenAPI document are unchanged); a missing binary is ConversionError::BinaryMissing, not a ConfigError, so every converter failure is handled in one place; an empty or oversized input stays a ValidationError on input.
      • Defaults: 64 MiB of input, 60 s, 32 kb/s at 48 kHz in voip mode, the recording formats above (each a setting or a literal of the converter, swapped by another MediaConverter).
      • The facade's ffmpeg feature is in full; the gate variables are META_WHATSAPP_RS_REQUIRE_FFMPEG, META_WHATSAPP_RS_TEST_FFMPEG and META_WHATSAPP_RS_TEST_FFPROBE, apart from META_WHATSAPP_RS_REQUIRE_LIVE (no live_ 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.
    • Kind: additive.
    • After: L19.
    • Decisive: a PNG comes out as a static WebP (its RIFF/WEBP header read apart from the converter) within 100 KB that Media::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 a ConversionError::InputRejected.
    • Landed: three TargetFormats in core, StaticSticker (a static WebP, image/webp, .webp, at most media::STATIC_STICKER_MAX_BYTES, 100 000 bytes), Jpeg and Png (8-bit, image/jpeg and image/png, .jpeg and .png, at most media::IMAGE_MAX_BYTES, 5 000 000 bytes), read from messages/sticker-messages and business-phone-numbers/media on 2026-11-01; the client's test keeps each within its upload table. FfmpegConverter makes 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 but png_pipe or jpeg_pipe (-format_whitelist). Each attempt runs fixed literal arguments: the L19 prefix (-protocol_whitelist pipe, -abort_on empty_output, environment reduced to PATH, 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 image2pipe on 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:v 2, 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 within with_timeout; an image no attempt brings under is a ValidationError on input. The result's header is checked (a WebP whose RIFF size is its length and whose first chunk is VP8 , VP8L, or VP8X without the animation flag; a JPEG of 8 bits and 3 components; a PNG of 8-bit RGB or RGBA ending in IEND). FfmpegConverter::check_for(&[TargetFormat]) checks each format's encoder; check() is it for OggOpus. 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 its libavcodec60): 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_upload taking 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, which validate_upload refuses, comes out as an 8-bit colour JPEG under 5 000 000 bytes, 4:3 kept), and ffmpeg_refuses_a_gif_a_jpeg_ls_an_animated_png_and_a_crafted_png (each InputRejected for 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 (lavfi sources, an EXIF segment written in Rust); nothing is committed. Removing the first-bytes check, the whitelist (or widening it to jpegls_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):
      1. 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_BYTES keeps its binary reading (16 MiB), as L19 decided: a voice note is far under either.
      2. Inputs: PNG and JPEG only, the formats of Meta's image table; no GIF, WebP, HEIC or animation.
      3. Static stickers only: no AnimatedSticker target here (L19c).
      4. 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.
      5. Bounds: 32 Mi pixels, 256 MiB a single allocation, the existing 64 MiB of input and 60 s, the timeout covering the whole ladder.
      6. The ladders as built; a result over the limit after every attempt is a ValidationError on input, as L19's result over the limit is.
      7. 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.
      8. 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.
      9. 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.
    • Review (adversarial, 2026-11-01, against ffmpeg 6.1.1): a PNG's zTXt chunks 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 of IEND, and passes it on without tEXt, zTXt, iTXt and eXIf and without anything after IEND (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_drop and 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 in check_for (check_for_wants_the_encoder_s_kind_too). -map_metadata -1 survives in behaviour (the image2pipe muxer and these encoders write no tags) and is pinned by the arguments' literal test. Probed and holding: a JPEG-LS image with an APP0 segment first, or a SOF0 copy before its own frame header, still never reaches jpeg_pipe (ffmpeg's probe lands on mjpeg or 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::kill waits). 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 VP8X header has the animation flag and whose ANIM and ANMF chunks are read apart from the converter, within 500 KB, which Media::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 Unknown left 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's ProductItemId and ProductSetId (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 (new returns a Result, FromStr and TryFrom replace the infallible conversions; ids::is_numeric_id, MAX_NUMERIC_ID_LEN, 64 being the service's existing cap), a ValidationError on the type's FIELD; every other id stays opaque. Deserialize does not check, so parsing never fails on an id's shape, and new_unchecked wraps Meta's plain-string values (webhook entry ids, debug_token targets) and stored rows the same way; such an id is still one encoded segment. The decisive tests, in client/src/request.rs (id_shape_tests), are a_numeric_id_holding_a_slash_is_refused_before_any_request (each of the six types built from 123/subscribed_apps and, only if that succeeds, used in a call its path names, every response scripted: nothing is sent) and a_group_id_holding_a_slash_stays_one_encoded_segment (both group accessors: DELETE /v25.0/Y2FwaV9ncm91cDox%2Fparticipants), with a_numeric_id_meta_sent_is_kept_and_stays_one_segment, and in core ids::tests::a_numeric_id_is_one_to_sixty_four_ascii_digits and a_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 paths commerce::catalog also 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 / in GraphEndpoint::url_segments fails 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 id W1, still open. The service's graph_id runs is_numeric_id plus its own no-leading-0 rule (D4); a non-numeric {pn} or {waba_id} is 404 not_found as before (tests/authz.rs, a_path_id_that_is_not_numeric_is_not_found, on every such route), a body's media id reaches Meta as given (tests/messages.rs, a_media_id_in_a_body_reaches_meta_as_given), and openapi/v1.json is 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's debug_token targets, 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, by client::templates). Each moves to numeric_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 after build (nothing else runs it). Blank is what the handshake already refused: empty, or only whitespace (str::trim's Unicode set), one predicate shared with verify_subscription, whose constant-time comparison is unchanged. The error's text is fixed and names no part of the token. The decisive tests are check_reports_a_blank_verify_token and check_passes_a_real_verify_token (tests/handler.rs), with the_check_error_carries_no_token_text; making check always pass fails the first and the last, and the doctest on check. The cms_inbox example's app runs 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 blank WA_VERIFY_TOKEN before 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 by Templates::create before any request) refuses a carousel whose cards carry a PRODUCT header unless it has exactly two cards, on components[<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, so TemplateEdit::validate keeps 2–10 for both kinds. The decisive tests, in client/src/templates/tests.rs, are a_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) and a_product_card_carousel_send_takes_up_to_ten_cards (10 sent, 11 refused with nothing sent), with a_product_card_carousel_edit_keeps_two_to_ten_cards and a_product_header_on_any_card_makes_a_product_card_carousel (a PRODUCT header 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 cards 422 on components[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 erase from 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, with FanoutSink returning a transient error before a permanent one. The store is meta_whatsapp_webhooks::deadletter::DeadLetters on KvStore, wired by WebhookHandlerBuilder::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 in DeliveryReport::dead_lettered and alerted by a tracing::error! naming the letter, kind and number only; a failed write releases the claim and answers 500, and without a store a permanent error answers 500 as before. Bounds: a ring of 1000 slots (letter id in slot id % 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) and DeadLetters::erase, called by the new facade meta_whatsapp_rs::erasure::Eraser beside ConversationStore::erase_all. The namespaces wa.webhook.deadletter and wa.webhook.deadletter.contact and 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 are a_batch_with_one_permanent_failure_delivers_the_others_and_dead_letters_it (webhooks/tests/deadletter.rs; with post_is_200_when_a_permanent_failure_is_dead_lettered in tests/router.rs for the status) and erasing_a_contact_removes_their_dead_letters (meta-whatsapp-rs/src/erasure.rs). Removing the dead-letter write fails the first two, removing erase from the eraser the third; treating a permanent error as transient fails the first, dropping the count bound fails the_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 answers 500); 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 missed phone_number, input, participant_recipient_id and parent BSUIDs: every_person_a_fixture_event_names_is_indexed walks 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::Permanent with SinkError::permanent and is_permanent (additive, the enum #[non_exhaustive]; a sink classifies its own errors); a new meta_whatsapp_rs::erasure::Eraser rather than a change to Inbox::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); FanoutSink returning a transient error before a permanent one; and the known limits, documented in the deadletter module (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 than capacity appends at once may evict a newer letter; a capacity change strands letters from get, list and replay until they age out; a letter naming nobody is reached by the bounds only). The namespaces wa.webhook.deadletter and wa.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_eq across them); reverting to a clone per subscriber fails it.
    • Landed: BroadcastSink<E> wraps each delivered event in one Arc and publishes Arc<E> (from_sender, receiver and BroadcastSubscription carry Arc<E>; E need not be Clone, but must be Sync); sse reads a broadcast::Receiver<Arc<WebhookEvent>>, its filter still a Fn(&WebhookEvent) -> bool. The EventSink port is unchanged: the sink still takes an owned E and does the wrapping. The decisive tests are ten_subscribers_share_one_allocation_per_event (adapters::sink::broadcast: ten subscriptions and ten raw receivers) and ten_sse_subscribers_share_one_allocation_per_event (webhooks::server, on the stream sse is built on), with sse_filters_see_the_shared_event checking that the public sse hands 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 inside sse's encoding step, after the filter, which only an allocation counter would see (unsafe_code is forbidden, so a counting allocator would need a new dev-dependency). WebhookEvent is asserted Send + Sync at compile time. OPEN_QUESTIONS #31 is closed and deleted (CHANGELOG). No parity row changes: parity row 10 already counted webhooks::sse and BroadcastSink as done.
    • Kind: additive.
    • After: nothing.
    • Decisive: live, with a TLS Redis in the test Compose and META_WHATSAPP_RS_REQUIRE_LIVE=1, a rediss:// store connects in a binary linking both ring and 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, so store::RedisTls does the TLS itself: a ClientConfig on aws_lc_rs::default_provider() (the platform's roots, or the CAs of root_certificates_pem; client_auth_pem for mutual TLS; from_client_config for your own), the handshake with tokio-rustls, the stream to MultiplexedConnection::new_with_config, and a RedisTlsConnection for RedisKvStore::new that reconnects on its own; none of redis-rs's TLS features is enabled. The test binaries link ring too (an adapters dev-dependency, rustls with both providers). The decisive test is live_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's redis-tls (TLS only, client certificates required, test-only certificates generated by tools/test-tls/generate.sh). Building on the process default (ClientConfig::builder()) panics it and every other test of that binary, the non-live builds_on_aws_lc_rs_with_both_providers_linked_and_no_default included; the library installing a default fails them on the no-default assertion; taking the process default when one is installed (reqwest's rule) fails uses_aws_lc_rs_when_the_process_default_is_ring (tests/redis_tls_ring_default.rs, which installs ring). The sentinel tests/redis_tls_process_default.rs (#[should_panic]) proves those binaries are the hostile case: without ring linked, 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.rs runs RedisTls against 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 sends AUTH and SELECT again 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 leaving rediss:// to integrator-built connections (an integrator's own connection still goes to RedisKvStore::new); the platform's roots by default (root_certificates_pem replaces them, from_client_config takes any configuration); TLS under the existing redis feature, not a feature of its own; ring allowed 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 ring finds nothing); the test Redis requires client certificates; its certificates are generated, none committed (OPEN_QUESTIONS #19).
    • Kind: additive.
    • After: nothing.
    • Decisive: doctor reports each scripted misconfiguration by name and exits non-zero; llms.txt links 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 copies docs/public/), indexes the guides, design, planning docs and every consumer skill, each link the file's Markdown on main; crates/meta-whatsapp-rs/tests/llms_txt.rs refuses 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 a FAIL: 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 as serve opens it, the migration histories read by SELECT (pending service migrations fail with WA_SERVER_MIGRATE=skip, warn with auto), the app's whatsapp_business_account subscription and its fields (GET /{app-id}/subscriptions: messages and account_update required, 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.rs scripts 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 show WA_SERVER_MIGRATE=auto warns 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.txt at the docs site's root (docs/public/), its links the raw Markdown on GitHub's main; 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); messages and account_update required (REQUIRED_FIELDS), the other fields only warning (RECOMMENDED_FIELDS); pending service migrations warning with WA_SERVER_MIGRATE=auto and failing with skip; 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; the GET /{app-id}/subscriptions read 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-Key and 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 binary meta-whatsapp-docs-mcp, run by just 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) and read (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: _meta on every request, server/discover, resultType, the caching hints, -32022 for a version it does not speak) and, after an initialize, 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 with isError: 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.rs runs the binary as a subprocess: a legacy client and a modern one each list the tools, search Idempotency-Key, read docs/guides/server.md's Idempotency keys section and get a missing page as isError: true with 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 page llms.txt links. 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's isError, 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 deny run in just ci's --workspace recipes), a binary only so no crate can depend on it, with no MCP SDK and no dependency but serde_json (no new crate in Cargo.lock); dual-era rather than 2026-07-28 alone; the corpus (corpus::ROOT_FILES and corpus::TREES in 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.ts or 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 its from_line to 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.json committed (registering the server stays each developer's choice: docs/dev-environment.md shows how).
    • Left: MCP resources and prompts (the pages as resources/read too); a search that folds Unicode case or ranks by more than counts; subscriptions/listen for 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-archival now shows POST /{WABA_ID}/message_templates/archive and …/unarchive, and templates/template-groups is in Meta's page list. Templates::archive and unarchive send {"hsm_ids": [..]} (1 to 100 ids, each digits) and return ArchiveResult / UnarchiveResult (the ids done, a reason for each that failed); Templates::create_group, get_group, update_group and delete_group take TemplateGroupCreate / TemplateGroupUpdate (template ids written as JSON integers, as the page's examples do) and return TemplateGroup with its WABA and the first page of its templates. A group id that is not digits is a Validation error before any request (group_ids_are_checked_as_digits_before_any_request fails 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 prints https://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's 2500 "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::Url gains app_deep_link: Option<AppDeepLink> (meta_app_id a u64, a JSON number on the wire; the two strings checked non-empty), and Button::url_with_app_deep_link builds 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 answers 422 on components[2].buttons[0].app_deep_link again 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: 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's payload_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_encryption answer 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_encryption sends status and client_encryption_key (a ClientEncryptionKey, checked to be base64 and not an obvious private key, Debug showing its length only), disable_payload_encryption the status alone, and settings() reads PayloadEncryption back (status, optional so that an answer without it still parses; client_encryption_key_fingerprint; and cloud_encryption_key, a CloudEncryptionKey that Debug shows as a length), with decode errors that withhold the body. set_connection_status (ConnectionStatus, the schema's eleven values) and set_webhook_url (an absolute https URI, 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, how webhook_url relates to the override). Left: StorageConfiguration::status (from L9) is still required and StorageStatus has no catch-all, so a storage_configuration answer without a status, or with a status Meta adds, fails the whole settings() read, as payload_encryption did before this item made its status optional (L20a's catch-all pass covers the enum); and CallbackOverride does not check its URI's scheme, where set_webhook_url requires https.

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_retention call 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 one WA_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_TYPES fails it.
    • After: S7.
    • Decisive: another tenant's received media id is 404 with 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_events mentions 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 naming messaging_account_id itself 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 404 with 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-intent and payments-in/onboarding-apis; a refund, a configuration's creation and an OAuth link are never replayed after a timeout.
    • Landed: client::payments (the messages in client::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_status message refuses pending, which the payments-in/pg parameters table excludes, and checks its reference_id as an order_details message's; the items' origin and importer are not required with the older payment_configuration alone (its page makes them optional); a total above u64::MAX is refused as such (Order::total sums in u128, 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, and PaymentOauthLink's Debug hides 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 beyond u64::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 with 131005.
    • Decided 2026-11-01 (the coordinator, under the owner's delegation, AGENTS.md § Decisions): where the pages contradict themselves, the examples win (payment_settings an array, importer_address an object, partially_shipped, parameters an object); the subtotal is not checked locally, the total equation is; the header image's provider is not modelled; the order_details header 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's pg_transaction_id); purpose_code is 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/*; a BRL order checked by Brazil's rules, an INR one by India's.
    • Landed: Currency::Brl selects Brazil's rules (OrderDetailsParameters::brl, brl_simplified, OrderStatus::brl with a PaymentUpdate), India's unchanged; the settings PixDynamicCode (with PixFlowType, Pix with redirect), PaymentSetting::boleto, OffsiteCardPay and the payment_link's uri alone; every message example of the five pages an exact-JSON test (payments/tests/brazil.rs; the one-click page's payment_method webhook 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 BRL header by media id is refused (Brazil's image object has link alone, 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 required retailer_id conflicts 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):
      1. 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 in BRL; Brazil's settings, payment_type br and payment update in INR).
      2. 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).
      3. payment_settings are optional in BRL: every Brazil table says Optional.
      4. A BRL order_status with neither order nor payment is accepted: no page requires either.
      5. last_four_digits must be exactly four ASCII digits.
      6. Pix codes and keys, Boleto digitable lines, a card's last four digits and credential_id are payment data shown to the buyer or echoed to the merchant, not secrets: no secret type, and Debug shows them. Meta's page calls credential_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.
      7. The header Brazil's order_status table lists, with no fields and no example, is not modelled (a gap).
    • After: P1a, P1b.
    • Kind: additive.
    • Decisive: the payments pages' webhook examples join the conformance sweep (their manifest status moves from OutOfScope to Typed), 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 ci exits 0 on main at 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-axum and store-postgres stay 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.sender and its rule, the erased kind, 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.pacer and its record layout, added and awaiting confirmation; B3's wa.bot.jobs, wa.bot.jobs.due, wa.bot.jobs.sent, wa.bot.jobs.message and wa.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 columns wa_server_wabas.generation and wa_server_wabas.previous_tenant_unbound_at with its default, the Unix epoch read as "a move before the records began" (UNRECORDED_MOVE), and the table wa_server_waba_tombstones with its columns and its ON DELETE SET NULL rule, added and awaiting confirmation; L11d's wa.es.coexistence and 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's wa.webhook.deadletter and wa.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.

Released under the MIT License.