quipu_core/schema.rs
1use crate::model::ValueKind;
2use serde::{Deserialize, Serialize};
3
4/// How a registry field is transformed before hitting disk.
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6pub enum FieldProtection {
7 None,
8 /// One-way SHA-256. Equality search keeps working (probe is hashed too);
9 /// the plaintext is never stored. No key to manage — but the digest is
10 /// deterministic and unsalted, so low-entropy values (SSNs, phone
11 /// numbers, ...) can be brute-forced by anyone with disk access; prefer
12 /// [`FieldProtection::Hmac`] for those.
13 Sha256,
14 /// One-way HMAC-SHA-256 keyed with [`crate::KeyRing::with_hmac_key`].
15 /// Equality search keeps working (probes are MACed with the same key);
16 /// without the key the stored digests cannot be brute-forced.
17 Hmac,
18 /// Hybrid AES-256-GCM + RSA-OAEP(SHA-256) with the store's public key.
19 /// Decryptable with the private key, but not searchable.
20 Rsa,
21}
22
23/// Opt-in *blind index* over a text field: normalized (lowercased) tokens are
24/// derived from the plaintext at write time, digested (domain-separated from
25/// the field's own stored digest — see
26/// [`crate::KeyRing::index_token_digest`]) and persisted next to the record.
27/// This is what makes prefix/substring search possible on protected fields,
28/// where the plaintext is never on disk.
29///
30/// Declaring an index is declaring a leak: token digests reveal which stored
31/// values share prefixes/fragments to anyone holding the digest key. Keep it
32/// `None` unless the field genuinely needs the search shape.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
34pub enum FieldIndex {
35 #[default]
36 None,
37 /// One token: the whole lowercased value. Enables case-insensitive exact
38 /// search ([`crate::MatchMode::ExactCi`]) on protected fields.
39 Exact,
40 /// Lowercased prefixes of 1..=n chars. Enables prefix search
41 /// ([`crate::MatchMode::Prefix`]) for probes up to n chars, with no false
42 /// positives (each token *is* the exact prefix).
43 Prefix(usize),
44 /// Lowercased n-char windows (n = 3 is the usual trigram choice; values
45 /// shorter than n are indexed as one whole-value token). Lets
46 /// [`crate::MatchMode::Contains`] narrow to candidates instead of
47 /// scanning, for probes of at least n chars. On one-way hashed fields the
48 /// candidates cannot be verified against the plaintext, so matches may
49 /// include false positives (matching fragments that are not contiguous);
50 /// pair with [`FieldProtection::Rsa`] when hits must be exact.
51 Ngram(usize),
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct FieldDef {
56 pub name: String,
57 pub kind: ValueKind,
58 pub protection: FieldProtection,
59 /// Indexed fields support [`crate::query::TargetFilter`] lookups
60 /// (current and historical values). RSA-protected fields cannot be indexed.
61 pub indexed: bool,
62 pub required: bool,
63 /// Blind token index for prefix/substring/case-insensitive search — see
64 /// [`FieldIndex`]. Text fields only.
65 pub search: FieldIndex,
66}
67
68impl FieldDef {
69 pub fn text(name: &str) -> Self {
70 Self {
71 name: name.to_string(),
72 kind: ValueKind::Text,
73 protection: FieldProtection::None,
74 indexed: false,
75 required: false,
76 search: FieldIndex::None,
77 }
78 }
79
80 pub fn indexed(mut self) -> Self {
81 self.indexed = true;
82 self
83 }
84
85 pub fn required(mut self) -> Self {
86 self.required = true;
87 self
88 }
89
90 pub fn kind(mut self, kind: ValueKind) -> Self {
91 self.kind = kind;
92 self
93 }
94
95 pub fn protection(mut self, p: FieldProtection) -> Self {
96 self.protection = p;
97 self
98 }
99
100 /// Attach a blind token index — see [`FieldIndex`] for the search shapes
101 /// and the leakage trade-off.
102 pub fn search(mut self, idx: FieldIndex) -> Self {
103 self.search = idx;
104 self
105 }
106}
107
108/// Field layout for one entity (or actor) type. Creating the schema is what
109/// "creates the registry table" for that type — it must exist before entities
110/// of the type can be registered or referenced by a log.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct TypeSchema {
113 pub type_name: String,
114 pub fields: Vec<FieldDef>,
115}
116
117impl TypeSchema {
118 pub fn new(type_name: &str, fields: Vec<FieldDef>) -> Self {
119 Self {
120 type_name: type_name.to_string(),
121 fields,
122 }
123 }
124
125 pub fn field(&self, name: &str) -> Option<&FieldDef> {
126 self.fields.iter().find(|f| f.name == name)
127 }
128}
129
130/// Example target type: a `name` you can search by (current or past) plus a
131/// free-form description. Field sets are fully customizable per type — this is
132/// only the out-of-the-box default.
133pub fn default_target_type() -> TypeSchema {
134 TypeSchema::new(
135 "default_target",
136 vec![
137 FieldDef::text("name").indexed().required(),
138 FieldDef::text("description"),
139 ],
140 )
141}
142
143/// Example actor type: searchable `name` and `role`.
144pub fn default_actor_type() -> TypeSchema {
145 TypeSchema::new(
146 "default_actor",
147 vec![
148 FieldDef::text("name").indexed().required(),
149 FieldDef::text("role").indexed(),
150 ],
151 )
152}
153
154/// Declaration of an extra audit-log column. Custom columns are themselves
155/// registry-managed: definitions are persisted in the meta table and values are
156/// validated against `kind` on every append.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct CustomColumnDef {
159 pub name: String,
160 pub kind: ValueKind,
161 pub required: bool,
162 /// `required` is only enforced for logs whose event time is at or after
163 /// this UTC-micros instant. Filled in automatically by
164 /// [`crate::AuditStore::define_custom_column`] — making a column required
165 /// must not retroactively invalidate events that were created (e.g.
166 /// parked in a DLQ) before the requirement existed, or their redrive
167 /// would fail forever.
168 pub required_since: Option<u64>,
169}
170
171impl CustomColumnDef {
172 pub fn new(name: &str, kind: ValueKind) -> Self {
173 Self {
174 name: name.to_string(),
175 kind,
176 required: false,
177 required_since: None,
178 }
179 }
180
181 pub fn required(mut self) -> Self {
182 self.required = true;
183 self
184 }
185}