ADR-026: los identificadores de la orden tienen formato declarado y salen del detector de tarjetas
Aceptado el 14/11/2026 por Andrés. Lo implementa el carril `detector-tarjetas`.
Contexto
08-seguridad.md promete que la API rechaza campos con forma de número de tarjeta, por si un partner envía uno por error. order-service lo cumplía revisando todo el cuerpo de POST /v1/orders: cualquier texto con 13 a 19 dígitos seguidos (con o sin espacios o guiones) que pasaran el algoritmo de Luhn (la suma de verificación con la que todo número de tarjeta cierra su último dígito) recibía 400 VALIDATION_ERROR. Solo el teléfono quedaba fuera.
Un dígito de control acierta por azar una de cada diez veces, así que la regla adivina: no distingue una tarjeta de un identificador que por casualidad tiene la misma forma. El carril f9-idempotencia (14/11/2026, commit 3460176) lo encontró en la tienda, no en un partner:
- La tienda arma
externalOrderIdcomoWEB-<Idempotency-Key>(apps/storefront/worker/orders.ts), y la llave es un UUID (identificador único universal de 128 bits, en hexadecimal). - Las cifras de un UUID salen seguidas a menudo. En 2 000 000 de llaves al azar, 0,22 % caen en el detector: una compra de cada ~450 (repetido en este carril: 4 315 de 2 000 000, 1 de cada 463).
- Esa compra recibe 400. Como la llave de idempotencia guarda la respuesta, el reintento con la misma llave recibe el mismo 400. Fue la falla intermitente de F9 en
make f4-e2e(400, 409, 409, 409, 409y cero órdenes).
Un servicio de órdenes no puede rechazar una orden válida. Un partner con identificadores numéricos largos (los de muchos marketplaces son solo dígitos) lo vería en una de cada diez órdenes, no en una de cada 450.
Opciones
| Opción | A favor | En contra |
|---|---|---|
| A. Los campos de identificador salen del detector y el contrato declara su formato; el detector sigue en el texto libre | Ningún identificador válido se rechaza por azar; el formato es una regla exacta que el partner conoce antes de integrarse, y además frena basura y caracteres de control en campos que van a logs y eventos | Una tarjeta puesta a propósito en un identificador pasa; el contrato se vuelve más estricto en campos que antes eran texto sin forma |
B. Mantener el detector en todo el cuerpo y pedir a la tienda otro formato de externalOrderId |
No cambia la capa | Arregla solo a la tienda; cualquier partner con identificadores numéricos sigue con rechazos al azar. Mueve la carga de un defecto de la capa a cada canal |
| C. Quitar el detector | Cero rechazos falsos | Una tarjeta enviada por error en la dirección o el nombre entraría y quedaría guardada, cifrada, en order-service: la capa saldría del alcance reducido de PCI DSS que fija 08-seguridad.md |
| D. Exigir además un prefijo de emisor (BIN, bank identification number: los primeros dígitos de la tarjeta, que identifican al banco emisor) o un largo exacto | Menos falsos positivos | Sigue adivinando; solo baja la tasa. El identificador de un partner puede empezar por 4 o por 5 igual que una tarjeta |
Decisión
Opción A.
- Formato de los identificadores. El contrato define el schema
Identifier: texto de 1 a 64 caracteres, letras ASCII, dígitos y.,_,:y-, que empieza por letra o dígito (^[A-Za-z0-9][A-Za-z0-9._:-]*$). Lo usan los seis campos de identificador deCreateOrderRequest:externalOrderId,channel,customer.customerId,fulfillment.storeId,lines[].skuylines[].priceVersion. Un valor fuera del formato recibe400 VALIDATION_ERRORcon el campo enerrors[].field. - El formato acepta lo que hoy se envía, verificado contra los datos y no supuesto:
WEB-<uuid>de la tienda,MKP-778899ymkp-caribe-<corrida>-<n>de los partners de prueba y de carga, los 1 143 SKU de la semilla (SKU-1001,AR-CIN-001-COG-85, hasta 18 caracteres), las 5 ubicaciones (BOG-01,CD-BOG), los 4 canales (ecommerce,mkp-andino), los 200customerIddel CRM (cus_8f3a21) y las versiones de precio del erp-adapter (pv_<ULID>). Supuesto S27: los identificadores de orden de los partners caben en ese alfabeto; si uno trae otros caracteres, el partner los traduce, igual que traduce sus estados. - El detector revisa solo el texto libre: nombre, correo, dirección (
line1,line2,city,region,postalCode) ypayment.token. El token sigue en el detector aunque es un identificador de la pasarela, porque es justo el campo donde un partner pondría la tarjeta por error, y los tokens de las pasarelas en uso (tok_...,pm_...y los del simulador) no son solo dígitos. El teléfono sigue fuera, como antes: es numérico por naturaleza. - Una sola fuente. La lista de campos con formato de identificador vive en un solo lugar del servicio (
CreateOrderRequest.IDENTIFIERS), que alimenta a la vez la validación de formato y las exclusiones del detector.make verifycompara esa lista y el patrón con el contrato y falla si difieren. - Compatibilidad. Agregar un patrón a un campo de petición restringe el contrato. Se acepta sin versión nueva porque
order-serviceya limitaba esos campos a 64 caracteres, ningún partner real está integrado (el demo es el único cliente) y todo lo que hoy se envía cumple el formato (punto 2).
Qué se sacrifica
- Una tarjeta puesta a propósito en un identificador no se detecta. Quien escriba un número de tarjeta en
externalOrderIdo enskulo verá aceptado o rechazado por otra causa (UNKNOWN_SKU), y enexternalOrderIdquedará guardado y viajará en eventos y webhooks. Se acepta porque el detector protege contra el error, no contra el abuso: la capa nunca recibe tarjetas, solo el token de la pasarela, y el canal tokeniza antes de llamar (08-seguridad.md). Un partner que quisiera filtrar tarjetas tiene caminos más directos que la capa. - El contrato es más estricto. Un partner con identificadores que usen espacios, barras o caracteres fuera de ASCII tiene que traducirlos (supuesto S27).
- El detector sigue adivinando en el texto libre. Un número de 13 a 19 dígitos que pase Luhn en una dirección se rechaza aunque no sea tarjeta. Es raro en una dirección colombiana y, si pasa, el mensaje dice qué campo y por qué, y el cliente lo puede corregir; en un identificador generado por máquina, el cliente no puede.