← GeoStream

09 — Reglas de validación y catálogo de errores

ID: GS-VAL-001 · Versión: 0.2.0 Contratos relacionados: contracts/test-vectors/location_samples.json, contracts/test-vectors/ids.json, enum ErrorCode en common.proto, esquema Problem en OpenAPI.

1. Principios

  1. Todo dato que llega de un cliente es no confiable y se valida en el servidor, aunque el SDK también lo valide.
  2. La validación es pura y determinista (gs-domain): dado el mismo input, el mismo now y la misma política, el mismo resultado.
  3. Se falla de forma cerrada: ante duda, rechazar.
  4. Un error informa del primer fallo según el orden definido (orden estable = pruebas estables).
  5. Los mensajes de error no contienen el valor recibido cuando puede ser sensible (coordenadas, códigos, tokens). Indican el campo y la razón.

2. Reglas generales de REST

Regla Detalle Error
Tamaño de cuerpo ≤ GS_REST_MAX_BODY_BYTES 413 PAYLOAD_TOO_LARGE
Content-Type application/json en peticiones con cuerpo 415 VALIDATION_FAILED
JSON malformado 400 VALIDATION_FAILED
Campos desconocidos Se rechazan en cuerpos de petición 422 VALIDATION_FAILED (reason: unknown_field)
Ruta inexistente 404 NOT_FOUND
Método no permitido 405 VALIDATION_FAILED
ID malformado en la ruta Se trata como inexistente 404 NOT_FOUND
ID malformado en el cuerpo 422 VALIDATION_FAILED (reason: invalid_format)
Idempotency-Key 1..=255 caracteres [A-Za-z0-9_\-:.] 422 VALIDATION_FAILED
If-Match "v<entero>" 422 VALIDATION_FAILED si malformado; 412 PRECONDITION_FAILED si no coincide
Paginación limit 1..=100 (default 50); cursor opaco ≤ 512 caracteres emitido por el servidor 422 VALIDATION_FAILED
Cadenas UTF-8 válido; sin caracteres de control (U+0000–U+001F, U+007F) salvo que se indique 422

2.1 Vocabulario de reason en errores de campo

required, invalid_format, out_of_range, not_finite, too_long, too_short, too_many, not_allowed, unknown_field, invalid_enum, conflict.

Cada error de campo REST: { "field": "<ruta JSON con puntos>", "reason": "<reason>" }.

3. Muestra de ubicación (LocationSample)

3.1 Orden de evaluación y reglas

Las reglas se evalúan en este orden; el primer fallo produce SAMPLE_STATUS_REJECTED, error_code = INVALID_LOCATION y field = la ruta indicada (en stream; en la ingesta HTTP el mismo par field/error_code va en SampleResult).

# Campo (field) Presencia Regla
1 sequence obligatorio entero en [1, 9007199254740991] (2^53 − 1)
2 captured_at obligatorio Timestamp válido: seconds en [−62135596800, 253402300799], nanos en [0, 999999999]
3 position obligatorio presente
4 position.latitude obligatorio finito, [−90, 90]
5 position.longitude obligatorio finito, [−180, 180]
6 position — si reject_null_island (política): rechazar si latitude == 0.0 ∧ longitude == 0.0 exactamente
7 position.horizontal_accuracy_m obligatorio finito, > 0, ≤ max_horizontal_accuracy_m (política)
8 position.altitude_m opcional finito, [−1000, 50000]
9 position.vertical_accuracy_m opcional finito, (0, 10000]
10 position.speed_mps opcional finito, [0, 300]
11 position.speed_accuracy_mps opcional finito, (0, 300]
12 position.heading_deg opcional finito, [0, 360)
13 position.heading_accuracy_deg opcional finito, (0, 180]
14 quality obligatorio presente
15 quality.kind obligatorio valor conocido ≠ UNSPECIFIED
16 captured_at — captured_at ≤ now + GS_LOCATION_MAX_FUTURE_SKEW_MS
17 quality.is_mock — si reject_mock_samples (política, apagada por defecto): rechazar si is_mock == true (DR-20261031-02)
18 quality.source — si reject_network_only (política, apagada por defecto): rechazar si source == NETWORK (DR-20261031-02)

Normalizaciones (sin error):

Reglas del SDK antes de enviar (12 §11): convertir velocidades/rumbos negativos de la plataforma en "ausente", normalizar heading == 360 a 0, nunca inventar precisión.

3.2 Resultados no erróneos

3.3 Calidad

3.4 Tiempo

4. Algoritmo de aceptación (normativo)

// ILUSTRATIVO en sintaxis; NORMATIVO en orden y semántica.
fn process_publish(conn, msg: PublishLocation) -> PublishAck:
    // 1. Precondiciones del lote (08 §6.4), en el orden de la tabla.
    if let Err(code) = check_batch_preconditions(conn, msg): 
        return PublishAck { session_id, request_error: code, results: [], retry_after_ms }
    now = server_clock.now()
    policy = effective_policy(conn.tenant, session)
    results = []
    last_accepted = None
    max_seq = 0
    for s in msg.samples (en orden):
        // 2. Validación pura (§3.1, reglas 1–16)
        if let Err(field) = validate_sample(s, policy, now):
            results.push(REJECTED(s.sequence, INVALID_LOCATION, field)); continue
        // 3. Antigüedad (§3.2)
        if s.captured_at < now - max_event_age:
            results.push(IGNORED_STALE(s.sequence)); continue
        // 4. Orden (CAS atómico en el almacén efímero, clave seq:{session}:{device})
        match ephemeral.seq_compare_and_set(session, device, s.sequence):
            Duplicate => results.push(IGNORED_DUPLICATE(s.sequence))
            Stale     => results.push(IGNORED_STALE(s.sequence))
            Accepted  =>
                results.push(ACCEPTED(s.sequence))
                last_accepted = Some(s); max_seq = s.sequence
                // 4-bis. Paso de estimación (22 §2.8). Solo si policy.track_estimates_enabled.
                //        La unidad es (device, sequence): una muestra publicada en varias
                //        sesiones conserva la misma sequence y se estima una sola vez.
                est = track_cache.get_or_compute(device, s.sequence,
                          || track.update(track_state(device), s, policy, now))
                bus.publish(loc_topic(session), LocationUpdate{
                    session, member, s, received_at: now, LIVE,
                    estimate: est.for_session(session, member) })
    // 5. Efectos finales
    if let Some(s) = last_accepted:
        if session.snapshot_ttl_seconds > 0: ephemeral.set_snapshot(session, member, update(s), ttl)
        ephemeral.devseq_max(device, max_seq)
        geofence_engine.evaluate(...)   // F11, asíncrono, nunca bloquea
        history_writer.enqueue(...)     // E6, canal acotado, nunca bloquea (24 §2)
    return PublishAck { session_id, request_error: UNSPECIFIED, results, retry_after_ms: 0 }

seq_compare_and_set DEBE ser atómico (script Lua en Redis/Valkey; mutex en memoria). Semántica: si no existe valor previo → Accepted; si seq > prev → guardar y Accepted; si seq == prev → Duplicate; si seq < prev → Stale.

Invariante del paso de estimación (P-E3 / FR-LOC-006). El paso 4-bis nunca modifica ni descarta la muestra s: la estimación viaja en el campo aparte LocationUpdate.estimate, etiquetada FILTERED o MAP_MATCHED. Un fallo del motor de trayectoria (por ejemplo, un conflicto de versión del estado en la ingesta HTTP) no invalida la aceptación: en ese caso estimate va ausente y se incrementa gs_track_state_conflict_total. La aceptación nunca depende de la disponibilidad del filtro.

5. Validación de otros recursos

Los campos y tipos exactos están en OpenAPI. Reglas semánticas adicionales:

Recurso / campo Regla Error
external_subject_id ^[A-Za-z0-9._:\-]{1,128}$ 422 invalid_format
installation_id UUID v4 canónico en minúsculas 422 invalid_format
CreateSession.mode obligatorio, enum 422 required / invalid_enum
CreateSession.expires_in_seconds 60..=max_session_ttl_seconds; default default_session_ttl_seconds 422 out_of_range
CreateSession.max_members ONE_TO_ONE: ausente o 2; otros: 2..=max_members_cap; default por modo 422
CreateSession.snapshot_ttl_seconds 0..=snapshot_ttl_max_seconds; default snapshot_ttl_default_seconds 422
CreateSession.stale_after_seconds 5..=3600; default stale_after_seconds_default 422
metadata ≤ 32 entradas; clave ^[a-z0-9_.-]{1,64}$; valor ≤ 512 caracteres 422 too_many / invalid_format / too_long
CreateSession.owner_external_subject_id solo aplicación; si lo envía un dispositivo → not_allowed 422
CreateSession.initial_members solo aplicación; ≤ min(max_members, 100); sin duplicados; total con propietario ≤ max_members 422
Sesiones activas por subject ≤ max_active_sessions_per_subject al crear o unirse 409 CONFLICT
UpdateSession.expires_at > now + 60 s y ≤ now + max_session_ttl_seconds 422
UpdateSession.max_members ≥ miembros activos (si no, 409 CONFLICT); inmutable en ONE_TO_ONE 422 / 409
AddMember.role = OWNER solo si la sesión no tiene propietario activo 409 CONFLICT
UpdateMember.role solo aplicación 403 PERMISSION_DENIED
UpdateMember.capabilities INV-MEM-1..3 403 PERMISSION_DENIED
SetSharing.sharing = true solo member_id = me con publish 403 PERMISSION_DENIED
CreateInvitation.grants ⊆ capacidades del emisor; manage solo propietario o aplicación 403 PERMISSION_DENIED
CreateInvitation.max_uses 1..=invitation_max_uses; default 1 422
CreateInvitation.expires_in_seconds 60..=invitation_max_ttl_seconds, y sin superar session.expires_at; default 86400 (recortado a la expiración de la sesión) 422
RedeemInvitation.code 1..=32 caracteres antes de normalizar; normalización de 05 §6 404 INVITATION_INVALID si no normaliza
TokenRequest.ttl_seconds GS_TOKEN_MIN_TTL_S..=GS_TOKEN_MAX_TTL_S 422
TokenRequest.scopes ⊆ allowed_device_scopes 403 INSUFFICIENT_SCOPE
Webhook.url https:// (o http:// solo con GS_ENV=dev), ≤ 2048, host resoluble y no privado (FR-WHK-004) 422 invalid_format / not_allowed
Webhook.event_types no vacío, subconjunto del catálogo de 07 §10.2 422
Geocercas 14_GEOFENCING.md §3 422
IngestLocations.samples 1..=max_samples_per_publish_http 413 PAYLOAD_TOO_LARGE si excede

5.1 Validación de recursos de la pista E

Recurso Reglas Error
CreateSession.mode = POOL solo aplicación; un dispositivo recibe not_allowed 422 / 403
CreateSession.history_mode enum; OFF por defecto; solo aplicación o manage 422
UpdateSession.history_mode ≠ OFF exige tenant.history_allowed 403 FEATURE_DISABLED
SetSharing.purposes ⊆ {HISTORY, ANALYTICS}; sin duplicados; solo el propio subject (INV-SHR-5) 422 / 403 PERMISSION_DENIED
SetSharing.purposes = {HISTORY} exige session.history_mode ≠ OFF 422 CONSENT_REQUIRED
AddMember.capabilities en sesión POOL ⊆ {publish} (INV-POOL-1) 422
AddMember en sesión POOL por dispositivo not_allowed 403 PERMISSION_DENIED
SetAvailability solo me; solo en sesión POOL; enum AVAILABLE/BUSY/PAUSED 403 / 422
UpdateMember.availability / .attributes solo aplicación con pools:manage; ≤ pool_max_attributes entradas; clave ^[a-z0-9_]{1,32}$; valor ≤ 64 caracteres 403 / 422
Nearby.center, radius_m lat/lon válidos; radius_m 1..=pool_query_max_radius_m 422
Nearby.limit 1..=100 422
Nearby.filter.attributes ≤ 3 entradas de igualdad 422
Nearby.rank distance o eta; eta exige pool_eta_prefilter ≥ 1 422
Nearby.max_age_s ≥ 0 422
Rings.center, ring_step_m o k exactamente uno de los dos; max_rings ≥ 1; per_ring_limit 1..=100 422
Eta.origins, .destinations ≥ 1 cada uno; len(origins) × len(destinations) ≤ eta_max_matrix_cells 422
Eta.mode enum de modo de transporte 422
Eta.source_preference PROVIDER, LEARNED, ESTIMATED o ausente 422
Assignment.workers, .tasks ≥ 1 cada uno; size ≤ assignment_max_size 422 / 413
Assignment.capacity ≥ 1 422
Assignment.algorithm hungarian (capacity = 1) o min_cost_flow 422
Assignment.max_cost ≥ 0 422
Provider.<port> port ∈ {ROUTE_MATRIX, ROUTE, MAP_MATCHING, GEOCODING}; URL https://; clave no vacía y ≤ 4096 422
IngestHistory.samples 1..=max_samples_per_publish_http; captured_at con antigüedad ≤ history_backfill_max_age_s 422
IngestHistory (sesión) sesión ACTIVE o cerrada hace < history_backfill_grace_s; HISTORY ∈ consent_purposes vigente 403 CONSENT_REQUIRED / 409
Trace.from, .to from < to; rango ≤ ventana máxima del tenant 422
Trace.simplify_m 0..=1000 422
Trace.format json, polyline o gsp1 422
Heatmap.metric presence, stops o speed 422
Heatmap.resolution 5..=12 y ≥ la resolución mínima del tenant 422
SpeedProfile.cell / .bbox exactamente uno de los dos; cell debe ser una celda H3 de la resolución indicada 422
Export.format ndjson o csv 422
Export.from, .to from < to 422
Tag.model, .name ≤ 100 caracteres; sin coordenadas ni identificadores de persona 422
EdgeReport.hashed_eid 16 bytes en base64url o hex; time_bucket ≤ 1 h de antigüedad y ≥ edge_rotation_s en el pasado 422
EdgeReport.ciphertext 1..=4096 bytes; base64url 422
EdgeReport.finder_ephemeral_pub punto P-256 comprimido (33 bytes SEC1) 422
EdgeReportsLookup.hashed_eids 1..=edge_lookup_max_ids entradas, sin duplicados 422
LpWebhook.fport 0..=255; debe existir un decodificador para el modelo registrado 422
SamplingRule.interval_ms acotado por el cliente a los rangos de 17 §4.1 (T-30) aplicado, no error

Error de consentimiento. Si la operación requiere un propósito que el subject no ha otorgado, la respuesta es 403 CONSENT_REQUIRED (nuevo código, §6). Nunca se degrada a PERMISSION_DENIED ni se escribe parcialmente.

6. Catálogo de errores

Fuente del nombre: ErrorCode en common.proto. En REST, code = nombre sin ERROR_CODE_.

code HTTP Stream Reintentable Significado Acción del cliente
UNAUTHENTICATED 401 StreamError, cierre 4401 Tras obtener credencial nueva Credencial ausente, inválida o revocada Pedir token nuevo; si falla, re-login del integrador
TOKEN_EXPIRED 401 StreamError (AuthRefresh) Sí, con token nuevo Token expirado forceRefresh
PERMISSION_DENIED 403 StreamError, request_error No Principal o capacidad insuficiente Mostrar error; no reintentar
INSUFFICIENT_SCOPE 403 StreamError No Falta scope en la credencial Corregir configuración del integrador
ACCOUNT_DISABLED 403 cierre 4403 No Tenant, aplicación (o subject) deshabilitado Detener
NOT_FOUND 404 StreamError, request_error No Inexistente o sin relación Tratar como inexistente
CONFLICT 409 — No (salvo idempotencia en curso: sí, tras breve espera) Estado incompatible (duplicado, límite de sesiones activas, petición idempotente en curso) Releer estado
VALIDATION_FAILED 400/405/415/422 StreamError, request_error No Entrada inválida Corregir petición
INVALID_LOCATION 422 (por muestra en ingesta) SampleResult No Muestra inválida Descartar muestra; registrar defecto
RATE_LIMITED 429 + Retry-After request_error + retry_after_ms; cierre 4429 Sí, tras la espera Límite excedido Esperar
PAYLOAD_TOO_LARGE 413 request_error; cierre 1009 No Cuerpo/lote/trama demasiado grande Reducir
SESSION_CLOSED 409 StreamError, request_error No Sesión cerrada Retirar sesión de la UI
SESSION_EXPIRED 409 StreamError, request_error No Sesión expirada Retirar sesión
SESSION_FULL 409 — No Límite de miembros Informar
MEMBER_REVOKED 403 request_error No El subject fue revocado de la sesión Retirar sesión
NOT_SHARING 409 request_error No Publicación sin compartición activa Detener publicación local
NOT_PUBLISHER 409 request_error No Otro dispositivo del subject es el publicador Detener publicación local; informar
INVITATION_INVALID 404 — No Invitación no canjeable Informar genéricamente
IDEMPOTENCY_KEY_REUSED 422 — No Misma key con cuerpo distinto Generar key nueva
PRECONDITION_FAILED 412 — Tras releer If-Match no coincide Releer y reintentar
PROTOCOL_VERSION_UNSUPPORTED 400 (upgrade) cierre 4001 No Versión de protocolo no soportada Actualizar SDK
PROTOCOL_ERROR — StreamError; cierre 4000 Reconectar Mensaje malformado Registrar defecto
TOO_MANY_SUBSCRIPTIONS — StreamError No Excede max_subscriptions Reducir suscripciones
TEMPORARILY_UNAVAILABLE 503 + Retry-After StreamError, request_error, cierre 4503 Sí Dependencia caída, drenaje, sobrecarga Reintentar con backoff
INTERNAL 500 StreamError, cierre 4500 Sí (con backoff, máx. 3) Error inesperado Reintentar; reportar request_id
FEATURE_DISABLED 403 StreamError No La política del tenant no habilita la capacidad solicitada (pools, historial, analítica, edge, tools) Detener; revisar la política del tenant
CONSENT_REQUIRED 403 StreamError No Falta el propósito de consentimiento del subject (HISTORY / ANALYTICS) Solicitar el consentimiento al usuario; reintentar
PROVIDER_UNAVAILABLE 503 + Retry-After — Sí Se exigía source_preference = provider y el proveedor no respondió Reintentar o quitar la exigencia de proveedor

Reglas:

7. Formato de error