diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b4928c1b..fd2089da 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -88,7 +88,11 @@ jobs:
done
- name: Test
- run: dotnet test Tracker.sln --no-build -c Release --logger "trx;LogFileName=test-results.trx"
+ # GT-588 — se EXCLUYE la categoria `Interop`, que necesita el CLI del Core construido.
+ # Corre en su propio job (`transparency-interop`), que si lo aporta. Sin esta exclusion,
+ # este job seria rojo; con el `return` silencioso que tenia antes, era verde sin comprobar
+ # nada, que es peor.
+ run: dotnet test Tracker.sln --no-build -c Release --filter "Category!=Interop" --logger "trx;LogFileName=test-results.trx"
- name: Smoke — la aplicacion arranca LIMPIA contra Postgres
# Anadido tras la regresion del 2026-07-19: el filtro global por tenant de CD-27
@@ -113,6 +117,69 @@ jobs:
path: src/apps/tracker-api/**/TestResults/*.trx
if-no-files-found: ignore
+ # GT-588 — la unica prueba de que el firmante C# y el verificador TypeScript producen y leen los
+ # MISMOS bytes. Entre ambos hay JSON canonico, CBOR determinista, cabeceras COSE y un arbol de
+ # Merkle: cuatro capas donde un solo byte de diferencia lo rompe todo, y ninguna de ellas se
+ # comprueba comparando estructuras C# contra estructuras C#.
+ #
+ # Va en su propio job porque necesita construir el CLI del Core, que es caro y no tiene por que
+ # frenar al resto. Y MUERDE: los tests fallan —no se saltan— cuando no encuentran el CLI, porque
+ # una prueba de interoperabilidad que pasa sin la otra mitad presente reporta como verificado algo
+ # que nadie miro.
+ transparency-interop:
+ name: Transparency interop (C# firma · TypeScript verifica)
+ runs-on: ubuntu-latest
+
+ env:
+ DOTNET_NOLOGO: "true"
+ DOTNET_CLI_TELEMETRY_OPTOUT: "true"
+
+ steps:
+ - name: Checkout Tracker
+ uses: actions/checkout@v4
+ with:
+ path: tracker
+
+ - name: Checkout Evolith Core
+ uses: actions/checkout@v4
+ with:
+ repository: beyondnetcode/evolith_arch32
+ token: ${{ secrets.CORE_REPO_TOKEN }}
+ path: core
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ cache: "npm"
+ cache-dependency-path: core/package-lock.json
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "10.0.x"
+
+ - name: Build the Core CLI (the verifier)
+ working-directory: core
+ run: |
+ npm ci
+ npm run build
+
+ - name: Verify the CLI exposes `audit verify`
+ working-directory: core
+ run: |
+ set -euo pipefail
+ # Se comprueba ANTES de correr los tests para que un CLI construido pero sin el comando
+ # de un mensaje claro, en vez de un fallo de proceso dentro de una asercion.
+ test -f src/sdk/cli/dist/main.js
+ node src/sdk/cli/dist/main.js audit verify --help > /dev/null
+
+ - name: Cross-language interop tests
+ working-directory: tracker/src/apps/tracker-api
+ env:
+ EVOLITH_CLI_MAIN: ${{ github.workspace }}/core/src/sdk/cli/dist/main.js
+ run: dotnet test Tracker.sln --filter "Category=Interop" --logger "trx;LogFileName=interop-results.trx"
+
frontend:
name: Frontend (lint + typecheck + build)
runs-on: ubuntu-latest
diff --git a/DECISIONS.es.md b/DECISIONS.es.md
index 4b0df05a..82f8b83d 100644
--- a/DECISIONS.es.md
+++ b/DECISIONS.es.md
@@ -72,3 +72,4 @@ _Nota: Las decisiones universales se heredan del Upstream Base ([evolith_arch32]
| T-053 | Consumir la identidad UMS: JWKS/OIDC preferido, simétrico como interino | Definir | Proposed | [T-053](./docs/adrs/T-053-consume-ums-identity.es.md) | El tracker-api valida OIDC/JWKS pero el UMS no lo expone. Preferir que el UMS publique JWKS (aguas arriba); interino simétrico implementado y verificado con mock. |
| T-054 | Gate de frontera en tiempo de edición: adoptar acotado, diferido a EAG-11 | Definir | Accepted | [T-054](./docs/adrs/T-054-edit-time-gate-adoption.es.md) | Adopta el edit-gate del Core pero DIFERIDO: `.claude/` está en `.gitignore`, `EAG-11` aún no da la fuente única de reglas, y el matcher puede misfirear. Activación condicionada a `EAG-11` + versionar `.claude/settings.json` + acotar rutas + vía de escape. |
| T-055 | El Core deposita sus veredictos; el Tracker posee el ledger y deriva el tenant de la clave | Definir | Accepted | [T-055](./docs/adrs/T-055-core-initiated-evidence-ingest.es.md) | Segunda dirección del tráfico de evidencia: `POST /core-evaluation-transactions` autenticado por clave de máquina atada al esquema POR NOMBRE, permiso propio `:ingest` SIN `:read`, tenant derivado de QUÉ CLAVE encajó (un `tenantId` en el cuerpo se rechaza con 400, no se ignora), idempotencia por `(tenant, correlationId)` respaldada por índice único, motor de cada regla VERBATIM (vocabulario abierto) y los dos responsables —quien pidió y quien debe arreglar— en columnas distintas. Estado `ingested`, distinto de `completed`. El DTO derivado a mano cumple `T-038` con guarda de deriva por fixture. Sigue siendo advisory (`T-039`). Cierra `GT-604`. |
+| T-056 | El sellado no lleva lógica, la validación de contenido es del tenant, y la IA propone pero nunca decide | Definir | Accepted | [T-056](./docs/adrs/T-056-three-layer-separation.es.md) | Tres capas con frontera dura, ratificadas al cablear `GT-588`. **(1) El sellado no mira el contenido:** `payload` es opaco y lo único que se fija es la regla de ESCRITURA (claves ordenadas en toda profundidad, comparación ordinal para que el idioma de la máquina no las reordene, una codificación). Por eso la interoperabilidad C#/TypeScript no crea un problema de estandarización por tenant — un notario sella documentos distintos con un procedimiento invariable. **(2) La validación de contenido la configura el tenant:** en cuanto la semántica de un cliente llega al motor como código, el motor pasa a ser un catálogo de casos particulares y cada cliente nuevo es una release. Misma frontera que `T-039` aplicada al contenido. **(3) La IA propone, nunca decide:** un decisor probabilístico vuelve la garantía incomprobable, porque dos ejecuciones podrían diferir y nadie sabría cuál vale; detrás tiene que haber un verificador determinista y, si la propuesta es probabilística, su confianza se muestra a quien la ratifica (`GT-590`, `GT-584`). |
diff --git a/DECISIONS.md b/DECISIONS.md
index ea948131..39e51e47 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -80,5 +80,6 @@ _Nota: Las decisiones universales se heredan del Upstream Base ([evolith_arch32]
| T-054 | Gate de frontera en tiempo de edición: adoptar acotado, diferido a EAG-11 | Definir | Accepted | [T-054](./docs/adrs/T-054-edit-time-gate-adoption.md) | Adopta el edit-gate del Core pero DIFERIDO: `.claude/` está en `.gitignore`, `EAG-11` aún no da la fuente única de reglas, y el matcher puede misfirear. Activación condicionada a `EAG-11` + versionar `.claude/settings.json` + acotar rutas + vía de escape. |
| T-055 | El Core deposita sus veredictos; el Tracker posee el ledger y deriva el tenant de la clave | Definir | Accepted | [T-055](./docs/adrs/T-055-core-initiated-evidence-ingest.md) | Segunda dirección del tráfico de evidencia: `POST /core-evaluation-transactions` autenticado por clave de máquina atada al esquema POR NOMBRE, permiso propio `:ingest` SIN `:read`, tenant derivado de QUÉ CLAVE encajó (un `tenantId` en el cuerpo se rechaza con 400, no se ignora), idempotencia por `(tenant, correlationId)` respaldada por índice único, motor de cada regla VERBATIM (vocabulario abierto) y los dos responsables —quien pidió y quien debe arreglar— en columnas distintas. Estado `ingested`, distinto de `completed`. El DTO derivado a mano cumple `T-038` con guarda de deriva por fixture. Sigue siendo advisory (`T-039`). Cierra `GT-604`. |
+| T-056 | El sellado no lleva lógica, la validación de contenido es del tenant, y la IA propone pero nunca decide | Definir | Accepted | [T-056](./docs/adrs/T-056-three-layer-separation.md) | Tres capas con frontera dura, ratificadas al cablear `GT-588`. **(1) El sellado no mira el contenido:** `payload` es opaco y lo único que se fija es la regla de ESCRITURA (claves ordenadas en toda profundidad, comparación ordinal para que el idioma de la máquina no las reordene, una codificación). Por eso la interoperabilidad C#/TypeScript no crea un problema de estandarización por tenant — un notario sella documentos distintos con un procedimiento invariable. **(2) La validación de contenido la configura el tenant:** en cuanto la semántica de un cliente llega al motor como código, el motor pasa a ser un catálogo de casos particulares y cada cliente nuevo es una release. Misma frontera que `T-039` aplicada al contenido. **(3) La IA propone, nunca decide:** un decisor probabilístico vuelve la garantía incomprobable, porque dos ejecuciones podrían diferir y nadie sabría cuál vale; detrás tiene que haber un verificador determinista y, si la propuesta es probabilística, su confianza se muestra a quien la ratifica (`GT-590`, `GT-584`). |
> **Plantilla para nuevos ADRs:** Al crear un nuevo documento para "ADR Local", utilice el esquema de Frontmatter definido en los estándares de Evolith Core y ubíquelo en la carpeta de gobernanza correspondiente.
diff --git a/README.es.md b/README.es.md
index 92f4b877..f3191938 100644
--- a/README.es.md
+++ b/README.es.md
@@ -8,7 +8,7 @@
[]()
[](https://github.com/beyondnetcode/evolith_arch32)
-[](./DECISIONS.md)
+[](./DECISIONS.md)
[](./LICENSE)
diff --git a/README.md b/README.md
index b8174ab3..ff9d7f2a 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
[]()
[](https://github.com/beyondnetcode/evolith_arch32)
-[](./DECISIONS.md)
+[](./DECISIONS.md)
[](./LICENSE)
diff --git a/contracts/evolith-core-contracts.json b/contracts/evolith-core-contracts.json
index fac25c75..9d6a73bc 100644
--- a/contracts/evolith-core-contracts.json
+++ b/contracts/evolith-core-contracts.json
@@ -10,10 +10,10 @@
},
"derivedFrom": {
"coreRepository": "beyondnetcode/evolith_arch32",
- "coreCommit": "f036710d6a4f5d0a80fb4158fc301353c2e7157f",
+ "coreCommit": "d69e0bc7ea9690b515cff878406c4aae679f74fb",
"derivedAt": "2026-08-01",
- "gap": "CP-01",
- "note": "Contract authority rebaselined from the deprecated npm two-schema package to Core's live four-schema machine-contract manifest."
+ "gap": "GT-605",
+ "note": "evidence-edge adopted: Core published the typed evidence-edge model as JSON Schema so this satellite, which is .NET and cannot import @beyondnet/evolith-contracts (ADR T-038), can build `evidence_edges` against the contract instead of transcribing it by hand."
},
"schemas": [
{
@@ -35,6 +35,11 @@
"id": "evaluation-result",
"version": "1.1.0",
"sha256": "b0cad39fb15f0dccd1adbcc0f59aa26aee2a0f9a13ad250ffe031db7a0875d9d"
+ },
+ {
+ "id": "evidence-edge",
+ "version": "1.0.0",
+ "sha256": "c73565c3cae069f3d2e4710c49b043c57d0a579390c8591c4a5017074da15798"
}
],
"consumer": {
diff --git a/docs/adrs/T-056-three-layer-separation.es.md b/docs/adrs/T-056-three-layer-separation.es.md
new file mode 100644
index 00000000..c2249786
--- /dev/null
+++ b/docs/adrs/T-056-three-layer-separation.es.md
@@ -0,0 +1,107 @@
+---
+adr: T-056
+title: El sellado no lleva lógica, la validación de contenido es del tenant, y la IA propone pero nunca decide
+status: Accepted
+date: 2026-08-01
+tags: [EvolithSatellite, governance, transparency, ai, multi-tenancy, architecture]
+authority: Decisión de producto, ratificada el 2026-08-01 al cablear GT-588
+relates: [T-039 el Core recomienda el tenant decide, T-055 ingesta de evidencia iniciada por el Core, core/ADR-0111 costura del proveedor probabilístico, core/ADR-0101 Core sin estado]
+gaps: [GT-588, GT-590, GT-584]
+---
+
+# ADR T-056 — Tres capas que no deben mezclarse
+
+## Status
+
+Aceptado (2026-08-01). Ratificado por el propietario del producto al decidir dónde vive el cable de
+firma de GT-588, a raíz de una pregunta directa: *«¿no te parece que estandarizar cada contenido es
+muy complejo y variable entre tenants?»*. La respuesta es que **no estandarizamos el contenido en
+absoluto** — y escribirlo es el sentido de este registro, porque el error que previene es uno que un
+ingeniero razonable cometería a propósito.
+
+## Context
+
+Tres asuntos distintos se venían discutiendo como si fueran uno, y confundirlos produce un fallo
+concreto y caro: un motor que acumula un catálogo de reglas por cliente, donde cada tenant nuevo
+obliga a tocar el núcleo.
+
+El detonante inmediato fue el ledger de transparencia. Una decisión firmada tiene que escribirse
+byte a byte igual por un firmante en C# y un verificador en TypeScript, lo que invita a pensar que
+«la forma de una decisión» debe estandarizarse entre tenants. No debe, y la razón vale mucho más
+allá del sellado.
+
+Por separado surgió la posibilidad de que un tenant enchufe **su propia IA**, anclada a nuestra
+configuración, para recibir contenido y contexto y validarlo. Es una buena idea en la capa correcta
+y peligrosa en la equivocada.
+
+## Decision
+
+**Tres capas, con frontera dura entre cada una.**
+
+### 1. El sellado no lleva lógica ni opinión
+
+La capa de transparencia/firma **no** mira el contenido, no lo juzga y no lo interpreta. Coge lo que
+llegue y lo escribe siempre igual para poder sellarlo — como una báscula que pesa un paquete sin
+abrirlo.
+
+En concreto: `DecisionStatement.payload` es opaco. Lo que se fija es la regla de **escritura**
+(claves ordenadas en toda profundidad, comparación ordinal para que el idioma de una máquina no las
+reordene, una sola codificación), no el contenido. Esa regla es universal: sirve para cualquier
+payload, incluido uno que un tenant invente mañana y que nunca hayamos visto.
+
+Por eso la interoperabilidad **no** crea un problema de estandarización por tenant. Un notario sella
+documentos radicalmente distintos con un único procedimiento invariable. Lo que varía es el
+documento; lo que no varía es el acto de sellar.
+
+### 2. La validación de contenido la configura el tenant, no la cableamos nosotros
+
+No escribimos lógica propia que juzgue contenido cuyo significado define cada tenant. Rulesets,
+criterios y compuertas son configuración. En cuanto la semántica de un cliente llega al motor como
+código, el motor se convierte en un catálogo de casos particulares y cada cliente nuevo es una
+release.
+
+Es la misma frontera que `T-039` ya traza para la autoridad de excepción (el Core recomienda, el
+tenant decide), aplicada al contenido.
+
+### 3. La IA propone; nunca es la autoridad final
+
+La IA de un tenant puede leer contenido y contexto y **sugerir**: «esto parece incompleto», «esto no
+encaja con lo que declaraste». No puede decidir, porque un decisor probabilístico vuelve la garantía
+incomprobable — dos ejecuciones podrían diferir y nadie sabría cuál vale.
+
+El reparto sano es **la IA propone → algo determinista verifica → un humano confirma cuando importa**,
+que es la costura que `core/ADR-0111` ya define y que las aprobaciones de runtime de `GT-590` ya
+implementan, incluido el campo `confidence`, que existe precisamente para decirle al humano que está
+ratificando una **conjetura** y no un hecho.
+
+## Consequences
+
+- La capa de sellado se mantiene pequeña y estable. No puede adquirir comportamiento por tenant,
+ porque no tiene dónde ponerlo.
+- Los vectores dorados del renderizado canónico son **cortos y no crecen con los tenants**: fijan la
+ regla (acentos, anidamiento, campos vacíos, textos largos), no los casos de negocio.
+- Cualquier propuesta de «validar X para el tenant Y» en código del motor queda rechazada por este
+ registro; su sitio es la configuración.
+- Cualquier propuesta de que un modelo emita un veredicto vinculante queda rechazada; detrás tiene
+ que haber un verificador determinista, y cuando la propuesta sea probabilística su confianza debe
+ mostrarse a quien la ratifica.
+- El coste es real y se asume: la validación por configuración es más difícil de autorar que unas
+ reglas cableadas, y la costura de la IA necesita una superficie de confirmación. Ambos se pagan una
+ vez, no por tenant.
+
+## Validation
+
+- El ledger de `GT-588` lleva `payload` como miembro opaco; ningún camino de código lo lee para
+ decidir. El renderizador canónico ordena claves y jamás inspecciona valores.
+- Las aprobaciones de runtime de `GT-590` ya llevan `confidence`, y la superficie de aprobación lo
+ muestra como una conjetura que se ratifica.
+- Una violación futura se detecta preguntando de cualquier validación nueva: *¿está leyendo
+ significado definido por el tenant dentro de código del motor?* Si la respuesta es sí, su sitio es
+ la capa 2 como configuración.
+
+## References
+
+- `T-039` — el Core recomienda, el tenant decide.
+- `core/ADR-0101` — el Core es un motor de evaluación sin estado; no puede sostener un ledger.
+- `core/ADR-0111` — la costura del proveedor probabilístico.
+- RFC 9943 — el Servicio de Transparencia es una entidad separada del Issuer.
diff --git a/docs/adrs/T-056-three-layer-separation.md b/docs/adrs/T-056-three-layer-separation.md
new file mode 100644
index 00000000..3cccc3e9
--- /dev/null
+++ b/docs/adrs/T-056-three-layer-separation.md
@@ -0,0 +1,104 @@
+---
+adr: T-056
+title: Sealing carries no logic, content validation belongs to the tenant, and AI proposes but never decides
+status: Accepted
+date: 2026-08-01
+tags: [EvolithSatellite, governance, transparency, ai, multi-tenancy, architecture]
+authority: Product decision, ratified 2026-08-01 while wiring GT-588
+relates: [T-039 core recommends tenant decides, T-055 core-initiated evidence ingest, core/ADR-0111 probabilistic provider seam, core/ADR-0101 stateless core]
+gaps: [GT-588, GT-590, GT-584]
+---
+
+# ADR T-056 — Three layers that must not be mixed
+
+## Status
+
+Accepted (2026-08-01). Ratified by the product owner while deciding where the GT-588 signing wire
+belongs, in response to a direct question: *"isn't standardising every content very complex and
+variable per tenant?"* The answer is that we do not standardise content at all — and writing that
+down is the point of this record, because the mistake it prevents is one a reasonable engineer
+would otherwise make on purpose.
+
+## Context
+
+Three distinct concerns kept being discussed as if they were one, and conflating them produces a
+specific, expensive failure: an engine that grows a catalogue of per-customer rules, where every
+new tenant means touching the core.
+
+The immediate trigger was the transparency ledger. A signed decision has to be written down
+byte-for-byte identically by a C# signer and a TypeScript verifier, which invites the question of
+whether "the shape of a decision" must therefore be standardised across tenants. It must not, and
+the reason generalises well beyond signing.
+
+Separately, the possibility was raised of letting a tenant plug in **their own AI**, anchored to
+our configuration, to receive content plus context and validate it. That is a good idea in the
+right layer and a dangerous one in the wrong layer.
+
+## Decision
+
+**Three layers, with a hard boundary between each.**
+
+### 1. Sealing carries no logic and no opinion
+
+The transparency/signing layer does **not** inspect content, does not judge it, and does not
+interpret it. It takes whatever arrives and writes it down the same way every time so that it can
+be sealed — like a scale that weighs a parcel without opening it.
+
+Concretely: `DecisionStatement.payload` is opaque. What is fixed is the *rendering* rule (keys
+sorted at every depth, ordinal comparison so a machine's locale cannot reorder them, one encoding),
+not the content. That rule is universal: it works for any payload, including one a tenant invents
+tomorrow that we have never seen.
+
+This is why the interop concern does **not** create a per-tenant standardisation problem. A notary
+seals wildly different documents with one unvarying procedure. What varies is the document; what
+does not vary is the act of sealing.
+
+### 2. Content validation is configured by the tenant, not wired by us
+
+We do not write our own logic that judges content whose meaning each tenant defines. Rulesets,
+criteria and gates are configuration. The moment a tenant's semantics reach the engine as code, the
+engine becomes a catalogue of special cases and every new customer is a release.
+
+This is the same boundary `T-039` already draws for exception authority (the Core recommends, the
+tenant decides), applied to content.
+
+### 3. AI proposes; it never is the final authority
+
+A tenant's AI may read content and context and **suggest**: "this looks incomplete", "this does not
+match what you declared". It may not decide, because a probabilistic decider makes the guarantee
+uncheckable — two runs could differ and nobody could say which one counts.
+
+The sound division of labour is **AI proposes → something deterministic verifies → a human confirms
+when it matters**, which is the seam `core/ADR-0111` already defines and which `GT-590`'s runtime
+approvals already implement, including the `confidence` field that exists precisely to tell the
+human they are ratifying a **guess** and not a fact.
+
+## Consequences
+
+- The signing layer stays small and stable. It cannot acquire per-tenant behaviour, because it has
+ no place to put it.
+- Golden test vectors for the canonical rendering stay **short and do not grow with tenants**: they
+ pin the rule (accents, nesting, empty fields, long text), not the business cases.
+- Any proposal to "validate X for tenant Y" in engine code is refused by this record; it belongs in
+ configuration.
+- Any proposal to let a model emit a binding verdict is refused; a deterministic verifier must sit
+ behind it, and where the proposal is probabilistic its confidence must be surfaced to whoever
+ ratifies it.
+- The cost is real and accepted: configuration-driven validation is harder to author than hardcoded
+ rules, and the AI-proposes seam needs a confirmation surface. Both are paid once, not per tenant.
+
+## Validation
+
+- `GT-588`'s ledger carries `payload` as an opaque member; no code path reads it to make a decision.
+ The canonical renderer sorts keys and never inspects values.
+- `GT-590`'s runtime approvals already carry `confidence`, and the approval surface displays it as a
+ conjecture being ratified.
+- A future violation is detectable by asking of any new validation: *is this reading tenant-defined
+ meaning inside engine code?* If yes, it belongs in layer 2 as configuration.
+
+## References
+
+- `T-039` — the Core recommends, the tenant decides.
+- `core/ADR-0101` — the Core is a stateless evaluation engine; it cannot hold a ledger.
+- `core/ADR-0111` — the probabilistic provider seam.
+- RFC 9943 — the Transparency Service is an entity separate from the Issuer.
diff --git a/reference/specs/design/tracker-postgresql-data-design.es.md b/reference/specs/design/tracker-postgresql-data-design.es.md
index afcf316f..d78cdb33 100644
--- a/reference/specs/design/tracker-postgresql-data-design.es.md
+++ b/reference/specs/design/tracker-postgresql-data-design.es.md
@@ -17,14 +17,14 @@
### 1.1 Mapa de Schemas
-**8 schemas · 49 tablas.** Derivado de los `ModelSnapshot` de EF Core, que son lo que la base tiene de verdad.
+**8 schemas · 50 tablas.** Derivado de los `ModelSnapshot` de EF Core, que son lo que la base tiene de verdad.
| Schema | Contexto EF | Tablas | Tablas (derivadas del snapshot de EF) |
|--------|------------|--------|---------------------------------------|
| `masterdata` | `TenantProjectionDbContext` | 4 | `InboxState`, `OutboxMessage`, `OutboxState`, `tenant_projection` |
| `tracker_construction` | `TrackerDbContext` | 1 | `peer_reviews` |
| `tracker_geo` | `TrackerDbContext` | 13 | `catalog_release`, `country`, `currency`, `exchange_rate`, `language`, `locale_profile`, `location`, `location_code`, `location_i18n`, `location_level`, `tax_rate`, `tax_scheme`, `time_zone` |
-| `tracker_governance` | `TrackerDbContext` | 21 | `artifact_field_schemas`, `audit_entries`, `core_evaluation_transactions`, `evidence_records`, `exception_requests`, `gate_decisions`, `gate_policies`, `gate_submissions`, `initiatives`, `opportunities`, `phase_artifacts`, `phase_gate_states`, `phase_progressions`, `products`, `provider_connections`, `publication_outbox`, `runtime_approvals`, `sdlc_executions`, `technical_blueprints`, `tenant_intelligence`, `tenants` |
+| `tracker_governance` | `TrackerDbContext` | 22 | `artifact_field_schemas`, `audit_entries`, `core_evaluation_transactions`, `evidence_edges`, `evidence_records`, `exception_requests`, `gate_decisions`, `gate_policies`, `gate_submissions`, `initiatives`, `opportunities`, `phase_artifacts`, `phase_gate_states`, `phase_progressions`, `products`, `provider_connections`, `publication_outbox`, `runtime_approvals`, `sdlc_executions`, `technical_blueprints`, `tenant_intelligence`, `tenants` |
| `tracker_intake` | `TrackerDbContext` | 5 | `intake_dead_letter`, `intake_outbox`, `ppm_endpoints`, `strategic_baselines`, `strategic_initiatives` |
| `tracker_metrics` | `TrackerDbContext` | 1 | `phase_sla_policies` |
| `tracker_qa` | `TrackerDbContext` | 3 | `defects`, `test_cycles`, `test_executions` |
diff --git a/reference/specs/design/tracker-postgresql-data-design.md b/reference/specs/design/tracker-postgresql-data-design.md
index 6164e00c..c7e41417 100644
--- a/reference/specs/design/tracker-postgresql-data-design.md
+++ b/reference/specs/design/tracker-postgresql-data-design.md
@@ -17,14 +17,14 @@
### 1.1 Schema Map
-**8 schemas · 49 tables.** Derived from the EF Core `ModelSnapshot` files, which are what the database actually has.
+**8 schemas · 50 tables.** Derived from the EF Core `ModelSnapshot` files, which are what the database actually has.
| Schema | EF context | Tables | Tables (derived from the EF snapshot) |
|--------|------------|--------|---------------------------------------|
| `masterdata` | `TenantProjectionDbContext` | 4 | `InboxState`, `OutboxMessage`, `OutboxState`, `tenant_projection` |
| `tracker_construction` | `TrackerDbContext` | 1 | `peer_reviews` |
| `tracker_geo` | `TrackerDbContext` | 13 | `catalog_release`, `country`, `currency`, `exchange_rate`, `language`, `locale_profile`, `location`, `location_code`, `location_i18n`, `location_level`, `tax_rate`, `tax_scheme`, `time_zone` |
-| `tracker_governance` | `TrackerDbContext` | 21 | `artifact_field_schemas`, `audit_entries`, `core_evaluation_transactions`, `evidence_records`, `exception_requests`, `gate_decisions`, `gate_policies`, `gate_submissions`, `initiatives`, `opportunities`, `phase_artifacts`, `phase_gate_states`, `phase_progressions`, `products`, `provider_connections`, `publication_outbox`, `runtime_approvals`, `sdlc_executions`, `technical_blueprints`, `tenant_intelligence`, `tenants` |
+| `tracker_governance` | `TrackerDbContext` | 22 | `artifact_field_schemas`, `audit_entries`, `core_evaluation_transactions`, `evidence_edges`, `evidence_records`, `exception_requests`, `gate_decisions`, `gate_policies`, `gate_submissions`, `initiatives`, `opportunities`, `phase_artifacts`, `phase_gate_states`, `phase_progressions`, `products`, `provider_connections`, `publication_outbox`, `runtime_approvals`, `sdlc_executions`, `technical_blueprints`, `tenant_intelligence`, `tenants` |
| `tracker_intake` | `TrackerDbContext` | 5 | `intake_dead_letter`, `intake_outbox`, `ppm_endpoints`, `strategic_baselines`, `strategic_initiatives` |
| `tracker_metrics` | `TrackerDbContext` | 1 | `phase_sla_policies` |
| `tracker_qa` | `TrackerDbContext` | 3 | `defects`, `test_cycles`, `test_executions` |
diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/EvidenceEdge.cs b/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/EvidenceEdge.cs
new file mode 100644
index 00000000..a1b16c36
--- /dev/null
+++ b/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/EvidenceEdge.cs
@@ -0,0 +1,133 @@
+namespace Tracker.Domain.Governance.EvidenceGraph;
+
+///
+/// GT-605 — una arista tipada, dirigida y fechada del grafo de evidencia.
+///
+/// Cierra la mitad persistente del gap. Existían dos grafos, cada uno sin la mitad del otro:
+/// el Core declaraba aristas tipadas que nada persistía, y el Tracker persistía
+/// List<string> que nada tipaba — sin tabla de aristas, sin tipo, sin búsqueda inversa
+/// y sin cota de profundidad. La consecuencia era que «qué ADR se movió por causa de qué decisión
+/// de compuerta por causa de qué turno de agente» no tenía respuesta.
+///
+/// OccurredAt forma parte de la IDENTIDAD de la arista, no es metadato: la misma
+/// relación afirmada en dos instantes son dos hechos del ledger y no uno. Es también lo que hace
+/// el registro auditable en vez de meramente navegable — una cadena de evidencia sin tiempo no
+/// puede responder «en el momento de la decisión, qué se sabía».
+///
+public sealed record EvidenceEdge
+{
+ private EvidenceEdge(
+ EvidenceNodeRef from,
+ EvidenceNodeRef to,
+ string type,
+ DateTime occurredAtUtc,
+ string? assertedBy,
+ string? provenance)
+ {
+ From = from;
+ To = to;
+ Type = type;
+ OccurredAtUtc = occurredAtUtc;
+ AssertedBy = assertedBy;
+ Provenance = provenance;
+ }
+
+ public EvidenceNodeRef From { get; }
+
+ public EvidenceNodeRef To { get; }
+
+ public string Type { get; }
+
+ public DateTime OccurredAtUtc { get; }
+
+ /// Quién afirmó la arista. Metadato de la afirmación: NO entra en la identidad.
+ public string? AssertedBy { get; }
+
+ /// Id de correlación o etiqueta de migración. Opaco a la travesía; tampoco entra en la identidad.
+ public string? Provenance { get; }
+
+ public static Result Create(
+ EvidenceNodeRef? from,
+ EvidenceNodeRef? to,
+ string? type,
+ DateTime occurredAtUtc,
+ string? assertedBy = null,
+ string? provenance = null)
+ {
+ if (from is null) return Result.Failure("EvidenceEdge.FromRequired");
+ if (to is null) return Result.Failure("EvidenceEdge.ToRequired");
+
+ if (!EvidenceEdgeType.IsValid(type))
+ return Result.Failure("EvidenceEdge.UnknownType");
+
+ // Un lazo no aporta ninguna travesía y sí un ciclo trivial en cada consulta.
+ if (from.Kind == to.Kind && from.Id == to.Id)
+ return Result.Failure("EvidenceEdge.SelfLoop");
+
+ if (occurredAtUtc == default)
+ return Result.Failure("EvidenceEdge.OccurredAtRequired");
+
+ return Result.Success(new EvidenceEdge(
+ from,
+ to,
+ type!,
+ DateTime.SpecifyKind(occurredAtUtc, DateTimeKind.Utc),
+ string.IsNullOrWhiteSpace(assertedBy) ? null : assertedBy.Trim(),
+ string.IsNullOrWhiteSpace(provenance) ? null : provenance.Trim()));
+ }
+
+ public static EvidenceEdge Rehydrate(
+ EvidenceNodeRef from,
+ EvidenceNodeRef to,
+ string type,
+ DateTime occurredAtUtc,
+ string? assertedBy,
+ string? provenance)
+ => new(from, to, type, occurredAtUtc, assertedBy, provenance);
+
+ ///
+ /// Identidad estable, espejo de evidenceEdgeKey() del contrato. Excluye
+ /// y —metadatos sobre la afirmación— e
+ /// incluye . Es lo que hace idempotente re-afirmar una arista.
+ ///
+ public string IdentityKey() => string.Join(
+ '|',
+ From.ToCanonicalString(),
+ Type,
+ To.ToCanonicalString(),
+ OccurredAtUtc.ToString("O", System.Globalization.CultureInfo.InvariantCulture));
+
+ ///
+ /// Invierte la arista intercambiando los extremos. El TIPO se conserva: sólo cambia la
+ /// dirección de recorrido, no lo que la arista significa.
+ ///
+ public EvidenceEdge Inverted() =>
+ new(To, From, Type, OccurredAtUtc, AssertedBy, Provenance);
+
+ ///
+ /// GT-605 — backfill mecánico de una referencia heredada de EvidenceRecordProps.References.
+ ///
+ /// Cada referencia se lee como «este registro de evidencia VALIDA la cosa referenciada»,
+ /// que es lo que la columna ha significado siempre en la práctica y la única lectura que no
+ /// inventa información.
+ ///
+ /// Devuelve null cuando la referencia no es canónica. Eso NO es un fallo: son ids
+ /// externos opacos, no son aristas, y la migración debe dejarlos en References. Por eso
+ /// la columna se conserva como proyección durante una release — borrarla antes perdería los
+ /// ids externos.
+ ///
+ public static EvidenceEdge? FromLegacyReference(
+ EvidenceNodeRef subject,
+ string? reference,
+ DateTime occurredAtUtc,
+ string provenance = "backfill:ReferencesJson")
+ {
+ var target = EvidenceNodeRef.Parse(reference);
+ if (target is null) return null;
+
+ var created = Create(
+ subject, target, EvidenceEdgeType.Validates, occurredAtUtc, "evolith_tracker", provenance);
+
+ return created.IsSuccess ? created.Value : null;
+ }
+}
diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/EvidenceEdgeValues.cs b/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/EvidenceEdgeValues.cs
new file mode 100644
index 00000000..4fe23c53
--- /dev/null
+++ b/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/EvidenceEdgeValues.cs
@@ -0,0 +1,95 @@
+namespace Tracker.Domain.Governance.EvidenceGraph;
+
+///
+/// GT-605 — el vocabulario de nodos y aristas del grafo de evidencia.
+///
+/// Esto es un ESPEJO de evidence-edge.schema.json, el schema que el Core publica en
+/// su machine-contract set y que este satélite pinea en
+/// contracts/evolith-core-contracts.json (id evidence-edge, sha256
+/// c73565c3…). Es un espejo y no un import porque el Tracker es .NET y por
+/// ADR T-038 no puede consumir @beyondnet/evolith-contracts.
+///
+/// Lo que impide que el espejo derive NO es la disciplina de quien lo edita: es la cadena
+/// pin → verify-contract-pins. Si el Core cambia el vocabulario, cambia el sha256 del
+/// fichero, el guardián `conform` de este repositorio se pone rojo y nadie puede fusionar hasta
+/// que ambos lados se muevan juntos. Los tests de EvidenceEdgeVocabularyTests afirman esta
+/// lista contra la del schema para que el fallo señale la línea exacta.
+///
+public static class EvidenceNodeKind
+{
+ public const string Adr = "adr";
+ public const string AgentTurn = "agent-turn";
+ public const string Artifact = "artifact";
+ public const string Evaluation = "evaluation";
+ public const string EvidenceRecord = "evidence-record";
+ public const string GateDecision = "gate-decision";
+ public const string Initiative = "initiative";
+ public const string RulesetRule = "ruleset-rule";
+
+ ///
+ /// En el MISMO orden que el `enum` del schema. El orden importa: el test de conformidad lo
+ /// compara posición a posición, que es lo que convierte «añadí un tipo y olvidé el otro lado»
+ /// en un fallo en vez de en una divergencia silenciosa.
+ ///
+ public static readonly IReadOnlyList All =
+ [
+ Adr, AgentTurn, Artifact, Evaluation, EvidenceRecord, GateDecision, Initiative, RulesetRule,
+ ];
+
+ ///
+ /// CERRADO a propósito. Un `kind` abierto es exactamente cómo la columna `References` del
+ /// Tracker acabó sin tipar: cualquier cadena entraba, así que nadie podía consultarla.
+ ///
+ public static bool IsValid(string? kind) => kind is not null && All.Contains(kind);
+}
+
+///
+/// GT-605 — los tipos de arista. Espejo del mismo schema.
+///
+public static class EvidenceEdgeType
+{
+ /// `{from}` no puede considerarse completo sin `{to}`.
+ public const string Requires = "requires";
+
+ /// `{from}` es evidencia de que `{to}` se cumple.
+ public const string Validates = "validates";
+
+ /// `{from}` impide que `{to}` avance.
+ public const string Blocks = "blocks";
+
+ ///
+ /// `{from}` cambió a causa de `{to}`.
+ ///
+ /// Es la única adición del contrato publicado sobre las tres nativas del Core, y no es
+ /// decoración: el criterio de aceptación de GT-605 es la frase «qué ADR se movió POR CAUSA DE
+ /// qué decisión de compuerta POR CAUSA DE qué turno de agente», y ninguna de las otras tres
+ /// expresa causación. Sin ella la travesía que se reclama como la mitad fuerte del foso no se
+ /// puede escribir.
+ ///
+ public const string CausedBy = "caused_by";
+
+ /// Las tres nativas del Core, primero y en su orden — adoptar el contrato es un ensanchamiento.
+ public static readonly IReadOnlyList CoreNative = [Requires, Validates, Blocks];
+
+ /// El vocabulario publicado, en el MISMO orden que el `enum` del schema.
+ public static readonly IReadOnlyList All = [Requires, Validates, Blocks, CausedBy];
+
+ public static bool IsValid(string? type) => type is not null && All.Contains(type);
+}
+
+///
+/// GT-605 — dirección de recorrido de la travesía.
+///
+public static class EvidenceTraversalDirection
+{
+ public const string Outgoing = "outgoing";
+
+ /// La búsqueda inversa que la columna jsonb `List<string>` no puede servir a ningún coste.
+ public const string Incoming = "incoming";
+
+ public const string Both = "both";
+
+ public static readonly IReadOnlyList All = [Outgoing, Incoming, Both];
+
+ public static bool IsValid(string? value) => value is not null && All.Contains(value);
+}
diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/EvidenceGraphTraversal.cs b/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/EvidenceGraphTraversal.cs
new file mode 100644
index 00000000..4705f6c1
--- /dev/null
+++ b/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/EvidenceGraphTraversal.cs
@@ -0,0 +1,87 @@
+namespace Tracker.Domain.Governance.EvidenceGraph;
+
+/// Un nodo alcanzado por la travesía, con cómo se llegó a él.
+public sealed record EvidenceTraversalHit(
+ EvidenceNodeRef Node,
+ int Depth,
+ IReadOnlyList Path);
+
+///
+/// GT-605 — travesía en anchura acotada en profundidad.
+///
+/// Es el espejo en C# de traverseEvidenceGraph() del contrato publicado, y existe
+/// para una razón concreta: el endpoint sirve la consulta con SQL recursivo, y dos
+/// implementaciones de una consulta de grafo sólo se mantienen iguales si una de ellas puede
+/// ejecutarse sobre el mismo conjunto de aristas y compararse. Esta es esa otra. Los tests
+/// resuelven el mismo sub-grafo por ambos caminos.
+///
+/// La cota NO es una optimización, es la corrección: el ledger es append-only y una travesía
+/// sin límite es trabajo sin límite sobre una tabla que sólo crece.
+///
+/// Garantías, las mismas que el contrato: anchura primero —así la primera vez que se alcanza
+/// un nodo es por un camino mínimo—, cada nodo aparece a lo sumo una vez, y los ciclos terminan.
+///
+public static class EvidenceGraphTraversal
+{
+ /// Profundidad por defecto del endpoint.
+ public const int DefaultDepth = 2;
+
+ ///
+ /// Techo duro. Un cliente que pida más no recibe un error: recibe esto, porque el límite
+ /// protege a la base de datos y no al llamante.
+ ///
+ public const int MaxDepth = 5;
+
+ public static IReadOnlyList Traverse(
+ IReadOnlyCollection edges,
+ EvidenceNodeRef start,
+ int maxDepth,
+ string direction = EvidenceTraversalDirection.Outgoing,
+ IReadOnlyCollection? types = null)
+ {
+ var depth = Math.Clamp(maxDepth, 0, MaxDepth);
+ var allowed = types is { Count: > 0 } ? new HashSet(types) : new HashSet(EvidenceEdgeType.All);
+
+ var seen = new HashSet { start.ToCanonicalString() };
+ var hits = new List { new(start, 0, []) };
+ var frontier = new List(hits);
+
+ for (var d = 1; d <= depth && frontier.Count > 0; d++)
+ {
+ var next = new List();
+
+ foreach (var current in frontier)
+ {
+ foreach (var edge in edges)
+ {
+ if (!allowed.Contains(edge.Type)) continue;
+
+ EvidenceNodeRef? target = null;
+ if (direction != EvidenceTraversalDirection.Incoming && Same(edge.From, current.Node))
+ {
+ target = edge.To;
+ }
+ else if (direction != EvidenceTraversalDirection.Outgoing && Same(edge.To, current.Node))
+ {
+ target = edge.From;
+ }
+
+ if (target is null) continue;
+
+ var key = target.ToCanonicalString();
+ if (!seen.Add(key)) continue;
+
+ var hit = new EvidenceTraversalHit(target, d, [.. current.Path, edge]);
+ hits.Add(hit);
+ next.Add(hit);
+ }
+ }
+
+ frontier = next;
+ }
+
+ return hits;
+ }
+
+ private static bool Same(EvidenceNodeRef a, EvidenceNodeRef b) => a.Kind == b.Kind && a.Id == b.Id;
+}
diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/EvidenceNodeRef.cs b/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/EvidenceNodeRef.cs
new file mode 100644
index 00000000..739469a6
--- /dev/null
+++ b/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/EvidenceNodeRef.cs
@@ -0,0 +1,66 @@
+namespace Tracker.Domain.Governance.EvidenceGraph;
+
+///
+/// GT-605 — un nodo del grafo de evidencia: una clase cerrada más un id único dentro de ella.
+///
+/// La forma canónica en cadena es evidence://<kind>/<id>, y no es
+/// decoración: es lo que hace que la columna References (List<string> en
+/// jsonb) sea migrable mecánicamente en vez de una reescritura. Una referencia ya almacenada o
+/// bien parsea como una de éstas —y entonces es un nodo tipado— o no parsea —y entonces es un id
+/// externo opaco de los que Contains() usa para deduplicar—. No hay que adivinar nada.
+///
+public sealed record EvidenceNodeRef
+{
+ /// Esquema de la forma canónica, espejo de EVIDENCE_REF_SCHEME.
+ public const string Scheme = "evidence";
+
+ private EvidenceNodeRef(string kind, string id)
+ {
+ Kind = kind;
+ Id = id;
+ }
+
+ public string Kind { get; }
+
+ public string Id { get; }
+
+ public static Result Create(string? kind, string? id)
+ {
+ if (!EvidenceNodeKind.IsValid(kind))
+ return Result.Failure("EvidenceEdge.Node.UnknownKind");
+
+ if (string.IsNullOrWhiteSpace(id))
+ return Result.Failure("EvidenceEdge.Node.IdRequired");
+
+ return Result.Success(new EvidenceNodeRef(kind!, id.Trim()));
+ }
+
+ /// Rehidrata desde persistencia sin revalidar: la fila ya pasó por .
+ public static EvidenceNodeRef Rehydrate(string kind, string id) => new(kind, id);
+
+ /// Forma canónica evidence://<kind>/<id>.
+ public string ToCanonicalString() => $"{Scheme}://{Kind}/{Id}";
+
+ ///
+ /// Inversa de . Devuelve null para cualquier cadena que
+ /// no sea canónica — que es la respuesta correcta y no un error: las referencias heredadas que
+ /// no parsean son ids externos opacos, NO son aristas, y deben quedarse donde están.
+ ///
+ public static EvidenceNodeRef? Parse(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value)) return null;
+
+ const string prefix = $"{Scheme}://";
+ if (!value.StartsWith(prefix, StringComparison.Ordinal)) return null;
+
+ var rest = value[prefix.Length..];
+ var slash = rest.IndexOf('/');
+ if (slash <= 0) return null;
+
+ var kind = rest[..slash];
+ var id = rest[(slash + 1)..];
+ if (!EvidenceNodeKind.IsValid(kind) || id.Length == 0) return null;
+
+ return new EvidenceNodeRef(kind, id);
+ }
+}
diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/Repositories.cs b/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/Repositories.cs
new file mode 100644
index 00000000..49d8966b
--- /dev/null
+++ b/src/apps/tracker-api/Tracker.Domain/Governance/EvidenceGraph/Repositories.cs
@@ -0,0 +1,25 @@
+namespace Tracker.Domain.Governance.EvidenceGraph;
+
+public interface IEvidenceEdgeRepository
+{
+ Task AddAsync(Guid tenantId, EvidenceEdge edge, CancellationToken ct = default);
+
+ ///
+ /// GT-605 — carga el sub-grafo alcanzable desde hasta
+ /// saltos.
+ ///
+ /// Devuelve ARISTAS y no nodos a propósito: quien llama las pasa por
+ /// , de modo que la respuesta del endpoint la
+ /// produce la MISMA función que los tests comparan contra el contrato publicado. Si el
+ /// repositorio devolviera nodos ya recorridos, habría dos semánticas de travesía —una en SQL y
+ /// otra en memoria— y nada que garantice que coinciden, que es exactamente el fallo que este
+ /// gap cierra.
+ ///
+ Task> LoadSubgraphAsync(
+ Guid tenantId,
+ EvidenceNodeRef start,
+ int maxDepth,
+ string direction = EvidenceTraversalDirection.Outgoing,
+ IReadOnlyCollection? types = null,
+ CancellationToken ct = default);
+}
diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/Transparency/CanonicalJson.cs b/src/apps/tracker-api/Tracker.Domain/Governance/Transparency/CanonicalJson.cs
new file mode 100644
index 00000000..cb538311
--- /dev/null
+++ b/src/apps/tracker-api/Tracker.Domain/Governance/Transparency/CanonicalJson.cs
@@ -0,0 +1,89 @@
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace Tracker.Domain.Governance.Transparency;
+
+///
+/// GT-588 — serialización JSON determinista: los bytes sobre los que se firma.
+///
+/// Una firma solo significa algo si firmante y verificador coinciden BYTE A BYTE en qué se
+/// firmó. El serializador por defecto no sirve: conserva el orden de inserción, así que la misma
+/// decisión leída de vuelta del ledger puede serializar distinto y romper la verificación por una
+/// razón que nada tiene que ver con manipulación.
+///
+/// Es el espejo en C# de canonicalJson() del Core (domain/transparency/codec.ts),
+/// y aquí «espejo» es literal: el CLI que verifica está escrito en TypeScript y recomputa estos
+/// mismos bytes. Una diferencia de una coma hace que ningún recibo verifique. Por eso el test de
+/// interoperabilidad no compara estructuras sino la salida del `evolith audit verify` real.
+///
+/// Reglas, idénticas a las suyas: claves ordenadas en TODA profundidad, y los miembros nulos
+/// se omiten —que es lo que hace su undefined—. Los nulos EXPLÍCITOS dentro del payload sí
+/// se conservan, igual que allí.
+///
+public static class CanonicalJson
+{
+ private static readonly JsonWriterOptions WriterOptions = new()
+ {
+ Indented = false,
+ // El escapado por defecto de .NET es más agresivo que el de JSON.stringify (escapa `+`,
+ // `<`, etc. como \uXXXX). Se relaja al conjunto estricto de JSON para que las mismas
+ // cadenas produzcan los mismos bytes en ambos lenguajes.
+ Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
+ };
+
+ /// Serializa con claves ordenadas y sin miembros nulos.
+ public static string Serialize(object? value)
+ {
+ var node = JsonSerializer.SerializeToNode(value, new JsonSerializerOptions
+ {
+ DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
+ });
+
+ using var stream = new MemoryStream();
+ using (var writer = new Utf8JsonWriter(stream, WriterOptions))
+ {
+ Write(node, writer);
+ }
+
+ return Encoding.UTF8.GetString(stream.ToArray());
+ }
+
+ /// Los bytes que se firman.
+ public static byte[] SerializeToUtf8(object? value) => Encoding.UTF8.GetBytes(Serialize(value));
+
+ private static void Write(JsonNode? node, Utf8JsonWriter writer)
+ {
+ switch (node)
+ {
+ case null:
+ writer.WriteNullValue();
+ break;
+
+ case JsonObject obj:
+ writer.WriteStartObject();
+ // `StringComparer.Ordinal` y no el cultural: el orden lo fija el punto de código,
+ // igual que `Array.prototype.sort()` sobre cadenas. Una comparación sensible a la
+ // cultura ordenaria distinto en una maquina con otro locale, y el ledger firmado
+ // en Lima no verificaria en Madrid.
+ foreach (var pair in obj.OrderBy(p => p.Key, StringComparer.Ordinal))
+ {
+ if (pair.Value is null) continue;
+ writer.WritePropertyName(pair.Key);
+ Write(pair.Value, writer);
+ }
+ writer.WriteEndObject();
+ break;
+
+ case JsonArray array:
+ writer.WriteStartArray();
+ foreach (var item in array) Write(item, writer);
+ writer.WriteEndArray();
+ break;
+
+ default:
+ node.WriteTo(writer, new JsonSerializerOptions { Encoder = WriterOptions.Encoder });
+ break;
+ }
+ }
+}
diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/Transparency/MerkleTree.cs b/src/apps/tracker-api/Tracker.Domain/Governance/Transparency/MerkleTree.cs
new file mode 100644
index 00000000..5689e020
--- /dev/null
+++ b/src/apps/tracker-api/Tracker.Domain/Governance/Transparency/MerkleTree.cs
@@ -0,0 +1,113 @@
+using System.Security.Cryptography;
+
+namespace Tracker.Domain.Governance.Transparency;
+
+///
+/// GT-588 — el árbol de Merkle binario SHA-256 de RFC 9162, que es la estructura de datos
+/// verificable a la que RFC 9942 delega las pruebas de inclusión.
+///
+/// Es lo que convierte el ledger de «una lista de líneas firmadas» en algo append-only
+/// comprobable: borrar o reordenar una entrada cambia la raíz, y la raíz va firmada en cada
+/// recibo. Sin él, cada línea probaría solo que alguien firmó ESA línea, y arrancar una página
+/// entera del libro no se notaría.
+///
+/// Espejo de domain/transparency/merkle.ts. Los prefijos de dominio (0x00 para hoja,
+/// 0x01 para nodo) no son decoración: sin ellos, un atacante podría presentar el hash de un nodo
+/// interno como si fuera una hoja.
+///
+public static class MerkleTree
+{
+ private static readonly byte[] LeafPrefix = [0x00];
+ private static readonly byte[] NodePrefix = [0x01];
+
+ /// RFC 9162 §2.1.1 — hash de hoja: SHA256(0x00 || entry).
+ public static byte[] LeafHash(byte[] entry) => Sha256([.. LeafPrefix, .. entry]);
+
+ /// RFC 9162 §2.1.1 — hash de nodo interno: SHA256(0x01 || left || right).
+ public static byte[] NodeHash(byte[] left, byte[] right) => Sha256([.. NodePrefix, .. left, .. right]);
+
+ ///
+ /// RFC 9162 §2.1.1 — Merkle Tree Hash sobre las ENTRADAS (no sobre hashes de hoja: el prefijo
+ /// se aplica aquí dentro).
+ ///
+ public static byte[] TreeHead(IReadOnlyList entries)
+ {
+ if (entries.Count == 0) return Sha256([]);
+ if (entries.Count == 1) return LeafHash(entries[0]);
+
+ var k = LargestPowerOfTwoBelow(entries.Count);
+ return NodeHash(
+ TreeHead([.. entries.Take(k)]),
+ TreeHead([.. entries.Skip(k)]));
+ }
+
+ ///
+ /// RFC 9162 §2.1.3.1 — el camino de auditoría (prueba de inclusión) de la entrada en
+ /// .
+ ///
+ public static IReadOnlyList InclusionPath(IReadOnlyList entries, int index)
+ {
+ if (index < 0 || index >= entries.Count)
+ {
+ throw new ArgumentOutOfRangeException(nameof(index), "merkle: index outside the tree");
+ }
+
+ if (entries.Count == 1) return [];
+
+ var k = LargestPowerOfTwoBelow(entries.Count);
+ List left = [.. entries.Take(k)];
+ List right = [.. entries.Skip(k)];
+
+ return index < k
+ ? [.. InclusionPath(left, index), TreeHead(right)]
+ : [.. InclusionPath(right, index - k), TreeHead(left)];
+ }
+
+ ///
+ /// Recomputa la raíz desde una hoja y su camino. Es la operación que hace el verificador, y
+ /// está aquí para que los tests puedan comprobar la prueba sin salir del dominio.
+ ///
+ public static byte[] RootFromPath(byte[] entry, int index, int treeSize, IReadOnlyList path)
+ {
+ var hash = LeafHash(entry);
+ var fn = index;
+ var sn = treeSize - 1;
+
+ foreach (var sibling in path)
+ {
+ if (sn == 0) throw new InvalidOperationException("merkle: inclusion path is too long");
+
+ if (fn % 2 == 1 || fn == sn)
+ {
+ hash = NodeHash(sibling, hash);
+ while (fn % 2 == 0 && fn != 0)
+ {
+ fn >>= 1;
+ sn >>= 1;
+ }
+ }
+ else
+ {
+ hash = NodeHash(hash, sibling);
+ }
+
+ fn >>= 1;
+ sn >>= 1;
+ }
+
+ return hash;
+ }
+
+ /// La mayor potencia de dos estrictamente menor que (la `k` de RFC 9162).
+ private static int LargestPowerOfTwoBelow(int n)
+ {
+ var k = 1;
+ while (k * 2 < n) k *= 2;
+ return k;
+ }
+
+ private static byte[] Sha256(byte[] data) => System.Security.Cryptography.SHA256.HashData(data);
+
+ /// Hex en minúsculas, la codificación que el ledger usa para raíces y digests.
+ public static string ToHex(byte[] bytes) => Convert.ToHexString(bytes).ToLowerInvariant();
+}
diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/Transparency/TransparencyModel.cs b/src/apps/tracker-api/Tracker.Domain/Governance/Transparency/TransparencyModel.cs
new file mode 100644
index 00000000..689c473f
--- /dev/null
+++ b/src/apps/tracker-api/Tracker.Domain/Governance/Transparency/TransparencyModel.cs
@@ -0,0 +1,114 @@
+using System.Text.Json.Serialization;
+
+namespace Tracker.Domain.Governance.Transparency;
+
+///
+/// GT-588 — las formas canónicas de una decisión firmada y su recibo.
+///
+/// Espejo de domain/transparency/transparency-statement.ts del Core. Los nombres de
+/// propiedad JSON son los suyos, en camelCase, porque el consumidor es el CLI `evolith audit
+/// verify`: renombrar cualquiera de ellos hace que el ledger no se pueda leer.
+///
+public sealed record DecisionStatement
+{
+ [JsonPropertyName("statementId")] public string StatementId { get; init; } = string.Empty;
+
+ /// RFC 9943 `sub` — sobre qué es la decisión (id de compuerta, sujeto evaluado…).
+ [JsonPropertyName("subject")] public string Subject { get; init; } = string.Empty;
+
+ [JsonPropertyName("eventType")] public string EventType { get; init; } = string.Empty;
+
+ [JsonPropertyName("verdict")] public string? Verdict { get; init; }
+
+ [JsonPropertyName("occurredAt")] public string OccurredAt { get; init; } = string.Empty;
+
+ [JsonPropertyName("tenantId")] public string? TenantId { get; init; }
+
+ [JsonPropertyName("actor")] public string? Actor { get; init; }
+
+ [JsonPropertyName("payload")] public object? Payload { get; init; }
+}
+
+///
+/// Cuán fiable es la identidad detrás de una firma.
+///
+/// Es el campo que hace imposible esconder la pregunta de la custodia de claves: un firmante
+/// de desarrollo no puede describirse como otra cosa que development, y la regla de
+/// gobernanza lee ESTO, no un comentario.
+///
+public static class IdentityAssurance
+{
+ /// Una clave efímera que el propio proceso se acuñó. Nunca es identidad de producción.
+ public const string Development = "development";
+
+ /// Una clave que aporta el despliegue (KMS, HSM, fichero) y cuya custodia es externa.
+ public const string ExternalCustody = "external-custody";
+}
+
+public sealed record SigningIdentity
+{
+ [JsonPropertyName("keyId")] public string KeyId { get; init; } = string.Empty;
+ [JsonPropertyName("issuer")] public string Issuer { get; init; } = string.Empty;
+ [JsonPropertyName("assurance")] public string Assurance { get; init; } = IdentityAssurance.Development;
+
+ /// Identificador de algoritmo COSE (−8 = EdDSA).
+ [JsonPropertyName("algorithm")] public int Algorithm { get; init; } = -8;
+
+ ///
+ /// SPKI en base64 de la clave pública, cuando el ledger LLEVA su propia clave de verificación.
+ ///
+ /// Conviene ser franco sobre lo que vale: una clave que un fichero afirma sobre sí mismo
+ /// demuestra que el fichero es autoconsistente y NADA sobre quién firmó — quien pueda reescribir
+ /// el ledger puede reescribir este campo y volver a firmar cada línea. Es una comodidad para
+ /// desarrollo y para detectar corrupción accidental. La verificación real exige un ancla de
+ /// confianza entregada FUERA DE BANDA, que es lo que `audit verify --trust-anchors` recibe.
+ ///
+ [JsonPropertyName("publicKeySpki")] public string? PublicKeySpki { get; init; }
+}
+
+/// RFC 9943 — un Signed Statement (COSE_Sign1 sobre los bytes de la decisión).
+public sealed record SignedStatement
+{
+ [JsonPropertyName("statementId")] public string StatementId { get; init; } = string.Empty;
+ [JsonPropertyName("issuer")] public string Issuer { get; init; } = string.Empty;
+ [JsonPropertyName("subject")] public string Subject { get; init; } = string.Empty;
+
+ /// Bytes COSE_Sign1 en base64.
+ [JsonPropertyName("cose")] public string Cose { get; init; } = string.Empty;
+
+ /// SHA-256 hex de los bytes COSE_Sign1 — la entrada de log que este statement aporta.
+ [JsonPropertyName("statementHash")] public string StatementHash { get; init; } = string.Empty;
+
+ [JsonPropertyName("identity")] public SigningIdentity Identity { get; init; } = new();
+}
+
+///
+/// RFC 9942 — un Recibo: COSE_Sign1 con payload DESACOPLADO (la cabeza del árbol), `vds` (395) = 1
+/// en la cabecera protegida y una prueba de inclusión bajo `vdp` (396) en la no protegida.
+///
+public sealed record Receipt
+{
+ [JsonPropertyName("statementId")] public string StatementId { get; init; } = string.Empty;
+ [JsonPropertyName("leafIndex")] public int LeafIndex { get; init; }
+ [JsonPropertyName("treeSize")] public int TreeSize { get; init; }
+ [JsonPropertyName("treeRoot")] public string TreeRoot { get; init; } = string.Empty;
+ [JsonPropertyName("cose")] public string Cose { get; init; } = string.Empty;
+ [JsonPropertyName("identity")] public SigningIdentity Identity { get; init; } = new();
+}
+
+/// RFC 9943 — un Signed Statement acompañado de su Recibo.
+public sealed record TransparentStatement
+{
+ [JsonPropertyName("statement")] public SignedStatement Statement { get; init; } = new();
+ [JsonPropertyName("receipt")] public Receipt Receipt { get; init; } = new();
+}
+
+///
+/// Una línea del ledger append-only: la decisión legible, más el statement firmado y el recibo que
+/// la hacen comprobable por alguien que no estuvo.
+///
+public sealed record TransparencyLedgerEntry
+{
+ [JsonPropertyName("decision")] public DecisionStatement Decision { get; init; } = new();
+ [JsonPropertyName("transparentStatement")] public TransparentStatement TransparentStatement { get; init; } = new();
+}
diff --git a/src/apps/tracker-api/Tracker.Infrastructure/DependencyInjection.cs b/src/apps/tracker-api/Tracker.Infrastructure/DependencyInjection.cs
index 75212ade..a113183a 100644
--- a/src/apps/tracker-api/Tracker.Infrastructure/DependencyInjection.cs
+++ b/src/apps/tracker-api/Tracker.Infrastructure/DependencyInjection.cs
@@ -1,4 +1,6 @@
using System.Reflection;
+using Tracker.Domain.Governance.EvidenceGraph;
+using Tracker.Infrastructure.Transparency;
using System.Threading.Channels;
using MassTransit;
using MediatR;
@@ -67,6 +69,7 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
// CD-30 — deriva en servidor que criterios incumple una compuerta, cruzando la ejecucion
@@ -95,6 +98,7 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi
services.AddScoped();
services.AddScoped();
+ services.AddTransparencySigning(configuration);
services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260801164354_AddEvidenceEdges.Designer.cs b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260801164354_AddEvidenceEdges.Designer.cs
new file mode 100644
index 00000000..e4a6e5d8
--- /dev/null
+++ b/src/apps/tracker-api/Tracker.Infrastructure/Migrations/20260801164354_AddEvidenceEdges.Designer.cs
@@ -0,0 +1,3927 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Tracker.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace Tracker.Infrastructure.Migrations
+{
+ [DbContext(typeof(TrackerDbContext))]
+ [Migration("20260801164354_AddEvidenceEdges")]
+ partial class AddEvidenceEdges
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("tracker_governance")
+ .HasAnnotation("ProductVersion", "10.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "ltree");
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Tracker.Infrastructure.Persistence.Artifacts.Entities.EvidenceRecordRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AdapterVersion")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text")
+ .HasDefaultValue("")
+ .HasColumnName("adapter_version");
+
+ b.Property("ArtifactId")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("artifact_id");
+
+ b.Property("ArtifactType")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("artifact_type");
+
+ b.Property("Classification")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text")
+ .HasDefaultValue("")
+ .HasColumnName("classification");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("content");
+
+ b.Property("ContentHash")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text")
+ .HasDefaultValue("")
+ .HasColumnName("content_hash");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("created_by");
+
+ b.Property("DeletedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at");
+
+ b.Property("DeletedBy")
+ .HasColumnType("text")
+ .HasColumnName("deleted_by");
+
+ b.Property("Determinism")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text")
+ .HasDefaultValue("unspecified")
+ .HasColumnName("determinism");
+
+ b.Property("Dimension")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text")
+ .HasDefaultValue("unspecified")
+ .HasColumnName("dimension");
+
+ b.Property("EvidenceType")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("evidence_type");
+
+ b.Property("Immutable")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("immutable");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean")
+ .HasColumnName("is_deleted");
+
+ b.Property("MetadataJson")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("metadata");
+
+ b.Property("Producer")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text")
+ .HasDefaultValue("")
+ .HasColumnName("producer");
+
+ b.Property("ReferencesJson")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasDefaultValue("[]")
+ .HasColumnName("references");
+
+ b.Property("RetentionPolicyRef")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text")
+ .HasDefaultValue("")
+ .HasColumnName("retention_policy_ref");
+
+ b.Property("SourceProvider")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text")
+ .HasDefaultValue("")
+ .HasColumnName("source_provider");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("status");
+
+ b.Property("SubmittedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("submitted_at");
+
+ b.Property("SubmittedBy")
+ .HasColumnType("uuid")
+ .HasColumnName("submitted_by");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("UpdatedBy")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("updated_by");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Classification");
+
+ b.HasIndex("Dimension");
+
+ b.HasIndex("TenantId");
+
+ b.HasIndex("ArtifactType", "ArtifactId");
+
+ b.ToTable("evidence_records", "tracker_governance");
+ });
+
+ modelBuilder.Entity("Tracker.Infrastructure.Persistence.Audit.Entities.AuditEntryRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Action")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("action");
+
+ b.Property("ActorId")
+ .HasColumnType("uuid")
+ .HasColumnName("actor_id");
+
+ b.Property("ActorType")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("actor_type");
+
+ b.Property("AgentId")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("agent_id");
+
+ b.Property("ChangesJson")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("changes");
+
+ b.Property("CorrelationId")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("correlation_id");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("created_by");
+
+ b.Property("DeletedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at");
+
+ b.Property("DeletedBy")
+ .HasColumnType("text")
+ .HasColumnName("deleted_by");
+
+ b.Property("EntityId")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("entity_id");
+
+ b.Property("EntityType")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("entity_type");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean")
+ .HasColumnName("is_deleted");
+
+ b.Property("ModelId")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("model_id");
+
+ b.Property("SessionId")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("session_id");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("TimestampUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("timestamp");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("UpdatedBy")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("updated_by");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ActorId");
+
+ b.HasIndex("ActorType");
+
+ b.HasIndex("TenantId");
+
+ b.HasIndex("EntityType", "EntityId");
+
+ b.ToTable("audit_entries", "tracker_governance");
+ });
+
+ modelBuilder.Entity("Tracker.Infrastructure.Persistence.Design.Entities.TechnicalBlueprintRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("created_by");
+
+ b.Property("DeletedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at");
+
+ b.Property("DeletedBy")
+ .HasColumnType("text")
+ .HasColumnName("deleted_by");
+
+ b.Property("FacetsJson")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("facets");
+
+ b.Property("InitiativeId")
+ .HasColumnType("uuid")
+ .HasColumnName("initiative_id");
+
+ b.Property("InitiativeType")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("initiative_type");
+
+ b.Property("IntegrationAdrRef")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasColumnName("integration_adr_ref");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean")
+ .HasColumnName("is_deleted");
+
+ b.Property("Notes")
+ .HasColumnType("text")
+ .HasColumnName("notes");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)")
+ .HasColumnName("status");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("UpdatedBy")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("updated_by");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InitiativeId")
+ .IsUnique();
+
+ b.HasIndex("TenantId");
+
+ b.ToTable("technical_blueprints", "tracker_governance");
+ });
+
+ modelBuilder.Entity("Tracker.Infrastructure.Persistence.Discovery.Entities.InitiativeRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ApprovalComplete")
+ .HasColumnType("boolean")
+ .HasColumnName("approval_complete");
+
+ b.Property("ApprovedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("approved_at");
+
+ b.Property("ApprovedBy")
+ .HasColumnType("uuid")
+ .HasColumnName("approved_by");
+
+ b.Property("BusinessCaseJson")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("business_case");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("code");
+
+ b.Property("ConstituentsJson")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("constituents");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("created_by");
+
+ b.Property("DeletedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at");
+
+ b.Property("DeletedBy")
+ .HasColumnType("text")
+ .HasColumnName("deleted_by");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("FacetsJson")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("facets");
+
+ b.Property("FunctionalBriefJson")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("functional_brief");
+
+ b.Property("InitiativeType")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("initiative_type");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean")
+ .HasColumnName("is_deleted");
+
+ b.Property("MetadataJson")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("metadata");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("name");
+
+ b.Property("Origin")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("origin");
+
+ b.Property("OriginRef")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("origin_ref");
+
+ b.Property("ProductScopeJson")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("product_scope");
+
+ b.Property("RejectionReason")
+ .HasColumnType("text")
+ .HasColumnName("rejection_reason");
+
+ b.Property("RequiredApproversJson")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("required_approvers");
+
+ b.Property("StakeholdersJson")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("stakeholders");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("status");
+
+ b.Property("SupersededByInitiativeId")
+ .HasColumnType("uuid")
+ .HasColumnName("superseded_by_initiative_id");
+
+ b.Property("TechnicalBriefJson")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("technical_brief");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("TypeFrozen")
+ .HasColumnType("boolean")
+ .HasColumnName("type_frozen");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("UpdatedBy")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("updated_by");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Status");
+
+ b.HasIndex("TenantId");
+
+ b.HasIndex("TenantId", "Code");
+
+ b.ToTable("initiatives", "tracker_governance", t =>
+ {
+ t.HasCheckConstraint("ck_initiatives_status", "status IN ('draft', 'submitted', 'approved', 'rejected', 'consolidated')");
+ });
+ });
+
+ modelBuilder.Entity("Tracker.Infrastructure.Persistence.Discovery.Entities.OpportunityRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("code");
+
+ b.Property("ConsolidatedInitiativeId")
+ .HasColumnType("uuid")
+ .HasColumnName("consolidated_initiative_id");
+
+ b.Property("ContextJson")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasDefaultValue("{}")
+ .HasColumnName("context");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("created_by");
+
+ b.Property("CustomFieldsJson")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasDefaultValue("{}")
+ .HasColumnName("custom_fields");
+
+ b.Property("DeletedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at");
+
+ b.Property("DeletedBy")
+ .HasColumnType("text")
+ .HasColumnName("deleted_by");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("IsDeleted")
+ .HasColumnType("boolean")
+ .HasColumnName("is_deleted");
+
+ b.Property("MetadataJson")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("metadata");
+
+ b.Property("Priority")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("priority");
+
+ b.Property("PromotedInitiativeId")
+ .HasColumnType("uuid")
+ .HasColumnName("promoted_initiative_id");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("source");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("status");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("title");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("UpdatedBy")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("updated_by");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Status");
+
+ b.HasIndex("TenantId");
+
+ b.HasIndex("TenantId", "Code")
+ .IsUnique();
+
+ b.ToTable("opportunities", "tracker_governance");
+ });
+
+ modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CatalogReleaseRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("code");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("created_by");
+
+ b.Property("PublishedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("published_at");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("source");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("UpdatedBy")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("updated_by");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.ToTable("catalog_release", "tracker_geo");
+ });
+
+ modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CountryRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("created_by");
+
+ b.Property("DefaultCurrencyCode")
+ .HasMaxLength(3)
+ .HasColumnType("character varying(3)")
+ .HasColumnName("default_currency_code");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("IsoAlpha2")
+ .IsRequired()
+ .HasMaxLength(2)
+ .HasColumnType("character(2)")
+ .HasColumnName("iso_alpha2")
+ .IsFixedLength();
+
+ b.Property("IsoAlpha3")
+ .IsRequired()
+ .HasMaxLength(3)
+ .HasColumnType("character(3)")
+ .HasColumnName("iso_alpha3")
+ .IsFixedLength();
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(96)
+ .HasColumnType("character varying(96)")
+ .HasColumnName("name");
+
+ b.Property("NumericCode")
+ .HasMaxLength(3)
+ .HasColumnType("character(3)")
+ .HasColumnName("numeric_code")
+ .IsFixedLength();
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("UpdatedBy")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("updated_by");
+
+ b.HasKey("Id");
+
+ b.HasIndex("IsoAlpha2")
+ .IsUnique();
+
+ b.HasIndex("IsoAlpha3")
+ .IsUnique();
+
+ b.ToTable("country", "tracker_geo");
+ });
+
+ modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.CurrencyRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(3)
+ .HasColumnType("character varying(3)")
+ .HasColumnName("code");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("created_by");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("MinorUnits")
+ .HasColumnType("smallint")
+ .HasColumnName("minor_units");
+
+ b.Property("Symbol")
+ .IsRequired()
+ .HasMaxLength(8)
+ .HasColumnType("character varying(8)")
+ .HasColumnName("symbol");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("UpdatedBy")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("updated_by");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.ToTable("currency", "tracker_geo");
+ });
+
+ modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.ExchangeRateRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("BaseCode")
+ .IsRequired()
+ .HasMaxLength(3)
+ .HasColumnType("character varying(3)")
+ .HasColumnName("base_code");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("created_by");
+
+ b.Property("QuoteCode")
+ .IsRequired()
+ .HasMaxLength(3)
+ .HasColumnType("character varying(3)")
+ .HasColumnName("quote_code");
+
+ b.Property("Rate")
+ .HasColumnType("numeric(20,10)")
+ .HasColumnName("rate");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("source");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("UpdatedBy")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("updated_by");
+
+ b.Property("ValidFrom")
+ .HasColumnType("date")
+ .HasColumnName("valid_from");
+
+ b.Property("ValidTo")
+ .HasColumnType("date")
+ .HasColumnName("valid_to");
+
+ b.HasKey("Id");
+
+ b.HasIndex("BaseCode", "QuoteCode", "ValidFrom")
+ .HasDatabaseName("ix_exchange_rate_pair");
+
+ b.ToTable("exchange_rate", "tracker_geo");
+ });
+
+ modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LanguageRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(8)
+ .HasColumnType("character varying(8)")
+ .HasColumnName("code");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("created_by");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("name");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("UpdatedBy")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("updated_by");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.ToTable("language", "tracker_geo");
+ });
+
+ modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocaleProfileRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)")
+ .HasColumnName("code");
+
+ b.Property("CountryId")
+ .HasColumnType("uuid")
+ .HasColumnName("country_id");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("created_by");
+
+ b.Property("DateFormat")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("date_format");
+
+ b.Property("FirstDayOfWeek")
+ .HasColumnType("smallint")
+ .HasColumnName("first_day_of_week");
+
+ b.Property("Name")
+ .HasMaxLength(96)
+ .HasColumnType("character varying(96)")
+ .HasColumnName("name");
+
+ b.Property("NumberFormat")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("number_format");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("UpdatedBy")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("updated_by");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.HasIndex("CountryId");
+
+ b.ToTable("locale_profile", "tracker_geo");
+ });
+
+ modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationCodeRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("LocationId")
+ .HasColumnType("uuid")
+ .HasColumnName("location_id");
+
+ b.Property("Scheme")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("scheme");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("value");
+
+ b.HasKey("Id");
+
+ b.HasIndex("LocationId", "Scheme")
+ .IsUnique();
+
+ b.HasIndex("Scheme", "Value")
+ .IsUnique();
+
+ b.ToTable("location_code", "tracker_geo");
+ });
+
+ modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationI18nRecord", b =>
+ {
+ b.Property("LocationId")
+ .HasColumnType("uuid")
+ .HasColumnName("location_id");
+
+ b.Property("Locale")
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)")
+ .HasColumnName("locale");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(160)
+ .HasColumnType("character varying(160)")
+ .HasColumnName("name");
+
+ b.HasKey("LocationId", "Locale");
+
+ b.ToTable("location_i18n", "tracker_geo");
+ });
+
+ modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationLevelRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("code");
+
+ b.Property("CountryId")
+ .HasColumnType("uuid")
+ .HasColumnName("country_id");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("created_by");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("name");
+
+ b.Property("Ordinal")
+ .HasColumnType("smallint")
+ .HasColumnName("ordinal");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("UpdatedBy")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("updated_by");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CountryId", "Ordinal")
+ .IsUnique();
+
+ b.ToTable("location_level", "tracker_geo");
+ });
+
+ modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.LocationRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CatalogReleaseId")
+ .HasColumnType("uuid")
+ .HasColumnName("catalog_release_id");
+
+ b.Property("CountryId")
+ .HasColumnType("uuid")
+ .HasColumnName("country_id");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("created_by");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LevelId")
+ .HasColumnType("uuid")
+ .HasColumnName("level_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(160)
+ .HasColumnType("character varying(160)")
+ .HasColumnName("name");
+
+ b.Property("ParentId")
+ .HasColumnType("uuid")
+ .HasColumnName("parent_id");
+
+ b.Property("Path")
+ .IsRequired()
+ .HasColumnType("ltree")
+ .HasColumnName("path");
+
+ b.Property("SupersedesId")
+ .HasColumnType("uuid")
+ .HasColumnName("supersedes_id");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("UpdatedBy")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("updated_by");
+
+ b.Property("ValidFrom")
+ .HasColumnType("date")
+ .HasColumnName("valid_from");
+
+ b.Property("ValidTo")
+ .HasColumnType("date")
+ .HasColumnName("valid_to");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ParentId")
+ .HasDatabaseName("ix_location_parent");
+
+ b.HasIndex("Path")
+ .HasDatabaseName("ix_location_path");
+
+ NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Path"), "gist");
+
+ b.HasIndex("CountryId", "ParentId")
+ .HasDatabaseName("ix_location_country_parent");
+
+ b.ToTable("location", "tracker_geo");
+ });
+
+ modelBuilder.Entity("Tracker.Infrastructure.Persistence.Geo.Entities.TaxRateRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("created_by");
+
+ b.Property("LocationId")
+ .HasColumnType("uuid")
+ .HasColumnName("location_id");
+
+ b.Property("OperationType")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("operation_type");
+
+ b.Property("Rate")
+ .HasColumnType("numeric(6,4)")
+ .HasColumnName("rate");
+
+ b.Property("TaxSchemeId")
+ .HasColumnType("uuid")
+ .HasColumnName("tax_scheme_id");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("UpdatedBy")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("updated_by");
+
+ b.Property("ValidFrom")
+ .HasColumnType("date")
+ .HasColumnName("valid_from");
+
+ b.Property