oficina / fichas Public

The schema: cards, entries, values, refs and the event log

§4.3's eight tables, replacing the scaffold's example Note. Nothing had
shipped, so the generated delta was collapsed into one honest 0001 rather
than carrying an empty placeholder migration forever.

Every content-bearing column is a blob — sealed_label, sealed_value,
payload_sealed, sealed_target. Every plaintext column earns it under §4.1:
the suite contract's feed row (revision, gone, updated_at), referential
integrity and routing (ids, key, kind, source), the roster reference
(owner), and the reach mark (offered) that a sibling must evaluate without
an asker and without unsealing. No user vocabulary reaches sqlite_master or
any row: card type and field keys are opaque constants of ours, and their
labels are sealed even for built-ins, because a label that is sometimes
plaintext is a rule nobody can apply.

Event is Sheets' events table field for field rather than a second design
that drifts from it, including trigger_id — which fichas uses so a derived
summary names the entry that provoked it and the actor stays honest under
ruling 9.

make ci green; models and migrations agree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 62d54ea8d733fec27f2fdde59d0dbf1df3b71335 parent 9091ffd
3 files changed, +295 −18
  • internal/fichas/migrations/0001_init.sql +55 −1
  • internal/fichas/migrations/schema.sql +55 −1
  • internal/fichas/models.go +185 −16

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/fichas/cmd/fichas	[no test files]
?   	amadan.net/oficina/fichas/internal/fichas	[no test files]
?   	amadan.net/oficina/fichas/internal/fichas/icons	[no test files]
ok  	amadan.net/oficina/fichas/internal/fichastest	0.019s
=== migration-check ===
go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check
rastrillo migration check: models and migrations agree
diff --git a/internal/fichas/migrations/0001_init.sql b/internal/fichas/migrations/0001_init.sql
index c63a80d..afa85d6 100644
--- a/internal/fichas/migrations/0001_init.sql
+++ b/internal/fichas/migrations/0001_init.sql
@@ -1,2 +1,56 @@
-CREATE TABLE `notes` (`id` integer PRIMARY KEY AUTOINCREMENT,`title` text,`body` text,`created_at` datetime,`updated_at` datetime);
+CREATE TABLE `card_types` (`id` integer PRIMARY KEY AUTOINCREMENT,`key` text,`sealed_label` blob,`created_at` datetime,`updated_at` datetime);
+
+CREATE UNIQUE INDEX `idx_card_types_key` ON `card_types`(`key`);
+
+CREATE TABLE `field_defs` (`id` integer PRIMARY KEY AUTOINCREMENT,`card_type_id` integer,`key` text,`sealed_label` blob,`kind` text,`tier` text,`ordinal` integer,`created_at` datetime,`updated_at` datetime);
+
+CREATE INDEX `idx_field_defs_key` ON `field_defs`(`key`);
+
+CREATE INDEX `idx_field_defs_card_type_id` ON `field_defs`(`card_type_id`);
+
+CREATE TABLE `cards` (`id` integer PRIMARY KEY AUTOINCREMENT,`card_type_id` integer,`revision` integer NOT NULL DEFAULT 1,`gone` numeric,`offered` numeric,`owner` text,`created_at` datetime,`updated_at` datetime);
+
+CREATE INDEX `idx_cards_updated_at` ON `cards`(`updated_at`);
+
+CREATE INDEX `idx_cards_owner` ON `cards`(`owner`);
+
+CREATE INDEX `idx_cards_offered` ON `cards`(`offered`);
+
+CREATE INDEX `idx_cards_gone` ON `cards`(`gone`);
+
+CREATE INDEX `idx_cards_card_type_id` ON `cards`(`card_type_id`);
+
+CREATE TABLE `card_values` (`card_id` integer,`field_def_id` integer,`sealed_value` blob,`print` text,PRIMARY KEY (`card_id`,`field_def_id`));
+
+CREATE INDEX `idx_card_values_print` ON `card_values`(`print`);
+
+CREATE TABLE `entries` (`id` integer PRIMARY KEY AUTOINCREMENT,`card_id` integer,`kind` text,`source` text,`actor` text,`created_at` datetime);
+
+CREATE INDEX `idx_entries_created_at` ON `entries`(`created_at`);
+
+CREATE INDEX `idx_entries_actor` ON `entries`(`actor`);
+
+CREATE INDEX `idx_entries_kind` ON `entries`(`kind`);
+
+CREATE INDEX `idx_entries_card_id` ON `entries`(`card_id`);
+
+CREATE TABLE `entry_values` (`entry_id` integer,`field_def_id` integer,`sealed_value` blob,`print` text,PRIMARY KEY (`entry_id`,`field_def_id`));
+
+CREATE INDEX `idx_entry_values_print` ON `entry_values`(`print`);
+
+CREATE TABLE `refs` (`id` integer PRIMARY KEY AUTOINCREMENT,`card_id` integer,`sealed_target` blob,`print` text,`created_at` datetime);
+
+CREATE INDEX `idx_refs_print` ON `refs`(`print`);
+
+CREATE INDEX `idx_refs_card_id` ON `refs`(`card_id`);
+
+CREATE TABLE `events` (`id` integer PRIMARY KEY AUTOINCREMENT,`kind` text,`object_kind` text,`object_id` integer,`actor_id` text,`payload_sealed` blob,`trigger_id` integer,`created_at` datetime);
+
+CREATE INDEX `idx_events_created_at` ON `events`(`created_at`);
+
+CREATE INDEX `idx_events_object_id` ON `events`(`object_id`);
+
+CREATE INDEX `idx_events_object_kind` ON `events`(`object_kind`);
+
+CREATE INDEX `idx_events_kind` ON `events`(`kind`);
diff --git a/internal/fichas/migrations/schema.sql b/internal/fichas/migrations/schema.sql
index 239e296..6e7efe5 100644
--- a/internal/fichas/migrations/schema.sql
+++ b/internal/fichas/migrations/schema.sql
@@ -1,5 +1,59 @@
-- Generated by rastrillo migration generate; DO NOT EDIT.
-- The schema every migration in this directory adds up to.
-CREATE TABLE `notes` (`id` integer PRIMARY KEY AUTOINCREMENT,`title` text,`body` text,`created_at` datetime,`updated_at` datetime);
+CREATE TABLE `card_types` (`id` integer PRIMARY KEY AUTOINCREMENT,`key` text,`sealed_label` blob,`created_at` datetime,`updated_at` datetime);
+
+CREATE TABLE `card_values` (`card_id` integer,`field_def_id` integer,`sealed_value` blob,`print` text,PRIMARY KEY (`card_id`,`field_def_id`));
+
+CREATE TABLE `cards` (`id` integer PRIMARY KEY AUTOINCREMENT,`card_type_id` integer,`revision` integer NOT NULL DEFAULT 1,`gone` numeric,`offered` numeric,`owner` text,`created_at` datetime,`updated_at` datetime);
+
+CREATE TABLE `entries` (`id` integer PRIMARY KEY AUTOINCREMENT,`card_id` integer,`kind` text,`source` text,`actor` text,`created_at` datetime);
+
+CREATE TABLE `entry_values` (`entry_id` integer,`field_def_id` integer,`sealed_value` blob,`print` text,PRIMARY KEY (`entry_id`,`field_def_id`));
+
+CREATE TABLE `events` (`id` integer PRIMARY KEY AUTOINCREMENT,`kind` text,`object_kind` text,`object_id` integer,`actor_id` text,`payload_sealed` blob,`trigger_id` integer,`created_at` datetime);
+
+CREATE TABLE `field_defs` (`id` integer PRIMARY KEY AUTOINCREMENT,`card_type_id` integer,`key` text,`sealed_label` blob,`kind` text,`tier` text,`ordinal` integer,`created_at` datetime,`updated_at` datetime);
+
+CREATE TABLE `refs` (`id` integer PRIMARY KEY AUTOINCREMENT,`card_id` integer,`sealed_target` blob,`print` text,`created_at` datetime);
+
+CREATE UNIQUE INDEX `idx_card_types_key` ON `card_types`(`key`);
+
+CREATE INDEX `idx_card_values_print` ON `card_values`(`print`);
+
+CREATE INDEX `idx_cards_card_type_id` ON `cards`(`card_type_id`);
+
+CREATE INDEX `idx_cards_gone` ON `cards`(`gone`);
+
+CREATE INDEX `idx_cards_offered` ON `cards`(`offered`);
+
+CREATE INDEX `idx_cards_owner` ON `cards`(`owner`);
+
+CREATE INDEX `idx_cards_updated_at` ON `cards`(`updated_at`);
+
+CREATE INDEX `idx_entries_actor` ON `entries`(`actor`);
+
+CREATE INDEX `idx_entries_card_id` ON `entries`(`card_id`);
+
+CREATE INDEX `idx_entries_created_at` ON `entries`(`created_at`);
+
+CREATE INDEX `idx_entries_kind` ON `entries`(`kind`);
+
+CREATE INDEX `idx_entry_values_print` ON `entry_values`(`print`);
+
+CREATE INDEX `idx_events_created_at` ON `events`(`created_at`);
+
+CREATE INDEX `idx_events_kind` ON `events`(`kind`);
+
+CREATE INDEX `idx_events_object_id` ON `events`(`object_id`);
+
+CREATE INDEX `idx_events_object_kind` ON `events`(`object_kind`);
+
+CREATE INDEX `idx_field_defs_card_type_id` ON `field_defs`(`card_type_id`);
+
+CREATE INDEX `idx_field_defs_key` ON `field_defs`(`key`);
+
+CREATE INDEX `idx_refs_card_id` ON `refs`(`card_id`);
+
+CREATE INDEX `idx_refs_print` ON `refs`(`print`);
diff --git a/internal/fichas/models.go b/internal/fichas/models.go
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"`
+}