# Verificar una cadena de auditoría de StandardOS

Cada exportación de StandardOS contiene un array `audit_trail`. Este documento
especifica exactamente cómo se calcula el hash de cada entrada, para que usted
pueda recalcular la cadena por su cuenta, en cualquier lenguaje, sin ejecutar
StandardOS y sin confiar en nosotros.

Una implementación completa, en unas cien líneas de JavaScript sin
dependencias, está publicada junto a este documento en
https://getstandardos.com/verify-chain.mjs. Está escrita para leerse, pero si prefiere implementarla usted mismo, este documento basta.

## Lo que la cadena garantiza

Cada entrada sella la anterior. Cambie cualquier campo de cualquier entrada
histórica y su `row_hash` deja de coincidir, y como el `prev_hash` de la
entrada siguiente es ese hash, todas las entradas posteriores se rompen
también. Borrar una entrada rompe el enlace a través del hueco. Añadir una entrada fabricada solo exige conocer la cabeza actual, lo cual es aceptable. La cadena demuestra que la historia no ha sido *reescrita*,
no que un evento concreto haya ocurrido.

## Lo que no garantiza por sí sola

La regla de hash es pública y sin clave, y es deliberado: una regla con clave
solo podría verificarla quien tuviera la clave, y ese seríamos nosotros. La
consecuencia es que cualquiera que pueda escribir en la base de datos puede
también recalcular todos los hashes posteriores, y la cadena por sí sola no lo
mostrará.

Para eso están las **anclas**. Véase «Anclas» más abajo. Verifique la cadena
contra un recibo de ancla que ya tenga y el ataque por recálculo queda cerrado,
porque no podemos alterar un recibo que ya está en su buzón.

## La preimagen

Para cada entrada, el hash es:

```
row_hash = lowercase_hex( sha256( utf8( preimage ) ) )
```

La preimagen es la concatenación de las partes siguientes, **sin separadores
salvo donde se indica**:

| # | Parte | Notas |
|---|------|-------|
| 1 | `prev_hash` | 64 caracteres hexadecimales en minúsculas |
| 2 | `old_data` | la cadena tal como se exporta, o `""` si es null |
| 3 | `new_data` | la cadena tal como se exporta, o `""` si es null |
| 4 | `action` | `INSERT`, `UPDATE` o `DELETE` |
| 5 | `table_name` | por ejemplo `risks` |
| 6 | *(solo hash_version ≥ 2)* `actor` + `"\|"` + `record_id` | `actor` es `""` si es null |
| 7 | *(solo hash_version ≥ 3)* `"\|"` + `principal` | `""` si es null |
| 8 | `occurred_at` | con el formato exacto de abajo |

La preimagen de cada versión es una extensión estricta por prefijo de la
anterior, así que una entrada v1 se hashea exactamente igual que antes de que
existiera v2. **Aplique la regla que nombra la `hash_version` de cada fila.**
La historia nunca se vuelve a hashear cuando cambia la regla; por eso existe la
columna.

### Formato de `occurred_at`

Esta es la parte en la que la gente se equivoca. La marca de tiempo se
representa **en UTC** como:

```
YYYY-MM-DDTHH:MM:SS.ffffffZ
```

Exactamente seis dígitos de fracción de segundo, siempre presentes, siempre rellenados con ceros, por ejemplo `2026-07-30T17:41:39.558458Z`. Postgres lo produce con
`to_char(occurred_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`.

Tenga en cuenta que `Date.toISOString()` de JavaScript emite **tres** dígitos
de fracción, no seis, y por tanto producirá el hash equivocado. El
`occurred_at` exportado es una cadena ISO con precisión de microsegundos; use
sus dígitos directamente en lugar de pasarlos por un tipo de fecha que no puede
contenerlos.

### `old_data` / `new_data`

Se exportan como **cadenas**, no como objetos, deliberadamente. Los bytes que
se hashean son la representación `jsonb` canónica de Postgres: claves de
objeto ordenadas por longitud y después byte a byte, un espacio tras cada `:` y
`,`. `JSON.stringify` no reproduce eso. Hashee la cadena exactamente como se
exporta. Analícela solo si quiere leerla.

Un `INSERT` no tiene `old_data`; un `DELETE` no tiene `new_data`. Ambos son
`null` en la exportación y aportan la cadena vacía a la preimagen.

## Comprobar la cadena

1. Ordene las entradas por `id` ascendente, dentro de un mismo `org_id`.
2. El `prev_hash` de la primera entrada debe ser igual a la **semilla de
   génesis**: `sha256(utf8(org_id))` en hexadecimal minúsculo, donde `org_id`
   es el UUID en su forma canónica con guiones y en minúsculas.
3. Para cada entrada: recalcule `row_hash` a partir de la preimagen y compare;
   después confirme que el `prev_hash` de la entrada siguiente es igual al
   `row_hash` de esta.

Si falla el paso 2, se han eliminado entradas al principio. Si falla el paso 3
en la entrada *n*, la cadena está intacta en las primeras *n−1* entradas y algo
en *n* no coincide con lo que se registró.

## Anclas

Cada día StandardOS registra la cabeza de la cadena de cada organización y le
envía por correo un recibo con el hash de cabeza, el número de entradas y la
fecha. Esos recibos son la parte independiente: están en su buzón, no en
nuestra base de datos.

Para usar uno, tome el hash de cabeza de un recibo anterior al periodo que le
interesa, busque la entrada con ese `row_hash` en su exportación y verifique la
cadena hasta ese punto. Un recálculo hecho después de enviar el recibo no puede
volver a producir ese hash de cabeza.

Conserve los recibos. Son la razón por la que «ni siquiera nosotros podemos
alterarlo sin que se note» es una afirmación matemática y no una declaración de
buenas intenciones.

### Lo que un ancla no cubre

Un ancla tomada a las 03:40 no dice nada de los eventos escritos a las 09:00.
Entre dos pasadas de anclaje hay una ventana en la que un evento puede
escribirse y retirarse sin que nada lo contradiga: la cadena de hash no lo
nota, porque borrar las entradas más recientes no rompe ningún enlace, y ningún
ancla las cubrió nunca.

Dos cosas estrechan esa ventana sin cerrarla. Cada pasada nocturna comprueba
que el ancla *anterior* sigue siendo válida, es decir, que el evento que
registró sigue presente con el mismo hash y que la cadena no ha menguado por
debajo del recuento anclado, y registra una alarma si no es así. Así que todo lo
que sobrevive a un ancla queda protegido desde entonces. Y el barrido profundo
vuelve a derivar cada cadena desde el génesis a diario, lo que detecta contenido
alterado sin recálculo.

Lo que queda sin cubrir es un intervalo de anclaje, para los eventos creados y
destruidos dentro de él. Preferimos dejarlo escrito antes que permitir que un
lector suponga lo contrario.

### Lo que cubre la regla de hash, por versión

La regla se ha ampliado dos veces, y cada fila registra qué versión la
escribió.

| Versión | Sella |
|---|---|
| 1 | contenido, acción, tabla, marca de tiempo y el enlace a la entrada anterior |
| 2 | lo anterior, más `actor` y `record_id` |
| 3 | lo anterior, más `principal` |

La historia nunca se vuelve a hashear cuando cambia la regla. Reescribir un
registro de solo adición para que se verifique bajo una regla nueva es
exactamente la operación que el registro existe para hacer detectable. La
consecuencia es que en una entrada v1 o v2 las columnas de atribución están
*registradas pero no selladas*: pueden alterarse sin romper la verificación.
La exportación lo marca por fila (`principal_attested`, `actor_attested`), y
`verify-chain.mjs` imprime cuántas entradas están afectadas. Trate una
atribución no atestiguada como una afirmación, no como prueba de quién actuó.
El contenido, el orden y el momento de cada entrada se verifican sea cual sea
la versión.

## Vectores de prueba

Si ha implementado la regla anterior, compruébela contra estos vectores antes
de confiar en su implementación con una exportación real. Cada entrada es una
fila completa de la pista de auditoría con el `row_hash` que debe producir.
Cubren las tres versiones de hash, `old_data`/`new_data` tanto nulos como
rellenos, y una fracción de segundo que no tiene ya seis dígitos.

No son decoración. Nuestra CI recalcula cada uno de ellos en cada push con el
verificador publicado, de modo que un cambio en la regla de hash que olvidara
actualizar este documento hace fallar nuestra compilación y no su auditoría.

```json
[
  {
    "hash_version": 1,
    "prev_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    "old_data": null,
    "new_data": "{\"id\":1}",
    "action": "INSERT",
    "table_name": "risks",
    "actor": null,
    "record_id": null,
    "principal": null,
    "occurred_at": "2026-07-30T17:41:39.558458Z",
    "row_hash": "62586c3fab919870dc2a5eec406fde33ddc31f73b92d5d851acbf3e822b15704"
  },
  {
    "hash_version": 2,
    "prev_hash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
    "old_data": "{\"id\":1}",
    "new_data": "{\"id\":2}",
    "action": "UPDATE",
    "table_name": "soa_entries",
    "actor": "11111111-1111-1111-1111-111111111111",
    "record_id": "22222222-2222-2222-2222-222222222222",
    "principal": null,
    "occurred_at": "2026-07-30T17:41:39.5Z",
    "row_hash": "6aad0bb2c1edf6053b297d5a38b88bcc9146fcfb6caa4e0f47dd009bcda60674"
  },
  {
    "hash_version": 3,
    "prev_hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
    "old_data": "{\"id\":3}",
    "new_data": null,
    "action": "DELETE",
    "table_name": "documents",
    "actor": null,
    "record_id": "33333333-3333-3333-3333-333333333333",
    "principal": "job:soa-justification-source",
    "occurred_at": "2026-08-03T09:00:00Z",
    "row_hash": "d895d6280468e1a0600034f15dc3a884a123333a5b73a2df7cf8e470c6cf2fe4"
  }
]
```

## Informar de una discrepancia

Si una verificación falla y usted cree que los datos son auténticos,
escríbanos a security@getstandardos.com con el `id` de la entrada y lo que ha
calculado. Una cadena que no verifica es o bien una manipulación o bien un
error en nuestro hash, y queremos saber cuál con la misma urgencia que usted.
