# Data Schemas

A data schema is the shape of a content type — what fields a `person` has, what a `product` looks like. You write it once, and it does three jobs at the same time: `uniweb validate` **checks** your data before it ships, a fetch of the type **reaches** the components that declare it, and the editor renders a **form** authors fill in. Define the shape once instead of re-describing it in a validator, a fetch handler, and a form.

A schema describes a record; it never changes one. A record reaches your component as it is — a field it lacks is absent — so a schema file declares no `default:` (the build refuses one). What an absent field renders as is your component's choice.

This guide is about the schema *definition* — authoring one, sharing it across projects, and registering it as a reusable content type. For how data is fetched and delivered to components at runtime, see [Working with Data](./working-with-data.md).

---

## What you get

| What you write | What you get |
| --- | --- |
| The shape, once, in a schema file | `validate` on your data, and a form in the editor — from one definition |
| A reference by name (`@/product`) | The reference is a name, not a path — move or rename the implementation freely |
| Shared schemas in one place (`@org/schemas`) | Fix `person` once; every foundation that references it picks up the change |

---

## Authoring a schema

A schema is a file in your foundation's `foundation/schemas/` folder. The simplest form is a flat list of fields:

```yaml
# foundation/schemas/product.yml
name: product
description: A product offered by the client.
fields:
  name:        { type: string, required: true }
  tagline:     { type: string }
  price:       { type: number }
  image:       { type: image }
  description: { type: markdown }
  url:         { type: url }
  featured:    { type: boolean }
```

The types are plain and content-oriented — `string`, `text`, `markdown`, `number`, `boolean`, `date`, `datetime`, `image`, `url`, `email`, `richtext`, plus structural `object` (also spelled `group`), `array`, and `ref` (a reference to another schema). You write the word that fits the content; the framework folds the friendly names to canonical kinds (`markdown` is a `text` field carrying that rich-content `format`, `richtext` is a ProseMirror `json` document, `image` is a file, `number` is a decimal).

### Describing the schema itself

Beside its content, a schema can carry a few keys about the model as a whole. All are optional:

```yaml
name: session                 # the schema's name — this is its identity
label: Session                # a display name, for people
plural: Sessions              # what many entries are called
icon: lu-presentation         # the schema's icon — a framework icon name
description: A conference talk.
source_locale: en             # the language the inline text is written in
linkable: true                # may other schemas reference this one? (default: yes)
grantable: true               # may an entry be shared, one at a time? (default: yes)
```

`label`, `plural` and `description` are plain strings in the source language — translations live in `locales/`, so `label: { en: 'Session' }` is a different shape and is rejected rather than quietly accepted. `plural` is written out because it cannot be worked out from the label: a `Person` schema's entries are `People`.

**`icon`** names the schema's icon with a framework icon name, written `family-name` — `lu-user` is the icon `user` of the Lucide family `lu`, the same spelling content uses for an icon (`![](lu-house)`). It is one value for every language. A name that is not `family-name` stops the build.

**`linkable`** controls whether this schema's entries may be referenced — by another schema's `ref` field, and, on a backend, as a site's records. It is `true` unless you write `linkable: false`, with or without a `brief:` section. The brief *is* what a reference shows, so a reference to an entry of a schema without one has no card to show.

**`grantable`** controls whether, on a backend, one entry can be shared with a person or a team — to read it, edit it or administer it. It is `true` unless you write `grantable: false`, which keeps every entry of the schema from being shared that way. A backend may refuse to turn it off while an entry of the schema is shared.

**There is no key for who may create entries.** When a backend stores your content, anyone signed in may create entries of any schema. What protects content is the entry itself: who may see or edit an entry is decided by the entry — its owner, and what they share — never by who may create one. `creatable_by` is no longer accepted: a schema carrying it is refused, so a restriction you believe you declared is never silently absent.

> An unrecognised key here is reported as a warning and not carried, so a typo like `lable` tells you rather than leaving you with a key that does nothing.

A section type names the schema in its `meta.js`, under the key it reads:

```js
// foundation/sections/Products/meta.js
export default {
  title: 'Products',
  data: { products: '@/product' },   // content.data.products, shaped by @/product
}
```

Your component then reads `content.data.products` — a list of products, each as its record holds it, so a field a product lacks is absent and the component decides what to show instead (`product.price ?? 0`). The declaration is what the section receives: a component that doesn't declare `products` receives no `products`. (The full `data:` reference — inline field maps and the editor "rich form" — is in [Component Metadata → Data](../reference/component-metadata.md#data).)

For content with more structure than a flat record — say a profile with a bio plus a list of publications — a schema declares named `sections:` instead of `fields:`. Each section is one record by default, or a repeating list (`many: true`):

```yaml
# foundation/schemas/person.yml
name: person
sections:
  identity:
    brief: true                      # single is the default; the card shown when referenced
    fields:
      name: { type: string, required: true }
      role: { type: string }
  publications:
    many: true                       # a repeating list of records
    fields:
      title: { type: string }
      year:  { type: number }
```

The flat `fields:` form is the common case; reach for `sections:` only when a single record genuinely can't express the content.

**The `fields:` form is one section.** It is `sections:` with a single section named `brief`, marked `brief: true` — the whole record is its own card. When a schema outgrows it, write that section out and add the others beside it:

```yaml
# foundation/schemas/product.yml — what `fields:` was, plus a section
name: product
sections:
  brief:
    brief: true
    fields:
      name:  { type: string, required: true }
      price: { type: number }
  specs:
    fields:
      weight: { type: string }
```

Keep the name `brief` if records of the schema are already stored in a backend: a section's name is where its data is kept. The record files change with the schema — a schema of more than one section is written by section, so each record's fields move under `brief:` ([Entity Content](../reference/entity-content.md)); the build names every field that is left at the top. Naming a schema's brief section `brief` is a good habit in any schema: whoever reads a record knows at a glance which section is its card.

### Append-only sections

A `multi` section can be marked **insert-only** with `append_only: true` — records may be added, but never edited or deleted:

```yaml
# foundation/schemas/membership.yml
name: membership
sections:
  identity:
    brief: true
    fields:
      name: { type: string, required: true }
  activity:
    many: true
    append_only: true              # records accumulate; existing ones are immutable
    fields:
      at:    { type: datetime }
      event: { type: string }
```

Once you [register](#registering-schemas) the schema, this rule is enforced wherever entities of the type are written — appends are accepted, but changing or removing an existing record is refused. There's no "replace the whole section" shortcut either: re-submitting records adds new ones rather than overwriting what's already there. Because the rule lives in the content type rather than in a form, it holds for every writer, not just the editor UI. That makes an append-only section **tamper-evident**: the accumulated history stands on its own. Reach for it for activity logs, submissions, audit trails — anything meant to accumulate and never be rewritten.

`append_only` is only valid where there are many records to append to — a `many: true` section, or a list of records authored as a field (`activity: { type: object, many: true, append_only: true }`). On a single record it's rejected rather than ignored. Records in files have no write step, so it has no effect until the type is registered.

### When the content *is* a list

Some content isn't a record with parts — it *is* a list. A navigation menu is a list of items; a form is a list of controls. Declare that as **one `many: true` section and nothing else**, and the content is a bare list with no wrapping key:

```yaml
# foundation/schemas/menu.yml
name: menu
sections:
  items:
    many: true
    fields:
      label: { type: string, required: true }
      href:  { type: string, translatable: false }
```

````markdown
```yaml:menu
- label: Home
  href: /
- label: Docs
  href: /docs
```
````

**No `brief:` here, and that's right** — there's no single record to be the card, so the type just isn't referenceable from another schema. `uniweb validate` checks each record and names its index (`[1].label`), and defaults are applied per entry. This is how `@std/nav` and `@std/form` are built, and both declare [`linkable: false`](#describing-the-schema-itself) as well.

One limit: it must be *exactly one* section. Two `many` sections with no single one leaves "which one is the value?" unanswerable, so nothing is checked.

**A list-shaped block carries no title of its own, and that's deliberate.** A heading and an intro are *content* — the author writes them as markdown in the section, and your component reads them from `content.title` and `content.paragraphs` exactly as it would for any other section. The block carries only the structured part.

It's worth stating because the alternative is tempting and wrong: putting a `title:` inside the block gives the section two titles, one editable as ordinary content and one buried in a data block, with nothing to say which wins. Keep the prose in the prose.

### Tree sections

A `many: true` section can be marked `tree: true`, letting its records nest **under each other** — a chapter tree, a category hierarchy, a threaded discussion:

```yaml
# foundation/schemas/handbook.yml
name: handbook
sections:
  identity:
    brief: true
    fields:
      title: { type: string, required: true }
  chapters:
    many: true
    tree: true                     # a chapter can sit under another chapter
    fields:
      title: { type: string }
      body:  richtext
```

Authors nest records under a reserved **`children:`** key — you declare one record's fields, and `children` holds more of the same:

```yaml
- title: Getting started
  children:
    - title: Installing
    - title: Your first page
```

The parent/child link is managed for you — there's **no field to declare** for it and no ID to wire up. `uniweb validate` descends into `children` to any depth and names the full path (`[1].children[0].title`), so a bad entry deep in a tree is findable.

This is what separates `tree:` from a [subsection](./designing-data-schemas.md#subsections-model-hierarchy). A subsection nests one *named* section inside another — a course has modules, a module has lessons, a fixed shape you spell out in the schema. `tree:` instead lets records of a **single** section nest under one another, so the shape is decided by the author as they write, not by you as you model.

`tree: true` is only valid where there are many records to nest — a `many: true` section, **nested sections included**, or a list of records authored as a field (`chapters: { type: object, many: true, tree: true }`). On a single record, or on a list of plain values, it's rejected rather than ignored. `nestable: true` is accepted as a lower-level spelling of the same flag.

### The sort axis: `sort_date`

A schema can name **which date field its records sort by** — the axis a feed, an archive, or a "latest first" listing orders on:

```yaml
# foundation/schemas/post.yml
name: post
sort_date: published_on          # names a date field below
fields:
  title:        { type: string, required: true }
  published_on: { type: date }
  body:         richtext
```

`sort_date` is a **schema-level** key, and its value is the **name of a date field** — not `true`/`false`, and not something you put on the field itself. With the flat `fields:` form the whole field set is the record's card, so any date field there can be the axis. With the `sections:` form, name a field in the **brief** section; a schema with no brief has no sort axis. It takes effect once the schema is [registered](#registering-schemas). (`sortDate` is accepted as an alias.)

---

## Field types and formats

The friendly type you write folds to a small set of **canonical kinds** the framework stores and ships. Write the word that fits the content — the framework normalizes it:

| You write | Canonical kind | Holds |
| --- | --- | --- |
| `string` | `string` | A short, single-line value |
| `text` | `text` | Long-form text (see `format` below) |
| `number` | `decimal` | A number |
| `integer` | `int` | A whole number |
| `boolean` | `bool` | `true` / `false` |
| `date`, `datetime` | `date`, `datetime` | An ISO-8601 date — a day, or a month or a year with [`format`](#a-fields-rules) — / a timestamp |
| `image` | `file` | A path or URL to a file |
| `url`, `email` | `string` (+ `format`) | A validated string |
| `markdown` | `text` (+ `format`) | A rich-content body (a source string) |
| `richtext` | `json` (+ `format: richtext`) | A rich document edited in the visual app |
| `json` | `json` | An opaque structured value (see `format`) |
| `object`, `group` | `object` | A nested record — declare its `fields:`. `group` is the friendlier spelling |
| `ref` | `ref` | A reference to another schema — write `{ ref: '@/person' }` |

You can always write the canonical kind directly; the friendly names just save you the folding.

**References.** A `ref` field points at a record of another schema. In a record's file, write the name of the record it points at — its file's name — so `speaker: ada` points at `records/speaker/ada.yml`; a `many: true` reference is a list of names. A component receives the record it points at reduced to its brief, as `{ entity, brief }` — so `talk.speaker.brief.name` — with `entity` the record's id where it has one ([Entity Content](../reference/entity-content.md#what-a-component-receives)). With a backend, `uniweb push` sends each reference to the record it names — in the same push when that record is new too — and `uniweb pull` writes the name back.

**Lists use `many: true`.** Any field or section becomes a list by adding `many: true` — `{ type: string, many: true }` (a list of strings), `{ ref: '@/course', many: true }` (a list of references), or a `many: true` section (a repeating list of records). List-level flags like `required` ride on the list; the type describes each item.

### A reference that opens its entry: `readable_with`

On a backend, a reference can carry access. A `ref` field that declares `readable_with` opens the entry holding it — to read, never to change — to whoever holds that level on the entry the reference points to:

```yaml
# foundation/schemas/submission.yml
name: submission
fields:
  title:  { type: string }
  course: { ref: '@/course', readable_with: edit }   # read by whoever may edit the course
```

A submission's `course` declaring `edit` is read by the course's staff and by none of its students; a lesson's `course` declaring `read` is read by everyone who may read the course. The level is `read`, `edit` or `admin`, and only a reference — a `ref`, or a list of them — can declare it; any other field is refused. It reaches one step: the course's own references open nothing further. The link is made from the entry's side, by setting the field. And a backend refuses adding it to a field entries already hold a value in, so references written as plain ones never become access later.

### A reference a purchase travels along: `sold_with`

On a backend that sells access to records, a reference can say that buying the entry holding it also buys what it names. Declare `sold_with: true` on it:

```yaml
# foundation/schemas/course.yml
name: course
fields:
  title:   { type: string }
  lessons: { ref: '@/lesson', many: true, sold_with: true }   # a buyer of the course reads its lessons
  mentor:  { ref: '@/person' }                                # buying the course buys nothing here
```

A buyer of a course reads, through it, the lessons its `lessons` field names — each element of a list is its own link — and onward along those entries' own `sold_with` fields, up to three steps (course → module → lesson → quiz). A reference without it carries nothing. It is a boolean, and only a reference — a `ref`, or a list of them — can declare it; any other field is refused. `false` says what leaving it out says. Unlike `readable_with`, it may be added to or removed from a field entries already fill.

Every list has an element type: `many: true` takes it from the field itself, and the lower-level `array` + `items:` states it explicitly. Writing `type: array` with no `items:` leaves it genuinely unknown, and a registered schema records the elements as opaque rather than guessing at them.

`required` holds on a list of values (`{ type: string, many: true, required: true }`) and on a list of references. It does **not** hold on a list of *records* or on a nested `object` — both become sections once the schema is [registered](#registering-schemas), and `required` binds the record that is *written*; it cannot force a record to *exist*. Put the flag on a field inside the record, and see [Constraints](#constraints) for "don't let this become empty".

### Constraints

A nested record and a list of records become **sections** when a schema is registered, and a section can carry rules a single field can't express. Declare them with `constraints:` — on the section in the `sections:` form, beside `fields:` in a schema written with `fields:` (they rule its one section), or on a nested-record field itself:

```yaml
fields:
  authors:
    type: object
    many: true
    constraints:
      - { kind: min_items, value: 1 }
    fields:
      name: { type: string, required: true }
```

`min_items` is the common one, and its scope is narrower than the name suggests:

- **A delete floor, not a fill requirement.** It refuses a delete that would take the section below N. Nothing forces an author to populate it in the first place. On a single-record section, read it as *"undeletable once created"*.
- **A write guarantee, never a render guarantee.** Your component still handles an empty list — the same schema can be rendered by a foundation that never saw the constraint, so content and code stay independent axes.

A rule names fields of its section, so it is never declared on a plain value: a schema that puts `constraints` on one is refused, pointing at the section's `constraints:`. A plain value narrows with `enum`, `format` and [its own rules](#a-fields-rules) instead. Constraints take effect once the schema is [registered](#registering-schemas) — records in files have no write step.

### Rich content: `format`

A `format` marks a field as carrying rich content. It is **type-bound** — the framework rejects a mismatch at build time:

| `format` | Valid on | Use it for |
| --- | --- | --- |
| `markdown` | `text` | A markdown body that round-trips as plain source |
| `richtext` | `json` | A rich document edited through a structured editor |
| `scene` | `json` | A visual scene composition (rendered by `@uniweb/scene`) |

```yaml
fields:
  summary: { type: markdown }  # a source body (round-trips as markdown text)
  body:    richtext            # a rich document, edited in the visual app (json + format: richtext)
```

The friendly aliases set these for you — `type: markdown` is exactly `type: text, format: markdown`, and **`type: richtext`** is exactly `type: json, format: richtext`. **Use `richtext` for a rich body edited in the visual app** — the common case; it's the editor's native, lossless document. Use `markdown` for a **source body** authored as text (file-based projects, or content you want to keep readable as raw source). Don't reach for `markdown` just because it's the familiar word — if it'll be edited visually, you want `richtext`.

There is no `html` type or format. Content is never markup — a component decides how content renders — so a schema declaring one is refused, naming `richtext` and `markdown`. And `format: prosemirror`, the earlier name of `format: richtext`, is refused naming its successor.

Other formats check a value rather than mark it — `email` and `url` on `string` and `text`, `currency` on `string` — and on a `date` the format is its precision ([A field's rules](#a-fields-rules)). A format the field's kind does not take is refused when the schema is built.

`format: icon` on a `string` says the value is an icon name, written the way content names an icon: `lu-flame` is the icon `flame` of the family `lu`, as in `![](lu-flame)`. The value is kept as written — nothing checks it — and, like any string with a format, it is one value for every language:

```yaml
fields:
  icon: { type: string, format: icon }   # like lu-flame
```

### Translatable fields: `translatable`

Text and rich-content fields are **translatable by default** — the framework keeps one value per locale. Set `translatable: false` to opt a field out (an ID, a slug, a machine code that's the same in every language):

```yaml
fields:
  title: { type: string }                        # translatable by default
  body:  { type: text, format: markdown }        # translatable by default
  sku:   { type: string, translatable: false }   # one value across all locales
```

You author a translatable field as a plain value in your source locale; translations live in the `locales/` folder. See [Internationalization](./internationalization.md).

### Picklists: `enum` and `options`

Two ways to constrain a field to a set of choices:

```yaml
fields:
  status:   { type: string, enum: [draft, published, archived] }  # inline list
  country:  { options: '@/lists/countries' }                      # a list kept as content
  province: { options: '@/lists/countries/subdivisions' }         # a nested list, by its path
```

- **`enum:`** — an **inline** list of allowed values. Best for a short, fixed set. An entry can also be `{ value, label }` — `{ value: published, label: Live }` — to give an editor a label to show; a record stores the `value`.
- **`options:`** — the **section of a lists schema** that holds the choices: the schema's ref, then the section's name — a nested section by its path. Best when the choices are a managed list, reused across fields or foundations, that people curate as content. A field stores the chosen item. Keep the lists a project shares in **one lists schema, a section per list**; a schema whose top holds a single list may be named alone (`options: '@/countries'`), and one with several is refused until the field names its section.

An inline array always belongs on `enum:`; `options:` always names a schema. A field cannot take its choices from a section of its own schema — keep the list in a schema of its own. When the schema is built, an `options:` that names no section of the schema, or a section that holds only other sections, is refused, naming the schema's lists.

### A field's rules

A field can carry the rules a backend checks on every write, as its own keys:

```yaml
fields:
  title:   { type: string, max_length: 250 }                        # characters
  age:     { type: integer, min: 0, max: 150 }
  photo:   { type: image, mime_types: ['image/*'], max_size: 2000000 }  # bytes
  fee:     { type: string, format: currency }                       # an ISO 4217 code, like CAD
  started: { type: date, format: year-month }                       # holds '2024-05'
  year:    { type: date, format: year }                             # holds '2024'
  code:    { type: string, pattern: '[A-Z]{2}[0-9]{4}' }            # the whole value, like AB1234
```

| Rule | On | Keeps the value to |
| --- | --- | --- |
| `max_length` | `string`, `text` | that many characters — each language of a translatable value on its own |
| `min`, `max` | `integer`, `number` | that range |
| `mime_types`, `max_size` | `image`, `file` | those types (`image/*`, `application/pdf`) and that many bytes |
| `format: year-month`, `format: year` | `date` | a month or a year instead of a day |
| `format: email`, `url`, `currency` | `string` — `email` and `url` on `text` too | a valid value of that kind |
| `pattern` | `string` | a regular expression the whole value matches |

A date's value is text at every precision. YAML reads an unquoted `2024` as a number, so a year is written `year: '2024'`; a month, `2024-05`, needs no quotes. A day written short, `2024-5-3`, is a day too, and a backend stores it in full, `2024-05-03`.

A `url` is a web address: `http://` or `https://`, or one written without a scheme that starts with a host name — `example.com/cv` — which a backend stores as `https://example.com/cv`. A path (`/about`) or another scheme (`mailto:`, `ftp:`) is not one; a link inside your own site is a `string`. It is also one a browser can open: a port above 65535, an unclosed `[`, or a character no host name may hold — `<`, or a space written `%20` — makes it not one.

A `pattern` matches the **whole** value: `[0-9]{4}` is four digits, not four digits somewhere in the text. On a `url` it matches the address as stored, with `https://` in front. It is written in the syntax JavaScript and a backend read the same way, so what they read differently is refused when the schema is built, each by name: look-around (`(?=…)`, `(?<!…)`), back-references (`\1`), named groups, flags (`(?i)` — write `[Aa]`), Unicode property classes (`\p{L}`) and POSIX ones (`[[:alpha:]]`). Inside a class, `( ) [ ] { } / |` and a `-` that is not part of a range are escaped: `[A-Za-z0-9_\-]`. A `pattern` checks a value only when there is one — an empty value is `required`'s concern.

A field takes only the keys this page describes. One it does not — `maxLength` (it is `max_length`), `placeholder`, `min_length` — is refused by name when the schema is built, rather than dropped. How a field is edited — a widget, a placeholder — belongs to the component that edits it, and a field's guidance for the person filling it is its `description`: a schema's field declares no `help`.

---

## What `uniweb validate` checks

A schema only earns its keep where something runs it, so it's worth knowing exactly where that happens. `uniweb validate` walks your site and checks every data input it can pair with a schema:

| Where the data lives | Checked against |
| --- | --- |
| **Records a query delivers** (`query: articles`) | The schema of the key they fill in the section's `meta.js` — each record, or all of them as one list when that schema's root is a list |
| **Every record file** in `records/` | The schema its folder names (`records/member/` → `@/member`) — whether or not a section reads it, since a push sends every record |
| A **tagged data block** (` ```yaml:form `) | The schema the section declares for that *tag*, whether the value is a record or a list |
| A **concept block** (` ```md:faq `) | Nothing — its structure comes from the markdown itself, one item per heading |

A violation **fails** `uniweb validate` (exit `1`); `--lax` reports without failing. `uniweb push` and `uniweb publish` run the same check and stop before sending anything, because the backend checks your records against the same schemas; `uniweb deploy` to a static host warns and carries on.

A record file is checked as it is written: flat for a schema of one section, **by section** for any other — each section under its own key, a list section as a list of records. A field written at the top of a record of a schema with more than one section is reported, naming the section it belongs in:

```yaml
# records/course/rust.yml — written by section
identity: { title: "Rust 101" }
modules:
  - title: "Getting Started"
```

The records a query compiles are checked against their schema too, each section where the schema puts it. A component receives each one as its brief, or whole ([Entity Content → What a component receives](../reference/entity-content.md#what-a-component-receives)).

Values are checked by their type and their field's rules as well — a `date` must be a real `YYYY-MM-DD` day, or a month `YYYY-MM` or a year `YYYY` when its `format` says so, written as text; a `datetime` a day and a time; a `url` a web address; and a value over its `max_length`, outside its `min`/`max`, not matching its `pattern`, a currency that is not a three-letter code, or a file of a type or size its `mime_types`/`max_size` exclude, is reported ([A field's rules](#a-fields-rules)).

One thing is reported as **deferred** rather than checked, for an honest reason — the data isn't there to look at: an **external query** (`url:`), which isn't fetched at build time. A schema whose root is a list is checked as the list: the records a query delivers, or the list a record file holds under its section's key.

An inline schema in a section's `data:` is reported too, rather than guessed at. A reference must name a record of its schema: one that names none is reported, and a push refuses it. An `options` value is not checked against the list it names.

**What is never checked, by design:** a visitor's answers to a form, an API response shape, or anything else that only exists at runtime. `validate` is a pre-ship gate over content you can see, not a runtime guard — the runtime stays tolerant, applying defaults and ignoring the rest.

---

## Three namespaces

A schema reference is a name in one of three namespaces. The prefix says where the definition lives, and who owns it:

| Ref | Means | Lives in |
| --- | --- | --- |
| `@/product` | **Your own** — a schema this foundation defines | `foundation/schemas/product.{yml,yaml,js,json}` |
| `@std/person` | A **shared standard** — common types anyone can use | the `@uniweb/schemas` package |
| `@acme/product` | **An org's** shared schema | that org's `@acme/schemas` package |

`@/` is the *self* namespace — "this foundation's own." Because you never write your own org name in your source, `@/`-refs stay portable: the same foundation can be registered under any scope. The standards (`@std/person`, `@std/event`, `@std/article`, `@std/project`, …) cover the shapes most sites need — reach for one before inventing your own, the same way you'd pull a well-known type off the shelf rather than redefining it.

Add `@uniweb/schemas` as a dependency when you reference any `@std/<name>`. Full reference, including how `@org/<name>` resolves: [Component Metadata → Data](../reference/component-metadata.md#data).

---

## Sharing schemas across projects

Build more than one foundation — a few brands, a client's product line — and they want the same shapes: a `person`, a `project`, a `product`. You don't copy the schema into each foundation. Define it once and reference it, two ways:

- **A schema package** — put the schemas in an `@org/schemas` package (a workspace package, or one you register) and reference them as `@org/<name>`. Each foundation lists the package as a dependency.

- **A routed directory** — keep the schemas in a plain folder anywhere on disk and point each foundation's `schemas.config.js` at it. No package, no install:

  ```js
  // foundation/schemas.config.js
  export default { '@acme': '../shared/acme-schemas' }
  ```

  `@acme/person` then resolves to `../shared/acme-schemas/person.{js,yml}`. The config is plain JS, so the path can be relative, absolute, or read from an environment variable — useful when one schema repo is shared across many client workspaces on your machine. Reference: [Component Metadata → Routing a scope](../reference/component-metadata.md#routing-a-scope-with-schemasconfigjs).

Either way the payoff is a single source of truth: fix `person` once and every foundation that references it picks up the change — and `uniweb validate` checks each foundation's data against the same definition. A schema ref's `@acme` names an organization in the Uniweb registry, not an npm scope — the two look alike and aren't connected. For which delivery to pick, how a git repo works as a source, and when registering is required, see [Schemas in Practice](./schemas-in-practice.md).

The package form is worth a second look here, because it's also what you register (next section). An `@org/schemas` package is an ordinary workspace package whose job is to hold schemas:

```text
acme-schemas/                 # the @acme/schemas package
├── package.json              # { "name": "@acme/schemas" }
└── schemas/
    ├── product.yml
    └── review.yml
```

Any foundation that lists `@acme/schemas` as a dependency can bind `@acme/product`. One definition, every foundation in the org — and `uniweb validate` checks each foundation's data against the same source of truth. This is exactly how the standards work: `@std/*` is the centrally-maintained `@uniweb/schemas` package.

---

## Registering schemas

Everything above works offline, against local files — authoring, validating, and rendering records from files need no account and no network. **Registering** is the step that turns a schema into a *reusable content type* in the Uniweb registry: authors can then create and manage entities of that type (in the editor, through the form your schema describes), and any foundation can reference it by name.

Registering is a platform command, so authenticate first:

```bash
uniweb login
```

### A foundation and the schemas it defines

`uniweb register` from a foundation submits the foundation together with the data schemas it owns:

```bash
cd foundation
uniweb register --scope @acme
```

The scope becomes part of the foundation's name — `register` writes `name: '@acme/<name>'` into its `main.js` — so later runs need no flag, and the foundation and its schemas always register under the same organization.

It submits **the schemas you own**: every schema file in your foundation's `schemas/` folder, resolved into your scope (`@/product` → `@acme/product`) — whether or not a section binds it, so a type only your app uses (a lesson, a quiz, a learner's progress) registers too. Placing the file in the folder is what makes it yours; a file whose name starts with `_` is a draft and is left out. Schemas you merely *reference* from another scope are **named, not re-submitted**: a foundation that renders `@std/person` and `@std/event` ships neither definition — it names them, and they resolve to the live standards already in the registry.

That split matters in practice. You don't re-upload the standards every publish (wasteful), and you don't need membership in the `@std` org to reference its schemas — registration authorizes against the artifacts you *publish* (`@acme/*` and the foundation), not against what they reference. The `@/` prefix is the signal for "mine, publish it"; every concrete scope (`@std/x`, another org's `@org/x`) is a reference by name.

### A schema repo on its own

You don't need a foundation to register schemas. Point `uniweb register` at a schemas-only package — the `@org/schemas` repo you maintain — and it registers the schemas directly, no foundation involved:

```bash
cd acme-schemas        # the @acme/schemas package
uniweb register --scope @acme
```

`register` detects a schemas-only package (one named `@org/schemas`, or any bare `schemas/*.yml` folder) and submits just its data schemas. This is exactly how the standards under `@std` are registered — now under your own org. So the `@acme/schemas` package earns its keep twice: every foundation references `@acme/product` and resolves it **locally** during development, and `uniweb register` registers those same definitions so they exist as managed types. Define once, develop offline, register.

A few practical notes:

- Set `"uniweb": { "scope": "@acme" }` in the package's `package.json` and you can drop `--scope` on every run.
- You register under a scope you belong to — membership over the declared scope is what authorizes the registration. (That's why you can register `@acme` but not `@std`.)
- Re-registering an unchanged schema is a no-op, so re-running `register` on a repo you haven't edited costs nothing.
- A backend that holds records of a schema accepts a change that keeps them valid — a label or description reworded, a field or a section added, a value added to a field's options — and refuses one that would invalidate or lose what they hold: a field's type changed, a field removed, an option some record uses dropped, a field made required that some record leaves empty. The refusal names the change and how many records it breaks; add the new shape beside what records hold, or publish it under a new name (`@acme/product-v2`).

---

## See Also

- [Schemas in Practice](./schemas-in-practice.md) — where a schema file lives, how a second project consumes it, and when registering is actually required. Read this if `@acme/person` won't resolve.
- [Designing Data Schemas](./designing-data-schemas.md) — the modeling decisions behind a set of related types: sections, subsections, embed vs reference, and relationship (edge) attributes, with a worked LMS example.
- [Working with Data](./working-with-data.md) — How a query's records reach your components at runtime, and `uniweb validate`.
- [Component Metadata → Data](../reference/component-metadata.md#data) — The `data:` field reference: named refs, inline field maps, and the editor rich-form.
- [Data Schemas as Contracts](../architecture/data-schemas-as-contracts.md) — Why one schema serves validation, editor UI, and delivery — and how the registry makes content types shareable.
- [Registering a Foundation](./publishing-and-clients.md) — Publishing a foundation as a catalog product.
- [CLI Commands](../reference/cli-commands.md) — Full command reference.