v0.3.4-pre.001

This commit is contained in:
2026-08-30 19:21:15 +02:00
parent bcce89f076
commit d95b1095e5
4 changed files with 1145 additions and 2 deletions

View File

@@ -0,0 +1,610 @@
<!-- file: docs/plans/025-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT_PLAN.md -->
<!-- version: 1 -->
# Plan `0.3.4` — Store/PostgreSQL `RawAccountState` + complétude RAW
## 1. Statut du gate `pre.001`
`0.3.4-pre.001` est un gate de conception. Il ne crée **aucun** SQL V002, aucune table account et aucun repository account. Il fixe la forme physique et le séquencement nécessaires avant implémentation.
Base canonique auditée :
```text
v0.3.3
workspace.package.version = 0.3.3
```
Version de travail de cette prerelease :
```text
workspace.package.version = 0.3.4-pre.1
label = 0.3.4-pre.001
```
Décision de scope : `ksp-store-api` reste inchangée. L'audit n'a révélé aucun gap backend-agnostic bloquant ; la difficulté restante est exclusivement l'implémentation physique PostgreSQL et son dispatch par la façade.
## 2. Invariants hérités et frontières
La trajectoire reste :
```text
RAW -> STRUCTURAL -> DECODED -> DOMAIN
```
`0.3.4` ferme seulement la complétude physique **RAW Store/PostgreSQL**. Store persiste et sert ; il ne choisit ni batch-size, ni priorité, ni backlog, ni retry job, ni stratégie de backfill.
Frontières inchangées :
```text
ksp-store-lib -> ksp-store-api
ksp-store-lib[postgres] -> ksp-store-postgres-lib
ksp-store-postgres-lib -> ksp-store-api
ksp-store-postgres-lib -X-> ksp-store-lib
Config -> Store autorisé
Store/backend -X-> Config
Transport -X-> Store
consumer ordinaire -> ksp-store-lib
consumer ordinaire -X-> ksp-store-postgres-lib
```
Une instance `Store` reste liée à un `RawNetworkId` et une base physique. Aucun multiplexage réseau/database n'est ajouté.
## 3. Inventaire de capabilities : 6/10 -> 10/10
| Capability | Impl physique stable | État base | Action 0.3.4 |
|--------------------------------|-------------------------|---------------|--------------|
| RawTransactionRead | PostgresBackend + Store | stable 0.3.3 | préserver |
| RawTransactionWrite | PostgresBackend + Store | stable 0.3.3 | préserver |
| RawTransactionObservationRead | PostgresBackend + Store | stable 0.3.3 | préserver |
| RawTransactionObservationWrite | PostgresBackend + Store | stable 0.3.3 | préserver |
| RawTransactionRetentionRead | PostgresBackend + Store | stable 0.3.3 | préserver |
| RawTransactionRetentionWrite | PostgresBackend + Store | stable 0.3.3 | préserver |
| RawAccountStateRead | aucune impl physique | pending 0.3.4 | implémenter |
| RawAccountStateWrite | aucune impl physique | pending 0.3.4 | implémenter |
| RawAccountObservationRead | aucune impl physique | pending 0.3.4 | implémenter |
| RawAccountObservationWrite | aucune impl physique | pending 0.3.4 | implémenter |
Canaries qui ferment encore volontairement la surface account dans `v0.3.3` :
- `crates/ksp-store-lib/tests/dependency_boundary.rs` interdit les quatre `impl RawAccount* for Store` ;
- `crates/ksp-store-lib/tests/hardening_completeness.rs` fige six impl transaction et interdit les quatre account ;
- `crates/ksp-store-postgres-lib/tests/dependency_boundary.rs` interdit les quatre `impl RawAccount* for PostgresBackend` ;
- `crates/ksp-store-postgres-lib/tests/hardening_completeness.rs` interdit encore `impl ksp_store_api::RawAccount*` et toute matérialisation `ksp_raw_account` dans la slice transaction.
Ces canaries devront être **transformées**, pas simplement supprimées : à `pre.008`, elles devront prouver exactement six capabilities transaction + quatre account. Les canaries qui imposent que V000 et `health.rs` restent business-free demeurent valides et ne doivent pas être assouplies.
### 3.1 Matrice détaillée des quatre capabilities account
| Capability | Input | Output | Guard réseau | SQL/transaction | Idempotence | Conflit | Absence/référence | Pagination | Error mapping | Preuve live |
|----------------------------|---------------------|---------------------------------|------------------------------------------------------------------------------|--------------------------------------------------------|----------------------------------|----------------------------------------|-----------------------------------|---------------------|---------------------------------------------------------------------------|------------------------------------------------|
| RawAccountStateRead | reference / query | Option<State> / Page<Reference> | reference/query network == backend | SELECT ; list keyset | n/a | n/a | get absent -> None | oui, cursor account | WrongNetwork, QueryInvalid, PageLimitUnsupported, DataInvalid, ReadFailed | get exact + list matrix |
| RawAccountStateWrite | state + observation | RawAcquisitionWriteOutcome | state + observation networks + exact same reference | transaction state+observation | exact existing -> AlreadyPresent | same full ref divergent -> RawConflict | n/a | non | WrongNetwork, Conflict, DataInvalid, WriteFailed | atomicité + identical/divergent + cancellation |
| RawAccountObservationRead | observation_key | Option<Observation> | aucun input réseau ; backend fournit son network à la référence reconstruite | SELECT by PK | n/a | n/a | absent -> None | non | DataInvalid, ReadFailed | round-trip optional metadata |
| RawAccountObservationWrite | observation | RawObservationWriteOutcome | observation.account.network == backend | transaction ; state FOR KEY SHARE + observation insert | exact existing -> AlreadyPresent | same key divergent -> RawConflict | state absent -> ReferenceNotFound | non | WrongNetwork, ReferenceNotFound, Conflict, DataInvalid, WriteFailed | missing ref + collision + concurrence |
## 4. Immutabilité V000/V001
Recalcul indépendant sur les bytes de l'archive stable :
```text
V000 legacy SQL SHA-256 = d29068b8c13b9dc0cc9ef6aaadd0fa12d41e0fe4c56541a1118c4bfc846a1450
V001 logical resources = 31488cda2f08f3f46c4cdbdbb6c18c243662fada02eac4487040c8735d72cc51
V001 resource count = 40
```
L'algorithme V001 est celui du moteur stable : domaine `ksp-migration-resources-v1\0`, puis pour chaque ressource ordonnée `id + NUL + SQL bytes + NUL` avant SHA-256.
Règles V002 :
```text
V000 : immuable
V001 : immuable
V002 : additive uniquement
nom logique proposé : raw_account_state
répertoire proposé : migrations/v002_raw_account_state/
resources proposées : tables/ + constraints/ + indexes/
hook migration supplémentaire : aucun
```
Le binding réseau singleton V001 est réutilisé ; V002 ne duplique pas `network` dans chaque ligne métier.
## 5. Audit field-by-field du contrat account
### 5.1 `RawAccountStateReference` + `RawAccountState`
Matrice complète modèle -> physique :
| Champ API | Rust | Cardinalité | PostgreSQL | Borne/constraint | Mapping read | Mapping write | Risque perte |
|------------|----------------|-------------|--------------------|-----------------------|----------------------------------|------------------------|---------------------------------|
| network | RawNetworkId | 1 | ksp_store_identity | exact backend network | binding runtime -> reference | aucune colonne row | aucun si guard exact |
| pubkey | Pubkey | 1 | BYTEA | exact 32 bytes | fixed bytes -> Pubkey | Pubkey bytes -> BYTEA | aucun |
| slot | u64 | 1 | NUMERIC(20,0) | 0..u64::MAX | decimal exact -> u64 | u64 -> decimal NUMERIC | narrowing interdit |
| state_hash | RawContentHash | 1 | BYTEA | exact 32 bytes | fixed bytes -> RawContentHash | hash bytes -> BYTEA | aucun ; pas de recalcul backend |
| lamports | u64 | 1 | NUMERIC(20,0) | 0..u64::MAX | decimal exact -> u64 | u64 -> decimal NUMERIC | narrowing interdit |
| owner | Pubkey | 1 | BYTEA | exact 32 bytes | fixed bytes -> Pubkey | Pubkey bytes -> BYTEA | aucun |
| executable | bool | 1 | BOOLEAN | NOT NULL | bool direct | bool direct | aucun |
| rent_epoch | u64 | 1 | NUMERIC(20,0) | 0..u64::MAX | decimal exact -> u64 | u64 -> decimal NUMERIC | narrowing interdit |
| data | Box<[u8]> | 1 | BYTEA | 0..=16 MiB | bytes + borne -> RawAccountState | bytes exacts -> BYTEA | base64/parse interdits |
Vue synthétique de projection :
| Champ API | Type API | Projection V002 | Invariant |
|------------|---------------------------|------------------------------------------|-----------------------------------------------------------------|
| network | RawNetworkId | binding DB existant `ksp_store_identity` | pas de colonne par ligne ; guard exact avant I/O |
| pubkey | Pubkey / 32 bytes | BYTEA NOT NULL | `octet_length = 32` ; composant PK |
| slot | u64 | NUMERIC(20,0) NOT NULL | 0..18446744073709551615 ; aucun narrowing |
| state_hash | RawContentHash / 32 bytes | BYTEA NOT NULL | `octet_length = 32` ; composant PK ; jamais recalculé par Store |
| lamports | u64 | NUMERIC(20,0) NOT NULL | domaine u64 exact |
| owner | Pubkey / 32 bytes | BYTEA NOT NULL | `octet_length = 32` |
| executable | bool | BOOLEAN NOT NULL | mapping direct |
| rent_epoch | u64 | NUMERIC(20,0) NOT NULL | domaine u64 exact |
| data | Box<[u8]> | BYTEA NOT NULL | 0..=16 MiB ; vide autorisé ; aucun base64 |
L'identité physique canonique retenue est :
```text
PRIMARY KEY (pubkey, slot, state_hash)
```
Le réseau fait partie de la référence API mais est porté physiquement par `ksp_store_identity` pour toute la base. Cette clé permet explicitement plusieurs états du même pubkey au même slot lorsque `state_hash` diffère.
`state_hash` est un digest **producer-owned**. Le backend ne le recalcule pas et ne suppose pas qu'il s'agit du hash de `data` seulement. L'idempotence vérifie donc le contenu complet malgré l'égalité du hash.
### 5.2 `RawAccountObservation`
Matrice complète modèle -> physique :
| Champ API | Rust | Cardinalité | PostgreSQL | Borne/constraint | Mapping read | Mapping write | Risque perte |
|---------------------------|---------------------------------|-------------|--------------------|------------------------|----------------------------|------------------------|---------------------------|
| observation_key | RawObservationKey | 1 | BYTEA | exact 32 bytes | fixed bytes -> key | key bytes -> BYTEA | aucun |
| account.pubkey | Pubkey | 1 | BYTEA | exact 32 bytes + FK | fixed bytes -> reference | bytes -> FK columns | aucun |
| account.slot | u64 | 1 | NUMERIC(20,0) | 0..u64::MAX + FK | decimal exact -> reference | decimal NUMERIC | narrowing interdit |
| account.state_hash | RawContentHash | 1 | BYTEA | exact 32 bytes + FK | fixed bytes -> reference | bytes -> FK columns | aucun |
| provider | RawProvenanceCode | 1 | TEXT | 1..128 + safe alphabet | validate common code | code text | aucun |
| protocol | RawProvenanceCode | 1 | TEXT | 1..128 + safe alphabet | validate common code | code text | aucun |
| acquisition_method | RawProvenanceCode | 1 | TEXT | 1..128 + safe alphabet | validate common code | code text | aucun |
| origin | RawAcquisitionOrigin | 1 | TEXT | 5 valeurs stables | static decode | static encode | unknown DB -> DataInvalid |
| received_at | RawTimestamp | 1 | BIGINT | 0..253402300799999 | checked i64 -> timestamp | u64 bounded -> i64 | borne API < i64::MAX |
| capture_session_id | Option<RawProvenanceCode> | 0..1 | TEXT NULL | NULL ou code sûr | NULL/validate | Option -> NULL/text | aucun |
| commitment | Option<RawProvenanceCode> | 0..1 | TEXT NULL | NULL ou code sûr | NULL/validate | Option -> NULL/text | aucun |
| endpoint_id | Option<RawProvenanceCode> | 0..1 | TEXT NULL | NULL ou code sûr | NULL/validate | Option -> NULL/text | aucun |
| filter_id | Option<RawProvenanceCode> | 0..1 | TEXT NULL | NULL ou code sûr | NULL/validate | Option -> NULL/text | aucun |
| observed_at | Option<RawTimestamp> | 0..1 | BIGINT NULL | borne + <= received_at | NULL/checked timestamp | Option -> NULL/i64 | borne API < i64::MAX |
| source_payload_hash | Option<RawContentHash> | 0..1 | BYTEA NULL | NULL ou 32 bytes | NULL/fixed bytes | Option -> NULL/BYTEA | aucun |
| source_payload_size_bytes | Option<u64> | 0..1 | BIGINT NULL | 0..=64 MiB | NULL/checked u64 | Option -> NULL/i64 | borne API < i64::MAX |
| is_startup | Option<bool> | 0..1 | BOOLEAN NULL | nullable | NULL/bool | Option -> NULL/bool | ne pas inventer false |
| transaction_signature | Option<RawTransactionSignature> | 0..1 | BYTEA NULL | NULL ou 64 bytes | NULL/fixed bytes | Option -> NULL/BYTEA | aucune FK transaction |
| write_version | Option<u64> | 0..1 | NUMERIC(20,0) NULL | NULL ou 0..u64::MAX | NULL/decimal exact | Option -> NULL/NUMERIC | narrowing interdit |
Vue synthétique de projection :
| Champ API | Type API | Projection V002 | Invariant |
|---------------------------|---------------------------------|------------------------|--------------------------------------------------|
| observation_key | RawObservationKey / 32 bytes | BYTEA NOT NULL | PK family-local ; collision = comparaison exacte |
| account.pubkey | Pubkey / 32 bytes | BYTEA NOT NULL | FK composite vers state |
| account.slot | u64 | NUMERIC(20,0) NOT NULL | domaine u64 exact ; FK composite |
| account.state_hash | RawContentHash / 32 bytes | BYTEA NOT NULL | FK composite |
| provider | RawProvenanceCode | TEXT NOT NULL | 1..128 octets + alphabet logique stable V001 |
| protocol | RawProvenanceCode | TEXT NOT NULL | idem V001 |
| acquisition_method | RawProvenanceCode | TEXT NOT NULL | idem V001 |
| origin | RawAcquisitionOrigin | TEXT NOT NULL | backfill/import/live/repair/replay |
| received_at | RawTimestamp | BIGINT NOT NULL | 0..253402300799999 |
| capture_session_id | Option<RawProvenanceCode> | TEXT NULL | mêmes checks V001 |
| commitment | Option<RawProvenanceCode> | TEXT NULL | mêmes checks V001 |
| endpoint_id | Option<RawProvenanceCode> | TEXT NULL | mêmes checks V001 |
| filter_id | Option<RawProvenanceCode> | TEXT NULL | mêmes checks V001 |
| observed_at | Option<RawTimestamp> | BIGINT NULL | borne + `observed_at <= received_at` |
| source_payload_hash | Option<RawContentHash> | BYTEA NULL | NULL ou 32 bytes |
| source_payload_size_bytes | Option<u64> | BIGINT NULL | 0..=64 MiB ; borne API tient dans BIGINT |
| is_startup | Option<bool> | BOOLEAN NULL | Yellowstone metadata observation-only |
| transaction_signature | Option<RawTransactionSignature> | BYTEA NULL | NULL ou 64 bytes ; aucune FK transaction |
| write_version | Option<u64> | NUMERIC(20,0) NULL | NULL ou domaine u64 exact |
La relation observation -> state est :
```text
FOREIGN KEY (account_pubkey, account_slot, account_state_hash)
REFERENCES ksp_raw_account_states(pubkey, slot, state_hash)
```
Aucun `ON DELETE CASCADE` n'est nécessaire : l'API account ne possède ni delete ni retention. Aucune FK n'est créée vers `ksp_raw_transactions` pour `transaction_signature`; cette signature est une metadata source optionnelle et la transaction correspondante n'est pas garantie présente dans le Store.
`observation_key` est unique **dans la famille account**. Le contrat API expose des reads family-specific et ne définit aucune unicité globale transaction+account ; une registry globale ajouterait un couplage physique non requis.
### 5.3 `RawAccountStateQuery`
| Entrée query | Cardinalité | Projection physique |
|-----------------------|------------------------|------------------------------------------------------------------|
| network | obligatoire | guard DB mono-réseau + digest cursor |
| pubkey | optionnel | filtre égalité ; aucune query owner |
| slots.start_inclusive | optionnel | `slot >=` |
| slots.end_inclusive | optionnel | `slot <=` |
| direction | Ascending / Descending | ordre total entièrement inversé |
| page.limit | u64 > 0 | limite physique PostgreSQL seulement ; `LIMIT + 1` |
| page.cursor | opaque <= 4 KiB | V1 account fixe, authentification de contexte par digest SHA-256 |
Aucune query API par owner/provider/time/status n'existe. Aucun index pour ces dimensions ne sera introduit dans V002.
## 6. Audit kbot3 ciblé
L'archive historique contient notamment `k_sol_obs_account_observations`, `k_sol_core_account_states`, `AccountStateStore` et le pipeline de normalisation `account_state_normalization`. Cette surface mélangeait acquisition, normalisation CORE et processing policy ; elle n'est pas transposable telle quelle.
| Élément historique | Classement | Décision KSP |
|----------------------------------------------------------|------------|-----------------------------------------------------------------------|
| unicité observation_key et FK comme invariants physiques | REPRENDRE | garder le principe, avec clés KSP actuelles |
| index account+slot pour navigation déterministe | REPRENDRE | réutiliser le principe seulement pour les queries réellement exposées |
| NUMERIC(20,0) pour lamports/rent_epoch | REPRENDRE | étendre à slot/write_version pour le domaine u64 complet |
| BIGSERIAL/id technique comme identité | REDESSINER | identité durable = `(pubkey, slot, state_hash)` |
| pubkey/owner/hash en TEXT/base58 | REDESSINER | BYTEA fixed-width |
| account data en base64 + `space` indépendant | REDESSINER | BYTEA complet ; longueur dérivée ; aucun double truth |
| observation duplique létat canonique | REDESSINER | observation référence un state complet séparé |
| slot BIGINT/i64 | REDESSINER | NUMERIC(20,0) sans narrowing |
| normalisation N1 -> CORE + processing ledger/version | REPORTER | future CORE/jobs/workers ; hors Store RAW 0.3.4 |
| batch maximum 1000 / policy de sélection | REPORTER | policy worker/job ; pas pagination Store |
| indexes owner/provider/method/time/status | REPORTER | aucune query publique actuelle ne les justifie |
| status/error_message persistés dans observation RAW | REJETER | observation réussie uniquement ; erreurs sûres hors ligne métier |
| sqlx et Store monolithique | REJETER | backend tokio-postgres séparé derrière ksp-store-lib |
| maintenance CASCADE/TRUNCATE comme surface normale | REJETER | aucune destructive capability account |
Aucun code kbot3 ne sera copié. Les optional Yellowstone actuelles (`write_version`, `transaction_signature`, `is_startup`) n'existaient pas comme contrat durable équivalent dans cette persistence historique et restent donc définies uniquement par `ksp-store-api` actuel.
Points héritage supplémentaires explicitement classés :
- **REPRENDRE** : l'ancien repository utilisait déjà une transaction pour rendre atomiques state CORE + mutation observation + processing ledger ; seul le principe transactionnel est repris, jamais le couplage CORE/ledger.
- **REPRENDRE** : l'unicité `observation_key` et la validation additive des contraintes/indexes sont de bons invariants physiques.
- **REDESSINER** : `write_version`, `transaction_signature` et `is_startup` n'ont pas d'équivalent persistant account fiable dans cette surface historique ; V002 suit exclusivement les optionalités du contrat KSP actuel.
- **REDESSINER** : l'idempotence historique `source_observation_id` + `ON CONFLICT DO UPDATE` est remplacée par clé producer-owned + comparaison exacte + conflit stable.
- **REJETER** : les erreurs historiques interpolent l'erreur SQL dans `ks_core::Error::db(...)`; KSP conserve uniquement kind/phase statiques et sûres.
## 7. Design physique V002 figé par `pre.001`
### 7.1 Tables minimales
```text
ksp_raw_account_states
ksp_raw_account_observations
```
Aucune table archive/tombstone account n'est ajoutée : `ksp-store-api` ne définit aucune capability de retention account.
### 7.2 Colonnes `ksp_raw_account_states`
```text
pubkey BYTEA NOT NULL
slot NUMERIC(20,0) NOT NULL
state_hash BYTEA NOT NULL
lamports NUMERIC(20,0) NOT NULL
owner BYTEA NOT NULL
executable BOOLEAN NOT NULL
rent_epoch NUMERIC(20,0) NOT NULL
data BYTEA NOT NULL
```
Contraintes minimales : fixed width 32 pour `pubkey`, `owner`, `state_hash`; domaine u64 pour `slot`, `lamports`, `rent_epoch`; `octet_length(data) <= 16777216`; aucune borne basse sur data.
### 7.3 Colonnes `ksp_raw_account_observations`
```text
observation_key BYTEA NOT NULL
account_pubkey BYTEA NOT NULL
account_slot NUMERIC(20,0) NOT NULL
account_state_hash BYTEA NOT NULL
provider TEXT NOT NULL
protocol TEXT NOT NULL
acquisition_method TEXT NOT NULL
origin TEXT NOT NULL
received_at_unix_millis BIGINT NOT NULL
capture_session_id TEXT NULL
commitment TEXT NULL
endpoint_id TEXT NULL
filter_id TEXT NULL
observed_at_unix_millis BIGINT NULL
source_payload_hash BYTEA NULL
source_payload_size_bytes BIGINT NULL
is_startup BOOLEAN NULL
transaction_signature BYTEA NULL
write_version NUMERIC(20,0) NULL
```
Les contraintes provenance doivent être sémantiquement identiques à V001 : code 1..128 octets/alphabet sûr, origin fermé, timestamps bornés/ordonnés, source hash 32 bytes et source payload <= 64 MiB.
### 7.4 Indexes justifiés
Indexes provenant des PK :
```text
PK ksp_raw_account_states(pubkey, slot, state_hash)
PK ksp_raw_account_observations(observation_key)
```
Un seul index métier additionnel est justifié par la query non filtrée :
```text
ix_ksp_raw_account_states_slot_pubkey_state_hash(slot, pubkey, state_hash)
```
Le PK couvre déjà efficacement la navigation lorsque `pubkey = ?` est imposé. Aucun index owner/provider/protocol/method/time/hash isolé n'est admis dans cette release.
## 8. Ordre total et pagination keyset
Ordre canonique :
```text
Ascending : (slot ASC, pubkey ASC, state_hash ASC)
Descending : (slot DESC, pubkey DESC, state_hash DESC)
```
Le backend PostgreSQL compare `BYTEA` selon son ordre btree déterministe. Aucun `OFFSET` n'est utilisé.
Cursor account V1 proposé :
```text
bytes 0..4 magic = "KSPA"
byte 4 version = 1
bytes 5..13 last_slot, u64 big-endian
bytes 13..45 last_pubkey, 32 bytes
bytes 45..77 last_state_hash, 32 bytes
bytes 77..109 SHA-256 binding digest
longueur fixe = 109 bytes
```
Domaine de digest :
```text
KSP/raw-account-state-cursor/v1
```
Le digest lie au minimum : network, présence/valeur du filtre pubkey, direction, start/end slot, last slot, last pubkey, last state_hash. Le magic/domain distinct de `KSPT`/`KSP/raw-transaction-cursor/v1` rend le replay cross-family impossible même si les deux cursors ont 109 octets.
Le plafond physique PostgreSQL reste le précédent réel nécessaire à `LIMIT + 1` :
```text
9_223_372_036_854_775_806
```
Ce plafond n'est pas une policy métier KSP.
## 9. Algorithmes d'écriture
### 9.1 Acquisition state + observation atomiques
Préconditions avant I/O métier :
```text
state.reference.network == backend.network
observation.account.network == backend.network
observation.account == state.reference
```
Algorithme :
```text
BEGIN
INSERT state ... ON CONFLICT (pubkey, slot, state_hash) DO NOTHING RETURNING ...
if inserted:
entity_outcome = Inserted
else:
SELECT canonical state BY PK FOR UPDATE
decode + compare exact lamports/owner/executable/rent_epoch/data
identical -> AlreadyPresent
divergent -> ERROR_CODE_RAW_CONFLICT
INSERT observation ... ON CONFLICT (observation_key) DO NOTHING RETURNING ...
if inserted:
observation_outcome = Inserted
else:
SELECT observation BY key FOR UPDATE
decode + compare exact reference + provenance + all optional metadata
identical -> AlreadyPresent
divergent -> ERROR_CODE_RAW_CONFLICT
COMMIT
```
Une divergence d'observation après insertion d'un nouveau state fait rollback de ce state. Aucun `ON CONFLICT DO UPDATE`, aucun `has_*` préalable, aucun overwrite silencieux.
### 9.2 Observation supplémentaire
```text
validate network before I/O
BEGIN
SELECT state BY composite reference FOR KEY SHARE
absent -> ReferenceNotFound
INSERT observation ... ON CONFLICT (observation_key) DO NOTHING RETURNING ...
collision -> SELECT FOR UPDATE + exact compare
COMMIT
```
Cette opération ne crée jamais implicitement le state et ne retransmet pas ses bytes.
## 10. Matrice de concurrence
| Scénario | Résultat permis | Invariant |
|------------------------------------------------------------|-------------------------------------------------------|---------------------------------------------------------------|
| 2 acquisitions identiques, même ref + même observation key | 1 Inserted/Inserted ; 1 AlreadyPresent/AlreadyPresent | attente unique + comparaison exacte ; aucun doublon |
| 2 acquisitions même ref, contenu divergent | un gagnant durable ; autre `RAW_CONFLICT` | aucun overwrite ; résultat gagnant dépend de lordonnancement |
| même pubkey+slot, state_hash différents | les deux states sont représentables | PK inclut state_hash |
| observation key identique, observations divergentes | une observation gagne ; autre `RAW_CONFLICT` | transaction perdante rollback son state nouvellement créé |
| record observation identique concurrent | 1 Inserted ; 1 AlreadyPresent | state préexistant inchangé |
| record observation divergente concurrent | 1 Inserted ; 1 `RAW_CONFLICT` | aucun update silencieux |
| annulation après insertion state avant observation | aucune moitié durable | transaction PostgreSQL rollback/drop |
Le backend ne promet pas quel concurrent divergent gagne ; il promet qu'un seul contenu incompatible devient durable et que l'autre reçoit le conflit stable.
## 11. Taxonomie d'erreurs
Aucun nouveau `PostgresBackendErrorKind` n'est requis par le design actuel.
| Cas | Taxonomie stable | Règle |
|---------------------------------------------------|------------------------------------------------------|---------------------------------|
| mauvais réseau opération | WrongNetwork -> `store.wrong_network` | guard avant SQL métier |
| référence absente pour observation supplémentaire | ReferenceNotFound -> `store.raw_reference_not_found` | pas de state implicite |
| contenu divergent / collision observation | Conflict -> `store_api.raw_conflict` | message/context statiques |
| cursor/query invalide | QueryInvalid -> `store_api.raw_query_invalid` | aucun bytes hostile dans erreur |
| page > limite physique PostgreSQL | PageLimitUnsupported | limite physique, pas policy KSP |
| row DB incompatible/malformée | DataInvalid | aucune valeur hostile recopiée |
| échec SELECT | ReadFailed | pas de server text exposé |
| échec INSERT/transaction | WriteFailed | pas de server text exposé |
| V002 absente/incompatible/divergente | MigrationMismatch / MigrationFailed / SchemaNewer | réutiliser taxonomy stable |
Les erreurs PostgreSQL externes ne doivent jamais être conservées comme source publique ni recopiées dans message/context.
## 12. Threat model `RawAccountState`
| Menace | Risque | Mitigation | Preuve prévue |
|---------------------------------------------------|-----------------------------------|-----------------------------------------------------------------------|----------------------------|
| URI pointe vers DB dun autre réseau | corruption cross-network | binding `ksp_store_identity` + guard réseau avant opération | unit + live |
| pubkey/hash/signature DB de mauvaise largeur | construction de modèle invalide | CHECK SQL + mapping `DataInvalid` statique | unit hostile rows |
| slot/lamports/rent_epoch/write_version > i64::MAX | narrowing/perte | NUMERIC(20,0) + conversion décimale fallible | unit limites u64 |
| data account > 16 MiB | allocation/persistence hostile | admission API + CHECK `octet_length <= 16777216` | unit + live boundary |
| data account vide | rejet incorrect | aucune borne basse SQL ; vide explicitement valide | unit |
| observation sans state | orphelin | FK composite + record pré-vérifie state | unit + live |
| échec observation après state insert | demi-acquisition durable | une transaction PostgreSQL commune | live cancellation/rollback |
| même ref, contenu divergent | overwrite silencieux | ON CONFLICT DO NOTHING + SELECT FOR UPDATE + comparaison exacte | unit + live concurrent |
| même pubkey/slot, hashes distincts | collision didentité | PK `(pubkey,slot,state_hash)` | unit + live |
| collision observation key | fusion de sources distinctes | comparaison exacte de tous les champs ; conflict sinon | unit + live |
| cursor transaction injecté dans account | replay cross-family | magic/domain account distincts | unit cursor |
| cursor account rejoué sur autre query | pagination incohérente | digest lie network/pubkey/range/direction/last key | unit cursor matrix |
| ordre même slot ambigu | duplication/skip entre pages | ordre total `(slot,pubkey,state_hash)` | unit + live pagination |
| metadata Yellowstone promue au state | couplage provider | is_startup/transaction_signature/write_version uniquement observation | schema + mapping canary |
| transaction_signature impose transaction row | couplage cross-family non garanti | aucune FK vers `ksp_raw_transactions` | schema canary |
| SQL/server text fuit | secret/PII leak | projection PostgresBackendError statique | hardening |
| V000/V001 modifiées | history incompatible | checksums figés et canaries exactes | migration tests |
| index non justifié | write amplification/bloat | un seul index métier V002 pour query unfiltered | schema completeness |
## 13. Compatibilité schema/migration
V002 devra suivre le moteur resource-owned de V001 : ressources ordonnées, checksums logiques, vérification catalogique et mutation selon les policies foundation existantes.
Principes :
```text
schema_creation = disabled : aucune création implicite
schema_creation = create_if_missing : bootstrap seulement selon contrat existant
schema_update = disabled : historique incomplet -> mismatch sûr
schema_update = update_if_needed : V002 appliquée additivement
history > latest embedded : SchemaNewer
history V000/V001 divergente : MigrationMismatch
extra columns externes : acceptables seulement si elles ne peuvent bloquer les INSERT KSP
colonnes/constraints/index requis incompatibles : mismatch avant ready
```
Les numéros de fichiers SQL exacts sont une responsabilité de `pre.002/pre.003`; leur sémantique est figée ici, pas leurs noms séquentiels.
## 14. Mapping/read privé
Le module backend proposé est `raw_account` privé, analogue structurellement à `raw_transaction` mais sans retention/archive.
Il devra contenir :
```text
mapping row -> RawAccountState
mapping row -> RawAccountObservation
mapping u64 <-> NUMERIC text sans narrowing
get state par composite reference
get observation par observation_key
list references par keyset
cursor account privé
write acquisition/observation privé
```
Aucun row/statement/client PostgreSQL ne traverse `ksp-store-postgres-lib` publiquement ni `ksp-store-lib`.
## 15. Stratégie de preuve PostgreSQL réelle
La preuve live restera `#[ignore]`, opt-in, avec une URI dédiée lue depuis stdin et sans environnement direct. Elle devra refuser de détruire des objets KSP préexistants non créés par le test.
Scénarios minimum :
```text
bootstrap V000+V001+V002 et health ready
réouverture idempotente du schéma
state bytes vide et 16 MiB
u64 au-delà de i64::MAX et u64::MAX pour colonnes NUMERIC
acquisition identique puis divergente
same pubkey + same slot + distinct state_hash
observation supplémentaire / reference not found
collision observation
pagination filtered/unfiltered ASC/DESC multi-page
cursor hostile / replay query / replay transaction
concurrence identique/divergente
cancellation entre state et observation -> rollback
coexistence avec au moins un canari RawTransaction stable dans la même DB V002
cleanup vérifié uniquement pour les objets créés par le test
```
Aucun appel Solana réseau n'est nécessaire à cette preuve.
## 16. Conformance cross-family finale
À `pre.008+`, les canaries devront compter exactement :
```text
PostgresBackend : 10 impl RAW
Store : 10 impl RAW
```
La façade doit continuer de n'exposer que les modèles/capabilities backend-agnostic. Le test `--no-default-features` de `ksp-store-lib` doit rester vert : un build sans backend PostgreSQL compile et rejette proprement un backend non compilé au runtime.
La conformance finale doit aussi démontrer que l'ajout account ne modifie pas les six comportements transaction : lecture/write/observation/list/retention/force-rehydrate restent couverts par leurs suites existantes.
## 17. Sizing recalibré
Le forecast initial du prompt concentrait migration et schema compatibility dans une seule tranche, ainsi que write+concurrence. Ces lots sont séparés pour rester dans la cible opérateur `~15-20 min/pre`.
| Tranche | Objet | Budget | Note |
|---------|-------------------------------------------------------------------|-----------|----------------------------------------|
| pre.001 | audit, kbot3, threat model, V002 design, sizing, plan/validation | 15-20 min | cette tranche |
| pre.002 | V002 registry + deux tables + PK/FK de base, sans repository | 15-20 min | split du forecast initial |
| pre.003 | contraintes complètes, index, schema compatibility, checksum V002 | 15-20 min | split du forecast initial |
| pre.004 | mapping privé state/observation + get reads + hostile rows | 15-20 min | pas de write |
| pre.005 | acquisition atomique state+observation + idempotence/conflict | 15-20 min | write principal |
| pre.006 | observation supplémentaire + races/cancellation unitaires | 15-20 min | concurrence isolée |
| pre.007 | list RawAccountStateQuery + keyset cursor V1 account | 15-20 min | navigation uniquement |
| pre.008 | 4 impl backend + 4 dispatch Store + conformance 10/10 | 15-20 min | frontières cross-crate |
| pre.009 | preuve PostgreSQL live account + coexistence RawTransaction | 15-20 min | ignored/opt-in |
| pre.010 | hardening/completeness cross-family + canaries ownership | 15-20 min | sécurité/completude |
| pre.011 | gate technique final + replay live ciblé + graphes | 15-20 min | avant docs finale |
| pre.012 | réconciliation docs finale sans rouvrir le code | 10-15 min | README/USAGE/plan/validation si requis |
| pre.013 | publication prep minimale + prompt 0.3.5 | 10-15 min | lane publication |
| rel.001 | stabilisation/tag après gate opérateur | 5-10 min | aucun scope nouveau |
## 18. Hors scope explicite
```text
modification ksp-store-api
retention/archivage/purge account
STRUCTURAL/CORE materialization
Interface events
workers/jobs/backfill executors
batch-size/priorité/backlog policy
Transport -> Store direct
index owner/provider/time/status sans query
compression account
nouveau backend autre que PostgreSQL
application Store Desk
```
## 19. Questions ouvertes après `pre.001`
Aucune question **bloquante** de contrat ne subsiste.
Les détails suivants restent volontairement d'implémentation : numérotation exacte des ressources V002, découpage précis des helpers de mapping et noms internes des statements. Ils ne doivent pas modifier les invariants de ce plan.
## 20. Gate d'acceptation `pre.001`
`pre.001` est acceptable si :
```text
base v0.3.3 vérifiée
V000/V001 checksums recalculés et figés
inventaire 6/10 -> 10/10 explicite
field mapping account complet
kbot3 classifié REPRENDRE/REDESSINER/REPORTER/REJETER
V002 figée sans SQL écrit
ordre/cursor figés
algorithmes atomicité/idempotence/concurrence figés
threat model complet
forecast recalibré
aucune modification fonctionnelle hors version prerelease
audits statiques applicables verts ; gate Cargo opérateur rejoué
```