Deciding how QIIUB exposes resource identifiers in API routes and responses
bigint Id en ~240 endpoints. Esto es el patrón que toda plataforma que ha migrado desde 2015 ha abandonado.GlobalId interno (sync). Recomendación: upgrade a UUIDv7. Decisión técnica con poco debate.PublicId expuesto en API. Recomendación: UUIDv7 + prefijo tipado, columna PublicId. Es donde está la discusión real.bigint Id interno se queda — sigue siendo PK clustered y nunca se expone./products/prod_01j8m410fpqm4r6t8v0x1a3c5e7 en lugar de /products/12345.QIIUB hoy tiene dos fuentes de verdad contradictorias sobre cómo deben verse los identificadores en las rutas de API:
{Id} in URLs)."
Ambos fueron aceptados el mismo día (2026-03-12) y nunca se resolvió la inconsistencia. El resultado: Phase 18 cerró con 246 endpoints y solo 2 entidades (Merchant, Partner) usando el PublicId. Todo lo demás — productos, ventas, clientes, empleados, purchase orders, shifts, gift cards, etc. — todavía expone el bigint Id interno.
bigint auto-increment filtra volumen de negocio (¿cuántas ventas, cuántos clientes?) y permite enumeración trivial.| Capa | Realidad hoy | Rige en |
|---|---|---|
| Clave primaria interna | bigint Id (IDENTITY) | Todas las entidades |
| Sync identity | GlobalId (uniqueidentifier, GUIDv4) | Todas las entidades de Merchant DB |
| ID externo Central DB | PublicId (char(8) hex) | Solo Merchant + Partner |
| ID externo Merchant DB | — (no existe — se expone Id) | ~230 endpoints |
| ID externo resto Central DB | — (no existe — se expone Id) | MerchantGroup, PlatformUser, Invitation, Template, Server, etc. |
// src/QIIUB.Api/Features/Products/GetProduct/GetProductEndpoint.cs:57 public override void Configure() { Get("/products/{Id}"); // ← expone bigint } // src/QIIUB.Api/Features/Sales/GetSale/GetSaleEndpoint.cs:90 Get("/sales/{Id}"); // ← expone bigint // src/QIIUB.Api/Features/Platform/Merchants/GetMerchant/GetMerchantEndpoint.cs:57 Get("/platform/merchants/{Id}"); // ← acepta Id O PublicId (8-hex) // src/QIIUB.Api/Features/Platform/Partners/GetPartner/GetPartnerEndpoint.cs:38 Get("/platform/partners/{PublicId}"); // ← solo PublicId (único consistente)
Total endpoints con bigint en ruta: ~240 de 246. Solo los endpoints de Partner y algunos de Merchant usan PublicId.
Research completo en docs/research/research-api-identifier-format.md (14 plataformas + 5 especificaciones, con fuentes verificables). Resumen ejecutivo por categoría:
| Familia | Ejemplo | Usado por | Adecuado para POS moderno |
|---|---|---|---|
| Bigint secuencial | 12345 |
Shopify REST, Revel, Epos Now, Lightspeed R-Series | ❌ Anti-pattern. Todos migrando afuera. |
| UUID v4 (GUID) | 550e8400-e29b-41d4-a716-446655440000 |
Toast, Lightspeed X-Series, Linear (canónico), Square Loyalty | ✓ Safe, boring, mainstream |
| Opaque base32 corto | 3D19QN31ANYR5 |
Clover, Square Merchant/Location | ⚠ Funciona pero sin tipado |
| Opaque base62 medio | bP9mAsEMYPUGjjGNaNO5ZDVyLhSZY |
Square Payment/Order/TeamMember | ⚠ Sin tipado, legible sí |
| Typed prefixed (Stripe-style) | cus_NffrFeUfNV2Hib |
Stripe, Clerk, Linear (parcial), Square Invoice (inv:0-…), Square Gift Card (gftc:…) |
✓ Emergiendo como estándar |
| UUIDv7 + prefix (TypeID spec) | user_01j8m3xk7q9vy2p4n6r8s0t1w3 |
Bindings: Go, Rust, Python, C#, Java, TS, Ruby… Adoptadores: Jetify, proyectos nuevos post-2023 |
✓ Mejor combinación disponible |
| Recurso | Ejemplo verbatim | Formato |
|---|---|---|
| Merchant | DM7VKY8Q63GNP | 13 chars uppercase |
| Location | L88917AVBK2S5 | 13 chars uppercase |
| Catalog item | W62UWFY35CWMYGVWK6TWJDNI | 24 chars uppercase |
| Customer | Q8002FAM9V1EZ0ADB2T5609X6NET1H0 | 26-31 chars variable |
| Order (old) | CAISENgvlJ6jLWAzERDzjyHVybY | 27 chars, sospecha protobuf |
| Order (new) | d7eKah653Z579f3gVtjlxpSlmUcZY | 29 chars mixed case |
| Payment | bP9mAsEMYPUGjjGNaNO5ZDVyLhSZY | 29 chars mixed case |
| Team Member | 1yJlHapkseYnNPETIU1B | 20 chars mixed case |
| Refund | bP9mAsEMYPUGjjGNaNO5ZDVyLhSZY_69MmgHubkLqx9wGhnmenRUHOaKitE6llfZuxcWYjGxd | 73 chars compuesto |
| Loyalty Account | 79b807d2-d786-46a9-933b-918028d7a8c5 | UUID v4 completo |
| Invoice | inv:0-ChCHu2mZEabLeeHahQnXDjZQECY | Typed prefixed con version marker |
| Gift Card | gftc:00113070ba5745f0b2377c1b9570cb03 | Typed prefixed con hex |
Punto válido del team: ¿no podría confundirse "TypeID" con campos como ProductTypeId, PaymentTypeId, UserTypeId — FKs a tablas de enums o discriminadores de tipo?
Sí, absolutamente. "TypeID" como vocabulario del proyecto sería confuso. Comparación rápida:
| Término | Problema | Veredicto |
|---|---|---|
TypeID | Se confunde con FKs a enums (ProductTypeId, PaymentTypeId) | ❌ Rechazado |
PrefixId | No existe en ningún sistema real. Invención. | ❌ Rechazado |
Slug | Ya significa handle SEO (tylenol-500mg-100ct). Conflicto directo con ProductWebListing.Slug. | ❌ Rechazado |
ExternalId | En Stripe/Toast significa "ID provisto por el integrador externo". Conflicto semántico. | ❌ Rechazado |
GlobalId | Ya existe y ya significa "sync identity". Reutilizar cambia contrato interno. | ⚠ Reservado para sync |
PublicId | Ya existe en QIIUB (Merchant/Partner). Ya significa "ID expuesto en API pública". Consistente. | ✓ Correcto |
PublicId (extendiendo la convención existente de ADR-0029). El formato de esa columna sigue el spec TypeID (UUIDv7 + prefijo). "TypeID" aparece solo en el ADR como referencia al spec que adoptamos — no en código, no en nombres de columnas, no en vocabulario del equipo.
// C# entity public class Product : BaseEntity { public long Id { get; set; } // interno, bigint public Guid GlobalId { get; set; } // sync identity — sin cambios public string PublicId { get; set; } // "prod_01j8m410fpqm..." — expuesto en API public int MerchantId { get; set; } // ... } // Ruta Get("/products/{PublicId}"); // Cliente HTTP GET /products/prod_01j8m410fpqm4r6t8v0x1a3c5e7
QIIUB tiene tres tipos de identificadores con tres roles bien diferenciados. Antes de discutir formato, hay que tener clara la arquitectura porque el formato del PublicId solo aplica a parte del sistema — no al hot path del cashier.
public class Product : BaseEntity { public long Id { get; set; } // (A) INTERNO — PK clustered (MerchantId, Id), FKs, joins. Nunca expuesto. public Guid GlobalId { get; set; } // (B) SYNC — dedup en push offline. En body MessagePack del sync engine. public string PublicId { get; set; } // (C) EXTERNO — único en URL/response de API pública. "prod_01j8m..." public int MerchantId { get; set; } // ... }
Id local + GlobalId (GUID).PublicId.
GlobalId.
/products/prod_01j8m...
Id + GlobalId + PublicId.(MerchantId, Id) clustered, (MerchantId, GlobalId) + (MerchantId, PublicId) non-clustered.
| Caso de uso | Id (bigint) | GlobalId (UUID) | PublicId (string) |
|---|---|---|---|
| Cashier UI (checkout, sale entry, lookup local) | SQLite local solo | SQLite local solo | No existe |
| Sync engine push/pull (batch background) | Nunca | SÍ (body MessagePack) | Nunca |
| POS llama cloud (returns, loyalty, AR, gift-card balance) | Nunca | A veces (request) | SÍ (URL + response) |
| Admin portal (qiiub-admin) — humanos BCPOS / Partners | Nunca | Nunca | SÍ (URL + response) |
| Merchant portal (qiiub-portal) — humanos del merchant | Nunca | Nunca | SÍ (URL + response) |
| Logs del servidor / Sentry / mensajes de error | Nunca | A veces | SÍ |
| Webhooks / integraciones terceras (futuro) | Nunca | Nunca | SÍ |
| FKs y joins en SQL | Solo aquí | No | No |
PublicId está en el camino del POS solo para operaciones cloud-roundtrip (returns, loyalty, etc.). El cashier no lo ve visualmente — pero está en logs, breadcrumbs de Sentry, y mensajes de error de la nube. Esto refuerza que el formato debe ser legible en debugging aunque no sea ultra-corto: el cashier nunca lo va a teclear, pero soporte sí lo va a leer.
El HTML original las trataba como una sola decisión. En realidad son dos decisiones independientes con perfiles de debate muy distintos.
GlobalId de UUIDv4 a UUIDv7Qué cambia: el generador de la columna existente GlobalId uniqueidentifier. Hoy es UUIDv4 (NEWSEQUENTIALID() en server, Guid.NewGuid() en POS offline). Pasaría a UUIDv7 en ambos lados.
Qué NO cambia: el contrato de API. GlobalId sigue siendo interno al sync engine. El body MessagePack del sync sigue llevando un GUID de 16 bytes.
Por qué: UUIDv7 es time-ordered globalmente → arregla fragmentación del índice no-clustered en (MerchantId, GlobalId) sin necesidad de NEWSEQUENTIALID(). Sync determinismo: cuando el servidor recibe 500 sales offline, ordena por GlobalId y obtiene orden cronológico estable sin depender del clock del cliente.
Costo: reemplazar 2-3 callsites donde generamos el GUID. Cero migración de data (UUIDs viejos siguen siendo válidos — UUIDv7 es un superset de la spec UUID).
Debate esperado: bajo. Es upgrade técnico sin impacto a clientes externos.
PublicId expuesto en APIQué cambia: agregamos columna PublicId varchar(40) a cada entidad expuesta. Migramos ~240 endpoints de {Id} a {PublicId}.
Recomendación: formato siguiendo el spec TypeID — prefijo tipado (2-5 chars) + _ + 26 chars Crockford base32 del UUIDv7 (que es el mismo valor del GlobalId, encodeado).
Qué NO cambia: Id bigint sigue siendo PK clustered. GlobalId sigue siendo sync identity. PublicId es nuevo, separado, opt-in por entidad.
Por qué este formato específicamente: ver §8 — análisis comparativo de NanoID vs UUIDv7 vs alternativas.
Debate esperado: alto. Las 7 preguntas abiertas (§9) atacan principalmente esta decisión.
Pregunta válida del equipo: "¿Por qué UUIDv7 y no algo más corto, dado que ya tenemos prefijo tipado para diferenciar tipos?". Aquí el análisis honesto con números reales.
Asumiendo prefijo de 5 chars (prod_):
| Opción suffix | Suffix | Total | Entropía | Time-ordered | Spec abierto |
|---|---|---|---|---|---|
| Stripe-style 12-char base62 | 12 | 17 | ~71 bits | parcial | no — Stripe propietario |
| NanoID 16 chars | 16 | 21 | ~96 bits | no | sí |
| NanoID 21 (default) | 21 | 26 | ~126 bits | no | sí |
| UUIDv7 base62 | 22 | 27 | 128 bits | sí | UUIDv7 sí, encoding no |
| UUIDv7 Crockford base32 (TypeID) | 26 | 31 | 128 bits | sí | sí (ambos) |
| ULID Crockford base32 | 26 | 31 | 128 bits | sí | sí |
| KSUID base62 | 27 | 32 | 160 bits | sí (1s) | sí |
| UUIDv7 hex | 32 | 37 | 128 bits | sí | sí |
| UUID v4 hex (GUID actual) | 36 | 41 | 122 bits | no | sí |
Diferencias clave:
CreatedAtUtc del cliente — pero los relojes de POS pueden estar mal calibrados, y dos sales con el mismo timestamp necesitan tiebreaker. (b) Que el ID mismo sea time-ordered → ordenamiento estable sin depender del clock.
GlobalId (Decisión A). Para el PublicId (Decisión B), aplica indirectamente — es práctico que ambos compartan el mismo valor encodeado distinto.
Sale proyecta ~100M+ rows agregados a 5-10 años. El índice no-clustered en (MerchantId, GlobalId) se fragmenta con UUIDs aleatorios — exactamente el problema que ADR-0005 mitigó con NEWSEQUENTIALID(). UUIDv7 lo arregla globalmente (server y client). NanoID y UUIDv4 reintroducen el problema.
PublicId es derivado de GlobalId, hereda el beneficio.
// Más corto (NanoID-16, 21 chars): prod_V1StGXR8_Z5jdHi6B- // UUIDv7 base62 (27 chars): prod_2x4y6z8a0b1c2d3e4f5g6h // TypeID Crockford base32 (31 chars) — recomendado: prod_01j8m410fpqm4r6t8v0x1a // UUIDv4 actual GUID (41 chars): prod_550e8400-e29b-41d4-a716-446655440000
5-10 chars extra para offline-determinismo + fragmentation-fix + spec-based. Trade reasonable. No es "UUIDv7 porque sí" — es "UUIDv7 porque QIIUB es offline-first y tiene Sale".
NEWSEQUENTIALID(). Time-ordered globalmente, server y client.sale_01j8m... se entiende solo; 987654 no.PublicId existente de Merchant/Partner. Extendemos, no inventamos.GlobalId directo).Merchant.PublicId + Partner.PublicId (8-hex actuales)?PublicId o renderizado computed desde GlobalId?PublicId varchar(40). Opción B: computed column desde GlobalId. A es más flexible (podemos cambiar formato sin regenerar); B es más limpio (single source of truth).{Id} y {PublicId} por 2-3 semanas. Como aún no hay clientes terceros, big-bang es más limpio.inv:0-… dejando la puerta abierta a inv:1-…. Stripe NO hace esto y nunca ha cambiado un prefijo. Recomendación: no incluir — complica más de lo que ayuda.ExternalId para que integradores traigan su propio ID?| Entidad | Prefijo | Ejemplo | Entidad | Prefijo | Ejemplo |
|---|---|---|---|---|---|
| Central DB | Merchant DB — ops | ||||
| Merchant | mrch | mrch_01j8m3x… |
Product | prod | prod_01j8m41… |
| Partner | prtn | prtn_01j8m3y… |
Sale | sale | sale_01j8m41… |
| PlatformUser | usr | usr_01j8m3z… |
Customer | cust | cust_01j8m41… |
| MerchantGroup | grp | grp_01j8m40… |
Employee | emp | emp_01j8m41… |
| Invitation | inv | inv_01j8m40… |
PurchaseOrder | po | po_01j8m41… |
| Template | tmpl | tmpl_01j8m40… |
Shift | shft | shft_01j8m41… |
| Server | srv | srv_01j8m40… |
GiftCard | gc | gc_01j8m41… |
| DeviceRegistration | dev | dev_01j8m40… |
LoyaltyAccount | loy | loy_01j8m41… |
| AiCreditTransaction | ait | ait_01j8m40… |
Supplier | sup | sup_01j8m41… |
PublicId = prefix + _ + 26 chars base32 de un UUIDv7. Uniforme en todas las entidades.GlobalId directamente. Upgrade a UUIDv7. Rutas: /products/{GlobalId}.char(8) hex actual de Merchant/Partner a todas las entidades.bigint Id en rutas. Aceptar la contradicción ADR-0005/0029 indefinidamente.refund_id = payment_id + "_" + suffix) es tentador para lookup gratis pero acopla los dos IDs para siempre y rompe el contrato "opaco".inv:0-…). Parece buena idea; en la práctica nunca se usa — cuando hay que migrar, el equipo introduce un prefijo nuevo (inv2_…) de todos modos.GlobalId as-is con 36 chars. Es lo que ADR-0005 insinuó; hoy sabemos que UUIDv7 + base32 da 26 chars con propiedades equivalentes.publicId.substring(5), no publicId.split("_")[1]. Regla: opaco.C:\temp\.claude\qiiub\ con plan de migración detallado: prefijos definitivos, librería C# (Jetify/TypeID bindings), estrategia de migración de data, actualización de 240 endpoints, ajuste de admin portal + mockups, tests de integración.docs/adr/0005-globalid-guid-sync-identity.md — reemplazar nota "under review" con referencia a ADR-0044docs/adr/0029-publicid-central-api-routes.md — marcar como supersedida por ADR-0044 (manteniendo el 8-hex para Merchant/Partner como legacy)docs/qiiub-context-for-ai.md §API Identifier Strategy — reemplazar con la regla finalfeedback_api_identifiers.md — actualizar con la regla finalResearch completo con fuentes verificables → docs/research/research-api-identifier-format.md
QIIUB — API Identifier Strategy Discussion · 2026-04-24 AST