diff --git a/product/infra/helm/README.md b/product/infra/helm/README.md
index 01aa747f..4f04c57c 100644
--- a/product/infra/helm/README.md
+++ b/product/infra/helm/README.md
@@ -50,6 +50,47 @@ for c in evolith-tracker-postgres evolith-tracker-api evolith-tracker-web; do
done
```
+## Turning on the transparency ledger (GT-588)
+
+The Tracker can sign every governance decision it records — a COSE_Sign1 statement plus a Merkle
+receipt, in the shape RFC 9943 (SCITT) defines — so an auditor can verify the record without
+trusting the producer. **It ships OFF**, and turning it on is a deployment decision because it
+changes what the product claims about its own audit trail and it requires real key material.
+
+The application **refuses to start** with signing enabled and no seeds. That is deliberate: falling
+back to a development key would produce a ledger that looks signed and proves nothing, which is
+worse than no ledger because it invites trust.
+
+Two Ed25519 seeds are required, and they must be **different**. RFC 9943 places receipt authority in
+an entity separate from the issuer; with one key the receipt is a self-assertion and the governance
+rule `AUD-TRANSP-04` rejects it.
+
+```bash
+# 1. Generate two 32-byte seeds. Keep them in your secret store — never in git.
+kubectl create secret generic tracker-transparency \
+ --from-literal=Transparency__IssuerKeySeedBase64="$(openssl rand -base64 32)" \
+ --from-literal=Transparency__TransparencyServiceKeySeedBase64="$(openssl rand -base64 32)"
+
+# 2. A PersistentVolumeClaim for the ledger. A ledger on the pod's ephemeral filesystem is erased
+# on every restart, and a transparency log that deletes itself is exactly what the gap calls
+# decorative — so the chart REFUSES to render without a claim.
+kubectl apply -f your-ledger-pvc.yaml
+
+# 3. Enable it.
+helm upgrade --install tracker-api product/infra/helm/evolith-tracker-api \
+ --set transparency.enabled=true \
+ --set transparency.existingSecretName=tracker-transparency \
+ --set transparency.persistence.enabled=true \
+ --set transparency.persistence.existingClaim=tracker-transparency-ledger
+```
+
+Verify with `evolith audit verify` against the ledger path. The governance rule `AUD-TRANSP-01`
+fails a ledger that is missing or empty, so a signature that silently stopped being emitted shows
+up at the next evaluation rather than never.
+
+**What the chart never does:** generate, default or version key material. Every seed comes from the
+deployment's own secret store.
+
## Notes
- **Connection string.** The .NET config system cannot concatenate a password
diff --git a/product/infra/helm/evolith-tracker-api/templates/configmap.yaml b/product/infra/helm/evolith-tracker-api/templates/configmap.yaml
index 90180c23..ffc95395 100644
--- a/product/infra/helm/evolith-tracker-api/templates/configmap.yaml
+++ b/product/infra/helm/evolith-tracker-api/templates/configmap.yaml
@@ -35,6 +35,16 @@ data:
Authentication__Ums__Issuer: {{ .Values.auth.ums.issuer | quote }}
Authentication__Ums__RequireHttpsMetadata: {{ .Values.auth.ums.requireHttpsMetadata | quote }}
Cors__Origins__0: {{ .Values.cors.origins | quote }}
+ {{- if .Values.transparency.enabled }}
+ # GT-588 — se emite SOLO cuando la firma esta activa, por la misma razon que Otlp: una clave
+ # presente en el configmap sugiere que hay expediente firmado cuando no lo hay.
+ Transparency__Enabled: "true"
+ Transparency__LedgerPath: {{ .Values.transparency.ledgerPath | quote }}
+ Transparency__Issuer: {{ .Values.transparency.issuer | quote }}
+ Transparency__IssuerKeyId: {{ .Values.transparency.issuerKeyId | quote }}
+ Transparency__TransparencyServiceIssuer: {{ .Values.transparency.transparencyServiceIssuer | quote }}
+ Transparency__TransparencyServiceKeyId: {{ .Values.transparency.transparencyServiceKeyId | quote }}
+ {{- end }}
{{- if .Values.otlp.endpoint }}
# T-049 / GT-616 — solo se emite cuando hay colector. Emitirlo vacio seria equivalente a no
# emitirlo (la app hace retorno temprano), pero dejaria una clave en el configmap que sugiere
diff --git a/product/infra/helm/evolith-tracker-api/templates/deployment.yaml b/product/infra/helm/evolith-tracker-api/templates/deployment.yaml
index 5152dd2a..6123169c 100644
--- a/product/infra/helm/evolith-tracker-api/templates/deployment.yaml
+++ b/product/infra/helm/evolith-tracker-api/templates/deployment.yaml
@@ -94,6 +94,22 @@ spec:
name: {{ .Values.agentRuntime.existingSecretName }}
key: {{ .Values.agentRuntime.apiKeyKey }}
{{- end }}
+ {{- if and .Values.transparency.enabled .Values.transparency.existingSecretName }}
+ # GT-588 — las semillas Ed25519 llegan del deposito de secretos del despliegue, nunca
+ # del chart. Si `enabled` esta activo y falta el Secret, la aplicacion FALLA AL
+ # ARRANCAR: es preferible un pod que no levanta a uno que sirve un expediente sin
+ # firmar mientras el chart dice que lo firma.
+ - name: Transparency__IssuerKeySeedBase64
+ valueFrom:
+ secretKeyRef:
+ name: {{ .Values.transparency.existingSecretName }}
+ key: {{ .Values.transparency.issuerKeySeedKey }}
+ - name: Transparency__TransparencyServiceKeySeedBase64
+ valueFrom:
+ secretKeyRef:
+ name: {{ .Values.transparency.existingSecretName }}
+ key: {{ .Values.transparency.transparencyServiceKeySeedKey }}
+ {{- end }}
- name: TMPDIR
value: /tmp
{{- with .Values.containerSecurityContext }}
@@ -138,9 +154,21 @@ spec:
successThreshold: {{ .Values.probes.startup.successThreshold }}
{{- end }}
volumeMounts:
+ {{- if and .Values.transparency.enabled .Values.transparency.persistence.enabled }}
+ - name: transparency-ledger
+ mountPath: {{ dir .Values.transparency.ledgerPath | quote }}
+ {{- end }}
# readOnlyRootFilesystem is true; give .NET a writable temp dir.
- name: tmp
mountPath: /tmp
volumes:
+ {{- if and .Values.transparency.enabled .Values.transparency.persistence.enabled }}
+ # GT-588 — el ledger de transparencia NO puede vivir en un emptyDir: se borraria en cada
+ # reinicio del pod, y un log de transparencia que se borra solo es exactamente lo que la
+ # ficha llama decorativo. Se exige un PVC que aporta el despliegue.
+ - name: transparency-ledger
+ persistentVolumeClaim:
+ claimName: {{ required "transparency.persistence.existingClaim es obligatorio cuando la persistencia del ledger esta activa" .Values.transparency.persistence.existingClaim }}
+ {{- end }}
- name: tmp
emptyDir: {}
diff --git a/product/infra/helm/evolith-tracker-api/values.yaml b/product/infra/helm/evolith-tracker-api/values.yaml
index bb1e0706..3d719531 100644
--- a/product/infra/helm/evolith-tracker-api/values.yaml
+++ b/product/infra/helm/evolith-tracker-api/values.yaml
@@ -253,3 +253,31 @@ networkPolicy:
protocol: TCP
- port: 5432
protocol: TCP
+
+# GT-588 — firma del expediente (RFC 9943 / SCITT). DESACTIVADA por defecto y a proposito:
+# encenderla cambia lo que el producto promete sobre su propia auditoria, y exige material de
+# clave real. La aplicacion se NIEGA a arrancar con `enabled: true` sin semillas, en vez de caer
+# a una clave de desarrollo: un ledger que parece firmado y no prueba nada induce confianza y es
+# peor que no tener ledger.
+#
+# Las semillas NO viven aqui. `existingSecretName` apunta a un Secret que crea el despliegue
+# (kubectl/sealed-secrets/KMS); este chart nunca genera ni versiona claves.
+transparency:
+ enabled: false
+ # Ruta del ledger JSONL. DEBE caer en un volumen persistente: un ledger en el filesystem
+ # efimero del pod desaparece en cada reinicio, y un log de transparencia que se borra solo es
+ # exactamente lo que la ficha llama decorativo.
+ ledgerPath: /var/lib/evolith/transparency/ledger.jsonl
+ # Volumen del ledger. Sin esto la ruta de arriba vive en el contenedor y se pierde.
+ persistence:
+ enabled: false
+ existingClaim: ""
+ issuer: evolith-tracker
+ issuerKeyId: tracker-issuer
+ # El Servicio de Transparencia es una entidad SEPARADA por RFC 9943. Compartir clave con el
+ # Issuer convierte el recibo en una autoafirmacion, y `AUD-TRANSP-04` lo rechaza.
+ transparencyServiceIssuer: evolith-tracker-transparency
+ transparencyServiceKeyId: tracker-ts
+ existingSecretName: ""
+ issuerKeySeedKey: Transparency__IssuerKeySeedBase64
+ transparencyServiceKeySeedKey: Transparency__TransparencyServiceKeySeedBase64
diff --git a/src/apps/tracker-api/Tracker.Tests/Infrastructure/Transparency/TransparencyOperabilityTests.cs b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Transparency/TransparencyOperabilityTests.cs
new file mode 100644
index 00000000..f4ae69d6
--- /dev/null
+++ b/src/apps/tracker-api/Tracker.Tests/Infrastructure/Transparency/TransparencyOperabilityTests.cs
@@ -0,0 +1,106 @@
+namespace Tracker.Tests.Infrastructure.Transparency;
+
+///
+/// GT-588 — que la firma del expediente se pueda ENCENDER en un despliegue.
+///
+/// El cable existía y estaba probado: un decorador de IAuditEntryRepository, un árbol
+/// de Merkle, COSE_Sign1, un ledger JSONL y su interop cross-lenguaje en CI. Y sin embargo ninguna
+/// decisión se firmaba en ningún entorno, por una razón que no estaba en el código: **el chart no
+/// declaraba la palanca**. `Transparency` no aparecía en `values.yaml`, ni en el configmap, ni en
+/// el deployment, así que un operador con claves en la mano no tenía por dónde activarla salvo
+/// inventando un `--set` sobre una clave inexistente — que Helm acepta en silencio y no enciende
+/// nada.
+///
+/// Estas pruebas leen el chart, no el código. Es a propósito: lo que faltaba era la
+/// superficie de operación, y una prueba sobre las clases habría seguido verde todo el tiempo que
+/// el gap estuvo abierto. Es la misma lección que `ObservabilityConventionTests` dejó escrita para
+/// las trazas — un valor que no está DECLARADO no se documenta ni se revisa.
+///
+/// Ninguna de ellas enciende la firma. Que un entorno la active con claves reales es una
+/// decisión de despliegue y de custodia de secretos, no algo que una prueba deba forzar.
+///
+public class TransparencyOperabilityTests
+{
+ private static string Chart() =>
+ Path.Combine(RepoRoot(), "product/infra/helm/evolith-tracker-api");
+
+ private static string Valores() => File.ReadAllText(Path.Combine(Chart(), "values.yaml"));
+ private static string ConfigMap() => File.ReadAllText(Path.Combine(Chart(), "templates/configmap.yaml"));
+ private static string Deployment() => File.ReadAllText(Path.Combine(Chart(), "templates/deployment.yaml"));
+
+ [Fact]
+ public void ElChartDeclaraLaPalancaDeFirma()
+ {
+ Valores().Should().Contain("transparency:",
+ "un `--set transparency.enabled=true` sobre una clave que no existe en values.yaml no "
+ + "falla, no se documenta y no se revisa: simplemente no enciende nada");
+ ConfigMap().Should().Contain("Transparency__Enabled",
+ "sin esta clave el configmap no puede activar la firma ni aunque el operador tenga claves");
+ }
+
+ [Fact]
+ public void SigueApagadaPorDefecto()
+ {
+ // La guarda existe para que encenderla sea POSIBLE, no para encenderla. Activar la firma
+ // cambia lo que el producto promete sobre su propio expediente y exige claves reales.
+ Valores().Should().MatchRegex(@"transparency:\s*\r?\n(\s+#[^\r\n]*\r?\n)*\s+enabled:\s*false",
+ "el defecto sigue siendo APAGADO");
+ }
+
+ [Fact]
+ public void LasSemillasVienenDeUnSecretYNuncaDelChart()
+ {
+ var deployment = Deployment();
+
+ deployment.Should().Contain("Transparency__IssuerKeySeedBase64",
+ "sin esta variable el pod arranca sin material de clave y la aplicacion se niega a "
+ + "firmar — correctamente, pero entonces el chart promete algo que no ocurre");
+ deployment.Should().Contain("secretKeyRef",
+ "las semillas Ed25519 llegan del deposito de secretos del despliegue");
+
+ // Lo que NO debe estar. Una semilla por defecto convertiria el ledger en algo que parece
+ // firmado y no prueba nada, que es peor que no tener ledger: induce confianza.
+ Valores().Should().NotMatchRegex(@"KeySeed\w*:\s*[""']?[A-Za-z0-9+/]{16,}",
+ "el chart NUNCA versiona material de clave");
+ }
+
+ [Fact]
+ public void ElLedgerExigeVolumenPersistente()
+ {
+ var deployment = Deployment();
+
+ deployment.Should().Contain("transparency-ledger",
+ "el ledger necesita su propio volumen");
+ deployment.Should().Contain("persistentVolumeClaim",
+ "un ledger en el filesystem efimero del pod se borra en cada reinicio, y un log de "
+ + "transparencia que se borra solo es exactamente lo que la ficha llama decorativo");
+ deployment.Should().Contain("required \"transparency.persistence.existingClaim",
+ "pedir persistencia sin PVC debe FALLAR el render, no montar silenciosamente un "
+ + "emptyDir con nombre de volumen persistente");
+ }
+
+ [Fact]
+ public void ElCableSigueSiendoUnDecoradorYNoUnCambioEnCadaLlamante()
+ {
+ // Si alguien sustituye el decorador por llamadas explicitas, el quinto camino que se
+ // añada mañana no firmara y nadie lo notara. Esta prueba es sobre la FORMA del cable.
+ var registro = File.ReadAllText(Path.Combine(
+ RepoRoot(),
+ "src/apps/tracker-api/Tracker.Infrastructure/Transparency/TransparencyServiceCollectionExtensions.cs"));
+
+ registro.Should().Contain("Decorate",
+ "el cable firma los caminos que YA existen y el que alguien añada sin acordarse de "
+ + "esta ficha; una lista de llamantes explicitos solo firma los que alguien recordo");
+ }
+
+ private static string RepoRoot()
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, ".git")))
+ {
+ dir = dir.Parent;
+ }
+ dir.Should().NotBeNull("la prueba necesita ubicar la raiz del repositorio");
+ return dir!.FullName;
+ }
+}