From 59ef76cb920790fa48b1cc45660be2ed9094e225 Mon Sep 17 00:00:00 2001 From: Bryan Matthew Simonson <7519963+bryanmatthewsimonson@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:01:32 -0700 Subject: [PATCH] =?UTF-8?q?docs(design):=20case-as-workspace=20=E2=80=94?= =?UTF-8?q?=20type=20fix,=20orbit=20fix,=20identity=20question?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A design draft, NOT approved: a self-contained brief a fresh session can be handed as its starting prompt (§7), and a list of what only the maintainer can settle (§6). The proposal was "use a case entity as the workspace, with the case npub as the capturing identity." Measured against the real registry, it splits cleanly in two: - The workspace half is mostly BUILT. `entity-field-schemas.js:66-71` already gives `case` four authored-only fields — the project reading — and all of Phase 20 assumes it. Only `entity-model.js:46-49`'s comment and `llm-prompts.js:383` ("cases named in the text") are stale, and they are why the model proposes papers and lawsuits as cases. - The identity half is forbidden in part and buys no segregation: `publishOwnedKeysManifest` already emits one primary-signed kind-30069 listing every owned entity pubkey with no case scoping, so per-case keys would be re-linked on the wire by the researcher's own event. It also collides with the custody rule (TEAM_CASE_DESIGN.md:104-110) and orphans 30054/30055/30063/30064 at forked coordinates. The presenting complaint has a smaller cause. `collectCaseEntityIds` (`case-bundle.js:34-49`) is claim-only while `memberUrlSets` (`case-membership.js:34-60`) is the Phase-20.1 tag-OR-claim union; the COVID case was built entirely by tagging, so its orbit resolves to 1 entity (itself) instead of 265, and the side panel's unscoped flat list of all 283 is what "keeps growing and growing." Proposes no score, no aggregation, no wire change in CW.1-CW.5. PHILOSOPHY.md and CASE_DOSSIER_DESIGN.md §2.2 govern where this disagrees. Phase number unclaimed — 28 is the next free slot. Co-Authored-By: Claude Fable 5 --- docs/CASE_WORKSPACE_KICKOFF.md | 1206 ++++++++++++++++++++++++++++++++ 1 file changed, 1206 insertions(+) create mode 100644 docs/CASE_WORKSPACE_KICKOFF.md 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 `