Data-plane persistence service for the CAFE stack.
Extracted from cafe-discovery as part of PERS-D1 — mechanical scan persistence extraction. Behaviour is identical to cmd/persistence in Discovery today; production deploy is unchanged until PERS-D2.
- Single writer for scan lifecycle events (
scan.started,scan.completed,scan.failed) - Owner DDL for
scan_results,tls_scan_results,scan_usage_events - Writes to Postgres and Redis; publishes
persistence.readyon NATS - Consumes NATS subjects
scan.*(same contract as Discovery persistence)
Non-objectifs (PERS-D1) : pas de module CP, pas d'API HTTP publique, pas de migration DDL identity (users, plans).
go test ./...
go build -o persistence ./cmd/persistence/main.go
docker build -f Dockerfile -t oleglod/cafe-persistence:local .golangci-lint run ./...deadcode sans options ne suit que le binaire production (main) : le module CP (internal/cpstore) et les routes internes apparaissent « morts » tant qu’ils ne sont reliés qu’aux tests (-test) ou aux tests Postgres (-tags=integration).
# Couverture réaliste : tests unitaires + intégration CP
deadcode -test -tags=integration ./...Attendu aujourd’hui : 0 unreachable après PERS-D4b (cpstore + internal/cpapi branchés depuis main).
Environment variables (or config.yaml via CONFIG_PATH):
| Variable | Default |
|---|---|
POSTGRES_HOST |
127.0.0.1 |
POSTGRES_PORT |
5432 |
POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DATABASE |
cafe |
POSTGRES_SSLMODE |
disable |
NATS_URL |
nats://localhost:4222 |
REDIS_URL |
redis://localhost:6379 |
LOG_LEVEL |
info |
PERSISTENCE_HEALTH_PORT |
8081 (HTTP /health, /ready — PERS-D2b) |
PERSISTENCE_INTERNAL_HTTP_PORT |
8082 (internal scan + CP API — PERS-D3a-impl / PERS-D4b) |
CAFE_PERSISTENCE_SERVICE_TOKEN |
(unset — internal API rejects all callers until set) |
config.yaml must include blockchains[].chain_id for wallet observation export (CPM wire contract).
OpenAPI spec and HTTP handlers for service-to-service scan operations (pending, read/list, delete, ledger).
| Artifact | Path |
|---|---|
| OpenAPI | openapi/internal/scan/v1.yaml |
| Route constants | internal/scanroutes/routes.go |
| Contract tests (spec) | internal/contract/scan_v1_openapi_test.go |
| HTTP handlers | internal/scanapi/ |
| Handler contract tests | internal/scanapi/handler_test.go |
Base path: /internal/scan/v1 on PERSISTENCE_INTERNAL_HTTP_PORT (default 8082, distinct from health 8081).
Auth: Authorization: Bearer <CAFE_PERSISTENCE_SERVICE_TOKEN> plus caller-propagated X-User-Id / optional X-Tenant-Id (ADR §9.1). Not exposed on public NGINX edge.
Consumer: cafe-discovery D6a-* milestones map public /api/discovery/v1 to this contract.
OpenAPI spec and HTTP handlers for service-to-service crypto policy storage (drafts, persist, policies, W1/W3 references).
| Artifact | Path |
|---|---|
| OpenAPI | openapi/internal/cp/v1.yaml |
| Route constants | internal/cproutes/routes.go |
| Contract tests (spec) | internal/contract/cp_v1_openapi_test.go |
| HTTP handlers | internal/cpapi/ |
| Handler contract tests | internal/cpapi/handler_test.go |
Base path: /internal/cp/v1 on PERSISTENCE_INTERNAL_HTTP_PORT (default 8082, same listener as scan).
Auth: same as scan — Authorization: Bearer <CAFE_PERSISTENCE_SERVICE_TOKEN> plus X-User-Id / optional X-Tenant-Id (ADR §9.2). Not exposed on public NGINX edge.
Semantic ownership: CPM §8.2 (payload, statuses, persist-once). CPM review: cafe-crypto-policy-mgt/docs/PERS_D3B_SPEC_REVIEW.md.
Consumers: cafe-crypto-policy-mgt D5a+ (CPM_STORE=persistence); cafe-discovery D6b (existence-only refs).
Public /api/cpm/v1 unchanged.
Owner-scoped crypto policy tables and writers; HTTP handlers in internal/cpapi (PERS-D4b).
Voir Schéma Postgres : rôle des migrations et des golden files pour le pourquoi des migrations malgré l’absence de prod.
| Artifact | Path |
|---|---|
| Domain entities | internal/domain/crypto_policy.go |
| DDL migrations | internal/cpddl/migrate.go |
| Postgres store | internal/cpstore/ |
| DDL golden | testdata/ddl/cp_indexes.golden |
Tables: crypto_policy_drafts, crypto_policies, draft_persist_state (ADR §8.4).
Applied at boot from cmd/persistence/main.go after scan migrations.
Un modèle mono-table avec status IN ('draft', 'persisted', 'superseded') peut sembler suffisant. Le schéma CP en utilise trois parce qu’il reflète la sémantique CPM §8.2 (OwnerScopedStore) et les invariants du contrat internal/cp/v1 (D3b-spec) : brouillon modifiable, policy immuable, persist idempotent.
| Table | Nature | Rôle |
|---|---|---|
crypto_policy_drafts |
Métier | Travail en cours (server_draft) — payload libre, upsert/delete, pas une CP officielle |
crypto_policies |
Métier | CP durable après wallet-auth — colonnes d’audit, immuable après persist, superseded au remplacement |
draft_persist_state |
Technique | Idempotence de PersistDraftOnce — lie draft_id → policy_id même après suppression du draft |
PUT draft POST persist (idempotent)
│ │
▼ ▼
crypto_policy_drafts ─────────► crypto_policies
│ ▲
│ draft_persist_state │ (policy_id réservé / completed)
└──────────────────────────────┘
- Statut unique :
server_draft. - Payload JSON modifiable (
UpsertDraft). - Soft delete utilisateur (
deleted_at). - Supprimé (hard delete) quand le persist réussit — comme le store mémoire CPM.
- Compté séparément pour le guard W1 (
draft_countdans/references/wallet). - Exclu du guard W3 et de
ListPoliciesByScan(seules les policies comptent).
L’adresse wallet est souvent dans le JSON (policy_context, etc.) et extraite à la volée ; pas besoin des colonnes d’audit indexées d’une policy persistée.
- Statuts :
persisted(active) ousuperseded(remplacée par un nouveau persist sur le mêmescan_id). - Colonnes dédiées + index W1 :
wallet_address,chain_id,ownership_status,wallet_control_method,wallet_control_verified_at,persisted_at. - Immutabilité (ADR §8.4.2) : après le premier
persisted, lepayloadet les champs d’audit ne sont plus mis à jour ; un remplacement crée une nouvelle ligne et marque l’anciennesuperseded. - Jamais de
signed_message/signatureen base (wallet-auth = CPM public API uniquement). - Comptée pour W3 (
/references/scan) et listée parscan_id(hors drafts).
Séparer drafts et policies évite de mélanger lignes mutables et lignes immuables, et permet des index partiels ciblés :
-- ex. W1 sans scan JSON
(user_id, wallet_address) WHERE status = 'persisted' AND deleted_at IS NULLTable technique calquée sur draftPersisted map[string]draftPersistState dans OwnerScopedStore (CPM).
| Colonne | Rôle |
|---|---|
draft_id (PK) |
Clé d’idempotence client (+ scope owner) |
policy_id |
ID alloué au premier essai (réutilisé si retry avant completion) |
completed |
true seulement après transaction persist réussie |
persisted_at |
Horodatage du succès |
user_id, tenant_id |
Scope owner |
Pourquoi une table à part ? Après un persist réussi, le draft est supprimé. Sans draft_persist_state, un replay POST /drafts/{draft_id}/persist ne pourrait plus répondre 409 DRAFT_ALREADY_PERSISTED de façon fiable.
Sémantique (ADR §5.5, D3b-spec) :
- Premier succès : réserve
policy_id, écritcrypto_policies,completed = true, supprime le draft. - Replay après succès →
409(même si le draft n’existe plus). - Échec avant completion : retry avec le même
draft_idréutilise le mêmepolicy_id(pas de double policy).
L’ADR §8.4.3 mentionne l’alternative « colonnes sur crypto_policy_drafts », mais elle ne tient pas si le draft est retiré au succès — d’où la table dédiée.
| Besoin | Mono-table draft | persisted | superseded |
Trois tables actuelles |
|---|---|---|
| Draft supprimé après persist + replay 409 | Nécessite de garder le draft ou un hack | draft_persist_state survit au draft |
| Immutabilité policy vs mutabilité draft | Risque d’update accidentel sur une CP officielle | Séparation stricte |
W1 : policy_count + draft_count |
Requêtes et index plus ambigus | Comptages distincts, index W1 sur policies |
| W3 : policies seulement | Filtrage status partout |
crypto_policies seule |
Retry mid-flight (même policy_id) |
État intermédiaire difficile à modéliser | completed = false + policy_id réservé |
Référence métier : cafe-crypto-policy-mgt/docs/PERS_D3B_SPEC_REVIEW.md, ADR persistence §8.4 et §5.5.
export POSTGRES_HOST=127.0.0.1 POSTGRES_PORT=5432
export POSTGRES_USER=cafe POSTGRES_PASSWORD=cafe POSTGRES_DATABASE=cafe POSTGRES_SSLMODE=disable
go test -tags=integration ./internal/cpddl/...
go test -tags=integration ./internal/cpstore/...Regenerate index golden after DDL changes:
go run ./scripts/gen_cp_indexes_golden.goInternal HTTP server (not exposed on public edge):
| Endpoint | Role |
|---|---|
GET /health |
Liveness — process up |
GET /ready |
Readiness — scan migrations applied + NATS connected + scan subscriptions active |
Compose healthcheck uses /ready (see cafe-deploy/compose/20-discovery.yml).
Contexte actuel : rien n’est en prod côté persistence CP/scan ; en dev on peut RAZ la DB quand on veut (
docker volume rm,DROP SCHEMA public CASCADE, etc.). Les migrations ici ne servent pas à préserver des données existantes.
Au boot, cafe-persistence applique le schéma dont le code a besoin :
- GORM
AutoMigrate(tables + colonnes de base) - DDL SQL complémentaire (index partiels IMM/W1/W3, drops legacy, etc.)
C’est invoqué depuis cmd/persistence/main.go (scanddl.MigrateScanSchema, cpddl.MigrateCPSchema).
Migrer = créer ou aligner le schéma attendu, pas “upgrader une prod vieille de N versions”.
| Besoin | Sans migration au boot |
|---|---|
Ownership ADR — seul cafe-persistence crée les tables scan et CP ; Discovery et CPM n’ont plus (ou n’auront plus) de DDL local |
Schéma créé à la main, scripts ops ad hoc, ou divergence entre services |
| Fresh install reproductible — CI, machine locale, collègue, staging : Postgres vide à chaque run | Erreurs runtime (“relation does not exist”) ou schémas différents selon l’environnement |
Contrat code ↔ base — PostgresStore, writers scan, index W1/W3 supposent colonnes et index précis |
Code et DB désalignés ; bugs silencieux (requêtes lentes, guards faux) |
| Jalons suivants — D4b (HTTP CP), D5a (client CPM), D6b (refs Discovery) consomment un stockage déjà figé | DDL et API inventés en même temps, dette de coordination |
La liberté de RAZ enlève la contrainte “ne pas casser les données”. Elle n’enlève pas le besoin d’un schéma défini, owned par persistence, appliqué automatiquement.
Un golden DDL est un snapshot versionné de ce que pg_indexes doit retourner après migration (noms d’index sur les tables scan ou CP).
Les tests -tags=integration (internal/scanddl/, internal/cpddl/) :
- connectent Postgres (souvent vide) ;
- exécutent la migration ;
- listent les index ;
- comparent à
scan_indexes.goldenoucp_indexes.golden.
But : détecter un changement d’index non voulu (oubli, renommage, régression GORM) en CI — pas imposer une procédure de rollback prod.
En dev, si tu changes volontairement le DDL : régénère le golden (scripts/gen_scan_indexes_golden.go, scripts/gen_cp_indexes_golden.go) et committe code + golden dans la même PR.
- Migrations incrémentales v1 → v2 → v3 avec préservation de données prod
- Scripts de rollback opérationnel sur schéma live
- Compatibilité avec une base legacy Discovery
Quand la prod existera, on pourra introduire des migrations versionnées si le schéma doit évoluer sans RAZ. Aujourd’hui, changer le schéma = reset volume dev + merge du nouveau DDL.
# Exemple : conteneur compose
docker compose -f compose/20-discovery.yml down
docker volume rm <volume_postgres> # nom selon stack cafe-deploy
# Ou dans psql
DROP SCHEMA public CASCADE;
CREATE SCHEMA public;Au prochain boot, cafe-persistence recrée tables et index via Migrate*Schema.
Rappel : pourquoi migrer même quand la DB dev est RAZ-able.
At boot, cafe-persistence is the sole writer of scan tables DDL in this jalon:
- GORM
AutoMigrateonscan_results,tls_scan_results,scan_usage_events - IMM index DDL (drop legacy uniques, create list indexes, ledger index, status default drop)
Logic lives in internal/scanddl/migrate.go and is invoked from cmd/persistence/main.go.
| Index | Table | Rôle |
|---|---|---|
idx_scan_results_user_address_created_at |
scan_results |
Historique liste (IMM-2) |
idx_tls_scan_results_user_url_created_at |
tls_scan_results |
Historique liste (IMM-2) |
idx_scan_usage_events_user_kind |
scan_usage_events |
Quota plan (IMM-6b-1) |
Index legacy absents après migration : idx_scan_results_user_address, idx_tls_scan_results_user_url.
Test d'intégration Postgres (-tags=integration) compare pg_indexes au golden file :
# Postgres requis (stack cafe-deploy, ou conteneur local)
export POSTGRES_HOST=127.0.0.1
export POSTGRES_PORT=5432
export POSTGRES_USER=cafe
export POSTGRES_PASSWORD=cafe
export POSTGRES_DATABASE=cafe
export POSTGRES_SSLMODE=disable
go test -tags=integration ./internal/scanddl/...Golden file : testdata/ddl/scan_indexes.golden
Quand internal/scanddl/migrate.go change (nouvel index, nouvelle table scan, etc.) :
- Démarrer Postgres vide (ou reset volume dev) :
docker run -d --name cafe-pers-ddl \
-e POSTGRES_USER=cafe -e POSTGRES_PASSWORD=cafe -e POSTGRES_DB=cafe \
-p 5432:5432 postgres:16- Régénérer le snapshot :
export POSTGRES_HOST=127.0.0.1 POSTGRES_PORT=5432
export POSTGRES_USER=cafe POSTGRES_PASSWORD=cafe POSTGRES_DATABASE=cafe POSTGRES_SSLMODE=disable
go run ./scripts/gen_scan_indexes_golden.go- Vérifier le diff sur
testdata/ddl/scan_indexes.golden, puis relancer :
go test -tags=integration ./internal/scanddl/...- Committer golden + code DDL ensemble (même PR).
Published as oleglod/cafe-persistence:<tag> :
| Tag | Source |
|---|---|
sha-<short_sha> |
Chaque build RC |
vX.Y.Z-rc<run_id> |
Label PR rc-vX.Y.Z ou workflow_dispatch |
vX.Y.Z, latest |
Promotion release (sans rebuild) |
- ADR persistence — §14.5 critère DDL
- PR plan PERS-D1
Cette PR n'active rien en stack. cafe-discovery conserve cmd/persistence jusqu'à PERS-D2 validé, puis suppression en PERS-D1b.
Rollback opérationnel = ne pas merger D2 ; image legacy oleglod/cafe-discovery-persistence reste buildable depuis Discovery.