diff --git a/rfc/0006.md b/rfc/0006.md
new file mode 100644
index 000000000..11e862a80
--- /dev/null
+++ b/rfc/0006.md
@@ -0,0 +1,3068 @@
+---
+title: |
+ `0006` Notebook Typescript API
+author: Kaspar Bumke
+date: 2026-07-08
+toc: true
+toc-depth: 3
+---
+
+::: {.callout-note}
+
+### RFC status
+
+This RFC is tracked in
+[#1334](https://github.com/ToposInstitute/CatColab/issues/1334).
+:::
+
+## Summary & Motivation
+
+Catlog and its WASM interface expose a rich and flexible API surface for formal
+modeling. The full flexibility of this is available to our frontend via
+Typescript, with types largely generated from Rust, but the ergonomics and
+type-safety of this API are sub-par.
+
+Colocation of the Typescript handling `catlog-wasm` and our
+`catcolab-document-types` with the frontend has led to entanglement of UI
+framework concerns with the modeling API.
+
+We want to improve this API surface with the aim of helping three main use-cases:
+
+1. Our use in the CatColab frontend
+2. Other programmatic use from Javascript and Typescript
+3. Users instructing LLMs
+
+We will focus on notebook documents, since that is the only document currently
+in use, but this API should be expandable to other types of documents in the
+future.
+
+## Technical approach
+
+We will replace our current `catcolab-document-methods` package with a package
+called `catcolab-documents` that makes use of `catcolab-document-types` and
+wraps notebook creation and editing in a more ergonomic interface.
+
+We will also add a package called `catcolab-logics` that defines the logics we
+currently make available to users on the frontend.
+
+In the future we may publish these packages on public registries such as NPM
+and JSR.
+
+### Creating notebooks
+
+`catcolab-documents` provides a default `binder` API instance and defines the
+`RichText` and `Instantiation` cells.
+
+
+
+```ts
+import { binder, RichText, Instantiation } from "catcolab-documents";
+import { Aspect, SimpleOlog, Type } from "catcolab-logics/simple-olog";
+```
+
+All documents are created through a binder instance. The default binder uses a
+plain in-memory store for documents and provides no reactivity on its document
+view. Creating custom binder instances is covered in [Use with SolidJS and
+Automerge](#use-with-solidjs-and-automerge).
+
+
+
+```ts
+const notebook = await binder.createNotebook(SimpleOlog, { title: "An Olog" });
+```
+
+### Editing notebooks
+
+All cells are added with a single `add` method. The first argument selects
+the kind of cell: `RichText` for prose, or an object/morphism type from the
+logic for formal cells in the case of editing models.
+
+
+
+```ts
+const intro = notebook.add(RichText, { content: "We define a simple olog." });
+```
+
+We can create objects and morphisms in the notebook.
+
+
+
+```ts
+const source = notebook.add(Type, {
+ label: "A",
+});
+
+const target = notebook.add(Type, {
+ label: "B",
+});
+
+const arrow = notebook.add(Aspect, {
+ label: "has",
+ from: source,
+ to: target,
+});
+```
+
+We can update any item.
+
+
+
+```ts
+notebook.update({ title: "A simple Olog example" });
+
+intro.update({
+ content: "We define a simple olog with two objects and one arrow.",
+});
+
+source.update({
+ label: "Source",
+});
+
+arrow.update({
+ label: "has as",
+ from: source,
+ to: target,
+});
+```
+
+We can also do partial updates.
+
+```ts
+arrow.update({
+ label: "has as example",
+});
+```
+
+### Instantiation
+
+Instantiation is just another cell type provided by `catcolab-documents`. In our
+initial implementation we only support instantiation of notebooks already defined
+in the local binder.
+
+```ts
+const anotherOlog = await binder.createNotebook(SimpleOlog, { title: "Another Olog" });
+const thing = anotherOlog.add(Type, { label: "Thing" });
+
+const instantiation = notebook.add(Instantiation, {
+ label: "ImportedOlog",
+ model: anotherOlog,
+ // maps ImportedOlog.Thing <- B
+ specializations: [{ object: thing, as: target }],
+});
+```
+
+### Iterating through cells
+
+A `cells` method is provided to iterate through all cells of the notebook.
+
+```ts
+import { CellKind } from "catcolab-documents";
+
+for (const cell of notebook.cells()) {
+ switch (cell.kind) {
+ case CellKind.RichText:
+ console.log("text:", cell.content);
+ break;
+ case CellKind.Object:
+ console.log("object:", cell.label, "type:", cell.type.obType.content);
+ break;
+ case CellKind.Morphism:
+ console.log("morphism:", cell.label, "type tag:", cell.type.morType.tag);
+ break;
+ }
+}
+```
+
+```
+text: We define a simple olog with two objects and one arrow.
+object: Source type: Object
+object: B type: Object
+morphism: has as type tag: Hom
+```
+
+Additionally we provide a `cellsOf` method where we can filter by cell type
+(and by `Shape`, see [Generic shapes of notebooks](#generic-shapes-of-notebooks)).
+
+
+
+
+
+```ts
+import { Attr, AttrType, Entity, Mapping, SimpleSchema } from "catcolab-logics/simple-schema";
+import { binder } from "catcolab-documents";
+
+const notebook = await binder.createNotebook(SimpleSchema, { title: "Example schema" });
+
+const person = notebook.add(Entity, { label: "Person" });
+const company = notebook.add(Entity, { label: "Company" });
+
+const mapping = notebook.add(Mapping, { label: "employer", from: person, to: company });
+```
+
+```ts
+const entities = notebook.cellsOf(Entity);
+const mappings = notebook.cellsOf(Mapping);
+
+console.log("entities:", entities.map((cell) => cell.label).join(", "));
+console.log("mappings:", mappings.map((cell) => cell.label).join(", "));
+```
+
+```
+entities: Person, Company
+mappings: employer
+```
+
+`cells` and `cellsOf` do not recurse into instantiations.
+
+
+
+Code example
+
+
+
+
+
+```ts
+import { Entity, Mapping, SimpleSchema } from "catcolab-logics/simple-schema";
+import { binder } from "catcolab-documents";
+
+const notebook = await binder.createNotebook(SimpleSchema, { title: "Example schema" });
+
+const person = notebook.add(Entity, { label: "Person" });
+const company = notebook.add(Entity, { label: "Company" });
+
+const mapping = notebook.add(Mapping, { label: "employer", from: person, to: company });
+```
+
+
+
+```ts
+import { Instantiation } from "catcolab-documents";
+
+const anotherSchema = await binder.createNotebook(SimpleSchema, { title: "Another schema" });
+const enterprise = anotherSchema.add(Entity, { label: "Enterprise" });
+const building = anotherSchema.add(Entity, { label: "Building" });
+const owner = anotherSchema.add(Mapping, { label: "owner", from: enterprise, to: building });
+
+const instantiation = notebook.add(Instantiation, {
+ label: "ImportedSchema",
+ model: anotherSchema,
+ specializations: [{ object: enterprise, as: company }],
+});
+```
+
+```ts
+const instantiations = notebook.cellsOf(Instantiation);
+const entities = notebook.cellsOf(Entity);
+const mappings = notebook.cellsOf(Mapping);
+
+console.log("instantiations:", instantiations.map((cell) => cell.label).join(", "));
+console.log("entities:", entities.map((cell) => cell.label).join(", "));
+console.log("mappings:", mappings.map((cell) => cell.label).join(", "));
+```
+
+```
+instantiations: ImportedSchema
+entities: Person, Company
+mappings: employer
+```
+
+
+
+### Further details on notebook editing
+
+#### Duplicating cells
+
+We can duplicate cells. Copies keep the same logical shape but receive
+fresh identities, and their handles can be updated independently.
+
+
+
+ Code example
+
+
+
+
+```ts
+import { SimpleOlog, Type } from "catcolab-logics/simple-olog";
+import { binder, RichText } from "catcolab-documents";
+
+const notebook = await binder.createNotebook(SimpleOlog, { title: "An Olog" });
+
+const a = notebook.add(Type, { label: "A" });
+const b = a.duplicate();
+b.update({
+ label: `B (copy of ${a.label})`,
+});
+
+console.log("a:", a.label);
+console.log("b:", b.label);
+```
+
+```
+a: A
+b: B (copy of A)
+```
+
+
+
+#### Re-ordering cells
+
+Every cell handle can move itself within the notebook. Moves locate the cell
+by its id at the moment the change applies, so they remain valid even if the
+notebook was edited after the handle was obtained.
+
+
+
+ Code examples
+
+
+
+
+
+```ts
+import { SimpleOlog, Type } from "catcolab-logics/simple-olog";
+import { binder, RichText } from "catcolab-documents";
+
+const notebook = await binder.createNotebook(SimpleOlog, { title: "An Olog" });
+
+const a = notebook.add(Type, { label: "A" });
+const b = notebook.add(Type, { label: "B" });
+const c = notebook.add(Type, { label: "C" });
+
+function names() {
+ return notebook
+ .cellsOf(Type)
+ .map((cell) => cell.label)
+ .join(", ");
+}
+```
+
+`moveUp` and `moveDown` shift a cell one position; `moveTo` moves it to an
+index, interpreted after the cell is removed from its current position.
+
+```ts
+c.moveUp();
+console.log(names());
+
+a.moveDown();
+console.log(names());
+
+b.moveTo(0);
+console.log(names());
+```
+
+```
+A, C, B
+C, A, B
+B, C, A
+```
+
+Impossible moves are silent no-ops and out-of-range targets clamp to the ends
+of the notebook.
+
+```ts
+a.moveUp();
+c.moveDown();
+console.log(names());
+
+b.moveTo(99);
+console.log(names());
+```
+
+```
+A, B, C
+A, C, B
+```
+
+
+
+#### Deleting cells
+
+Every cell handle can remove itself from the notebook with `delete`. Like the
+reorder methods, delete locates the cell by its id when the change applies, so
+it stays valid even if the notebook was edited after the handle was obtained.
+
+
+
+ Code examples
+
+Deleting a cell removes it from the notebook's order and contents.
+
+```ts
+console.log(names());
+b.delete();
+console.log(names());
+```
+
+```
+A, B, C
+A, C
+```
+
+Rich-text cells can be deleted in the same way.
+
+```ts
+const note = notebook.add(RichText, { content: "A note." });
+console.log(notebook.cells().length);
+note.delete();
+console.log(notebook.cells().length);
+```
+
+```
+4
+3
+```
+
+After deletion, reading fields off the stale handle returns `undefined`.
+
+```ts
+b.delete();
+console.log(b.label);
+```
+
+```
+undefined
+```
+
+Deleting an already-deleted cell is a silent no-op.
+
+```ts
+b.delete();
+b.delete();
+console.log(names());
+```
+
+```
+A, C
+```
+
+
+
+#### Getting a cell by id
+
+We can retrieve a cell by its id, filtering by the type of cell we are looking
+for.
+
+
+
+ Code example
+
+```ts
+const retrievedA = notebook.get(Type, a.id);
+if (retrievedA.tag === "Ok") {
+ console.log("retrieved:", retrievedA.content.label);
+}
+```
+
+```
+retrieved: A
+```
+
+
+
+#### Subscribing to changes.
+
+We can subscribe to notebook changes with `onChange`. This triggers on _any_
+change to the notebook. No arguments are passed to the callback.
+
+
+
+ Code example
+
+```ts
+const unsubscribe = notebook.onChange(() => {
+ console.log("changed");
+});
+```
+
+
+
+### A `Result` type
+
+In cases where where functions take in unknown and user generated inputs we want a
+standardized way to return the result on success and diagnostics on failure.
+
+Our `Result` type is a tagged union structurally identical to the `JsResult`
+type in our Rust bindings (`catlog-wasm`). Results coming out of the Rust layer
+can therefore pass through untouched.
+
+When a failure is about validation of unknown or user generated input, the
+`Err` content is an array of issues following the [Standard
+Schema](https://standardschema.dev) definition. This allows for flexibility in
+validation libraries and implementation while sticking to a standard format,
+and lets us design and make use of general purpose libraries that follow this
+standard.
+
+```ts
+/** The result of a fallible operation. */
+type Result> =
+ | { readonly tag: "Ok"; readonly content: T }
+ | { readonly tag: "Err"; readonly content: E };
+
+/** The issue interface of the failure output. */
+interface Issue {
+ /** The error message of the issue. */
+ readonly message: string;
+ /** The path of the issue, if any. */
+ readonly path?: ReadonlyArray | undefined;
+}
+
+/** The path segment interface of the issue. */
+interface PathSegment {
+ /** The key representing a path segment. */
+ readonly key: PropertyKey;
+}
+```
+
+Since it is general enough, in the case of e.g. validation failure of a model,
+we can use the path field to point at specific object or morphism. If we have
+the need we can extend this in the future e.g. adding a `uuid` field to the
+`PathSegment` interface, without breaking compatibility with the standard.
+
+### Model validation
+
+#### Validate method
+
+`validate` is an asynchronous method that returns a `Result`: an `Ok` carrying
+the elaborated model as `content`, or an `Err` carrying an array of issues.
+
+
+
+
+
+```ts
+import type { DblModel } from "catlog-wasm";
+import type { Result } from "catcolab-documents";
+import { Aspect, SimpleOlog, Type } from "catcolab-logics/simple-olog";
+import { binder } from "catcolab-documents";
+
+const notebook = await binder.createNotebook(SimpleOlog, { title: "An Olog" });
+
+const source = notebook.add(Type, { label: "A" });
+const target = notebook.add(Type, { label: "B" });
+notebook.add(Aspect, { label: "has", from: source, to: target });
+```
+
+A well-formed notebook validates to an `Ok` carrying the model as `content`.
+
+```ts
+const result: Result = await notebook.validate();
+console.log("valid:", result.tag === "Ok");
+```
+
+```
+valid: true
+```
+
+The validated model is available on the result and can be queried.
+
+```ts
+const result = await notebook.validate();
+if (result.tag === "Ok") {
+ console.log("objects:", result.content.obGenerators().length);
+ console.log("morphisms:", result.content.morGenerators().length);
+}
+```
+
+```
+objects: 2
+morphisms: 1
+```
+
+#### Subscribing to validation changes
+
+You can subscribe to validation changes with `onValidate` and get notified when
+any of the formal content of the notebook affected the validation result.
+
+`onValidate` will trigger a validation if the notebook has not been validated
+yet and should always call the callback at least once with the current
+validation.
+
+```ts
+const unsubscribe = notebook.onValidate((result: Result) => {
+ console.log("valid:", result.tag === "Ok");
+});
+```
+
+#### Validation result
+
+An ill-formed notebook results in an `Err` carrying issues.
+
+
+
+```ts
+import { SimpleOlog, Type } from "catcolab-logics/simple-olog";
+import { binder, Instantiation } from "catcolab-documents";
+
+const first = await binder.createNotebook(SimpleOlog, { title: "First" });
+const second = await binder.createNotebook(SimpleOlog, { title: "Second" });
+
+first.add(Type, { label: "A" });
+second.add(Type, { label: "B" });
+
+// A cycle: `first` instantiates `second`, which instantiates `first`.
+first.add(Instantiation, { label: "ImportedSecond", model: second });
+second.add(Instantiation, { label: "ImportedFirst", model: first });
+
+const result = await first.validate();
+console.log("valid:", result.tag === "Ok");
+if (result.tag === "Err") {
+ console.log("issues:", result.content.map((issue) => issue.message).join("; "));
+}
+```
+
+```
+valid: false
+issues: Instantiation cycle detected: "First" → "Second" → "First". A notebook cannot instantiate itself, directly or indirectly. To fix, remove one of the instantiations in this chain.
+```
+
+### Serialization
+
+
+
+
+
+```ts
+import { PetriNet } from "catcolab-logics/petri-net";
+import { binder } from "catcolab-documents";
+
+const notebook = await binder.createNotebook(PetriNet, { title: "Example Petri-net" });
+```
+
+We can dump a notebook.
+
+
+
+```ts
+const petriNetData = notebook.dump();
+```
+
+And load it.
+
+```ts
+const loaded = await binder.loadNotebook(PetriNet, petriNetData);
+console.log("tag:", loaded.tag);
+if (loaded.tag === "Ok") {
+ console.log("loaded:", loaded.content.title);
+}
+```
+
+```
+tag: Ok
+loaded: Example Petri-net
+```
+
+Trying to load a document with the wrong shape yields an `Err` result whose
+issues describe the mismatch.
+
+```ts
+import { SimpleOlog } from "catcolab-logics/simple-olog";
+
+const wrong = await binder.loadNotebook(SimpleOlog, petriNetData);
+if (wrong.tag === "Err") {
+ console.log("issues:", wrong.content.map((issue) => issue.message).join("; "));
+}
+```
+
+```
+issues: Cannot load document with theory "petri-net" using a shape with theory "simple-olog".
+```
+
+We can also load a notebook from a `DocumentRef`.
+
+```ts
+const loaded = await binder.loadNotebookFromRef(PetriNet, {
+ id: "some-document-id",
+ version: null,
+});
+```
+
+### Migrating between logics
+
+
+
+
+
+```ts
+import { Aspect, SimpleOlog, Type } from "catcolab-logics/simple-olog";
+import { Entity, Mapping, SimpleSchema } from "catcolab-logics/simple-schema";
+import { binder } from "catcolab-documents";
+
+const olog = await binder.createNotebook(SimpleOlog, { title: "An Olog" });
+
+const a = olog.add(Type, { label: "A" });
+const b = olog.add(Type, { label: "B" });
+olog.add(Aspect, { label: "has", from: a, to: b });
+```
+
+`migrateTo` returns a `Result`.
+
+```ts
+const migration = await olog.migrateTo(SimpleSchema);
+console.log("tag:", migration.tag);
+if (migration.tag === "Ok") {
+ const schema = migration.content;
+
+ // The original document was rewritten in place, not copied.
+ console.log("same document:", schema.document === olog.document);
+ console.log("theory:", schema.document.theory);
+ console.log(
+ "entities:",
+ schema
+ .cellsOf(Entity)
+ .map((cell) => cell.label)
+ .join(", "),
+ );
+ console.log(
+ "mappings:",
+ schema
+ .cellsOf(Mapping)
+ .map((cell) => cell.label)
+ .join(", "),
+ );
+ console.log("valid:", (await schema.validate()).tag === "Ok");
+}
+```
+
+```
+tag: Ok
+same document: true
+theory: simple-schema
+entities: A, B
+mappings: has
+valid: true
+```
+
+### Diagram notebooks
+
+Diagrams are another type of notebook and have a very similar editing interface.
+
+#### Diagram creation
+
+
+
+ `model` definition
+
+
+
+
+
+```ts
+import { Aspect, SimpleOlog, Type } from "catcolab-logics/simple-olog";
+import { binder, RichText } from "catcolab-documents";
+
+const model = await binder.createNotebook(SimpleOlog, { title: "An Olog" });
+
+const A = model.add(Type, { label: "A" });
+const B = model.add(Type, { label: "B" });
+const has = model.add(Aspect, { label: "has", from: A, to: B });
+```
+
+
+
+
+
+```ts
+const diagram = await binder.createNotebook(SimpleOlog.Diagram, {
+ title: "Olog diagram",
+ in: model,
+});
+```
+
+```ts
+console.log("name:", diagram.title);
+console.log("theory:", diagram.theory);
+```
+
+```
+name: Olog diagram
+theory: simple-olog
+```
+
+#### Adding cells over the model
+
+
+
+```ts
+diagram.add(RichText, { content: "We picture two instances of the olog." });
+
+const x = diagram.add(SimpleOlog.Diagram.Individual, { label: "x", over: A });
+const y = diagram.add(SimpleOlog.Diagram.Individual, { label: "y", over: B });
+```
+
+```ts
+console.log("over:", x.over.label);
+console.log("type:", x.type.obType.content);
+```
+
+```
+over: A
+type: Object
+```
+
+
+
+```ts
+const f = diagram.add(SimpleOlog.Diagram.Aspect, { from: x, to: y, over: has });
+```
+
+```ts
+console.log("over:", f.over.label);
+console.log("from:", f.from.label);
+console.log("to:", f.to.label);
+```
+
+```
+over: has
+from: x
+to: y
+```
+
+### Analysis notebooks
+
+Analyses are yet another type of notebook, again with a similar editing interface.
+Common analysis cell types will be packaged by us into `catcolab-analyses`.
+
+
+
+ `model` definition
+
+
+
+
+
+```ts
+import { SimpleOlog, Type } from "catcolab-logics/simple-olog";
+import { binder, RichText } from "catcolab-documents";
+
+const model = await binder.createNotebook(SimpleOlog, { title: "An Olog" });
+
+const source = model.add(Type, { label: "A" });
+const target = model.add(Type, { label: "B" });
+```
+
+
+
+
+
+```ts
+const analysis = await binder.createNotebook(SimpleOlog.Analysis, {
+ title: "Olog analysis",
+ of: model,
+});
+```
+
+```ts
+console.log("name:", analysis.title);
+console.log("type:", analysis.analysisType);
+```
+
+```
+name: Olog analysis
+type: model
+```
+
+
+
+```ts
+import { Visualization } from "catcolab-analyses";
+analysis.add(RichText, { content: "We visualize the olog." });
+
+const viz = analysis.add(Visualization);
+```
+
+```ts
+console.log("analysis id:", viz.type.id);
+console.log("layout:", viz.params.layout);
+```
+
+```
+analysis id: diagram
+layout: graphviz-directed
+```
+
+```ts
+viz.update({ direction: "horizontal" });
+
+console.log("layout:", viz.params.layout);
+console.log("direction:", viz.params.direction);
+```
+
+```
+layout: graphviz-directed
+direction: horizontal
+```
+
+#### Running an analysis
+
+Running an analysis is an asynchronous function that returns a `Result`: an
+`Ok` carrying the analysis output as `content`, or an `Err` carrying an array
+of issues.
+
+```ts
+const result = await viz.run();
+if (result.tag === "Ok") {
+ const { svg } = result.content;
+ console.log("is svg:", svg.includes("
}>
+ {(m) => (
+
+
+
+ {(cell) => }
+
+
+
+ )}
+
+
+ );
+}
+```
+
+```tsx
+import { Attr, AttrType, Entity, SimpleSchema } from "catcolab-logics/simple-schema";
+async function until(predicate: () => boolean) {
+ while (!predicate()) {
+ await new Promise((resolve) => setTimeout(resolve));
+ }
+}
+
+const notebook = await solidBinder.createNotebook(SimpleSchema, { title: "Company schema" });
+const person = notebook.add(Entity, { label: "Person" });
+const string = notebook.add(AttrType, { label: "String" });
+notebook.add(AttrType, { label: "Integer" });
+notebook.add(AttrType, { label: "Boolean" });
+const name = notebook.add(Attr, { label: "name", from: person, to: string });
+
+const [text, setText] = createSignal("");
+const container = document.createElement("div");
+document.body.appendChild(container);
+const dispose = render(() => , container);
+
+await until(() => testCurrentModel() !== undefined);
+console.log(
+ "all:",
+ [...container.querySelectorAll(".completion-list li")].map((li) => li.textContent).join(", "),
+);
+console.log("codomain:", codomainLabel(testCurrentModel(), name));
+
+setText("in");
+console.log(
+ "filtered:",
+ [...container.querySelectorAll(".completion-list li")].map((li) => li.textContent).join(", "),
+);
+
+const model = testCurrentModel();
+if (!model) {
+ throw new Error("Expected a valid model.");
+}
+const integer = filteredCompletions(model, MyAttrType.obType, "in").find(
+ (id) => label(model, id) === "Integer",
+)!;
+selectCodomain(notebook, name, integer);
+await until(() => codomainLabel(testCurrentModel(), name) === "Integer");
+console.log("codomain:", codomainLabel(testCurrentModel(), name));
+
+dispose();
+container.remove();
+```
+
+```
+all: String, Integer, Boolean
+codomain: String
+filtered: String, Integer
+codomain: Integer
+```
+
+
+
+### Rich text handling
+
+Rich text is a special case because our editor components will need to bypass
+our notebook interface to work with ProseMirror and the Automerge plugin for
+ProseMirror.
+
+We need to define a `getRichTextRef` method on our store for this to work. This
+will add a `editorRef` field to the RichText cell that our editor component can
+make use of.
+
+
+
+Code example
+
+
+
+
+
+```tsx
+import * as Automerge from "@automerge/automerge";
+import { type Doc, getBackend, getObjectId, type Patch } from "@automerge/automerge";
+import {
+ type DocHandle,
+ type DocHandleChangePayload,
+ type Prop,
+ Repo,
+} from "@automerge/automerge-repo";
+import { makeDocumentProjection } from "@automerge/automerge-repo-solid-primitives";
+import { basicSchemaAdapter, init } from "@automerge/prosemirror";
+import {
+ createBinder,
+ defineShape,
+ type DocumentStore,
+ RichText,
+ type RichTextCell,
+} from "catcolab-documents";
+import { SimpleOlog } from "catcolab-logics/simple-olog";
+import { EditorState } from "prosemirror-state";
+import { EditorView } from "prosemirror-view";
+import { createEffect, createSignal, onCleanup } from "solid-js";
+import { For, render } from "solid-js/web";
+import { expect } from "vitest";
+
+import type { Document } from "catcolab-document-types";
+
+type StoreHandle = {
+ readonly docHandle: DocHandle;
+ readonly docView: Document;
+};
+
+function makeAutomergeRichTextStore(): DocumentStore {
+ const repo = new Repo();
+ return {
+ createHandle: async (initialDoc) => {
+ const docHandle = repo.create(initialDoc as Document);
+ return { docHandle, docView: makeDocumentProjection(docHandle) };
+ },
+ getDocumentView: (handle) => handle.docView,
+ changeDocument: (handle, fn) => handle.docHandle.change(fn),
+ subscribe: (handle, callback) => {
+ handle.docHandle.on("change", callback);
+ return () => handle.docHandle.off("change", callback);
+ },
+ copyValue: (handle, value) => {
+ const objId = getObjectId(value as object);
+ return getBackend(handle.docHandle.doc()).materialize(objId!) as typeof value;
+ },
+ getDocumentRef: (handle) => ({
+ id: handle.docHandle.documentId,
+ version: null,
+ server: "",
+ }),
+ getHandle: async () => ({
+ tag: "Err",
+ content: [{ message: "This store cannot resolve references." }],
+ }),
+ // This is needed for the RichTextCellEditor to bypass the notebook interface.
+ getRichTextRef: (handle, cellId) => ({
+ docHandle: handle.docHandle,
+ path: ["notebook", "cellContents", cellId, "content"],
+ }),
+ };
+}
+
+function isPathPrefixOf(candidate: Prop[], target: readonly Prop[]) {
+ return (
+ candidate.length <= target.length &&
+ candidate.every((part, index) => part === target[index])
+ );
+}
+
+function hasStructuralReplacement(patches: Patch[], textPath: readonly Prop[]) {
+ return patches.some(
+ (patch) =>
+ (patch.action === "put" || patch.action === "del") &&
+ isPathPrefixOf(patch.path, textPath),
+ );
+}
+
+function RichTextCellEditor(props: { cell: RichTextCell; onView: (view: EditorView) => void }) {
+ let editorRoot!: HTMLDivElement;
+ const [reinitTrigger, setReinitTrigger] = createSignal(0);
+
+ createEffect(() => {
+ void reinitTrigger();
+
+ // The editorRef is exposed on the cell.
+ const ref = props.cell.editorRef;
+ if (!ref) {
+ throw new Error("RichTextCellEditor: cell has no editorRef");
+ }
+
+ const docHandle = ref.docHandle as DocHandle;
+ const path = [...ref.path];
+ const { schema, pmDoc, plugin } = init(docHandle, path, {
+ schemaAdapter: basicSchemaAdapter,
+ });
+ const state = EditorState.create({ schema, plugins: [plugin], doc: pmDoc });
+ const view: EditorView = new EditorView(editorRoot, {
+ state,
+ dispatchTransaction: (tx) => {
+ if (!view.isDestroyed) {
+ view.updateState(view.state.apply(tx));
+ }
+ },
+ });
+
+ const onRemoteChange = (payload: DocHandleChangePayload) => {
+ if (hasStructuralReplacement(payload.patches, path)) {
+ setReinitTrigger((n) => n + 1);
+ }
+ };
+
+ docHandle.on("change", onRemoteChange);
+ props.onView(view);
+ onCleanup(() => {
+ docHandle.off("change", onRemoteChange);
+ view.destroy();
+ });
+ });
+
+ return ;
+}
+```
+
+```tsx
+const InformalShape = defineShape({ informal: [RichText] });
+
+const store = makeAutomergeRichTextStore();
+const frontendBinder = createBinder(store);
+const notebook = await frontendBinder.createNotebook(SimpleOlog, { title: "Notes" });
+const note = notebook.add(RichText, { content: "" });
+
+let view: EditorView | undefined;
+const container = document.createElement("div");
+document.body.appendChild(container);
+const dispose = render(
+ () => (
+
+ {(cell) => (view = nextView)} />}
+
+ ),
+ container,
+);
+
+view!.dispatch(view!.state.tr.insertText("Hello from ProseMirror"));
+expect(note.content).toBe("Hello from ProseMirror");
+
+const ref = note.editorRef!;
+const docHandle = ref.docHandle as DocHandle;
+docHandle.change((doc) => {
+ Automerge.splice(doc as Doc, [...ref.path], 0, 5, "Howdy");
+});
+expect(view!.state.doc.textContent).toBe("Howdy from ProseMirror");
+
+const staleView = view;
+note.update({ content: "Replaced programmatically" });
+expect(view).not.toBe(staleView);
+expect(staleView?.isDestroyed).toBe(true);
+expect(view!.state.doc.textContent).toBe("Replaced programmatically");
+
+dispose();
+container.remove();
+```
+
+
+
+### Future extensions
+
+#### Path equations
+
+As this RFC is written, path equations are being added to the `simple-olog` and
+`simple-schema` theories in the frontend. `PathEquation` is another cell type,
+with a `CellKind.PathEquation` discriminant, and a shape opts into it via
+`supportsEquations: true`.
+
+
+
+
+
+```ts
+import { Entity, Mapping, SimpleSchema } from "catcolab-logics/simple-schema";
+import { binder, PathEquation } from "catcolab-documents";
+
+const notebook = await binder.createNotebook(SimpleSchema, { title: "Example schema" });
+
+const person = notebook.add(Entity, { label: "Person" });
+const department = notebook.add(Entity, { label: "Department" });
+const company = notebook.add(Entity, { label: "Company" });
+
+const worksIn = notebook.add(Mapping, { label: "works in", from: person, to: department });
+const partOf = notebook.add(Mapping, { label: "part of", from: department, to: company });
+const employer = notebook.add(Mapping, { label: "employer", from: person, to: company });
+
+// An employee's employer is the company their department is part of.
+const equation = notebook.add(PathEquation, {
+ label: "employment",
+ lhs: [worksIn, partOf],
+ rhs: [employer],
+});
+```
+
+
+
+Code verification
+
+```ts
+console.log("equation:", equation.label);
+console.log("lhs:", equation.lhs.map((step) => step.label).join(" ; "));
+console.log("rhs:", equation.rhs.map((step) => step.label).join(" ; "));
+console.log("equations:", notebook.cellsOf(PathEquation).length);
+
+const result = await notebook.validate();
+console.log("valid:", result.tag === "Ok");
+```
+
+```
+equation: employment
+lhs: works in ; part of
+rhs: employer
+equations: 1
+valid: true
+```
+
+
+
+#### Tabular instances
+
+Tabular instances are being added to CatColab. We can envision an API for
+instances that mirrors our Notebook API.
+
+
+
+
+
+```ts
+import { binder } from "catcolab-documents";
+import { SimpleSchema, Mapping, Attr, Entity, AttrType } from "catcolab-logics/simple-schema";
+
+const schema = await binder.createNotebook(SimpleSchema, { title: "Company schema" });
+const person = schema.add(Entity, { label: "Person" });
+const company = schema.add(Entity, { label: "Company" });
+const str = schema.add(AttrType, { label: "String" });
+
+const employer = schema.add(Mapping, { label: "employer", from: person, to: company });
+const name = schema.add(Attr, { label: "name", from: person, to: str });
+```
+
+An instance can have a very similar `add` method.
+
+
+
+```ts
+const instance = await binder.createInstance(schema, { title: "Company instance" });
+const acme = instance.add(company, {});
+instance.add(person, { name: "Alice", employer: acme });
+instance.add(person, { name: "Bob", employer: acme });
+```
+
+A "table" could be retrieved via a `rowsOf` that mirrors `cellsOf` though,
+unlike cells it may be better to keep a separate `values` field on rows, to
+keep them in their own namespace.
+
+```ts
+for (const row of instance.rowsOf(person)) {
+ console.log(row.values["name"]);
+}
+```
+
+```
+Alice
+Bob
+```
+
+Our API could in principle allow us to define logic shapes as notebooks of
+`SimpleSchema`. To illustrate this here is the `SimpleSchema` logic defined as
+a notebook of `SimpleSchema` by aliasing the real `SimpleSchema` to
+`SuperSchema` first.
+
+
+
+
+
+```ts
+import {
+ Attr as SuperAttr,
+ AttrType as SuperAttrType,
+ Entity as SuperEntity,
+ Mapping as SuperMapping,
+ SimpleSchema as SuperSchema,
+} from "catcolab-logics/simple-schema";
+import { binder } from "catcolab-documents";
+const SimpleSchema = await binder.createNotebook(SuperSchema, { title: "simple-schema" });
+
+const Entity = SimpleSchema.add(SuperEntity, { label: "Entity" });
+const AttrType = SimpleSchema.add(SuperEntity, { label: "AttrType" });
+const String = SimpleSchema.add(SuperAttrType, { label: "String" });
+
+const Mapping = SimpleSchema.add(SuperEntity, { label: "Mapping" });
+SimpleSchema.add(SuperMapping, { label: "from", from: Mapping, to: Entity });
+SimpleSchema.add(SuperMapping, { label: "to", from: Mapping, to: Entity });
+SimpleSchema.add(SuperAttr, { label: "label", from: Mapping, to: String });
+
+const Attr = SimpleSchema.add(SuperEntity, { label: "Attr" });
+SimpleSchema.add(SuperMapping, { label: "from", from: Attr, to: Entity });
+SimpleSchema.add(SuperMapping, { label: "to", from: Attr, to: AttrType });
+SimpleSchema.add(SuperAttr, { label: "label", from: Entity, to: String });
+```
+
+Schemas can can then be defined as instances of the `SimpleSchema` notebook as
+follows.
+
+```ts
+const schema = await binder.createInstance(SimpleSchema, { title: "Company schema" });
+
+const person = schema.add(Entity, { label: "Person" });
+const company = schema.add(Entity, { label: "Company" });
+const str = schema.add(AttrType, { label: "String" });
+
+const employer = schema.add(Mapping, { label: "employer", from: person, to: company });
+const name = schema.add(Attr, { label: "name", from: person, to: str });
+```
+
+To use this flexibility for defining logics through notebooks in practice a lot
+of details would still need to be worked out of course.
+
+#### Validated models
+
+There is some awkwardness in the discrepancy between the validated model and
+the corresponding notebook. While we can clearly define the shape of the
+notebook we are working with, when it comes to querying the validated model we
+just have a `DblModel` and its associated methods that don't let us work with
+the same shapes and formal content types we have already defined.
+
+This can be seen in the [SolidJS example with validation &
+completions](#solidjs-example-with-validation-completions) in e.g. this
+extract:
+
+
+
+```ts
+function label(model: DblModel, id: QualifiedName): string {
+ return model.obGeneratorLabel(id)?.join(".") ?? "?";
+}
+
+function filteredCompletions(model: DblModel, obType: ObType, text: string): QualifiedName[] {
+ const needle = text.toLowerCase();
+ return model
+ .obGeneratorsWithType(obType)
+ .filter((id) => label(model, id).toLowerCase().includes(needle));
+}
+
+function codomainLabel(model: DblModel | undefined, morphism: NotebookCell): string {
+ if (!model) {
+ return "?";
+ }
+ const cod = model?.morPresentation(morphism.id)?.cod;
+ return cod?.tag === "Basic" ? label(model, cod.content) : "?";
+}
+```
+
+We can instead imagine passing around a validated model that is
+aware of the underlying shape of the formal content and could be queried
+similar to how our notebooks can be queried.
+
+
+
+```ts
+const model: ValidatedModel = await notebook.validate();
+
+for (const mapping of model.judgmentsOf(Mapping)) {
+ console.log(mapping.label);
+ console.log(mapping.from);
+ console.log(mapping.to);
+}
+```
+
+### Rollout
+
+Our implementation should loosely follow these steps.
+
+1. **Test suite for model notebooks**
+
+ We will implement the tests first. This very document forms part of a spec
+ we can test against. We should write some more tests covering further
+ feature details and edge cases. If possible we should implement property
+ based tests that show our new API will not cause document corruption.
+
+2. **Package for model notebooks**
+
+ A package that meets the spec outline in the test suite. We will not
+ implement diagram or analysis notebooks initially.
+
+3. **CatColab script execution**
+
+ Our initial feature will be a way, possibly through a secret "developer"
+ interface, a user can write Javascript that makes use of this API to create
+ and edit model notebooks.
+
+4. **LLM integration in CatColab**
+
+ Our LLM integration should make use of the CatColab scripting capability to
+ build out a way for users to use LLMs to write scripts.
+
+5. **Test suite & implement analyses and diagrams**
+
+ We will spec and implement a package for analyses and add diagram capability
+ to the relevant models. These will become available to the script execution
+ and LLM integration.
+
+6. **New features use the API & port existing features**
+
+ Once we have proven the stability and usefuleness of the new API we should
+ dictate that any new features need to be made using the new API. We will
+ also begin the process of porting existing features to the new API.