Docs Core Concepts Schema Evolution

Schema Evolution

Stable v0.1.0
Schema.md v2
Updated APR 2026

Goal

Capture fast. Structure later. Zero migration pain. Schema = contract, not cage.


5.1 Schemaless Day One

No config. No setup. Drop .md files. Write key:: value.
onde lint checks syntax only. Misspelled enums? Fine. Missing fields? Fine. Files in random folders? Fine.
Graph builds anyway. Queries work anyway.
Rule: Speed first. Law later.


5.2 When to Add Structure

Add schema when chaos costs more than setup. Signals:

  • Team writes status:: IN_PROGRESS but you expect DOING
  • Required fields missing. Decisions lack confidence. Tasks lack deadlines.
  • Files scatter. tasks/ contains notes. people/ contains bugs.
  • Context window bloat. One file eats 8% of LLM memory.
  • You want auto-naming, auto-sorting, or strict edge rules.

Rule: Extract when patterns emerge. Not before.


5.3 Build Your First Schema (Step-by-Step)

Zero manual typing required. onde reads your mess. Writes the law. You refine it.

Step 1: Extract from Existing Files

onde schema extract > .onde/schema.md

What happens:

  • Scans all .md files
  • Groups nodes by type::
  • Infers property types, enums, edges
  • Detects directory layout
  • Marks 100%-present fields as @required
  • Adds @soft to directory rules (warnings, not errors)
  • Writes clean .onde/schema.md

Step 2: Review & Clean

Open .onde/schema.md. You will see inferred sections. Clean them:

  • Remove junk types (type:: scratch, type:: temp)
  • Fix typos (stautsstatus)
  • Merge duplicate enums (enum(A B C) + enum(A,B,C) → one ## Enums block)
  • Drop unused properties
  • Keep only what matters

Step 3: Set Defaults & Requirements

Add safety nets so agents/humans skip less:

### task
status:   enum(TaskStatus) @required
priority: enum(Priority) @default(C)
deadline: date
  • @required = lint fails if missing
  • @default(C) = auto-fills when empty
  • No directive = optional, no auto-fill

Step 4: Map Directories

Tell onde where each type lives:

## Directories
tasks/**:     type(task)
people/**:    type(person)
decisions/**: type(decision)
drafts/**:    ignore
  • /** = deep (any nesting)
  • / = shallow (top-level only)
  • ignore = skip parsing entirely

Step 5: Enforce Filenames (Optional)

Stop random naming drift:

tasks/**:     type(task) @naming("{id}.md")
people/**:    type(person) @naming("{title-kebab}.md")

Templates: {id}, {title}, {title-kebab}, {title-snake}, {type}, {date}, {now}.

Step 6: Soft Validate First

Never go hard day one. Test the law:

onde lint --soft
  • All schema violations → warnings
  • Exit 0. Files untouched.
  • Read output. Fix data OR fix schema.
  • Repeat until warnings drop to acceptable level.

Step 7: Lock It

Remove --soft. Run strict:

onde lint
  • Violations → errors. Exit 1.
  • Fix remaining issues.
  • Commit schema + cleaned files.
  • Workspace now schemaful.

Rule: Extract → Clean → Soft → Lock. Never skip soft pass.


5.4 Schema Anatomy

Four sections. Order matters. Keep it tight.

---
type: schema
version: 6
@max-tokens: 100000
---

## Enums
### TaskStatus
values: TODO DOING DONE BLOCKED CANCELLED
@numeric: 0 1 2 3 4

## Node Types
### task
id:       string @id @default(counter(prefix: "task", pad: 3))
status:   enum(TaskStatus) @required
priority: enum(Priority) @default(C)
deadline: date
assigned: edge(person) @inverse(owns) @many
depends:  edge(task) @inverse(blocks) @many
@sort: priority asc, deadline asc
@group: status(TODO DOING DONE BLOCKED CANCELLED)

## Edge Types
depends: task > task @inverse(blocks)
assigned: task > person @inverse(owns)

## Directories
tasks/**: type(task) @naming("{id}.md")
drafts/**: ignore
SectionPurpose
FrontmatterSchema type + version + workspace token cap
## EnumsReusable value sets. Referenced via enum(Name).
## Node TypesField definitions + directives + display defaults. Core of schema.
## Edge TypesGlobal edge declarations. Direction + inverse.
## DirectoriesFolder → type mapping. Naming + budgets + strictness.

5.5 Enums

Reusable value sets. Defined once, referenced everywhere. Two forms.

Reusable Enum (## Enums section)

## Enums
### TaskStatus
values: TODO DOING DONE BLOCKED CANCELLED
@numeric: 0 1 2 3 4

Inline Enum (one-off, on field)

### task
status: enum(TODO DOING DONE) @required

No ## Enums entry needed for one-offs. Good for fields unique to one type.

Reusable vs Inline

Reusable (## Enums)Inline
WhenShared across 2+ typesSingle type only
Referenceenum(TaskStatus)enum(A B C)
Ordering@numeric enables sort/aggNo ordering
RefactorChange one placeChange per field

@numeric — Enable Ordering

### Priority
values: A B C
@numeric: 1 2 3

@numeric maps values to numbers. Enables @sort, @avg, @sum, @min, @max on enum fields. Without @numeric, only @count works on enums.

Priority A=1, B=2, C=3@sort: priority asc orders A before B before C.

Enum Validation

status: enum(TaskStatus) @required

status:: DOING → valid. status:: IN_PROGRESS → lint error. Full-stop matching. No partial match. No case-insensitive. Exact value or fail.


5.6 Property Types

Every field has a type. Schema enforces. Lint validates.

TypeExampleValidation
stringtitle: stringAny text. Use @max-length, @pattern for constraints.
intpoints: intInteger. 42 ok. 3.14 fails.
float(N)confidence: float(0-1)Decimal with optional range. 0.85 ok. 1.5 fails if range 0-1.
boolactive: booltrue or false only.
datedeadline: dateYYYY-MM-DD format.
datetimecreated_at: datetimeYYYY-MM-DDTHH:MM:SS format.
enum(Name)status: enum(TaskStatus)Must be value from ## Enums section.
enum(A B C)mode: enum(safe risky)Inline enum. No ## Enums entry needed.
edge(Type)assigned: edge(person)Link to type:: person node. Creates directed edge.

Type coercion in @check: date → datetime → numeric → string comparison. Missing fields = check skipped silently.


5.7 ID Formats & Generation

Every node needs a primary key. Declare it. Control how it generates.

Declaration

### task
id: string @id @default(counter(prefix: "task", pad: 3))
  • @id = marks field as primary key. Unique across workspace. Duplicate = lint error.
  • @default(...) = generation strategy. Runs when id:: missing.
  • Manual id:: anything@default skipped. Never overwritten.
  • No id field at all → auto-included as id: string @id @default(counter(prefix: "{type}", pad: 3)).

Generation Strategies

StrategyExampleBest For
counter(prefix, pad)task-001, task-002Small graph, human-readable, sequential
counter(prefix, pad, include_title: true)task-001-fix-tokenSortable + descriptive
kebabtask-fix-token-refreshDeterministic, same title = same ID
snaketask-fix_token_refreshCode-friendly
uuid-shorttask-a1b2c3d4Medium graph, compact
uuid-fulltask-550e8400-...Large graph, zero collision
timestamptask-20260407T143000ZLogs, temporal data
hashtask-3f2a9bDedup, content-addressable (6 hex chars)
barefix-token-refreshNo type prefix
manualanythingMigration, existing systems

Counter Options

id: string @id @default(counter(prefix: "t", pad: 4))
# → t-0001, t-0002

id: string @id @default(counter(prefix: "none"))
# → 001, 002

Collision Handling (kebab/snake/bare)

Same title = collision. Second gets suffix:

task-meeting-notes      # first
task-meeting-notes-2    # collision

5.8 Edges & Relationships

Declaring Edges

Two ways. Both valid. Schema picks style.

On node type field (most common):

### task
assigned: edge(person) @inverse(owns) @many
depends:  edge(task) @inverse(blocks) @many

Global edge type (explicit direction + inverse):

## Edge Types
depends:  task > task @inverse(blocks)
assigned: task > person @inverse(owns)
part_of:  any > project @inverse(contains)

task > person means: edge goes FROM task TO person. @inverse(owns) means: person gets owns:: [[task]] auto-generated.

Inverse Edges

Write assigned:: [[sarah-chen]] on task → onde lint auto-adds owns:: [[task-882]] on sarah-chen. Bidirectional. Always consistent.

### task
assigned: edge(person) @inverse(owns) @many
SideFieldDirection
Task writesassigned:: [[person-001]]Forward edge
Person getsowns:: [[task-882]]Auto-generated inverse

No inverse declared = one-way edge only. Forward works. No back-link created.

Edge Cardinality

DirectiveMeaningLint Error
(none)Single target. Max one.More than one = error.
@manyMultiple targets. Any count.No limit.
@many(min:1)At least one required.Zero = error.
@many(max:5)At most five.Six = error.
@many(min:1, max:5)Between one and five.Zero or six = error.
assignees: edge(person) @inverse(owns) @many(min:1, max:5)
reviewers: edge(person) @many(max:3)
lead:      edge(person) @inverse(owns)    # single only

Edge Targets

edge(person) = target must be type:: person. Lint validates. Link to wrong type = error.

Multi-type targets: edge(task decision) in dir rules. Edge fields on node type accept one type only. Dir rules allow multiple types per folder.


5.9 Node Type Display Directives

Control how onde find and onde organize output nodes. Set per type. Override in files.

@sort — Default Sort Order

### task
@sort: priority asc, deadline asc

Queries without explicit --sort use this. priority asc = A before B before C (if @numeric set). Multiple fields = tiebreaker chain.

@group — Default Grouping

### task
@group: status(TODO DOING DONE BLOCKED CANCELLED) > priority(A B C)

onde find without --group-by uses this. Groups by status first, then priority within each status group. > separates group levels.

@default-display — Output Format

### task
@default-display: dense
ValueEffect
denseonde find task outputs TOON pipe rows. ~15 tokens/node.
standardonde find task outputs full ## Heading + key:: value. ~100-500 tokens/node.

Override per query: onde find task --format md-dense or --format md.

@summary-template — Auto-Generated Body

### decision
@summary-template: "{decided_by} decided on {decided_at}. Confidence: {confidence}."

Node has no body text? Lint generates summary from template into body. Agent sees context without writing prose.


5.10 Field Directives (Data Quality)

Directives live on fields. Enforce rules. Auto-fill values. Catch drift.

Core Directives

DirectiveEffectExample
@requiredFails lint if missingstatus: enum(TaskStatus) @required
@default(X)Auto-fills when emptypriority: enum(Priority) @default(C)
@auto(now)Sets timestamp on creationcreated_at: datetime @auto(now)
@on-updateOverwrites timestamp every lintupdated_at: datetime @on-update
@check("A > B")Compares two fields on same node@check("deadline > decided_at")
@pattern("regex")Validates string formatcode: string @pattern("^[A-Z]{3}-\\d{3}$")
@max-length(N)Caps string lengthtitle: string @max-length(200)
@uniqueValue distinct across all nodes of typeslug: string @unique
@deprecated("msg")Warns on use. Still works.old_field: string @deprecated("use slug")
@indexMarks field for query index. Currently same as @unique.email: string @index
@many(min, max)Edge cardinality boundsassignees: edge(person) @many(min:1, max:5)

Timestamp Patterns

NeedDirectiveBehavior
Created timestamp@auto(now)Set once on first lint. Never overwrites.
Modified timestamp@on-updateSet on creation + overwrite every lint.
Bothcreated_at: datetime @auto(now) + updated_at: datetime @on-updateStandard pattern.

@check Rules

  • Expression: field1 operator field2. Operators: >, <, >=, <=, ==, !=.
  • Type coercion: date → datetime → numeric → string. Tries each in order.
  • Missing field = check skipped silently. Not an error.
  • @check("deadline > decided_at") on decision type → lint verifies deadline after decided_at.

@unique Rules

  • Cross-node validation. Runs after all files parsed.
  • Empty/missing values not tracked. Only non-empty values checked.
  • slug: string @unique → two tasks with slug “fix-auth” = lint error.
  • Lists both violating files: unique constraint violated — "slug" value "fix-auth" used by: task-001, task-002

5.11 Directory Rules & Naming

Folders enforce type. Type enforces folder. Pick strictness level.

Pattern Types

PatternDepthExample Match
tasks/Shallow (top only)tasks/fix.mdtasks/auth/fix.md
projects/**Deep (any nesting)projects/a/b/c.md
*-scratch/**Globtemp-scratch/notes.md
drafts/**: ignoreSkipNever parsed. Zero overhead.

Multi-Type Directories

projects/**: type(task decision note)

File in projects/ can be task, decision, or note. Any of the listed types. Agent chooses.

Resolution Order

Most specific wins. Always.

  1. Exact file path → tasks/overview.md
  2. Deepest prefix → projects/alpha/** beats projects/**
  3. Glob → **/q2/** beats **
  4. Shallow → tasks/
  5. No match → error (hard) or warning (@soft)

Naming Templates

tasks/**: type(task) @naming("{id}.md")
# → task-882.md, task-883.md

decisions/**: type(decision) @naming("{date}-{title-kebab}.md")
# → 2026-04-15-use-postgres.md

Variables: {id}, {title}, {title-kebab}, {title-snake}, {type}, {date}, {date:YYYY}, {now}, {now:YYYY-MM}.

Naming Exceptions

tasks/**:              type(task) @naming("{id}.md")
tasks/overview.md:     @naming-ignore
tasks/archive/**:     @naming-ignore

Exact path beats prefix. Overview and archive keep original names.

Naming Inheritance

Child inherits parent @naming unless overridden.

tasks/**:              @naming("{id}.md")
tasks/backlog/**:      @naming("{id}-backlog.md")   # override
tasks/backlog/old/**:  @naming-ignore                # override: skip

Custom File Extensions (@ext)

Default: scanner parses .md only. Use @ext for double extensions (.task.md, .person.md) or non-standard extensions.

## Directories

# Double extension: parse .task.md files
tasks/**:     type(task) @naming("{id}.task.md") @ext(task.md)

# Multiple extensions: parse both .md and .task.md
tasks/**:     type(task) @ext(md task.md)

# Workspace default (frontmatter)
@ext: md task.md person.md decision.md
DirectiveScanner behavior
No @extParse .md only (default)
@ext(task.md)Parse .task.md only
@ext(md task.md)Parse both
@ext(csv)Parse .csv files

@ext + @naming must agree. @naming("{id}.task.md") + @ext(md) = mismatch warning. Fix: align both.

Inherited from parent. Child override replaces entirely (not additive).

File Type Restrictions (@reject-ext)

Prevent wrong file types from accumulating in dirs. Agent drops .txt, .csv, .json scratch files. @reject-ext catches them.

## Directories
tasks/**:     type(task) @reject-ext(txt csv json log)
people/**:    type(person) @reject-ext(txt)
ERR  tasks/notes.txt   rejected extension — .txt not allowed in tasks/** (reject-ext: txt csv json log)

@soft = warning. No @reject-ext = no restriction. Files matched by @ext never rejected. Child override replaces parent.

@ext + @reject-ext Together

@ext = what to parse. @reject-ext = what to forbid.

tasks/**: type(task) @naming("{id}.task.md") @ext(task.md) @reject-ext(txt csv json)

Effect: scan .task.md only. Validate naming. Reject .txt, .csv, .json. .md = ignored (not scanned, not rejected).

Dir rules make path links typed. tasks/ = always type(task).

depends:: [[/tasks/auth-fix.md]]    # path link = guaranteed correct type
assigned:: [[../people/sarah.md]]   # relative link = same dir type

Auto-Suggest Rules

onde schema dirs --suggest
# Outputs detected patterns with @soft. Review. Paste into schema.

Rule: Map dirs early. Enforce names later. Use @soft until team adapts.


5.12 Token Budgets (Context Guardrails)

LLM context = finite. One bloated file = blind agent. Budgets prevent silent degradation.

Where to Set

## Directories
tasks/**:           type(task) @max-file-tokens(500) @max-dir-tokens(15000)
tasks/active.md:    @max-file-tokens(2000)
memory/**:          type(memory) @max-file-tokens(200) @max-dir-tokens(10000) @soft

Or workspace total in frontmatter:

---
type: schema
@max-tokens: 100000
---

What Each Does

DirectiveScopeEffect
@max-file-tokens(N)Per fileFails if single .md exceeds N
@max-dir-tokens(N)Per patternFails if sum of matched files exceeds N
@max-tokens(N)WorkspaceFails if grand total exceeds N
@softAny ruleDowngrades to warning. Exit 0.

Inheritance & Override

  • Child inherits parent @max-file-tokens if not set.
  • @max-dir-tokens never inherits. Scoped to pattern only.
  • Exact file path > deepest prefix > parent rule.

Rule: Set budgets after workspace stabilizes. Start high. Tighten gradually. Use @soft during transition.


5.13 Node Count Limits (Density Guardrails)

Token budgets = file size. Node count limits = density. 200 short rows can fit under token budget while being unscannable. Node limits catch this directly.

DirectiveScopeEffect
@max-rows(N)Per dense blockCaps - pipe rows under one heading
@max-nodes(N)Per fileCaps all node definitions (Mode 1 + dense) in one file
@max-dir-nodes(N)Per dir patternCaps total nodes across all matched files
@auto-splitWith any limitLint auto-paginates excess into *.batch-NN.md files

All support @soft. Same resolution order as token budgets (most specific wins). All opt-in.

Example

## Directories
tasks/**:           type(task) @max-rows(50) @max-nodes(50) @max-dir-nodes(500)
tasks/active.md:    @max-rows(20) @auto-split
decisions/**:       type(decision) @max-nodes(10)
memory/**:          type(memory) @max-nodes(1000) @soft

Auto-Split vs Manual Split

@auto-split (schema)onde organize --splitonde organize --split --batch-size N
TriggerAuto during lintManualManual
Output*.batch-NN.md (N per file)One node per file*.batch-NN.md (N per file)
FormatDense preservedMode 1Dense preserved

@auto-split = auto-paginate. onde organize --split = full decomposition. --batch-size = manual paginate.


5.14 Schema CLI Commands

onde schema extract                # generate schema from existing nodes
onde schema extract > schema.md    # write to file
onde schema show                   # print current schema
onde schema diff                   # compare schema.md to actual node data
onde schema dirs --suggest         # detect dir + naming patterns from files
onde migrate --from 3 --to 4       # schema version migration
onde migrate --from 3 --to 4 --dry-run
CommandWhen
schema extractStarting schema. Day one.
schema showQuick check. What’s active?
schema diffDrift detection. Schema says X, nodes say Y.
schema dirs --suggestAdding dir rules. Auto-detect patterns.
migrateSchema version change. Additive only.

5.15 .onde/config.md — Override Layer

Schema = shared contract. config.md = local override. No schema edit needed.

Place .onde/config.md inside any .onde/ directory. Deepest wins.

# .onde/config.md
@max-file-tokens: 500
@max-file-tokens(active.md): 2000
@max-rows: 30
@max-nodes: 100
@max-dir-nodes: 5000
@max-dir-tokens: 15000
@max-tokens: 100000
@auto-split
@soft
Use config.md when…Use schema when…
Temporary budget exceptionPermanent team-wide budget
One person needs more headroomAll files in a dir share same limit
Per-file overridesBudget version-controlled with schema

Full resolution order: config.md per-file > deepest config.md > schema dir rule > no match.


5.16 Switching Modes

Mode = file presence. Not config flag. Instant switch. Zero data loss.

Schemaless → Schemaful

onde schema extract > .onde/schema.md
onde lint --soft    # test law
onde lint           # lock law
git add .onde/schema.md
git commit -m "Add schema."

Schemaful → Schemaless

mv .onde/schema.md .onde/schema.md.bak
onde lint    # syntax-only instantly

Graph unchanged. Links unchanged. Files untouched. Validation drops. Freedom returns.

Gradual Adoption

  • Keep @soft on directory rules. Warnings guide. Errors don’t block.
  • Run onde lint --soft globally. All violations → warnings.
  • Promote rules to hard one by one. Team adapts without friction.

Rule: Schema = opt-in. Remove file = opt-out. Always reversible.


5.17 Schema Migration

Additive only. Never delete properties. Deprecate. Git = rollback.

onde migrate --from 3 --to 4 --dry-run
onde migrate --from 3 --to 4
onde lint
git diff
onde git commit

What happens:

  • Adds new fields with @default → existing nodes auto-filled
  • Deprecated fields → warning. Still works. Not deleted.
  • Type changes → lint errors. Fix manually.
  • No data loss. Git has history. Rollback any time.

Rule: Bump version. Migrate. Lint. Commit. Never destructive.


5.19 Common Traps & Fixes

TrapSymptomFix
Extracted schema too noisy50+ inferred properties, many junkDelete unused. Keep core. Rerun lint.
@required blocks old filesLint fails on legacy nodes missing fieldAdd @default(X) or backfill data.
Dir rule too stricttasks/ rejects nested foldersChange tasks/tasks/**.
Naming breaks path links[[/tasks/old.md]] fails after renameonde relink --to id before --rename.
Budget fails day oneEvery file over limitRaise limit. Match reality. Tighten later.
@check silent failComparison not triggeringCheck field names. Ensure compatible types.
Schema typo causes mass errorsstauts instead of statusFix schema. Not files. Rerun lint.
@soft hides real issuesWarnings pile up, never fixedSchedule cleanup. Promote to hard when ready.
Inverse missingNo back-link on targetAdd @inverse(owns) to source field.
Edge type unknowndepends:: [[X]] failsAdd edge to ## Edge Types section.
Wrong edge target typeedge(person) but linked to taskLink to correct type. Lint validates.
@unique collisionTwo nodes share slugChange one slug or remove @unique.
Node count limit surpriseDense file hit limit unexpectedlySet @max-rows/@max-nodes. Enable @auto-split.
@ext vs @naming mismatchFiles never found by scannerAlign both: @ext(task.md) + @naming("{id}.task.md").
Wrong extension files in dir.txt / .csv junk in tasks/Add @reject-ext(txt csv) to dir rule.
Custom ext file invisible.task.md not parsedAdd @ext(task.md) to dir rule.

5.20 Schema Design Patterns

Pattern A: Single-Type Flat Workspace

## Directories
tasks/:      type(task) @naming("{id}.md")
people/:     type(person) @naming("{title-kebab}.md")
decisions/:  type(decision)

One type per dir. Flat structure. Easy to navigate. Good for small teams, simple domains.

Pattern B: Multi-Type Project Tree

## Directories
projects/**: type(task decision note meeting)
people/**:   type(person) @naming("{id}.md")
memory/**:   type(memory) @soft
projects/**/archive/**: ignore

Agent nests freely inside projects/. Multiple types allowed. Good for complex domains.

Pattern C: Double Extensions for Type Safety

## Directories
tasks/**:     type(task) @naming("{id}.task.md") @ext(task.md) @reject-ext(txt csv json md)
people/**:    type(person) @naming("{id}.person.md") @ext(person.md) @reject-ext(txt csv json md)
decisions/**: type(decision) @naming("{date}-{title-kebab}.decision.md") @ext(decision.md)

File extension = type identity. ls tasks/ = all .task.md. Finder/explorer groups by extension. No name collisions with .md files. Strict: reject all other extensions.

Pattern D: Gradual Tightening

## Directories
# Week 1: @soft everything
tasks/**: type(task) @soft @naming("{id}.md") @soft

# Week 3: hard type, soft naming
tasks/**: type(task) @naming("{id}.md") @soft

# Week 6: hard everything
tasks/**: type(task) @naming("{id}.md") @reject-ext(txt csv)

Pattern E: Schema-First Workspace

## Enums
### TaskStatus
values: TODO DOING DONE BLOCKED CANCELLED
@numeric: 0 1 2 3 4

## Node Types
### task
id:       string @id @default(counter(prefix: "task", pad: 3))
status:   enum(TaskStatus) @required
priority: enum(A B C) @default(C)
deadline: date
@sort: priority asc, deadline asc
@group: status(TODO DOING DONE BLOCKED CANCELLED)
@default-display: dense

Define schema before writing nodes. Lint catches mistakes immediately. Good for teams with clear domain model. Bad for exploratory work.

Pattern F: Mixed Strict/Free Zones

## Directories
# Core domain: strict
tasks/**:      type(task) @naming("{id}.md") @reject-ext(txt csv)
people/**:     type(person) @naming("{id}.md")

# Exploration: free
research/**:   type(note memory assumption) @soft
scratch/**:    ignore

Core dirs locked down. Research dirs open. Scratch invisible. Best of both worlds.

Rule: Match pattern to team maturity. New team = Pattern A or D. Mature team = Pattern B or C. Mixed = Pattern F.


Next Step:Querying & Visualization: Find Anything, Render Anywhere