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
- Concept
- Directory Structure
- File Schemas
- Git Conventions
- Sync Modes
- Nested Spaces
- Migration Guide (V0.9 → V1.0)
- Versioning Policy
- 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.
Entity Lifecycle
Every entity (task, memory, skill, conversation, etc.) stored in .morph/ follows this lifecycle:
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.yamlis 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:
| Channel | Direction | API | Nature |
|---|---|---|---|
| Action | Morphee → Integration | execute() | Synchronous request/response |
| File | Integration ↔ .morph/ | morph_write() / morph_read() | Async, persistent, git-tracked |
| Event | Integration → EventBus → subscribers | emit + 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 bylocalStorage/in-memory as today - Persistent cards (
pinned_views) — stable, defined incanvas.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:
prioritymust be 0–3.retry_countmust not exceedmax_retries— the backend enforces this before writing.idin 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:
| Variable | Description |
|---|---|
{{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_secondsminimum is 60 (enforced by backend, H-DOS-002).cron_expressionmust be valid 5-field standard cron syntax.timezonemust be a valid IANA timezone string.- Exactly one of
fire_at,interval_seconds,cron_expressionmust be non-null (matchesschedule_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.
roleisuserorassistantiso_timestampis UTC ISO 8601- Tool calls and component renders are NOT stored inline — they are referenced by ID in
metadata.tool_call_idsif needed
Constraints:
message_countin 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:
| Type | When to use | Example |
|---|---|---|
fact | Objective, stable information | "The API endpoint is /api/v2/users" |
preference | User likes/dislikes, habits | "Alice prefers dark mode" |
event | Time-bound occurrences | "Dentist appointment Thursday 9am" |
note | General information, less structured | "Reminder to discuss budget at next meeting" |
canvas_component | Archived canvas card content | "Shopping list: milk, eggs, butter (5 items)" |
Directory mapping:
type | Directory |
|---|---|
fact | memory/facts/ |
preference | memory/preferences/ |
event | memory/events/ |
note | memory/notes/ |
canvas_component | memory/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
EncryptionServicehandles 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.encis 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:
| Type | When |
|---|---|
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:
| Branch | Purpose |
|---|---|
main | Production 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_urlmust 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_urlpoints 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_urlpoints 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:
| Resource | Inherited by default | Override |
|---|---|---|
| ACL | Yes (if inherit_acl: true) | Define acl.yaml in sub-Space |
| Integrations | Yes (if inherit_integrations: true) | Add/remove in sub-Space config.yaml |
| Memory | No — sub-Space has its own memory | Use scope: "group" for cross-Space memory |
| Tasks | No — sub-Space has its own tasks | N/A |
| Skills | Yes — parent skills available | Sub-Space can shadow with same-named skill |
| Canvas | No — each Space has its own canvas | N/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.9 | V1.0 |
|---|---|
Tasks in PostgreSQL tasks table | Tasks in .morph/tasks/task-{id}.md + PostgreSQL read cache |
Skills in PostgreSQL skills table | Skills in .morph/skills/{name}.yaml + read cache |
Schedules in PostgreSQL schedules table | Schedules in .morph/schedules/{name}.yaml + read cache |
Memory in PostgreSQL memory_vectors | Memory files in .morph/memory/{type}/{id}.md + pgvector read cache |
Conversations in PostgreSQL conversations + messages | Conversations in .morph/conversations/conv-{id}.md + PostgreSQL read cache |
| Canvas positions in localStorage | Canvas layout in .morph/canvas.yaml + localStorage as L1 cache |
| Spaces as database rows | Spaces 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.
| # | Question | Context | Target Phase |
|---|---|---|---|
| Q1 | Conflict 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 |
| Q2 | vault.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 manager | V1.0 Phase 2 |
| Q3 | Performance ceiling — At what .morph/ size does git operations slow down? | Need to benchmark git log, grep, status at 10k / 50k / 100k files | V1.0 Phase 1 |
| Q4 | Pinned views schema — Exact format for stable canvas cards in canvas.yaml | See §3.3 pinned_views placeholder | V1.1 |
| Q5 | Sub-Space discovery — Should morph_discover scan recursively or only top-level? | Recursive could be slow on large repos; top-level misses nested spaces | V1.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.