Ebrebook: una garantia només existeix on la base de dades la pot veure

Ebrebook és una plataforma de reserves que dona servei a diversos negocis sobre una sola instal·lació, cadascun amb el seu subdomini. Ordena les reserves i dona control sobre les franges disponibles, amb el focus en negocis locals i turístics: perruqueries, restaurants, lloguer de material, activitats.

Producte propi, en desenvolupament — encara sense clients en producció

Perds temps o clients amb reserves, trucades o recordatoris? El diagnòstic és gratuït.

Demanar diagnòstic
Tests a la suite
178 → 195
Camps sense acotar
22en 12 formularis
Tipus de negoci
4sobre una taula

Què és i en quin punt es troba

És un producte propi i està en desenvolupament: encara no té clients en producció. El que segueix no és un cas d'èxit, és el que vam aprendre construint-lo i, sobretot, auditant-lo amb la instrucció de trobar errors en lloc de confirmar encerts.

Què té de difícil gestionar franges disponibles?

Un calendari et diu si hi ha alguna cosa a aquella hora. Un sistema de reserves t'ha de dir si pots reservar, que és una altra pregunta.

La diferència és en tres coses. Els recursos són exclusius —una taula, un empleat, un caiac no es poden vendre dues vegades—, hi ha concurrència real perquè dues persones poden demanar la mateixa franja alhora, i els quatre tipus de negoci del producte tenen regles diferents: una cita ocupa un professional, una taula ocupa un objecte físic, i una activitat és un comptador de places que no és exclusiu de ningú.

On apareix de debò una doble reserva?

La intuïció diu que és un problema de velocitat: si el sistema respon ràpid, no dona temps que dues persones agafin la mateixa franja. És fals.

El problema és que entre comprovar i escriure passa temps. L'Anna pregunta si dijous a les deu està lliure i li diuen que sí. En Bruno pregunta el mateix i també li diuen que sí, perquè en aquell instant continua sent veritat. Tots dos escriuen. Ningú no ha fet res malament: la veritat va caducar entre la pregunta i la resposta. Per ràpid que vagis, aquesta finestra mai no és zero.

Les defenses que sí que funcionen són dues, i són independents. En els camins públics, l'aplicació demana a la base de dades el control exclusiu del recurs abans de confirmar, així que la segona petició espera el seu torn i, quan hi arriba, ja veu la franja ocupada. Per sota, tres restriccions d'exclusió de PostgreSQL fan impossible emmagatzemar dues reserves que es trepitgin sobre el mateix professional, la mateixa taula o el mateix recurs.

Verificat amb peticions concurrents reals, no simulades: la mateixa franja exacta, solapament parcial i el cas frontera —una reserva que comença just quan acaba l'altra, que s'ha de permetre—. Els tres correctes, i el que perd rep un missatge clar, no un error de servidor.

I tot i així hi havia una doble reserva

Amb aquestes dues defenses muntades, un restaurant podia vendre la mateixa taula dues vegades.

Quan un grup no cap a cap taula, el motor n'ajunta diverses. Desava la primera com a dada ferma i les altres les escrivia com a text al camp de notes: "taules addicionals: T3".

La restricció no ho podia veure. T3 no apareixia en cap columna que la base de dades pogués mirar. I el bloqueig tampoc no servia, perquè no hi havia cap cursa per guanyar: la comprovació preguntava per la dada ferma i responia, sincerament, que T3 era lliure.

El resultat, reproduïble sense concurrència i sempre: sis persones assignades a una taula de dues, a la mateixa hora. I com més èxit tingués el restaurant amb grups grans, més sovint passaria.

L'arranjament va ser modelar cada taula ocupada com una fila pròpia i moure-hi la restricció. L'antiga es va eliminar en lloc de deixar-la conviure: dues restriccions per al mateix invariant són dues fonts de veritat, i la vella n'era una versió estrictament més feble.

Un invariant que vols que garanteixi la base de dades ha de viure en una columna que la base de dades vegi. En text lliure és invisible per a qualsevol restricció, per ben dissenyada que estigui.

Com s'impedeix que un negoci toqui les dades d'un altre?

L'aïllament de lectura estava ben resolt des del principi, i per un motiu estructural: la identitat del negoci es dedueix del compte de qui crida, mai de l'adreça des de la qual arriba la petició. Un negoci no pot llegir, modificar ni cancel·lar la reserva d'un altre perquè aquest objecte senzillament no és al seu conjunt de dades. No hi ha cap comprovació per eludir.

Un detall que sembla menor i no ho és: l'error d'un identificador que existeix en un altre negoci és idèntic byte a byte al d'un identificador que no existeix en cap. Un 403 confirmaria que el recurs existeix i permetria enumerar. Un 404 indistingible, no.

Però l'aïllament d'escriptura era una altra història, i aquesta la va trobar l'auditoria, no nosaltres.

Els formularis de l'API declaraven les referències a empleats, serveis, taules i recursos com a camps normals. Per defecte, això vol dir "qualsevol de la base de dades", sense acotar per negoci. I els identificadors d'empleat són públics: l'agenda d'un negoci els exposa a qualsevol que en conegui el subdomini.

La cadena completa, verificada de principi a fi: llegir el personal públic d'un competidor sense autenticar-se, crear una reserva legítima al negoci propi, i reassignar-la a un empleat del competidor. L'agenda de la víctima perdia quatre franges reals, i res no l'avisava. El mateix amb bloquejos de rang arbitrari: l'agenda aliena sencera, d'una tirada.

Per què l'arranjament no va ser l'obvi?

Dues decisions, i la segona és la interessant.

La primera: en cercar els camps afectats per introspecció en lloc de fer-ho a mà, en van aparèixer vint-i-dos en dotze formularis, no els tres identificats. Es van resoldre amb un mecanisme que s'enganxa a la construcció de camps i els acota automàticament, no amb una llista que algú s'hagi de recordar de mantenir. Arreglar la classe d'error, no l'error.

La segona: la xarxa de seguretat òbvia era afegir el negoci a les tres restriccions d'exclusió. Hauria estat un error. Aquestes restriccions comparen avui dues reserves sobre el mateix empleat sense mirar de qui són, i per això rebutgen la reserva de l'atacant. Posant el negoci dins de l'expressió, dues reserves de negocis diferents sobre el mateix empleat deixarien de col·lidir: la garantia s'afebliria en lloc de reforçar-se.

La xarxa correcta era una altra: una clau forana composta que fa que la base de dades rebutgi la incoherència encara que s'intenti escriure per SQL directe, saltant-se l'aplicació sencera.

Afegir una defensa no és el mateix que afegir la defensa correcta. Una restricció mal triada et pot deixar més exposat que no posar-la, i amb la sensació contrària.

Per què 178 tests en verd no ho van detectar?

Aquesta és la part incòmoda i la que més ens emportem.

Hi havia 178 tests, tots passant. Cobrien bé els camins públics amb motor. Cinc de les sis troballes de l'auditoria vivien just on no arribava la cobertura: el CRUD genèric del panell, la modificació de reserves, les taules fusionades, els estats intermedis i el canvi d'hora.

I hi ha un parany concret que gairebé produeix un fals aprovat a la mateixa auditoria. Per defecte, els tests de Django s'executen dins d'una transacció que es desfà en acabar, i dos fils dins d'aquesta transacció no veuen les escriptures de l'altre. Un test de concurrència escrit així o es penja, o "demostra" que no hi ha dobles reserves quan sí que n'hi ha. Un verd fals és pitjor que no provar res, perquè publiques una afirmació recolzada en una prova que no provava res.

Una suite en verd mesura el que mesura. La pregunta útil no és quants tests hi ha, sinó quins camins no toca cap.

Què hi fa aquí la IA, i què no fa encara?

La predicció d'absències i de demanda per franja horària és al full de ruta, i avui la resposta honesta és que no és viable: no hi ha històric. Zero reserves amb desenllaç conegut, zero clients recurrents, zero registre d'ocupació.

El que sí que està construït és la porta que ho diu. El sistema es nega a entrenar per sota d'un llindar —cinc-cents desenllaços amb cinquanta casos positius per a absències, vuit setmanes i cinquanta-sis dies diferents per a ocupació— i retorna una resposta alternativa al seu lloc.

Aquesta alternativa és una heurística de regles amb pesos fixats per criteri, no apresos: antelació de la reserva, si el client és nou o recurrent, si hi ha telèfon per avisar, si s'ha enviat recordatori. Funciona des del primer dia sense cap dada històrica, és explicable davant del negoci i, el que és important, va generant les etiquetes que un model futur necessitarà.

És l'escala que apliquem sempre: heurística primer, i només es puja un esglaó quan l'anterior demostra que es queda curt. Un model entrenat no serveix de res el dia que no hi ha dades, que és exactament el dia en què arrenca un producte.

Què falta i què faríem diferent?

El que continua obert, sense adorns: en el flux de taller, un vehicle que entra a reparar allibera la seva franja i un altre client la pot reservar. El panell intern no comprova solapaments i es recolza només en la base de dades, així que retorna un error de servidor on hauria de dir "aquesta franja ja està ocupada". Reprogramar una reserva per API està trencat en totes dues direccions. I el motor tracta tots els dies com si tinguessin vint-i-quatre hores, cosa que produeix franges fantasma els dos caps de setmana del canvi d'hora.

No existeixen els temps de neteja o preparació entre reserves, ni la retenció d'una franja mentre el client omple el formulari.

El que faríem diferent: plantejar el producte des d'una necessitat concreta i amb demanda demostrada, en lloc de construir un sistema que serveixi quatre tipus de negoci alhora. Els quatre paradigmes comparteixen una sola taula, i aquesta decisió és la que fa que un estat pensat per a tallers tingui conseqüències en la disponibilitat d'una perruqueria.

Tecnologies

  • Django
  • Django REST Framework
  • PostgreSQL 16
  • Restriccions GiST sobre rangs
  • Celery
  • Redis
  • Docker Compose
  • Caddy