diff --git a/docs/CASE_WORKSPACE_KICKOFF.md b/docs/CASE_WORKSPACE_KICKOFF.md new file mode 100644 index 0000000..02d757d --- /dev/null +++ b/docs/CASE_WORKSPACE_KICKOFF.md @@ -0,0 +1,1206 @@ +# Case-as-workspace — the type fix, the orbit fix, and the identity question + +> **Status:** design draft, **2026-07-17**, written for maintainer +> review. **NOT approved.** Nothing here is a decision; §6 is the list +> of things only the maintainer can settle. This file exists to be +> merged to `main` and handed to a fresh Claude Code session as its +> starting prompt — so it is self-contained, and §7 is the "start +> here" brief. +> +> **Where this document and the constitution disagree, the +> constitution governs** — [`docs/PHILOSOPHY.md`](PHILOSOPHY.md) (the +> normative constitution of the audit family, `30056`–`30061`), +> [`CASE_DOSSIER_DESIGN.md`](CASE_DOSSIER_DESIGN.md) §2.2 ("**No +> case-level score, ever**"), and +> [`TRUTH_ADJUDICATION_DESIGN.md`](TRUTH_ADJUDICATION_DESIGN.md) §1 +> (the form-of-judgment spine). This draft proposes no score, no +> aggregation, and no new evaluative number; if an implementation of +> it starts computing "case strength," it has left the constitution +> and the constitution wins. Two further normative texts are +> load-bearing here and are quoted where they bite: +> `TEAM_CASE_DESIGN.md` §1 ("the case is a **lens, not a container**") +> and §2.1's **custody rule** ("the case key never signs judgment +> kinds"). §3 and §4 are written to respect both; §6 Q4 asks whether +> the maintainer wants to amend them, because part of his proposal +> cannot be built without doing so. +> +> **Phase number:** unclaimed. ROADMAP's tail is Phase 26 (in +> progress, `ROADMAP.md:1807`); Phase 27 is in flight on +> `claude/phase-27-*` and not yet in ROADMAP. **28** is the next free +> slot — confirm before claiming it. +> +> **Every number in §1 and §4 was re-measured** from +> `xray-backup-2026-07-17 (1).json` for this draft, not carried over +> from the investigation briefs. Where the briefs disagreed, the +> corrections are called out inline. **The widely-repeated "252 +> entities / 796 claims" is stale: the real figures are 283 and 980.** + +--- + +## §1. The problem + +In the maintainer's words: + +> "The LLM suggest feature keeps on suggesting/creating 'cases', but +> doing it for things like scientific papers and court cases. … As a +> researcher trying to figure out the answer to a question, or go deep +> on a particular topic, I want to make sure that the articles and +> entities I capture don't mess with too many other unrelated items. +> … The current design dictates that the entities list will keep +> growing and growing…" + +### §1.1 What is actually in the registry + +| Measured | Value | +|---|---| +| entities | **283** — person 137, organization 85, thing 32, place 24, **case 5** | +| `local_keys` | **261** (260 `entity:` + the reserved `xray:user` slot) | +| claims | **980** (843 carry a `publishedPubkey`) | +| archive records | 62 articles + 98 `source_documents` | +| `platform_accounts` | 390 — **0 carry `linkedEntityId`** (the Phase-9 link layer is inert) | +| identity profiles | 2 — `epistack` (`6daa7f3b…`), `Personal` (`4ba5145d…`) | +| publishing pubkeys on claims | `6daa7f3b…` × 818, `4ba5145d…` × 25 | + +The five `case` entities, with the provenance field that decides the +whole argument (`entity-model.js:314`, `cleanSuggestedBy` at `:188`): + +| name | `suggested_by` | created | member sources | +|---|---|---|---| +| **What is the origin of Covid?** | **`user`** | 2026-07-03 | 49 (all tag-mediated) | +| Proximal Origin paper | `llm:claude-opus-4-8` | 2026-07-12 | 1 | +| Pekar et al. 2022 paper | `llm:claude-opus-4-8` | 2026-07-12 | 1 | +| Worobey et al. paper | `llm:claude-opus-4-8` | 2026-07-12 | 1 | +| The pending litigation connected to the matter | `llm:claude-opus-4-8` | 2026-07-12 | 1 | + +**`suggested_by` separates project from subject-of-study with 100% +accuracy on this data.** Every user-made case is a project; every +LLM-made case is a subject. All five carry `authored_fields: null` — +no case has a scope question yet, including the real one. + +Two corrections to the framing the briefs inherited: + +- **The three "paper" cases are all tagged on one article** — + `michaelweissman.substack.com/p/an-inconvenient-probability-v57`. + Their apparent orbits are that one article's entities, three times. +- **"The pending litigation connected to the matter" is not a COVID + lawsuit.** It is tagged and claimed exclusively on + `bricksandminifigs.com/blog/blog/2026/07/10/an-update-to-our-customers-and-community` + — the Lego-store matter. + +### §1.2 Root cause: `case` is overloaded, and the prompt says so out loud + +The word does three jobs — *lawsuit*, *workspace*, *story-under- +assessment* — and the two halves of the codebase have already picked +different ones. + +**The type comment still says "subject."** `entity-model.js:46-50`: + +```js +// `case` (Phase 11.1) models a real-world story under assessment — +// "John Dehlin excommunication", "Bricks & Minifigs scandal" — so the +// side-panel entity detail can serve as the case dashboard +export const ENTITY_TYPES = ['person', 'organization', 'place', 'thing', 'case']; +``` + +**The field schema says "project."** `entity-field-schemas.js:66-71` +gives `case` four rows, **all `provenance: 'authored'`** — the only +type in the registry with zero *sourced* fields: + +```js + case: Object.freeze([ + row('scope_question', 'Scope question', 'text', { provenance: 'authored' }), + row('status', 'Status', 'enum', { provenance: 'authored', evolves: true, … }), + row('opened', 'Opened', 'date', { provenance: 'authored' }), + row('closed', 'Closed', 'date', { provenance: 'authored' }) + ]) +``` + +Every other type answers *"what does the world assert about this?"*; +`case` answers *"what am I investigating?"*. Phase 19 redefined the +type in the schema and never told the comment. Phase 20 then built +`case-membership.js`, `case-dossier.js`, `case-graph.js`, and +`case-synthesis` on the project reading. **The code has already +decided. Only the comment and the prompt are stale.** + +**The prompt is the proximate cause, and it is not subtle.** +`llm-prompts.js:382-387`: + +```js +const RULES_ENTITIES = ` +ENTITIES (people / organizations / places / things / cases named in the text): +… +- type must be one of: ${ENTITY_TYPES.join(', ')}.`; +``` + +Line 383 asks the model to find, inside the article, the one thing +that by construction is never in it — the researcher's own frame. Line +387 interpolates `ENTITY_TYPES` raw (`llm-prompts.js:26` imports it), +rendering `person, organization, place, thing, case` with **no +definition of any type**. `person`/`organization`/`place` self-define; +`thing` and `case` do not. So the model resolves `case` to ordinary +English — a matter under investigation — and proposes the litigation. +**The model is obeying the prompt.** Compare the forensic rules in the +same file, where this debt was already paid: `:431-438` spends eight +lines defining attribution ("*the most common error, so read +carefully*") and `:449-450` injects the full `MANEUVER_GUIDE`. Entities +get one parenthetical. The asymmetry is the bug. + +Note the Options hint already disagrees with the prompt and is right — +`llm-prompts.js:89-90`: *"people, organizations, places, and things +named in the text."* No cases. + +**Nothing auto-accepts.** `reader/index.js:1905` → `xray:llm:suggest` +→ `background/index.js:485` `runSuggestionPass` → `openLlmReview` +(`reader/index.js:1928`). The four bad cases were confirmed by hand. +But the prompt manufactured the proposals, and a reviewer clicking +through twenty rows takes the type the model chose. + +### §1.3 The uncomfortable part: this behavior is *documented as intended* + +`CASE_DOSSIER_DESIGN.md:14-20`, verbatim: + +> "A **case** is an entity (`type: 'case'`) used as a folder: +> articles, claims, entities, and judgments accumulate in its orbit, +> and **on several occasions the LLM Suggest pass has proposed court +> cases as case entities — consistent with the intent.**" + +The maintainer's complaint is against **blessed** behavior. This +kickoff proposes to supersede that sentence and the `entity-model.js:46-49` +comment (§3.1). That is an amendment to a design doc, and it needs his +sign-off, not a patch. Related irony worth naming: `entity-model.js:48` +offers **"Bricks & Minifigs scandal"** as a canonical example case — +the exact corpus he now cites as contamination. The two use cases were +*designed to share one registry*. + +### §1.4 The contamination: what actually happened + +**Not a manual paste.** `portal_identities` in his backup is `[]` — he +pasted nothing. (One investigation brief asserted `addManualIdentity` +was the mechanism; **that is wrong**, and the correction matters +because it changes which line to fix.) + +The real chain needs no user action at all: + +1. He has two identity profiles and **one workspace**. Switching + profiles moves `local_primary_identity` and **touches no content** + — `IdentityProfiles.activate()` (`identity-profiles.js:189-197`) is + *only* `Storage.primaryIdentity.set(profile.privateKey)`. +2. So 818 epistack claims and 25 Bricks & Minifigs claims sit in one + `article_claims` store, each stamped with its own + `publishedPubkeys` (`claim-model.js:437-452`). +3. `resolveIdentities()` (`portal/identity.js:99-109`) unions + **every `publishedPubkey` on every claim** into "me" under source + `'publish-history'`. Both npubs are now one identity set. + Provenance is tracked for chip display and **scopes nothing**. +4. `portal/index.js:981-987` → `fetchCorpus({pubkeys: state.identities.map(…)})` + → `portal/corpus.js:169,186-188` — **one REQ, all authors, all + kinds.** +5. `portal/reconcile.js:88,115,159` cross-products every local d-tag + against every pubkey in the union. COVID assessments become + "expected" under the Bricks npub and vice versa — the ledgers + **interlock**, not merely co-display. + +The module header predicted this exactly — `identity-profiles.js:12-17`: + +> "Switching identity does NOT touch content records. … an identity +> switch **without a workspace reset** would make the portal/reconcile +> attribute the old npub's publishes to the new one. `resetWorkspace()` +> is the paired half." + +He ran the documented-unsupported configuration. The shipped UI even +says so (`options/index.js:377`: "*use Start fresh workspace (Advanced) +for a clean slate*"). **His proposal is, correctly, a request to make +the paired half unnecessary** — because `resetWorkspace()` is +destructive and serial (`identity-profiles.js:252-266`), and "delete +one project to work the other" is not an answer for someone running +two concurrently. + +### §1.5 The finding that actually explains "the entities list keeps growing" + +**His real case's entity orbit contains exactly one entity: itself.** + +Membership is defined twice, and the two disagree: + +| Definition | Where | Rule | +|---|---|---| +| `memberUrlSets` | `case-membership.js:34-60` | **tag OR claim** (the Phase-20.1 union) | +| `collectCaseEntityIds` | `case-bundle.js:34-49` | **claim only** — `c.about.includes(caseEntityId)` | + +`case-bundle.js:31-32` declares itself "**THE definition of case-orbit +membership**," and `case-dossier.js:80` uses it for `orbit.entity_ids`. +He built his COVID workspace entirely by **tagging** — 49 member +articles, **0 claims `about` the case**. Measured: + +``` +"What is the origin of Covid?" + member articles = 49 (all tag-mediated) + SHIPPED collectCaseEntityIds → 1 entity (the case itself) + UNION (tag-inclusive) → 265 entities +``` + +**Correction to one brief:** it concluded from this that "his case +dashboard is empty." It is not. `deriveArticleRows` +(`case-dossier.js:290-318`) *is* tag-inclusive — the 49 articles render +as first-class rows with `membership: 'tag'`, `processed: false`. And +`case-graph.js:30-32` sidesteps the orbit entirely by reading +`data.entitiesById`. So articles and the graph work; **the entity +orbit is what is empty**, and it feeds `collectCaseBundle` +(`case-bundle.js:59`), the integrity filter (`case-dossier.js:131-136`), +and the forensic bridge (`:138`). + +The codebase **documents its own gap** — `case-dossier.js:184-187`: + +> "Full entity registry snapshot (Phase 20.3) — the case graph +> resolves names for entities TAGGED on member articles that never +> entered an orbit claim (**so aren't in orbit.entities**)." + +So: he has a case view. Half of it is wired to the wrong side of a +union that Phase 20.1 already defined. What he sees instead is the +side panel's flat list of all 283 (`sidepanel/index.js:107` renders +`EntityModel.getAll()` with a type filter and no case filter). **That +is "the entities list will keep growing and growing."** He is proposing +an identity re-architecture to fix, substantially, a one-sided orbit +definition and a stale prompt. + +--- + +## §2. What already exists + +Scrupulously: **most of this proposal is built.** The parts that are +not built are the parts that are dangerous. Read this section as the +do-not-rebuild list. + +### §2.1 Built and correct — do not touch + +| Capability | Where | Note | +|---|---|---| +| **Union membership (tag ∪ claim)** | `case-membership.js:34-60` | the definition his workspace depends on | +| **Add/remove sources outside the reader** | `case-membership.js:103-141` | writes `context: ''` refs, canonicalized to the alias root; **never publishes** (`:8-13`) | +| **Case dossier assembler** | `case-dossier.js` (1055 lines) | derived, computed-on-read, no wire kind | +| **Local case graph** | `case-graph.js` + `portal/case-graph-view.js` | pure/deterministic; case center, member articles, co-tag adjacency, contradiction warn-edges, ghost nodes (`:155-159`), degree-ranked cap | +| **Portal case dashboard** | `portal/case-view.js` (508 lines) | 10+ blocks; router at `portal/index.js:745,767` | +| **Case scope authoring** | `sidepanel/index.js:299-305` | "Case scope — *your framing*"; the project reading, already shipped | +| **Case export / bundle** | `case-export.js`, `case-bundle.js` | 11.6 / 11.8 | +| **Case synthesis** | Phase 20.4, `caseSynthesis` flag | grounded brief, no new wire kind | +| **Identity profiles** | `identity-profiles.js:115-227` | per-case identities, **already the prescribed default** (`TEAM_CASE_DESIGN.md:224-228`) | +| **Workspace clear/backup** | `identity-profiles.js:36-95,236-266` | 21 content stores + 3 IDBs, enumerated and pin-tested | +| **Foreign keyless entities** | `entity-model.js:476-510` | TC.1/KS.3; adopt-on-sight | +| **Case-scoped follow sets** | `follow-model.js:31,46-57` | `FOLLOW_SCOPES = ['case','entity','global']` | + +### §2.2 Built, tested, and **unused** — free leverage + +**Case-scoped follows have zero consumers.** `FOLLOW_SCOPES` includes +`'case'` and `anchorKey()` produces `'case:'`, but both consumers +hardcode global: + +- `network/index.js:40` — `const GLOBAL = { scope: 'global' };` +- `follow-publish.js:25-27` — `selectFollowsToPublish()` returns + `FollowModel.getSet({ scope: 'global' })`, commented "**the GLOBAL + anchor, nothing else**". + +The only `scope: 'case'` call sites in the repo are +`tests/follow-model.test.mjs:24` and `tests/follow-publish.test.mjs:43`. +A tested, shipped, consumer-less data model with **no migration cost** +— and it carries the OPSEC closure the proposal wants +(`KNOWLEDGE_SHARING_DESIGN.md:203-208`: case- and entity-anchored +follow sets **never publish**). This is the single largest piece of +free real estate here, and it is exactly the "simplify collaboration" +half of the proposal. + +### §2.3 The concept he is asking for **already has a name in this repo — and it is a singleton** + +`options/options.html:239-247` defines *workspace* as, near-verbatim, +his proposed case scope: + +> "A workspace is everything you have captured or authored: entities +> (and their keypairs, including the entity-sync key), claims, +> evidence links, assessments, forensic findings, truth adjudications, +> platform accounts, the archive cache, audit records, and the +> signed-event journal. Settings, relays, feature flags, the LLM key, +> and your saved identities are not part of it." + +Compare: *"use a case entity as the workspace, including all articles, +entities, links, comments, etc."* + +**So the proposal, stated precisely, is: make `workspace` per-case and +N-instance.** Today N=1, it is defined extensionally by three frozen +lists (`WORKSPACE_CLEAR_KEYS` / `KEEP_KEYS` / `DATABASES`, +`identity-profiles.js:36-95`), and the only "switch" is *destroy and +start over*. There is **no active-case concept anywhere** — +`grep -rni "active_case|activeCase|currentCase|current_case" src/` +returns zero. + +### §2.4 New vs renaming — say it plainly + +| Element of the proposal | Verdict | +|---|---| +| "Each case is like a project" | **Renaming.** The schema (`entity-field-schemas.js:66-71`) and all of Phase 20 already say this. Only `entity-model.js:46-49` and `llm-prompts.js:383` are stale. | +| "Everything gets tagged" | **Built.** `case-membership.js` + the `['p', casePubkey, '', 'about']` wire idiom (`TEAM_CASE_DESIGN.md:100-102`). | +| "A universal graph … explored in one place" | **80% built.** `case-graph.js` does one case. Missing: a multi-case root and cross-case edges (§3.3). | +| "Case entity as the workspace" | **New — and collides with `TEAM_CASE_DESIGN.md:66-72`** ("lens, not a container … **Nothing in the data model carries case membership beyond ordinary tags**"). | +| "Avoid accidentally merging two projects" | **New.** A real gap; §4.1 only *prescribes* against it and nothing enforces. | +| "Segregating their data with that case npub as the capturing identity" | **New, forbidden in part, and does not deliver segregation** (§4.2, §4.3). | +| "Simplify collaboration" | **Free** — §2.2's case-scoped follows. | + +### §2.5 Two live tensions the repo already carries, independent of this proposal + +Record these either way; they are not this proposal's fault. + +1. **`TEAM_CASE_DESIGN.md:224` prescribes per-case identities. Phase 24 + (COMPLETE) derives every entity pubkey from the primary** — + `ENTITY_IDENTITY_DESIGN.md:104-125`: "same primary + same entity + type/name ⇒ same pubkey, **forever**", `ENTITY_KEY_DOMAIN = + 'xray-entity-v1'` (`entity-model.js:44`). Following the prescription + therefore mints a *different* pubkey per case for the same human. The + two were never reconciled. **Live consequence:** + `restoreDerivedKeys()` (`entity-model.js:337-356`) re-derives from + *whatever primary is active* — run it under "Personal" and every + epistack-era entity silently re-derives to a wrong pubkey. The + doc-comment (`:329-334`) discloses the discontinuity only for the + *legacy-random* case, **not** the cross-profile one. This looks like + an undocumented gap, not a decision. +2. **The custody rule is doc-only.** `grep` finds no enforcement in + `src/` — nothing stops a case key from signing a 30062 today. + Compare Phase 16, where the analogous red line ("30066 stays free") + *is* guard-tested. **The guard is owed regardless of which way §6 Q4 + goes.** +3. Minor, but it bites "avoid merging two projects": the portal's case + facet is **name-keyed, not pubkey-keyed** — `portal/index.js:191-197` + scans `state.entityIndex` for `ent.type === 'case' && ent.name === name`. + Two same-named cases collide. (They also collide at the id layer, + since `generateEntityId` is `sha256(type:name)`.) + +--- + +## §3. The design + +Split hard along one line: **the parts that scope a *view* are cheap, +safe, and reversible; the parts that change *identity* are expensive, +partly forbidden, and — the decisive point — do not deliver the +segregation they are meant to buy (§4.2).** + +The recommended shape is **lens, not partition**, consistent with +`TEAM_CASE_DESIGN.md:66-72`. **It requires zero wire-format changes.** +That is not a happy accident; it is the selection criterion. + +### §3.1 SAFE — the type vocabulary fix + +**Recommendation: do NOT add a type. Do NOT split `case`. Fix the +comment, the prompt, and the guard.** + +`ENTITY_TYPES` stays `['person','organization','place','thing','case']` +and `case` means **the researcher's workspace** — which is what +`entity-field-schemas.js:66-71` already encodes and what every Phase-20 +module already assumes. Amend the two stale texts: + +- `entity-model.js:46-49` — the "real-world story under assessment" + comment. Replace: a `case` is the **researcher's investigation + workspace**; it carries only authored fields; it is created by a + human, never extracted from an article. +- `CASE_DOSSIER_DESIGN.md:14-20` — strike "consistent with the intent." + +**Why not add `project` / `proceeding` / `paper`?** Each is a verified, +load-bearing cost, and the last one is disqualifying: + +1. Every new type needs a field registry — + `tests/entity-field-schemas.test.mjs:39` pins + `Object.keys(ENTITY_FIELD_SCHEMAS).sort() === ENTITY_TYPES.sort()`. +2. `ENTITY_TYPES` is exhaustively pinned — + `tests/entity-model.test.mjs:261`, plus the tag-map loop at `:267` + and the `buildArticleEvent` loop at `:277`. +3. **The tag mapping is quadruplicated, not centralized.** There is no + `ENTITY_TYPE_TAGS` constant. It is `entityTypeToTag` + (`entity-model.js:56-65`), hand-copied as an inline ternary at + `event-builder.js:310` **and again at `:319`**, and inverted as + `TAG_TO_TYPE` at `:987-990`. The only guard is a "Keep in sync" + comment. A new type that updates only the map **falls back to + `'place'` silently on the wire** — the test at + `tests/entity-model.test.mjs:277` exists because this already + happened once. +4. **Bundle import hard-rejects unknown types** — + `case-bundle.js:135-138` pushes to `invalid` and `continue`s. A + collaborator on an older build **silently drops** every + `project`-typed entity. That directly damages the collaboration goal + in the proposal. (30078 sync is tolerant — `entity-sync.js:130` only + requires a string — so the split breaks bundles, not sync.) +5. **Disqualifying: the wire type is a read-path key on the kind-0 + `about`.** `event-builder.js:570` emits + `` `${entity.type} entity created by X-Ray` ``, parsed back by a + regex built from the **live** vocabulary — + `adopt-entity.js:35`: + ```js + const m = new RegExp('^(' + ENTITY_TYPES.join('|') + ') entity created by X-Ray').exec(content.about || ''); + ``` + Rename `case` → `project` and **every already-published case kind-0 + on relays stops matching**, silently falling back to `defaultType` + (`'person'`). Every collaborator adopting his COVID case would adopt + it as a *person*. + +`ASSESSMENTS_DESIGN.md:427-430` already argued this, in the +maintainer's own words — entity type is wire-visible, so churning the +vocabulary means "a type migration + republish later — exactly the +wire-vocabulary churn the compat rule exists to avoid." That reasoning +now cuts **against** the split. + +**A scientific paper is not an entity problem — it is already an +article.** X-Ray's native representation of "Proximal Origin" is a +captured kind-30023, and the URL layer already knows what a paper is +(`url-identity.js:33` `ARXIV_HOSTS`, `:104-114` `arxivOriginal()`). +Captured articles are already first-class case-graph nodes +(`case-graph.js:104`). Where an entity *handle* is genuinely wanted, +`thing` fits better than `case` ever did: `thing_type` = "scientific +paper" / "litigation", `creator` (entity-ref, **multiple**) = the +authors, `created_date` = publication date +(`entity-field-schemas.js:61-65`), and gaps close with custom fields — +`CUSTOM_FIELD_RE = /^custom:[a-z0-9][a-z0-9_-]{0,47}$/` +(`:77`, synthesized def at `:100-106`) → `custom:journal`, +`custom:docket`, `custom:court`. + +### §3.2 SAFE — the suggest-prompt fix + +Make the model **structurally unable to mint a workspace**. Three edits +plus a guard, all mechanical: + +1. **`llm-prompts.js`, near the `:26` import** — a suggestable subset: + ```js + // A `case` is the RESEARCHER's workspace — authored fields only + // (entity-field-schemas.js:66), never a thing named in an article. + // The model may not mint one; humans create cases in the side panel. + export const SUGGESTABLE_ENTITY_TYPES = Object.freeze( + ENTITY_TYPES.filter((t) => t !== 'case')); + ``` +2. **Rewrite `RULES_ENTITIES` (`:382-387`)** — drop "cases named in the + text", **define every type** (the `thing` gap is half the bug), and + name the failure mode explicitly, in the house style already used + for forensic attribution at `:431-438`: + > `thing` — anything else with a name: a scientific paper, a lawsuit, + > a product, a report, a policy, an event. **When in doubt, it is a + > thing.** A SCIENTIFIC PAPER is a thing, never a case. A LAWSUIT is + > a thing, never a case. "Case" here means the researcher's own + > investigation workspace — it is never named in an article and you + > must never propose one. +3. **Narrow the tool enum** (`:170-173`) to `SUGGESTABLE_ENTITY_TYPES`. + Guidance only — `:126-131` documents that strict mode is + deliberately off and "the real firewall is each model's create() at + accept time." Hence: +4. **Hard guard in the validator** — `llm-proposals.js:179-181` is + currently `if (!ENTITY_TYPES.includes(prop.entity_type))`. Switch to + `SUGGESTABLE_ENTITY_TYPES` with a `case`-specific message that tells + the human what to do instead. + +**One deliberate narrowing to flag.** `reader/llm-review.js:214` calls +`validateProposal` inside `validityOf(row)`, which re-runs on every +render — so a human retyping a row *to* `case` in the review modal +would see it go invalid (the type `