oficina / memoria Public

Clone
git clone https://amadan.net/oficina/memoria

Plain git — no account needed to clone.

Download

Download this file

1package memoria
2
3import "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.
9var 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.
29type 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.
117type 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.
134type 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.
200type 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