oficina / fichas Public

Seal tiers default to sealed; propose seeded card types over runtime DDL

Revision 1 defaulted dates and enums to plaintext, assuming plaintext was
the price of queryability. memoria's index.go says otherwise: the FTS index
lives in memory and nowhere else, because FTS5 stores postings in plaintext
and an on-disk index would put readable passages in the replicated file. So
sealed means "not queryable by SQL against the durable file", not "not
queryable", and that default is withdrawn.

Default is now sealed, with plaintext only where the durable file must
answer without the key: the suite contract's feed row, structural integrity,
and the reach mark the sibling evaluates. Prints are for high-cardinality
values only — a print over a five-value enum yields five visible equality
classes that frequency analysis orders.

§4.3 is proposed, not ruled: no user-defined SQLite tables, because
sqlite_master holds table and column names in plaintext in the replicated
file, so a user-created type would write its own vocabulary into the clear.
Customer and Call Log become seeded rows in card_types and field_defs rather
than schema, field keys are opaque with sealed labels, and user-defined
fields may be sealed or sealed+print only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev f0d60e6cdd71db1e40b5a7909767305a2a41290a parent 4d0e653
1 file changed, +109 −5
  • docs/superpowers/specs/2026-09-19-fichas-design.md +109 −5

No CI script — run details

CI log
amadan: no CI script — add an executable .amadan/ci or a `ci` target to the Makefile
diff --git a/docs/superpowers/specs/2026-09-19-fichas-design.md b/docs/superpowers/specs/2026-09-19-fichas-design.md
index 9edaaa2..4c274f1 100644
--- a/docs/superpowers/specs/2026-09-19-fichas-design.md
+++ b/docs/superpowers/specs/2026-09-19-fichas-design.md
@@ -110,9 +110,111 @@ make it rarely-configured rather than a setting:
enums plaintext (ranges and grouping are needed; content value is low),
identifiers as prints, free text sealed.
-**Get this list right and the storage engine is an implementation detail.
-Get it wrong and no engine helps.** The per-type default table does not
-exist yet and slice 1 cannot start without it.
+### 4.1 Sealed is not unqueryable, and revision 1 assumed it was
+
+`internal/memoria/index/index.go`: *"The index lives in memory and nowhere
+else. That is not a performance choice: FTS5 stores its term postings in
+plaintext, so an index on disk would put readable passages in the replicated
+database file and break the one rule. The sealed chunk rows are the durable
+copy; this is rebuilt from them on wake."*
+
+**So sealed means "not queryable by SQL against the durable file", not "not
+queryable".** Anything unsealed into the in-memory index at wake can be
+searched, sorted, grouped and range-queried. Revision 1 of this file
+defaulted dates and enums to plaintext on the assumption that plaintext was
+the price of queryability. It is not, and that default is withdrawn.
+
+**Default: sealed.** Plaintext only where the durable file must answer
+without the key — three cases, and no others:
+
+1. **The suite contract.** S1's feed row must pass the plaintext-grep gate
+ unaided.
+2. **Structural integrity** — ids, foreign keys, kind routing.
+3. **The reach mark.** Under 2026-09-19's ruling the sibling evaluates its
+ own "offered" mark, with no asker and no unsealing.
+
+**Prints are for high-cardinality values only.** A keyed print over a
+five-value `stage` enum yields five visible equality classes, and frequency
+analysis orders them. Low-cardinality fields are plaintext or sealed, never
+printed.
+
+### 4.2 The default table (proposed)
+
+**Customer**
+
+| Field | Tier | Why |
+|---|---|---|
+| `id`, `type` | plaintext | structural; routing |
+| `created_at`, `updated_at`, `revision`, `gone` | plaintext | S1's feed row. memoria §3.2 already concedes update timestamps |
+| `offered` | plaintext | the reach mark (§4.1 case 3) |
+| `owner` | plaintext | a roster reference, not content. Sheets keeps `actor_id` plaintext |
+| `name`, `domain` | sealed + print | "is this the same company" without unsealing; high cardinality |
+| `email`, `phone` | sealed + print | "do we already have this contact" |
+| `stage` | sealed | low cardinality, so no print. Pipeline composition is commercially sensitive |
+| `next_action_date` | sealed | range-queried in the in-memory index |
+| notes, free text | sealed | — |
+
+**Call Log entry**
+
+| Field | Tier | Why |
+|---|---|---|
+| `id`, `card_id`, `kind`, `source` | plaintext | structural; routing |
+| `created_at` | plaintext | the feed needs to know a row was written |
+| `occurred_at` | sealed | *when you called them* is content, and is deliberately distinct from `created_at` |
+| `actor` | plaintext | Sheets precedent |
+| `body`, `duration` | sealed | — |
+| Calls meeting ref | sealed + print | dedupe on re-import |
+
+**Event log** takes Sheets' shape unchanged (§5). **References** are sealed
+with a print for dedupe — §9's open item is that a plaintext reference to a
+non-team item is at-rest knowledge, in a second app, that a private item
+exists.
+
+**Cost and its trigger:** sealing nearly everything means the card set
+unseals into RAM at wake to build the index. memoria §7.3 measures 19s for
+90k chunks and rules "snapshot, never rebuild" above that. A CRM at
+thousands of cards is milliseconds; **inherit the trigger, do not rediscover
+it.**
+
+### 4.3 NEEDS PAUL — no user-defined SQLite tables
+
+**Proposed, and it is the shape of the whole app.** `sqlite_master` stores
+table and column names in plaintext in the file Litestream replicates, so a
+user-created card type would write its own vocabulary — "Litigation",
+`settlement_amount` — into the durable file in the clear. That is the leak
+§4.1 just closed by sealing `stage`. Runtime DDL also breaks
+`rastrillo migration generate`, whose output says "DO NOT EDIT" because the
+schema is known at build time.
+
+**So Customer and Call Log ship as seeded rows, not as tables:**
+
+```
+card_types(id, key, sealed_label)
+field_defs(id, card_type_id, key, sealed_label, kind, tier)
+cards(id, card_type_id, created_at, updated_at, revision, gone, offered, owner)
+card_values(card_id, field_def_id, sealed_value, print)
+entries(id, card_id, kind, source, created_at, actor)
+entry_values(entry_id, field_def_id, sealed_value, print)
+refs(id, card_id, sealed_target, print)
+events(...)
+```
+
+Slice 2's reshape-by-sentence then adds **rows**, not tables. Two
+consequences:
+
+- **Field keys are opaque; labels are sealed.** Built-ins take stable keys
+ we choose (`f_name`); user-defined fields take generated ones. A user's
+ field *name* is content and never reaches the durable file in the clear.
+- **User-defined fields may be sealed or sealed+print only.** The plaintext
+ tier is reserved for structural fields the app ships, because plaintext
+ exists to serve the suite contract and referential integrity — needs only
+ the app has. This removes the foot-gun where someone marks a sensitive
+ field plaintext without understanding the trade.
+
+The usual EAV objection is that querying it in SQL is miserable. That
+mostly dissolves here: §4.1 already gave up querying in SQL, and
+`card_values` is only the sealed durable copy the in-memory index rebuilds
+from.
## 5. The event log — versioning, audit and S1's feed are one table
@@ -252,8 +354,10 @@ there and restated here only as they bind fichas:
- **RULED — a saved answer built on reached content cannot be handed off.**
Doctrine §7 already has the shape: "a source the receiver cannot check is
a source it must not accept."
-- **The seal-tier list (§4) does not exist.** **The last thing blocking
- slice 1.**
+- **The seal-tier default table is proposed in §4.2** — Paul accepted
+ "sealed" as the default on 2026-09-20. **§4.3 is the open one:** no
+ user-defined SQLite tables, and built-in card types shipping as seeded
+ rows rather than schema. It is the shape of the whole app and is unruled.
- **Third-party personal data.** Fichas holds notes about people who never
signed up. That is a lawful-basis and subprocessor question, not a
retention one, and TensorX's zero-retention posture does not answer it