| 1 | package memoria |
| 2 | |
| 3 | import "time" |
| 4 | |
| 5 | // Models is every model the schema generator manages. Keep it in step |
| 6 | // with the structs below: `rastrillo migration generate` reads it to work |
| 7 | // out what the database should look like, and `rastrillo migration check` |
| 8 | // fails CI when it and the migrations disagree. |
| 9 | var Models = []any{ |
| 10 | &Document{}, |
| 11 | &Chunk{}, |
| 12 | &Connector{}, |
| 13 | &SyncRun{}, |
| 14 | &SourcePage{}, |
| 15 | &SiblingFeed{}, |
| 16 | &SiblingTally{}, |
| 17 | } |
| 18 | |
| 19 | // Document is one thing memoria holds. |
| 20 | // |
| 21 | // What is plaintext here is deliberate and matches Sheets' §2.2: a |
| 22 | // coordinate is not content. Source, ExternalID, Revision and the |
| 23 | // timestamps are how the app finds and reconciles a row, and sealing them |
| 24 | // would make the store unqueryable while protecting nothing an attacker |
| 25 | // with the bucket could not infer from row counts anyway. The spec's §3.2 |
| 26 | // lists them as known leaks rather than pretending otherwise. |
| 27 | // |
| 28 | // Title and Body are content and are sealed. |
| 29 | type Document struct { |
| 30 | ID int64 |
| 31 | // Source names where this came from — "upload" or "paste" in slice 0. |
| 32 | // Sibling sources arrive in slice 1 and carry the app's name. |
| 33 | Source string `gorm:"index;not null"` |
| 34 | // ExternalID is the id in the source system, STABLE ACROSS EDITS — |
| 35 | // a file path, a Notion page id, a sibling's item id. Keyed |
| 36 | // (seal.Fingerprint) so it is not a plaintext-derived oracle. |
| 37 | // |
| 38 | // It used to be a hash of the body, which made every edit a new |
| 39 | // document: the old one stayed indexed under the same title, and |
| 40 | // Revision could only ever increment for a byte-identical re-upload. |
| 41 | // The staleness story the spec leans on was unreachable for the case |
| 42 | // it names. Found by a code review of this branch. |
| 43 | ExternalID string `gorm:"index;not null"` |
| 44 | // BodyPrint is a keyed fingerprint of the body — the change |
| 45 | // detector. Keyed for the same reason ExternalID is. |
| 46 | BodyPrint string `gorm:"not null;default:''"` |
| 47 | // Revision increments on every change to the body. **This is |
| 48 | // memoria's own counter and it is not what S2 is asked about.** |
| 49 | // |
| 50 | // The model said the authorization re-check was keyed on it. That is |
| 51 | // wrong for sibling content and would have been a quiet |
| 52 | // misauthorization: the revision S2 rules on is a token the *sibling* |
| 53 | // issued, memoria never invents one, and two apps counting |
| 54 | // independently agree only by accident. What this number is for is |
| 55 | // memoria-owned content under §4.2, where memoria is the app of |
| 56 | // record and its own count is the truth — and for §5.2's provenance, |
| 57 | // where a derived artefact built from revision 1 must not be served |
| 58 | // after revision 2 lands. Sibling documents carry SiblingRevision |
| 59 | // below and this counts their local edits alongside it. |
| 60 | Revision int `gorm:"not null;default:1"` |
| 61 | // SiblingItemID is the app of record's own id for this document, |
| 62 | // sealed and reversible (seal.PurposeItemID) because it goes back out |
| 63 | // on the wire in every S2 question and S3 fetch. |
| 64 | // |
| 65 | // It cannot be recovered from ExternalID, which is a keyed |
| 66 | // fingerprint — deliberately one-way, so the bucket does not confirm |
| 67 | // which documents a team holds. A one-way id is right for finding a |
| 68 | // row and useless for asking somebody about it, so both are here. |
| 69 | // |
| 70 | // Empty for memoria-owned content (§4.2), which has no app of record |
| 71 | // to ask. |
| 72 | SiblingItemID []byte |
| 73 | // SiblingRevision is the revision token the sibling issued for the |
| 74 | // body memoria holds. **Never compared, only matched for equality** |
| 75 | // (§4.1): STALE and AHEAD are the sibling's verdict on its own log, |
| 76 | // not memoria's arithmetic on two numbers, and a sibling restored to |
| 77 | // an older revision must not be second-guessed by preferring the |
| 78 | // larger one. |
| 79 | // |
| 80 | // int64 because that is what the contract module types it as. §4.1's |
| 81 | // prose calls a revision an opaque token that need not be numeric; |
| 82 | // the shipped wire shape is narrower than that sentence, and the |
| 83 | // substantive rule — equality only, never ordering — is what memoria |
| 84 | // actually depends on and is unaffected. |
| 85 | // |
| 86 | // Plaintext, like Revision, and for the same reason §3.2 gives: a |
| 87 | // coordinate is not content, and the count of edits is already |
| 88 | // inferable from row history. |
| 89 | SiblingRevision int64 `gorm:"not null;default:0"` |
| 90 | // SealedTitle and SealedBody are sealed under distinct HKDF purposes. |
| 91 | SealedTitle []byte `gorm:"not null"` |
| 92 | SealedBody []byte `gorm:"not null"` |
| 93 | // SealedPath is the document's ancestry in its source, outermost |
| 94 | // first — "Handbook › Onboarding" for a page nested two deep. Empty |
| 95 | // for uploads and paste, which have no ancestry. |
| 96 | // |
| 97 | // Sealed, and under its own purpose: a path is content. It is |
| 98 | // indexed in its own low-weighted column rather than folded into |
| 99 | // the heading trail, for the reason index.PathWeight gives. |
| 100 | SealedPath []byte |
| 101 | // Bytes is the plaintext length, kept so the UI can say how big a |
| 102 | // document is without opening it. A size is already inferable from |
| 103 | // the sealed length, so this leaks nothing new. |
| 104 | Bytes int |
| 105 | CreatedAt time.Time |
| 106 | UpdatedAt time.Time |
| 107 | |
| 108 | Chunks []Chunk `gorm:"constraint:OnDelete:CASCADE"` |
| 109 | } |
| 110 | |
| 111 | // Chunk is one retrievable passage of a Document. |
| 112 | // |
| 113 | // The searchable form of this text lives only in memory (internal/memoria/index): |
| 114 | // FTS5 keeps term postings in plaintext, so an on-disk index would put |
| 115 | // readable passages in the replicated file. These rows are the durable |
| 116 | // copy and the index is rebuilt from them. |
| 117 | type Chunk struct { |
| 118 | ID int64 |
| 119 | DocumentID int64 `gorm:"index;not null"` |
| 120 | // Ordinal is the passage's position in its document, from 0 — |
| 121 | // a coordinate, plaintext, and what a citation points at. |
| 122 | Ordinal int `gorm:"not null"` |
| 123 | // SealedHeading is the heading trail ("FY2024 › Revenue"). Sealed: |
| 124 | // a heading is content, and Sheets §2.1 seals titles for the same |
| 125 | // reason. |
| 126 | SealedHeading []byte |
| 127 | SealedText []byte `gorm:"not null"` |
| 128 | CreatedAt time.Time |
| 129 | } |
| 130 | |
| 131 | // SiblingFeed is memoria's position in one sibling's change feed. |
| 132 | // |
| 133 | // One row per configured app of record, created on first poll. |
| 134 | type SiblingFeed struct { |
| 135 | ID int64 |
| 136 | // App is the sibling's configured name, which is also the Source on |
| 137 | // every document it owns. The unique index is what makes "one |
| 138 | // position per app" a property of the schema rather than of the code |
| 139 | // that writes it. |
| 140 | App string `gorm:"uniqueIndex;not null"` |
| 141 | // SealedCursor is the sibling's own opaque cursor, sealed |
| 142 | // (seal.PurposeCursor) because memoria does not know what is in it. |
| 143 | // |
| 144 | // **Written only after a page has been fully applied.** §5.1 and gate |
| 145 | // 7: a cursor moved past a page nobody applied skips changes the |
| 146 | // sibling will never offer again, and the sibling has no way to know |
| 147 | // it happened. Every failure path in Poll returns before this column |
| 148 | // is touched. |
| 149 | SealedCursor []byte |
| 150 | // PolledAt is when the last poll DRAINED THE FEED — not when one |
| 151 | // started, and not when one got partway. |
| 152 | // |
| 153 | // It is what the sources page WILL say the corpus is current as of — |
| 154 | // the page is not built yet, and saying "is" would be claiming a |
| 155 | // reader today sees something they do not. What is true today is the |
| 156 | // column's rule: a poll that failed halfway, or stopped short, must |
| 157 | // not move it. §8.1: "a failed or stale S1 → the page states the |
| 158 | // corpus is current as of a time, rather than implying freshness". |
| 159 | PolledAt time.Time |
| 160 | // CountingSince is when this instance began keeping breaker counters |
| 161 | // for this sibling. |
| 162 | // |
| 163 | // **It is what makes "cannot determine" distinguishable from "no |
| 164 | // anomaly".** Without it, a fresh instance and a sibling that has |
| 165 | // genuinely refused nobody look identical: both have no refusals in |
| 166 | // the window and nobody in the baseline. §8.1 is explicit that this |
| 167 | // is the one place where mistaking the two would silently disable a |
| 168 | // guard rather than mis-render a page, so the instance records when |
| 169 | // it started looking and the page says so when the answer is younger |
| 170 | // than the question. |
| 171 | CountingSince time.Time |
| 172 | // Reconciling marks a source whose sibling has been restored behind |
| 173 | // memoria (§4.1's post-restore rule 1): its chunks leave the ranking |
| 174 | // entirely until a re-announce completes, so the other sources fill |
| 175 | // the page to its full depth instead of it rendering nearly empty. |
| 176 | // |
| 177 | // Not set by anything yet — AHEAD is not on the wire (§13) — and here |
| 178 | // rather than added later because the column is what makes the |
| 179 | // re-announce state durable across the hibernation that is certain to |
| 180 | // happen in the middle of one. |
| 181 | Reconciling bool `gorm:"not null;default:false"` |
| 182 | CreatedAt time.Time |
| 183 | UpdatedAt time.Time |
| 184 | } |
| 185 | |
| 186 | // SiblingTally is §8.1's breaker counter: for one sibling, one asker and |
| 187 | // one UTC day, how many passages that person was allowed to see and how |
| 188 | // many they were refused. |
| 189 | // |
| 190 | // **The only per-person record memoria keeps.** §3.2's leak list carries |
| 191 | // it. Two integers a day is the smallest thing that answers the question |
| 192 | // the breaker asks, and the section is explicit that a per-refusal log |
| 193 | // would answer it too and is not built. |
| 194 | // |
| 195 | // Why durable rather than in memory: memoria hibernates (§3.5), so an |
| 196 | // in-memory window resets on every wake, and a breaker that can never |
| 197 | // accumulate seven days of baseline is a breaker that is *off* — reading, |
| 198 | // on the owner's page, as "no anomaly". That is the failure this whole |
| 199 | // section exists to prevent, arriving through the storage choice. |
| 200 | type SiblingTally struct { |
| 201 | ID int64 |
| 202 | // App, AskerPrint and Day are the key. Unique together, so "one row |
| 203 | // per person per sibling per day" is a property of the schema rather |
| 204 | // than of the code that writes it. |
| 205 | App string `gorm:"uniqueIndex:idx_tally;not null"` |
| 206 | // AskerPrint is a KEYED, one-way fingerprint of the asker |
| 207 | // (seal.PurposeAsker). The predicate counts distinct people and |
| 208 | // compares a person against themselves across two periods; it never |
| 209 | // needs the address back, and a reversible id here would make this |
| 210 | // table a roster of who reads what. |
| 211 | AskerPrint string `gorm:"uniqueIndex:idx_tally;not null"` |
| 212 | // Day is the UTC date, "2006-01-02". Plaintext: it is the retention |
| 213 | // key and the window key, and sealing it would make the table |
| 214 | // unqueryable while hiding nothing — the row's existence already says |
| 215 | // somebody asked that day. |
| 216 | Day string `gorm:"uniqueIndex:idx_tally;index;not null"` |
| 217 | // SealedCounts holds the two integers, sealed together under |
| 218 | // seal.PurposeTally. |
| 219 | // |
| 220 | // One blob rather than two columns because they are read and written |
| 221 | // as a pair and never independently, and because a sealed integer |
| 222 | // cannot be incremented in SQL either way — the write is a |
| 223 | // read-modify-write in a transaction whichever shape this takes. |
| 224 | SealedCounts []byte `gorm:"not null"` |
| 225 | CreatedAt time.Time |
| 226 | UpdatedAt time.Time |
| 227 | } |
| 228 | |