| index db7e53c..05de872 100644 |
| --- a/internal/fichas/models.go |
| +++ b/internal/fichas/models.go |
| @@ -3,23 +3,192 @@ package fichas |
| import "time" |
| |
| // Models is every model the schema generator manages. Keep it in step |
| -// with the structs below: `rastrillo migration generate` reads |
| -// it to work out what the database should look like, and |
| -// `rastrillo migration check` fails CI when it and the |
| -// migrations disagree. |
| +// with the structs below: `rastrillo migration generate` reads it to work |
| +// out what the database should look like, and `rastrillo migration check` |
| +// fails CI when it and the migrations disagree. |
| var Models = []any{ |
| - &Note{}, |
| -} |
| - |
| -// Note is a starting example model — rename it, replace it, add more |
| -// beside it. Whatever it becomes, the rule stays the same: every query |
| -// touching user-owned rows goes through scope.Owned (SKILL.md) — reads |
| -// AND writes — and a request body is never bound onto a model directly |
| -// (explicit map[string]any + .Select allowlist). |
| -type Note struct { |
| - ID int64 |
| - Title string |
| - Body string |
| + &CardType{}, |
| + &FieldDef{}, |
| + &Card{}, |
| + &CardValue{}, |
| + &Entry{}, |
| + &EntryValue{}, |
| + &Ref{}, |
| + &Event{}, |
| +} |
| + |
| +// The design is docs/superpowers/specs/2026-09-19-fichas-design.md. Two of |
| +// its rulings shape every struct in this file and are worth stating here, |
| +// because the next person will otherwise read the schema as timid. |
| +// |
| +// §4.3, ruled 2026-09-20: there are no user-defined tables. A card type is |
| +// rows in CardType and FieldDef, never DDL. sqlite_master holds table and |
| +// column names in plaintext in the file litestream replicates, so a |
| +// user-created type would write its own vocabulary — "Litigation", |
| +// "settlement_amount" — into the clear. That is the leak §4.1 closed by |
| +// sealing everything else. |
| +// |
| +// §4.1: sealed does not mean unqueryable. The search index lives in memory |
| +// and is rebuilt from these rows on wake, exactly as memoria's does and for |
| +// the same reason (FTS5 stores its postings in plaintext). So a column is |
| +// plaintext here ONLY where the durable file must answer without the key: |
| +// the suite contract's feed row, referential integrity, and the reach mark |
| +// a sibling evaluates for itself. Everything else is sealed. |
| + |
| +// Tier is where a field's values live: the vocabulary of §4.2. |
| +// |
| +// TierPrint is a keyed one-way fingerprint and answers equality only — never |
| +// ranges, never ordering. It is for HIGH-CARDINALITY values. A print over a |
| +// five-value enum yields five visible equality classes that frequency |
| +// analysis puts in order, which is why `stage` is sealed and not printed. |
| +// |
| +// TierPlain is reserved for the structural fields this app ships. A |
| +// user-defined field may be TierSealed or TierPrint and nothing else |
| +// (§4.3), so that nobody marks a sensitive field plaintext without |
| +// understanding the trade. |
| +type Tier string |
| + |
| +const ( |
| + TierSealed Tier = "sealed" |
| + TierPrint Tier = "print" |
| + TierPlain Tier = "plain" |
| +) |
| + |
| +// CardType is one kind of card. Customer and Call Log ship as seeded rows |
| +// of this table (§4.4) — "ship a catalogue, not a builder". |
| +type CardType struct { |
| + ID int64 |
| + // Key is opaque and ours, never the user's words: built-ins take |
| + // stable constants (f_customer), anything added later takes a |
| + // generated one. It is plaintext because code selects on it. |
| + Key string `gorm:"uniqueIndex;size:64"` |
| + // SealedLabel is what a person reads. Sealed even for built-ins, so |
| + // that one rule covers the catalogue and whatever is added to it — |
| + // a label that is sometimes plaintext is a rule nobody can apply. |
| + SealedLabel []byte |
| + CreatedAt time.Time |
| + UpdatedAt time.Time |
| +} |
| + |
| +// FieldDef is one typed field on a card type. Adding a field is a row, not |
| +// a migration. |
| +type FieldDef struct { |
| + ID int64 |
| + CardTypeID int64 `gorm:"index"` |
| + Key string `gorm:"index;size:64"` // opaque, as CardType.Key |
| + SealedLabel []byte |
| + // Kind is the value's shape — text, date, enum, number — and drives |
| + // validation and how the in-memory index treats it. Plaintext: it is |
| + // this app's vocabulary, not the team's content. |
| + Kind string `gorm:"size:32"` |
| + Tier Tier `gorm:"size:16"` |
| + // Ordinal is display order within the type. |
| + Ordinal int |
| CreatedAt time.Time |
| UpdatedAt time.Time |
| } |
| + |
| +// Card is a mutable entity — the thing a person opens. Its field values are |
| +// CardValue rows; nothing about what it *says* is in this struct. |
| +type Card struct { |
| + ID int64 |
| + CardTypeID int64 `gorm:"index"` |
| + // Revision, Gone and UpdatedAt are the suite contract's feed row |
| + // (S1: item_id, revision, gone, touched_at) and are plaintext so the |
| + // feed can be served without unsealing and passes the plaintext-grep |
| + // gate unaided. memoria §3.2 already concedes update timestamps as a |
| + // known leak; this is the same trade, made deliberately. |
| + Revision int64 `gorm:"not null;default:1"` |
| + Gone bool `gorm:"index"` |
| + // Offered is the reach mark. Under the 2026-09-19 credential ruling |
| + // the sibling evaluates its own mark, with no asker and no unsealing, |
| + // so this must be answerable by SQL against the durable file. |
| + Offered bool `gorm:"index"` |
| + // Owner is a roster reference, not content. Sheets keeps actor_id |
| + // plaintext and this follows it. |
| + Owner string `gorm:"index;size:128"` |
| + CreatedAt time.Time |
| + UpdatedAt time.Time `gorm:"index"` |
| +} |
| + |
| +// CardValue is one field's value on one card. Sealed, with an optional |
| +// keyed print for the equality questions worth answering without the key — |
| +// "is this the same company", "do we already have this contact". |
| +type CardValue struct { |
| + CardID int64 `gorm:"primaryKey"` |
| + FieldDefID int64 `gorm:"primaryKey"` |
| + SealedValue []byte |
| + // Print is empty unless the FieldDef's Tier is TierPrint. Indexed |
| + // because answering equality is the entire reason it exists. |
| + Print string `gorm:"index;size:64"` |
| +} |
| + |
| +// Entry is an append-only event belonging to a card — a call log, a note. |
| +// Immutable once written (§3): a correction is a new entry that supersedes, |
| +// never an edit, because an entry is a fact about a past moment. |
| +type Entry struct { |
| + ID int64 |
| + CardID int64 `gorm:"index"` |
| + Kind string `gorm:"index;size:32"` |
| + // Source is where it came from — "manual", "calls". Routing, not |
| + // content. |
| + Source string `gorm:"size:32"` |
| + // Actor names the real writer, per suite ruling 9. A generated entry |
| + // is written by the app as itself and says so; it is never attributed |
| + // to whoever triggered it. |
| + Actor string `gorm:"index;size:128"` |
| + // CreatedAt is when the row was written — the feed needs that. |
| + // When the call actually happened is an EntryValue, sealed, because |
| + // "when you called them" is content. |
| + CreatedAt time.Time `gorm:"index"` |
| +} |
| + |
| +// EntryValue is CardValue's twin for entries. |
| +type EntryValue struct { |
| + EntryID int64 `gorm:"primaryKey"` |
| + FieldDefID int64 `gorm:"primaryKey"` |
| + SealedValue []byte |
| + Print string `gorm:"index;size:64"` |
| +} |
| + |
| +// Ref points at an item in a sibling app — (app, instance, item) — and |
| +// holds no copy of it (§3, decision 5). |
| +// |
| +// SealedTarget is sealed rather than plaintext because of §9's open item: a |
| +// readable reference to a non-team item is at-rest knowledge, in a second |
| +// app, that a private item exists. That is the leak S1's visibility flag |
| +// was removed to prevent. |
| +type Ref struct { |
| + ID int64 |
| + CardID int64 `gorm:"index"` |
| + SealedTarget []byte |
| + Print string `gorm:"index;size:64"` // dedupe only |
| + CreatedAt time.Time |
| +} |
| + |
| +// Event is the change log: what changed, who did it (§5, Paul 2026-09-19). |
| +// Deliberately Sheets' events table, field for field, rather than a second |
| +// design that drifts from it. |
| +// |
| +// Retention is the load-bearing part and it is not tidiness. Kind, |
| +// ObjectKind, ObjectID, ActorID and CreatedAt are kept permanently and |
| +// survive the object's deletion — "who deleted this" is the question an |
| +// audit log most needs to answer. PayloadSealed holds the before/after |
| +// values and is tombstoned on a bounded window, because restore needs an |
| +// unbroken chain and erasure has to be able to break it. |
| +type Event struct { |
| + ID int64 |
| + Kind string `gorm:"index;size:32"` |
| + ObjectKind string `gorm:"index;size:16"` |
| + ObjectID int64 `gorm:"index"` |
| + ActorID string `gorm:"size:128"` |
| + // PayloadSealed is sealed under its own purpose, as Sheets seals |
| + // "event.payload". Emptied when the window passes; the row stays. |
| + PayloadSealed []byte |
| + // TriggerID is the event that caused this one. Sheets uses it for a |
| + // recalc's input; fichas uses it for the derived summary, so that a |
| + // machine-written change names the entry that provoked it and the |
| + // actor stays honest (§5, §7). |
| + TriggerID int64 |
| + CreatedAt time.Time `gorm:"index"` |
| +} |