# The Lexicon Format — Reference

Everything the format can express, in one place: every field, every section,
every edge syntax, at every optional layer — plus which of it the app actually
reads today, since a couple of widely-documented pieces (§6) currently do
nothing at all.

Reconciled against `web/src/lib/authoringGuide.mjs` (the app's one generated
source of truth), `lexiconIngest.ts` (the parser), `relationShapes.mjs`, and
`wikilinks.mjs`. Where a document and the code disagreed, the code won and the
difference is noted.

**This file is meant to be handed to a model in one piece.** That is its job —
drop it into Claude, ChatGPT or anything else that reads a file, and ask for
nodes. For a human reading, https://nodemancy.com/docs is written for you and
this is not.

Two file kinds. A **lens** (`_lens.md`) declares a taxonomy. An **entry** (one
`.md` file) is a node: a concept with a title, a definition, and links.

Two lanes, and they change which fields are required. **Vault** — files on disk
under `lexicon/<slug>/`, identity comes from the folder and filename, pushed
with `npm run sync`. **Paste** — markdown pasted into the app's import box, no
filesystem, so several fields a vault gets for free (the lens's own slug, its
parent, which lens an entry belongs to) have to be stated instead. Every field
below is marked with which lane(s) actually need it.

---

## 1. `_lens.md` frontmatter

| Field | Lane | Meaning |
|---|---|---|
| `title` | both | Required. Quote it if it contains punctuation. |
| `slug` | **paste: required** · vault: unused | The lens's own identity, and never guessed from the title. A pasted block with no `slug:` cannot be imported; a vault lens gets it from the folder name. |
| `parent` | both, optional | The macro lens's slug — **only on a micro/child lens.** Omit it on the macro lens itself and on a standalone lens. Two levels maximum: macro → micro, never micro → micro. |
| `authority` | both | `vault` = this file is the source of truth; `npm run sync` overwrites the app's copy with it. `web` = the app owns it — do not hand-author. No `_lens.md` at all defaults to `web`. **A pasted lens is always `authority: web`** — it becomes app-owned the moment it is imported, and `vault` is accepted with a warning and corrected on the way in. |

## 2. `_lens.md` body — only three sections are parsed

The parser reads exactly `## Families`, `## Groups`, `## Relations`. Anything
else under a `##` heading — prose, a `## Facet:` block, a stray section — is
inert. See §6 before reaching for a fourth section.

**Families** — the top-level wings:

```markdown
## Families
<!-- key | Label | #color -->
- parties | Parties | #6478f5
```

**Groups** — rooms; nest under a family with an optional 4th field:

```markdown
## Groups
<!-- key | Label | #color | familyKey -->
- holders | Holders | #6478f5 | parties
```

Every `group` an entry uses must be declared here. An undeclared group **warns
and lands ungrouped** — it does not reject the entry. Grey and near-grey are
refused as group colours: grey means *unread draft*, and it can only mean that
if it never means anything else.

**Relations** — the typed-edge vocabulary. Four columns are the floor; two more
are optional and purely additive:

```markdown
## Relations
<!-- value | Label | directed|symmetric | #color | shape | down|invert -->
- owns          | Owns          | directed | #c14953 | weighted | down
- subsidiary-of | Subsidiary of | directed | #7c4a8c | tree     | invert
```

- **Columns 1–4, required:** `value` (what follows `::` in a wikilink),
  `Label`, `directed` or `symmetric`, `#color`.
- **Column 5, `shape` (optional):** declares what kind of graph this relation
  forms, so the app can flag a contradiction instead of drawing it silently.
  - `tree` — at most one outgoing edge per node, no loops (`reports-to`,
    `subsidiary-of`).
  - `chain` — one in, one out. A succession in an office.
  - `dag` — fans out freely, never loops (`controls`).
  - `weighted` — the edge's `=value` is a share OF THE TARGET; stakes into one
    entity totalling over 100 get flagged (`owns`).
  - `many` — no constraint. **The default, and usually the honest answer:**
    `parent-of` and `casts` are `many` however tree-shaped any one case looks,
    because a person can have three children and a film five leads.
  - A shape only ever produces a **warning**, never a rejection — the moment
    one could reject a write, a vault-authority lens could hold a sentence the
    database refuses. An absent or unrecognised value falls back to `many`, so
    a typo can never manufacture a phantom violation.
- **Column 6, `down`/`invert` (optional, only meaningful with column 5
  present** — a five-field row ending in a word is read as the shape, not the
  direction): `invert` means *this relation's natural sentence puts the source
  underneath* — "Blizzard is a **subsidiary of** Activision" has Blizzard as
  the source, but it belongs below Activision on an org chart — so flip it
  before drawing. `down`, blank, or anything else means the ordinary reading.
  House default is to author hierarchy from the superior
  (`parent-company-of`, written in the parent's own file); `invert` exists so a
  relation that only reads naturally from below does not have to fight that.

## 3. Entry (node) frontmatter

Nine keys are read. `title` is the only one required.

| Field | Lane | Meaning |
|---|---|---|
| `title` | both | Required. If absent, the first `#` heading in the body is used; with neither, the block is an error and will not import. |
| `slug` | **paste: set it whenever the lens tags its slugs** · vault: derived from filename | Skip it and `title: "Sam Dash"` becomes `sam-dash` — unlinkable in a `wg-*` lens where every sibling points at `[[wg-dash]]`. Whatever you supply is normalised by the same rule link targets are, so a slug and a link to it cannot disagree. |
| `lens` | **paste: required for multi-lens documents** · vault: unused | Which lens this entry belongs to, by slug. Absent means "the lens the importer is pointed at", which is right for an ordinary single-lens paste. Naming a lens you do not have is an **error**, with a did-you-mean. |
| `group` | both, effectively required | Must match a key in this lens's `## Groups`. Undeclared → warning, entry lands ungrouped. |
| `kind` | both, optional | Freeform entity type (`person`, `event`, `organization`…). **Not declared anywhere** — see §6. Match what the lens already uses; do not invent a category. |
| `event_date` | both, optional | `YYYY-MM-DD`, or it is ignored with a warning. **The paste importer reads it.** The stripping described in older documentation happens in the *assisted-generation* pipelines, where generated content does not get to assert dates — a hand-pasted or vault-authored date survives. |
| `aliases` | both, optional | A bare string, or a YAML array: `["Ned", "Lord Stark"]`. |
| `visibility` | both, optional | `public` (default) or `restricted`. Restricted keeps the node yours in every lens it appears in, and it is never sent to a hosted model. Any other value reads as public. |
| `status` | both, optional | Freeform workflow chip: `stub`, `draft`, `needs-sources`. Stored, never interpreted. |
| anything else | lane-dependent | **Reported and dropped** by the paste importer — by name, in the preview, then the node imports without it. A vault sync keeps unknown keys verbatim. Put facts in the body, not in an invented key. |

Two keys are exceptions to that last row. `synced_hash` is skipped silently
because it is the sync tool's own bookkeeping. `type` is skipped silently by
the paste lane and takes the database's default — it is a record marker, not
something you set. If you meant the entity type, that is `kind`.

**Slug normalisation**, applied to whatever you write: lowercase everything,
replace every run of non-`[a-z0-9]` with a single `-`, strip leading and
trailing `-`. So `"R'hllor, Lord of Light"` becomes `rhllor-lord-of-light`, and
`slug: WG Dash` becomes `wg-dash`.

⚠️ Slugs are unique across a **whole account**, not per lens. That is why most
lenses prefix theirs (`wg-`, `got-`) — and why an untagged `the-supreme-court`
in one lens collides with the same title in another.

**Body, in order:** `# Title` heading → `**Definition.**` (1–4 sentences, no
wikilinks inside it — it is reused as a label and a tooltip, where raw `[[…]]`
shows through as noise) → `**See also.**` (comma-separated wikilinks).

## 4. Edges — four forms, one syntax

```
[[target-slug|Display Label]]                     plain
[[target-slug::relation|Display Label]]           typed
[[target-slug::relation=value|Display Label]]     typed, carrying a value
[[target-slug::relation=value@from..to]]          any of those, dated
```

- The label is what a reader sees; the slug is what resolves. Never put `|`
  inside a label. A trailing `#anchor` is parsed and ignored.
- `relation` must be declared in the lens's `## Relations`; an undeclared one
  falls back to a neutral edge with a warning. The link still draws — it just
  stops making a claim.
- `value` is **everything after the first `=`, kept verbatim** — it is not
  always a number. `owns=12` is a share; `officer-of=Chairman` is a title in
  the same slot. Write a bare number for a share (`owns=51`, not `owns=51%`;
  the `%` is tolerated and adds nothing).
- **One value per relation per target, when the edges are undated.** `owns=51`
  and `owns=49` on the same pair with no dates is a contradiction, not two
  edges — only the first is kept. Dated differently, they are two facts about
  two times, and both are kept.

### Spans — when an edge held

Everything after the last `@` is the range.

```
[[atvi::owns=51@2013-10-11..2016-01-05]]    closed
[[atvi::owns=100@2023-10-13..]]             held since; no closing date
[[atvi::owns=12@..2016-01-05]]              held until; start not established
[[atvi::owns=12@2013-10-11]]                a POINT: that day alone
[[wg-nixon::chaired@1940-05-10..1945-07-26]]  dates without a value
```

The `..` is what makes a span open-ended. A bare `@2013-10-11` means that day
and no other — "as of" prose usually means "from then on", and a silent
"and thereafter" is the kind of default that turns a typo into a claim.

**Write the precision you have.** A bound may be a year, a month, or a day. It
is stored as the first day of the period named, together with the precision it
was claimed at, so the pair reconstructs the whole window and nothing is
invented:

| written | means |
|---|---|
| `@2016` | somewhere in 2016 |
| `@2016-03` | somewhere in March 2016 |
| `@2016-03-14` | that day |

`@2016..2016` is a whole year, not one second on New Year's Day.

A value containing `@` survives: `officer-of=Chairman@Large` keeps its `@`,
because the text after the last `@` is read as dates only when it actually
parses as a date range. `2013-02-30` is refused rather than rounded.
- **Direction.** Symmetric relations (`allied-with`, `married-to`) go on
  **both** endpoints, or the graph only reads correctly from one side. Directed
  relations (`parent-of`, `owns`) go on the **source only**, pointing at the
  target.
- **A link to a slug that does not exist is not an error.** It draws nothing
  and waits — useful when writing toward a node you have not made yet, and
  expensive when you meant to hit one that exists and mistyped its name.

⚠️ **A wrong typed edge is worse than a plain one.** Read it back as a sentence
— *subject, relation, target.* If that sentence is false, use a plain link
rather than forcing the nearest available relation. Real example from this
codebase: a model wrote `Sam Dash [[wg-dean::informed-on]]`, which reads as
"Dash informed on Dean" — backwards; Dean was the informant. The lens had no
relation for "was counsel to", so the nearest one got used. The fix was a plain
link.

## 5. Multi-lens skeletons (paste lane) — macro/micro in practice

Building more than one lens at once — a macro lens plus its children — has its
own shape:

- **One block** holds every lens in the batch: macro lens first, then its
  children in whatever order. Four lenses is one document, not four.
- The macro lens: no `parent:`.
- Each child: `parent: <macro-lens-slug>`.
- Every lens in the block: `slug:` (required) and `authority: web`.
- Entries follow in later, separate batches, each carrying
  `lens: <target-lens-slug>` in its frontmatter (§3).

Lenses and entries *can* share one document — lenses are created first, then
entries, and an entry naming a lens that could not be created is held back and
reported rather than filed somewhere arbitrary. Doing it in two passes is still
easier to check.

Two levels only. No lens nests under a lens that is itself someone's child. A
lens becomes a *macro* lens the moment another names it as parent — the status
is derived from the arrangement, never a switch you set.

## 6. What is declared but not real

Two things that look like schema and are not:

- **`## Facet: <name>` sections do nothing.** They are real in older
  documentation, in the bundled skill's templates, and in the bundled
  `validate_lens.py` — which *does* parse and check them. But the live parser
  only reads Families / Groups / Relations. Writing `## Facet: kind` and
  expecting the app to validate `kind:` values against it will not work: `kind`
  is whatever string an entry uses, matched against convention, never
  declaration.
- **`type: lexicon-entry`** appears in the skill's entry template and in older
  examples. It is not read. Harmless to include; does nothing if you do.

Both are safe to omit. Real lens files that still carry them are fine as-is —
just do not treat their presence as evidence the app enforces them.

## 7. A minimal, fully connected example

Small on purpose. A taxonomy this size with disconnected nodes is the exact
failure worth designing against: output that is syntactically valid and
semantically empty — everything in one group, nothing linked. Every node below
links to at least one other; that is the part worth copying, not the field
list.

```markdown
---
title: "Example Holdings"
slug: example-holdings
authority: web
---

# Example Holdings

## Families
<!-- key | Label | #color -->
- parties   | Parties   | #6478f5
- companies | Companies | #2dcca0

## Groups
<!-- key | Label | #color | familyKey -->
- holders | Holders     | #6478f5 | parties
- target  | The Company | #2dcca0 | companies

## Relations
<!-- value | Label | directed|symmetric | #color | shape -->
- owns            | Owns            | directed  | #c14953 | weighted
- officer-of      | Officer of      | directed  | #4a8c6b | many
- co-founded-with | Co-founded with | symmetric | #8a5b70 | many
```

```markdown
---
title: "Acme Corp"
slug: acme-corp
lens: example-holdings
group: target
kind: organization
---

# Acme Corp

**Definition.** The holding company at the center of this example.

**See also.** [[jane-holder|Jane Holder]]

---
title: "Jane Holder"
slug: jane-holder
lens: example-holdings
group: holders
kind: person
---

# Jane Holder

**Definition.** Majority owner and CEO of Acme Corp.

**See also.** [[acme-corp::owns=60|Acme Corp]], [[acme-corp::officer-of=Chief Executive Officer|Acme Corp]], [[max-holder::co-founded-with|Max Holder]]

---
title: "Max Holder"
slug: max-holder
lens: example-holdings
group: holders
kind: person
---

# Max Holder

**Definition.** Co-founder and minority owner of Acme Corp.

**See also.** [[acme-corp::owns=40|Acme Corp]], [[jane-holder::co-founded-with|Jane Holder]]
```

Three entries, six edges, zero orphans, and one `weighted` relation whose two
stakes sum honestly to 100 rather than 200. It shows the two shape declarations
you will use almost everywhere (`weighted`, `many`) without needing the 6th
`invert` column — that one is demonstrated in §2.

## 8. What the import preview tells you

| Status | Meaning |
|---|---|
| ready | Will import. |
| exists · skip | A node with that slug is already here. A collision, not an error — nothing is overwritten. |
| error | Will not import, and says why. Missing title, a title with no letters or digits to form a slug, an unknown lens, or two blocks claiming one slug. |
| warn | Will import, with something undeclared, unresolvable or dropped. |

Nothing is written until you confirm. A code fence *wrapping* the payload is
recognised and removed, so pasting a whole chat reply — prose, fences and all —
works.

## 9. Sources, in order of authority

`web/src/lib/authoringGuide.mjs` (generated, current) → `lexiconIngest.ts`,
`relationShapes.mjs`, `wikilinks.mjs` (the parsers, which win any disagreement)
→ https://nodemancy.com/docs (written for a human reader) → the bundled
`lexicon-authoring` skill and its `validate_lens.py`, which is **behind** the
app on both points in §6.
