Skip to main content

OpenMorph Specification v0.1.0

Status: Canonical — locked Feb 20, 2026. No schema changes without a version bump. Scope: This document defines the .morph/ directory format. It is the source of truth for all implementation work in OpenMorph (V1.0 Layer 3).


Table of Contents

  1. Concept
  2. Directory Structure
  3. File Schemas
  4. Git Conventions
  5. Sync Modes
  6. Nested Spaces
  7. Migration Guide (V0.9 → V1.0)
  8. Versioning Policy
  9. Open Questions

1. Concept

.morph/ is to AI augmentation what .git/ is to version control.

Drop a .morph/ directory into any folder and it becomes a Morph Space — an AI-augmented context with its own tasks, memory, conversations, skills, and canvas. The directory is:

  • Human-readable — plain Markdown and YAML, editable with any text editor
  • Portable — copy it to another machine, share it via git, check it into a repo
  • Version-controlled — every write is a git commit; branch and time-travel as first-class operations
  • Tool-agnostic — any tool that reads Markdown/YAML can integrate with .morph/
  • Offline-first — git is local; the Morphee backend is a sync hub, not a dependency

Breaking change notice: V1.0 migrates ALL existing Spaces to .morph/ format. PostgreSQL becomes identity + read cache only. No backward compatibility with V0.9 data format.


2. Directory Structure

any-directory/                         # User's project or folder ("outside world")
├── .morph/ # ← Presence signals a Morph Space (the repo root)
│ │
│ │ ── Root files (owned by core, shared with all integrations) ──────────
│ ├── config.yaml # REQUIRED: Space identity + integration config
│ ├── acl.yaml # OPTIONAL: Access control (who reads/writes what)
│ ├── canvas.yaml # REQUIRED: Canvas layout state
│ │
│ │ ── Credential vault ────────────────────────────────────────────────
│ └── vault/
│ ├── com.google.calendar.enc # One encrypted file per integration
│ └── net.atlassian.jira.enc

│ │ ── Core integrations (built-in, created on first write) ────────────
│ ├── core.tasks/
│ │ ├── manifest.yaml # Integration identity + capabilities
│ │ └── task-{uuid}.md # One file per task
│ ├── core.conversations/
│ │ ├── manifest.yaml
│ │ └── conv-{uuid}.md
│ ├── core.memory/
│ │ ├── manifest.yaml
│ │ ├── facts/{uuid}.md
│ │ ├── preferences/{uuid}.md
│ │ ├── events/{uuid}.md
│ │ └── notes/{uuid}.md
│ ├── core.skills/
│ │ ├── manifest.yaml
│ │ └── {skill-slug}.yaml
│ ├── core.scheduler/
│ │ ├── manifest.yaml
│ │ └── {schedule-slug}.yaml
│ └── core.canvas/
│ └── manifest.yaml

│ │ ── Third-party integrations (created by WASM extensions) ───────────
│ ├── net.atlassian.jira/
│ │ ├── manifest.yaml # Format, version, capabilities declared
│ │ ├── issues/
│ │ │ └── PROJ-123.yaml
│ │ └── sync-state.yaml
│ ├── com.google.calendar/
│ │ ├── manifest.yaml
│ │ └── events/
│ └── io.github.alice.my-extension/ # Community extensions
│ └── manifest.yaml

├── src/ # User's own files — "outside world"
└── README.md # Outside world — ACL-gated access

Directory naming conventions:

  • core.* — built-in Morphee integrations. Registered and maintained by Morphee.ai.
  • com.*, net.*, org.* — third-party integrations using reverse-domain notation.
  • io.github.{user}.{name} — community / open-source extensions.
  • Morphee.ai acts as the protocol authority for namespace registration (analogous to IANA).

The .morph/ root is the shared space. All integrations with root access (morph_root_access = True) can read root files (config.yaml, acl.yaml, canvas.yaml). Each integration is otherwise sandboxed to its own subdirectory.

The "outside world" is the parent folder of .morph/ (e.g. src/, README.md). Integrations may declare outside_world: true in their capabilities to request access. Access is governed by acl.yaml and not yet enforced (V1.2+).

manifest.yaml is required in every integration directory. Written automatically on first write via BaseInterface.morph_write(). Declares identity, version, and capabilities.

Rules:

  • .morph/config.yaml is the only truly required file — its presence defines a Space.
  • Integration directories are created on first write (not upfront).
  • Files use UUID-based names for system entities and slug-based names for user-defined entities.
  • No binary files. Ever.

2.1 Integration Communication Protocol

Every integration communicates via three channels. All three are declared in BaseInterface:

ChannelDirectionAPINature
ActionMorphee → Integrationexecute()Synchronous request/response
FileIntegration ↔ .morph/morph_write() / morph_read()Async, persistent, git-tracked
EventIntegration → EventBus → subscribersemit + subscribe()Async, real-time, ephemeral

Event catalog — published_events

Each integration declares a static list of events it can emit as a class attribute:

class CalendarIntegration(BaseInterface):
morph_directory = "com.google.calendar"

published_events = [
EventDefinition(
name="com.google.calendar.event_created",
description="A calendar event was created",
payload_schema={"event_id": "str", "title": "str", "start": "datetime"},
),
EventDefinition(
name="com.google.calendar.event_upcoming",
description="A calendar event starts within the next 15 minutes",
payload_schema={"event_id": "str", "minutes_until": "int"},
),
]

published_events is schema metadata — readable by any tool, the InterfaceManager, and future WASM sandboxing to know what an integration emits without running it.

Event names follow the integration namespace: {morph_directory}.{event_slug}.

Subscriptions — subscribe()

Integrations that react to other integrations' events register handlers in setup():

class TaskIntegration(BaseInterface):
morph_directory = "core.tasks"

async def setup(self):
# Auto-create a task when a calendar event is created
await self.subscribe("com.google.calendar.event_created", self.on_calendar_event)

async def on_calendar_event(self, payload: dict) -> None:
# payload matches the publisher's payload_schema
...

subscribe() registers with the global EventBus (Redis pub/sub). Pattern matching is supported: "core.tasks.*" receives all task events.

V1.2 — Declarative cross-wiring via acl.yaml

In V1.2, subscriptions can also be declared in .morph/acl.yaml without code changes. Users and AI can wire integrations together at the Space level:

# .morph/acl.yaml (V1.2+)
subscriptions:
- from: com.google.calendar
event: event_created
to: core.tasks
handler: create_task_from_event

This makes integration composition a configuration concern, not a code concern — and enables the WASM extension marketplace to offer pre-built wiring templates.


3. File Schemas

3.1 config.yaml (REQUIRED)

The presence of this file declares a Morph Space.

# OpenMorph spec version — MUST be present and valid
openmorph_version: "0.1.0"

# Space identity
space:
id: "550e8400-e29b-41d4-a716-446655440000" # UUID, backend-generated
name: "TechCorp API Project"
type: "project" # project | personal | team | family | classroom
created_at: "2026-02-20T10:00:00Z"
created_by: "alice@example.com" # Identity hint, not enforced

# Parent space for nested spaces (omit if root)
parent:
path: "../.morph" # Relative path to parent .morph dir
inherit_acl: true # Whether to inherit parent ACL rules
inherit_integrations: true # Whether to inherit parent integrations

# Link to Morphee backend identity (omit for fully local/offline spaces)
group:
id: "123e4567-e89b-12d3-a456-426614174000" # Group UUID
name: "TechCorp Engineering"
sync_url: "https://api.morphee.app/sync/123e4567-e89b-12d3-a456-426614174000"

# Git configuration
git:
enabled: true
remote_url: "git@github.com:techcorp/api-project.git" # null if local-only
branch: "main"
auto_commit: true # Commit on every write
auto_push: false # Push only on explicit sync

# Sync mode (see §5)
sync:
mode: "morphee-hosted" # local-only | morphee-hosted | git-remote
encryption: "group-key" # group-key | user-key | none

# Active integrations (determines which tools the AI can use in this Space)
integrations:
- llm
- memory
- tasks
- google-calendar
- gmail
# skills and schedules are implicitly enabled when their dirs exist

Field constraints:

  • openmorph_version — must be a valid semver string matching a known spec version. Readers MUST reject unknown major versions.
  • space.type — must be one of: project, personal, team, family, classroom.
  • sync.mode — must be one of: local-only, morphee-hosted, git-remote.
  • parent.path — must be a relative path, not absolute. Circular references are invalid.

3.2 acl.yaml (OPTIONAL)

Access control rules for this Space. If absent, the parent Space's ACL applies. If no parent, the group's default ACL applies.

openmorph_version: "0.1.0"

# Space owner — full access always
owner: "550e8400-e29b-41d4-a716-446655440001"

# Explicit member rules (augments group membership)
members:
- user_id: "550e8400-e29b-41d4-a716-446655440002"
role: "editor" # viewer | editor | admin
granted_by: "550e8400-e29b-41d4-a716-446655440001"
granted_at: "2026-02-20T10:00:00Z"
expires_at: null # null = permanent

# Resource-level overrides
permissions:
tasks:
create: ["editor", "admin"]
read: ["viewer", "editor", "admin"]
update: ["editor", "admin"]
delete: ["admin"]
conversations:
create: ["viewer", "editor", "admin"] # anyone can chat
read: ["viewer", "editor", "admin"]
delete: ["admin"]
memory:
create: ["editor", "admin"]
read: ["viewer", "editor", "admin"]
delete: ["admin"]
skills:
create: ["admin"]
execute: ["editor", "admin"]
files:
read: ["viewer", "editor", "admin"]
write: ["editor", "admin"]

3.3 canvas.yaml (REQUIRED)

Stores the spatial canvas layout. Persists card positions and minimized state across sessions. This is what canvas.yaml in .morph/ replaces the current localStorage store.

openmorph_version: "0.1.0"

# Schema version for canvas layout (independent of openmorph_version)
canvas_version: 1

updated_at: "2026-02-20T10:00:00Z"

# Active component cards (currently rendered on canvas)
# Note: component IDs are ephemeral per AI render session.
# In V1.0, persistent cards have stable IDs (e.g. pinned task views).
cards: []

# Persisted layout preferences (stable across renders)
layout:
# Default grid slots that auto-placement uses (% of canvas dimensions)
grid_columns: 3
grid_row_height_pct: 42.0

# Named pinned views that always appear on the canvas
# These are stable V1.0 feature — not ephemeral AI renders
pinned_views: []
# Example:
# - id: "pinned-tasks-view"
# type: "task_list"
# position: { x: 2.0, y: 2.0 }
# config: { filter: "status:pending" }
# minimized: false

# Background style
background:
style: "dots" # dots | grid | none
opacity: 0.05

Note on ephemeral vs persistent cards:

In V0.9, all canvas cards are ephemeral (created by the AI per conversation, lost on reload). In V1.0:

  • Ephemeral cards (AI renders) — not stored in canvas.yaml, managed by localStorage/in-memory as today
  • Persistent cards (pinned_views) — stable, defined in canvas.yaml, appear every time the Space is opened

The canvas.yaml format is designed to accommodate both. Implementation of pinned_views is deferred to V1.1.


3.4 tasks/task-{id}.md

One file per task. Filename is task-{uuid}.md. YAML frontmatter carries machine-readable state; Markdown body carries human-readable notes.

---
# Required fields
id: "550e8400-e29b-41d4-a716-446655440010"
type: "message"
status: "pending"
description: "Add dentist appointment for Alice on Thursday"

# Timestamps (ISO 8601 UTC)
created_at: "2026-02-20T10:00:00Z"
updated_at: "2026-02-20T10:01:00Z"
started_at: null
completed_at: null

# Ownership
space_id: "550e8400-e29b-41d4-a716-446655440000"
group_id: "123e4567-e89b-12d3-a456-426614174000"
user_id: "550e8400-e29b-41d4-a716-446655440001"

# Assignment
assigned_to: null
assigned_skill_id: null
assigned_interface_id: null

# Hierarchy
parent_task_id: null

# Execution
priority: 1 # 0=LOW, 1=NORMAL, 2=HIGH, 3=URGENT
input: {}
output: null

# Error handling
retry_count: 0
max_retries: 3
error_message: null
error_stack: null

# Blocking
blocked_reason: null
blocked_data: null
approval_id: null

# Free-form metadata
metadata: {}
---

## Notes

Add to Google Calendar. Alice mentioned she prefers 9-11am slots.

Valid type values: message | webhook | cron | skill_execution | approval_request | subtask

Valid status values: pending | running | blocked | paused | completed | failed | cancelled

State machine:

pending  →  running    (task starts executing)
pending → cancelled (user cancels before start)
pending → paused (user pauses)
running → blocked (waiting for approval or external event)
running → paused (user pauses)
running → completed (success)
running → failed (unrecoverable error)
blocked → running (approval granted / event resolved)
blocked → cancelled
paused → running (user resumes)
paused → cancelled
failed → pending (retry — increments retry_count)
failed → cancelled

completed and cancelled are terminal states — no transitions out.

Constraints:

  • priority must be 0–3.
  • retry_count must not exceed max_retries — the backend enforces this before writing.
  • id in frontmatter must match the filename UUID. Readers should validate this.

3.5 skills/{name}.yaml

One file per skill. Filename is a URL-safe slug of the skill name (e.g. daily-briefing.yaml).

# Required identity fields
id: "550e8400-e29b-41d4-a716-446655440020"
name: "daily-briefing"
description: "Summarize tasks and calendar events for the day"

# Ownership
group_id: "123e4567-e89b-12d3-a456-426614174000"
space_id: "550e8400-e29b-41d4-a716-446655440000" # null = group-wide skill
created_by: "550e8400-e29b-41d4-a716-446655440001"

# Lifecycle
enabled: true
run_count: 0
last_run_at: null
created_at: "2026-02-20T10:00:00Z"
updated_at: "2026-02-20T10:00:00Z"

# Skill definition
definition:
# Input parameters
parameters:
- name: "date"
type: "string" # string | integer | boolean | array | object
description: "Target date (ISO 8601). Defaults to today."
required: false
default: null

# Execution steps (ordered)
steps:
- id: "fetch_tasks"
integration: "tasks"
action: "list"
params:
status: "pending"
space_id: "{{space_id}}"
on_error: "continue" # fail | continue

- id: "fetch_calendar"
integration: "google_calendar"
action: "list_events"
params:
date: "{{params.date}}"
user_id: "{{user_id}}"
on_error: "continue"

- id: "render_briefing"
integration: "frontend"
action: "show_card"
params:
title: "Today's Briefing"
content: "{{steps.fetch_tasks.result}} {{steps.fetch_calendar.result}}"
position_hint: "top-left"
on_error: "fail"

Template variables available in params:

VariableDescription
{{params.{name}}}Skill input parameter value
{{steps.{id}.result}}Full result object from a previous step
{{steps.{id}.result.{field}}}Specific field from a previous step
{{space_id}}Current Space UUID
{{group_id}}Current Group UUID
{{user_id}}Requesting user UUID

Valid on_error values: fail (abort skill) | continue (skip step, proceed)

Naming constraint: The YAML filename slug must be derivable from the name field by replacing spaces and special chars with - and lowercasing. Implementations must validate this on write.


3.6 schedules/{name}.yaml

One file per schedule. Filename is the schedule name slug.

# Required identity
id: "550e8400-e29b-41d4-a716-446655440030"
name: "monday-briefing"
description: "Run the daily briefing skill every Monday at 9am"

# Ownership
group_id: "123e4567-e89b-12d3-a456-426614174000"
space_id: "550e8400-e29b-41d4-a716-446655440000"
user_id: "550e8400-e29b-41d4-a716-446655440001"

# Schedule type — exactly one of fire_at, interval_seconds, cron_expression must be set
schedule_type: "cron" # once | interval | cron
fire_at: null # ISO 8601 UTC — only for type=once
interval_seconds: null # integer ≥ 60 — only for type=interval
cron_expression: "0 9 * * MON" # Standard 5-field cron — only for type=cron
timezone: "America/New_York" # IANA timezone string (default: UTC)

# What to execute
action_type: "skill" # notification | task | chat_message | skill
action_payload:
skill_id: "550e8400-e29b-41d4-a716-446655440020"
params: {}

# Lifecycle
enabled: true
last_run_at: null
next_run_at: "2026-02-23T09:00:00-05:00"
run_count: 0
max_runs: null # null = unlimited

# Timestamps
created_at: "2026-02-20T10:00:00Z"
updated_at: "2026-02-20T10:00:00Z"

Valid action_type values: notification | task | chat_message | skill

action_payload by type:

# notification
action_payload:
title: "Time to review tasks"
body: "You have 3 pending tasks"

# task
action_payload:
task_type: "message"
description: "Weekly review reminder"

# chat_message
action_payload:
message: "Good morning! Here's your weekly summary."

# skill
action_payload:
skill_id: "uuid"
params: {}

Constraints:

  • interval_seconds minimum is 60 (enforced by backend, H-DOS-002).
  • cron_expression must be valid 5-field standard cron syntax.
  • timezone must be a valid IANA timezone string.
  • Exactly one of fire_at, interval_seconds, cron_expression must be non-null (matches schedule_type).

3.7 conversations/conv-{id}.md

Full conversation history. One file per conversation.

---
id: "550e8400-e29b-41d4-a716-446655440040"
title: "Planning dentist appointment"

# Ownership
space_id: "550e8400-e29b-41d4-a716-446655440000"
group_id: "123e4567-e89b-12d3-a456-426614174000"
user_id: "550e8400-e29b-41d4-a716-446655440001"

# Timestamps
created_at: "2026-02-20T10:00:00Z"
updated_at: "2026-02-20T10:05:00Z"

# Message count (for quick stats without parsing body)
message_count: 4

# Optional: IDs of pinned messages
pinned_message_ids: []
---

## user | 2026-02-20T10:00:00Z

Can you add a dentist appointment for Alice on Thursday?

## assistant | 2026-02-20T10:00:03Z

I'll add that to the calendar now. What time works best for Alice?

## user | 2026-02-20T10:00:45Z

9am please.

## assistant | 2026-02-20T10:00:48Z

Done — I've added "Alice: Dentist" on Thursday at 9am to the calendar.

Message format in body:

Each message is a level-2 heading with the pattern ## {role} | {iso_timestamp} followed by the message content.

  • role is user or assistant
  • iso_timestamp is UTC ISO 8601
  • Tool calls and component renders are NOT stored inline — they are referenced by ID in metadata.tool_call_ids if needed

Constraints:

  • message_count in frontmatter must equal the actual count of ## headings in the body. The backend validates this on read.
  • Message content is stored verbatim (no truncation). Long conversations may result in large files — this is acceptable.

3.8 memory/{type}/{id}.md

Individual memory entries. Four types: facts, preferences, events, notes.

---
id: "550e8400-e29b-41d4-a716-446655440050"
type: "preference"

# Source
space_id: "550e8400-e29b-41d4-a716-446655440000"
group_id: "123e4567-e89b-12d3-a456-426614174000"
user_id: "550e8400-e29b-41d4-a716-446655440001" # null if group memory
source_conversation_id: "550e8400-e29b-41d4-a716-446655440040"

# Classification
scope: "space" # space | group

# Timestamps
created_at: "2026-02-20T10:00:00Z"
updated_at: "2026-02-20T10:00:00Z"
---

Alice prefers morning appointments (9–11am) over afternoon slots.

Valid type values: fact | preference | event | note | canvas_component

Type semantics:

TypeWhen to useExample
factObjective, stable information"The API endpoint is /api/v2/users"
preferenceUser likes/dislikes, habits"Alice prefers dark mode"
eventTime-bound occurrences"Dentist appointment Thursday 9am"
noteGeneral information, less structured"Reminder to discuss budget at next meeting"
canvas_componentArchived canvas card content"Shopping list: milk, eggs, butter (5 items)"

Directory mapping:

typeDirectory
factmemory/facts/
preferencememory/preferences/
eventmemory/events/
notememory/notes/
canvas_componentmemory/canvas/

canvas_component format:

---
id: "uuid"
type: "canvas_component"
space_id: "uuid"
group_id: "uuid"
user_id: "uuid"
source_conversation_id: "uuid"
scope: "space"
created_at: "2026-02-20T10:00:00Z"
updated_at: "2026-02-20T10:00:00Z"
metadata:
component_id: "original-component-uuid"
title: "Shopping list"
source: "canvas_dismiss"
---

Shopping list: 5 items — milk, eggs, butter, bread, orange juice.

3.9 vault.enc

An encrypted key-value store for credentials and secrets. Never stored in plaintext.

ENC:gAAAAABh... (Fernet-encrypted blob)

The decrypted content is YAML:

# Credentials for integrations configured in this Space
google_oauth_access_token: "ya29.a0..."
google_oauth_refresh_token: "1//0..."
google_oauth_expires_at: "2026-02-20T11:00:00Z"

Rules:

  • The entire file is a single Fernet-encrypted blob with an ENC: prefix.
  • The encryption key is derived from the group's key (group-key mode) or the user's key (user-key mode).
  • The backend's EncryptionService handles all vault reads/writes.
  • The file must never be committed to a public git remote. The backend must validate sync.mode != "git-remote" before writing credentials to vault.enc. For git-remote sync, credentials must live in the OS keychain (Tauri) or backend secrets store.
  • vault.enc is excluded from diffs and patches.

4. Git Conventions

Every write to .morph/ is a git commit. Commit message format:

morph({type}): {short description}

{optional body}

Commit types:

TypeWhen
morph(task)Task created, updated, status changed
morph(skill)Skill created or updated
morph(schedule)Schedule created or updated
morph(memory)Memory entry stored or deleted
morph(conversation)Conversation updated (new messages)
morph(canvas)Canvas layout changed
morph(config)Space configuration changed
morph(acl)ACL rules changed
morph(migrate)Data migration (automated)
morph(sync)Pull/merge from remote

Examples:

morph(task): create task "Add dentist appointment"

morph(task): complete task 550e8400 — "Add dentist appointment"

morph(memory): store preference — Alice prefers morning appointments

morph(conversation): update conv 550e8400 — 2 new messages

morph(skill): create skill "daily-briefing"

morph(canvas): update canvas layout — card moved top-left

morph(migrate): V0.9 → V1.0 migration — 47 tasks, 12 conversations

Author: All automated commits use:

Author: Morphee <morphee@local>

Human commits (user edits .morph/ files directly) preserve the user's git identity.

Branch naming:

BranchPurpose
mainProduction state of the Space
experimental/{name}AI-created experimental branches
sync/{timestamp}Temporary branch for conflict resolution

5. Sync Modes

Configured in config.yaml under sync.mode.

local-only

The Space lives entirely on the local device. No network sync.

  • Git is optional but encouraged for local history
  • git.remote_url must be null
  • Credentials in vault use OS keychain (Tauri) — no backend needed
  • Web client: not supported (web has no local filesystem)

morphee-hosted

The Space syncs to Morphee's backend and a Morphee-managed GitLab repo.

  • group.sync_url points to the backend sync endpoint
  • The backend maintains a GitLab repo per Space
  • Sync happens: on explicit user request, on app foreground, or on timer
  • Pull = git fetch origin && git merge origin/main --ff-only
  • Push = git push origin main
  • Conflicts: detected by non-fast-forward push → sync/{timestamp} branch created → user resolves

git-remote

The Space syncs to a user-provided git remote (GitHub, GitLab, Gitea, etc.).

  • git.remote_url points to the user's repo
  • No Morphee backend involvement for sync
  • The user is responsible for authentication (SSH keys, tokens)
  • Credentials must NOT be in vault.enc (would be committed to the user's repo)
  • Same pull/push/conflict flow as morphee-hosted

Sync algorithm

1. Pull remote (if not local-only):
- git fetch origin
- If local is behind: git merge origin/main --ff-only
- If diverged: create sync/{timestamp} branch, notify user

2. Write local change:
- Write file(s) to .morph/
- git add {changed files}
- git commit -m "morph({type}): {description}"

3. Push (if not local-only and auto_push=true):
- git push origin main
- On failure: queue for next sync

Conflict resolution (V1.0): Last-write-wins for non-overlapping fields. If the same task file was modified in two branches simultaneously, the backend presents both versions to the AI + user for manual merge. AI-mediated merge UI is V1.1.


6. Nested Spaces

A Space can contain sub-Spaces. Each sub-Space has its own .morph/ directory inside the parent:

project/
├── .morph/ # Root Space (TechCorp API Project)
│ └── config.yaml
├── frontend/
│ ├── .morph/ # Sub-Space (Frontend Team)
│ │ └── config.yaml # parent.path: "../../.morph"
│ └── src/
└── backend/
├── .morph/ # Sub-Space (Backend Team)
│ └── config.yaml # parent.path: "../../.morph"
└── api/

Inheritance rules:

ResourceInherited by defaultOverride
ACLYes (if inherit_acl: true)Define acl.yaml in sub-Space
IntegrationsYes (if inherit_integrations: true)Add/remove in sub-Space config.yaml
MemoryNo — sub-Space has its own memoryUse scope: "group" for cross-Space memory
TasksNo — sub-Space has its own tasksN/A
SkillsYes — parent skills availableSub-Space can shadow with same-named skill
CanvasNo — each Space has its own canvasN/A

Circular nesting is invalid. Implementations must detect and reject it.

Maximum nesting depth: 10 levels. Deeper hierarchies must be rejected with a clear error.


7. Migration Guide (V0.9 → V1.0)

This is a breaking change. The migration script runs once on first V1.0 deploy.

What changes

V0.9V1.0
Tasks in PostgreSQL tasks tableTasks in .morph/tasks/task-{id}.md + PostgreSQL read cache
Skills in PostgreSQL skills tableSkills in .morph/skills/{name}.yaml + read cache
Schedules in PostgreSQL schedules tableSchedules in .morph/schedules/{name}.yaml + read cache
Memory in PostgreSQL memory_vectorsMemory files in .morph/memory/{type}/{id}.md + pgvector read cache
Conversations in PostgreSQL conversations + messagesConversations in .morph/conversations/conv-{id}.md + PostgreSQL read cache
Canvas positions in localStorageCanvas layout in .morph/canvas.yaml + localStorage as L1 cache
Spaces as database rowsSpaces as .morph/ directories (database row becomes pointer)

Migration script behavior

1. For each existing Space (PostgreSQL):
a. Create .morph/ directory at backend storage path
b. Write config.yaml from Space metadata
c. Write acl.yaml from ACL rules (if any)
d. Write canvas.yaml (empty state)
e. git init + initial commit "morph(migrate): V0.9 → V1.0 migration"

2. For each Task in the Space:
a. Write .morph/tasks/task-{id}.md
b. Commit: "morph(migrate): import task {id}"

3. For each Skill:
a. Write .morph/skills/{name}.yaml
b. Commit: "morph(migrate): import skill {name}"

4. For each Schedule:
a. Write .morph/schedules/{name}.yaml
b. Commit: "morph(migrate): import schedule {name}"

5. For each Conversation + Messages:
a. Write .morph/conversations/conv-{id}.md
b. Commit: "morph(migrate): import conversation {id}"

6. For each Memory entry:
a. Write .morph/memory/{type}/{id}.md
b. Commit: "morph(migrate): import {count} memories"

7. Update PostgreSQL:
a. Mark all Spaces as migrated (add migrated_at column)
b. PostgreSQL data is NOT deleted — it becomes the read cache
c. All subsequent writes go to Git first, then update PostgreSQL

8. Verify:
a. Read back all migrated entities from .morph/ files
b. Compare checksums against PostgreSQL data
c. Log migration report

Rollback: If migration fails partway, the script can be re-run idempotently (it skips already-migrated entities). PostgreSQL data is never deleted during migration.

Timeline

Migration runs automatically on first V1.0 backend startup. Expected duration: ~2 minutes for a typical group with 1000 tasks / 500 conversations / 100 skills.


8. Versioning Policy

The spec follows semver:

  • Patch (0.1.x) — Clarifications, fixing spec bugs, adding optional fields. Fully backward compatible.
  • Minor (0.x.0) — New optional top-level sections, new optional fields. Backward compatible.
  • Major (x.0.0) — Breaking changes to required fields, renamed sections, removed fields. Migration required.

Reader behavior:

  • Readers MUST understand the major version in openmorph_version.
  • Readers MAY ignore unknown optional fields (forward compatibility).
  • Readers MUST reject files with a higher major version than they understand.

Current version: 0.1.0

Next planned version: 0.2.0 — adds pinned_views in canvas.yaml (V1.1 persistent canvas cards)


9. Open Questions

These are the only design decisions NOT yet locked. They will be resolved before implementing the relevant phase.

#QuestionContextTarget Phase
Q1Conflict resolution UX — Last-write-wins, manual diff UI, or AI-mediated?V1.0 uses last-write-wins. AI-mediated merge is the goal.V1.1
Q2vault.enc in git-remote mode — How do users manage credentials when Morphee backend isn't involved?Options: OS keychain only, .env file at project root (gitignored), or user-managed secret managerV1.0 Phase 2
Q3Performance ceiling — At what .morph/ size does git operations slow down?Need to benchmark git log, grep, status at 10k / 50k / 100k filesV1.0 Phase 1
Q4Pinned views schema — Exact format for stable canvas cards in canvas.yamlSee §3.3 pinned_views placeholderV1.1
Q5Sub-Space discovery — Should morph_discover scan recursively or only top-level?Recursive could be slow on large repos; top-level misses nested spacesV1.0 Phase 2

Written Feb 20, 2026. Locked for OpenMorph V1.0 implementation. Next review: when Q1–Q5 are resolved or when a schema change is needed.