diff --git a/.planboard.json b/.planboard.json
new file mode 100644
index 0000000..04e0b33
--- /dev/null
+++ b/.planboard.json
@@ -0,0 +1,29 @@
+{
+ "version": 1,
+ "plans": {
+ "docs/plan/compiler/2026-08-01-haxe-parity-language.md": {
+ "tasks": {
+ "111d92e6436d": {
+ "status": "doing",
+ "updatedAt": "2026-08-12T23:42:50Z"
+ },
+ "80e49fea186b": {
+ "status": "done",
+ "updatedAt": "2026-08-12T23:42:38Z"
+ },
+ "84addb11f795": {
+ "status": "done",
+ "updatedAt": "2026-08-12T23:42:35Z"
+ },
+ "942b4a1e8925": {
+ "status": "done",
+ "updatedAt": "2026-08-12T23:42:15Z"
+ },
+ "ea0f91fca71b": {
+ "status": "done",
+ "updatedAt": "2026-08-12T23:42:40Z"
+ }
+ }
+ }
+ }
+}
diff --git a/.planboard/work/docs--plan--compiler--2026-08-01-haxe-parity-language/progress.md b/.planboard/work/docs--plan--compiler--2026-08-01-haxe-parity-language/progress.md
new file mode 100644
index 0000000..37ece26
--- /dev/null
+++ b/.planboard/work/docs--plan--compiler--2026-08-01-haxe-parity-language/progress.md
@@ -0,0 +1,9 @@
+---
+planboard: generated
+kind: ledger
+plan: docs/plan/compiler/2026-08-01-haxe-parity-language.md
+started: 2026-08-13
+---
+
+# Ledger — Haxe-Parity Language Adoptions Implementation Plan
+
diff --git a/crates/rt/src/parser.rs b/crates/rt/src/parser.rs
index f047df1..f54d741 100644
--- a/crates/rt/src/parser.rs
+++ b/crates/rt/src/parser.rs
@@ -1004,10 +1004,55 @@ type Order { status: Pending | Paid | Shipped }
#[test]
fn article_debug() {
- let src = std::fs::read_to_string(concat!(
- env!("CARGO_MANIFEST_DIR"), "/../../docs/examples/blog/types/article.wo"
- )).unwrap();
- let sch = parse(&src).unwrap();
+ // formerly read docs/examples/blog/types/article.wo; the blog sample
+ // moved to the cleanup branch, so the fixture lives inline now —
+ // same shape: scalars, embedded doc, edges, backlink, computed,
+ // policies, triggers, service
+ let src = r#"
+type Article {
+ id: Id
+ slug: Slug @unique
+ title: Text
+ author: ref Author
+ published: Bool = false
+ published_at: Timestamp?
+ created_at: Timestamp = now()
+ updated_at: Timestamp = now()
+
+ meta: {
+ excerpt: Text
+ body_md: Markdown
+ hero_image: Url?
+ reading_min: Int?
+ }
+
+ tags: multi Tag @edge(:TAGGED_AS)
+ related: multi Article @edge(:RELATED_TO)
+ prerequisites: multi Article @edge(:PREREQUISITE)
+ comments: backlink Comment.article
+ word_count: Int = words(meta.body_md)
+
+ policy read anyone when published == true
+ policy read for role Admin
+ policy read for role Author when author == $session.user
+ policy write for role Admin
+ policy write for role Author when author == $session.user
+ policy delete for role Admin
+
+ on update
+ when old.published == false and new.published == true
+ do set self.published_at = now()
+ do emit "article.published"(self)
+ do enqueue "send-subscriber-emails" with { article_id: self.id }
+
+ on update
+ do set self.updated_at = now()
+
+ service rest "/api/articles"
+ expose list, get, create, update, delete, subscribe
+}
+"#;
+ let sch = parse(src).unwrap();
eprintln!("types: {}", sch.types.len());
for t in &sch.types {
eprintln!(" type {} — {} fields, {} services", t.name, t.fields.len(), t.services.len());
diff --git a/docs/examples/agent-loop/README.md b/docs/examples/agent-loop/README.md
deleted file mode 100644
index f012a34..0000000
--- a/docs/examples/agent-loop/README.md
+++ /dev/null
@@ -1,148 +0,0 @@
-# agent-loop — build the loop that turns a model into an agent
-
-A guided, working implementation of an **agent loop** — the mechanism at the
-core of Claude Code, opencode, Cursor, and every other "agentic" tool — in one
-Python file, standard library only, running entirely on the **local model**
-served by [`prototypes/llama-moe-stream/start-local-agents.sh`](../../../prototypes/llama-moe-stream/start-local-agents.sh)
-(or any OpenAI-compatible endpoint, e.g. Ollama).
-
-You already run opencode against the local Qwen3-Coder server. This example is
-what opencode *is*, with the product stripped away: read
-[`agent.py`](agent.py) top to bottom and you know how every coding agent works.
-
-## 1. The idea
-
-A language model only ever produces text. What makes it an *agent* is a loop
-around it that (a) tells it what tools exist, (b) executes the tool calls it
-emits, and (c) feeds the results back — until it stops asking:
-
-```
-messages = [system, user question]
-loop:
- reply = POST /v1/chat/completions (messages + TOOL SCHEMAS)
- append reply to messages
- if reply has no tool_calls: # the model is done
- print reply.content; stop
- for each tool_call in reply:
- result = run it locally # YOUR code — the model never executes anything
- append {role: "tool", content: result} to messages
-```
-
-Two properties fall out of this shape, and they are the whole mental model:
-
-- **The transcript is the only state.** `messages` grows append-only; the
- model re-reads the entire history every round. There is no other memory —
- which is why the assistant's own tool-call message must be appended too, not
- just the results (the model has to see *what it asked for* next round).
-- **The model proposes, your process disposes.** Tool calls are requests in
- JSON. The executor decides what actually happens — it is the security
- boundary, so the agent is exactly as dangerous as its tools, never more.
-
-## 2. The five pieces (each maps to a section of `agent.py`)
-
-| # | Piece | In `agent.py` |
-| --- | --- | --- |
-| 1 | **A tool-calling endpoint** — `llama-server --jinja` applies Qwen's chat template so tool schemas go in and structured `tool_calls` come out | `chat()` |
-| 2 | **Tool schemas** — the JSON contract shown to the model; descriptions are prompts, write them like documentation | `TOOLS` |
-| 3 | **The executor** — dispatch, argument parsing, sandboxing; every failure returned as words, never raised | `execute()` |
-| 4 | **The transcript** — one append-only `messages` list | `run_turn()` |
-| 5 | **Stop conditions** — natural (no `tool_calls`) and budgeted (`MAX_TURNS`) | `run_turn()` |
-
-The tools here are deliberately read-only (`list_dir`, `read_file`, `search`)
-and confined to `AGENT_ROOT` — enough to make a useful repo-Q&A agent with
-zero risk while you study the loop.
-
-## 3. Run it
-
-```bash
-# 1. Start the local model (from prototypes/llama-moe-stream)
-prototypes/llama-moe-stream/start-local-agents.sh # Qwen3-Coder on :8080
-
-# 2. One-shot, over this repo
-cd /path/to/writeonce-all
-python3 docs/examples/agent-loop/agent.py "which file implements the shard bus, and how do cross-shard reads work?"
-
-# 3. Interactive — the conversation persists across questions
-python3 docs/examples/agent-loop/agent.py
-```
-
-Tool calls print to stderr as they happen (`⚙ search({"pattern": ...})`), so
-you watch the loop investigate before it answers.
-
-Against Ollama instead: `LLM_URL=http://localhost:11434/v1 LLM_MODEL=qwen3:4b
-python3 agent.py ...` (Ollama serves the same OpenAI-compatible `/v1`; small
-models tool-call noticeably worse than Qwen3-Coder-30B — that difference is
-itself instructive).
-
-## 4. The guards that keep the loop alive
-
-The naive loop works until the model misbehaves — and it will. The one rule:
-**never raise at the model; return the failure as the tool result.** A
-tool-calling model reads the error and corrects itself next round; an
-exception just kills the conversation.
-
-| Failure | Guard in `agent.py` |
-| --- | --- |
-| Arguments aren't valid JSON | error string back: "resend the call with corrected JSON" |
-| Tool name doesn't exist | error string back, listing the real tools |
-| Wrong/missing/extra parameters | `TypeError` caught → described back |
-| Tool output too big for the context | truncated at 8k chars with an instruction to narrow |
-| Path outside the project | `_resolve()` refuses (symlinks resolved first) |
-| Model never stops calling tools | `MAX_TURNS` budget ends the turn with a readable note |
-
-## 5. Smoke-test without a model
-
-```bash
-python3 docs/examples/agent-loop/test_loop.py
-```
-
-Stands up a canned `/chat/completions` on a local port and scripts three
-rounds — a real `read_file`, a tool call with deliberately broken JSON
-arguments, then a final answer — asserting that results are fed back, errors
-self-correct, and the loop terminates. This is the loop's mechanics verified
-in milliseconds, no GGUF required.
-
-## Configuration
-
-| Variable | Default | Meaning |
-| --- | --- | --- |
-| `LLM_URL` | `http://127.0.0.1:8080/v1` | OpenAI-compatible base URL |
-| `LLM_MODEL` | `qwen3-coder` | model name (`--alias` of the llama-server) |
-| `LLM_TIMEOUT` | `300` | per-request timeout, seconds |
-| `AGENT_ROOT` | current directory | sandbox root for all three tools |
-| `MAX_TURNS` | `20` | tool rounds per user message |
-
-## How this relates to writeonce
-
-This is the third corner of the local-agent triangle in this repo:
-
-- [`mcp-think`](../mcp-think/) is the **tool side** — a local model offered
- *as tools to* an agent (Claude).
-- [`prototypes/llama-moe-stream`](../../../prototypes/llama-moe-stream/) is
- the **model side** — serving the MoE this loop drives.
-- **This example is the agent side** — the harness itself.
-
-The natural next step joins them to [plan 15](../../plan/15-mcp-streamable-http.md):
-once the writeonce runtime speaks MCP over Streamable HTTP, the hardcoded
-`TOOLS` list here gets replaced by an **MCP client** — `tools/list` supplies
-the schemas, `tools/call` becomes the executor (the JSON-RPC lifecycle is
-already demonstrated in [`mcp-think/test_client.py`](../mcp-think/test_client.py)).
-Then this same ~40-line loop lets a fully local model operate a `.wo`
-application: `article_create`, `set_price`, live resources — one catalog, one
-engine, one port, no cloud.
-
-## Ideas to build next
-
-Each is a small, self-contained extension of the loop — and each corresponds
-to a feature you use daily in Claude Code/opencode:
-
-- **MCP tool source** — fetch schemas from an MCP server at startup instead of
- hardcoding `TOOLS`; route `execute()` through `tools/call`. (= MCP support)
-- **A `write_file` tool behind a y/n prompt** — the executor asks *you* before
- acting. (= permission modes)
-- **A `spawn_agent` tool** that runs a fresh `run_turn()` with its own
- transcript and returns only the final answer. (= sub-agents)
-- **Transcript compaction** — when `messages` outgrows the context window,
- summarize the older rounds into one message. (= auto-compact)
-- **Parallel tool execution** — a reply may carry several `tool_calls`; run
- them concurrently, append results in order. (= parallel tool use)
diff --git a/docs/examples/agent-loop/agent.py b/docs/examples/agent-loop/agent.py
deleted file mode 100644
index a7eb0c2..0000000
--- a/docs/examples/agent-loop/agent.py
+++ /dev/null
@@ -1,285 +0,0 @@
-#!/usr/bin/env python3
-"""agent-loop — a complete agent in one file, on a local model.
-
-The whole trick behind Claude Code, opencode, Cursor and every other
-"agentic" tool is one loop:
-
- send the transcript + tool schemas to the model
- while the model answers with tool calls:
- run the tools, append the results to the transcript
- send again
- print the final text
-
-Everything else those tools add (permissions, context management,
-sub-agents) is elaboration on that loop. This file IS the loop, small
-enough to read in one sitting: an OpenAI-compatible chat endpoint
-(llama-server from prototypes/llama-moe-stream, or Ollama) + three
-read-only repo tools + the guards that keep the loop alive when the
-model misbehaves.
-
-Run (one-shot): python3 agent.py "what does crates/rt/src/shard.rs do?"
-Run (interactive): python3 agent.py
-
-Configuration (environment):
- LLM_URL OpenAI-compatible base URL (default http://127.0.0.1:8080/v1)
- LLM_MODEL model name / alias (default qwen3-coder)
- LLM_TIMEOUT per-request timeout in seconds (default 300)
- AGENT_ROOT directory the tools may touch (default: current directory)
- MAX_TURNS tool rounds per user message (default 20)
-
-Dependencies: Python standard library only.
-"""
-
-import json
-import os
-import re
-import sys
-import urllib.error
-import urllib.request
-
-LLM_URL = os.environ.get("LLM_URL", "http://127.0.0.1:8080/v1").rstrip("/")
-LLM_MODEL = os.environ.get("LLM_MODEL", "qwen3-coder")
-LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "300"))
-ROOT = os.path.realpath(os.environ.get("AGENT_ROOT", os.getcwd()))
-MAX_TURNS = int(os.environ.get("MAX_TURNS", "20"))
-MAX_RESULT = 8_000 # chars of tool output fed back per call
-SKIP_DIRS = {".git", "target", "node_modules", "build", "__pycache__", ".cache"}
-
-
-# ---------------------------------------------------------------- the tools
-# Schemas are the contract shown to the model; implementations are the
-# security boundary. Read-only on purpose — an agent is exactly as dangerous
-# as its tools, never more.
-
-TOOLS = [
- {"type": "function", "function": {
- "name": "list_dir",
- "description": "List one directory: entries with a trailing / for "
- "subdirectories and a byte size for files.",
- "parameters": {"type": "object", "properties": {
- "path": {"type": "string",
- "description": "directory, relative to the project root"},
- }, "required": ["path"]}}},
- {"type": "function", "function": {
- "name": "read_file",
- "description": "Read a text file with line numbers. Large files are "
- "windowed — pass offset (1-based first line) and limit "
- "(max lines) to page through.",
- "parameters": {"type": "object", "properties": {
- "path": {"type": "string", "description": "file, relative to the project root"},
- "offset": {"type": "integer", "description": "first line to show, 1-based (default 1)"},
- "limit": {"type": "integer", "description": "max lines to show (default 200)"},
- }, "required": ["path"]}}},
- {"type": "function", "function": {
- "name": "search",
- "description": "Search file contents under a directory with a Python "
- "regular expression. Returns path:line: text matches. "
- "Use a specific pattern — results cap at 100 matches.",
- "parameters": {"type": "object", "properties": {
- "pattern": {"type": "string", "description": "Python regex to find"},
- "path": {"type": "string", "description": "directory to search, relative to the project root (default: whole root)"},
- }, "required": ["pattern"]}}},
-]
-
-
-class ToolError(Exception):
- """A tool refusing to do something — reported to the model, never fatal."""
-
-
-def _resolve(path: str) -> str:
- """Confine every path the model asks for to ROOT (symlinks resolved)."""
- full = os.path.realpath(os.path.join(ROOT, path))
- if full != ROOT and not full.startswith(ROOT + os.sep):
- raise ToolError(f"path escapes the project root: {path}")
- return full
-
-
-def list_dir(path: str = ".") -> str:
- full = _resolve(path)
- if not os.path.isdir(full):
- raise ToolError(f"not a directory: {path}")
- rows = []
- for name in sorted(os.listdir(full)):
- p = os.path.join(full, name)
- rows.append(f"{name}/" if os.path.isdir(p)
- else f"{name} ({os.path.getsize(p)} bytes)")
- return "\n".join(rows) or "(empty directory)"
-
-
-def read_file(path: str, offset: int = 1, limit: int = 200) -> str:
- full = _resolve(path)
- if os.path.isdir(full):
- raise ToolError(f"{path} is a directory — use list_dir")
- try:
- with open(full, errors="replace") as f:
- lines = f.readlines()
- except FileNotFoundError:
- raise ToolError(f"no such file: {path}")
- if not lines:
- return "(empty file)"
- offset = max(1, int(offset))
- limit = max(1, min(int(limit), 1000))
- window = lines[offset - 1: offset - 1 + limit]
- if not window:
- raise ToolError(f"{path} has only {len(lines)} lines; offset {offset} is past the end")
- out = "".join(f"{i}\t{line}" for i, line in enumerate(window, offset))
- last = offset + len(window) - 1
- if last < len(lines):
- out += (f"\n[agent] showing lines {offset}-{last} of {len(lines)} — "
- f"call again with offset={last + 1} for more")
- return out
-
-
-def search(pattern: str, path: str = ".") -> str:
- try:
- rx = re.compile(pattern)
- except re.error as e:
- raise ToolError(f"bad regex {pattern!r}: {e}")
- start = _resolve(path)
- hits: list[str] = []
- for dirpath, dirnames, filenames in os.walk(start): # never follows symlinks
- dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS)
- for fname in sorted(filenames):
- full = os.path.join(dirpath, fname)
- if os.path.islink(full) or os.path.getsize(full) > 2_000_000:
- continue
- try:
- with open(full, errors="replace") as f:
- head = f.read(1024)
- if "\0" in head: # binary — skip
- continue
- f.seek(0)
- for i, line in enumerate(f, 1):
- if rx.search(line):
- rel = os.path.relpath(full, ROOT)
- hits.append(f"{rel}:{i}: {line.rstrip()[:200]}")
- if len(hits) >= 100:
- hits.append("[agent] 100-match cap hit — tighten the pattern or narrow the path")
- return "\n".join(hits)
- except OSError:
- continue
- return "\n".join(hits) or f"no matches for {pattern!r} under {path}"
-
-
-TOOL_IMPLS = {"list_dir": list_dir, "read_file": read_file, "search": search}
-
-
-# ---------------------------------------------------------- executing calls
-# The one rule that keeps the loop alive: NEVER raise at the model. Whatever
-# goes wrong — unknown tool, broken JSON, missing file — comes back as the
-# tool result, in words. A tool-calling model reads the error and corrects
-# itself on the next round; an exception would just kill the conversation.
-
-def execute(call: dict) -> str:
- fn_block = call.get("function") or {}
- name = fn_block.get("name", "")
- impl = TOOL_IMPLS.get(name)
- if impl is None:
- return f"[agent] unknown tool {name!r} — available: {', '.join(TOOL_IMPLS)}"
- try:
- args = json.loads(fn_block.get("arguments") or "{}")
- except json.JSONDecodeError as e:
- return f"[agent] arguments were not valid JSON ({e}) — resend the call with corrected JSON"
- if not isinstance(args, dict):
- return "[agent] arguments must be a JSON object"
- try:
- out = impl(**args)
- except ToolError as e:
- return f"[agent] {e}"
- except TypeError as e:
- return f"[agent] bad arguments for {name}: {e}"
- except OSError as e:
- return f"[agent] {name} failed: {e}"
- if len(out) > MAX_RESULT:
- out = (out[:MAX_RESULT] + f"\n[agent] truncated — {len(out)} chars total; "
- "narrow the request (offset/limit, tighter pattern)")
- return out
-
-
-# ------------------------------------------------------------------ the loop
-
-def chat(messages: list[dict]) -> dict:
- """One request to the model. Returns the assistant message verbatim."""
- body = json.dumps({
- "model": LLM_MODEL,
- "messages": messages,
- "tools": TOOLS,
- "tool_choice": "auto",
- }).encode()
- req = urllib.request.Request(
- f"{LLM_URL}/chat/completions",
- data=body,
- headers={"Content-Type": "application/json"},
- )
- try:
- with urllib.request.urlopen(req, timeout=LLM_TIMEOUT) as resp:
- data = json.load(resp)
- except urllib.error.HTTPError as e:
- detail = e.read().decode(errors="replace")[:400]
- raise SystemExit(f"the model endpoint rejected the request (HTTP {e.code}): {detail}")
- except (urllib.error.URLError, TimeoutError, OSError) as e:
- raise SystemExit(
- f"cannot reach the model at {LLM_URL} ({e}).\n"
- "Start one first:\n"
- " prototypes/llama-moe-stream/start-local-agents.sh # llama-server on :8080\n"
- " LLM_URL=http://localhost:11434/v1 LLM_MODEL=qwen3:4b … # or a pulled Ollama model")
- return data["choices"][0]["message"]
-
-
-def run_turn(messages: list[dict]) -> str:
- """THE AGENT LOOP. Everything above exists to serve these few lines."""
- for _ in range(MAX_TURNS):
- msg = chat(messages)
- messages.append(msg) # the transcript is the only state
- calls = msg.get("tool_calls") or []
- if not calls: # no tool call = the model is done
- return _strip_think(msg.get("content") or "")
- for call in calls:
- fn = call.get("function") or {}
- print(f" ⚙ {fn.get('name', '?')}({(fn.get('arguments') or '')[:120]})",
- file=sys.stderr)
- messages.append({
- "role": "tool",
- "tool_call_id": call.get("id", ""),
- "content": execute(call),
- })
- return (f"[agent] stopped after {MAX_TURNS} tool rounds without a final answer — "
- "ask a narrower question or raise MAX_TURNS")
-
-
-def _strip_think(text: str) -> str:
- # Reasoning models may inline chain-of-thought as …;
- # the user wants the conclusion, not the scratchpad.
- return re.sub(r".*?", "", text, flags=re.DOTALL).strip()
-
-
-# ----------------------------------------------------------------- the shell
-
-SYSTEM = (f"You are a code assistant working inside the project rooted at {ROOT}. "
- "Use the tools to look at real files before answering; never invent "
- "file contents or paths. When you have enough evidence, answer "
- "concisely and cite locations as path:line.")
-
-
-def main() -> None:
- messages: list[dict] = [{"role": "system", "content": SYSTEM}]
- if len(sys.argv) > 1: # one-shot
- messages.append({"role": "user", "content": " ".join(sys.argv[1:])})
- print(run_turn(messages))
- return
- print(f"agent-loop — model {LLM_MODEL} at {LLM_URL}\n"
- f"root {ROOT} (Ctrl-D to exit; the conversation persists across questions)")
- while True: # interactive
- try:
- line = input("\nyou> ").strip()
- except EOFError:
- print()
- return
- if not line:
- continue
- messages.append({"role": "user", "content": line})
- print(run_turn(messages))
-
-
-if __name__ == "__main__":
- main()
diff --git a/docs/examples/agent-loop/test_loop.py b/docs/examples/agent-loop/test_loop.py
deleted file mode 100644
index bb8388c..0000000
--- a/docs/examples/agent-loop/test_loop.py
+++ /dev/null
@@ -1,105 +0,0 @@
-#!/usr/bin/env python3
-"""Smoke-test the agent loop without any model.
-
-Stands up a canned OpenAI-compatible /chat/completions endpoint on a local
-port and drives agent.run_turn() through a scripted conversation:
-
- round 1: the "model" calls read_file on notes.txt -> executed for real
- round 2: it sends a tool call with broken JSON args -> fed back as an error, not a crash
- round 3: it returns a final answer
-
-This verifies the three load-bearing behaviours of the loop: tool execution
-with result feedback, errors-as-results self-correction, and termination on
-a plain (tool-call-free) answer.
-
-Usage: python3 test_loop.py # standard library only, no model needed
-"""
-
-import json
-import os
-import sys
-import tempfile
-import threading
-from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
-
-HERE = os.path.dirname(os.path.abspath(__file__))
-MARKER = "the WAL fsyncs before acknowledging the commit"
-
-# What the fake model answers, round by round.
-SCRIPT = [
- {"role": "assistant", "content": None, "tool_calls": [
- {"id": "call_1", "type": "function", "function": {
- "name": "read_file",
- "arguments": json.dumps({"path": "notes.txt"})}}]},
- {"role": "assistant", "content": None, "tool_calls": [
- {"id": "call_2", "type": "function", "function": {
- "name": "search",
- "arguments": '{"pattern": '}}]}, # deliberately broken JSON
- {"role": "assistant",
- "content": f"FINAL: per notes.txt, {MARKER}."},
-]
-
-REQUESTS: list[dict] = []
-
-
-class MockModel(BaseHTTPRequestHandler):
- def do_POST(self):
- REQUESTS.append(json.loads(self.rfile.read(int(self.headers["Content-Length"]))))
- body = json.dumps({"choices": [{"message": SCRIPT[len(REQUESTS) - 1]}]}).encode()
- self.send_response(200)
- self.send_header("Content-Type", "application/json")
- self.send_header("Content-Length", str(len(body)))
- self.end_headers()
- self.wfile.write(body)
-
- def log_message(self, format, *args):
- pass
-
-
-def main() -> int:
- server = ThreadingHTTPServer(("127.0.0.1", 0), MockModel)
- threading.Thread(target=server.serve_forever, daemon=True).start()
-
- workdir = tempfile.mkdtemp(prefix="agent-loop-test-")
- with open(os.path.join(workdir, "notes.txt"), "w") as f:
- f.write(f"Durability rule: {MARKER}.\n")
-
- # agent.py reads its configuration at import time — set env first.
- os.environ["LLM_URL"] = f"http://127.0.0.1:{server.server_address[1]}/v1"
- os.environ["LLM_MODEL"] = "mock"
- os.environ["AGENT_ROOT"] = workdir
- sys.path.insert(0, HERE)
- import agent
-
- answer = agent.run_turn([
- {"role": "system", "content": "test"},
- {"role": "user", "content": "what is the durability rule?"},
- ])
- server.shutdown()
-
- def tool_results(request: dict) -> list[str]:
- return [m["content"] for m in request["messages"] if m.get("role") == "tool"]
-
- assert len(REQUESTS) == 3, f"expected 3 model rounds, got {len(REQUESTS)}"
-
- # Round 2's request must carry the real file content back as a tool result.
- round2 = tool_results(REQUESTS[1])
- assert any(MARKER in r for r in round2), f"file content not fed back: {round2}"
- print("ok — tool call executed, result appended to the transcript")
-
- # Round 3's request must carry the JSON error as a result, not a crash.
- round3 = tool_results(REQUESTS[2])
- assert any("[agent]" in r and "JSON" in r for r in round3), \
- f"broken arguments not reported back: {round3}"
- print("ok — malformed tool arguments came back as an error result")
-
- assert answer.startswith("FINAL:") and MARKER in answer, f"unexpected answer: {answer}"
- print("ok — loop terminated on the tool-call-free answer")
-
- print(f"\nOK — the agent loop round-trips tools, self-corrects, and stops.\n"
- f"Now run it against a real model: python3 {os.path.join(HERE, 'agent.py')}")
- return 0
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/docs/examples/blog/README.md b/docs/examples/blog/README.md
deleted file mode 100644
index 9a6814e..0000000
--- a/docs/examples/blog/README.md
+++ /dev/null
@@ -1,204 +0,0 @@
-# `blog` — a sample writeonce app
-
-A complete blogging website in **~200 lines of `.wo`** that creates a database, exposes REST + live-subscription endpoints, renders HTML pages, enforces row-level policies, and emits typed client SDKs.
-
-> This project is a **docs artifact** — it illustrates the shape of a real `wo init`'d project. The `wo` toolchain referenced here is the one specified in [`../../runtime/wo-language.md`](../../runtime/wo-language.md); the engine is at prototype stage in [`../../../prototypes/wo-db/`](../../../prototypes/wo-db/).
-
-## What it does
-
-| Thing | How |
-| --- | --- |
-| Persists articles, authors, tags, comments | `type` declarations compiled to relational rows + embedded documents + graph edges |
-| Serves 24 REST endpoints (CRUD + subscribe × 4 types) | `service rest` blocks on each type |
-| Serves 4 web pages (list, detail, tag, admin) | `##ui` screens + route table in `app.wo` |
-| Pushes live updates on every commit | `live: true` on screens + `LIVE` queries under the hood |
-| Enforces "drafts hidden from anonymous readers" | `policy read anyone when published == true` |
-| Bumps `published_at` automatically | `on update` trigger inside the transaction |
-| Generates a typed Go client | `wo gen sdk --lang go` |
-
-## Project layout
-
-```
-blog/
-├── wo.toml # project manifest (like go.mod)
-├── app.wo # routes, theme, startup hooks
-├── types/
-│ ├── author.wo # Author type + per-type service/policy
-│ ├── article.wo # Article — all three paradigms in one type
-│ ├── tag.wo # Tag taxonomy
-│ └── comment.wo # Reader comments
-├── styles/
-│ ├── main.css # global stylesheet (linked from app.wo styles:)
-│ └── code-theme.css # syntax highlighting tokens
-├── ui/
-│ ├── article_list.wo # home page list view (live)
-│ ├── article_detail.wo # per-article page with comments + related
-│ └── components/ # reusable components (.wo + .htmlx + .css per component)
-│ ├── article-card.wo # selector + typed inputs + styles:
-│ ├── article-card.htmlx
-│ ├── article-card.css
-│ ├── comments.wo # selector + source + actions + role + styles:
-│ ├── comments.htmlx
-│ └── comments.css
-└── tests/
- └── article_test.wo # `wo test` picks this up
-```
-
-No `main.wo` is needed — a pure type+service app auto-generates its entry point. Add `main.wo` if you need CLI args, background workers, or custom startup logic beyond the `on startup` hook in `app.wo`.
-
-### UI: components vs. screens
-
-The `ui/` tree separates concerns the way Angular separates `@Component` / template / parent:
-
-- **Screens** (`ui/article_list.wo`, `ui/article_detail.wo`) declare a `##ui` block — they own the route, the page-level data source, and the section layout. They embed components by selector via `use: ` + `with: { ... }` and pass typed inputs.
-- **Components** (`ui/components/*.wo`) declare a `##component` block with `template: 'foo.htmlx'`, typed `inputs:`, and — when the component owns its own query — its `source:`, `sort:`, `live:`, and `actions:`. No HTML.
-- **Templates** (`ui/components/*.htmlx`) are pure presentation. They read from the component's `inputs` and from the rows produced by its `source`. No data-source declarations, no role checks.
-
-Screens never inline a component's HTML or its query; templates never declare data sources. Each `.wo` paired with one `.htmlx` is the unit of UI reuse.
-
-### Styling
-
-CSS is declared at two scopes; in both cases the compiler emits the `` tags into the SSR layout and serves the files under `/static/`:
-
-- **App-level (global)** — `##app styles: [...]` in `app.wo` lists global stylesheets. Resolved relative to `./styles/`. Linked once, in declaration order, on every page.
-- **Component-scoped** — `##component styles: [...]` lists CSS files alongside the component. The compiler rewrites bare selectors in those files to `[data-component=""] `, using the `data-component` attribute the templates already emit. Rules cannot leak outside the component subtree, so two components can both declare `.title` without colliding.
-
-A component's CSS is only fetched on pages that embed the component. Global styles always load. Neither layer requires a build step — `wo run` serves the files as-is.
-
-## Run it
-
-```bash
-$ cd docs/examples/blog
-$ wo run
-[wo] parsing: 7 files, 4 types, 2 ui screens
-[wo] compiling schema: 4 sql tables, 1 doc collection, 3 graph edge types
-[wo] starting runtime (engine: in-memory, data_dir: ./data)
-[wo] on startup: seed_admin() — inserted admin@example.com
-[wo] HTTP listening on :8080
-
- GET /api/articles list
- GET /api/articles/:id get
- POST /api/articles create
- PATCH /api/articles/:id update
- DELETE /api/articles/:id delete
- WS /api/articles/live subscribe
- GET /api/authors list
- GET /api/authors/me me
- WS /api/authors/live subscribe
- GET /api/tags list
- GET /api/comments list
- POST /api/comments create
- WS /api/comments/live subscribe
- ... (and the rest)
-
- GET / ui.article-list
- GET /article/:slug ui.article-detail
- GET /tag/:slug ui.article-list (filtered)
- GET /admin ui.article-list (role: Admin)
-```
-
-## Exercise the REST API
-
-```bash
-# Create an author (requires admin session — see auth docs; stub'd here for brevity)
-$ curl -X POST localhost:8080/api/authors \
- -H "Content-Type: application/json" \
- -H "Authorization: Bearer $ADMIN_TOKEN" \
- -d '{"email":"alice@example.com","handle":"alice","display":"Alice","role":"Author"}'
-{"id":2,"email":"alice@example.com","handle":"alice",...}
-
-# Create an article as that author
-$ curl -X POST localhost:8080/api/articles \
- -H "Authorization: Bearer $ALICE_TOKEN" \
- -d '{
- "slug": "hello",
- "title": "Hello, writeonce",
- "author": 2,
- "meta": {"excerpt":"First post","body_md":"# Hi\n\nHello."},
- "published": true
- }'
-{"id":1,"slug":"hello","title":"Hello, writeonce","published_at":"2026-04-17T12:00:00Z",...}
-
-# List published articles (public — no token)
-$ curl localhost:8080/api/articles
-[{"id":1,"slug":"hello","title":"Hello, writeonce",...}]
-
-# Filter by tag (via the query layer)
-$ curl 'localhost:8080/api/articles?tags.slug=rust'
-[...]
-```
-
-## Subscribe to live updates
-
-```bash
-$ websocat ws://localhost:8080/api/articles/live?published=eq.true
-{"kind":"snapshot","rows":[{"id":1,"slug":"hello",...}]}
-
-# Now in another terminal, update article 1. The open socket receives:
-{"kind":"update","id":1,"old":{"title":"Hello, writeonce"},"new":{"title":"Hello!"}}
-```
-
-No polling. The subscription predicate was registered at connect time; the engine's commit path emits the delta directly.
-
-## Generate a Go client
-
-```bash
-$ wo gen sdk --lang go --out ./client
-[wo] reading types from ./types/
-[wo] writing ./client/sdk.go (4 types, 16 endpoints, 4 subscriptions)
-```
-
-Use it:
-
-```go
-import "github.com/you/blog/client"
-
-c, _ := client.Connect(ctx, "wo://localhost:8080", client.WithToken(token))
-
-// Typed query
-articles, _ := c.Articles.List(ctx, client.Where{Published: ptr(true)})
-
-// Typed subscription — deltas arrive on a channel
-sub, _ := c.Articles.Subscribe(ctx, client.Where{Published: ptr(true)})
-for d := range sub.C {
- switch d.Kind {
- case client.Insert:
- fmt.Printf("new article: %s\n", d.Row.Title)
- case client.Update:
- fmt.Printf("updated: %s\n", d.Row.Slug)
- }
-}
-```
-
-## Run the tests
-
-```bash
-$ wo test
-=== tests/article_test.wo ===
- create and fetch by slug OK (3ms)
- policy blocks public read of unpublished drafts OK (4ms)
- graph traversal: related articles OK (7ms)
- live subscription receives delta on commit OK (12ms)
-
-PASS 4/4 tests, 0 failures (26ms)
-```
-
-Each `test` block runs against an isolated engine snapshot that's rolled back at the end — no setup/teardown code needed.
-
-## Build a production binary
-
-```bash
-$ wo build --target linux-amd64 --out bin/blog
-[wo] static binary: bin/blog (14 MB, database + HTTP + subscription engine embedded)
-$ ./bin/blog
-[wo] HTTP listening on :8080
-```
-
-One binary, no dependencies. Copy it to a server, run it, done. The database file lives in `./data/` relative to the binary; the WAL ensures crash safety ([Phase 3](../../runtime/database/03-inmemory-engine.md)).
-
-## What to read next
-
-- [`../../runtime/wo-language.md`](../../runtime/wo-language.md) — the user-facing language overview this project builds on
-- [`../../runtime/database/02-wo-language.md`](../../runtime/database/02-wo-language.md) — the two-layer language spec (schema + query layers)
-- [`../../runtime/database/06-lowcode-fullstack.md`](../../runtime/database/06-lowcode-fullstack.md) — the `##ui`/`##policy`/`##service`/`##app` block spec
-- [`../../../prototypes/wo-db/`](../../../prototypes/wo-db/) — the C++ prototype that runs the query-layer subset today
diff --git a/docs/examples/blog/api.rest b/docs/examples/blog/api.rest
deleted file mode 100644
index 2ffcc48..0000000
--- a/docs/examples/blog/api.rest
+++ /dev/null
@@ -1,173 +0,0 @@
-###############################################################################
-# blog/api.rest — exercise the `.wo` runtime against this directory.
-#
-# Start the server first (from repo root):
-# cargo run --bin wo -- run docs/examples/blog
-#
-# Then in VS Code (REST Client extension) or JetBrains (HTTP Client) click
-# "Send Request" on each block top to bottom. `# @name foo` lets later blocks
-# pick up ids minted by earlier ones.
-#
-# A more annotated, side-by-side cousin of this file (with the same requests
-# but heavier commentary on Stage-3+ stubs) lives at reference/rest/blog.rest.
-###############################################################################
-
-@host = http://127.0.0.1:8080
-
-
-### Runtime info — 200
-GET {{host}}/
-
-### Liveness probe — 200 "ok"
-GET {{host}}/healthz
-
-
-###############################################################################
-# Author — exposes: list, get, me, subscribe (no create)
-###############################################################################
-
-### List authors — 200 [] on a fresh boot (Stage 2 has no startup seeding)
-GET {{host}}/api/authors
-
-### Author create not exposed — 405
-POST {{host}}/api/authors
-Content-Type: application/json
-
-{ "email": "alice@example.com", "handle": "alice", "display": "Alice" }
-
-### /me — Stage 3 session layer; 501
-GET {{host}}/api/authors/me
-
-### LIVE subscribe — Stage 3; 501
-GET {{host}}/api/authors/live
-
-
-###############################################################################
-# Article — exposes: list, get, create, update, delete, subscribe
-###############################################################################
-
-### Create an article — 201
-# @name createArticle
-POST {{host}}/api/articles
-Content-Type: application/json
-
-{
- "slug": "hello-writeonce",
- "title": "Hello, writeonce",
- "author": 1,
- "published": true,
- "meta": {
- "excerpt": "First post on the new runtime.",
- "body_md": "# Hi\n\nHello from the `.wo` runtime. The server, the database, and this HTTP API are all one binary.\n"
- }
-}
-
-### Create a draft — 201
-# @name createDraft
-POST {{host}}/api/articles
-Content-Type: application/json
-
-{
- "slug": "second-draft",
- "title": "Second Post (draft)",
- "author": 1,
- "published": false,
- "meta": { "excerpt": "", "body_md": "WIP." }
-}
-
-### List articles — 200 with 2 rows
-GET {{host}}/api/articles
-
-### Get one article — 200
-GET {{host}}/api/articles/{{createArticle.response.body.id}}
-
-### PATCH the title — 200
-PATCH {{host}}/api/articles/{{createArticle.response.body.id}}
-Content-Type: application/json
-
-{ "title": "Hi, writeonce!" }
-
-### PATCH an embedded-doc field (Stage 2 = shallow merge — re-send the whole `meta`)
-PATCH {{host}}/api/articles/{{createArticle.response.body.id}}
-Content-Type: application/json
-
-{
- "meta": { "excerpt": "Updated excerpt.", "body_md": "# Hi\n\nUpdated body." }
-}
-
-### Publish the draft — 200 (`on update` trigger that sets published_at is Stage 3+)
-PATCH {{host}}/api/articles/{{createDraft.response.body.id}}
-Content-Type: application/json
-
-{ "published": true }
-
-### Delete the draft — 204
-DELETE {{host}}/api/articles/{{createDraft.response.body.id}}
-
-### Re-fetch the deleted id — 404
-GET {{host}}/api/articles/{{createDraft.response.body.id}}
-
-### LIVE subscribe — Stage 3; 501
-GET {{host}}/api/articles/live
-
-
-###############################################################################
-# Tag — exposes: list, get, subscribe
-###############################################################################
-
-### List tags — 200 []
-GET {{host}}/api/tags
-
-### Tag create not exposed — 405
-POST {{host}}/api/tags
-Content-Type: application/json
-
-{ "slug": "rust", "label": "Rust" }
-
-### LIVE subscribe — Stage 3; 501
-GET {{host}}/api/tags/live
-
-
-###############################################################################
-# Comment — exposes: list, get, create, update, delete, subscribe
-###############################################################################
-
-### Create a comment — 201
-# @name createComment
-POST {{host}}/api/comments
-Content-Type: application/json
-
-{
- "article": {{createArticle.response.body.id}},
- "author": 1,
- "body": "Nice post. Runs on one binary which is still weird to me."
-}
-
-### List comments — 200 with 1 row
-GET {{host}}/api/comments
-
-### Get one comment — 200
-GET {{host}}/api/comments/{{createComment.response.body.id}}
-
-### Update body — 200 (the `set self.edited_at = now()` trigger lands in Stage 3+)
-PATCH {{host}}/api/comments/{{createComment.response.body.id}}
-Content-Type: application/json
-
-{ "body": "Edited: really, one binary? Neat." }
-
-### Delete the comment — 204
-DELETE {{host}}/api/comments/{{createComment.response.body.id}}
-
-### LIVE subscribe — Stage 3; 501
-GET {{host}}/api/comments/live
-
-
-###############################################################################
-# Final state — one updated article, no drafts, no comments.
-###############################################################################
-
-### Final article list — 200 with 1 row
-GET {{host}}/api/articles
-
-### Final comment list — 200 []
-GET {{host}}/api/comments
diff --git a/docs/examples/blog/app.wo b/docs/examples/blog/app.wo
deleted file mode 100644
index cde95e2..0000000
--- a/docs/examples/blog/app.wo
+++ /dev/null
@@ -1,48 +0,0 @@
--- Root manifest. Names the app, maps URL paths to UI screens, and configures
--- project-wide concerns (theme, i18n). The compiler uses this to assemble the
--- static route table and the SSR renderer.
-
-##app
-name: "blog"
-version: 1
-theme: "light"
-i18n: [en]
-
--- Global stylesheets. Resolved relative to ./styles/, served under
--- /static/styles/, and emitted as tags in the SSR layout in this
--- order. Component-scoped CSS lives next to the component (see ui/components).
-styles:
- - styles/main.css
- - styles/code-theme.css
-
--- URL → UI screen binding. `:slug` is a dynamic path segment that binds to a
--- parameter visible inside the screen as `$slug`.
-routes:
- / -> ui.article-list
- /article/:slug -> ui.article-detail { key: $slug }
- /tag/:slug -> ui.article-list { filter: { tags.slug == $slug } }
- /admin -> ui.article-list { role: Admin }
-
--- Project-wide policies — applied on every query, AND-ed with any type-level
--- policy. Useful for ops-level toggles like admin bypass.
-policy admin-bypass
- applies_to: Article, Comment, Author, Tag
- when: $session.role == Admin
- effect: skip-row-filters
-
--- Lifecycle hooks. `on startup` runs once before the HTTP server binds;
--- convenient for idempotent seeding.
-on startup
- do: seed_admin()
-
--- Inline function — available to triggers and lifecycle hooks.
-fn seed_admin() {
- if count(Author{ role == Admin }) == 0 {
- insert Author {
- email: "admin@example.com",
- handle: "admin",
- display: "Admin",
- role: Admin
- };
- }
-}
diff --git a/docs/examples/blog/tests/article_test.wo b/docs/examples/blog/tests/article_test.wo
deleted file mode 100644
index d3f2b35..0000000
--- a/docs/examples/blog/tests/article_test.wo
+++ /dev/null
@@ -1,83 +0,0 @@
--- Tests live under tests/ and are picked up by `wo test`.
--- Each `test` block runs in an isolated database sandbox that's rolled back
--- at the end of the block — no cleanup code needed.
-
-test "create and fetch by slug" {
- let a = insert Author {
- email: "alice@example.com",
- handle: "alice",
- display: "Alice",
- role: Author
- };
-
- let art = insert Article {
- slug: "hello",
- title: "Hello, writeonce",
- author: a,
- meta: {
- excerpt: "A first post.",
- body_md: "# Hello\n\nThis is writeonce."
- },
- published: true
- };
-
- let fetched = select Article{ slug == "hello" };
- assert fetched.id == art.id;
- assert fetched.title == "Hello, writeonce";
- assert fetched.published_at != null; -- set by the on-update trigger
- assert fetched.word_count > 0; -- computed field
-}
-
-test "policy blocks public read of unpublished drafts" {
- let a = insert Author { email: "bob@example.com", handle: "bob", display: "Bob", role: Author };
- insert Article {
- slug: "draft", title: "Draft", author: a,
- meta: { excerpt: "", body_md: "" },
- published: false
- };
-
- -- Anonymous session: the `anyone when published == true` policy applies.
- as session anonymous {
- let rows = select Article{ slug == "draft" };
- assert len(rows) == 0;
- }
-
- -- As the author, the row is visible.
- as session $a {
- let rows = select Article{ slug == "draft" };
- assert len(rows) == 1;
- }
-}
-
-test "graph traversal: related articles" {
- let a = insert Author { email: "c@x", handle: "carol", display: "Carol", role: Author };
-
- let a1 = insert Article { slug: "one", title: "One", author: a, meta: { excerpt: "", body_md: "" }, published: true };
- let a2 = insert Article { slug: "two", title: "Two", author: a, meta: { excerpt: "", body_md: "" }, published: true };
- let a3 = insert Article { slug: "three", title: "Three", author: a, meta: { excerpt: "", body_md: "" }, published: true };
-
- -- Create :RELATED_TO edges: one → two, one → three
- link Article{ slug == "one" } -[:RELATED_TO]-> Article{ slug == "two" };
- link Article{ slug == "one" } -[:RELATED_TO]-> Article{ slug == "three" };
-
- let related = Article{ slug == "one" }.related;
- assert len(related) == 2;
- assert contains(related, slug == "two");
- assert contains(related, slug == "three");
-}
-
-test "live subscription receives delta on commit" {
- let sub = subscribe live Article{ published == true };
-
- let a = insert Author { email: "d@x", handle: "dave", display: "Dave", role: Author };
- insert Article {
- slug: "live-test", title: "Live", author: a,
- meta: { excerpt: "", body_md: "" },
- published: true
- };
-
- -- receive(sub) blocks until the next delta or the test-default timeout.
- let delta = receive(sub);
- assert delta.kind == Insert;
- assert delta.row.slug == "live-test";
-}
diff --git a/docs/examples/blog/types/article.wo b/docs/examples/blog/types/article.wo
deleted file mode 100644
index 2ee941a..0000000
--- a/docs/examples/blog/types/article.wo
+++ /dev/null
@@ -1,68 +0,0 @@
--- The heart of the app. Article combines all three paradigms in one type:
--- * relational scalars (slug, title, published, published_at)
--- * an embedded document (meta.body_md, meta.excerpt, meta.hero_image)
--- * graph edges (tags, related, prerequisites)
--- The compiler picks storage per field: scalars → relational row, meta → doc,
--- multi/ref → graph edges + FK columns.
-
-type Article {
- id: Id
- slug: Slug @unique -- URL path component
- title: Text
- author: ref Author -- foreign key into Author
- published: Bool = false
- published_at: Timestamp? -- set by the trigger below
- created_at: Timestamp = now()
- updated_at: Timestamp = now()
-
- -- Embedded document. All fields stored together; no join on read.
- meta: {
- excerpt: Text
- body_md: Markdown
- hero_image: Url?
- reading_min: Int? -- computed by a pre-commit hook
- }
-
- -- Tags: many-to-many graph edge with a label, no edge properties.
- tags: multi Tag @edge(:TAGGED_AS)
-
- -- "You might also like" — directed graph edge between articles.
- related: multi Article @edge(:RELATED_TO)
-
- -- Prerequisite reading, also a directed edge; distinct label so queries
- -- can traverse tags vs prereqs independently.
- prerequisites: multi Article @edge(:PREREQUISITE)
-
- -- Inverse of Comment.article. Read-only from this side.
- comments: backlink Comment.article
-
- -- Computed: word count, derived from the markdown body.
- word_count: Int = words(meta.body_md)
-
- -- Policies — row-level access rules. The planner AND-s them into every
- -- query so they can't be bypassed by a poorly-scoped handler.
- policy read anyone when published == true
- policy read for role Admin
- policy read for role Author when author == $session.user
- policy write for role Admin
- policy write for role Author when author == $session.user
- policy delete for role Admin
-
- -- Pre-commit trigger: set published_at when the article is first published.
- -- Fires inside the transaction, so the set is atomic with the update.
- on update
- when old.published == false and new.published == true
- do set self.published_at = now()
- do emit "article.published"(self)
- do enqueue "send-subscriber-emails" with { article_id: self.id }
-
- -- Bump updated_at on every mutation — except the insert, where created_at
- -- already covers it.
- on update
- do set self.updated_at = now()
-
- -- REST surface. `subscribe` is the LIVE query endpoint — WebSocket that
- -- pushes a delta on every commit matching the predicate.
- service rest "/api/articles"
- expose list, get, create, update, delete, subscribe
-}
diff --git a/docs/examples/blog/types/author.wo b/docs/examples/blog/types/author.wo
deleted file mode 100644
index 28f4a10..0000000
--- a/docs/examples/blog/types/author.wo
+++ /dev/null
@@ -1,26 +0,0 @@
--- Authors write articles. Keeping the type small: one author is one writer
--- with a session-bindable identity (role == "author" or "admin").
-
-type Author {
- id: Id
- email: Email @unique -- primary identity; uniqueness is planner-enforced
- handle: Slug @unique -- URL-friendly (e.g. "alice")
- display: Text
- bio: Markdown? -- optional
- avatar: Url?
- joined_at: Timestamp = now()
- role: Reader | Author | Admin = Reader -- tagged union, stored as enum
-
- -- Inverse link: computed from Article.author. No storage column; resolved at query time.
- articles: backlink Article.author
-
- -- Policies are expressed next to the type; the planner AND-s them into every query.
- policy read anyone
- policy write for role Admin
- policy write for role Author when self == $session.user -- authors can edit their own profile
-
- -- Expose a small REST surface. `me` is a virtual endpoint the runtime wires
- -- to the current session user.
- service rest "/api/authors"
- expose list, get, me, subscribe
-}
diff --git a/docs/examples/blog/types/comment.wo b/docs/examples/blog/types/comment.wo
deleted file mode 100644
index a51466a..0000000
--- a/docs/examples/blog/types/comment.wo
+++ /dev/null
@@ -1,31 +0,0 @@
--- Reader comments on an article. Kept small for the sample, but demonstrates
--- cascading policies (a reader can only edit their own comment) and two-way
--- live subscription (both articles and comments push deltas).
-
-type Comment {
- id: Id
- article: ref Article
- author: ref Author
- body: Markdown
- created_at: Timestamp = now()
- edited_at: Timestamp?
-
- -- Anyone can read a comment on a visible article. The engine threads the
- -- Article.read policy through this relationship automatically because of
- -- the `ref Article` above.
- policy read anyone
-
- -- Writing is by role; the owner-check is the row-level rule.
- policy write for role Author when author == $session.user
- policy write for role Admin
- policy delete for role Author when author == $session.user
- policy delete for role Admin
-
- -- Stamp edited_at whenever the body changes post-insert.
- on update
- when old.body != new.body
- do set self.edited_at = now()
-
- service rest "/api/comments"
- expose list, get, create, update, delete, subscribe
-}
diff --git a/docs/examples/blog/types/tag.wo b/docs/examples/blog/types/tag.wo
deleted file mode 100644
index d0a8922..0000000
--- a/docs/examples/blog/types/tag.wo
+++ /dev/null
@@ -1,20 +0,0 @@
--- Tags are a small taxonomy. A separate type (rather than a string array on
--- Article) because we want to query by tag cheaply AND attach per-tag metadata
--- (colour, description) later without a migration that rewrites articles.
-
-type Tag {
- id: Id
- slug: Slug @unique
- label: Text
- description: Markdown?
- colour: Text = "#888"
-
- -- Computed field: count of articles with this tag.
- -- Re-evaluated on read; if it gets hot, flip to `@materialized` to cache.
- article_count: Int = count(Article{ tags contains self })
-
- policy read anyone
- policy write for role Admin
-
- service rest "/api/tags" expose list, get, subscribe
-}
diff --git a/docs/examples/blog/ui/article_detail.wo b/docs/examples/blog/ui/article_detail.wo
deleted file mode 100644
index 62731f4..0000000
--- a/docs/examples/blog/ui/article_detail.wo
+++ /dev/null
@@ -1,33 +0,0 @@
--- Detail page: one article, its body, its comments, and a "related" section
--- that traverses the :RELATED_TO graph edge. Every section can be live-bound.
-
-##ui
-#article-detail
- title: $article.title
- source: Article
- key: slug
- live: true
-
- sections:
- - header:
- fields: [title, author.display, published_at, tags]
-
- - body:
- renderer: markdown
- source: meta.body_md
-
- -- Graph traversal: one hop along :RELATED_TO, rendered inline.
- - related:
- title: "You might also like"
- renderer: list
- source: Article{ slug == $key }.related
- columns: [title, meta.excerpt, published_at]
- live: true
-
- -- Reader comments. The article-comments component owns its own data
- -- source, sort, live binding, and create action — this screen only
- -- declares the embed and binds its typed inputs.
- - comments:
- use: article-comments
- with:
- article-id: $article.id
diff --git a/docs/examples/blog/ui/article_list.wo b/docs/examples/blog/ui/article_list.wo
deleted file mode 100644
index a5c314f..0000000
--- a/docs/examples/blog/ui/article_list.wo
+++ /dev/null
@@ -1,28 +0,0 @@
--- Home page: list of published articles, newest first. The `live: true` flag
--- makes the runtime auto-register a LIVE query matching the displayed columns;
--- the rendered HTML ships with a small client that binds deltas to the DOM.
-
-##ui
-#article-list
- title: "Articles"
- source: Article
- live: true
-
- filter:
- published == true
-
- columns:
- - slug label: "Slug" renderer: code
- - title label: "Title" searchable
- - author.display label: "Author"
- - published_at label: "Published" renderer: relative-date
- - tags label: "Tags" renderer: tag-chips
-
- sort:
- default: published_at desc
-
- actions:
- row-click: /article/:slug
- create: /article/new role: Author | Admin
-
- pagination: 20
diff --git a/docs/examples/blog/ui/components/article-card.css b/docs/examples/blog/ui/components/article-card.css
deleted file mode 100644
index a0ccd8b..0000000
--- a/docs/examples/blog/ui/components/article-card.css
+++ /dev/null
@@ -1,15 +0,0 @@
-/* Scoped to the article-card component via [data-component="article-card"]. */
-
-.article-card { padding: 1.25rem 0; border-bottom: 1px solid #eee; }
-.article-card-title { margin: 0 0 0.25rem; font-size: 1.2rem; }
-.article-card-title a { text-decoration: none; color: #222; }
-.article-card-title a:hover { color: #0066cc; }
-
-.article-card-meta { color: #999; font-size: 0.85rem; margin-bottom: 0.25rem; }
-.article-card-author { color: #555; }
-
-.article-card-excerpt { color: #555; margin-bottom: 0.5rem; }
-
-.article-card-tags { list-style: none; padding: 0; display: flex; gap: 0.5rem; font-size: 0.8rem; }
-.article-card-tags a { color: #888; text-decoration: none; }
-.article-card-tags a:hover { color: #0066cc; }
diff --git a/docs/examples/blog/ui/components/article-card.htmlx b/docs/examples/blog/ui/components/article-card.htmlx
deleted file mode 100644
index 8f0f6af..0000000
--- a/docs/examples/blog/ui/components/article-card.htmlx
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
{{comment.body}}
-