| Metadato | Valor |
|---|---|
| Versión / Version | 2 |
| Estado / Status | Normativo — vigente para todo root computado desde Phase 05.1.2 |
| URI del perfil / Profile URI | https://docs.docuten.com/earchiving/merkle-profile/v2 |
| Dónde se declara / Where it is declared | merkleProfile (obligatorio) en aip-hash-attestation-v2.xsd; merkleProfile (aditivo, opcional) en integrity-report-v1.xsd |
| Implementación / Implementation | earchiving-core/src/main/java/com/docuten/earchiving/core/crypto/MerkleTree.java — build(List<Map.Entry<String,String>>) |
| Función de hash / Hash function | SHA-256 únicamente (política criptográfica del proyecto: SHA-384 prohibido para fixity, MD5/SHA-1 prohibidos con valor probatorio) |
| Decisiones / Decisions | D-24, D-25, D-26, D-29, D-49 (05.2-CONTEXT.md) · GATE-02 (Phase 05.1.2) |
| Sustituye a / Supersedes | Perfil v1 (sin prefijos de dominio, nodo impar duplicado) — ver §6 |
| Fecha / Date | 2026-08-27 |
Este documento es la definición normativa del algoritmo con el que Docuten eArchiving computa
los Merkle roots que después sella un QTSP y sobre los que se solicita un sello de tiempo
cualificado. Es el documento que la URI de la tabla anterior identifica y al que apunta el elemento
merkleProfile de la evidencia sellada.
Existe porque, sin él, un peritaje que solo disponga del ZIP del AIP y de su sello no podría
recomputar el root: el algoritmo, el orden de las hojas, la concatenación y el tratamiento del nodo
impar vivían únicamente en el Javadoc de MerkleTree, que no viaja con el paquete (D-26).
La URI es un identificador, no una URL que deba resolverse. No se dereferencia en tiempo de sellado ni de verificación.
This document is the normative definition of the algorithm Docuten eArchiving uses to compute
the Merkle roots that a QTSP later signs and that are submitted for a qualified timestamp. It is the
document identified by the URI in the table above and referenced by the merkleProfile element
inside the sealed evidence. It exists because otherwise an expert witness holding only the AIP ZIP
and its seal could not recompute the root: the algorithm, leaf ordering, concatenation and odd-node
handling used to live only in the MerkleTree Javadoc, which does not travel with the package
(D-26). The URI is an identifier, not a URL to be dereferenced.
La entrada es una lista ordenada de pares (entryId, entryHashHex):
entryId — identificador de la entrada. Para el sello #2 de un AIP es la ruta completa del
fichero dentro del ZIP (nunca el nombre base: dos representaciones pueden contener ficheros
homónimos con contenido distinto).entryHashHex — SHA-256 de la entrada en hexadecimal minúscula, exactamente 64 caracteres.El orden de la lista es parte de la definición del root: build() la consume posicionalmente. El
orden canónico está fijado en §3.
Entradas rechazadas (fail-closed). build() lanza IllegalArgumentException antes de
construir cualquier nivel si:
null o vacía;entryHashHex es null o no tiene exactamente 64 caracteres;entryId.La validación es previa por una razón sustantiva, no de estilo: una entrada mal formada o un
entryId repetido contamina un root que después se sella, y una evidencia ya emitida no se puede
desellar.
The input is an ordered list of (entryId, entryHashHex) pairs, where entryId is the full
path of the file inside the AIP ZIP (never the base name) and entryHashHex is the entry's SHA-256
in lowercase hex, exactly 64 characters. List order is part of the root's definition — see §3.
build() throws before building any level if the list is null/empty, if any entryHashHex is null
or not exactly 64 characters, or if two entries share the same entryId: a malformed entry
contaminates a root that will be sealed, and issued evidence cannot be unsealed.
Para cada entrada, en el orden de la lista:
entryHashBytes = hexDecode(entryHashHex) // 32 bytes
leafHash = SHA-256( 0x00 || entryHashBytes ) // 32 bytes
0x00 es un único byte anteponido a los 32 bytes del hash de la entrada. El nivel 0 del árbol
es la secuencia de leafHash, en el mismo orden que la entrada.
Nótese que el hash de hoja es un hash del hash de la entrada, no del contenido del fichero: el
perfil opera sobre entryHashHex, que el llamador ya computó.
parentHash = SHA-256( 0x01 || leftChild || rightChild ) // 1 + 32 + 32 bytes
leftChild y rightChild son los 32 bytes crudos de los hijos (no su representación hex).
Partiendo del nivel 0, se repite mientras el nivel tenga más de un nodo:
mientras longitud(nivel) > 1:
impar = longitud(nivel) es impar
emparejados = impar ? longitud(nivel) - 1 : longitud(nivel)
siguiente = []
para i = 0, 2, 4, …, emparejados - 2:
siguiente.añadir( SHA-256( 0x01 || nivel[i] || nivel[i+1] ) )
si impar:
siguiente.añadir( nivel[longitud(nivel) - 1] ) # PROMOCIÓN, sin re-hashear
nivel = siguiente
rootHash = hexEncode(nivel[0]) # 64 caracteres hex minúscula
Promoción del nodo impar. Cuando un nivel tiene un número impar de nodos, el último nodo pasa tal cual al nivel siguiente: no se re-hashea, no se empareja consigo mismo y no se duplica.
Esta regla es el corazón del perfil. El perfil v1 rellenaba el nivel impar duplicando el último
nodo, que es el patrón CVE-2012-2459: con ella, dos conjuntos de hojas de cardinalidad distinta
producen el MISMO root. La forma exacta del ataque: un conjunto de 6 hojas
[e1, e2, e3, e4, e5, e6] y el conjunto de 8 que repite sus dos últimas
[e1, e2, e3, e4, e5, e6, e5, e6] devuelven, bajo v1, el mismo root — de modo que el root sellado
no distinguía un AIP de 6 ficheros de uno de 8 con la cola repetida. Bajo v2 los dos roots son
distintos por construcción, porque el nodo impar nunca se empareja consigo mismo.
La propiedad no depende de esta prosa: la fija de forma permanente la aserción unitaria
MerkleTreeTest.oddNodeDuplication_v1Shape_collidesAcrossDifferentLeafSets (D-29), que mide la
colisión del perfil viejo y su ausencia en el vigente sin pasar por ningún servicio.
Starting from level 0, while a level has more than one node: pair adjacent nodes into
SHA-256(0x01 || left || right); if the level has an odd number of nodes, the last node is
promoted verbatim to the next level — not re-hashed, not paired with itself, not duplicated.
This rule is the core of the profile: v1 padded odd levels by duplicating the last node, which
is the CVE-2012-2459 pattern, making two leaf sets of different cardinality collide on the same
root. The property is pinned by the permanent unit assertion
MerkleTreeTest.oddNodeDuplication_v1Shape_collidesAcrossDifferentLeafSets (D-29).
Con una sola entrada el bucle no se ejecuta y rootHash = hexEncode(leafHash), es decir
SHA-256(0x00 || entryHashBytes). El root de un árbol de una hoja sí lleva el prefijo de hoja.
Cada hoja tiene un camino de hermanos de hoja a raíz. Cada entrada del camino lleva el prefijo
"L:" o "R:" según el hermano esté a la izquierda o a la derecha del nodo actual, seguido del
hash del hermano en hex.
Las hojas que cuelgan de un nodo promovido no reciben ninguna entrada en el nivel donde se
produjo la promoción: su camino tiene simplemente una entrada menos. La verificación aplica la misma
función de nodo interior (SHA-256(0x01 || …)) y por tanto no puede divergir del escritor en el
prefijo — ambos pasan por el mismo helper.
Los caminos de prueba no se persisten del lado AIP: se recomputan. Del lado del audit log el
árbol completo sí queda en audit_log_seals.merkle_tree.
Matiz que esta sección no puede omitir (§6.1). Que el árbol completo quede persistido en
merkle_tree no significa que quede sellado: el MessageImprint de la TSA del audit log cubre
solo los 32 bytes de rootHash, así que hojas, hermanos y el propio merkleProfile viajan CON la
evidencia pero FUERA del token. El orden canónico de esas hojas es el ascendente por seq, no
LEAF_NAME_ORDER (§3.1).
El orden de las hojas es parte de la definición del root, así que quien sella y quien verifica deben
ordenar igual. El orden lo define un único punto de verdad en el código,
MerkleTree.LEAF_NAME_ORDER, cuya expresión es textualmente:
Comparator.comparing((String name) -> name.toLowerCase(Locale.ROOT))
.thenComparing(Comparator.naturalOrder())
Es decir: comparación primaria por el nombre plegado a minúsculas con Locale.ROOT, y desempate
por orden natural (lexicográfico por punto de código UTF-16) del nombre sin plegar.
Los dos detalles son normativos y ninguno es decorativo:
Locale.ROOT, no toLowerCase() a secas. El plegado sin locale depende de
Locale.getDefault(): en turco/azerí "I".toLowerCase() produce "ı" (U+0131), que ordena en
otra posición que "i". Sin fijar el locale, el root sellado sería función del entorno de
ejecución (imagen base, LANG del pod) y no solo del contenido del AIP: sellar bajo un locale y
verificar bajo otro daría un FAIL falso, entregado además dentro de un informe cualificado
firmado.a.txt / A.txt) comparan iguales, y como
List.sort es estable el orden lo acabaría decidiendo el orden de enumeración del ZIP —
ZipFile.entries() (directorio central) en el escritor frente a ZipInputStream (orden de
stream) en el verificador, dos órdenes que nadie garantiza que coincidan. Con el desempate, el
comparador solo devuelve 0 para nombres idénticos.Leaf order is part of the root's definition, so writer and verifier must sort identically. The
single point of truth is MerkleTree.LEAF_NAME_ORDER, quoted verbatim above: primary comparison on
the Locale.ROOT-folded name, tie-broken by the natural order of the unfolded name. Locale.ROOT
is mandatory (locale-dependent folding would make the sealed root a function of the runtime
environment), and the tie-breaker is mandatory (without it the comparator is not total and ZIP
enumeration order would decide).
LEAF_NAME_ORDER: es el orden del sello #2 del AIP, NO el del audit logLEAF_NAME_ORDER es normativo únicamente para el escritor y el verificador del sello #2 del
AIP, cuyos entryId son rutas de fichero dentro del ZIP — exactamente el alfabeto para el que
el comparador está diseñado (ver el Javadoc de MerkleTree.LEAF_NAME_ORDER: plegado de mayúsculas,
desempate por orden natural, colisiones a.txt/A.txt).
Para audit_log_seals, el segundo consumidor del perfil (D-49), el orden canónico es el
ASCENDENTE por seq. Ahí el entryId de cada hoja es el UUID de una entrada de auditoría, y
sobre ese alfabeto LEAF_NAME_ORDER no ordena nada con significado: un UUID no tiene semántica
lexicográfica, así que ordenar por él sería tan arbitrario como ordenar por el hash. El orden por
seq, en cambio, sí es canónico y reproducible por un tercero sin acceso al código: es el que fija
el propio rango sellado (seq_from/seq_to de la fila) y el que ya impone la cadena hash del
audit_log.
AuditLogSealSchedulerImpl no aplica ni debe aplicar LEAF_NAME_ORDER a las hojas del audit
log: consume el resultado de findByTenantIdAndSeqBetweenOrderBySeq sin reordenar. Cambiar ese
orden alteraría en silencio el root de todos los sellos v2 ya emitidos, sin que ninguna migración lo
declare.
Esta acotación tiene aserción permanente, no solo prosa. La fija
AuditLogSealMerkleProfileTest#auditLogLeafOrder_isAscendingBySeq_notLexicographicByEntryId_pinnedForSpecSection3,
cuyo lote está construido para que los entryId estén en orden lexicográfico exactamente INVERSO al
de sus seq — de modo que los dos órdenes son distinguibles y el test mide algo. Vive fuera del
servicio de verificación, así que un cambio de orden en el scheduler la rompe de inmediato en vez de
divergir en silencio de esta sección (que es lo que ocurrió hasta el gap-closure 05.1.2-23: esta
sección declaraba LEAF_NAME_ORDER como canónico sin acotar y §2.6 metía el audit log en el
alcance, mientras el código ordenaba por seq — WR-04).
LEAF_NAME_ORDER is normative only for seal #2 of the AIP, whose entryIds are ZIP file
paths — the alphabet the comparator was designed for. For audit_log_seals (the second consumer,
D-49) the canonical order is ascending seq: there the entryId is an audit-entry UUID, over
which LEAF_NAME_ORDER orders nothing meaningful, whereas seq is canonical, reproducible from the
sealed range (seq_from/seq_to) itself and already imposed by the audit_log hash chain.
AuditLogSealSchedulerImpl does not and must not apply LEAF_NAME_ORDER to audit-log leaves;
changing that order would silently change the root of every v2 seal already issued. This scoping is
pinned by a permanent assertion outside the verification service, not by prose alone.
El sello #2 cubre el AIP completo tal como queda en custodia DESPUÉS del merge del paso 10 de la
saga de ingesta, sin excluir ningún fichero: contenido, METS.xml, premis.xml y los ficheros
de metadata/preservation/ generados por la propia atestación de origen (D-26 de
05.1-CONTEXT.md, reafirmado por D-26 de 05.2-CONTEXT.md).
La consecuencia práctica, que un verificador debe respetar: la lista de hojas es la lista de todos los ficheros del ZIP. Recomputar el root excluyendo, por ejemplo, los artefactos de atestación produce un valor distinto y un FAIL falso.
El sello #1 (origin-attestation.xml) es un artefacto distinto y no puede cubrir los ficheros de
atestación que él mismo genera; esa asimetría es de diseño, no una exclusión de este perfil.
Seal #2 covers the entire AIP as it stands after the ingest saga's step-10 merge, with no file
excluded — content, METS.xml, premis.xml and the metadata/preservation/ files produced by
origin attestation itself. A verifier that recomputes the root while excluding, say, the attestation
artefacts will get a different value and a false FAIL.
Este perfil NO es un renderizado literal de la función MTH() de RFC 6962 §2.1 — esa función
particiona recursivamente en la mayor potencia de dos y no tiene concepto de «nodo impar
promovido». Este perfil es un HÍBRIDO: conserva la estructura bottom-up existente (necesaria para
verifyInclusion y sus siblingPaths incrementales) y toma de RFC 6962 únicamente los BYTES de
domain separation (0x00/0x01). Un tercero que intente recomputar el root aplicando la fórmula
recursiva de RFC 6962 al pie de la letra obtendrá un valor DISTINTO.
Para que la afirmación sea comprobable, la fórmula de la RFC, literal:
MTH({}) = SHA-256()MTH({d(0)}) = SHA-256(0x00 || d(0))n > 1, con k = mayor potencia de dos menor que n:
MTH(D[n]) = SHA-256(0x01 || MTH(D[0:k]) || MTH(D[k:n]))La diferencia es estructural, no de notación: la RFC parte la lista completa en [0:k] y [k:n]
con k potencia de dos, de modo que nunca necesita rellenar un nivel; este perfil empareja nivel a
nivel y promueve el sobrante. Ambas construcciones coinciden cuando el número de hojas es una
potencia de dos, y divergen en cualquier otro caso.
Lo que sí se toma de la RFC —y lo único— son los prefijos de dominio, que dan resistencia a segunda preimagen entre niveles: con ellos, un hash de nodo interior no puede hacerse pasar por un hash de hoja ni al revés.
Ninguna afirmación de este documento, del Javadoc de MerkleTree ni de ningún otro artefacto de
gobernanza debe decir «conforme a RFC 6962» sin este matiz.
The bold paragraph above is normative: this profile is not a literal rendering of RFC 6962
§2.1's MTH(). The RFC splits the full list at the largest power of two and never pads; this profile
pairs level by level and promotes the leftover node. The two agree only when the leaf count is a
power of two. Only the domain-separation bytes 0x00/0x01 are taken from the RFC. No statement in
this document, in the MerkleTree Javadoc or in any other governance artefact may claim
"RFC 6962 conformance" without this qualification.
| Perfil v1 (legacy) | Perfil v2 (vigente) | |
|---|---|---|
| Hoja | SHA-256(entryHashBytes) — sin prefijo |
SHA-256(0x00 \|\| entryHashBytes) |
| Nodo interior | SHA-256(left \|\| right) — sin prefijo |
SHA-256(0x01 \|\| left \|\| right) |
| Nivel impar | último nodo duplicado (CVE-2012-2459) | último nodo promovido sin re-hashear |
| Guardas de entrada | ninguna | entryHashHex de 64 hex, entryId único |
| Declarado en la evidencia | no | sí, en los DOS consumidores — sidecar del AIP (merkleProfile del XSD v2) y audit_log_seals (MerkleTree.merkleProfile, gap-closure 05.1.2-23). Declarado ≠ sellado: ver §6.1 |
MerkleTree.build() es una única implementación estática, sin dispatch por versión. No existe
forma de computar un root v1 con el código vigente: el perfil v1 solo se reconstruye dentro de
MerkleTreeTest como referencia permanente del defecto. Por tanto todo root computado desde
Phase 05.1.2 —AIP y audit log, los dos únicos consumidores— es v2.
En el lado de la serialización, en cambio, sí hay coexistencia:
aip-hash-attestation-v1.xsd no se toca y sigue siendo legible.
AipHashAttestationXmlReader despacha entre v1 y v2 leyendo el namespace del elemento raíz antes
del unmarshal, con el mismo XMLInputFactory endurecido contra XXE. Un namespace que no sea
ninguno de los dos es un error (fail-closed), nunca un fallback silencioso a v1.merkleProfile == null: perfil no declarado,
que en la práctica significa el legacy ambiguo. El lector no lo rellena con la URI de v2, porque
eso convertiría «no lo sé» en una afirmación falsa dentro de un peritaje.Condición de cierre de la ventana (D-25 de 05.2-CONTEXT.md, matiz D-49). El cambio de perfil
fue gratuito porque el corpus sellado era local y recomputable. La ventana la cierra el primer
entorno inmutable o productivo, no la ausencia de atestación externa: pueden existir tokens TSA
reales emitidos por un QTSP sobre roots v1 procedentes de ejecuciones locales, y eso no cierra
la ventana. Cuando se confirme que ningún sidecar v1 vivo necesita seguir siendo legible, v1 podrá
eliminarse y el dispatch del lector simplificarse.
La fila «Declarado en la evidencia» dice que los dos consumidores declaran el perfil. No dice que en los dos quede cubierto por el sello, porque no es cierto, y la diferencia es exactamente la que un peritaje necesita saber:
| Consumidor | ¿Declara el perfil? | ¿Queda el perfil DENTRO de lo sellado? | Mecanismo |
|---|---|---|---|
AIP, sello #2 (sidecar aip-hash-attestation-v2.xsd) |
sí | sí | El perfil se puebla en buildAipHashAttestationType, es decir ANTES de canonicalize(), así que entra en lo que el QTSP firma y la TSA sella. No existe un segundo punto de mutación posterior |
audit_log_seals (sellado por lotes del audit log) |
sí | NO | El MessageImprint que AuditLogSealSchedulerImpl.sealTenant(UUID) envía a la TSA son EXCLUSIVAMENTE los 32 bytes de rootHash (parseHex(tree.rootHash()) → tsaPort.timestamp(rootBytes)). El JSONB merkle_tree, que es donde viaja merkleProfile, queda FUERA del token |
audit_log_seals desde la Fase 05.6 (Plan 05.6-04) |
sí | sí | AuditLogSealSchedulerImpl.sealTenant(UUID) envía a la TSA SHA-256(JCS(forma canónica del árbol)) —root, hojas ordenadas por entryId, caminos de hermanos y merkleProfile—, calculado por MerkleTreeCanonicalForm.sealImprint con JcsCanonicalizer. AuditQueryService.verifySeal recomputa ese imprint desde merkle_tree y lo compara con el del token. Ver la nota de cierre al final de esta sección. Sustituida por la fila siguiente desde la Fase 06.2 |
audit_log_seals desde la Fase 06.2 (PRE-02, planes 06.2-03 y 06.2-15) |
sí | sí | El MessageImprint es SHA-256(JCS(sobre)), con el sobre de siete campos de SealEnvelope.imprint (earchiving-core): sealKind (BATCH | HEARTBEAT), tenantId, seqFrom, seqTo, lastEntryHash, prevSealHash y tree —el árbol de la fila anterior (root, hojas ordenadas por entryId, caminos de hermanos y merkleProfile) anidado, o null en un latido—. prevSealHash = SHA-256 de los bytes tal cual del token del sello anterior (génesis SHA-256("")), así que el encadenado de sellos queda dentro de lo sellado y no solo en una columna. Sigue habiendo una sola fórmula (D-14 de 05.6), que usan el sellado (AuditLogSealSchedulerImpl.sealTenant), AuditQueryService.verifySeal y el verificador de cadena completa (AuditChainVerifier.verifyTenant). Los números del sobre y de las entradas se canonicalizan según RFC 8785 desde 06.2-15 (D-31), con rechazo de todo valor entero de magnitud mayor que 2^53. Ver la segunda nota de cierre |
Consecuencia, dicha sin rodeos. Para audit_log_seals el perfil viaja CON el dato, no
DENTRO de la evidencia sellada: quien pueda alterar la fila puede alterar el perfil declarado sin
invalidar el token TSA. La declaración cierra el problema de legibilidad (un auditor con solo la
BD puede saber bajo qué reglas se computó el root sin abrir MerkleTree.java); no cierra el de
vinculación criptográfica. Ninguna afirmación de conformidad debe apoyarse en este campo como si
estuviera sellado.
La mitad abierta tiene dueño, no es un hueco silencioso. Está registrada como DEF-08 en §6 de
docs/governance/gate-chains-inventory.md, con ítem completo en el deferred-items.md de la fase
05.1.2 y destinatario QTSP-02 del ledger de requisitos — una fila que existe HOY y que sobrevive
al cierre de la fase. Ruta preferida para cerrarla: sellar el árbol canonicalizado con JCS
(mismo patrón canonicalize() → sellar que ya está validado en la ruta del AIP, y que además
protege hojas y hermanos, no solo el root). Plan B si la canonicalización JCS no se implementa:
MessageImprint sobre SHA-256(rootHash ‖ perfil). Hoy ningún consumidor de src/main recomputa
ese imprint —el token solo se produce, se persiste y se expone en hex—, así que ampliar lo que
cubre no rompe ningún lector existente. La medida ejecutable del límite es
AuditLogSealMerkleProfileTest#persistedEvidence_declaresMerkleProfile_butTheTsaImprintCoversOnlyTheRoot_def08.
Both consumers declare the profile; only one seals it. On the AIP path the profile is populated
before canonicalize(), so it is inside what the QTSP signs and the TSA timestamps. On the
audit_log_seals path the TSA MessageImprint covers only the 32 bytes of rootHash, so the
JSONB merkle_tree carrying merkleProfile is outside the seal: the profile travels with
the data, not inside the sealed evidence, and whoever can alter the row can alter the declared
profile without invalidating the token. That open half is tracked as DEF-08 with recipient
QTSP-02; preferred fix is to seal the JCS-canonicalized tree, fallback
SHA-256(rootHash ‖ profile). No conformance claim may rest on this field as if it were sealed.
Nota de cierre (Fase 05.6, Plan 05.6-04, 2026-09-24): para audit_log_seals el perfil AHORA SÍ está
sellado, no solo declarado. La fila histórica de la tabla, los tres párrafos anteriores y el resumen
en inglés describen el estado que dejó la fase 05.1.2 y se conservan a propósito: son el registro de
qué cerró el gap-closure original de GATE-02 (la legibilidad del perfil) y qué dejó abierto (la
vinculación criptográfica). Desde 05.6 se cerró por la ruta preferida que describen (D-13..D-16
de 05.6-CONTEXT.md):
AuditLogSealSchedulerImpl.sealTenant(UUID) ya no sella parseHex(tree.rootHash()): sella
SHA-256(JCS(root + hojas + hermanos + merkleProfile)), calculado por
MerkleTreeCanonicalForm.sealImprint con JcsCanonicalizer (movido a earchiving-core por el Plan
05.6-03). JCS (RFC 8785) no ordena arrays, así que el orden de las hojas lo impone el código (por
entryId) y los caminos de hermanos conservan su orden hoja → raíz. El JSONB merkle_tree
persistido conserva su forma.AuditQueryService.verifySeal recomputa el imprint desde el JSONB con la misma y única fórmula
(D-14: sin verificador dual ni columna de versión) y lo compara con el MessageImprint real del
token RFC 3161. Alterar el perfil, una hoja o un hermano en merkle_tree da valid=false. La frase
«ningún consumidor de src/main recomputa ese imprint» deja de ser cierta desde este cierre.AuditLogSealMerkleProfileTest#persistedEvidence_tsaImprintCoversTheFullCanonicalizedTree_def08Closed,
AuditLogSealMerkleProfileTest#canonicalForm_isIndependentOfTheLeafInputOrder_d15a y
AuditQueryServiceTest (mutación del perfil y de un hermano → valid=false).Lo que el cierre NO cambia, dicho igual de claro:
MERKLE_PROFILE_V2_URI. Eso responde a medias a IN-03 de la ronda 9 del deferred-items.md de
05.1.2, que pedía para esta tabla la pregunta «¿lo comprueba alguien?»: para audit_log_seals se
comprueba su integridad; su valor sigue sin tratarse como precondición, a diferencia del verificador
del AIP.valid=true no valida la firma del token ni su cadena hasta el QTSP (DI-05.6-04-B del
deferred-items.md de la fase 05.6).valid=false. Solo existen en dev (y en
pre, si llegó a desplegarse antes de 05.6) y se tratan en el flip (precedente D-50 de 05.1.2). La
operativa vive en docs/ops/qtsp-go-live-checklist.md § «Sección DEF-08 — Vinculación criptográfica
del perfil Merkle del audit log» e ítem (h). La fila DEF-08 de §6 de
docs/governance/gate-chains-inventory.md lleva la misma cláusula de cierre.Closure note (Phase 05.6, Plan 05.6-04): the audit-log seal now covers
SHA-256(JCS(root + leaves + siblings + merkleProfile)) and verifySeal recomputes it from the stored
JSONB with the single formula, so tampering with the profile or a sibling yields valid=false. The
historical row and paragraphs above are kept on purpose. Not changed: the verifier checks the profile's
integrity, not that it equals MERKLE_PROFILE_V2_URI, and it does not validate the token's trust chain.
Segunda nota de cierre (Fase 06.2, PRE-02/AUDC-04, 2026-10-02): la fórmula de 05.6 YA NO es la vigente. La nota anterior se conserva como registro de lo que cerró 05.6, pero su primera viñeta, su método y dos de sus límites dejaron de ser ciertos:
SHA-256(JCS(árbol)), sino SHA-256(JCS(sobre de siete campos)) con el árbol
anidado (fila «desde la Fase 06.2» de la tabla). El método MerkleTreeCanonicalForm.sealImprint se
retiró en 06.2-03: el único cálculo del imprint vivo es SealEnvelope.imprint. El árbol sigue
viajando en el JSONB merkle_tree con su forma, y el sobre se reconstruye desde las columnas de la fila
(seal_kind, seq_from, seq_to, last_entry_hash, prev_seal_hash) y ese JSONB.docker compose down -v;
la precondición HALT de v12/0001 lo exige). Sigue sin haber columna de versión ni verificador
dual. La premisa es que la fase 06.2 está desplegada antes del primer arranque de pre; si no, esta
decisión se reabre (D-04 y D-31 de 06.2-CONTEXT.md).valid=true no valida la firma del token ni su cadena hasta el QTSP» (DI-05.6-04-B) está
cerrado en su mecanismo: verifySeal valida con DSS el token contra las anclas de confianza
configuradas (GATE-15-DSS-TOKEN). valid=true significa «firma íntegra + cadena hasta el ancla
configurada» y nada más: ni revocación, ni policyOid, ni cualificación eIDAS, cuyo ensayo contra la
cadena real de Uanataca sigue en docs/ops/qtsp-go-live-checklist.md §5 (carril pre).MERKLE_PROFILE_V2_URI.Second closure note (Phase 06.2): the 05.6 formula is no longer current. The audit-log seal imprint
is SHA-256(JCS(seven-field envelope)) — sealKind, tenantId, seqFrom, seqTo,
lastEntryHash, prevSealHash (SHA-256 of the previous token's stored bytes) and the nested tree
(null for a heartbeat) — computed only by SealEnvelope.imprint; MerkleTreeCanonicalForm.sealImprint
was retired. Still a single formula, numbers canonicalized per RFC 8785 with integers beyond 2^53
rejected. valid=true now means intact signature plus a chain up to the configured anchor, nothing
more. Audit-log seals are never purged.
attestation-v1.xsd (sello #1) NO declara merkleProfile en esta fase. La omisión es
deliberada y queda registrada aquí, no silenciada.
Ese esquema también embebe un root computado por MerkleTree.build() (aipMerkleRoot, paso 7 de la
saga). Extenderlo exigiría el MISMO trabajo de dispatch v1/v2 que se ha pagado para el sello #2, y
su consumidor —autenticidad de origen del SIP, no verificación de integridad post-hoc— no lee
merkleProfile en ningún punto. Duplicar el coste de dispatch por un campo sin consumidor es
presupuesto abierto, que D-08 de 05.1.2-CONTEXT.md prohíbe para esta fase.
Condición que forzaría extenderlo. Que alguien construya una verificación independiente de
origin-attestation.xml sin acceso al resto del AIP —es decir, un consumidor que necesite recomputar
aipMerkleRoot a partir del sello #1 solo—. En ese momento attestation-v1.xsd necesitará el mismo
tratamiento: campo merkleProfile y dispatch de lectura.
Tampoco entra en este documento la validación criptográfica de la firma cualificada ni del token TSA que envuelven la evidencia: este perfil define cómo se computa el valor sellado, no cómo se acredita el sello.
attestation-v1.xsd (seal #1) does not declare merkleProfile in this phase. It also embeds a
root computed by MerkleTree.build() (aipMerkleRoot), but extending it would require the same
v1/v2 dispatch work paid for seal #2 for a field whose consumer — SIP origin authenticity, not
post-hoc integrity verification — never reads it. What would force the extension: an independent
verification of origin-attestation.xml without access to the rest of the AIP. Cryptographic
validation of the qualified signature and the TSA token is also out of scope here: this profile
defines how the sealed value is computed, not how the seal is proven.
Documento normativo · Phase 05.1.2 (GATE-02) · https://docs.docuten.com/earchiving/merkle-profile/v2