Warp Commerce Type Specification v0.2
Status: Draft — still v0.x. Surface area may change in minor revisions; breaking changes will bump the major. v1.0 will be declared only when two independent implementations exist (see Versioning below).
Supersedes: v0.1. v0.2 keeps every v0.1
type intact and adds four normative sections — serialization
conventions, the PhoneNumber adapter normalization contract, nullable
field semantics, and the offer-branch invariant — plus three new
types (Occasion, SegmentCriteria, ABTestVariant). The full
delta is in the Changelog.
Introduction
Warp’s commerce types are the language other systems implement against when they say “Warp-compatible.” They are not data classes; they are a contract.
Other workflow runtimes ship String, Number, and Object as their
universal primitives and let merchants discover at runtime — on a live
order, with a real customer — that they confused MAD for EUR, or wired
a label into a phone-number slot, or fed an order id where a customer
id was expected. Warp refuses that bargain. Every commerce concept
gets its own type, every wrong wire fails at compile time, and every
error message names the fix in commerce language rather than Rust
language.
The closest analogy is SQL: PostgreSQL doesn’t accept a string where an INTEGER column is declared, and it doesn’t accept rows from one table claiming to belong to another. The commerce types in this spec play the same role for commerce workflows.
Compiler enforcement. The Warp compiler type-checks every workflow
before it can be installed against a tenant, returning a CompileError
that names the line and the violation. The six commerce invariants are
enforced to different degrees today:
- I-3 Capacity Verification, I-4 Temporal Integrity, I-5 Identity Permanence — enforced at compile time; a violation blocks compilation.
- I-6 Commitment Tree Consistency — partially checked (literal child/parent values; best-effort).
- I-1 Value Conservation — emits a warning; currency mixing compiles with a warning rather than blocking.
- I-2 State Monotonicity — not yet enforced at compile time; on the roadmap.
The @warp-lang/commerce-types npm package additionally provides runtime
validators (auditCommerce, checkI*) for these invariants.
Type System Principles
- Every commerce concept has a dedicated type. Money, phone
numbers, order identifiers, customer identifiers, currency codes,
languages, channels — each is its own type, never a
StringorNumber. A node that handles money declaresCurrency; a node that addresses a customer declaresPhoneNumber. AStringslot means “free text” — never money, never a phone, never an id. - Wrong types fail at compile time, never at runtime. Wiring a
Stringto aPhoneNumberinput is aCompileErrorthat names the slot and suggestsPhoneNumber::parse(…). There is no runtime path that downgrades a typed slot to “accept anything.” - Types are portable — same type, any platform. A
CartStatethat comes from a Shopify webhook is the sameCartStatethat comes from an Agora native event. Adapters translate at the boundary; downstream nodes never see platform-specific shapes. - Error messages speak commerce, not Rust. A failure reads
“Cannot operate on mixed currencies: MAD and EUR. Use convert_to()
first.” — not
expected CurrencyCode::EUR, found CurrencyCode::MAD. The compiler is the user’s ally (P-2).
Serialization Conventions
The live ACP integration on 2026-05-28 surfaced inconsistent casing between Warp’s PascalCase Rust enum variants and ACP’s lowercase JSON tokens. v0.2 codifies the contract that resolved that mismatch.
All enum variants serialize to lowercase strings. Currency codes are the one exception (they are uppercase by ISO convention):
| Type | Variant | JSON output |
|---|---|---|
Language |
Language::French |
"french" |
Language |
Language::Arabic |
"arabic" |
Language |
Language::English |
"english" |
Language |
Language::Darija |
"darija" |
Channel |
Channel::WhatsApp |
"whatsapp" |
Channel |
Channel::Email |
"email" |
Channel |
Channel::SMS |
"sms" |
Channel |
Channel::FCM |
"fcm" |
CurrencyCode |
CurrencyCode::MAD |
"MAD" |
CurrencyCode |
CurrencyCode::EUR |
"EUR" |
CurrencyCode |
CurrencyCode::USD |
"USD" |
Platform |
Platform::Shopify |
"shopify" |
Platform |
Platform::Agora |
"agora" |
Occasion |
Occasion::Eid |
"eid" |
Occasion |
Occasion::Ramadan |
"ramadan" |
ABTestVariant |
ABTestVariant::A |
"A" |
ABTestVariant |
ABTestVariant::B |
"B" |
ABTestVariant and currency codes preserve their canonical case
because both are single-letter or three-letter identifiers whose
case is conventional outside Warp (A/B testing literature, ISO 4217).
Parsers must be case-insensitive on input. A Language parser
accepts all of "French", "FRENCH", "french", "FrEnCh" and
yields Language::French. A Channel parser accepts "WhatsApp",
"WHATSAPP", "whatsapp". Adapters MAY pass through upstream
casing; Warp normalizes on input and always emits lowercase on output.
Rationale. Real-world systems return inconsistent casing. ACP
emits "french". Shopify emits "en" (a different token entirely,
mapped by the Shopify adapter to Language::English at the boundary).
WooCommerce emits "en_US". A spec that demanded a single casing on
input would force every adapter to add a normalization step Warp
could centralize. Warp accepts any case on input and normalizes on
output; the contract is “lowercase emitted, anything accepted.”
Two helpers in acp_client.rs (in the warp-catalog crate — part of
Warp’s commercial distribution, not included in this open-source repository)
demonstrate the boundary pattern:
parse_acp_language(&str) -> Language and
parse_acp_channel(&str) -> Channel. Both fall back to a safe default
(French / WhatsApp) with a tracing::warn! on unknown values —
never panic.
Core Types
Currency
Definition. A monetary value with its currency code attached.
The implementation type is Currency { amount: Decimal, code: CurrencyCode }
(source). Amounts are exact-precision decimals (no floating-point
drift on money). Currency codes are the closed set MAD | EUR | USD
in v0.2 — additional codes land per merchant demand, not
speculatively.
Invariants.
- Two
Currencyvalues cannot be combined unless theirCurrencyCodes match.Currency::mad(100).add(Currency::eur(20))returnsCurrencyError::MixedCurrencies. There is no implicit conversion. - All arithmetic uses
rust_decimal::Decimal— exact, no rounding drift.Currency::mad(0.1) + Currency::mad(0.2)is exactlyCurrency::mad(0.3), not0.30000000000000004. - Conversion between currencies is explicit and carries a rate.
Currency::mad(1000).convert_to(EUR, 0.092)≈Currency::eur(92). There is no built-in FX oracle; callers (typically nodes that wrap an FX adapter) pass the rate they were quoted.
Construction.
Currency::mad(580) // MAD 580.00
Currency::eur(20) // EUR 20.00
Currency::usd(Decimal::from_str("19.99").unwrap())
In .warp source, written as a first-class literal:
min_value: Currency(200, MAD)
Error cases.
CurrencyError::MixedCurrencies { left, right }— surfaces fromadd(),is_at_least(), andCartState::total()whenever two values disagree oncode. The Display message names both codes and points the caller atconvert_to().
Display format. "{amount:.2} {code}", e.g. "580.00 MAD".
Serialization format (JSON).
{ "amount": "580.00", "code": "MAD" }
The amount is serialized as a string (via rust_decimal::serde::str)
to preserve precision across systems whose JSON numbers are 64-bit
floats. Currency codes serialize in uppercase per ISO 4217 — see
Serialization Conventions.
PhoneNumber
Definition. An E.164-formatted phone number with a WhatsApp-routable flag.
Invariants.
- The internal E.164 string is private; the only path to obtain a
PhoneNumberisPhoneNumber::parse(raw). - E.164 shape: leading
+, then 7–15 ASCII digits, no spaces, no dashes, no parentheses. whatsapp_routableisfalseon construction; flipped totruebywith_whatsapp(), which the WhatsApp adapter calls after a Business API check confirms the number is reachable. A consumer that requires WhatsApp reachability should branch on this flag.
Construction.
let phone = PhoneNumber::parse("+212661234567")?;
let phone_with_wa = phone.with_whatsapp();
Error cases.
PhoneNumberError::InvalidFormat(raw)— raw string did not satisfy the E.164 shape. The Display message echoes the rejected input so operators can grep webhook logs.
Display format. "+212661234567" (the raw E.164 string).
Serialization format (JSON).
{ "e164": "+212661234567", "whatsapp_routable": false }
Adapter Normalization Contract for PhoneNumber
New in v0.2. Real-world adapters never see E.164 on the wire.
Shopify emits "0612345678". WooCommerce emits "06 12 34 56 78".
Some upstreams emit "+212600000000" (already E.164, normalized
upstream). A type that accepts only E.164 will reject most real
merchant data unless adapters normalize first.
Adapters MUST normalize phone numbers to E.164 before constructing
a PhoneNumber. Raw local formats are not valid input to
PhoneNumber::parse. The normalization step lives in the adapter,
not in the type — PhoneNumber::parse is the second line of defense
and the contract gatekeeper, never the normalizer.
Morocco normalization rules (the home-country reference; other countries follow the same shape):
| Raw input | Normalized form | Notes |
|---|---|---|
"06XXXXXXXX" |
"+2126XXXXXXXX" |
strip leading 0, prepend +212 |
"07XXXXXXXX" |
"+2127XXXXXXXX" |
strip leading 0, prepend +212 |
"212XXXXXXXXX" |
"+212XXXXXXXXX" |
prepend + |
"+212XXXXXXXXX" |
"+212XXXXXXXXX" |
pass through unchanged |
" 06 XX XX XX XX " |
normalize then "+2126XXXXXXXX" |
strip whitespace before applying rules |
"" or null |
(do not construct) | use Option::None upstream |
On unparseable input. If normalization produces a value that
PhoneNumber::parse still rejects:
- Log a warning with the raw value (
tracing::warn!) so operators can grep webhook logs and trace the offending record. - Use a fallback or
None— emit the surrounding event with the phone field absent, never with an unnormalized string. - Never panic. A malformed phone from one customer must not take down the adapter for every other customer.
- Never pass an unnormalized phone to a
PhoneNumberfield. Bypassing this contract surfaces as a runtime parse error deep inside a workflow; the type system’s value is destroyed if adapters bypass the contract.
The adapter normalization contract complements the five-rule adapter contract in Adapter Contract below — it is rule 3 (“Validate phone numbers into PhoneNumber before emission”) spelled out in operational detail.
OrderID
Definition. A validated order identifier.
Invariants.
- Non-empty.
- Maximum 128 characters.
- Allowed character set: ASCII alphanumeric, hyphen (
-), underscore (_). No spaces, no quotes, no slashes. OrderIDis a distinct type fromCustomerID; passing one where the other is expected fails at compile time (seeOrderIDdoctest).
Platform namespacing convention. Every adapter prefixes its raw
platform id with the platform short-name before constructing the
OrderID, so dashboards know which platform every id came from at
a glance:
| Platform | Prefix | Example |
|---|---|---|
| Shopify | shopify_ |
shopify_820982911946154500 |
| WooCommerce | wc_ |
wc_1234 |
| OpenCart | (store-native) | ord_2026_001 |
| Agora | agora_ |
agora_order_42 |
| Odoo | odoo_ |
odoo_1234 |
The prefix is enforced at the adapter boundary, not by the type. The type rejects invalid characters; the namespacing convention is operational discipline.
Construction.
let id = OrderID::new("shopify_820982911946154500")?;
Error cases.
OrderIDError::EmptyOrderIDError::TooLong(raw)— over 128 chars.OrderIDError::InvalidChars(raw)— contains a character outside the allowed set.
Display format. The raw string, unmodified.
Serialization format (JSON). Bare string: "shopify_820982911946154500".
CustomerID
Definition. A validated customer identifier.
Invariants. Identical to OrderID — non-empty, ≤128 chars,
alphanumeric + - + _. The distinction lives at the type level:
fn accepts_customer_id(_id: CustomerID) { /* … */ }
let order_id = OrderID::new("ord_123").unwrap();
accepts_customer_id(order_id); // <- compile error
Adapters namespace customer ids the same way they namespace order
ids: shopify_customer_207119551, wc_customer_55.
Construction.
let cust = CustomerID::new("shopify_customer_207119551")?;
Error cases. CustomerIDError::{Empty, TooLong, InvalidChars} —
same shape as OrderIDError.
Display + serialization. Bare string, same as OrderID.
TenantId
Definition. Tenant-isolation key. Opaque string, compared by value.
Invariants.
- Used as the prefix of every Restate workflow key:
"{tenant_id}:{primary}"whereprimaryis the session id (cart workflows), order id (order workflows), or analogous per-invocation identifier. The functiontenant_workflow_keyconstructs the composite;assert_tenant_keychecks at the top of every tenant-scoped workflow body that the URL-supplied Restate key matches the input’s claimed tenant. - Used as the Postgres RLS scope (
set_config('app.tenant_id', …)). Every connection inwarp-storageis bound to oneTenantIdfor its lifetime; cross-tenant reads return zero rows by policy, not by application convention.
Construction.
let t = TenantId::new("tenant_aimer_prod_001");
Display format. The raw string.
Serialization format (JSON). Bare string: "tenant_aimer_prod_001".
CustomerProfile
Definition. The unified customer record ACP returns. The phone
field is typed PhoneNumber — a node that calls
profile.phone into a WhatsAppSend.to slot is automatically
type-safe because both sides are PhoneNumber. ACP’s adapter
re-parses the upstream phone string through PhoneNumber::parse at
the boundary, so a malformed phone from a misconfigured customer
record fails fast.
Fields.
| Field | Type | Required |
|---|---|---|
customer_id |
String |
yes |
phone |
PhoneNumber |
yes |
language |
Language |
yes |
preferred_channel |
Channel |
yes |
email |
Option<String> |
no |
name |
Option<String> |
no |
Notes.
customer_idisStringhere (notCustomerID) because the ACP boundary returns a free-form id. A future revision will tighten this toCustomerIDonce every ACP adapter validates upstream.preferred_channelcarries the customer’s setting; ACP’sStrategyRecommendationmay suggest overriding it per message.- Nullable-field semantics are normative — see Nullable Fields — Semantic Meaning.
Serialization format (JSON).
{
"customer_id": "cust_001",
"phone": { "e164": "+212661234567", "whatsapp_routable": true },
"language": "arabic",
"preferred_channel": "whatsapp",
"email": "customer@example.com",
"name": "Customer Name"
}
StrategyRecommendation
Definition. What ACPEvaluateStrategy returns: a typed offer
recommendation for the next outbound message.
Fields.
| Field | Type | Notes |
|---|---|---|
discount_code |
Option<String> |
None means “no discount” — see below |
confidence |
f32 |
0.0..=1.0, inclusive on both ends |
rationale |
String |
human-readable trace (e.g. "matched_rule: …") |
recommended_channel |
Channel |
what ACP thinks should override preferred_channel |
recommended_products |
Vec<String> |
optional upsell SKUs; empty Vec when none |
The offer branch invariant is the cross-field rule the live ACP integration surfaced. It is spelled out in its own section below: The Offer Branch Invariant.
CartState
Definition. The live cart for a customer at a point in time.
Fields.
| Field | Type | Notes |
|---|---|---|
cart_id |
String |
session-scoped cart handle |
customer_id |
CustomerID |
typed — distinct from order id |
items |
Vec<CartItem> |
line items; total() re-sums these |
subtotal |
Currency |
what the merchant’s checkout reported |
currency |
CurrencyCode |
the cart’s nominal currency |
vendor_ids |
Vec<String> |
distinct vendor handles touched by the cart |
CartItem.
| Field | Type |
|---|---|
product_id |
String |
name |
String |
quantity |
u32 |
unit_price |
Currency |
vendor_id |
String |
Methods.
total() -> Result<Currency, CurrencyError>— sumsunit_price * quantityacross all items. ReturnsCurrencyError::MixedCurrenciesif any two items disagree oncode. The C-01 contract for money holds inside the cart, not just on its boundaries — a multi-currency cart snapshot is a data bug and the type surfaces it loudly.item_count() -> u32— sum of quantities (not number of distinct SKUs).vendor_count() -> usize— number of distinct vendor handles represented. Multi-vendor carts get split per-vendor for fulfillment; this number drives the fan-out factor downstream.
Why subtotal and total() can diverge. Many checkouts apply
discounts or store credit to the merchant-reported subtotal; the
line items don’t know about that adjustment. total() is what the
line items add to; subtotal is what the merchant’s checkout said
the customer owed.
Language
Definition. Closed set of languages Warp’s communication nodes can speak.
Variants. Arabic | French | English | Darija.
Notes.
- Darija is first-class. Darija routes to LaudioLabs STT pipelines in the audio nodes (Phase 3) and to Darija-specific WhatsApp templates in the communication nodes (today).
- Adding a variant is a deliberate cross-cutting decision because
every template selector branches on this —
Languageis enum, not string, on purpose.
Display format. Title case English ("Arabic", "French",
"English", "Darija") — same as the enum variant names. The Rust
Display impl keeps PascalCase for human-readable logs; serialization
is separate.
Serialization format (JSON). Lowercase, per
Serialization Conventions:
"arabic" | "french" | "english" | "darija". Parsers are
case-insensitive on input.
Channel
Definition. Closed set of outbound channels Warp can reach a customer on.
Variants. WhatsApp | FCM | Email | SMS.
Used by CustomerProfile.preferred_channel (what the customer prefers)
and by StrategyRecommendation.recommended_channel (what ACP thinks
should override the preference for the next message).
Display format. "WhatsApp" | "FCM" | "Email" | "SMS" (PascalCase
in human-readable logs).
Serialization format (JSON). Lowercase, per
Serialization Conventions:
"whatsapp" | "fcm" | "email" | "sms". Parsers are case-insensitive
on input.
Platform
Definition. The commerce platform that produced an event.
Variants. Agora | Shopify | WooCommerce | OpenCart | Magento | Odoo.
The Odoo variant landed in Phase 2 session 8 alongside Warp’s
first ERP adapter — sale.order.created / sale.order.cancelled
webhooks translate to OrderPlacedInput / CartAbandonedInput
with the odoo_ namespace prefix on every identifier.
Tag carried by every typed trigger event so downstream nodes can branch on the source (Shopify and Agora differ on WhatsApp opt-in semantics, for example) and dashboards can attribute outcomes by platform.
Platform is closed at the type level so adapters cannot drift —
adding “Wix” requires a Warp release, not a customer-side hack.
Serialization format (JSON). Lowercase: "agora" | "shopify" | "woocommerce" | "opencart" | "magento" | "odoo".
Occasion (new in v0.2)
Definition. A closed set of marketing-relevant occasions Warp’s
campaign nodes branch on. Occasion-aware templates exist for
abandoned-cart, post-purchase, and re-engagement workflows; the
Occasion enum is what selects them.
Variants. Ramadan | Eid | BlackFriday | NewYear | BackToSchool | Mothers Day | None.
Notes.
- MENA-first defaults:
RamadanandEidare first-class variants, not generic “holiday” tags. The template library carries calibrated templates per occasion (P-7). Noneis a valid variant — workflows that don’t care about occasion-tagging carry it explicitly so absence-of-tag is observable.
Display format. PascalCase variant names in logs.
Serialization format (JSON). Lowercase: "ramadan" | "eid" | "black_friday" | "new_year" | "back_to_school" | "mothers_day" | "none".
SegmentCriteria (new in v0.2)
Definition. The typed predicate CustomerSegment evaluates over
a list of CustomerProfiles to produce a CampaignAudience.
Fields.
| Field | Type | Notes |
|---|---|---|
min_purchases |
Option<u32> |
exclude customers with fewer than N orders |
min_total_spend |
Option<Currency> |
exclude below this lifetime spend |
languages |
Vec<Language> |
empty = no language filter |
preferred_channels |
Vec<Channel> |
empty = no channel filter |
occasion |
Option<Occasion> |
restrict to a specific marketing occasion |
Invariants.
min_total_spendisOption<Currency>, notOption<Decimal>— spend filters must declare a currency. A filter set in MAD applied to a customer base reported in EUR is a compile-time error from the predicate engine, not a runtime semantic bug.- An empty
SegmentCriteria(all fieldsNone/ empty) matches every customer. The composition is AND across fields; OR-composition uses multipleCustomerSegmentnodes piped into aCampaignAudienceunion.
Serialization format (JSON).
{
"min_purchases": 2,
"min_total_spend": { "amount": "500.00", "code": "MAD" },
"languages": ["arabic", "french"],
"preferred_channels": ["whatsapp"],
"occasion": "ramadan"
}
ABTestVariant (new in v0.2)
Definition. The label that ABTestRoute assigns to a workflow
invocation so downstream nodes can branch on the assignment.
Variants. A | B | C | D (four-way max in v0.2; opens the door
to wider tests without immediately requiring them).
Invariants.
- Variant assignment is sticky per
(experiment_id, customer_id)— the same customer in the same experiment always gets the same variant. The assignment store lives inwarp-storage; the type carries only the label. - The set is closed; adding
Erequires a Warp release.
Display format. Single uppercase letter ("A", "B", "C",
"D").
Serialization format (JSON). Single uppercase letter. The single-letter convention follows A/B testing literature and is preserved on the wire (see Serialization Conventions).
Nullable Fields — Semantic Meaning
New in v0.2. Warp uses Option<T> only where None carries a
defined business meaning. Adapters and downstream workflows MUST
honour these semantics rather than treating None as an error to
recover from.
CustomerProfile.name
Semantic. None means the platform has no name for this
customer. Many e-commerce checkouts allow guest purchases with no
name field; ACP also returns name: null for customers known only
by phone.
Workflow contract.
- Templates that interpolate
nameMUST provide a fallback. The cart-recoverycart_remindertemplate uses{{name | default: "you"}}semantics (or the language equivalent —"vous"in French,"أنت"in Arabic). - An adapter that has a name string MUST include it. An adapter that
doesn’t MUST emit
None— not an empty string, not the literal text"null", not"Customer".
CustomerProfile.email
Semantic. None means no email on record. WhatsApp-first
customers in MENA frequently have no email field on their commerce
profile.
Workflow contract.
EmailSendMUST branch onSome(_)before invoking. Attempting delivery without checking yields a runtime guardrail trip, never a silent no-op.- A workflow that requires email delivery as its only outreach MUST
declare that constraint and fail closed when
emailisNone. Mixed-channel workflows (e.g. “WhatsApp if reachable, email otherwise”) MUST branch explicitly.
StrategyRecommendation.discount_code
Semantic. None means the strategy engine determined that no
discount is appropriate for this context. This is a valid business
decision, not an error.
The live ACP integration on 2026-05-28 returned discount_code: null
with confidence: 1.0 and rationale: "matched_rule: checkout_giftwrap" — ACP was certain the right next message was a
gift-wrap upsell, with no coupon. Treating that response as “ACP
failed to return a discount” would have sent the customer a generic
discount message instead of the gift-wrap nudge.
Workflow contract.
- Workflows MUST branch on
Some(_)before rendering an offer template that depends on a coupon code. See The Offer Branch Invariant. Nonepaired withconfidence == 1.0is not a contradiction — high confidence in “no discount” is a real ACP outcome.
StrategyRecommendation.recommended_products
Semantic. Empty Vec (not None) when ACP has no upsell to
suggest. v0.2 declines to model “no upsell” as None because the
operations on the field (iter, len, is_empty) work uniformly
on an empty Vec and avoid an Option<Vec<_>> unwrap in every
caller.
The Offer Branch Invariant
New in v0.2. The cart-recovery flow has a two-message structure: a first nudge (“you left items in your cart”) and a follow-up either with an offer (“here’s 10% off code WARP10”) or without (“any questions about your cart?”). Selecting between those follow-ups is a two-condition branch.
The invariant. Render the offer follow-up if and only if:
send_offer = discount_code.is_some() AND confidence > threshold
Both conditions must be true. Neither alone is sufficient.
discount_code |
confidence |
threshold |
Branch taken |
|---|---|---|---|
Some("WARP10") |
0.85 |
0.7 |
offer |
Some("WARP10") |
0.50 |
0.7 |
generic (low conf.) |
None |
1.0 |
0.7 |
generic (no coupon) |
None |
0.50 |
0.7 |
generic |
Why both checks. The naïve shape send_offer = confidence > threshold was the original cart-recovery branch through Phase 2. The
Phase 3 session 6 live run against acp.aimer.ma returned confidence = 1.0 with discount_code = null (the matched_rule: checkout_giftwrap case), which under the naïve shape would have
rendered the cart_offer template with discount_code: null in the
params block — sending the customer a message that promised a
discount that didn’t exist. The fix is the dual-condition check;
it shipped in commit 399f2ea 2026-05-28.
This rule is normative for any v0.2-compatible implementation. A follow-up that selects the offer template on confidence alone is not Warp-compatible.
Adapter Contract
Any system that emits Warp-typed events must:
- Namespace platform-native identifiers. Prefix order ids and
customer ids with the platform name (
shopify_…,wc_customer_…) at the adapter boundary so cross-platform dashboards can attribute every row to a source. - Validate monetary amounts into
Currencybefore emission. A bad amount must fail at the adapter, not deep in a workflow seven steps later. The adapter parses, the workflow trusts. - Validate phone numbers into
PhoneNumberbefore emission. A raw string can ride on aStringslot; nothing typed asPhoneNumbermay carry one. Adapters MUST follow the PhoneNumber normalization contract — local formats are not valid input toPhoneNumber::parse. - Include
tenant_idon every event. Adapters in v0.2 accepttenant_idon the envelope as a stop-gap; per-tenant webhook URLs with header-based tenancy land in a later spec revision (ADR-0003). Either way, no event leaves the adapter without aTenantId. - Use ISO 8601 for all timestamps. Adapters whose upstream uses
other formats (e.g. OpenCart’s
YYYY-MM-DD HH:MM:SS) translate at the boundary. Workflows never re-format.
A Platform variant must exist for an adapter; do not invent values
client-side. Add the variant first (Warp release), ship the adapter
second.
Implementations. Warp ships five adapters that implement this
contract today: Agora (native event bus, no webhook), Shopify,
WooCommerce, OpenCart, and Odoo. The five reference implementations
live in crates/warp-catalog/src/adapters/ — part of Warp’s commercial
distribution, not included in this open-source repository.
Versioning
This spec follows semver.
- Major bumps for breaking changes — removed fields, narrowed invariants, renamed variants, changed serialization formats. A v1 consumer must not be able to read a v2 emitter.
- Minor bumps for additive changes — new optional fields on existing types, new enum variants on extensible enums, new sibling types. v0.2 is a minor bump from v0.1 (three new types, four new normative sections, no breaking change to any v0.1 type).
- Patch bumps for documentation fixes that do not change the surface — clearer prose, fixed examples, corrected serialization snippets.
v0.x is unstable. Minor bumps may include breaking changes during v0; pin against an exact version in adapter contracts. v1.0 will be declared only when two independent implementations exist (a Warp runtime + a second-party tool that produces or consumes typed events) — the public commitment line, not a marketing goalpost.
Out of scope in v0.2
These types are named in CLAUDE.md but not yet shipped:
DeliveryWindow— date range with timezone, validated against delivery areas.CampaignAudience—List<CustomerProfile>with segment metadata. v0.2 shipsSegmentCriteria(the predicate); the audience type that the predicate produces lands with the first node that emits it.VendorDraft— product submission with enrichment state machine.SKU— product identifier with catalog validation.
Each will land with the first node that needs it. Until then, the
relevant fields are Option<String> on the adapter surface and free
text in the workflow.
Changelog
v0.2 (2026-05-29)
- Serialization Conventions section added. All enum variants
serialize to lowercase strings; currency codes and
ABTestVariantpreserve their canonical casing. Parsers are case-insensitive on input. Codifies the contract that resolved the Phase 3 session 6 ACP-casing mismatch. - PhoneNumber normalization contract for adapters added. Spells
out the Morocco normalization rules (
06X… → +212 6X…), the unparseable-input fallback (tracing::warn!+Noneor fallback, never panic), and the rule that adapters MUST normalize beforePhoneNumber::parserather than relying on the type to do it. - Nullable Fields — Semantic Meaning section added. Defines what
Nonemeans forCustomerProfile.name,CustomerProfile.email,StrategyRecommendation.discount_code, andStrategyRecommendation.recommended_products. Documents thatname: nullis real-world,email: nullrequires the workflow to branch, anddiscount_code: nullwith high confidence is a valid business decision (not an error). - Offer Branch Invariant section added. Defines
send_offer = discount_code.is_some() AND confidence > threshold. Neither condition alone is sufficient. Surfaced by the Phase 3 session 6 live run againstacp.aimer.mareturningconfidence=1.0, discount_code=null; the dual-condition fix shipped in commit399f2ea2026-05-28. - Occasion type added — closed enum (
Ramadan | Eid | BlackFriday | NewYear | BackToSchool | MothersDay | None), MENA-first defaults. - SegmentCriteria type added — typed predicate for
CustomerSegment. Currency-aware filters (no naked decimal spend thresholds). - ABTestVariant type added — closed enum
A | B | C | D, sticky per(experiment_id, customer_id). - Adapter count updated. Five platform adapters implemented: Agora, Shopify, WooCommerce, OpenCart, Odoo (Magento variant reserved; SAP S/4HANA spec’d but not yet implemented).
v0.1 (initial)
- Initial specification.
- 10 core commerce types:
Currency,PhoneNumber,OrderID,CustomerID,TenantId,CustomerProfile,CartState,Language,Channel,Platform. - Adapter contract (5 rules).
- Versioning policy.