docs: remove stale old-runtime docs; abandon the ##ui frontend track

Analyzed the full 131-file docs tree (4 parallel classifiers) against the
shipped woc/wovm toolchain. Removed 21 stale docs, kept all intentional
history (Rust-track plans/done, the runtime/database design series cited by
current specs, syscall/postgres/assembly/c-runtime studies, discarded/
learnings). Deleted:

- old-runtime "front door": writeonce-pl.md, runtime/wo-language.md
  (pitched the Rust wo runtime -- REST/LiveView/SQL+Cypher -- as the current
  language; contradicted the new README)
- v1 design set: 02-recovery, 03-data, 04-ui, 05-datalayer,
  06-markdown-render, 07-ssl; runtime/database/05-go-sdk
- future-scope/ai-agents-content-management (unfinished old-runtime CMS)
- the ##ui/.htmlx LiveView frontend track (product decision to abandon):
  9 plan/exploration/ui/*, plan/14-mvc-ui-implementation,
  superpowers/plans/2026-08-01-ui-htmlx-live; 13d pricing-UI board row

Tree left link-clean: 46 dead links to the removed docs neutralized to plain
text or deleted as pure see-also bullets across 20 kept docs; whole-tree
link-resolving scan reports zero links to any deleted file. Removal recorded
in discarded.md; board Frontend section + project-structure tree updated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
shoney.arickathil 2026-08-17 20:06:36 +02:00
parent db45bfb4da
commit 531b0283c6
41 changed files with 45 additions and 3458 deletions

View file

@ -374,13 +374,14 @@ log-watcher proof.
| ⬜ | 15a–15e MCP over streamable HTTP | [15](plan/15-mcp-streamable-http.md) | 15e needs 13c + 09d | | ⬜ | 15a–15e MCP over streamable HTTP | [15](plan/15-mcp-streamable-http.md) | 15e needs 13c + 09d |
| ⬜ | 16c–16f typed columns, lossless resync, restore, SCRAM | [16](plan/16-postgres-mirror.md) | | | ⬜ | 16c–16f typed columns, lossless resync, restore, SCRAM | [16](plan/16-postgres-mirror.md) | |
### Frontend — parked ### Frontend — removed as stale (2026-08-17)
| Status | Phase | Doc | The `##ui` / `.htmlx` LiveView frontend track — 13d pricing UI, the 14-MVC-UI
| ------ | -------------------------------- | ------------------------------------------------------------------- | implementation plan, the 7-of-7 `ui-htmlx-live` plan, and the 9-doc
| ⏸ | 13d pricing UI | [13](plan/13-class-model-live-pricing.md) | `plan/exploration/ui/` design set — was **removed**. It was built entirely on
| ⏸ | 14 MVC UI implementation (14a–f) | [14](plan/14-mvc-ui-implementation.md) | the non-advancing Rust runtime (`.dev/reference/crates/wo-htmlx`, `cargo run`,
| ⏸ | UI exploration track | [exploration/ui/00-overview.md](plan/exploration/ui/00-overview.md) | WebSocket live-patches) and contradicts the current woc/wovm direction. Recorded
in [`discarded.md`](plan/discarded.md).
--- ---

View file

@ -1,6 +1,6 @@
# Problem Statement # Problem Statement
The current writeonce architecture works, but it carries weight that the project doesn't need. This document identifies the structural problems that motivate the redesign described in [02-recovery.md](./02-recovery.md). The current writeonce architecture works, but it carries weight that the project doesn't need. This document identifies the structural problems that motivate the redesign described in 02-recovery.md.
## Too Many Moving Parts ## Too Many Moving Parts

View file

@ -1,172 +0,0 @@
# Recovery — The Target Architecture
This document describes where writeonce is going: a single, self-contained binary that owns its own storage, serves its own content, and pushes updates to connected clients in real-time — with no external database, no cloud pipeline, and no separate API server.
## Guiding Principle
**Everything in one process.** The database, the server logic, and the client-facing interface all live in a single codebase and ship as a single executable. If you can run the binary, you have the full platform.
## Own Database
The current PostgreSQL instance is a derived cache — it stores JSONB copies of files that already exist as the source of truth. The recovery architecture eliminates this indirection entirely.
### What Changes
- **No external database.** No PostgreSQL, no Diesel ORM, no connection pooling, no migrations.
- **Local file storage.** Markdown files and JSON metadata files are stored in a local directory, just as they are today in `writeonce-articles-s3/`. The file system *is* the database.
- **Custom storage segments (.seg files).** Research area: segment files that provide efficient read access, indexing, and potentially append-only writes for content. Think of these as a lightweight, purpose-built storage layer — not a general-purpose database engine, but enough to support indexed lookups by `blog-title` and ordered listing by date.
- **Indexed by blog-title.** The `sys_title` / blog-title field remains the primary key for content retrieval. The embedded storage must support O(1) or O(log n) lookups by this field.
### What Stays the Same
- Articles are still structured as JSON metadata + Markdown content pairs.
- The `sys_title`, `published`, `tags`, `author`, and section structure remain the content model.
- Content is still the source of truth — but now it's read directly from local storage instead of being derived through a sync pipeline.
## No AWS Infrastructure
The current architecture uses S3 as a file host and Lambda as a sync trigger. In the target architecture, there is nothing to sync *to* — the files are already where they need to be.
### What Gets Removed
| Current Component | Why It Existed | Why It's No Longer Needed |
|---|---|---|
| S3 bucket | Remote file storage | Files live locally alongside the binary |
| Lambda function (Go) | Watch S3 for changes, call API | No remote store to watch — file changes are local |
| aws-infra service (Rust) | Bridge to AWS S3/EC2 APIs | No AWS dependency |
| Pulumi IaC | Manage Lambda + S3 resources | No cloud resources to manage |
### What Replaces It
The binary watches its own content directory. When a file changes (new article, updated metadata), the embedded database re-indexes and notifies subscribers. The deployment model becomes:
```
1. Place the binary on a server
2. Point it at a content directory
3. It serves
```
No credentials, no IAM roles, no SDK configuration.
## No Separate API
Today, `writeonce-api` is a standalone Actix-web server that mediates between the frontend and the database. In the target architecture, the server logic is embedded in the same process as the database and the content renderer.
### What This Means
- **No HTTP hop between database and server.** Queries go directly from the request handler to the storage engine in-process. No network serialization, no connection pool, no ORM layer.
- **Single codebase.** No multi-repo coordination. A new article field is added once — in the content model — and it flows through storage, indexing, and rendering in the same compilation unit.
- **Single deployment.** One binary, one container, one process. No docker-compose orchestrating API + database + infra services.
The binary still exposes HTTP endpoints — it's still a web server. But it's a web server with an embedded database, not a web server that talks to an external one.
## Real-Time Subscriptions Without WebSocket
The current architecture has no mechanism for pushing content updates to connected clients. The target architecture adds real-time subscriptions, but explicitly without WebSocket.
### Why Not WebSocket
WebSocket adds connection state management, heartbeat logic, reconnection handling, and protocol upgrade complexity. For a content platform where updates are infrequent (articles are published, not streamed), the overhead isn't justified.
### Subscription Model
The target is a subscription mechanism where:
- A client subscribes to a content query (e.g., "all published articles" or "article with sys_title X")
- When the underlying data changes, the server pushes the relevant diff to the subscriber
- No polling from the client side
Candidate approaches to research:
- **Server-Sent Events (SSE)** — unidirectional push over HTTP. Simple, well-supported, no protocol upgrade. Natural fit for infrequent content updates.
- **SpacetimeDB-style subscriptions** — clients register queries, the engine tracks which rows match, and only sends diffs when the result set changes. This is the aspirational model.
- **Long polling** — fallback option. Simple but less efficient than SSE for multiple subscribers.
The key constraint: the subscription mechanism must work without requiring clients to maintain persistent bidirectional connections.
## Target Architecture
```
content directory
(JSON + MD files, .seg index)
|
| file watch + re-index
v
+---------------------------+
| writeonce binary |
| |
| +-------------------+ |
| | embedded storage | | .seg files, blog-title index
| | (read/write/index)| |
| +-------------------+ |
| | |
| +-------------------+ |
| | server logic | | route handlers, content queries
| | (HTTP endpoints) | |
| +-------------------+ |
| | |
| +-------------------+ |
| | subscription mgr | | SSE / query-based push
| | (real-time push) | |
| +-------------------+ |
| |
+---------------------------+
|
HTTP / SSE
|
v
+-------------------+
| frontend app | Angular or successor
| (browser client) |
+-------------------+
```
## Single Repository
The five current repos collapse into one:
```
writeonce/
content/ # articles (JSON + MD), images, assets
storage/ # embedded database engine (.seg files, indexing)
server/ # HTTP handlers, subscription manager
frontend/ # client application
writeonce.toml # configuration (port, content dir, index settings)
```
One repo. One build. One deploy artifact.
## What Needs Research
| Area | Question | Notes |
|------|----------|-------|
| **.seg file format** | What storage format gives efficient indexed reads over JSON+MD content? | Look at LSM trees, append-only logs, SQLite's page format for inspiration |
| **File watching** | How to efficiently detect content changes on Linux/macOS? | `inotify` on Linux, `kqueue` on macOS, or cross-platform via `notify` crate |
| **SSE vs alternatives** | Is SSE sufficient for the subscription model, or is something custom needed? | SSE handles the "push diffs to subscribers" case well for low-frequency updates |
| **Index structure** | What index structure supports `blog-title` lookup + date-ordered listing? | B-tree or hash index for title, sorted set for date ordering |
| **Language choice** | Continue with Rust for the unified binary? | Rust fits: single binary output, no runtime, strong typing, existing team knowledge |
| **Frontend coupling** | Should the frontend be embedded in the binary (serve static assets) or remain separate? | Embedding simplifies deployment; separate allows independent frontend iteration |
## Migration Path
The transition from current to target doesn't have to be all-or-nothing:
1. **Phase 1** — Build the embedded storage engine. Read JSON+MD files from a local directory, index by `blog-title`, serve via HTTP. No AWS, no PostgreSQL. This alone replaces `writeonce-api` + `aws-infra` + `lambda-function` + PostgreSQL.
2. **Phase 2** — Add real-time subscriptions (SSE). Clients subscribe to content queries and receive push updates when files change.
3. **Phase 3** — Collapse repositories. Move frontend into the unified codebase. Ship as a single binary that serves both API and static assets.
Each phase produces a working system. The current architecture can run in parallel until the new one is ready.
## Implementation phases
The "embedded storage engine" of Phase 1 above lands in three numbered plan docs under [`docs/plan/`](./plan/):
| Phase | Doc | What it ships |
| --- | --- | --- |
| 10 | [`plan/10-storage-foundations.md`](./plan/10-storage-foundations.md) | On-disk row codec (length-prefix + flags + LSN + CRC32C); per-type segment files (`data/<TypeName>.seg`); `posix_fallocate` preallocation; `pwrite`-only append path. Reads still in-memory. |
| 11 | [`plan/11-wal-and-recovery.md`](./plan/11-wal-and-recovery.md) | WAL log with `fdatasync` at commit; group commit per loop tick; control file with `last_durable_lsn` (rename-on-write); replay loop on startup. `kill -9` mid-write loses nothing acknowledged. |
| 12 | [`plan/12-engine-disk-cutover.md`](./plan/12-engine-disk-cutover.md) | `Engine`'s row payload moves to disk; in-memory map becomes `BTreeMap<i64, SegmentOffset>`. Periodic checkpoint flushes segments + advances the control file. RAM bounded by id-count, not row size. |
Postgres' storage subsystem is the design reference — see [`docs/plan/exploration/postgresql/`](./plan/exploration/postgresql/) for which Postgres modules informed which decision and what writeonce skips (multi-process IPC, latches, separate writer processes).
The durability syscalls themselves live in [`docs/plan/exploration/linux/12-pwrite-fsync.md`](./plan/exploration/linux/12-pwrite-fsync.md).

View file

@ -1,184 +0,0 @@
# Data Layer — Local Storage with Subscriptions
This document describes the embedded data layer that replaces PostgreSQL: local `.seg` files with indexing, and a subscription model where clients register queries and receive diffs on route visit — no polling required.
## .seg File Storage
The `.seg` (segment) format is the on-disk representation of article data. Each segment file holds serialized article content with positional indexing for fast lookups.
### Design Goals
- **No external database process.** The binary reads and writes `.seg` files directly. No socket connections, no protocol negotiation, no separate daemon.
- **Indexed by blog-title.** The primary access pattern is `GET /blog/:sys_title`. The storage layer must resolve a `sys_title` to its article content without scanning all files.
- **Append-friendly.** New articles and updates append to the segment. Deletes are tombstoned and compacted later.
- **Human-readable source.** The JSON + Markdown files remain the authoring format. `.seg` files are a derived index — if they're deleted, they can be rebuilt from the content directory.
### Proposed Structure
```
content/
linux-misc/
linux-misc.json # authored metadata (source of truth)
linux-misc.md # authored content (source of truth)
aws-lambda-pulumi/
aws-lambda-pulumi.json
aws-lambda-pulumi.md
data/
articles.seg # serialized article records
index/
title.idx # blog-title -> offset mapping
date.idx # publish date -> offset (sorted)
tags.idx # tag -> [offsets] (inverted index)
```
The `content/` directory is what the author edits. The `data/` directory is what the engine builds and queries. Losing `data/` is a cold start, not data loss.
### Segment File Internals
```
+------------------+
| segment header | magic bytes, version, record count
+------------------+
| record 0 | length-prefixed serialized article
+------------------+
| record 1 |
+------------------+
| ... |
+------------------+
| record N |
+------------------+
```
Each record is a length-prefixed byte sequence containing the full article (metadata + content merged). Records are addressed by byte offset from the start of the file.
### Index Files
**title.idx** — Hash map serialized to disk. Maps `sys_title` (string) to byte offset in `articles.seg`. Loaded into memory at startup for O(1) lookups.
**date.idx** — Sorted array of `(timestamp, offset)` pairs. Supports range queries for "articles published between X and Y" and ordered listing for the homepage.
**tags.idx** — Inverted index. Maps each tag string to a list of offsets. Supports "all articles tagged with X" queries.
On startup, index files are memory-mapped or loaded into heap. On content change, affected indexes are rebuilt incrementally.
## Subscription Model
The subscription model is inspired by SpacetimeDB: clients register queries, and the engine tracks which results match. When underlying data changes, only the relevant diffs are pushed to subscribers.
### How It Works
```
Client A Server Content Dir
| | |
|--- GET /blog/linux-misc -| |
| |-- read from .seg index ---->|
|<-- article + SSE stream -| |
| | |
| (subscribed to | |
| sys_title=linux-misc) | |
| | |
| |<-- file change detected ----|
| | |
| |-- re-index article -------->|
| |-- diff against last push -->|
| | |
|<-- SSE: updated content -| |
| | |
```
### Route-Based Subscription
When a user visits a route, the response includes both the current content and an SSE stream. The client is automatically subscribed to changes for that query — no explicit subscription handshake needed.
```
GET /blog/linux-misc
```
Response:
```
HTTP/1.1 200 OK
Content-Type: text/html
<!-- full article content rendered -->
<!-- SSE connection opened for this query -->
<script>
const source = new EventSource('/subscribe/blog/linux-misc');
source.onmessage = (event) => {
// apply diff to current content
};
</script>
```
The subscription lives as long as the browser tab is open. When the user navigates away, the EventSource closes and the server drops the subscription. No heartbeat management, no reconnection logic beyond what SSE provides natively (automatic reconnect is built into the EventSource API).
### Query Registration
Subscriptions are not limited to single-article lookups. The engine supports registering arbitrary content queries:
| Query Type | Example | Subscription Behavior |
|---|---|---|
| Single article | `sys_title = "linux-misc"` | Push when this specific article changes |
| All published | `published = true` | Push when any article is published or unpublished |
| By tag | `tags contains "rust"` | Push when a rust-tagged article is added, removed, or updated |
| Homepage list | `published = true ORDER BY date DESC LIMIT 10` | Push when the top-10 list changes |
The server maintains a registry of active subscriptions. On each content change, it evaluates which subscriptions are affected and pushes diffs only to those clients.
### Diff Format
When content changes, the server doesn't resend the full article. It sends a minimal diff:
```json
{
"type": "update",
"sys_title": "linux-misc",
"changes": {
"content.sections[2].paragraphs[0]": "Updated paragraph text...",
"content.tags": ["linux", "kernel", "new-tag"]
},
"version": 42
}
```
The `version` field enables clients to detect missed updates and request a full resync if needed.
## Sample Dataset
To validate the storage engine and subscription model, a sample dataset should exercise the core access patterns:
### Articles
| sys_title | tags | published | purpose |
|---|---|---|---|
| `sample-getting-started` | `[tutorial, beginner]` | true | Basic article, tests single-article subscription |
| `sample-rust-patterns` | `[rust, patterns]` | true | Tests tag-based queries |
| `sample-draft-wip` | `[draft]` | false | Tests published filter — should not appear in public queries |
| `sample-long-form` | `[deep-dive, rust]` | true | Multiple sections, images, code snippets — tests complex content rendering |
| `sample-frequently-updated` | `[changelog]` | true | Updated often — tests subscription diff delivery |
### Test Scenarios
1. **Cold start** — Delete `data/`, start the binary. It should rebuild `.seg` and index files from `content/` and serve all articles.
2. **Single article query** — `GET /blog/sample-getting-started` returns the article and opens an SSE subscription.
3. **Live update** — Edit `sample-frequently-updated.json` while a client is subscribed. The client should receive an SSE event with the diff.
4. **Tag query** — Subscribe to `tags contains "rust"`. Both `sample-rust-patterns` and `sample-long-form` should be in the result set. Adding a new article tagged `rust` should trigger a push.
5. **Publish toggle** — Change `sample-draft-wip` from `published: false` to `true`. Clients subscribed to the homepage list should receive a push with the new article added.
## SpacetimeDB Reference
SpacetimeDB is the primary architectural inspiration for the subscription model. Key concepts to study:
- **Modules** — server logic that runs inside the database, not beside it
- **Subscription queries** — clients register SQL-like queries; the engine evaluates them incrementally on each transaction
- **Incremental view maintenance** — only recompute the parts of a query result that changed
- **Client SDK generation** — type-safe client code generated from the server schema
Add SpacetimeDB as a reference submodule for quick access to their implementation patterns:
```bash
git submodule add https://github.com/clockworklabs/SpacetimeDB.git references/spacetimedb
```
The goal is not to replicate SpacetimeDB — it's to take its subscription semantics and apply them to a much narrower domain (blog content), where the simplicity of the problem allows a simpler implementation.

View file

@ -1,216 +0,0 @@
# User Interface — Server-Rendered HTMLX
No Angular. No React. No frontend framework. The UI is a set of `.htmlx` template files that the server parses, populates with content from the embedded database, and serves as plain HTML. Real-time updates arrive via SSE and are applied with minimal client-side scripting.
## Why Not Angular
The current `writeonce-app` is an Angular 18 SPA with Tailwind, PrismJS, ngx-markdown, and FontAwesome. It works, but it's a heavy delivery mechanism for what is fundamentally a read-heavy content site:
- **~200MB of `node_modules`** for a site that renders markdown articles
- **Client-side routing** for content that doesn't need it — every article is a distinct URL, not an interactive application
- **JavaScript-dependent rendering** — content doesn't exist until Angular boots, hydrates, and fetches from the API
- **Separate build pipeline** — `npm run build` produces static assets that must be deployed to nginx independently of the API
The content is static between updates. The interactivity is limited to navigation and code highlighting. A server-rendered approach matches the actual requirements.
## HTMLX Templates
The author defines the site layout using `.htmlx` files — HTML with embedded data bindings that the server resolves at render time.
### Template Structure
```
templates/
layout.htmlx # outer shell: <html>, <head>, <body>
header.htmlx # site header, navigation
footer.htmlx # site footer
home.htmlx # homepage: article list
article.htmlx # single article view
about.htmlx # static page
contact.htmlx # static page
components/
article-card.htmlx # summary card for article listings
code-snippet.htmlx # code block with language + title
img-caption.htmlx # image with caption
section.htmlx # article section (heading + paragraphs)
```
### Template Syntax
Templates use a binding syntax that references content from the database. The server parses these bindings, resolves them against the current content, and outputs plain HTML.
```html
<!-- header.htmlx -->
<header>
<nav>
<a href="/">writeonce</a>
<a href="/about">about</a>
<a href="/contact">contact</a>
</nav>
</header>
```
```html
<!-- article.htmlx -->
<article>
<h1>{{article.title}}</h1>
<p class="meta">by {{article.author}} &middot; {{article.tags}}</p>
{{#each article.sections}}
<section>
<h2>{{heading}}</h2>
{{#each paragraphs}}
<p>{{this}}</p>
{{/each}}
</section>
{{/each}}
{{#each article.codes}}
{{> code-snippet snippet=this}}
{{/each}}
{{#each article.images}}
{{> img-caption image=this}}
{{/each}}
</article>
```
```html
<!-- home.htmlx -->
<main>
<h1>articles</h1>
{{#each articles}}
{{> article-card article=this}}
{{/each}}
</main>
```
The `{{> partial}}` syntax includes another `.htmlx` file as a component. The server resolves these at render time — no client-side component tree.
### Content Subscription in Templates
Templates declare what data they need. The server resolves these declarations against the embedded database and subscribes the client to changes:
```html
<!-- article.htmlx -->
<!-- subscribe: article WHERE sys_title = :route_param -->
<article>
<h1>{{article.title}}</h1>
...
</article>
```
```html
<!-- home.htmlx -->
<!-- subscribe: articles WHERE published = true ORDER BY date DESC LIMIT 10 -->
<main>
{{#each articles}}
{{> article-card article=this}}
{{/each}}
</main>
```
The `<!-- subscribe: ... -->` comment is a directive to the server. It declares the query that populates the template's data context. The same query is used to register an SSE subscription for live updates (as described in [03-data.md](./03-data.md)).
## Rendering Pipeline
```
Browser request
|
v
Route match (/blog/linux-misc)
|
v
Load template (article.htmlx)
|
v
Parse subscribe directive
(article WHERE sys_title = "linux-misc")
|
v
Query embedded database (.seg index)
|
v
Resolve template bindings ({{article.title}}, etc.)
|
v
Compose with layout.htmlx + header.htmlx + footer.htmlx
|
v
Inject SSE subscription script
|
v
Send complete HTML response
```
The browser receives a fully rendered page on first load. No JavaScript framework boots. No API call fires. The content is already in the HTML.
## Live Updates via SSE
After the initial HTML is delivered, a small inline script opens an SSE connection for the page's subscription query:
```html
<script>
const source = new EventSource('/subscribe/blog/linux-misc');
source.onmessage = (event) => {
const diff = JSON.parse(event.data);
applyDiff(diff);
};
</script>
```
The `applyDiff` function is a lightweight client-side updater — it targets DOM elements by data attribute and patches their content. No virtual DOM, no reconciliation, no framework. For a content site where updates are infrequent and localized (a paragraph changed, a tag was added), direct DOM manipulation is sufficient.
```html
<h1 data-bind="article.title">Linux Misc</h1>
<p data-bind="article.sections[0].paragraphs[0]">First paragraph...</p>
```
When a diff arrives for `article.title`, the script finds the element with `data-bind="article.title"` and replaces its text content. This is the minimal client-side code the architecture requires.
## Code Highlighting
The current frontend uses PrismJS for syntax highlighting. In the server-rendered model, highlighting can happen at either layer:
**Server-side (preferred):** The server parses code blocks during template rendering and emits pre-highlighted HTML with CSS classes. The browser only needs the PrismJS CSS theme, not the JavaScript library. This eliminates client-side parsing entirely.
**Client-side (fallback):** Include PrismJS as a small script that runs on page load and on SSE update. Simpler to implement initially but adds a JavaScript dependency.
## Markdown Rendering
The current frontend uses `ngx-markdown` and `marked` to parse markdown in the browser. In the target architecture, markdown is rendered to HTML on the server during template composition. The browser never sees raw markdown.
This aligns with the content model: the JSON metadata already defines the article structure (sections, paragraphs, code snippets, images). The markdown file provides prose content. The server combines both into final HTML — the template just places the pre-rendered blocks.
## What Gets Removed
| Current (Angular) | Target (HTMLX) |
|---|---|
| `writeonce-app/` (full Angular project) | `templates/` (handful of .htmlx files) |
| `node_modules/` (~200MB) | None |
| `angular.json`, `tsconfig.json`, `karma.conf.js` | None |
| npm build pipeline | Template parsed at request time |
| Nginx static file serving | Binary serves its own HTML |
| Client-side routing | Server-side route matching |
| Client-side markdown parsing | Server-side rendering |
| Client-side code highlighting | Server-side or minimal JS |
## Styling
Templates use plain CSS. Tailwind can optionally be used as a build-time utility (generating a static CSS file), but there is no runtime CSS framework. The author writes styles in a `styles.css` file that the server serves as a static asset.
```
templates/
styles/
main.css # site-wide styles
article.css # article-specific styles
code-theme.css # syntax highlighting theme (PrismJS compatible)
```
## Template Authoring Experience
The `.htmlx` files are editable by the same author who writes articles. The template syntax is intentionally close to HTML — there's no JSX, no TypeScript, no build step. An author who knows HTML can modify the site layout.
This closes the loop on the writeonce philosophy: the author writes content (markdown + JSON) and layout (`.htmlx` + CSS) as files, and the binary turns them into a live site.

View file

@ -1,139 +0,0 @@
# Data Layer — Implementation Status
The embedded data layer described in [02-recovery.md](./02-recovery.md) and [03-data.md](./03-data.md) has been implemented as a Cargo workspace with 8 crates. All 44 tests pass. No external database, no AWS, no tokio — direct Linux syscalls on a custom event loop.
## Workspace Structure
```
writeonce-all/
Cargo.toml # workspace root
docs/ # architecture documentation
sample-content/ # 5 test articles for validation
crates/
wo-model/ # content model
wo-seg/ # .seg file format
wo-index/ # index files
wo-store/ # unified storage engine
wo-watch/ # inotify file watcher
wo-event/ # epoll event loop
wo-sub/ # subscription system
wo-rt/ # custom runtime
```
## Crate Summary
| Crate | Purpose | Tests | Key Types |
|-------|---------|-------|-----------|
| **wo-model** | Article structs matching existing JSON schema, `ContentLoader` for directory walking | 8 | `Article`, `ArticleContent`, `ArticleBody`, `Section`, `CodeSnippet`, `ContentLoader` |
| **wo-seg** | Binary `.seg` file format — length-prefixed records, tombstoning, positional I/O | 6 | `SegWriter`, `SegReader`, `SegHeader` |
| **wo-index** | Three index types for O(1) and O(log n) access patterns | 8 | `TitleIndex`, `DateIndex`, `TagIndex` |
| **wo-store** | Unified storage engine composing seg + indexes, cold-start rebuild | 3 | `Store` |
| **wo-watch** | Content directory watcher using inotify | 4 | `ContentWatcher`, `ContentChange` |
| **wo-event** | Custom event loop on epoll with eventfd, timerfd, signalfd | 5 | `EventLoop`, `EventFd`, `TimerFd`, `SignalFd` |
| **wo-sub** | Subscription manager with fd-based notifications, `register!` macro | 6 | `SubscriptionManager`, `Subscription`, `Notification` |
| **wo-rt** | Runtime tying all crates together — single process, single event loop | 4 | `Runtime`, `RuntimeHandle`, `Config` |
## Linux Kernel Syscalls Used
| Syscall | Crate | Purpose |
|---------|-------|---------|
| `pread` / `pwrite` | wo-seg | Positional read/write for .seg records without seeking |
| `fallocate` | wo-seg | Pre-allocate .seg file space to reduce fragmentation |
| `epoll_create1` / `epoll_ctl` / `epoll_wait` | wo-event | Event-driven I/O multiplexing for the main loop |
| `eventfd` | wo-event, wo-sub | Lightweight signaling between watcher and subscription manager |
| `timerfd_create` / `timerfd_settime` | wo-event | Periodic tasks (compaction, keepalive) as file descriptors |
| `signalfd` | wo-event | SIGINT/SIGTERM delivered as fd events for graceful shutdown |
| `inotify_init1` / `inotify_add_watch` | wo-watch | File system change detection on the content directory |
| `pipe2` | wo-sub (tests) | Mock subscriber fds for testing notification delivery |
## .seg File Format
```
Offset Size Field
0 4 Magic: b"WOSF"
4 2 Version: u16 LE (1)
6 2 Flags: u16 LE (reserved)
8 8 Record count: u64 LE
16 8 Data start offset: u64 LE
24 8 Reserved
32+ variable Records: [u32 length][u8 flags][bincode payload]...
```
- Records are addressed by byte offset from file start
- Flags: `0x00` = active, `0x01` = tombstoned
- Payload: bincode-serialized `Article` struct
## Index Files
| File | Format | Access Pattern |
|------|--------|----------------|
| `title.idx` | On-disk hash table (Robin Hood, load factor 0.5), 138 bytes/slot | O(1) lookup by `sys_title` |
| `date.idx` | Sorted `(i64 timestamp, u64 offset)` array, 16 bytes/entry | Binary search for date ranges, latest N |
| `tags.idx` | Bincode-serialized `HashMap<String, Vec<u64>>` | Tag-to-offsets inverted index |
All indexes are derived from `.seg` and rebuildable from `content/` on cold start.
## Subscription Model
No SSE. No WebSocket. Notifications are written directly to subscriber file descriptors.
- **Subscribe**: `SubscriptionManager::subscribe(fd, Subscription::ByTitle("linux-misc"))`
- **Notify**: on content change, length-prefixed `Notification` written to matching fds
- **Cleanup**: `EPOLLHUP` on epoll triggers automatic `unsubscribe(fd)`
- **Dedup**: if a fd matches multiple patterns (title + tag), it receives only one notification
Subscription patterns:
- `Subscription::ByTitle(sys_title)` — single article
- `Subscription::ByTag(tag)` — all articles with tag
- `Subscription::All` — all content changes
## Store Query API
```rust
store.get_by_title("linux-misc") -> Option<Article>
store.list_published(skip, limit) -> Vec<Article>
store.list_by_tag("rust") -> Vec<Article>
store.list_by_date_range(start, end) -> Vec<Article>
store.count_published() -> usize
store.article_version("linux-misc") -> Option<u64>
store.rebuild() // full rebuild from content/
```
## Runtime Event Loop
Single `epoll` instance multiplexing all file descriptors:
| Token | Fd | Handler |
|-------|----|---------|
| `WATCHER` | inotify fd | Process file changes → update store → notify subscribers |
| `SIGNAL` | signalfd | SIGINT/SIGTERM → graceful shutdown |
| `TIMER` | timerfd | Periodic tasks (compaction, stats) |
| `NOTIFY` | eventfd | Subscription notification signal |
| `1000+` | subscriber fds | Hangup detection → unsubscribe + cleanup |
## External Dependencies
| Crate | Version | Purpose |
|-------|---------|---------|
| `serde` | 1.x | Serialization derives |
| `serde_json` | 1.x | JSON parsing for article files |
| `bincode` | 1.x | Compact binary serialization for .seg records and notifications |
| `libc` | 0.2.x | Raw Linux syscall bindings |
No tokio. No async-std. No database driver. No HTTP framework (yet).
## What Comes Next
The data layer delivers everything the HTTP server and UI layers need:
1. **`Store` with zero-copy query access** — all article queries resolve in-process
2. **Subscription system accepting raw fds** — HTTP layer hands socket fds to `subscribe()`
3. **Shared event loop** — HTTP listener socket registers on the same epoll
4. **Automatic cold-start** — if `data/` is missing, rebuilds from `content/` on startup
5. **Graceful shutdown** — SIGTERM triggers clean fd cleanup
Next phases per [02-recovery.md](./02-recovery.md):
- **HTTP server** — route handlers using the `Store` query API, embedded in the same binary
- **HTMLX templates** — server-rendered HTML with `{{bindings}}` per [04-ui.md](./04-ui.md)
- **Frontend collapse** — serve static assets from the binary, eliminate the Angular app
3

View file

@ -1,246 +0,0 @@
# Markdown File Rendering
## Current State (writeonce-articles-s3)
Each article is a directory containing a JSON metadata file and one or more `.md` files:
```
auto-scale-gitlab-runner-using-aws-spot-instance/
docker-machine-test-with-t2.md
gitlab-runner-config.md
stop-test-gitlab-docker-machine.md
gitlab-runner-with-kubernetes-executor/
gitlab-runner-with-kubernetes-executor.json
deploy.md
permission.md
role-binding.md
role-defination.md
gitlab-runnergitlab-runner-deploy.md
```
The JSON metadata currently defines the full article structure — sections, headings, paragraphs, and code snippet references. Markdown files are limited to code blocks referenced via the `codes[].snippet` field.
## Problem
The JSON metadata carries too much content. Headings, paragraphs, prose — all of this is duplicated as JSON strings inside `content.content.sections`. The markdown files only hold code snippets, referenced by `sectionIndex` and `paragraphIndex`.
This is backwards. The markdown file should be the content. The JSON should be minimal metadata.
## Target: Markdown-First Content Model
**The markdown file is the article.** All prose, headings, code blocks, and inline formatting live in the `.md` file. The JSON metadata file holds only what markdown cannot express: system fields, tags, publication state, and author.
### Minimal JSON Metadata
```json
{
"sys_title": "gitlab-runner-with-kubernetes-executor",
"title": "Gitlab Runner with Kubernetes Executor",
"published": true,
"author": "Shoney Arickathil",
"tags": ["kubernetes", "gitlab", "ci-cd"],
"published_on": 1740950884
}
```
No `content.content.sections`. No `content.content.codes`. No `paragraphs[]` arrays. No `sectionIndex`/`paragraphIndex` mapping.
### Markdown File = Full Article Content
````markdown
# Introduction
Deploying a Gitlab runner using kubernetes is a great option to overcome
the limitations of other gitlab runner executor such as docker and docker machine.
## Running Gitlab Runner in gitlab namespace
Create the namespace and apply the deployment:
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: gitlab-runner
namespace: gitlab
```
````
## Permissions
The runner needs RBAC permissions to create pods:
```yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: gitlab-runner
```
Everything is in the markdown — headings, paragraphs, code blocks with language hints, links, images. The rendering pipeline parses the markdown directly.
### Directory Structure
```
content/
gitlab-runner-with-kubernetes-executor/
gitlab-runner-with-kubernetes-executor.json # minimal metadata
gitlab-runner-with-kubernetes-executor.md # full article content
linux-misc/
linux-misc.json
linux-misc.md
```
One JSON for metadata. One markdown for content. No scattered `.md` files per code snippet.
## What Changes
| Before | After |
| -------------------------------------------------------------- | ----------------------------------------------------------------------- |
| JSON holds sections, headings, paragraphs as structured arrays | JSON holds only sys_title, title, published, author, tags, published_on |
| Markdown files hold only code snippets | Markdown file holds the entire article |
| `codes[].snippet` maps filename to sectionIndex/paragraphIndex | No mapping needed — headings and code blocks are inline in markdown |
| Renderer reads JSON structure, injects code from .md files | Renderer parses markdown directly into HTML |
| Multiple .md files per article (one per code snippet) | One .md file per article |
## Impact on the Data Layer
### wo-model
The `Article` struct simplifies:
```rust
pub struct Article {
pub sys_title: String,
pub title: String,
pub published: bool,
pub author: String,
pub tags: Vec<String>,
pub published_on: Option<i64>,
}
```
The nested `ArticleContent` / `ArticleBody` / `Section` / `CodeSnippet` hierarchy is no longer needed. Article content comes from parsing the `.md` file at render time, not from the JSON.
### wo-md
Currently handles only inline markdown (`**bold**`, `` `code` ``, links). Needs to become a full markdown-to-HTML renderer:
- Block elements: headings (`#`, `##`), paragraphs, code fences (` `lang ```), lists, blockquotes
- Inline elements: bold, italic, code, links, images
- Code fence language extraction for `wo-md::highlight()`
- The renderer reads `{sys_title}/{sys_title}.md`, parses it, and returns HTML
### wo-htmlx
The `article.htmlx` template simplifies. Instead of iterating `{{#each article.content.content.sections}}`, it renders the pre-parsed markdown HTML:
```html
<article>
<h1>{{article.title}}</h1>
<p class="meta">by {{article.author}} &middot; {{article.tags}}</p>
{{article.content_html}}
</article>
```
Where `content_html` is the full HTML output from the markdown renderer.
### wo-store
`ContentLoader` reads the `.json` for metadata and the `.md` for content. The `.seg` file stores both. At query time, the markdown is either:
- Pre-rendered to HTML during ingestion (stored in .seg alongside metadata)
- Rendered on-demand at request time (read .md from disk)
Pre-rendering is preferred — it avoids parsing markdown on every HTTP request.
## Migration Path
1. Update `wo-model` with the simplified `Article` struct
2. Extend `wo-md` to handle full markdown (block-level parsing, code fences)
3. Update `ContentLoader` to read `.json` + `.md` pairs
4. Update `wo-store` to store pre-rendered HTML in the .seg file
5. Simplify `article.htmlx` template
6. Migrate existing articles: extract prose from JSON into `.md` files
Existing articles with the old JSON format can coexist during migration — `ContentLoader` checks for a `.md` file and falls back to the JSON structure if none exists.
## Blog Subscription — Live Content Reload
When a user visits `http://localhost:3000/blog/sample-rust-patterns`, the content should stay live. Any edit to `sample-content/sample-rust-patterns/sample-rust-patterns.md` must auto-reflect in the browser without a page refresh.
### How It Works
```
Browser visits /blog/sample-rust-patterns
│
▼
1. Server renders article HTML from .seg (pre-rendered from .md)
2. Server writes HTML response to socket fd
3. Server registers socket fd in subscription table:
register!(sub_manager, socket_fd, ByTitle("sample-rust-patterns"))
4. Connection transitions to Subscribed state (stays open)
│
│ (user edits sample-rust-patterns.md)
│
▼
5. inotify fires IN_MODIFY on sample-rust-patterns.md
6. ContentWatcher maps file → sys_title "sample-rust-patterns"
7. Store rebuilds: re-reads .json + .md, re-renders markdown to HTML, updates .seg + indexes
8. SubscriptionManager::notify("sample-rust-patterns", ...) fires
9. For each subscribed fd: write(fd, diff_payload)
│
▼
10. Browser receives payload on the open connection
11. Client-side script applies the update to the DOM
```
### What Needs to Work
| Component | Requirement |
|-----------|-------------|
| **inotify** (wo-watch) | Already watches `content/` directory. `.md` file changes must trigger `ContentChange::Modified(sys_title)` |
| **Store rebuild** (wo-store) | On `.md` change: re-read file, re-render markdown to HTML, update `.seg` and indexes |
| **Subscription table** (wo-sub) | Route handler registers the browser's socket fd via `register!` after sending initial HTML |
| **Notification** (wo-sub) | On content change, write updated `content_html` to all subscribed fds as JSON payload |
| **Event loop** (wo-rt) | After writing initial response, transition connection to `Subscribed` state. Keep fd on epoll for hangup detection. |
| **Client script** | Injected in the HTML. Reads payloads from the open connection. Replaces article content in the DOM. |
### Client-Side Script
Injected by the template renderer into every article page:
```html
<script>
// Connection stays open after initial HTML.
// Server writes length-prefixed JSON payloads when content changes.
const decoder = new TextDecoder();
const articleEl = document.querySelector('article');
fetch(window.location.href, { headers: { 'X-Subscribe': '1' } })
.then(r => r.body.getReader())
.then(reader => {
(function read() {
reader.read().then(({ done, value }) => {
if (done) return;
try {
const payload = JSON.parse(decoder.decode(value));
if (payload.content_html) {
articleEl.innerHTML = payload.content_html;
}
} catch (e) {}
read();
});
})();
});
</script>
```
### inotify and .md Files
The current `ContentWatcher` watches for `.json` file changes. It must also trigger on `.md` file changes:
- `IN_MODIFY` on `*.md` → `ContentChange::Modified(sys_title)`
- The sys_title is derived from the parent directory name (same as for JSON)
- Both `.json` and `.md` changes trigger a store rebuild and subscriber notification

View file

@ -1,252 +0,0 @@
# SSL and Deployment
## Problem
In [01-problem.md](./01-problem.md), the infrastructure overhead was identified — multiple repos, AWS dependencies, separate deployment pipelines. But one problem went unaddressed: the server-side infrastructure that sits in front of the application — nginx reverse proxy, SSL certificates, systemd service management, and deployment to the production host.
Currently this requires manual SSH, manual nginx config, manual certbot runs. For a single-binary platform, the deployment should be as simple as the architecture.
## Target
Given:
- SSH access to `writeonce.de` is configured
- nginx exists at the default path `/etc/nginx/`
- The writeonce binary listens on a local port (e.g., `127.0.0.1:3000`)
The deployment pipeline should:
1. Build the binary
2. Copy it to the server
3. Create/update the systemd service
4. Restart the service
5. Configure nginx as a reverse proxy
6. Obtain and auto-renew SSL certificates via Let's Encrypt
## Systemd Service
The writeonce binary runs as a systemd service for automatic restart, logging, and boot-start.
### Service File
```ini
# /etc/systemd/system/writeonce.service
[Unit]
Description=writeonce content platform
After=network.target
[Service]
Type=simple
User=writeonce
Group=writeonce
WorkingDirectory=/opt/writeonce
ExecStart=/opt/writeonce/writeonce
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/writeonce/data
PrivateTmp=true
[Install]
WantedBy=multi-user.target
```
### Directory Layout on Server
```
/opt/writeonce/
writeonce # the binary
content/ # article .json + .md files
data/ # derived .seg + .idx (rebuilt on start)
templates/ # .htmlx templates
static/ # CSS, images
```
### Service Management
```bash
# Install / update
sudo systemctl daemon-reload
sudo systemctl enable writeonce
sudo systemctl restart writeonce
# Check status
sudo systemctl status writeonce
journalctl -u writeonce -f
```
## Nginx Reverse Proxy
Nginx sits in front of the writeonce binary, handling SSL termination and proxying requests to `127.0.0.1:3000`.
### Nginx Config
```nginx
# /etc/nginx/sites-available/writeonce.de
server {
listen 80;
server_name writeonce.de www.writeonce.de;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name writeonce.de www.writeonce.de;
ssl_certificate /etc/letsencrypt/live/writeonce.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/writeonce.de/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# HSTS
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Keep connections open for database subscriptions
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
# Static assets — let nginx serve directly for better caching
location /static/ {
alias /opt/writeonce/static/;
expires 1y;
add_header Cache-Control "public, immutable";
}
}
```
### Enable Site
```bash
sudo ln -sf /etc/nginx/sites-available/writeonce.de /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
## SSL with Let's Encrypt
### Initial Certificate
```bash
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d writeonce.de -d www.writeonce.de
```
Certbot modifies the nginx config to add SSL directives and obtains the certificate.
### Auto-Renewal
Certbot installs a systemd timer that runs twice daily:
```bash
# Check timer
systemctl list-timers | grep certbot
# Manual test
sudo certbot renew --dry-run
```
Certificates auto-renew before expiry. Nginx reloads automatically via certbot's deploy hook.
### Deploy Hook for Nginx Reload
```bash
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/bin/bash
systemctl reload nginx
```
## Deployment Script
A single script that builds, copies, and restarts:
```bash
#!/bin/bash
# deploy.sh — run from the development machine
set -e
SERVER="writeonce.de"
REMOTE_DIR="/opt/writeonce"
echo "Building release binary..."
cargo build --release -p wo-rt --bin writeonce
echo "Copying binary to server..."
scp target/release/writeonce $SERVER:$REMOTE_DIR/writeonce.new
echo "Syncing content and templates..."
rsync -az --delete content/ $SERVER:$REMOTE_DIR/content/
rsync -az --delete templates/ $SERVER:$REMOTE_DIR/templates/
rsync -az --delete static/ $SERVER:$REMOTE_DIR/static/
echo "Swapping binary and restarting..."
ssh $SERVER "
sudo mv $REMOTE_DIR/writeonce.new $REMOTE_DIR/writeonce
sudo systemctl restart writeonce
"
echo "Deployed. Checking status..."
ssh $SERVER "sudo systemctl status writeonce --no-pager"
```
### First-Time Setup
Run once on the server to create the user, directory, and service:
```bash
#!/bin/bash
# setup.sh — run on the server
set -e
# Create user
sudo useradd -r -s /bin/false writeonce
# Create directory
sudo mkdir -p /opt/writeonce/{content,data,templates,static}
sudo chown -R writeonce:writeonce /opt/writeonce
# Install service
sudo cp writeonce.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable writeonce
# Configure nginx
sudo cp writeonce.de.nginx /etc/nginx/sites-available/writeonce.de
sudo ln -sf /etc/nginx/sites-available/writeonce.de /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
# SSL
sudo certbot --nginx -d writeonce.de -d www.writeonce.de
```
## What This Replaces
| Before | After |
|--------|-------|
| Pulumi IaC managing Lambda + S3 | `deploy.sh` with scp + rsync |
| AWS Lambda deployment pipeline | `systemctl restart writeonce` |
| S3 bucket for content hosting | `rsync content/` to server |
| Docker Compose for API + DB | Single binary, one systemd service |
| Multiple nginx configs for API + frontend | One nginx config, one proxy_pass |
| Manual SSL setup | `certbot --nginx` with auto-renewal |
## Connection Keepalive for Subscriptions
The nginx config sets `proxy_read_timeout 86400s` (24 hours) to keep persistent connections open for the database subscription model. When a browser visits an article page and the connection transitions to `Subscribed` state, nginx must not timeout and close the upstream connection.
If nginx is removed in the future (the binary handles TLS directly via `rustls`), this concern disappears — the binary owns the socket end-to-end.

View file

@ -90,19 +90,18 @@ Unchanged from CLAUDE.md's description: `rt` is the monolithic Stage-2 runtime p
``` ```
docs/ docs/
├── 01…08-*.md numbered design docs (this file is 08) ├── 00-*,01,08-*.md status / principles / problem / structure docs
├── writeonce-pl.md language positioning ├── runtime/ the 7-phase database design series + runtime concept refs
├── runtime/ user-facing language overview + the 7-phase database series ├── examples/ log-watcher/, employee/, employee-list/ samples
├── examples/ blog/, ecommerce/, pricing/ samples; ⏳ log-watcher/ (plan 10)
├── plan/ numbered engineering plans 00–16, linux/ cards, assembly/, ├── plan/ numbered engineering plans 00–16, linux/ cards, assembly/,
│ ├── exploration/ c-runtime/ (A–F, done), ui/ (htmlx track), colibri/ │ ├── exploration/ c-runtime/ (A–F, done), linux/, postgresql/, assembly/
│ └── oop-vm/ ⏳ the OOP-track contracts: 00-wob-format, 01-error-catalog, │ └── oop-vm/ ⏳ the OOP-track contracts: 00-wob-format, 01-error-catalog,
│ 02-corpus, 03-shard-actor, 04-db-binding, 05-http-service, │ 02-corpus, 03-shard-actor, 04-db-binding, 05-http-service,
│ 06-ui-live, 07-systems-stdlib │ 06-ui-live, 07-systems-stdlib
├── superpowers/ ├── superpowers/
│ ├── specs/ the two approved track specs (2026-08-01) │ ├── specs/ the two approved track specs (2026-08-01)
│ └── plans/ implementation plans 1–10 (2026-08-01, prose-only) │ └── plans/ implementation plans 1–10 (2026-08-01, prose-only)
└── future-scope/, cm.md legacy notes └── cm.md legacy notes
``` ```
Repo rule restated: documentation belongs here; code directories keep one orientation README each. Repo rule restated: documentation belongs here; code directories keep one orientation README each.

View file

@ -1,182 +0,0 @@
# AI Agents and Content Management
## Context
AI agents (Claude Code, Copilot, Cursor, custom agents) work within a specific project or working directory. Their sessions, context, and understanding are scoped to that directory. This works well when projects are completely different domains.
But writeonce content is not isolated — articles reference each other, share tags, build on concepts from other articles. An agent editing `gitlab-runner-with-kubernetes-executor.md` would benefit from knowing that `auto-scale-gitlab-runner-using-aws-spot-instance.md` exists and covers related infrastructure. Without explicit mappings, the agent treats each article as an island.
## Problem
1. **Agents lack cross-article awareness.** When asked to write or update an article about Kubernetes, the agent doesn't know that related articles about Docker, CI/CD, or AWS already exist in the content directory — unless it manually searches.
2. **No semantic grouping.** Tags provide flat categorization (`kubernetes`, `ci-cd`), but they don't express relationships: "this article is a prerequisite for that one", "these three articles form a series", "this article supersedes that one."
3. **Context window waste.** Without mappings, the agent must scan all articles to find related content. With explicit mappings, it can load exactly the relevant files.
## Solution: Metadata-Driven Content Mappings
Users define relationships between articles in the JSON metadata. These mappings serve two purposes:
1. **Human navigation** — rendered as "related articles" links on the site
2. **Agent context** — when an agent works on an article, it loads the mapped articles into its context for cross-referencing
### Mapping Fields in JSON Metadata
Per [06-markdown-render.md](../06-markdown-render.md), the JSON metadata is minimal. Add a `mappings` field:
```json
{
"sys_title": "gitlab-runner-with-kubernetes-executor",
"title": "Gitlab Runner with Kubernetes Executor",
"published": true,
"author": "Shoney Arickathil",
"tags": ["kubernetes", "gitlab", "ci-cd"],
"published_on": 1740950884,
"mappings": {
"related": ["auto-scale-gitlab-runner-using-aws-spot-instance"],
"prerequisite": ["linux-misc"],
"series": {
"name": "gitlab-runner",
"order": 2
}
}
}
```
### Mapping Types
| Type | Meaning | Agent Use |
| -------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------- |
| `related` | Topically related articles | Agent loads these for cross-reference when editing |
| `prerequisite` | Articles the reader should read first | Agent ensures no concept duplication, references prerequisites instead of re-explaining |
| `series` | Articles that form an ordered sequence | Agent maintains narrative continuity across the series |
| `supersedes` | This article replaces an older one | Agent can mark the old article as outdated or unpublished |
| `references` | External articles or URLs the content builds on | Agent checks links are still valid, cites them properly |
### Directory Structure with Mappings
```
content/
gitlab-runner-with-kubernetes-executor/
gitlab-runner-with-kubernetes-executor.json # metadata + mappings
gitlab-runner-with-kubernetes-executor.md # full article
auto-scale-gitlab-runner-using-aws-spot-instance/
auto-scale-gitlab-runner-using-aws-spot-instance.json
auto-scale-gitlab-runner-using-aws-spot-instance.md
linux-misc/
linux-misc.json
linux-misc.md
```
## Agent Workflows
### 1. Writing a New Article
The author asks an agent: "Write an article about deploying GitLab Runner on ECS."
The agent:
1. Scans the content directory for existing articles with tags `gitlab`, `ci-cd`, `aws`
2. Finds `gitlab-runner-with-kubernetes-executor` and `auto-scale-gitlab-runner-using-aws-spot-instance`
3. Reads their `.md` files to understand what's already covered
4. Writes the new article, referencing existing articles rather than re-explaining shared concepts
5. Suggests `mappings.related` entries for the new article's JSON
### 2. Updating an Existing Article
The author asks: "Update the Kubernetes executor article with the new runner token format."
The agent:
1. Reads the article's JSON metadata and `.md` content
2. Reads the `mappings.related` articles to check for consistency
3. Makes the update in the `.md` file
4. Checks if the change affects any prerequisite or series articles
5. inotify detects the `.md` change → store rebuilds → subscribers notified
### 3. Content Audit
The author asks: "Which articles reference outdated AWS configurations?"
The agent:
1. Loads all article metadata (the Store already indexes everything)
2. Follows `mappings` to build a dependency graph
3. Reads the `.md` files of articles tagged with `aws`
4. Identifies outdated patterns (old SDK versions, deprecated services)
5. Reports findings with links to specific articles and line numbers
### 4. Series Management
The author asks: "Add a new part to the gitlab-runner series."
The agent:
1. Finds all articles with `mappings.series.name == "gitlab-runner"`
2. Reads them in order to understand the narrative arc
3. Writes the new article continuing from where the series left off
4. Sets `mappings.series.order` to the next number
5. Updates the previous article's mappings to reference the new one
## Integration with writeonce Architecture
### Store Index
Add a mappings index alongside the existing title, date, and tag indexes:
```
data/
articles.seg
index/
title.idx
date.idx
tags.idx
mappings.idx # sys_title → related sys_titles
```
The mappings index allows efficient traversal: "give me all articles related to X" without scanning every article's JSON.
### Template Rendering
The `article.htmlx` template can render related articles:
```html
<article>
<h1>{{article.title}}</h1>
{{article.content_html}} {{#each article.related}}
<aside class="related">
<h3>Related</h3>
<ul>
<li><a href="/blog/{{sys_title}}">{{title}}</a></li>
</ul>
</aside>
{{/each}}
</article>
```
### Subscription
When a mapped article changes, subscribers to related articles can optionally be notified. If article A lists article B in `mappings.related`, and article B is updated, subscribers to article A can receive a notification that related content changed.
## Agent Configuration
For agents to use the mappings effectively, the project can include an agent instruction file (e.g., `CLAUDE.md` or `.agent/instructions.md`):
```markdown
## Content Management
- Articles are in `content/{sys_title}/{sys_title}.md`
- Metadata is in `content/{sys_title}/{sys_title}.json`
- Before writing or editing an article, read its `mappings` field and load related articles for context
- When creating a new article, suggest appropriate `mappings` based on tags and content overlap
- Maintain narrative continuity within `series` mappings
- Do not duplicate explanations that exist in `prerequisite` articles — reference them instead
```
This turns the content directory into an agent-navigable knowledge graph where the metadata provides the edges and the markdown files provide the nodes.
## queryable graph database
- traversable knowledge graphs available on RAM.
- which linux kernels, develop in C ++. User wants to learn it.

View file

@ -2,7 +2,7 @@
> **Status: ⬜ not started** — Track 1 (runtime foundations). Board: [00-status.md](../00-status.md) > **Status: ⬜ not started** — Track 1 (runtime foundations). Board: [00-status.md](../00-status.md)
**Context sources:** [`./02-event-loop-epoll.md`](./done/02-event-loop-epoll.md), [`./linux/00-linux.md`](./exploration/linux/00-linux.md) § File Watching, [`../02-recovery.md`](../02-recovery.md) § No AWS Infrastructure. **Context sources:** [`./02-event-loop-epoll.md`](./done/02-event-loop-epoll.md), [`./linux/00-linux.md`](./exploration/linux/00-linux.md) § File Watching, `../02-recovery.md` § No AWS Infrastructure.
## Goal ## Goal

View file

@ -2,7 +2,7 @@
> **Status: ⬜ not started** — Track 1 (runtime foundations); also a prerequisite of the parked UI track. Board: [00-status.md](../00-status.md) > **Status: ⬜ not started** — Track 1 (runtime foundations); also a prerequisite of the parked UI track. Board: [00-status.md](../00-status.md)
**Context sources:** [`./03-hand-rolled-http.md`](./done/03-hand-rolled-http.md), [`./linux/00-linux.md`](./exploration/linux/00-linux.md) § Efficient File Serving, [`../02-recovery.md`](../02-recovery.md). **Context sources:** [`./03-hand-rolled-http.md`](./done/03-hand-rolled-http.md), [`./linux/00-linux.md`](./exploration/linux/00-linux.md) § Efficient File Serving, `../02-recovery.md`.
## Goal ## Goal

View file

@ -2,7 +2,7 @@
> **Status: ⬜ not started (scope reduced)** — replay + ack-after-fsync + group commit landed via 09c and its follow-ups; remaining here: snapshots (`.data`), compaction, WAL rotation. Board: [00-status.md](../00-status.md) > **Status: ⬜ not started (scope reduced)** — replay + ack-after-fsync + group commit landed via 09c and its follow-ups; remaining here: snapshots (`.data`), compaction, WAL rotation. Board: [00-status.md](../00-status.md)
**Context sources:** [`./10-storage-foundations.md`](./10-storage-foundations.md), [`../runtime/database/02-wo-language.md#concurrency-model`](../runtime/database/02-wo-language.md#concurrency-model), [`../runtime/database/03-inmemory-engine.md`](../runtime/database/03-inmemory-engine.md), [`./exploration/postgresql/wal.md`](./exploration/postgresql/wal.md), [`./exploration/postgresql/buffer-and-checkpoint.md`](./exploration/postgresql/buffer-and-checkpoint.md), [`./exploration/linux/12-pwrite-fsync.md`](./exploration/linux/12-pwrite-fsync.md), [`../02-recovery.md`](../02-recovery.md). **Context sources:** [`./10-storage-foundations.md`](./10-storage-foundations.md), [`../runtime/database/02-wo-language.md#concurrency-model`](../runtime/database/02-wo-language.md#concurrency-model), [`../runtime/database/03-inmemory-engine.md`](../runtime/database/03-inmemory-engine.md), [`./exploration/postgresql/wal.md`](./exploration/postgresql/wal.md), [`./exploration/postgresql/buffer-and-checkpoint.md`](./exploration/postgresql/buffer-and-checkpoint.md), [`./exploration/linux/12-pwrite-fsync.md`](./exploration/linux/12-pwrite-fsync.md), `../02-recovery.md`.
## Goal ## Goal

View file

@ -2,7 +2,7 @@
> **Status: 🔄 in progress** — 13a ✅ shipped; 13b ✅ shipped (methods execute over RPC); 13c (LIVE push) is next; 13d ⏸ parked (frontend); 13e ⬜. Board: [00-status.md](../00-status.md) > **Status: 🔄 in progress** — 13a ✅ shipped; 13b ✅ shipped (methods execute over RPC); 13c (LIVE push) is next; 13d ⏸ parked (frontend); 13e ⬜. Board: [00-status.md](../00-status.md)
**Context sources:** [`../runtime/database/02-wo-language.md`](../runtime/database/02-wo-language.md) (schema layer, § Schema-Layer DML brace disambiguation, § Cross-Paradigm Transaction Coordinator), [`../runtime/database/04-client-api.md`](../runtime/database/04-client-api.md) (subscription engine), [`./09-concurrency-scaleout.md`](./09-concurrency-scaleout.md) (thread-per-core scale-out), [`./exploration/ui/00-overview.md`](./exploration/ui/00-overview.md) + [`./exploration/ui/01-htmlx-format-spec.md`](./exploration/ui/01-htmlx-format-spec.md) (live UI), [`../examples/pricing/`](../examples/pricing/) (the demo this phase makes real), [`../examples/ecommerce/shared/logic/checkout.wo`](../examples/ecommerce/shared/logic/checkout.wo) (the existing `fn … in txn snapshot` signature style methods reuse). **Context sources:** [`../runtime/database/02-wo-language.md`](../runtime/database/02-wo-language.md) (schema layer, § Schema-Layer DML brace disambiguation, § Cross-Paradigm Transaction Coordinator), [`../runtime/database/04-client-api.md`](../runtime/database/04-client-api.md) (subscription engine), [`./09-concurrency-scaleout.md`](./09-concurrency-scaleout.md) (thread-per-core scale-out), `./exploration/ui/00-overview.md` + `./exploration/ui/01-htmlx-format-spec.md` (live UI), [`../examples/pricing/`](../examples/pricing/) (the demo this phase makes real), [`../examples/ecommerce/shared/logic/checkout.wo`](../examples/ecommerce/shared/logic/checkout.wo) (the existing `fn … in txn snapshot` signature style methods reuse).
## Context ## Context
@ -73,7 +73,7 @@ Each lands as its own numbered plan doc (`13a-…`, `13b-…`) when ready. The b
### `13a-class-surface.md` — lexer, parser, AST, spec amendments — ✅ shipped ### `13a-class-surface.md` — lexer, parser, AST, spec amendments — ✅ shipped
`class` joins the keyword map (`crates/rt/src/lexer.rs` keyword match, ~line 202 — note `self` stays an ident per decision 4). `parse_type` (`crates/rt/src/parser.rs:120`) takes the leading keyword as a parameter and serves both constructs; `fn` members inside the body parse-and-discard through the existing brace-depth skip — the same mechanism that already swallows `on update … do { … }` triggers. `ast::TypeDecl` gains `is_class: bool`; `Catalog::from_schemas` ignores it (decision 5), so REST CRUD works the moment parsing does. Docs amended in the same change: a "Class Model" subsection in [`02-wo-language.md`](../runtime/database/02-wo-language.md) next to § Schema-Layer DML, the "Isn't OO" paragraph in [`wo-language.md`](../runtime/wo-language.md), the class line in [`writeonce-pl.md`](../writeonce-pl.md), and `just pricing` / `just pricing-demo` recipes. `class` joins the keyword map (`crates/rt/src/lexer.rs` keyword match, ~line 202 — note `self` stays an ident per decision 4). `parse_type` (`crates/rt/src/parser.rs:120`) takes the leading keyword as a parameter and serves both constructs; `fn` members inside the body parse-and-discard through the existing brace-depth skip — the same mechanism that already swallows `on update … do { … }` triggers. `ast::TypeDecl` gains `is_class: bool`; `Catalog::from_schemas` ignores it (decision 5), so REST CRUD works the moment parsing does. Docs amended in the same change: a "Class Model" subsection in [`02-wo-language.md`](../runtime/database/02-wo-language.md) next to § Schema-Layer DML, the "Isn't OO" paragraph in `wo-language.md`, the class line in `writeonce-pl.md`, and `just pricing` / `just pricing-demo` recipes.
**Exit (met):** `wo run docs/examples/pricing` parses 2 classes, serves `/api/products` CRUD; parser unit tests (`parses_class_with_methods`, `class_method_braces_do_not_truncate_body`) green; blog/ecommerce/hello unchanged. **Exit (met):** `wo run docs/examples/pricing` parses 2 classes, serves `/api/products` CRUD; parser unit tests (`parses_class_with_methods`, `class_method_braces_do_not_truncate_body`) green; blog/ecommerce/hello unchanged.
### `13b-method-execution.md` — methods over RPC — ✅ shipped ### `13b-method-execution.md` — methods over RPC — ✅ shipped
@ -88,7 +88,7 @@ The subscription registry from [`04-client-api.md`](../runtime/database/04-clien
### `13d-pricing-ui.md` — the `/pricing` screen, MVC ### `13d-pricing-ui.md` — the `/pricing` screen, MVC
The screen ships as an **MVC triplet** per [`exploration/ui/08-mvc-structure.md`](./exploration/ui/08-mvc-structure.md), built in the sub-phase sequence of [`14-mvc-ui-implementation.md`](./14-mvc-ui-implementation.md): model = the classes themselves, view = [`pricing.htmlx`](../examples/pricing/ui/pricing/pricing.htmlx) (plain htmlx, logic-free) + external [`pricing.scss`](../examples/pricing/ui/pricing/pricing.scss) (strict SCSS subset compiled at `wo build`, no external deps), controller = [`pricing.wo`](../examples/pricing/ui/pricing/pricing.wo) (`route:`/`view:`/`styles:`, `model:` bindings, `actions:` calling the 13b class methods). SSR per [`exploration/ui/01-htmlx-format-spec.md`](./exploration/ui/01-htmlx-format-spec.md), compiler glue per [`02-ui-compiler.md`](./exploration/ui/02-ui-compiler.md), and the vanilla-JS client runtime ([`03-client-runtime.md`](./exploration/ui/03-client-runtime.md)) patches the price cell when the 13c delta lands. The controller's `model:` block is the M→V binding; the watchlist narrows the subscription predicate server-side. The screen ships as an **MVC triplet** per `exploration/ui/08-mvc-structure.md`, built in the sub-phase sequence of `14-mvc-ui-implementation.md`: model = the classes themselves, view = [`pricing.htmlx`](../examples/pricing/ui/pricing/pricing.htmlx) (plain htmlx, logic-free) + external [`pricing.scss`](../examples/pricing/ui/pricing/pricing.scss) (strict SCSS subset compiled at `wo build`, no external deps), controller = [`pricing.wo`](../examples/pricing/ui/pricing/pricing.wo) (`route:`/`view:`/`styles:`, `model:` bindings, `actions:` calling the 13b class methods). SSR per `exploration/ui/01-htmlx-format-spec.md`, compiler glue per `02-ui-compiler.md`, and the vanilla-JS client runtime (`03-client-runtime.md`) patches the price cell when the 13c delta lands. The controller's `model:` block is the M→V binding; the watchlist narrows the subscription predicate server-side.
**Exit:** browser at `/pricing` shows selected products; a `set_price` commit from curl changes the price cell in every open browser without reload. **Exit:** browser at `/pricing` shows selected products; a `set_price` commit from curl changes the price cell in every open browser without reload.
### `13e-pricing-at-scale.md` — millions of readers, millions of live updates ### `13e-pricing-at-scale.md` — millions of readers, millions of live updates
@ -122,5 +122,4 @@ No new architecture — this sub-phase wires the demo to [`09-concurrency-scaleo
- [`../runtime/database/02-wo-language.md`](../runtime/database/02-wo-language.md) — schema layer the class grammar extends; transaction coordinator methods reuse. - [`../runtime/database/02-wo-language.md`](../runtime/database/02-wo-language.md) — schema layer the class grammar extends; transaction coordinator methods reuse.
- [`../runtime/database/04-client-api.md`](../runtime/database/04-client-api.md) — subscription engine 13c scopes down. - [`../runtime/database/04-client-api.md`](../runtime/database/04-client-api.md) — subscription engine 13c scopes down.
- [`./09-concurrency-scaleout.md`](./09-concurrency-scaleout.md) — the scale architecture 13e instantiates. - [`./09-concurrency-scaleout.md`](./09-concurrency-scaleout.md) — the scale architecture 13e instantiates.
- [`./exploration/ui/00-overview.md`](./exploration/ui/00-overview.md) — UI track 13d draws on.
- [`../examples/hello/main.wo`](../examples/hello/main.wo) — the minimal example whose `Revision`-trigger pattern is the declarative ancestor of methods. - [`../examples/hello/main.wo`](../examples/hello/main.wo) — the minimal example whose `Revision`-trigger pattern is the declarative ancestor of methods.

View file

@ -1,90 +0,0 @@
# 14 — MVC UI implementation: model = class, view = htmlx + scss, controller = .wo
> **Status: ⏸ parked (frontend)** — backend focus first; design stays current. Board: [00-status.md](../00-status.md)
**Context sources:** [`./exploration/ui/08-mvc-structure.md`](./exploration/ui/08-mvc-structure.md) (the design this plan implements), [`./exploration/ui/01-htmlx-format-spec.md`](./exploration/ui/01-htmlx-format-spec.md) / [`02-ui-compiler.md`](./exploration/ui/02-ui-compiler.md) / [`03-client-runtime.md`](./exploration/ui/03-client-runtime.md) (the three UI-track pieces this plan sequences, each with port sources and LOC budgets), [`./13-class-model-live-pricing.md`](./13-class-model-live-pricing.md) (the class methods controllers call: 13a/13b; the LIVE deltas views consume: 13c), [`../examples/pricing/ui/pricing/`](../examples/pricing/ui/pricing/) (the reference MVC triplet), [`reference/crates/wo-htmlx/`](../../.dev/reference/crates/wo-htmlx/) (the v1 template engine, primary port source).
## Context
[`exploration/ui/08-mvc-structure.md`](./exploration/ui/08-mvc-structure.md) locks the screen anatomy: **model** = the `class`/`type` itself, **view** = plain `.htmlx` + external `.scss`, **controller** = a `.wo` file (`route:`/`view:`/`styles:`/`model:`/`actions:`) that binds the model into the view and is the only place UI may call class methods. The UI exploration docs 01–03 already specify the htmlx engine, the `##ui` compiler, and the client runtime in implementable detail. What's missing is the build order, the two genuinely new pieces (the controller format and the SCSS subset compiler), and the wiring into the `13` class-model track. This doc is that sequence.
Everything lands in **`crates/ui`** (currently a placeholder) and small deltas to `crates/rt` — consistent with the crate inventory in [`crates/README.md`](../../crates/README.md). The deployment shape never changes: one binary serving SSR + database + API on the kernel-primitive runtime.
## Goal
`cargo run --bin wo -- run docs/examples/pricing` (after plan 13a–13c land) serves `GET /pricing` as styled SSR HTML; clicking ☆ dispatches a controller action; an Ops `set-price` action calls `Product.set_price`, the commit pushes a delta, and the price cell patches in every open browser without reload — the [plan 13d exit criterion](./13-class-model-live-pricing.md), implemented MVC-shaped.
## Dependency graph
```
14a htmlx engine ──────┬─→ 14c controller format ─→ 14d SSR routes ─→ 14e actions ─→ 14f live patch
14b scss compiler ─────┘ │ │ │
(14a ∥ 14b — no shared code) needs engine needs 13a+13b needs 13c
(exists today)
```
Phases 05/06 (hand-rolled JSON / bespoke error) are orthogonal: `crates/ui` adopts `serde`/`serde_json` per the ui/01 decision and migrates when 05 lands. Phase 08 (`sendfile`) upgrades static-asset serving in 14d when it arrives; 14d ships with plain buffered writes first.
## Sub-phase sequence
### `14a-htmlx-engine.md` — port the view engine into `crates/ui`
Execute [`exploration/ui/01-htmlx-format-spec.md`](./exploration/ui/01-htmlx-format-spec.md) as written: port `reference/crates/wo-htmlx` (585 LOC — `parser.rs`, `ast.rs`, `value.rs`, `registry.rs`, `render.rs` carried over per its table) into `crates/ui/src/htmlx/`, extend with `<wo:live>` structured nodes, `wo:bind` capture, and the `data-wo-manifest` JSON emitter (~250 LOC new). One addition beyond the 01 spec, from the MVC design: `<wo:live source="…">` records whether `source` is a bare name (controller model binding, resolved in 14d) or an inline query — a one-field change to `LiveSubscription`.
**Exit:** the 01 spec's criteria — `cargo build -p ui` green, golden parse+render for every `.htmlx` under `docs/examples/{blog,ecommerce}` **plus** [`pricing/ui/pricing/pricing.htmlx`](../examples/pricing/ui/pricing/pricing.htmlx), manifest matches the 01 schema.
### `14b-scss-subset.md` — the stylesheet compiler
New, no port source (~400 LOC at `crates/ui/src/scss/`): scanner → rule tree → flattener. Exactly the subset locked in [08-mvc decision 3](./exploration/ui/08-mvc-structure.md): `$variables`, nesting (including `&`-less descendant flattening), `@use "partials"` (`ui/styles/_*.scss`), comments. **No mixins, functions, `@extend`, or color math** — `rgba($accent, 0.06)` in the reference file compiles by literal substitution of `$accent` and is the only function-form supported. Output is one flat `.css` per screen, written to `target/wo/<app>/static/`.
**Exit:** [`pricing.scss`](../examples/pricing/ui/pricing/pricing.scss) → golden-file CSS; unknown construct = compile error naming file:line (never silent passthrough); runs standalone (`wo build` integration is 14d).
### `14c-controller-format.md` — parse the controller, keep the shorthand
The `Kind::HashHash("ui")` skip arm in `crates/rt/src/parser.rs` (L80–88) parses for real, into one of two IRs by key-shape dispatch:
- **Controller form** (`route:`/`view:`/`styles:`/`model:`/`actions:` — the MVC triplet's `pricing.wo`) → new `Controller` IR: route pattern, view/styles paths resolved relative to the screen directory, `model:` entries as named query strings (`LIVE` flag captured, execution deferred), `actions:` entries as `(name, params, target-method-or-fn, role-set)`.
- **Shorthand form** (`source:`/`columns:`/… — the existing ecommerce/blog screens) → the `Screen` IR of [`exploration/ui/02-ui-compiler.md`](./exploration/ui/02-ui-compiler.md), whose codegen emits a generated view + a synthesized `Controller` — [08-mvc decision 5](./exploration/ui/08-mvc-structure.md): the shorthand is sugar over the triplet, one downstream path.
`##app` and `##component` keep their current skip behaviour (owned by ui/05 and ui/02 respectively).
**Exit:** `pricing.wo` parses to a `Controller` with 2 model bindings + 3 actions; every existing `##ui` screen in blog/ecommerce parses to `Screen` and compiles to an `.htmlx` that 14a round-trips; a controller naming a missing view file is a compile error.
### `14d-ssr-routes.md` — the single binary serves the screen
Wire controllers into `crates/rt`'s router (`crates/rt/src/server.rs`): each `Controller.route` becomes a GET route; the handler resolves `model:` bindings against the in-process engine (snapshot `select` now — the `LIVE` flag additionally registers a 13c subscription when that phase is live), renders the view via 14a with the model names as root scope, and emits HTML + manifest + `<link href="/static/<screen>.css">`. `wo build`/`wo run` gain the asset step: compile SCSS (14b), bake `/_wo/runtime.js` (`include_bytes!`, per ui/03 decision 6), serve `target/wo/.../static/` with buffered writes (upgraded to `sendfile` when [phase 08](./08-sendfile-static-assets.md) lands).
**Exit:** `GET /pricing` returns styled SSR HTML with a valid manifest and resolvable CSS/JS links; `GET /` on the blog sample is unaffected; route table printed at boot includes UI routes alongside REST.
### `14e-action-dispatch.md` — controller actions call class methods
`wo:action` buttons POST to `/_wo/action/<screen>/<action>` with `wo:args` + form payload. The dispatcher looks up the controller's action table, enforces the `role:` set server-side (per-app policy model of [`exploration/ui/07-per-app-policies.md`](./exploration/ui/07-per-app-policies.md)), and invokes the target: a class method via the 13b row-scoped RPC path (`Product{ id == id }.set_price(amount)`) or a free `fn`. Response is 204 — the UI never re-renders from the action response; the visible change arrives as a 13c delta, keeping one update path.
**Requires:** 13a + 13b. **Exit:** the ☆/★ watch toggle round-trips; `set-price` with an Ops session commits a Price; without the role it's 403 and no transaction starts.
### `14f-live-patching.md` — the browser follows commits
Execute [`exploration/ui/03-client-runtime.md`](./exploration/ui/03-client-runtime.md) as written (~500 LOC vanilla JS at `crates/ui/assets/wo-runtime.js`, JSON frames over one WebSocket, targeted DOM patching by `data-key` + `wo:bind`, coalescing backpressure, snapshot resync on reconnect), pointed at the 13c subscription endpoint.
**Requires:** 13c. **Exit:** the plan-13d criterion — `set_price` via curl in one terminal, the price cell changes in every open `/pricing` browser without reload; kill the server, restart, the page resyncs on reconnect.
## Verification targets (after 14f)
| Check | Target | How |
| --- | --- | --- |
| Golden corpus | every `.htmlx` in blog/ecommerce/pricing parses + renders byte-stable | `cargo test -p ui` golden files |
| SCSS | `pricing.scss` → golden CSS; errors carry file:line | `cargo test -p ui scss` |
| SSR | `GET /pricing` < 5 ms p99 on the dev box (RAM engine, no I/O on read path) | scripted curl loop |
| End-to-end | 13d criterion green | two-terminal demo, scripted in `just pricing-demo` |
| Single binary | UI + DB + API + WS in one `wo build` output, no Node anywhere | `ldd` shows libc only; no build-step JS |
| Dep budget | `crates/ui`: `serde`/`serde_json` only (dropped when phase 05 lands) | `Cargo.toml` review |
## Non-scope
- **No SPA router, no client-side templates.** Navigation is full-page SSR; only `wo:bind` cells and `<wo:live>` subtrees mutate in place. (ui/00 decision; unchanged.)
- **No SCSS mixins/functions/`@extend`/color math** beyond literal variable substitution — the subset is a floor, widened only by demonstrated need in the sample corpus.
- **No theme system / design tokens.** Shared partials under `ui/styles/_*.scss` are the only sharing mechanism for now.
- **No component framework.** `##component` partials render server-side via the 14a engine; they have no client behaviour beyond inherited `wo:bind` sites.
- **No changes to the REST API surface.** UI routes live beside `/api/*`; nothing under `/api` changes shape in this plan.
## Cross-references
- [`./exploration/ui/08-mvc-structure.md`](./exploration/ui/08-mvc-structure.md) — the design; its exit criteria are satisfied by 14c/14b/14f respectively.
- [`./13-class-model-live-pricing.md`](./13-class-model-live-pricing.md) — 13a/13b gate 14e; 13c gates 14f; 13d's exit criterion is this plan's end-to-end target.
- [`./exploration/ui/00-overview.md`](./exploration/ui/00-overview.md) — the UI track's master frame (per-app binaries, shared DB daemon) that 14d's asset/serving choices stay compatible with.
- [`reference/crates/wo-htmlx/`](../../.dev/reference/crates/wo-htmlx/) — primary port source (585 LOC), per ui/01.
- [`../examples/pricing/ui/pricing/`](../examples/pricing/ui/pricing/) — the reference triplet every sub-phase tests against.

View file

@ -52,3 +52,5 @@ Status board: [`00-status.md`](../00-status.md) · Doctrine: [`../00-principles.
| **Per-example `principle.md` files** | 2026-08-08: one canonical repo-level [`docs/00-principles.md`](../00-principles.md) instead; examples link to it. | | **Per-example `principle.md` files** | 2026-08-08: one canonical repo-level [`docs/00-principles.md`](../00-principles.md) instead; examples link to it. |
| **Minimal 3-file log-watcher sample** | Breaks the file-for-file `.hx` → `.wo` mapping and leaves the "could not express" column unproven — which is the sample's entire acceptance criterion. | | **Minimal 3-file log-watcher sample** | Breaks the file-for-file `.hx` → `.wo` mapping and leaves the "could not express" column unproven — which is the sample's entire acceptance criterion. |
| **Raw code in plan documents** | Plans carry concept, reason, and required behavior in words; the executor writes the code. | | **Raw code in plan documents** | Plans carry concept, reason, and required behavior in words; the executor writes the code. |
| **`##ui` / `.htmlx` LiveView frontend track** | 2026-08-17: removed the 9-doc `exploration/ui/` design set, the `14-mvc-ui-implementation` plan, and the `ui-htmlx-live` plan. All were built on the non-advancing Rust runtime (`.dev/reference/crates/wo-htmlx`, `cargo run`, WebSocket live-patches) and contradict the current woc/wovm direction. The 13d pricing-UI row went with them. Revisit only if a UI story is re-opened on the woc/wovm stack. |
| **Old-runtime "front door" + v1 design docs** | 2026-08-17: removed `writeonce-pl.md`, `runtime/wo-language.md`, `future-scope/ai-agents-content-management.md`, the numbered v1 set `02-recovery`/`03-data`/`04-ui`/`05-datalayer`/`06-markdown-render`/`07-ssl`, and `runtime/database/05-go-sdk.md`. They pitched the old Rust `wo` runtime (REST + LiveView + SQL/Cypher) as the current language and contradicted the shipped woc/wovm toolchain. The `runtime/database/` design series is kept as cited design history; the Rust-track plans/`done` are kept per the status board. |

View file

@ -2,7 +2,7 @@
> **Status: ✅ done** (Rust Stage 2 — shipped, maintained, not advancing) — `runtime/netpoll_epoll.rs`: the hand-rolled `epoll` loop that replaced the async runtime. Board: [00-status.md](../../00-status.md) > **Status: ✅ done** (Rust Stage 2 — shipped, maintained, not advancing) — `runtime/netpoll_epoll.rs`: the hand-rolled `epoll` loop that replaced the async runtime. Board: [00-status.md](../../00-status.md)
**Context sources:** [`../01-problem.md`](../../01-problem.md), [`../02-recovery.md`](../../02-recovery.md), [`./linux/00-linux.md`](../exploration/linux/00-linux.md), [`./done/01-scafolding-crates.md`](01-scafolding-crates.md). **Context sources:** [`../01-problem.md`](../../01-problem.md), `../02-recovery.md`, [`./linux/00-linux.md`](../exploration/linux/00-linux.md), [`./done/01-scafolding-crates.md`](01-scafolding-crates.md).
## Goal ## Goal

View file

@ -2,7 +2,7 @@
> **Status: ✅ done** (Rust Stage 2 — shipped, maintained, not advancing) — hand-rolled HTTP/1.1, plus keep-alive and pipelining. Board: [00-status.md](../../00-status.md) > **Status: ✅ done** (Rust Stage 2 — shipped, maintained, not advancing) — hand-rolled HTTP/1.1, plus keep-alive and pipelining. Board: [00-status.md](../../00-status.md)
**Context sources:** [`./02-event-loop-epoll.md`](./02-event-loop-epoll.md), [`./linux/00-linux.md`](../exploration/linux/00-linux.md), [`../02-recovery.md`](../../02-recovery.md). **Context sources:** [`./02-event-loop-epoll.md`](./02-event-loop-epoll.md), [`./linux/00-linux.md`](../exploration/linux/00-linux.md), `../02-recovery.md`.
## Goal ## Goal

View file

@ -146,4 +146,4 @@ Work stealing (breaks single-writer ACID), shared-heap locking (the doctrine exi
- [`../../docs/plan/09-concurrency-scaleout.md`](../../09-concurrency-scaleout.md) — the thread-per-core doctrine. - [`../../docs/plan/09-concurrency-scaleout.md`](../../09-concurrency-scaleout.md) — the thread-per-core doctrine.
- [`../../docs/plan/exploration/linux/07-io_uring.md`](../linux/07-io_uring.md), [`08-mmap.md`](../linux/08-mmap.md) — the two shared-page mechanisms. - [`../../docs/plan/exploration/linux/07-io_uring.md`](../linux/07-io_uring.md), [`08-mmap.md`](../linux/08-mmap.md) — the two shared-page mechanisms.
- [`../../docs/plan/13-class-model-live-pricing.md`](../../13-class-model-live-pricing.md) — 13e's read-replica alternative, contrasted in improvement 1. - [`../../docs/plan/13-class-model-live-pricing.md`](../../13-class-model-live-pricing.md) — 13e's read-replica alternative, contrasted in improvement 1.
- [`../../docs/writeonce-pl.md`](../../../writeonce-pl.md) — the C/assembly "one address" pedagogy this doc extends to a full runtime. - [`README.md`](../../../../README.md) — the C/assembly "one address" pedagogy the single-binary story extends to a full runtime.

View file

@ -1,6 +1,6 @@
# 02 — The end goal: the writeonce single binary on this runtime environment # 02 — The end goal: the writeonce single binary on this runtime environment
**Context sources:** [`00-plan.md`](./00-plan.md) (the runtime-environment phases, A–B ✅), [`01-architecture.md`](./01-architecture.md) (the one-address trace), [`../../../runtime/wo-language.md`](../../../runtime/wo-language.md) ("one binary per project; no runtime to install on the target host"; `.wo` has "its own lexer, parser, analyzer, and bytecode"), [`../../09-concurrency-scaleout.md`](../../09-concurrency-scaleout.md)–[`12`](../../12-engine-disk-cutover.md) (the Rust product track this proves out), [`../../../runtime/database/02-wo-language.md`](../../../runtime/database/02-wo-language.md) (catalog + transaction semantics the payload carries). **Context sources:** [`00-plan.md`](./00-plan.md) (the runtime-environment phases, A–B ✅), [`01-architecture.md`](./01-architecture.md) (the one-address trace), `../../../runtime/wo-language.md` ("one binary per project; no runtime to install on the target host"; `.wo` has "its own lexer, parser, analyzer, and bytecode"), [`../../09-concurrency-scaleout.md`](../../09-concurrency-scaleout.md)–[`12`](../../12-engine-disk-cutover.md) (the Rust product track this proves out), [`../../../runtime/database/02-wo-language.md`](../../../runtime/database/02-wo-language.md) (catalog + transaction semantics the payload carries).
## The end goal, stated once ## The end goal, stated once
@ -85,6 +85,6 @@ Steps 1–2 and 4–6 exist in `wo-rt-c` today with the notes store standing in
## Cross-references ## Cross-references
- [`00-plan.md`](./00-plan.md) — the kernel phases; [`01-architecture.md`](./01-architecture.md) — the one-address trace through the same stack. - [`00-plan.md`](./00-plan.md) — the kernel phases; [`01-architecture.md`](./01-architecture.md) — the one-address trace through the same stack.
- [`../../../runtime/wo-language.md`](../../../runtime/wo-language.md) — the user-facing single-binary promise this document implements. - [`README.md`](../../../../README.md) — the user-facing single-binary promise this document implements.
- [`../../13-class-model-live-pricing.md`](../../13-class-model-live-pricing.md) (13b methods), [`../../14-mvc-ui-implementation.md`](../../14-mvc-ui-implementation.md) (UI assets) — the payload-side tracks. - [`../../13-class-model-live-pricing.md`](../../13-class-model-live-pricing.md) (13b methods) — the payload-side track.
- [`../../09-concurrency-scaleout.md`](../../09-concurrency-scaleout.md) — shard-key routing and 2PC the contract defers to. - [`../../09-concurrency-scaleout.md`](../../09-concurrency-scaleout.md) — shard-key routing and 2PC the contract defers to.

View file

@ -1,6 +1,6 @@
## Linux Kernel Features ## Linux Kernel Features
Kernel primitives that the writeonce binary can leverage, mapped to the architectural needs identified in [docs/01-problem.md](../../../01-problem.md) and [docs/02-recovery.md](../../../02-recovery.md). Kernel primitives that the writeonce binary can leverage, mapped to the architectural needs identified in [docs/01-problem.md](../../../01-problem.md) and docs/02-recovery.md.
### Per-primitive reference cards ### Per-primitive reference cards

View file

@ -1,213 +0,0 @@
# UI track — `.htmlx` live templates + Angular-style monorepo
**Context sources:** [`docs/examples/ecommerce/ui/`](../../examples/ecommerce/ui/) (current `##ui` screens — storefront, order_tracker, admin_orders), [`docs/examples/ecommerce/types/`](../../examples/ecommerce/types/) + [`docs/examples/ecommerce/logic/`](../../examples/ecommerce/logic/) (the shared-schema + shared-fn anchor), [`reference/crates/wo-htmlx/`](../../../../.dev/reference/crates/wo-htmlx/) (v1 template engine — `{{path}}`, `{{#each}}`, `{{> partial}}`, `data-bind` attributes), [`templates/`](../../../../templates/) (v1 blog's concrete `.htmlx` usage), [`docs/runtime/database/06-lowcode-fullstack.md`](../../../runtime/database/06-lowcode-fullstack.md) (Phase 6's `##ui` + `##app` block spec).
## Context
Three threads converge into one plan:
1. **`##ui` needs a concrete output format.** Phase 6's spec says screens "compile to a render tree" served as SSR HTML with a thin client runtime, but the actual template format isn't named. The v1 `.htmlx` engine at [`reference/crates/wo-htmlx/`](../../../../.dev/reference/crates/wo-htmlx/) already speaks `{{bindings}}`, `{{#each}}`, `{{> partials}}`, and `data-bind` attributes — it's 90% of what the new runtime needs and already has a working parser + renderer. Adopting it (and extending it with live-subscription semantics) is cheaper than inventing a new format.
2. **The samples want a home that matches how real frontends are organised.** The ecommerce sample today is one flat directory with `types/`, `logic/`, and `ui/` beside each other. A real deployment has *multiple apps* against the same data: a customer storefront, an admin dashboard, a fulfillment console, maybe a read-only analytics viewer. Each has its own routes, its own policies, its own ideal binary shape. Angular (via Nx / Angular CLI workspaces) solved this with `apps/*` + `libs/*` on top of a shared root config — writeonce adopts the same shape.
3. **Each app wants to be its own binary but share a database.** Running storefront and admin as one monolith conflates concerns: a CPU spike in admin stalls customer checkout; an admin auth bug opens customer data paths. Splitting into per-app binaries that share a single database backend (via the Phase-4 native wire protocol) gives blast-radius isolation without duplicating data.
Intended outcome: `docs/examples/ecommerce/` refactors into a workspace with `shared/` + `apps/storefront/` + `apps/admin/`. `wo build apps/storefront` produces a `storefront` binary. `wo db serve` runs the shared database. The apps connect over `wo://…` and serve `.htmlx` SSR pages that subscribe to LIVE queries without a page reload.
## Goal
After this track's sub-phases land:
- A **monorepo workspace** at `docs/examples/ecommerce/` with `shared/{types,logic,components}` + `apps/{storefront,admin}` structure.
- A **per-app binary** for each app under `apps/`: `wo build apps/storefront` → `./target/wo/storefront`, `wo build apps/admin` → `./target/wo/admin`. Each binary includes only its own `##ui` / `##app` / app-local types and logic; shared code compiles in by path reference.
- A **shared DB daemon** (`wo db serve`) — one process, no UI, just the engine and wire-protocol server. Each app binary connects as a client via the Phase-4 native protocol.
- **`.htmlx` as the compiled UI output.** Every `##ui` screen compiles into an `.htmlx` template file that the app binary serves; hand-written `.htmlx` files under `apps/X/ui/*.htmlx` are accepted as a first-class authoring alternative.
- **`.htmlx` subscribes.** A `<wo:live source="...">` subtree in the template registers a LIVE query at page load; a ~20 KB client JS runtime patches DOM nodes on delta frames without reloading the page.
- **Per-app users + policies.** Each app declares its role set in `apps/X/app.wo`; row-level policies on shared types stay global, app-scoped policies layer on top per route.
## Design decisions (locked)
1. **`.htmlx` is the template format; `##ui` is the DSL that emits it.** Authors choose per-screen: declare `##ui #home { source: Product, columns: [...] }` in `.wo` and let the compiler produce `home.htmlx`; OR hand-write `home.htmlx` for a custom page. Both flow through the same `wo-htmlx` renderer.
2. **Extend v1 `.htmlx` with two new constructs** — `<wo:live source="..." key="...">...</wo:live>` (subscription subtree) and `wo:bind="field"` (field-level live binding). The rest of the v1 syntax (`{{path}}`, `{{#each}}`, `{{> partial}}`) carries through unchanged.
3. **One binary per app, shared database process.** Not a shared library, not a monolith. Apps connect via the Phase-4 wire protocol (`wo://host:port`) — the same connection a Go/TS client would use. No in-process shared state between apps; their isolation is enforced by the OS process boundary.
4. **Angular-parallel workspace layout.** `apps/*` for deployable binaries, `shared/*` for libs shared across apps (types, logic, UI components), `wo.toml` at the workspace root. Each app also has its own `wo.toml` that names which `shared/` directories it depends on.
5. **File structure mirrors Angular component organisation.** Each UI screen lives in its own directory: `apps/storefront/ui/home/{home.wo, home.htmlx, home.css}`. Tests go in `home.test.wo`. Matches the Angular component pattern (`home.component.ts`, `home.component.html`, `home.component.scss`).
6. **Per-app routes, not per-screen routes.** `apps/storefront/app.wo` declares route table; each route maps to a `ui.<screen>` declared under `apps/storefront/ui/*/`. Cross-app navigation is an external redirect, not an internal route.
7. **Policy composition.** Global policies live in `shared/types/<type>.wo` next to the `type` declaration (today). App-scoped policies live in `apps/X/app.wo` and AND with the global set — an admin app might relax a storefront policy for ops roles but can never relax beyond what the type's own policy permits.
## Angular parallels — what writeonce copies, what it doesn't
| Angular feature | Writeonce translation | Notes |
| --- | --- | --- |
| `nx workspace` / `angular.json` | Root `wo.toml` with `[workspace] apps = [...], shared = [...]` | Path references, not package registry |
| `apps/<app>/` | `apps/<app>/` with `app.wo` + `ui/` + local `types/` + local `logic/` | 1:1 naming |
| `libs/<lib>/` | `shared/<lib>/` | Used `shared/` instead of `libs/` — matches the more common monorepo idiom (Nx defaults to `libs`, but `shared` is clearer for this audience) |
| `<component>.ts` + `.html` + `.scss` | `<screen>.wo` + `.htmlx` + `.css` under `ui/<screen>/` | One-directory-per-screen |
| `ng build <app>` | `wo build apps/<app>` | Per-app binary output |
| Dependency injection | Service-block resolution — `service rest` blocks in shared types are callable from any app by import | No runtime DI container; bindings are resolved at compile time |
| RxJS observables | LIVE subscription frames on a WebSocket | Declarative `live` attribute instead of imperative `.subscribe(...)` |
| Zone.js change detection | Per-row delta dispatch + field-level `wo:bind` | No full-tree change detection — only the rows/fields the delta names get repainted |
| `HttpClient` | Built-in wire-protocol client inside the app binary | No separate library to import; always present |
### What we don't copy
- **No TypeScript.** Authoring is `.wo` (for logic) + `.htmlx` (for templates) + `.css`. If a page needs bespoke JS interactivity beyond what `wo:bind` covers, a `<script>` tag inside `.htmlx` is fine — but the client runtime itself is vanilla JS, not a framework.
- **No component library split.** Angular's `@angular/core`, `@angular/common`, etc. are a package hierarchy. Writeonce's runtime is one binary; "components" are just shared `.htmlx` partials under `shared/components/`.
- **No decorator metadata / reflect-metadata.** Rust macros + compile-time codegen do the same work.
## Reference materials
Read before writing each sub-phase:
| Source | Why |
| --- | --- |
| [`reference/crates/wo-htmlx/src/parser.rs`](../../../../.dev/reference/crates/wo-htmlx/src/parser.rs) + [`render.rs`](../../../../.dev/reference/crates/wo-htmlx/src/render.rs) | The v1 template engine's exact surface — what parses, what renders, what the AST looks like. ~500 LOC total. |
| [`templates/article.htmlx`](../../../../templates/article.htmlx), [`templates/home.htmlx`](../../../../templates/home.htmlx) | Concrete usage of the v1 format — how `{{path}}` and `data-bind` actually read in real templates. |
| [`docs/examples/ecommerce/ui/{storefront,order_tracker,admin_orders}.wo`](../../examples/ecommerce/ui/) | The `##ui` side — what the declarative DSL promises to produce. These screens are the target of the first compiler pass. |
| [`docs/runtime/database/06-lowcode-fullstack.md`](../../../runtime/database/06-lowcode-fullstack.md) | Phase 6's full-stack block spec — `##ui`, `##app`, `##policy`, `##service`, `##logic` — already designed but not yet compiled. |
| [`docs/runtime/database/04-client-api.md`](../../../runtime/database/04-client-api.md) | Phase 4's wire protocol — what the per-app binary speaks to the shared DB over. |
| [Nx monorepo docs](https://nx.dev/concepts/more-concepts/why-monorepos) | Background on the apps/libs split pattern; shape of `nx.json`. |
## Target layout
```
docs/examples/ecommerce/
├── wo.toml # workspace — lists apps, names the shared DB port
├── shared/ # imported by any apps that need it
│ ├── types/
│ │ ├── customer.wo # Customer + role union + policy read/write
│ │ ├── product.wo # Product + inventory + similar_to graph
│ │ ├── order.wo # Order + line_items + lifecycle triggers
│ │ └── purchase.wo # link Customer -> Product
│ ├── logic/
│ │ └── checkout.wo # fn checkout / mark_paid / mark_shipped
│ └── components/ # reusable .htmlx partials
│ ├── layout.htmlx # top-level page chrome
│ ├── header.htmlx
│ ├── money.htmlx # {{> money amount=x}} → $x.xx
│ └── order-row.htmlx # used by both storefront and admin
├── apps/
│ ├── storefront/ # customer-facing; no admin routes
│ │ ├── wo.toml # declares `shared = ["../shared"]`
│ │ ├── app.wo # routes: / → home, /product/:sku → product-detail
│ │ ├── logic/
│ │ │ └── cart.wo # app-local: fn add_to_cart, fn remove_from_cart
│ │ ├── types/
│ │ │ └── cart.wo # type Cart { lines: [CartLine], ... } — not shared
│ │ └── ui/
│ │ ├── home/
│ │ │ ├── home.wo # ##ui #home — declarative spec
│ │ │ ├── home.htmlx # optional hand-written override
│ │ │ └── home.css
│ │ └── product-detail/
│ │ └── product-detail.wo
│ └── admin/
│ ├── wo.toml
│ ├── app.wo # routes: /orders → orders, /inventory → inventory; gated role Admin|Ops
│ ├── logic/
│ │ └── fulfillment.wo # app-local: fn ship_order calls shared.mark_shipped
│ └── ui/
│ ├── orders/
│ │ ├── orders.wo # ##ui #admin-orders, live: true
│ │ └── orders.htmlx # hand-tuned layout overrides the auto-generated
│ └── inventory/
│ └── inventory.wo
└── tests/ # workspace-level integration
└── cross-app.test.wo # a checkout from storefront visible in admin live feed
```
Compile outputs:
```
target/wo/
├── storefront # ~12 MB static binary — app.wo compiled + ui/ templates baked in
├── admin # ~12 MB static binary
└── db # the `wo db` server (shared by all apps)
```
## `.htmlx` with live subscriptions — target format
v1 carries forward unchanged:
```htmlx
<h1>{{article.title}}</h1>
<ul>
{{#each articles}}
<li><a href="/article/{{slug}}">{{title}}</a></li>
{{/each}}
</ul>
{{> layout.footer}}
```
Two new constructs for live:
```htmlx
<!-- Subtree bound to a LIVE query; client subscribes at page load -->
<wo:live source="Order{ status != Cancelled }" sort="placed_at desc" key="id">
<table class="orders">
<thead><tr><th>#</th><th>Status</th><th>Customer</th><th>Total</th></tr></thead>
<tbody>
{{#each rows}}
<tr data-key="{{id}}">
<td>{{id}}</td>
<td wo:bind="status" class="status-{{status}}">{{status}}</td>
<td>{{customer.name}}</td>
<td>{{> money amount=total}}</td>
</tr>
{{/each}}
</tbody>
</table>
</wo:live>
```
Semantics:
- `<wo:live source="...">` emits a `LIVE <source>` query registration at SSR time. The initial result renders the `{{#each rows}}` body.
- The compiler also emits a JSON manifest (in a `<script data-wo-manifest>` tag) telling the client runtime which DOM id maps to which row key and what fields are `wo:bind`-ed.
- On page load, the client runtime opens a WebSocket back to the app, subscribes, and processes delta frames: `Insert` appends a row, `Update` finds `[data-key="<id>"]` and replaces `wo:bind`-ed cells, `Delete` removes the row.
- `wo:bind="field"` on any element tells the runtime "this element's text content reflects `row.field`"; delta Updates patch it in place.
## Sub-phase sequence
Each lands as its own plan doc under `docs/plan/ui/`. No code yet — this master plan outlines the order.
| # | File | Goal |
| --- | --- | --- |
| `01` | `01-htmlx-format-spec.md` | Nail down the exact `.htmlx` grammar — everything v1 has plus `<wo:live>` and `wo:bind`. Includes a manifest-emission spec so the client knows what to subscribe to. |
| `02` | `02-ui-compiler.md` | `##ui` → `.htmlx` compiler. Walks the parsed Phase-6 block and emits the template with the right `<wo:live>` / `{{#each}}` / `wo:bind` skeleton. Falls back gracefully when a hand-written `.htmlx` exists beside the `.wo`. |
| `03` | `03-client-runtime.md` | ~20 KB vanilla-JS runtime bundled with the app binary. Parses `<script data-wo-manifest>`, opens WebSocket, handles `snapshot`/`insert`/`update`/`delete` frames, patches DOM by `data-key` + `wo:bind`. |
| `04` | `04-workspace-layout.md` | Concrete refactor of `docs/examples/ecommerce/` from the current flat shape into `shared/` + `apps/*`. Defines `wo.toml` workspace grammar. |
| `05` | `05-per-app-binaries.md` | `wo build apps/X` produces one static binary per app. Each contains its own types/logic/ui + the shared dirs it imports. Shared DB connection is configured via `WO_DB` env var. |
| `06` | `06-shared-db-daemon.md` | `wo db serve` — headless database daemon. Per-app authn (API key per app), per-app connection scope. Apps see only types their policy allows. |
| `07` | `07-per-app-policies.md` | App-scope policy composition — global `policy read ...` on a type AND app-local `policy` in `app.wo` ⇒ effective policy = AND of both. Admin app's relaxations, storefront's restrictions. |
## Verification
After all seven sub-phases land:
| Target | Measure |
| --- | --- |
| `wo build apps/storefront` succeeds, produces one static binary | `file target/wo/storefront` → ELF, `ldd` shows only libc |
| `wo build apps/admin` succeeds | same |
| `wo db serve` + `storefront --db wo://localhost:5555` + `admin --db wo://localhost:5555` all run simultaneously | three processes, three ports, one data directory |
| Admin live orders table updates within 100 ms of a checkout on the storefront | Open `/admin/orders` in a browser, fire `POST /api/fn/checkout` against storefront's wire port, observe DOM patch |
| Customer's Order visible in their storefront order tracker but not to other customers; admin sees all | Policy round-trip |
| `wo run apps/storefront` serves hand-written `home.htmlx` if present, falls back to `##ui #home` generation if not | File-presence-based dispatch |
| `curl http://localhost:8080/healthz` from each app process | `200 ok` — standard liveness across the tracks |
## Non-scope
- **No TypeScript, no JSX.** `.htmlx` is HTML + Mustache + two `wo:` tags. The client runtime is 500 lines of vanilla JS.
- **No build-time Angular-style bundling.** No Webpack, no esbuild, no tree-shaking. The client JS is a pre-compiled static artifact inside each app binary.
- **No hot module reload in production.** `wo dev` reloads in development (inotify watches `apps/*/ui/`); production binaries don't self-reload.
- **No cross-app shared session state.** Each app authenticates independently. Shared identity is the customer row in the shared DB — both apps see the same user, but each app issues its own session token.
- **No dynamic shared-library linking between apps.** Sharing happens at source level (`shared/` dirs imported by path). Each binary is a fully-static blob.
- **No React/Vue compatibility layer.** If a downstream app wants those, they sit outside the writeonce runtime and talk to the shared DB over the wire protocol — same as any other client.
## Cross-references
- [`../09-concurrency-scaleout.md`](../../09-concurrency-scaleout.md) — when the shared DB daemon needs to handle 10k connections across multiple apps, that plan's thread-per-core model applies to the daemon process.
- [`../assembly/02-writeonce-stance.md`](../assembly/02-writeonce-stance.md) — still no asm. The client runtime is vanilla JS, no WASM.
- [`../../runtime/database/06-lowcode-fullstack.md`](../../../runtime/database/06-lowcode-fullstack.md) — Phase 6's full-stack block spec that this track implements.
- [`../../runtime/database/04-client-api.md`](../../../runtime/database/04-client-api.md) — the wire protocol per-app binaries speak to the shared DB over.
- [`../../examples/ecommerce/ui/admin_orders.wo`](../../examples/ecommerce/ui/admin_orders.wo) — the motivating workload: a live ops table bound to the order stream.
- [`reference/crates/wo-htmlx/`](../../../../.dev/reference/crates/wo-htmlx/) — the template engine ~90% of this track will reuse.
- [`templates/`](../../../../templates/) — v1 blog's actual `.htmlx` files; the format this track extends.

View file

@ -1,111 +0,0 @@
# 01 — `.htmlx` format spec
**Context sources:** [`./00-overview.md`](./00-overview.md) §§ "`.htmlx` with live subscriptions — target format" (L127–166), "Design decisions" (L28–37), [`reference/crates/wo-htmlx/`](../../../../.dev/reference/crates/wo-htmlx/) (the v1 template engine that 90% of this phase ports), [`templates/article.htmlx`](../../../../templates/article.htmlx) and [`templates/home.htmlx`](../../../../templates/home.htmlx) (v1 concrete usage), [`docs/examples/ecommerce/shared/components/order-row.htmlx`](../../../examples/ecommerce/shared/components/order-row.htmlx) (the live-binding workload this format must serve).
## Goal
Lock the exact `.htmlx` grammar — every v1 Mustache construct unchanged plus two new constructs: a `<wo:live source=… key=…>…</wo:live>` subscription subtree and a `wo:bind="field"` field-level live attribute — and define the JSON manifest emitted in a `<script data-wo-manifest>` so the client runtime (phase 03) knows which DOM nodes map to which subscription rows and fields.
## Design decisions (locked)
1. **Mustache constructs carry through unchanged.** `{{path}}`, `{{#each xs as y}}…{{/each}}`, `{{#if cond}}…{{/if}}`, `{{#when cond}}…{{/when}}`, `{{> partial arg=val}}`. The v1 parser already handles all of these; the new parser inherits them verbatim. See [`reference/crates/wo-htmlx/src/parser.rs`](../../../../.dev/reference/crates/wo-htmlx/src/parser.rs) (173 LOC) and the AST in [`ast.rs`](../../../../.dev/reference/crates/wo-htmlx/src/ast.rs) (18 LOC).
2. **`<wo:live>` is a parsed structured node, not HTML passthrough.** The parser recognises the `<wo:` prefix, captures attributes (`source`, `key`, optional `sort`, `filter`), and recursively parses the body as a normal `.htmlx` subtree. No nesting in this phase — error at parse if a `<wo:live>` contains another `<wo:live>`.
3. **`wo:bind="field"` is an HTML attribute, parsed but emitted verbatim.** SSR writes the attribute through; the consumer is the client runtime. The parser records each `(element, field)` pair into the manifest; nothing else changes about element rendering.
4. **Helpers are a closed Rust enum.** v1 invocation forms (`{{relative ts}}`, `{{#if (eq for "ops")}}`, `{{> money amount=x}}`) carry through. The registered set is fixed for this phase: `relative`, `eq`, `markdown`, `code`, `money`, `tag-chips`, `pill`, `image`, `stock-badge`, `list`. No author extensibility.
5. **Manifest is one JSON object per page.** Emitted as `<script type="application/json" data-wo-manifest>{ … }</script>` and consumed only by phase 03's client runtime. Schema below; `version: 1` is a constant for this phase.
## Scope
### New files inside `crates/ui/src/htmlx/`
| File | Responsibility | Port source |
| --- | --- | --- |
| `mod.rs` | Re-exports `Template`, `Manifest`, `LiveSubscription`, `BindSite`, `ParseError`, `RenderError` | [`reference/crates/wo-htmlx/src/lib.rs`](../../../../.dev/reference/crates/wo-htmlx/src/lib.rs) (11 LOC) |
| `ast.rs` | Adds `Node::Live { attrs, body }` and `wo_bind: Option<String>` on element nodes | [`reference/crates/wo-htmlx/src/ast.rs`](../../../../.dev/reference/crates/wo-htmlx/src/ast.rs) (18 LOC) — extend by ~50 LOC |
| `parser.rs` | Adds `<wo:` prefix recognition + attribute capture; rest unchanged | [`reference/crates/wo-htmlx/src/parser.rs`](../../../../.dev/reference/crates/wo-htmlx/src/parser.rs) (173 LOC) — extend by ~90 LOC |
| `value.rs` | Path resolution against a context Value | [`reference/crates/wo-htmlx/src/value.rs`](../../../../.dev/reference/crates/wo-htmlx/src/value.rs) (122 LOC) — copied verbatim |
| `registry.rs` | Closed helper-fn registry | [`reference/crates/wo-htmlx/src/registry.rs`](../../../../.dev/reference/crates/wo-htmlx/src/registry.rs) (121 LOC) — extend by ~60 LOC for new helpers |
| `render.rs` | Emits HTML; wraps `<wo:live>` body in `<div data-wo-subscription="…">` for the runtime | [`reference/crates/wo-htmlx/src/render.rs`](../../../../.dev/reference/crates/wo-htmlx/src/render.rs) (140 LOC) — extend by ~70 LOC |
| `manifest.rs` | Walks the AST, collects subscriptions + bind sites, serialises JSON | new (~150 LOC) |
Total: ~835 LOC (585 ported + ~250 new).
### `Cargo.toml` change
```toml
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
```
(Both already in `crates/rt`; `crates/ui` adopts them rather than hand-rolling JSON for the manifest at this phase.)
### Manifest schema
```json
{
"version": 1,
"subscriptions": [
{
"id": "orders-live-0",
"source": "Order{ status != Cancelled }",
"key": "id",
"sort": "placed_at desc",
"filter": null,
"root_selector": "[data-wo-subscription=\"orders-live-0\"]"
}
],
"bind_sites": [
{ "subscription_id": "orders-live-0", "key": "id", "field": "status" },
{ "subscription_id": "orders-live-0", "key": "id", "field": "total" }
]
}
```
## API shape (target)
```rust
use ui::htmlx::{Template, Manifest, HelperRegistry};
let tmpl = Template::parse(src)?; // Result<Template, ParseError>
let html = tmpl.render(&ctx, &registry)?; // Result<String, RenderError>
let mani = tmpl.manifest(); // Manifest
// SSR pattern: page = head + html + "<script data-wo-manifest>" + mani.to_json() + "</script>"
```
## Exit criteria
1. `cargo build -p ui` green; no new top-level dependencies beyond `serde`/`serde_json`.
2. **Golden parse + render** for every `.htmlx` file under [`docs/examples/blog/ui/components/`](../../examples/blog/ui/components/) and [`docs/examples/ecommerce/shared/components/`](../../examples/ecommerce/shared/components/) — output is HTML and parses back into an isomorphic AST.
3. **Manifest emission** for `<wo:live source="Order{ status != Cancelled }" key="id" sort="placed_at desc">…</wo:live>` produces a `LiveSubscription` with the source string preserved verbatim and the body wrapped under `data-wo-subscription="orders-live-0"`.
4. **Bind-site collection** for `<td wo:bind="status">{{status}}</td>` inside `<wo:live>` records `(subscription_id, key="id", field="status")` once and only once.
5. **v1 regression**: `cargo run --bin wo -- run docs/examples/blog` continues to start without parser errors. The blog sample has no `<wo:live>` or `wo:bind` today; nothing should regress.
6. All 14 existing `crates/rt` tests pass.
## Non-scope
- **No SSR.** Phase 02 emits templates; phase 03 ships the runtime; serving them is a downstream concern that the per-app binary in phase 05 wires together.
- **No nested `<wo:live>`.** Parse error in this phase. Author can compose live subtrees by partial inclusion (`{{> child}}`).
- **No author-extensible helpers.** The closed enum is the contract for this phase.
- **No streaming render.** Templates are rendered to a single `String`.
- **No manifest version negotiation.** Wire format is `version: 1` always.
## Verification
```bash
cargo build -p ui
cargo test -p ui --test golden_v1 # blog/ecommerce templates byte-identical
cargo test -p ui --test golden_extensions # <wo:live> + wo:bind cases
cargo test -p ui --test manifest # manifest emission
# v1 regression
cargo run --bin wo -- run docs/examples/blog &
PID=$!; sleep 1; curl -fsS http://127.0.0.1:8080/ >/dev/null; kill $PID
cd reference/crates && cargo build && cargo test
```
## After this phase
Phase 02 (`02-ui-compiler.md`) consumes the `Template` + `Manifest` types defined here as its emission target — every `##ui` block compiles down to an `.htmlx` file that parses cleanly under this phase's parser. Phase 03 (`03-client-runtime.md`) consumes the manifest JSON schema as its wire input.

View file

@ -1,95 +0,0 @@
# 02 — `##ui` → `.htmlx` compiler
**Context sources:** [`./00-overview.md`](./00-overview.md) §§ "Sub-phase sequence" (L172–180), "Design decisions" 1–6, [`./01-htmlx-format-spec.md`](./01-htmlx-format-spec.md) (the emission target), [`../../runtime/database/06-lowcode-fullstack.md`](../../../runtime/database/06-lowcode-fullstack.md) (the `##ui` block spec), [`docs/examples/blog/ui/article_list.wo`](../../../examples/blog/ui/article_list.wo), [`docs/examples/blog/ui/article_detail.wo`](../../../examples/blog/ui/article_detail.wo), [`docs/examples/ecommerce/apps/admin/ui/orders/orders.wo`](../../examples/ecommerce/apps/admin/ui/orders/orders.wo) (the test corpus), [`crates/rt/src/parser.rs:80–116`](../../../../crates/rt/src/parser.rs) (the parse-and-discard call site to replace).
## Goal
Walk a parsed `##ui` block — every key listed in 00-overview's locked grammar (`title`, `source`, `live`, `role`, `filter`, `quick-filters`, `columns`, `sort`, `actions`, `pagination`, `refresh`, `highlight-new`, `key`, `sections`, `inputs`, `use`, `with`, `template`, `styles`) — and emit a complete `.htmlx` template that parses under phase 01's grammar. When a hand-written `<screen>.htmlx` sits beside the `.wo`, the compiler honours it and only validates that the manifest still aligns.
## Design decisions (locked)
1. **File-presence dispatch.** If `apps/<X>/ui/<screen>/<screen>.htmlx` exists alongside `<screen>.wo`, the hand-written file wins. The compiler still emits a manifest; it errors if the manifest's `bind_sites` reference fields the hand-written template doesn't expose. Anchored in [`./00-overview.md`](./00-overview.md) L24, L30, L46.
2. **`live: true` ⇒ `<wo:live>` wrapper.** The compiler emits `<wo:live source="<source>" key="<key|id>" sort="<sort.default|nothing>" filter="<filter|nothing>">` around the auto-generated `<table>`. `key` defaults to `id` when not declared.
3. **`renderer: <name>` ⇒ helper invocation by rule table.** A static rule table maps each renderer name to its emission form: `markdown` → `{{markdown <field>}}`, `money` → `{{> money amount=<field>}}`, `relative-date` → `{{relative <field>}}`, `code` / `tag-chips` / `pill` / `image` / `stock-badge` / `list` similarly. The set is closed and matches phase 01's helper registry.
4. **`actions: row-* / bulk-*` ⇒ `data-action` + `data-role` attributes.** Click → POST `/api/fn/<fn>`. Role gating is a `data-role="<set>"` attribute the runtime hides on; phase 07 wires the server-side check.
5. **Generated templates land in `target/wo/<app>/ui/<screen>.htmlx`.** Same path the per-app binary in phase 05 reads from at startup. Build artefact, not committed.
6. **`crates/rt/src/parser.rs:80–88` no longer skips `##ui`.** The `Kind::HashHash` arm parses into a `Screen` IR (this phase's new type). All other `##` blocks (`##app`, `##component`) keep their current `skip_top_level_chunk` behaviour for now — `##app` is owned by phase 05 and `##component` parses inline as a sibling of `##ui` but emits no template (it's a partial that other screens reference).
## Scope
### New files inside `crates/ui/src/compiler/`
| File | Responsibility | Port source |
| --- | --- | --- |
| `mod.rs` | Re-exports `compile_screen`, `Screen`, `Column`, `Action`, `CompileError` | new (~30 LOC) |
| `screen.rs` | `Screen` IR — every key listed in the goal section above | new (~150 LOC) |
| `codegen.rs` | Walk `Screen` → emit `.htmlx` source string | new (~280 LOC) |
| `renderers.rs` | Closed `renderer:` → helper-emission rule table | new (~120 LOC) |
| `fallback.rs` | File-presence dispatch + manifest cross-check | new (~80 LOC) |
### Modified file
| File | Change | Notes |
| --- | --- | --- |
| `crates/rt/src/parser.rs` | Replace the `Kind::HashHash(_)` skip arm at L80–88 with a real parse into `Screen` when the tag is `ui` | +60 LOC delta |
Total: ~720 LOC (all new — the v1 codebase has no `##ui` precedent to port).
### `Cargo.toml` change
`crates/ui` already depends on `serde`/`serde_json` from phase 01. No new deps.
## API shape (target)
```rust
use ui::compiler::{compile_screen, compile_app, Screen};
let screen: Screen = ql::parse_ui_block(src)?;
let template: String = compile_screen(&screen, &ctx)?; // an .htmlx string
let mani = ui::htmlx::Template::parse(&template)?.manifest();
// Whole-app pipeline used by phase 05's `wo build`:
let outputs: Vec<(PathBuf, String)> = compile_app(&app_dir)?;
for (path, src) in outputs { fs::write(path, src)?; }
```
## Exit criteria
1. `cargo build -p ui` and `cargo build -p rt` green.
2. **Compile every sample `##ui`** in the test corpus: blog `article_list.wo`, blog `article_detail.wo`, ecommerce `apps/storefront/ui/home/home.wo`, `apps/storefront/ui/orders/orders.wo`, `apps/admin/ui/orders/orders.wo`. Output template parses cleanly under phase 01.
3. **Hand-written fallback honoured.** With a hand-written `apps/admin/ui/orders/orders.htmlx` present, the compiler returns its source unchanged but still emits the manifest.
4. **Manifest cross-check fires.** Renaming `body` to `text` in a hand-written template that the `##ui` block expects under `wo:bind="body"` produces a `CompileError::HandWrittenMissingField` diagnostic.
5. **Parser change is non-breaking.** `crates/rt`'s 14 unit tests still pass; `cargo run --bin wo -- run docs/examples/blog` boots and serves REST as before.
6. `cd reference/crates && cargo build && cargo test`.
## Non-scope
- **No SSR.** This phase emits files only — the runtime in phase 05 reads them at startup.
- **No author-defined renderers.** The closed table from phase 01's helper registry is the contract.
- **No build-output caching.** The compiler runs on every `wo build`. Caching is a future concern.
- **No partial recovery.** First parse or codegen error aborts compilation for the screen; whole-app compilation reports per-screen status.
- **No `##component` codegen in this phase.** Components remain their existing partial-include shape (`{{> name args}}`) — only `##ui` screens drive new emission.
## Verification
```bash
cargo build -p ui -p rt
cargo test -p ui --test compile_blog
cargo test -p ui --test compile_ecommerce
cargo test -p ui --test fallback_handwritten
# end-to-end: emit templates for the storefront and inspect them
cargo run --bin wo -- build docs/examples/ecommerce/apps/storefront --emit-templates-only
ls target/wo/storefront/ui/ # home.htmlx, orders.htmlx
head -1 target/wo/storefront/ui/orders.htmlx # starts with <wo:live source="Order{ … }">
# v1 regression
cargo run --bin wo -- run docs/examples/blog &
PID=$!; sleep 1; curl -fsS http://127.0.0.1:8080/ >/dev/null; kill $PID
cd reference/crates && cargo build && cargo test
```
## After this phase
Phase 03 (`03-client-runtime.md`) is now unblocked: every screen has a manifest the client runtime can read. Phase 04 (`04-workspace-layout.md`) places the compiled outputs under `target/wo/<app>/ui/`, which phase 05 then bakes into the per-app binary.

View file

@ -1,109 +0,0 @@
# 03 — Client runtime
**Context sources:** [`./00-overview.md`](./00-overview.md) §§ "`.htmlx` with live subscriptions — target format" (L127–166) and decisions 1–2, [`./01-htmlx-format-spec.md`](./01-htmlx-format-spec.md) (the manifest schema this runtime consumes), [`reference/crates/wo-sub/src/lib.rs`](../../../../.dev/reference/crates/wo-sub/src/lib.rs) (the v1 frame model the wire format mirrors), [`docs/examples/ecommerce/shared/components/order-row.htmlx`](../../../examples/ecommerce/shared/components/order-row.htmlx) (the live workload the runtime must update without reload).
## Goal
Ship a ~500-line vanilla-JS client at `crates/ui/assets/wo-runtime.js` that, on page load, reads the `<script data-wo-manifest>` JSON, opens one WebSocket back to the originating app, subscribes to every declared `LiveSubscription`, and patches DOM in place on `Insert`/`Update`/`Delete` frames — without a framework, without a build step, served as a static asset at `/_wo/runtime.js`.
## Design decisions (locked)
1. **Vanilla JS, no transpiler.** The file shipped is the file written. Anchored in [`./00-overview.md`](./00-overview.md) L25, L198–199.
2. **JSON over WebSocket.** Frame schema mirrors `reference/crates/wo-sub` semantics evolved into this phase's predicate-subscription model. `{ subscription_id, kind: "snapshot"|"insert"|"update"|"delete", key, row|fields }`.
3. **Targeted DOM patching, not virtual-DOM.** `update` ⇒ `document.querySelectorAll('[data-wo-subscription="<id>"] [data-key="<k>"] [wo\\:bind="<f>"]')` ⇒ `el.textContent = row[f]`. Matches the Zone-less Angular note in 00-overview decision 9.
4. **Reconnect = full snapshot resync.** On reconnect the runtime re-subscribes and replaces each `<wo:live>` body with the fresh snapshot. No diff, no replay buffer.
5. **Backpressure = drop all but latest update per `data-key`.** A coalescing queue keyed by `(subscription_id, key)` collapses queued `update` frames; the latest wins. New frames of other kinds (`insert`/`delete`) flush the queue.
6. **Asset baked into binary, served at `/_wo/runtime.js`.** `crates/ui/src/runtime/asset.rs` does `pub const RUNTIME_JS: &[u8] = include_bytes!("../../assets/wo-runtime.js");`. Phase 05 mounts the route in the per-app binary.
## Scope
### New files
| File | Responsibility | Port source |
| --- | --- | --- |
| `crates/ui/assets/wo-runtime.js` | The runtime — reads manifest, opens WS, dispatches frames, patches DOM | new (~500 LOC JS) |
| `crates/ui/src/runtime/mod.rs` | Re-exports `RUNTIME_JS`, `runtime_etag()`, `Frame` | new (~20 LOC) |
| `crates/ui/src/runtime/asset.rs` | `include_bytes!` of the JS + sha256 ETag | new (~30 LOC) |
| `crates/ui/src/runtime/frame.rs` | Wire-frame `enum Frame` mirroring the JS schema | new (~120 LOC) |
Total: ~170 LOC Rust + ~500 LOC JS.
### `Cargo.toml` change
```toml
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10" # ETag for the runtime asset
```
`serde` / `serde_json` were already pulled in by phase 01. `sha2` is new for the asset ETag.
### Wire frame schema (mirrored in JS and Rust)
```json
{ "kind": "snapshot", "subscription_id": "orders-live-0", "rows": [ {…}, … ] }
{ "kind": "insert", "subscription_id": "orders-live-0", "key": "42", "row": {…} }
{ "kind": "update", "subscription_id": "orders-live-0", "key": "42", "fields": { "status": "Paid" } }
{ "kind": "delete", "subscription_id": "orders-live-0", "key": "42" }
```
## API shape (target)
```rust
use ui::runtime::{RUNTIME_JS, runtime_etag, Frame};
// Phase-05 router mounts:
router.route(Method::GET, "/_wo/runtime.js", |_req| {
Response::ok()
.header("Content-Type", "application/javascript")
.header("ETag", runtime_etag())
.body(RUNTIME_JS.to_vec())
});
// Phase-06 wire-protocol handler emits frames:
let frame = Frame::Update { subscription_id: id.into(), key: k.into(), fields: patch };
ws.send_text(serde_json::to_string(&frame)?)?;
```
## Exit criteria
1. `cargo build -p ui` green; `wc -c crates/ui/assets/wo-runtime.js` ≤ 25 600 bytes (25 KB cap, slack on the 20 KB target).
2. **Frame round-trip test.** A Rust unit test serialises one of each `Frame` variant; a Node-driven test (`node --test`) parses the same JSON, asserts shape.
3. **DOM-patch test (jsdom).** `node crates/ui/runtime-tests/run.mjs` loads a stub HTML containing one `<wo:live>` block and a manifest, fakes a WebSocket emitting `snapshot` → `insert` → `update` → `delete` frames, and asserts each patch hits the right element.
4. **Reconnect test.** Killing the fake WS triggers exponential backoff; on resume the runtime re-issues subscriptions and replaces the body with the new snapshot.
5. **Asset served.** Once phase 05 lands, `curl http://127.0.0.1:8080/_wo/runtime.js` returns the file with a stable `ETag` matching `sha256(RUNTIME_JS)`.
6. `cd reference/crates && cargo build && cargo test`.
## Non-scope
- **No browser test matrix.** jsdom is the only target; Playwright/headless-Chrome are deferred.
- **No optimistic UI.** Action buttons (`data-action="…"`) POST and wait — no client-side state mutation before the server confirms.
- **No client-side routing.** Page navigation is full-page reload.
- **No framework integration.** No React/Vue/Solid bindings.
- **No gzip / Brotli.** The JS ships uncompressed; HTTP-level compression is a phase-08-style concern.
- **No `<noscript>` fallback.** Pages with `<wo:live>` without JS show the SSR snapshot frozen.
## Verification
```bash
cargo build -p ui
cargo test -p ui --test wire_frames
# JS unit test (jsdom) — repo will need node ≥ 20
node crates/ui/runtime-tests/run.mjs
# Size budget
wc -c crates/ui/assets/wo-runtime.js
test "$(wc -c < crates/ui/assets/wo-runtime.js)" -le 25600
# v1 regression
cargo run --bin wo -- run docs/examples/blog &
PID=$!; sleep 1; curl -fsS http://127.0.0.1:8080/ >/dev/null; kill $PID
cd reference/crates && cargo build && cargo test
```
## After this phase
Phases 01 + 02 + 03 together cover the prototype demo path: a `##ui` block compiles to `.htmlx`, the SSR pass renders it with a manifest, the runtime opens a WebSocket and patches the DOM on commit. Phase 05 (`05-per-app-binaries.md`) bakes `RUNTIME_JS` into each app binary; phase 06 (`06-shared-db-daemon.md`) is the WebSocket origin that emits the frames defined here.

View file

@ -1,139 +0,0 @@
# 04 — Workspace layout + `wo.toml` grammar
**Context sources:** [`./00-overview.md`](./00-overview.md) §§ "Target layout" (L73–117), "Design decisions" 4–5 (L33–34), [`docs/examples/ecommerce/wo.toml`](../../../examples/ecommerce/wo.toml) (the workspace manifest already in the repo), [`docs/examples/ecommerce/apps/storefront/wo.toml`](../../../examples/ecommerce/apps/storefront/wo.toml) (the per-app manifest already in the repo), [`docs/examples/blog/`](../../examples/blog/) (the degenerate single-app form).
## Goal
Lock the `wo.toml` grammar at both workspace and per-app scope, fill any structural gaps in `docs/examples/ecommerce/` so its layout matches 00-overview's "Target layout" exactly, and ship a Rust workspace loader (`crates/app/src/workspace.rs` + sibling) that walks a workspace root, reads each app's manifest, and resolves `{{> partial}}` lookups across `shared = […]` directories first-match-wins.
## Design decisions (locked)
1. **`wo.toml` is TOML.** Not `.wo`. The workspace + app manifests already exist in the repo using TOML; this phase formalises the schema and adds a parser. Anchored in [`docs/examples/ecommerce/wo.toml`](../../../examples/ecommerce/wo.toml).
2. **Path-based shared dependencies, no registry.** `shared = ["../../shared/types", …]` resolves at parse time relative to the per-app `wo.toml`. No semver, no fetch. Anchored in [`./00-overview.md`](./00-overview.md) decision 4.
3. **Workspace root vs. single app, by `kind`/`app_kind` field.** A `wo.toml` with `kind = "workspace"` triggers workspace loading and reads `[workspace]`. A `wo.toml` with `app_kind = "app"` is a leaf app. A `wo.toml` with neither is a degenerate single-app workspace (the blog example) — loaded as if it were `apps/<itself>`.
4. **One screen per directory.** `apps/<X>/ui/<screen>/{<screen>.wo, <screen>.htmlx, <screen>.css}` is locked layout. The loader walks `apps/<X>/ui/*/` and registers each subdirectory as a screen. Anchored in [`./00-overview.md`](./00-overview.md) decision 5 + Target Layout (L98–103).
5. **Component resolution: app-local first, then shared in declared order.** `{{> money amount=x}}` looks in `apps/<X>/ui/components/` first, then each path in `[dependencies] shared = […]` in order. First match wins. Errors at compile time if the partial cannot be resolved.
## Scope
### New files inside `crates/app/src/`
| File | Responsibility | Port source |
| --- | --- | --- |
| `workspace.rs` | Parse workspace `wo.toml`, walk and load each app | new (~200 LOC) |
| `manifest.rs` | Per-app `wo.toml` schema (`AppManifest`) | new (~150 LOC) |
| `resolver.rs` | Component / type / logic path resolution across `shared = […]` | new (~120 LOC) |
| `mod.rs` | Re-exports `Workspace`, `App`, `AppManifest`, `WorkspaceError`, `ResolverError` | new (~30 LOC) |
Total: ~500 LOC.
### Existing files filled in (not new code)
`docs/examples/ecommerce/` — gap-fill any directories named in 00-overview's "Target layout" that don't yet exist (e.g. `shared/components/header.htmlx`, `apps/storefront/types/cart.wo`). Pure structural work; no runtime change.
### `Cargo.toml` change
```toml
[dependencies]
toml = "0.8"
```
`toml` is the explicit cost of skipping a hand-rolled TOML parser at this phase. The dependency-removal track will revisit when relevant; for prototype, parsing TOML by hand isn't worth the LOC.
### Workspace `wo.toml` schema
```toml
name = "ecommerce-workspace"
version = "0.1.0"
kind = "workspace"
[runtime]
wo = ">= 0.1"
[workspace]
apps = ["apps/storefront", "apps/admin"]
shared = ["shared/types", "shared/logic", "shared/components"]
[database]
listen = "127.0.0.1:5555"
data_dir = "./data"
isolation = "snapshot"
```
### Per-app `wo.toml` schema
```toml
name = "storefront"
version = "0.1.0"
app_kind = "app"
[dependencies]
shared = ["../../shared/types", "../../shared/logic", "../../shared/components"]
[server]
listen = ":8080"
[database]
url = "wo://127.0.0.1:5555"
api_key_env = "STOREFRONT_DB_KEY"
```
## API shape (target)
```rust
use app::{Workspace, App};
let ws = Workspace::load(Path::new("docs/examples/ecommerce"))?;
assert_eq!(ws.apps().len(), 2);
let storefront = ws.app("storefront").unwrap();
let money_partial = storefront.resolve_component("money")?; // shared/components/money.htmlx
let cart_type = storefront.resolve_type("Cart")?; // apps/storefront/types/cart.wo
// Degenerate form:
let blog = Workspace::load(Path::new("docs/examples/blog"))?; // single-app workspace
assert_eq!(blog.apps().len(), 1);
```
## Exit criteria
1. `cargo build -p app` green; the new `toml` dep compiles.
2. **Workspace load.** `Workspace::load(docs/examples/ecommerce)` returns 2 apps + 3 shared dirs and no errors.
3. **Component resolution.** `storefront.resolve_component("money")` returns the path to `shared/components/money.htmlx`. `storefront.resolve_component("nonsense")` errors as `ResolverError::NotFound`.
4. **App-local override.** Adding `apps/storefront/ui/components/money.htmlx` makes `resolve_component("money")` return the app-local path; removing it falls back to the shared one.
5. **Degenerate form.** `Workspace::load(docs/examples/blog)` loads as a one-app workspace; `wo run docs/examples/blog` continues to start unchanged.
6. `cd reference/crates && cargo build && cargo test`.
## Non-scope
- **No semver, no registry, no lockfile.** Path references only.
- **No `wo dev` hot-reload.** File watching against `apps/*/ui/` is deferred (would consume `reference/crates/wo-watch/`).
- **No cross-workspace symlinks.** `shared = […]` paths must resolve under the workspace root.
- **No build-time enforcement that an app touches only its declared shared dirs.** That's an integrity check for a later hardening phase.
- **No env-var interpolation in `wo.toml`.** `${VAR}` syntax stays out; runtime config comes through env vars at startup, not manifest time.
## Verification
```bash
cargo build -p app
cargo test -p app --test workspace_load
cargo test -p app --test resolver
# end-to-end inspection
cargo run --bin wo -- ls-apps docs/examples/ecommerce
# storefront apps/storefront
# admin apps/admin
cargo run --bin wo -- ls-apps docs/examples/blog
# blog .
# v1 regression
cargo run --bin wo -- run docs/examples/blog &
PID=$!; sleep 1; curl -fsS http://127.0.0.1:8080/ >/dev/null; kill $PID
cd reference/crates && cargo build && cargo test
```
## After this phase
The workspace layout is the input that phase 05 (`05-per-app-binaries.md`) compiles into a binary per app, and the namespace within which phase 06 (`06-shared-db-daemon.md`) issues per-app API keys. Phase 07 (`07-per-app-policies.md`) reads `apps/<X>/app.wo` for app-scope policy declarations whose location this phase locks.

View file

@ -1,124 +0,0 @@
# 05 — Per-app static binaries (`wo build apps/X`)
**Context sources:** [`./00-overview.md`](./00-overview.md) §§ "Goal" (L21–23), "Design decisions" 3–4 (L32–33), [`./01-htmlx-format-spec.md`](./01-htmlx-format-spec.md), [`./02-ui-compiler.md`](./02-ui-compiler.md), [`./03-client-runtime.md`](./03-client-runtime.md), [`./04-workspace-layout.md`](./04-workspace-layout.md), [`./06-shared-db-daemon.md`](./06-shared-db-daemon.md) (the wire URL contract this phase consumes), [`docs/examples/ecommerce/apps/storefront/wo.toml`](../../../examples/ecommerce/apps/storefront/wo.toml).
## Goal
`wo build apps/<X>` produces a single static binary at `target/wo/<X>` that contains the app's `##ui` / `##app` blocks compiled to `.htmlx`, the imported `shared/` dirs the app's `wo.toml` names, the vanilla-JS client runtime from phase 03, and a thin `main()` that reads `WO_DB`, opens a wire connection to the shared daemon, and serves the public HTTP listener declared in `[server] listen`.
## Design decisions (locked)
1. **One Cargo build per app, dynamic Cargo project templating.** `wo build apps/<X>` materialises a Cargo project under `target/wo-build/<X>/`, fills `[bin] name = "<X>"`, copies/generates `app_config.rs`, and invokes `cargo build --release`. The resulting binary is copied to `target/wo/<X>`.
2. **No per-app Rust source generation beyond config.** The same `crates/app` is linked into every app binary. The only generated Rust file is `app_config.rs` containing the route table, embedded templates, and embedded runtime asset. Avoids exploding cargo metadata across N apps.
3. **`include_bytes!` bakes templates + runtime + CSS at compile time.** A Cargo `build.rs` writes `app_config.rs` enumerating every compiled `.htmlx`, every `.css` from `apps/<X>/ui/<screen>/<screen>.css` and `shared/components/*.css`, plus the runtime JS via `RUNTIME_JS` from phase 03.
4. **Connection target precedence: `WO_DB` env > `[database].url` from manifest > error.** The app refuses to start if neither is set. Anchored in [`./00-overview.md`](./00-overview.md) "Goal" (L23) and [`docs/examples/ecommerce/apps/storefront/wo.toml`](../../../examples/ecommerce/apps/storefront/wo.toml) L21–26.
5. **HTTP listener address comes from `[server] listen`.** Different from the database URL — the database URL is what this binary connects *to*; `[server].listen` is what the binary itself exposes to browsers. `WO_LISTEN` env var overrides for ops.
## Scope
### New files
| File | Responsibility | Port source |
| --- | --- | --- |
| `crates/app/src/build.rs` | `wo build apps/<X>` driver: template Cargo project, run cargo, copy binary | new (~250 LOC) |
| `crates/app/build.rs` (Cargo build script) | Generates `app_config.rs` with embedded templates + runtime + routes | new (~100 LOC) |
| `crates/app/src/main.rs` | App-binary entrypoint: reads `WO_DB`, opens wire, starts HTTP | new (~150 LOC) |
| `crates/app/src/route_table.rs` | Compile-time route table from `app.wo` routes block | new (~120 LOC) |
| `crates/rt/src/bin/wo.rs` | Wire `wo build <path>` subcommand | modify (+40 LOC) |
Total: ~660 LOC new + ~40 LOC modified.
### `Cargo.toml` change
`crates/app` adds itself as a workspace member that produces a binary. No new external deps beyond what phases 01–04 already brought in.
```toml
[[bin]]
name = "wo-app"
path = "src/main.rs"
```
### Generated `app_config.rs` shape
```rust
// Generated by crates/app/build.rs — do not edit.
pub const APP_NAME: &str = "storefront";
pub const APP_LISTEN: &str = ":8080";
pub const APP_DB_URL: Option<&str> = Some("wo://127.0.0.1:5555");
pub const APP_API_KEY_ENV: &str = "STOREFRONT_DB_KEY";
pub const TEMPLATES: &[(&str, &[u8])] = &[
("home", include_bytes!("../target/wo/storefront/ui/home.htmlx")),
("orders", include_bytes!("../target/wo/storefront/ui/orders.htmlx")),
];
pub const ROUTES: &[(&str, &str, &str)] = &[
("GET", "/", "ui.home"),
("GET", "/orders", "ui.orders"),
];
```
## API shape (target)
```rust
// build-time API used by the wo CLI:
use app::build;
let binary_path: PathBuf = build::build(Path::new("docs/examples/ecommerce/apps/storefront"),
Path::new("target/wo"))?;
// runtime: every app binary's main() looks like this
fn main() -> Result<()> {
let db_url = env::var("WO_DB").ok()
.or(app_config::APP_DB_URL.map(str::to_owned))
.ok_or(BootError::NoDatabase)?;
let api_key = env::var(app_config::APP_API_KEY_ENV)?;
let db = wire::connect(&db_url, &api_key)?;
let r = build_router(&app_config::ROUTES, &app_config::TEMPLATES, db);
rt::http::serve(app_config::APP_LISTEN, r)
}
```
## Exit criteria
1. `cargo build -p app` green; `crates/app` produces the `wo-app` library + the `wo build` driver.
2. **Storefront builds.** `cargo run --bin wo -- build docs/examples/ecommerce/apps/storefront` produces `target/wo/storefront`. `file target/wo/storefront` reports an ELF executable.
3. **Storefront boots.** `WO_DB=wo://127.0.0.1:5555 STOREFRONT_DB_KEY=test ./target/wo/storefront &` then `curl -fsS http://127.0.0.1:8080/healthz` returns `200`. (The DB daemon from phase 06 is mocked or stubbed for this test if 06 hasn't landed yet — refuse-to-start without DB is the contract; the test verifies refuse-to-start when `WO_DB` is unset.)
4. **Admin builds separately.** `wo build apps/admin` produces a *different* binary with a disjoint route table. Diffing the two `app_config.rs` files shows different route lists.
5. **Refuse-to-start without DB.** `./target/wo/storefront` with no `WO_DB` and no manifest URL exits non-zero with a clear error.
6. `cd reference/crates && cargo build && cargo test`.
## Non-scope
- **No cross-compilation.** Linux x86_64 only this phase. `--target` flags are passed through but untested.
- **No musl static linking.** glibc-linked binaries are fine for prototype.
- **No container packaging, no systemd unit generation.**
- **No binary-size optimisation** beyond `--release`. `wo build --strip` is a future flag.
- **No incremental compile cache management.** `target/wo-build/<X>/` is reused across builds but not pruned.
## Verification
```bash
cargo build -p app
# storefront build + boot
cargo run --bin wo -- build docs/examples/ecommerce/apps/storefront
file target/wo/storefront # ELF 64-bit
ls -la target/wo/storefront/ui/ # home.htmlx, orders.htmlx (compiled in phase 02)
# refuse-to-start without WO_DB
./target/wo/storefront 2>&1 | grep -q "WO_DB"
# admin builds independently
cargo run --bin wo -- build docs/examples/ecommerce/apps/admin
test -x target/wo/admin
# v1 regression
cargo run --bin wo -- run docs/examples/blog &
PID=$!; sleep 1; curl -fsS http://127.0.0.1:8080/ >/dev/null; kill $PID
cd reference/crates && cargo build && cargo test
```
## After this phase
`wo build` produces shippable per-app binaries; what they connect to is owned by phase 06 (`06-shared-db-daemon.md`), and the policy gate they enforce on every query is owned by phase 07 (`07-per-app-policies.md`). After 06 + 05 + 07 land together, the prototype demo path closes: `wo db serve` + `wo build apps/storefront` + `wo build apps/admin` running side by side, sharing one data dir, with admin live updates triggered by storefront commits.

View file

@ -1,120 +0,0 @@
# 06 — Shared database daemon (`wo db serve`)
**Context sources:** [`./00-overview.md`](./00-overview.md) §§ "Goal" (L23), "Design decisions" 3 (L32), "Non-scope" (L201–203), [`./03-client-runtime.md`](./03-client-runtime.md) (the wire frames this daemon emits), [`./05-per-app-binaries.md`](./05-per-app-binaries.md) (the apps that connect), [`reference/crates/wo-sub/src/lib.rs`](../../../../.dev/reference/crates/wo-sub/src/lib.rs) (the v1 subscription registry, 470 LOC, that needs generalising past `ByTitle`/`ByTag`/`All`), [`../../runtime/database/04-client-api.md`](../../../runtime/database/04-client-api.md) (the wire-protocol owner).
## Goal
Stand up a headless daemon — `wo db serve` — that runs the engine + WAL + subscription registry behind the Phase-4 native wire protocol on `127.0.0.1:5555`, with no HTTP and no template rendering. Each connection presents an API key from a static table, binds a `Principal { app, roles }` for downstream policy evaluation, and can register `Subscription::ByPredicate` against any type — a generalisation of v1's article-only subscription model that this phase ports and broadens.
## Design decisions (locked)
1. **Daemon = `crates/db` thin entrypoint + `crates/engine` + the wire acceptor.** No HTTP, no `.htmlx`, no `##ui`. The shared DB process knows nothing about the UI layer.
2. **API-key table is in-memory, env-seeded.** On startup the daemon reads `WO_DB_KEY_<APP>=<hex>` for each app declared in the workspace and builds an `AuthTable: HashMap<ApiKey, Principal>`. A `--keys <file>` flag is accepted but treated as a future hook.
3. **Generalise `wo-sub`** from `Subscription::ByTitle/ByTag/All` to `Subscription::ByPredicate(TypeRef, Expr, SortKey)`. The v1 variants stay as legacy aliases (`ByTitle(t)` ⇒ `ByPredicate(Article, sys_title == t, _)`) for the blog regression test. Anchored in [`reference/crates/wo-sub/src/lib.rs`](../../../../.dev/reference/crates/wo-sub/src/lib.rs) L8–17.
4. **Connection scope = `Principal { app, roles }` stored on the connection.** Every query evaluator reads it; phase 07 wires it into policy AND-composition.
5. **One data dir, one engine, many connections.** Snapshot isolation by default (per `[database].isolation = "snapshot"` in the workspace `wo.toml`).
6. **Foreground-only this phase.** No daemonisation, no PID file, no signal handling beyond `SIGTERM` graceful shutdown. A future ops doc can add `wo db daemonize`.
## Scope
### New files
| File | Responsibility | Port source |
| --- | --- | --- |
| `crates/db/src/main.rs` | Entrypoint, arg parsing, env-key loading | new (~100 LOC) |
| `crates/db/src/server.rs` | Wire-protocol acceptor (TCP listener + per-conn handler) | new (~250 LOC) |
| `crates/db/src/auth.rs` | `AuthTable`, `Principal`, key handshake | new (~120 LOC) |
| `crates/sub/src/lib.rs` | Generalised subscription manager | port [`reference/crates/wo-sub/src/lib.rs`](../../../../.dev/reference/crates/wo-sub/src/lib.rs) (470 LOC) + ~150 new |
| `crates/sub/src/predicate.rs` | Predicate evaluation against a row (uses `crates/ql` if available, else minimal subset) | new (~150 LOC) |
Total: ~1240 LOC (470 ported + ~770 new).
### `Cargo.toml` change
`crates/db` becomes a binary target:
```toml
[[bin]]
name = "wo-db"
path = "src/main.rs"
```
Plus deps already in the workspace: `serde`, `serde_json`, optionally `libc` for the listener (matching `crates/rt`'s direction).
### Wire handshake (added to Phase 4 protocol)
```
client → server: HELLO app="storefront" api_key="<hex>"
server → client: WELCOME principal={ app, roles } | ERROR "unauthorised"
```
After `WELCOME`, frames follow the Phase-4 native protocol. Subscription-registration frames carry `ByPredicate(TypeRef, Expr, SortKey)`.
## API shape (target)
```rust
use db::{DbServer, AuthTable, Principal};
use sub::{SubscriptionManager, Subscription};
let mut auth = AuthTable::new();
auth.insert(ApiKey::from_env("WO_DB_KEY_STOREFRONT")?,
Principal { app: "storefront".into(), roles: roles!("Customer") });
auth.insert(ApiKey::from_env("WO_DB_KEY_ADMIN")?,
Principal { app: "admin".into(), roles: roles!("Admin", "Ops") });
let server = DbServer::bind("127.0.0.1:5555", Path::new("./data"), auth)?;
server.run()?; // foreground; SIGTERM exits cleanly
// inside a connection handler:
let sub = Subscription::ByPredicate(
TypeRef::new("Order"),
parse_expr("status != Cancelled")?,
SortKey::new("placed_at", SortDir::Desc),
);
let id = subs.register(conn_fd, sub)?;
```
## Exit criteria
1. `cargo build -p db -p sub` green; `wo-db` binary produced under `target/release/`.
2. **Daemon starts.** `WO_DB_KEY_STOREFRONT=aaaa WO_DB_KEY_ADMIN=bbbb cargo run --bin wo-db -- --listen 127.0.0.1:5555 --data-dir /tmp/wo-test` runs foreground and accepts `SIGTERM`.
3. **Two principals.** Two clients connect, one with each API key; each receives a distinct `Principal` in the `WELCOME` frame.
4. **Predicate subscription.** Client registers `Subscription::ByPredicate(Order, "status != Cancelled", "placed_at desc")`; the manager returns a fresh `subscription_id`; on a stub `Order` insert, the matching client receives an `insert` frame.
5. **v1 regression.** A connection running the legacy `Subscription::ByTitle("hello-world")` against the blog corpus still produces notifications via the legacy alias.
6. `cd reference/crates && cargo build && cargo test`.
## Non-scope
- **TLS.** `wo://` is plaintext this phase. TLS is its own phase later.
- **API-key rotation, revocation, expiry.** Static map only. JWT, mTLS, etc. — out of scope.
- **Multi-data-dir, replication, sharding.** One process, one data dir.
- **WAL changes.** Engine + WAL semantics inherit from the Stage-2 in-memory engine; persistent storage and crash recovery belong to the database series, not this phase.
- **Daemonisation, PID file, systemd integration.** Foreground-only.
- **Metric / structured-log emission.** Plain `eprintln!` traces only.
### Risk to flag in the doc
If `crates/ql` is too thin to evaluate `status != Cancelled` end-to-end at the time this phase lands, lock a minimal predicate subset — `==`, `!=`, `>`, `<`, `&&`, `||` against scalar fields — and document the gap explicitly. Phases that need richer predicates (graph traversals, computed fields) wait for `ql` to mature.
## Verification
```bash
cargo build -p db -p sub
# foreground daemon + two connections
WO_DB_KEY_STOREFRONT=aaaa WO_DB_KEY_ADMIN=bbbb \
cargo run --bin wo-db -- --listen 127.0.0.1:5555 --data-dir /tmp/wo-test &
DB_PID=$!
cargo test -p db --test multi_app_principals
cargo test -p sub --test predicate_subscription
kill $DB_PID
# legacy v1 path
cargo test -p sub --test legacy_by_title
cd reference/crates && cargo build && cargo test
```
## After this phase
The wire URL contract is now stable, which unblocks phase 05 (per-app binaries) connecting to `wo://127.0.0.1:5555`. Phase 07 (`07-per-app-policies.md`) hooks its `EffectivePolicy` resolver into the query path inside this daemon — every query the daemon executes carries the connection's `Principal`, which is exactly what 07's AND-composition needs.

View file

@ -1,105 +0,0 @@
# 07 — Per-app policy composition
**Context sources:** [`./00-overview.md`](./00-overview.md) decision 7 (L36) and "Goal" (L26), [`./04-workspace-layout.md`](./04-workspace-layout.md) (where app-scope policies live), [`./06-shared-db-daemon.md`](./06-shared-db-daemon.md) (where composition is evaluated), [`docs/examples/blog/types/article.wo`](../../../examples/blog/types/article.wo) and the other type files (existing global `policy read/write` blocks), [`docs/examples/ecommerce/apps/admin/ui/orders/orders.wo`](../../examples/ecommerce/apps/admin/ui/orders/orders.wo) L18 (a `role: Admin | Ops` set expression).
## Goal
Layer per-app policy scopes (declared in `apps/<X>/app.wo` and per-`##ui` `role:` clauses) on top of the global per-type policies that already live next to type declarations, enforcing `effective = global AND app_scope`. An app can narrow what its connection sees, never broaden. A static check at `wo build` time refuses any app-scope that names a role outside the type's own policy domain.
## Design decisions (locked)
1. **AND-only composition.** App-scope can narrow, never broaden. Anchored in [`./00-overview.md`](./00-overview.md) decision 7 (L36).
2. **Three carrier locations.** (a) Global `policy read/write` blocks beside `type` declarations in `shared/types/<type>.wo` — already in the language. (b) `policy` blocks inside `apps/<X>/app.wo` — new structured form parsed in this phase. (c) `role: <RoleSet>` clauses inside `##ui` blocks and `actions:` rows — already in samples (e.g. [`docs/examples/ecommerce/apps/admin/ui/orders/orders.wo`](../../examples/ecommerce/apps/admin/ui/orders/orders.wo) L18, L50–53). All three feed the same `EffectivePolicy` composer.
3. **Evaluation point = wire-protocol query handler in phase 06.** When a connection issues a query, the daemon reads `connection.principal`, fetches global + app-scope policies for the touched types, AND-composes, applies. No client-side enforcement; no compile-time inlining.
4. **`role:` is a set expression, not a single string.** `Admin | Ops` parses to `RoleSet::union(Admin, Ops)`. Anchored in [`docs/examples/ecommerce/apps/admin/ui/orders/orders.wo`](../../examples/ecommerce/apps/admin/ui/orders/orders.wo) L18.
5. **Build-time domain check.** `wo build apps/<X>` walks every `role: <set>` referenced by the app and verifies each role is one the global type policy actually defines for that type. An app cannot mention `role: Anonymous` against a type whose global policy never permits anonymous access — that's a build error, not a runtime one.
## Scope
### New files
| File | Responsibility | Port source |
| --- | --- | --- |
| `crates/policy/src/scope.rs` | `AppScope`, `EffectivePolicy`, AND composer | new (~150 LOC) |
| `crates/policy/src/parse.rs` | Parse `role: A \| B` set expressions and `policy` blocks in `app.wo` | new (~100 LOC) |
| `crates/policy/src/check.rs` | Static build-time domain check | new (~100 LOC) |
| `crates/policy/src/mod.rs` | Re-exports | new (~30 LOC) |
### Modified files
| File | Change |
| --- | --- |
| `crates/db/src/server.rs` | Plug `EffectivePolicy::compose(global, app_scope, type)` into the per-query path; pass `connection.principal` through. (+50 LOC) |
| `crates/app/src/build.rs` | Run `policy::check::domain_check(app)` before invoking cargo. (+30 LOC) |
Total: ~460 LOC (all new — no v1 precedent for app-scope policy composition).
### `Cargo.toml` change
None beyond what phases 01–06 already added.
## API shape (target)
```rust
use policy::{GlobalPolicy, AppScope, EffectivePolicy, RoleSet};
// Composition (called per query in the daemon)
let effective = EffectivePolicy::compose(&global_for(&order_type),
&app_scope_for("admin"),
&order_type);
let allowed_rows = effective.filter(rows, &principal);
// Build-time domain check (called by `wo build apps/X`)
policy::check::domain_check(&app)?; // errors if any role: in app references undefined role
// Role set parser (used by both compiler and daemon)
let rs: RoleSet = "Admin | Ops".parse()?;
assert!(rs.contains(Role::Admin));
assert!(rs.contains(Role::Ops));
```
## Exit criteria
1. `cargo build -p policy -p db -p app` green.
2. **AND composition.** Unit test `compose(global = "published == true", app_scope = "owner == $session.id", t = Article)` returns an `EffectivePolicy` whose `allows_read` is true only when *both* clauses hold.
3. **Build-time domain check fires.** A test workspace where `apps/storefront/app.wo` declares `role: Anonymous` against a type whose global policy does not define `Anonymous` — `wo build apps/storefront` exits non-zero with `PolicyDomainError`.
4. **Cross-app integration.** Two storefront customers issue the same `GET /api/orders` against the daemon; each sees only their own rows (storefront app-scope narrows global). Admin sees both. Test runs against the phase-06 daemon.
5. **v1 regression.** `wo run docs/examples/blog` boots; the global `policy read for anyone when published == true` on the blog `Article` type continues to gate anonymous reads as it does today.
6. `cd reference/crates && cargo build && cargo test`.
## Non-scope
- **Row-level write policies.** Read only this phase. Phase-6 spec mentions write composition; defer.
- **Field-level (column-level) policies.** All-or-nothing per row.
- **Audit logging of policy decisions.** No structured emission in this phase.
- **Policy versioning, migration, schema changes.** Whatever the type currently declares is the one definition.
- **Dynamic role assignment.** Roles are static per session; promotion / impersonation flows are out.
## Verification
```bash
cargo build -p policy -p db -p app
cargo test -p policy --test compose
cargo test -p policy --test domain_check
cargo test -p policy --test role_set_parse
# integration: two customers + one admin against the daemon
WO_DB_KEY_STOREFRONT=aaaa WO_DB_KEY_ADMIN=bbbb \
cargo run --bin wo-db -- --listen 127.0.0.1:5555 --data-dir /tmp/wo-pol &
DB_PID=$!
cargo test --test cross_app_policy
kill $DB_PID
# v1 regression — blog policy unchanged
cargo run --bin wo -- run docs/examples/blog &
PID=$!; sleep 1
curl -fsS http://127.0.0.1:8080/api/articles # only published rows
test -z "$(curl -fsS http://127.0.0.1:8080/api/articles | grep '"published":false')"
kill $PID
cd reference/crates && cargo build && cargo test
```
## After this phase
The seven-doc UI track is complete. With phases 01–07 implemented end-to-end, the prototype demo path closes: `wo db serve` runs the shared daemon; `wo build apps/storefront` and `wo build apps/admin` produce two binaries with disjoint route tables and separate API keys; a checkout on the storefront fires a commit that the daemon broadcasts to admin's open `<wo:live>` subscription, the admin client runtime patches the orders table in place, and the same row never crosses storefront's narrower policy back to a different customer's session. After this phase, future hardening — TLS, key rotation, write-side composition, hot reload — gets its own track.

View file

@ -1,81 +0,0 @@
# 08 — MVC structure: model = class, view = htmlx + scss, controller = .wo
**Context sources:** [`reference/writeonce-app/src/app/`](../../../../.dev/reference/writeonce-app/src/app/) (the v1 Angular app whose component anatomy this formalizes), [`./00-overview.md`](./00-overview.md) ("Angular-component-style layout" — `home/{home.wo, home.htmlx, home.css}`), [`./01-htmlx-format-spec.md`](./01-htmlx-format-spec.md) (the view grammar: Mustache + `<wo:live>` + `wo:bind`), [`./02-ui-compiler.md`](./02-ui-compiler.md), [`./03-client-runtime.md`](./03-client-runtime.md), [`../../13-class-model-live-pricing.md`](../../13-class-model-live-pricing.md) (the class methods controllers call), [`../../../examples/pricing/ui/pricing/`](../../../examples/pricing/ui/pricing/) (the reference screen).
## Goal
Every writeonce UI screen follows **Model–View–Controller**, with the same file anatomy the v1 Angular app used — but collapsed into the single binary. One screen = one directory with three files:
```
ui/pricing/
├── pricing.wo # Controller — binds the model into the view, exposes actions
├── pricing.htmlx # View — plain htmlx (Mustache + wo:live/wo:bind), no logic
└── pricing.scss # View styles — external, compiled at `wo build`
```
## The mapping, against the v1 Angular app
| MVC role | v1 Angular (`reference/writeonce-app/src/app/`) | writeonce |
| --- | --- | --- |
| **Model** | `models/article.ts` (interface) + `services/article.service.ts` (HTTP fetch) | the `class` / `type` declaration itself (`types/product.wo`). No service layer: the database is in-process, and a model binding **is** a query — `LIVE select` for push, `select` for snapshot |
| **View** | `article.component.html` + `article.component.css` | `pricing.htmlx` + `pricing.scss`. Plain markup; the only dynamic constructs are Mustache paths and `<wo:live>` / `wo:bind` from [`01-htmlx-format-spec.md`](./01-htmlx-format-spec.md) |
| **Controller** | `article.component.ts` (`@Component({templateUrl, styleUrl})`, fields, methods, `service.subscribe(...)`) | `pricing.wo` — declares `view:` / `styles:` (≈ `templateUrl` / `styleUrl`), a `model:` block (≈ component fields), and an `actions:` block whose handlers **call class methods** |
What Angular needed four layers for (interface, service, component class, template) writeonce does in three files against one runtime — there is no HTTP client between controller and model because there is no network between them.
## Design decisions
1. **The controller is declarative, like everything else in `.wo`.** It does not contain imperative rendering code; it declares *what* is bound and *which* method each action invokes. Shape:
```wo
##ui
#pricing
route: /pricing
view: pricing.htmlx -- ≈ Angular templateUrl
styles: pricing.scss -- ≈ Angular styleUrl
-- Model → View binding. Names declared here are the root scope of
-- the .htmlx file; LIVE bindings re-patch the view on every commit.
model:
products: LIVE select Product{ name, sku, prices }
watchlist: $session.watchlist
-- Controller actions: the only place UI may invoke class methods.
actions:
set-price(id, amount): Product{ id == id }.set_price(amount) role: Ops | Admin
watch(id): session.watchlist += id
```
2. **The view is plain `.htmlx`, logic-free.** Mustache paths, `{{#each}}`/`{{#if}}`, partials, helpers, `<wo:live>` subtrees, `wo:bind` attributes — nothing else. A `<wo:live source="products">` whose `source` is a bare name resolves against the controller's `model:` block (the M→V binding); an inline query in `source` remains legal for controller-less partials. Views never call methods — they raise actions (`wo:action="set-price"`), the controller dispatches.
3. **Styles are external SCSS, compiled at `wo build`.** No `<style>` blocks in views, no inline styles, one `.scss` per screen plus shared partials (`ui/styles/_*.scss`). `wo build` compiles a **strict SCSS subset** — variables, nesting, `@use` of partials; no mixins/functions in the first cut — to flat CSS served as a static asset via `sendfile` ([`../../08-sendfile-static-assets.md`](../../08-sendfile-static-assets.md)). Hand-rolled in `crates/ui` (~400 LOC scanner + nesting flattener), zero external dependencies — same stance as every other phase.
4. **One binary, unchanged.** `wo build` links the SSR renderer, the compiled views + manifest, the flattened CSS, the database engine, the REST/WS API, and the kernel-primitive concurrency runtime (epoll today, thread-per-core io_uring per [`../../09-concurrency-scaleout.md`](../../09-concurrency-scaleout.md)) into the single output. MVC changes the *source layout*, not the deployment shape.
5. **The `##ui` table shorthand survives as sugar.** The earlier declarative screen spec (`columns:` / `sort:` / `pagination:`, as in the ecommerce `home.wo`) compiles to a generated view + controller pair. Writing the triplet by hand is the general form; the shorthand is the 80% case. Either way the compiler output is identical: SSR HTML + manifest + bindings.
## Request flow
```
GET /pricing
→ router (controller route:) [crates/http]
→ controller resolves model: bindings against the engine (in-process)
→ SSR renders pricing.htmlx with model scope [crates/ui, per 01/02]
→ emits HTML + <script data-wo-manifest> + <link pricing.css>
browser action wo:action="set-price"
→ POST dispatched to the controller action
→ action calls Product.set_price(amount) [class method, plan 13b]
→ commit → delta → every <wo:live> subscriber [plan 13c]
→ client runtime patches wo:bind cells [03-client-runtime.md]
```
## Migration note
The two existing screen specs (`docs/examples/ecommerce/apps/*/ui/*/`, single-file `##ui` shorthand) stay valid under decision 5. New screens — starting with [`docs/examples/pricing/ui/pricing/`](../../../examples/pricing/ui/pricing/) — use the triplet. The v1 Angular app stays archived; its components are the *shape* reference, not a port source (the htmlx port source remains `reference/crates/wo-htmlx`).
## Exit criteria (implementation sequenced in [plan 14](../../14-mvc-ui-implementation.md), landing with plan 13d)
1. `crates/ui` resolves a controller file: `route:`/`view:`/`styles:`/`model:`/`actions:` parsed, view rendered with model scope, actions dispatched to class methods.
2. SCSS subset compiler: `pricing.scss` → flat CSS at build, golden-file tested.
3. The pricing screen works end-to-end per [plan 13d's exit criterion](../../13-class-model-live-pricing.md): a `set_price` commit patches the price cell in every open browser without reload.

View file

@ -2,7 +2,7 @@
A seven-phase design series that starts with "should writeonce use a document or graph database?" and arrives at a full-stack declarative application platform — then plans the migration from writeonce's current flat-file store to that platform. A seven-phase design series that starts with "should writeonce use a document or graph database?" and arrives at a full-stack declarative application platform — then plans the migration from writeonce's current flat-file store to that platform.
> **Start here if you're new:** [wo-language.md](./wo-language.md) — the user-facing overview of what writeonce *is* (a programming language with DB + HTTP in its runtime, Go-style toolchain). This series is the engineering plan that gets you there. > **Start here if you're new:** wo-language.md — the user-facing overview of what writeonce *is* (a programming language with DB + HTTP in its runtime, Go-style toolchain). This series is the engineering plan that gets you there.
Each phase is self-contained and shippable on its own. Every phase after Phase 1 builds on the previous ones. Each phase is self-contained and shippable on its own. Every phase after Phase 1 builds on the previous ones.
@ -14,7 +14,7 @@ Each phase is self-contained and shippable on its own. Every phase after Phase 1
| **2** | [The `.wo` Language & ACID Engine](./database/02-wo-language.md) | Design a two-layer `.wo` language (unified `type` schema layer + hybrid SQL/Cypher query layer with fixed glue) for an e-commerce platform with ACID transactions across relational, document, and graph storage. | | **2** | [The `.wo` Language & ACID Engine](./database/02-wo-language.md) | Design a two-layer `.wo` language (unified `type` schema layer + hybrid SQL/Cypher query layer with fixed glue) for an e-commerce platform with ACID transactions across relational, document, and graph storage. |
| **3** | [In-Memory Engine](./database/03-inmemory-engine.md) | RAM-primary, SSD-durable storage engine using `io_uring`, `mlockall`, group commit. 64 GB Linux machine. | | **3** | [In-Memory Engine](./database/03-inmemory-engine.md) | RAM-primary, SSD-durable storage engine using `io_uring`, `mlockall`, group commit. 64 GB Linux machine. |
| **4** | [Client API: Wire Protocol & Subscriptions](./database/04-client-api.md) | Native binary protocol + GraphQL over WebSocket. Subscription engine inside the transaction coordinator — no polling anywhere. | | **4** | [Client API: Wire Protocol & Subscriptions](./database/04-client-api.md) | Native binary protocol + GraphQL over WebSocket. Subscription engine inside the transaction coordinator — no polling anywhere. |
| **5** | [Go Client SDK](./database/05-go-sdk.md) | Typed Go client with subscription-first design. `gen` codegen from `.wo` schema. Subscribe to a live query in 5 lines. | | **5** | Go Client SDK | Typed Go client with subscription-first design. `gen` codegen from `.wo` schema. Subscribe to a live query in 5 lines. |
| **6** | [Low-Code Full-Stack](./database/06-lowcode-fullstack.md) | Expand `.wo` into an application language (like SAP CDS): `##ui`, `##logic`, `##policy`, `##service` blocks compiled into a single binary. | | **6** | [Low-Code Full-Stack](./database/06-lowcode-fullstack.md) | Expand `.wo` into an application language (like SAP CDS): `##ui`, `##logic`, `##policy`, `##service` blocks compiled into a single binary. |
| **7** | [Replacing `wo-seg`](./database/07-wo-seg-migration.md) | Phased coexistence plan: abstract the article store behind a trait, stand up the `.wo` engine as a second impl, dual-run, cut over, decommission `wo-seg`. | | **7** | [Replacing `wo-seg`](./database/07-wo-seg-migration.md) | Phased coexistence plan: abstract the article store behind a trait, stand up the `.wo` engine as a second impl, dual-run, cut over, decommission `wo-seg`. |
@ -56,10 +56,5 @@ Phase 7: Replace wo-seg ← trait abstraction, dual-run, cutover, decommis
## Related Documents ## Related Documents
- [wo-language.md](./wo-language.md) — writeonce as a programming language: toolchain, hello-world, stdlib, client model
- [surreal-case-study.md](./surreal-case-study.md) — SurrealDB runtime analysis; why writeonce does not use a multi-model DB for live queries - [surreal-case-study.md](./surreal-case-study.md) — SurrealDB runtime analysis; why writeonce does not use a multi-model DB for live queries
- [async.md](./async.md) — custom async runtime using Linux kernel primitives - [async.md](./async.md) — custom async runtime using Linux kernel primitives
- [05-datalayer.md](../05-datalayer.md) — current `.seg` + `.idx` implementation (8 crates, 44 tests)
- [03-data.md](../03-data.md) — data layer design with subscription model
- [06-markdown-render.md](../06-markdown-render.md) — markdown-first content model
- [ai-agents-content-management.md](../future-scope/ai-agents-content-management.md) — the `mappings` feature that started this series

View file

@ -15,9 +15,9 @@ Reference repositories:
## The Question ## The Question
writeonce articles have two shapes at once: they are **documents** (per-article JSON metadata + markdown body, per [06-markdown-render.md](../../06-markdown-render.md)) and they form a **graph** (the `mappings` field — `related`, `prerequisite`, `series`, `supersedes`, `references` — per [ai-agents-content-management.md](../../future-scope/ai-agents-content-management.md)). writeonce articles have two shapes at once: they are **documents** (per-article JSON metadata + markdown body, per 06-markdown-render.md) and they form a **graph** (the `mappings` field — `related`, `prerequisite`, `series`, `supersedes`, `references` — per ai-agents-content-management.md).
Should writeonce adopt an off-the-shelf document or graph database to back these two shapes, or keep the flat-file `.seg` + `.idx` storage already implemented in [05-datalayer.md](../../05-datalayer.md)? Should writeonce adopt an off-the-shelf document or graph database to back these two shapes, or keep the flat-file `.seg` + `.idx` storage already implemented in 05-datalayer.md?
**Short answer: no external DB.** The dataset is small (hundreds of articles, not millions of rows), single-writer (author commits), and read-heavy. A full rebuild on change is cheap. The `mappings` graph fits entirely in RAM. External databases would add a process, a protocol, a driver, and a failure mode — none of which writeonce needs. **Short answer: no external DB.** The dataset is small (hundreds of articles, not millions of rows), single-writer (author commits), and read-heavy. A full rebuild on change is cheap. The `mappings` graph fits entirely in RAM. External databases would add a process, a protocol, a driver, and a failure mode — none of which writeonce needs.
@ -45,7 +45,7 @@ Every article is a pair of files in `content/{sys_title}/`:
Plus `{sys_title}.md` with the full article body. Plus `{sys_title}.md` with the full article body.
The access patterns, per [05-datalayer.md](../../05-datalayer.md): The access patterns, per 05-datalayer.md:
| Pattern | Frequency | Current Implementation | | Pattern | Frequency | Current Implementation |
| --- | --- | --- | | --- | --- | --- |
@ -112,7 +112,7 @@ What it buys:
What it costs: What it costs:
- A Postgres process, a driver (tokio-postgres or raw libpq), connection pooling — writeonce's [05-datalayer.md](../../05-datalayer.md) explicitly removed all of this - A Postgres process, a driver (tokio-postgres or raw libpq), connection pooling — writeonce's 05-datalayer.md explicitly removed all of this
- JSONB query planning is excellent but still pays per-query cost that an in-process hash does not - JSONB query planning is excellent but still pays per-query cost that an in-process hash does not
- Recursive CTEs on deep mapping chains are slower than a RAM graph walk - Recursive CTEs on deep mapping chains are slower than a RAM graph walk
@ -161,7 +161,7 @@ What it costs:
## Option 4: In-Memory Graph — NetworkX / petgraph ## Option 4: In-Memory Graph — NetworkX / petgraph
NetworkX (Python) and petgraph (Rust) are _libraries_, not databases. You load the graph into process memory and traverse it directly. This is the model gestured at in [ai-agents-content-management.md line 181](../../future-scope/ai-agents-content-management.md) — "traversable knowledge graphs available on RAM." NetworkX (Python) and petgraph (Rust) are _libraries_, not databases. You load the graph into process memory and traverse it directly. This is the model gestured at in ai-agents-content-management.md line 181 — "traversable knowledge graphs available on RAM."
For writeonce, petgraph is the right shape: For writeonce, petgraph is the right shape:
@ -208,7 +208,7 @@ For writeonce's hundreds of articles, petgraph is the correct answer. It fits th
## Proposed Addition: `mappings.idx` Backed by petgraph ## Proposed Addition: `mappings.idx` Backed by petgraph
Per [05-datalayer.md](../../05-datalayer.md), indexes live alongside `.seg`. Add a fourth index: Per 05-datalayer.md, indexes live alongside `.seg`. Add a fourth index:
``` ```
data/ data/
@ -268,5 +268,3 @@ Key files to study:
See also: See also:
- [surreal-case-study.md](../surreal-case-study.md) — why writeonce does not use a multi-model DB for live queries - [surreal-case-study.md](../surreal-case-study.md) — why writeonce does not use a multi-model DB for live queries
- [05-datalayer.md](../../05-datalayer.md) — current `.seg` + `.idx` implementation
- [ai-agents-content-management.md](../../future-scope/ai-agents-content-management.md) — the `mappings` feature this index supports

View file

@ -73,7 +73,7 @@ That three-paradigm sketch is **the execution substrate** — the thing the engi
Both layers are `.wo` files — same extension, same tooling, same parser front-end. They differ in role: Both layers are `.wo` files — same extension, same tooling, same parser front-end. They differ in role:
- The **schema layer** names the data model once. One `type` declaration per entity covers what the three paradigm blocks cover today (relational columns, embedded documents, graph edges) plus constraints, computed fields, policies, and triggers. It is the source of truth for codegen ([Phase 5](./05-go-sdk.md)) and for the full-stack blocks ([Phase 6](./06-lowcode-fullstack.md)). - The **schema layer** names the data model once. One `type` declaration per entity covers what the three paradigm blocks cover today (relational columns, embedded documents, graph edges) plus constraints, computed fields, policies, and triggers. It is the source of truth for codegen (Phase 5) and for the full-stack blocks ([Phase 6](./06-lowcode-fullstack.md)).
- The **query layer** is the operational surface. SQL and Cypher stay as-is — they are universally legible, every backend developer already reads them — but five things are tightened so the three grammars share semantics (parameters, `RETURNING`, dotted paths, transactions, `LIVE`). - The **query layer** is the operational surface. SQL and Cypher stay as-is — they are universally legible, every backend developer already reads them — but five things are tightened so the three grammars share semantics (parameters, `RETURNING`, dotted paths, transactions, `LIVE`).
The two layers ship on different timelines. The query layer is Phase 2 (already prototyped at [`prototypes/wo-db/`](../../../prototypes/wo-db/)). The schema layer enters when Phase 5 codegen needs a single authoritative input. The two layers ship on different timelines. The query layer is Phase 2 (already prototyped at [`prototypes/wo-db/`](../../../prototypes/wo-db/)). The schema layer enters when Phase 5 codegen needs a single authoritative input.

View file

@ -2,7 +2,7 @@
> How remote clients connect, query, and subscribe to live changes — no polling anywhere in the chain. > How remote clients connect, query, and subscribe to live changes — no polling anywhere in the chain.
**Previous**: [Phase 3 — In-Memory Engine](./03-inmemory-engine.md) | **Next**: [Phase 5 — Go Client SDK](./05-go-sdk.md) | **Index**: [database.md](../database.md) **Previous**: [Phase 3 — In-Memory Engine](./03-inmemory-engine.md) | **Next**: Phase 5 — Go Client SDK | **Index**: [database.md](../database.md)
--- ---
@ -27,7 +27,7 @@ Every naive realtime system reaches for polling first. For an e-commerce platfor
| Inventory display lies for up to N ms | Inventory display reflects the commit | | Inventory display lies for up to N ms | Inventory display reflects the commit |
| "Order shipped" email triggered by cron | Fired by a committed status-change | | "Order shipped" email triggered by cron | Fired by a committed status-change |
Every subscription-based design in this section follows the same rule already set by writeonce in [05-datalayer.md](../../05-datalayer.md) and [03-data.md](../../03-data.md): **the client registers a query once, the server pushes deltas on commit, the client never asks again.** Every subscription-based design in this section follows the same rule already set by writeonce in 05-datalayer.md and 03-data.md: **the client registers a query once, the server pushes deltas on commit, the client never asks again.**
## Protocol Layer — Pick One or Both ## Protocol Layer — Pick One or Both
@ -193,7 +193,7 @@ Each connected client has server-side state:
| Role / RBAC context | Session | Feeds row-level policies into the planner | | Role / RBAC context | Session | Feeds row-level policies into the planner |
| Back-pressure credits | Per-subscription | Client advertises how many outstanding `DELTA` frames it can buffer | | Back-pressure credits | Per-subscription | Client advertises how many outstanding `DELTA` frames it can buffer |
On disconnect (TCP close, keepalive failure, `EPOLLHUP`-equivalent from io_uring completion): all sessions state is freed, all subscriptions unregistered. Same philosophy as `wo-sub`'s `EPOLLHUP` → automatic `unsubscribe(fd)` from [05-datalayer.md](../../05-datalayer.md), scaled up to a real server. On disconnect (TCP close, keepalive failure, `EPOLLHUP`-equivalent from io_uring completion): all sessions state is freed, all subscriptions unregistered. Same philosophy as `wo-sub`'s `EPOLLHUP` → automatic `unsubscribe(fd)` from 05-datalayer.md, scaled up to a real server.
## Back-Pressure ## Back-Pressure

View file

@ -1,371 +0,0 @@
# Phase 5 — Go Client SDK
> A typed Go client with subscription-first design — subscribe to a live query in 5 lines, deltas arrive on a channel.
**Previous**: [Phase 4 — Client API](./04-client-api.md) | **Next**: [Phase 6 — Low-Code Full-Stack](./06-lowcode-fullstack.md) | **Index**: [database.md](../database.md)
---
Concrete scenario: a developer writes a Go backend that serves a web frontend, and uses the `.wo` database as the store. They must be able to **subscribe to a query in ~5 lines of idiomatic Go** and have deltas arrive on a channel.
Everything else the SDK does — connect, query, mutate, transact — is table stakes covered by every existing Go DB driver. Subscriptions are what this SDK has to get right.
## Target API Surface
The engine speaks `.wo` on the wire. Every SDK method — typed or untyped — is a thin wrapper over one primitive: **send a `.wo` source string with `$name` parameters, get back a uniform `Result`**.
```go
import "go.writeonce.dev/wo"
// 1. Connect
client, err := wo.Connect(ctx, "wo://db.example.com:5555",
wo.WithAPIKey(os.Getenv("WO_KEY")),
wo.WithTLS(tlsConfig),
)
defer client.Close()
// 2. Wo — the primitive: run ANY .wo source (one statement or a whole
// BEGIN...COMMIT block mixing SQL, Cypher, and document updates). Params
// use the $name rule from the .wo language spec.
result, err := client.Wo(ctx, `
UPDATE products
SET inventory.on_hand -= $qty
WHERE id = $pid AND inventory.on_hand >= $qty
RETURNING id AS pid;
INSERT INTO orders (user_id, total_cents, status)
VALUES ($uid, $total, 'pending')
RETURNING id AS oid;
MATCH (u:user {id: $uid}), (p:product {id: $pid})
CREATE (u)-[:PURCHASED {order_id: $oid, qty: $qty, at: now()}]->(p);
`, wo.Params{
"uid": uid, "pid": 42, "qty": 2, "total": 9800,
})
// Result layout:
// result.Rows — rows from trailing SELECT/MATCH/RETURNING statements
// result.Aliases — the RETURNING alias table: {"pid": 42, "oid": 17}
// result.Affected — rows touched by INSERT/UPDATE/DELETE, per-statement
// 3. Query — sugar over Wo that scans trailing rows into a typed destination.
var products []Product
err = client.Query(ctx,
"SELECT id, sku, price_cents, meta FROM products WHERE price_cents < $max",
wo.Params{"max": 5000},
).Scan(&products)
// 4. Exec — sugar for writes that don't return rows.
_, err = client.Exec(ctx,
"UPDATE products SET price_cents = $new WHERE id = $id",
wo.Params{"new": 4900, "id": 42},
)
// 5. Tx — wraps Wo/Query/Exec in a server-side BEGIN...COMMIT. The callback's
// return value decides commit vs rollback. Useful when the program needs
// to branch between statements on intermediate results.
err = client.Tx(ctx, func(tx *wo.Tx) error {
r, err := tx.Wo(ctx,
`UPDATE products SET inventory.on_hand -= $qty
WHERE id = $pid AND inventory.on_hand >= $qty
RETURNING id AS pid;`,
wo.Params{"pid": 42, "qty": 2})
if err != nil { return err }
if r.Affected[0] == 0 { return wo.ErrInsufficientInventory }
_, err = tx.Wo(ctx, `
INSERT INTO orders (user_id, status) VALUES ($uid, 'pending') RETURNING id AS oid;
MATCH (u:user {id: $uid}), (p:product {id: $pid})
CREATE (u)-[:PURCHASED {order_id: $oid, qty: $qty}]->(p);
`, wo.Params{"uid": uid, "pid": 42, "qty": 2})
return err
})
```
`Wo` is the primitive; `Query`, `Exec`, and `Subscribe` are typed sugar. If the engine accepts the `.wo` source on disk, `client.Wo` accepts the same string over the wire.
### When to use raw `.wo` vs typed codegen
Both styles coexist in the same program; they share the connection pool.
| Use raw `.wo` (`client.Wo`) when | Use typed codegen (`client.Orders.Create`, etc.) when |
| --- | --- |
| Ad-hoc queries, admin tools, one-off scripts | The app's hot path — compile-time schema checking + IDE autocomplete pay for themselves |
| Cross-cutting queries that join multiple generated types | Per-type CRUD + subscriptions |
| Multi-statement transactions threading `RETURNING` aliases | Single-statement operations |
| DB repair, data migration, ad-hoc analytics | Anything the codegen already covers |
| You want to paste a block from a `.wo` source file straight into Go | You want refactor-safe struct field access |
The canonical rule: **write typed code first, drop to raw `.wo` when the type system gets in the way**. They interleave freely — a typed `client.Orders.Subscribe(...)` can run next to a raw `client.Wo(...)` admin query in the same handler.
## Subscribe — The Primary Use Case
Idiomatic Go for a stream of values is a channel read inside a `for` loop, cancelled by `context.Context`. That is the exact shape a `.wo` subscription should take:
```go
sub, err := client.Subscribe(ctx,
"LIVE SELECT sku, inventory.on_hand FROM products WHERE sku IN $skus",
wo.Params{"skus": cartSkus},
)
if err != nil { return err }
defer sub.Close()
for delta := range sub.Deltas() {
switch d := delta.(type) {
case wo.Insert:
log.Printf("new row: %+v", d.Row)
case wo.Update:
log.Printf("sku=%s on_hand=%d -> %d", d.Key, d.Old["on_hand"], d.New["on_hand"])
case wo.Delete:
log.Printf("removed: %s", d.Key)
case wo.Resync:
// server dropped our queue — refetch and resume
currentState = refetch()
}
}
// loop exits when:
// - ctx cancelled (client shutdown)
// - sub.Close() called (defer)
// - server sent COMPLETE (schema change, permission revoked)
// sub.Err() returns the reason
if err := sub.Err(); err != nil { log.Fatal(err) }
```
**Contract:**
- `sub.Deltas()` returns `<-chan wo.Delta` — standard read-only channel. The SDK closes it when the subscription ends.
- `ctx` cancellation immediately stops deliveries and closes the channel. No leaked goroutines.
- Ordering: deltas arrive in commit order. A `DELTA` on the wire always reflects a committed transaction.
- Back-pressure: the channel has a bounded buffer (default 1024). If it fills, the SDK's policy kicks in (see below).
## Typed SDK via `.wo` Schema Codegen
The `.wo` **schema layer** ([Phase 2](./02-wo-language.md)) declares types. `wo-gen` reads the type DSL — not the underlying `##sql`/`##doc`/`##graph` blocks — as its input; that way one Go struct corresponds to one entity, with embedded documents and graph-edge projections folded in naturally.
```wo
type Product {
id: Id
sku: SKU @unique
price: Money
meta: { title: Text, description: Markdown, images: [Url], reviews: [Review] }
inventory: { on_hand: Int @check(>= 0), reserved: Int = 0, reorder_at: Int }
purchased_by: multi User via Purchase -- inverse graph link
}
```
```bash
wo-gen --schema ./schema.wo --out ./internal/wodb
```
Produces:
```go
package wodb
// from `type Product` — embedded structs compile from the inline `{...}` fields
type Product struct {
ID int64 `wo:"id"`
SKU string `wo:"sku"`
Price Money `wo:"price"`
Meta ProductMeta `wo:"meta"`
Inventory InventoryLvl `wo:"inventory"`
PurchasedBy []PurchaseEdge `wo:"purchased_by"` // link-with-props → edge struct
}
// embedded document inside Product.Meta
type ProductMeta struct {
Title string `wo:"title"`
Description string `wo:"description"`
Images []string `wo:"images"`
Attributes map[string]string `wo:"attributes"`
Reviews []Review `wo:"reviews"`
}
// graph link carrying properties — target + edge props in one struct
type PurchaseEdge struct {
Target User `wo:"target"`
Order int64 `wo:"order"`
Qty int `wo:"qty"`
At time.Time `wo:"at"`
}
// registered live queries become typed helpers
func InventoryChanged(ctx context.Context, c *wo.Client, skus []string) (*wo.TypedSubscription[InventoryLvl], error)
```
One type declaration → one Go struct. Zero-property graph edges (`multi User @edge(:FOLLOWS)`) generate `Friends []User`; link-with-properties types generate `[]EdgeStruct`; computed fields become read-only struct fields populated by the planner.
**Transition path.** While the Phase 2 prototype is still authored directly in `##sql/##doc/##graph` blocks (the query layer), `wo-gen` accepts either — a file of type declarations, or the raw paradigm blocks — and emits the same Go output. The type DSL becomes mandatory only once Phase 6 full-stack blocks (which attach to types) start shipping.
Typed subscription loop loses all `interface{}` ceremony:
```go
sub, err := wodb.InventoryChanged(ctx, client, cartSkus)
if err != nil { return err }
defer sub.Close()
for d := range sub.C {
switch d.Kind {
case wo.DeltaUpdate:
log.Printf("sku=%s now %d in stock", d.Key, d.New.OnHand)
}
}
```
Generics (Go 1.18+) make `TypedSubscription[T]` a single parameterized type — no per-query generated struct. Only the `T` struct itself is generated.
## Connection Lifecycle
```go
type Client struct {
// opaque; holds a connection pool, codec, session registry
}
func Connect(ctx context.Context, dsn string, opts ...Option) (*Client, error)
func (c *Client) Close() error
func (c *Client) Ping(ctx context.Context) error
```
Inside the SDK, one TCP connection per client is fine for native protocol (multiplexed), but a small pool (2–4) helps when one connection's receive goroutine is saturated decoding a large result set. Connection state:
- **Connecting** → `HELLO` sent, waiting for `WELCOME`
- **Ready** → normal operation
- **Reconnecting** → transient network error; automatic exponential backoff; all subscriptions queued for re-registration
- **Closed** → terminal
**Reconnection semantics for subscriptions** (the subtle part): on reconnect, the SDK re-sends every active `SUBSCRIBE` frame. The server replies with a `RESYNC` marker and the current matching state. The app's subscription channel emits a single `wo.Resync{}` value so the consumer knows to rebuild local state. No delta is silently lost, no delta is silently duplicated.
## Options
Fluent options, not a bloated config struct:
```go
wo.WithAPIKey(key string)
wo.WithJWT(token string)
wo.WithMTLS(cert tls.Certificate)
wo.WithTLS(cfg *tls.Config)
wo.WithPoolSize(n int) // default 2
wo.WithSubscriptionBuffer(n int) // default 1024
wo.WithOverflowPolicy(wo.DropAndResync | wo.Coalesce | wo.Disconnect)
wo.WithLogger(l *slog.Logger)
wo.WithRetry(wo.RetryPolicy{...})
wo.WithProtocol(wo.ProtocolNative | wo.ProtocolGraphQL) // native default
```
## Transactions
`client.Tx` maps to [Phase 2's](./02-wo-language.md) `BEGIN ... COMMIT`. The callback's return value decides commit vs rollback:
- `return nil` → `COMMIT` sent, error only if server rejects commit
- `return err` → `ROLLBACK` sent, original `err` surfaced to caller
- `panic` → `ROLLBACK` sent, panic re-raised
- `ctx` cancel → `ROLLBACK` sent, `ctx.Err()` returned
Nested `tx.Wo`/`tx.Query`/`tx.Exec` route to the same server-side transaction — no connection hopping. The SDK enforces this by pinning the transaction to one connection for its lifetime. `RETURNING` aliases bound by one statement in the txn are visible to every later statement in the same txn through the server-side alias table (see [Phase 2 — Transaction Coordinator](./02-wo-language.md#cross-paradigm-transaction-coordinator)), so a Go `tx.Wo` call can leave `$oid` set and the next `tx.Wo` call can use it.
## Back-Pressure Handling in the SDK
The server's back-pressure policy from [Phase 4](./04-client-api.md) is mirrored client-side:
| Client situation | SDK behavior |
| --- | --- |
| Consumer reading channel fast enough | Normal delivery |
| Channel buffer full (1024 unread deltas) | Per `WithOverflowPolicy`: drop buffered + emit `wo.Resync`, or coalesce same-key updates, or close the subscription with `ErrOverflow` |
| Network stalled | `ctx.Deadline` + keepalive `PING` every 10s; stall > 30s → disconnect and reconnect |
| Server closed subscription | Channel closed, `sub.Err()` returns reason (schema change, permission revoked, engine shutdown) |
**Never block the receive goroutine on a full channel.** The SDK drains the socket no matter what; overflow policy decides what to do with the deltas it can't deliver.
## Full Cart-Inventory Example
End-to-end: Go HTTP handler that renders a cart page and keeps its inventory line live via Server-Sent Events to the browser. The SDK drives the upstream subscription to `.wo`:
```go
func (h *Handler) cartInventoryStream(w http.ResponseWriter, r *http.Request) {
skus := parseSkus(r.URL.Query().Get("skus"))
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
flusher := w.(http.Flusher)
sub, err := wodb.InventoryChanged(r.Context(), h.db, skus)
if err != nil { http.Error(w, err.Error(), 500); return }
defer sub.Close()
for d := range sub.C {
payload, _ := json.Marshal(d)
fmt.Fprintf(w, "event: inventory\ndata: %s\n\n", payload)
flusher.Flush()
}
}
```
The browser connects once with `new EventSource('/cart/inventory?skus=...')`. The Go handler holds one subscription to `.wo`. When inventory commits in the database, the delta flows: engine → subscription registry → Go SDK channel → SSE stream → DOM update. Zero polling anywhere in the chain.
## Go-Specific Design Details
| Go idiom | Application |
| --- | --- |
| `context.Context` threading | Every method takes `ctx` as first arg; cancellation propagates to the wire |
| `io.Closer` | `Client`, `Tx`, `Subscription` all implement `Close() error` |
| Small interfaces | `type Runner interface { Wo(ctx, src, params) (wo.Result, error) }` — `*Client` and `*Tx` both satisfy it, so helper functions compose cleanly |
| `database/sql`-style `Scan` | `client.Query(...).Scan(&dest)` accepts struct, slice of struct, or primitives |
| `sql.Null*` analogues | `wo.NullString`, `wo.NullInt64`, `wo.NullDoc` for optional doc columns |
| Struct tags | `wo:"column_name"` + JSON-style for nested doc fields (`wo:"meta.title"`) |
| `errors.Is` / `errors.As` | `errors.Is(err, wo.ErrConflict)`, `wo.AsError(err, &woErr)` |
| No goroutine leaks | Every background goroutine tied to ctx or a sync.WaitGroup closed in `Client.Close()` |
| Testing via interfaces | `wo.DB` interface; provide `wotest.NewMock()` for unit tests; real embedded engine for integration |
## Comparison With Existing Go DB SDKs
| SDK | Query style | Subscriptions | Transactions | Typed results |
| --- | --- | --- | --- | --- |
| `database/sql` + `pq` | SQL strings | No | Yes | Manual `Scan` |
| `pgx` | SQL strings | `LISTEN/NOTIFY` only (no row-level) | Yes | Manual or `pgxscan` |
| `sqlc` | Generated Go funcs from `.sql` | No | Yes | Generated structs |
| `ent` | ORM | No | Yes | Generated |
| `go-redis` | Commands | Pub/sub + keyspace notifications (no query) | Multi/Exec | Manual |
| `surrealdb/surrealdb.go` | Raw queries | `Live()` returning channel | Yes | Manual |
| `gqlgen` / `machinebox/graphql` | GraphQL docs | WebSocket subscriptions | N/A | Generated |
| **`sa`** (this design) | `.wo` queries | **Native `LIVE` → typed channel** | Yes | Codegen from schema |
The reference points are `sqlc` (for the codegen pipeline) and `surrealdb-go` (for the subscription channel API). Combining their best ideas and tightening the subscription contract is what this SDK is.
## Reference Implementations To Steal From
- **surrealdb/surrealdb.go** — `Live()` returns a channel; closest API precedent. <https://github.com/surrealdb/surrealdb.go>
- **jackc/pgx** — reference quality for a Go database driver. Connection pool, copy protocol, prepared statements all done right. <https://github.com/jackc/pgx>
- **sqlc-dev/sqlc** — codegen from SQL to typed Go. The model for `.wo` → Go. <https://github.com/sqlc-dev/sqlc>
- **Khan/genqlient** — generated typed GraphQL client. Ergonomic precedent for typed query helpers. <https://github.com/Khan/genqlient>
- **nats-io/nats.go** — subscription-first API, back-pressure handled well. `sub.NextMsg(ctx)` and channel-based `ChanSubscribe` both supported. <https://github.com/nats-io/nats.go>
- **hasura/go-graphql-client** — GraphQL subscriptions over WebSocket in Go. <https://github.com/hasura/go-graphql-client>
## SDK Delivery
| Artifact | Purpose |
| --- | --- |
| `go.writeonce.dev/wo` | Runtime package: client, query, subscribe |
| `go.writeonce.dev/wo/wotest` | Mock client + in-memory engine for unit tests |
| `wo-gen` binary | Reads `schema.wo`, emits typed Go code |
| Go module example repo | Cart + inventory demo wired end-to-end |
| Generated docs | `go doc` + hosted examples |
Publishing strategy: semantic versioning, `v0.x` while the wire protocol is unstable, `v1.0` only after the protocol is frozen.
## Why The SDK Matters As Much As The Engine
A database with a beautiful engine and a painful client is a database no one uses. The e-commerce Go backends this targets are built under deadline — if `Subscribe` is not as easy as opening a channel, developers will reach for polling (`time.Tick` + `SELECT`) and defeat the whole architecture.
The success metric is blunt: **a developer who has never seen `.wo` before should have a working subscription to a live query inside 15 minutes**, counting install, schema codegen, and the first delta landing on their channel. If the SDK is any harder than that, the rest of this doc is academic.
## Future SDKs
Same shape, other languages:
- **TypeScript / browser** — fetch + WebSocket for GraphQL subscriptions; types via codegen from `.wo`. Highest priority after Go for a web-first product.
- **Rust** — direct native protocol, `tokio`-friendly, `impl Stream<Item = Delta>` for subscriptions.
- **Python** — async/await, `async for delta in sub` idiom.
- **Java / Kotlin** — Flow (Kotlin) or Reactive Streams (Java) for subscriptions.
Each follows the same rule: subscribe-to-query must be the shortest, most obvious thing in the API.

View file

@ -2,7 +2,7 @@
> Expand `.wo` from a query language into a declarative application DSL — schema, services, UI, business logic, and authorization in one language, compiled into a single binary. > Expand `.wo` from a query language into a declarative application DSL — schema, services, UI, business logic, and authorization in one language, compiled into a single binary.
**Previous**: [Phase 5 — Go Client SDK](./05-go-sdk.md) | **Index**: [database.md](../database.md) **Previous**: Phase 5 — Go Client SDK | **Index**: [database.md](../database.md)
--- ---
@ -282,7 +282,7 @@ For the writeonce schema above, `sa build` produces:
| SSR HTML | `app/ui/*.wo` + routes in `app.wo` | Per-route renderers compiled into the server binary | | SSR HTML | `app/ui/*.wo` + routes in `app.wo` | Per-route renderers compiled into the server binary |
| Client runtime | `app/ui/*.wo` | Small JS bundle: subscription client + DOM patcher + form binding | | Client runtime | `app/ui/*.wo` | Small JS bundle: subscription client + DOM patcher + form binding |
| Admin UI | All of the above | Auto-generated CRUD screens for every `##sql`/`##doc` entity (override any with a `##ui` block) | | Admin UI | All of the above | Auto-generated CRUD screens for every `##sql`/`##doc` entity (override any with a `##ui` block) |
| Typed SDKs | `app/database/*.wo` | Go/TypeScript/Rust clients per [Phase 5](./05-go-sdk.md) | | Typed SDKs | `app/database/*.wo` | Go/TypeScript/Rust clients per Phase 5 |
| Migrations | Schema diff vs. current database | Versioned forward/backward migrations in `migrations/` | | Migrations | Schema diff vs. current database | Versioned forward/backward migrations in `migrations/` |
| Observability | Everything | Structured logs, query metrics, subscription lag dashboards | | Observability | Everything | Structured logs, query metrics, subscription lag dashboards |
@ -359,7 +359,7 @@ Rough effort on top of Phases 2–4: **12–24 months** with a small team, most
This section is the endgame, not the next step. The sensible build order: This section is the endgame, not the next step. The sensible build order:
1. Ship the engine ([Phase 2](./02-wo-language.md), in-memory + io_uring durability via [Phase 3](./03-inmemory-engine.md)) — query layer (`##sql`/`##doc`/`##graph`) only. 1. Ship the engine ([Phase 2](./02-wo-language.md), in-memory + io_uring durability via [Phase 3](./03-inmemory-engine.md)) — query layer (`##sql`/`##doc`/`##graph`) only.
2. Ship the wire protocol + Go SDK ([Phase 4](./04-client-api.md) + [Phase 5](./05-go-sdk.md)). 2. Ship the wire protocol + Go SDK ([Phase 4](./04-client-api.md) + Phase 5).
3. Ship the **schema-layer `type` DSL** that compiles to the three paradigm blocks. From this point forward, authoring happens against types; the paradigm blocks become an artifact the compiler emits. 3. Ship the **schema-layer `type` DSL** that compiles to the three paradigm blocks. From this point forward, authoring happens against types; the paradigm blocks become an artifact the compiler emits.
4. Add type-attached `service` (and standalone `##service` for bundles) — declarative endpoints. 4. Add type-attached `service` (and standalone `##service` for bundles) — declarative endpoints.
5. Add type-attached `policy` (and standalone `##policy` for cross-entity rules) — declarative authorization. 5. Add type-attached `policy` (and standalone `##policy` for cross-entity rules) — declarative authorization.

View file

@ -56,7 +56,7 @@ Port the C++ prototype (`prototypes/wo-db/src/*`) to Rust, split along the natur
| `sub` | live subscriptions — delta frames on commit | new ([Phase 4](./04-client-api.md)) | 4 | | `sub` | live subscriptions — delta frames on commit | new ([Phase 4](./04-client-api.md)) | 4 |
| `http` | wire protocol — REST / GraphQL-over-WS / native codec | new ([Phase 4](./04-client-api.md)) | 4 | | `http` | wire protocol — REST / GraphQL-over-WS / native codec | new ([Phase 4](./04-client-api.md)) | 4 |
| `db` | top-level facade: `open()`, `Tx`, `Query`, `Subscribe` — the Rust SDK | integrates the above | 2–4 | | `db` | top-level facade: `open()`, `Tx`, `Query`, `Subscribe` — the Rust SDK | integrates the above | 2–4 |
| `gen` | codegen: `.wo type` → Rust structs, Go structs, TypeScript | `sa-gen`/`wo-gen` in [Phase 5](./05-go-sdk.md) | 5 | | `gen` | codegen: `.wo type` → Rust structs, Go structs, TypeScript | `sa-gen`/`wo-gen` in Phase 5 | 5 |
All 15 crates (these 14 plus the existing `rt` binary crate) now exist as empty skeletons in `crates/`. See [`crates/README.md`](../../../crates/README.md) and [`docs/plan/done/01-scafolding-crates.md`](../../plan/done/01-scafolding-crates.md) for the scaffolding plan that landed them. All 15 crates (these 14 plus the existing `rt` binary crate) now exist as empty skeletons in `crates/`. See [`crates/README.md`](../../../crates/README.md) and [`docs/plan/done/01-scafolding-crates.md`](../../plan/done/01-scafolding-crates.md) for the scaffolding plan that landed them.
@ -203,7 +203,5 @@ Each phase has its own exit criteria above. End-to-end verification for the whol
- [02-wo-language.md](./02-wo-language.md) — the two-layer `.wo` language the engine speaks - [02-wo-language.md](./02-wo-language.md) — the two-layer `.wo` language the engine speaks
- [03-inmemory-engine.md](./03-inmemory-engine.md) — the storage engine behind `wo-db` - [03-inmemory-engine.md](./03-inmemory-engine.md) — the storage engine behind `wo-db`
- [04-client-api.md](./04-client-api.md) — wire protocol and `LIVE` subscriptions - [04-client-api.md](./04-client-api.md) — wire protocol and `LIVE` subscriptions
- [05-go-sdk.md](./05-go-sdk.md) — the Go SDK built from `.wo` types via `wo-gen`
- [01-evaluation.md](./01-evaluation.md) — why writeonce built `wo-seg` in the first place, and why that choice still looks right for the blog even as the platform grows past it - [01-evaluation.md](./01-evaluation.md) — why writeonce built `wo-seg` in the first place, and why that choice still looks right for the blog even as the platform grows past it
- [../05-datalayer.md](../../05-datalayer.md) — current `.seg` + `.idx` implementation details
- `prototypes/wo-db/` — the C++ prototype of the `.wo` engine, the reference implementation the Rust port follows - `prototypes/wo-db/` — the C++ prototype of the `.wo` engine, the reference implementation the Rust port follows

View file

@ -104,7 +104,7 @@ SurrealDB's live query system pushes changes to connected clients in real-time:
| Runtime | Tokio multi-threaded executor | Single-threaded epoll event loop | | Runtime | Tokio multi-threaded executor | Single-threaded epoll event loop |
| Protocol framing | WebSocket frames | Length-prefixed payloads (no protocol) | | Protocol framing | WebSocket frames | Length-prefixed payloads (no protocol) |
SurrealDB's live queries are the architectural inspiration for writeonce's subscription model (as noted in [03-data.md](../03-data.md)), but the implementation is fundamentally different — SurrealDB uses a full async runtime with WebSocket transport, while writeonce uses kernel fd notifications with no protocol layer. SurrealDB's live queries are the architectural inspiration for writeonce's subscription model (as noted in 03-data.md), but the implementation is fundamentally different — SurrealDB uses a full async runtime with WebSocket transport, while writeonce uses kernel fd notifications with no protocol layer.
## Storage Engine Architecture ## Storage Engine Architecture

View file

@ -1,249 +0,0 @@
# writeonce — the `.wo` Language and Runtime
> A declarative programming language with database and subscription-native HTTP in its standard runtime. Like `go run`, you write `.wo` files and execute them — but your program is a full-stack application.
---
## What writeonce is
`writeonce` is a programming language, a standard runtime, and a toolchain. Three layers of one product:
1. **The language** — `.wo` source files. Declarative by default (`type`, `class`, `service`, `policy`, `on <event>`) with a hybrid SQL+Cypher query sublanguage for the imperative parts. Types, queries, transactions, subscriptions, policies, triggers, HTTP endpoints, and UI screens are all first-class language constructs. A `class` is a `type` plus `fn` methods (`self` receiver, transactional) — state and behavior, **no inheritance** ([plan 13](../plan/13-class-model-live-pricing.md)).
2. **The runtime** — an ACID multi-paradigm database (relational + document + graph), an HTTP server, a subscription engine, and a scheduler. All of it links into a single binary with your program. No external Postgres, no external Redis, no separate Node process.
3. **The toolchain** — the `wo` command: `wo run`, `wo build`, `wo test`, `wo fmt`, `wo mod`, `wo gen`. Modelled directly on the Go toolchain. One binary per project; no runtime to install on the target host.
The one-line pitch: **Go + Postgres + `net/http` + Phoenix LiveView, folded into one language and one binary.**
## Hello, world
> Full example projects:
> - [`docs/examples/blog/`](../examples/blog/) — a blog (~200 lines): articles, authors, tags, comments, live subscriptions, row-level policies, typed Go client.
> - [`docs/examples/ecommerce/`](../examples/ecommerce/) — an e-commerce store (~300 lines): cross-paradigm ACID checkout, link types with properties, tagged unions, a **live order-ops table** that delta-updates in place.
A complete `.wo` program that creates a database table, exposes six REST endpoints with live subscriptions, and emits a typed Go client:
```wo
-- article.wo
type Article {
id: Id
title: Text
body: Markdown
author: Text
created_at: Timestamp = now()
service rest "/api/articles"
expose list, get, create, update, delete, subscribe
}
```
Run it:
```bash
$ wo run
[wo] compiling ./article.wo
[wo] schema: 1 type, 0 migrations needed
[wo] 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
```
Use it:
```bash
$ curl -X POST localhost:8080/api/articles \
-H "Content-Type: application/json" \
-d '{"title":"Hello","body":"# First post","author":"me"}'
{"id":1,"title":"Hello","body":"# First post","author":"me","created_at":"2026-04-17T..."}
$ curl localhost:8080/api/articles
[{"id":1,"title":"Hello","...":"..."}]
```
Generate a typed client:
```bash
$ wo gen sdk --lang go --out ./client
# produces ./client/sdk.go with typed Article struct and Subscribe helper
```
Subscribe from the client — deltas push on every commit, no polling:
```go
import "myapp.example.com/client"
c, _ := client.Connect("wo://localhost:8080")
sub, _ := c.Articles.Subscribe(ctx, client.Where{Author: "me"})
for delta := range sub.C {
fmt.Printf("%s: %+v\n", delta.Kind, delta.Row)
}
```
Three files, five commands, zero infrastructure. Compare the same thing in Go+Postgres+React: one SQL schema, one migration tool, one ORM, one HTTP router, one subscription layer (polling or Redis pub-sub), one hand-written client, one React hook — roughly 2000 lines before you write any business logic.
## The toolchain
Go-literal. Every command maps to a Go equivalent so the mental model transfers:
| Command | Go equivalent | Purpose |
| --- | --- | --- |
| `wo init <name>` | `go mod init` | scaffold a new project |
| `wo run` | `go run ./...` | compile and execute |
| `wo build` | `go build` | emit a static binary |
| `wo test` | `go test` | run `.wo` tests |
| `wo fmt` | `gofmt` | canonical formatter |
| `wo vet` | `go vet` | lint + type-check without running |
| `wo mod <cmd>` | `go mod` | dependencies |
| `wo doc <sym>` | `go doc` | render docs for a type |
| `wo gen sdk --lang <L>` | `go generate` (codegen) | emit a client SDK |
| `wo migrate [--plan\|--apply]` | no direct equivalent | schema evolution |
| `wo dev` | no direct equivalent | hot-reload dev server |
**`wo run` vs `wo build`.** Same as Go: `wo run` compiles to a temp binary and executes it; `wo build` writes a named binary. No interpreter mode — `.wo` is compiled, always.
**`wo dev` is the one non-Go addition.** Edit a `.wo` file, the runtime hot-swaps the affected module without restarting. Live subscriptions survive the reload. This is the Phoenix LiveView influence.
## Program structure
```
myapp/
├── wo.toml # like go.mod — name, version, dependencies
├── main.wo # optional entry point
├── types/ # `type` declarations (one file per domain concept)
│ ├── article.wo
│ └── user.wo
├── ui/ # ##ui screens (optional)
├── tests/ # *_test.wo files
└── wo.lock # locked dependency graph (like go.sum)
```
Minimum project is one `.wo` file with one `type` declaration. The compiler generates:
- the database schema (relational row, document structures, graph edges) from the type's fields
- HTTP handlers from type-attached `service` blocks
- transactional triggers from `on <event>` blocks
- row-level policies from `policy` blocks
- typed client SDKs from the same type, on demand
No `main()` is required for a pure type-and-service app. The runtime starts the HTTP server, loads the database, and dispatches. If you need procedural entry logic (CLI args, graceful shutdown hooks, cron jobs), add `main.wo` with a `main { ... }` block.
## The runtime — what's in the standard library
Every `wo build` links these in. They're not external packages you import — they're the language.
| Component | Responsibility | Mapped to phase |
| --- | --- | --- |
| **Database** | In-RAM ACID multi-paradigm (relational + doc + graph) with WAL durability | [Phase 2](./database/02-wo-language.md) + [Phase 3](./database/03-inmemory-engine.md) |
| **Transaction coordinator** | MVCC, snapshot isolation, cross-paradigm `RETURNING` alias table | [Phase 2](./database/02-wo-language.md) |
| **HTTP server** | REST + GraphQL dispatch generated from `service` blocks | [Phase 4](./database/04-client-api.md) + `crates/http` |
| **Subscription engine** | `LIVE` queries push deltas on commit, zero polling | [Phase 4](./database/04-client-api.md) |
| **Wire protocol** | Native binary codec for typed clients | [Phase 4](./database/04-client-api.md) |
| **Codegen** | `wo gen sdk` — Go, TypeScript, Rust, Python clients from `type` declarations | [Phase 5](./database/05-go-sdk.md) |
| **UI renderer** | `##ui` screens → SSR HTML + client runtime | [Phase 6](./database/06-lowcode-fullstack.md) |
| **Authorization** | `policy` blocks compiled into planner rewrite rules | [Phase 6](./database/06-lowcode-fullstack.md) |
| **Scheduler** | Single-threaded event loop over io_uring; one core per process (shard to scale) | [async.md](./async.md) + [Phase 2 concurrency](./database/02-wo-language.md#concurrency-model) |
Comparison to Go's stdlib:
| Need | Go | writeonce |
| --- | --- | --- |
| HTTP server | `net/http` | built-in `service rest` |
| Database | none (use `database/sql` + driver + Postgres) | **built-in** |
| Template rendering | `html/template` | `##ui` blocks |
| Concurrency | goroutines + channels | single-threaded event loop (Redis-style); shard to scale past one core |
| Testing | `testing` | `wo test` + `.wo` test syntax |
| Formatting | `gofmt` | `wo fmt` |
| Modules | `go.mod` + `go.sum` | `wo.toml` + `wo.lock` |
## Clients — who consumes your program
The same `.wo` type declarations that define the database also define the wire format. `wo gen sdk` emits:
- **Go** — typed structs, `*Client`, `TypedSubscription[T]` generics over a channel
- **TypeScript / browser** — types + `fetch` + WebSocket subscriptions
- **Rust** — structs, `tokio` async client, `impl Stream<Item = Delta>`
- **Python** — dataclasses, `async for delta in sub`
- **curl / raw REST** — documented via auto-generated OpenAPI spec at `/openapi.json`
- **GraphQL clients** — SDL auto-generated at `/graphql/schema.graphql`
**Raw `.wo` DML is a first-class escape hatch.** Every client SDK exposes a single method — `client.Wo(ctx, src, params)` in Go, equivalents in TypeScript/Rust/Python — that accepts any `.wo` source the server would accept: mixed SQL + Cypher, `BEGIN … COMMIT` blocks with `RETURNING` aliases threading across statements, ad-hoc MATCH-then-SELECT queries that cross multiple generated types. The typed methods are sugar; the engine speaks `.wo` on the wire. A Go program can send a cross-paradigm transaction as a single string and the server parses + executes it exactly like `wo run` would — see [Phase 5: Go Client SDK](./database/05-go-sdk.md) for the full API.
One schema, every protocol. A browser app, a mobile client, and a background worker can all subscribe to the same live query and receive the same delta stream.
## What this is, and isn't
**Is.** A declarative, full-stack, single-binary language for building CRUD apps with live data. A replacement for the "Go backend + Postgres + Redis + React + Prisma + GraphQL server" stack.
**Isn't.**
- Not a general-purpose language like Rust or Go. You can't write a kernel module or a video codec in `.wo`. The scope is data-shaped applications.
- Not a JavaScript meta-framework. No Node, no React. The UI layer (`##ui`) is declarative and compiles to SSR HTML with a small vanilla-JS client.
- Not a DSL that transpiles to another language. `.wo` has its own lexer, parser, analyzer, and bytecode. The [`prototypes/`db`/`](../../prototypes/`db`/) C++ prototype and the planned Rust crates implement the runtime natively.
- Not a hosted service. Your binary owns its own DB file. No managed cloud offering is required.
## How this maps to the design series
This overview is the user-facing frame. The underlying engineering plan is the 7-phase series linked from [database.md](./database.md):
- **[Phase 2](./database/02-wo-language.md)** designs the language and the transaction coordinator.
- **[Phase 3](./database/03-inmemory-engine.md)** builds the storage engine.
- **[Phase 4](./database/04-client-api.md)** builds the wire protocol and subscription engine.
- **[Phase 5](./database/05-go-sdk.md)** builds the first typed client (Go) and `wo gen`.
- **[Phase 6](./database/06-lowcode-fullstack.md)** adds `##ui` and the application-level blocks.
- **[Phase 7](./database/07-wo-seg-migration.md)** migrates writeonce-the-blog from `wo-seg` onto this runtime.
Phase 1 (evaluation) and the case studies in [surreal-case-study.md](./surreal-case-study.md) argue *why* the language exists at all. Read those first if you're skeptical; read the phase docs if you're implementing; read this page if you want to know what it feels like to use.
## Reference points
The design absorbs lessons from several systems. In order of influence:
- **Go** — toolchain shape, single-binary deployment, "the language is the build system"
- **Phoenix LiveView** — subscription-native UI, hot-reloading dev server
- **SAP CDS** — declarative entity/service language, admin UI generation
- **SurrealDB** — multi-paradigm query language, `LIVE` subscriptions over wire
- **PocketBase** — single-binary CRUD backend (the proof of concept that this is shippable)
- **EdgeDB** — unified type system above storage paradigms
- **Elixir / Erlang / OTP** — hot code loading, supervision, subscription semantics
- **Django** — admin UI as a built-in, not a bolt-on
None of these give you all of: a language, a database, a subscription engine, a UI toolkit, a client codegen, and a single-binary output. writeonce is the attempt to fuse the best of each into one thing.
## Minimal "hello, world" as a full program
If you want a pure procedural test, without the server:
```wo
-- hello.wo
main {
print("hello, world")
}
```
```bash
$ wo run hello.wo
hello, world
```
If you want the database without HTTP:
```wo
type Counter {
name: Text @unique
value: Int = 0
}
main {
insert Counter { name: "visits" };
update Counter{ name == "visits" }.value += 1;
let c = select Counter{ name == "visits" };
print(c.value);
}
```
If you want the full app — database, HTTP, subscriptions, clients — it's the article example at the top of this page.
Three progressive shapes, one language, one command to run each.

View file

@ -1,94 +0,0 @@
# UI — .htmlx SSR + LIVE Subscriptions Implementation Plan
> **Status: ⏸ parked** — the UI track (`.htmlx` SSR + LIVE subscriptions) is off the critical path by the 2026-08-08 scope directive and is deliberately **not sequenced** on the board; the language track runs to the log-watcher proof first. Board: [00-status.md](../../00-status.md)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
>
> **Style rule (user convention):** concept, reason, and required behavior in words only; the executor writes the code.
**Goal:** Sub-project 5 of the OOP spec — the single binary serves a user interface: `##ui` screens compile to `.htmlx` templates rendered server-side, LIVE subscriptions push delta frames over WebSocket on commit, and the pricing demo's driving workload runs end to end — a price update on the server patches subscribed browsers' cells in place.
**Architecture:** Plan 7 of 7. Depends on plans 1–6. Direction is already locked by the UI track (`docs/plan/exploration/ui/00-overview.md`): **`.htmlx` is the template format; `##ui` is the DSL that emits it** — the v1 engine at `.dev/reference/crates/wo-htmlx/` (bindings, each-blocks, partials, data-bind attributes) is the semantic reference the C renderer ports. The subscription machinery is the Stage-3 story (`docs/runtime/database/04-client-api.md`, plan 13c): a per-shard registry hooked into the commit path emits delta frames to WebSocket subscribers — replacing the honest 501 that plan 6 preserved. Static assets (the client runtime JS) serve per the sendfile doctrine (`docs/plan/08-sendfile-static-assets.md`). The UI track's larger workspace story (apps/, per-app binaries, shared DB daemon — ui docs 04–06) is explicitly OUT of this plan: one binary, its own screens, first.
**Tech Stack:** C11 + libc (WebSocket framing hand-rolled; SHA-1 + base64 for the upgrade handshake hand-rolled — the only crypto in the runtime, ~150 lines, documented). OCaml (##ui parsing, .htmlx emission). Vanilla JS client runtime (~20 KB target per the UI track), no build tooling.
## Global Constraints
- All prior plans' constraints carry over (libc only, no commits — drafts to `.dev/commit.md`, gates, docs under `docs/`).
- **`.htmlx` semantics follow the v1 engine** where features overlap (`{{path}}` bindings, `{{#each}}`, `{{> partial}}`, `data-bind`); the live-subtree extension (`<wo:live source="...">`) is this plan's addition, specified in the format doc it writes.
- **Deltas ride commits:** subscription taps sit AFTER the WAL accepts, never in the read or ack path — a slow subscriber can never delay a commit (the Postgres-mirror tap discipline, applied to WebSockets: overflow drops the subscriber loudly, never blocks the shard).
- **Subscriptions are shard-local:** a socket subscribes on the shard that owns its connection; queries against rows on that shard push directly. Cross-shard live queries are out of scope, stated in the doc.
- **No JS frameworks, no bundlers** — the client runtime is one hand-written file served as a static asset.
---
## File Structure
```
runtime/src/
ws.c ws.h WebSocket upgrade (SHA-1/base64), frame codec, ping/pong (Task 1)
sub.c sub.h per-shard subscription registry + commit tap + delta encode (Task 2)
htmlx.c htmlx.h template parse + SSR render (Task 4)
assets.c assets.h static asset serving, sendfile path (Task 5)
compiler/src/ ##ui parsing + .htmlx emission (Task 3)
client/wo-live.js the DOM-patching client runtime (Task 5)
tests/corpus/ui/ render goldens + live end-to-end scripts (Task 6)
docs/plan/oop-vm/06-ui-live.md .htmlx subset, wo:live semantics, delta frame format (Task 1)
```
---
### Task 1: WebSocket transport
**Concept & reason:** the push channel, hand-rolled to the zero-dep bar: HTTP upgrade handshake (the fixed-GUID SHA-1/base64 accept key — the two primitives implemented locally with test vectors from their RFCs), frame codec (text frames, masking rules, fragmentation tolerated on receive, close handshake, ping/pong), mounted on plan 6's connection machine so a socket upgrades in place and joins the shard's loop. The doc this task writes pins everything downstream: the delta frame JSON shape (kind: insert/update/delete, class, id, changed fields), the subscribe message a client sends, and the `wo:live` template semantics Task 3–5 implement.
- [ ] Failing tests: RFC test vectors for the accept key; frame codec round-trips incl. masked payloads and close; a socket-level upgrade-then-echo harness on the real loop.
- [ ] Implement; green.
- [ ] Record commit draft: `feat(runtime): hand-rolled WebSocket — upgrade handshake (local SHA-1/base64 with RFC vectors), frame codec, ping/pong/close, mounted on the shard loop; docs/plan/oop-vm/06-ui-live.md pins delta/subscribe/wo:live formats.`
### Task 2: Subscription registry + commit taps
**Concept & reason:** Stage 3's engine, per shard. A registry maps subscription keys (class + optional indexed-field filter, the plan-5 WHERE subset) to subscriber lists (socket + subscription id). The commit path gains a tap after the WAL accept: each committed mutation consults the registry, encodes one delta frame, and enqueues it on matching subscribers' write queues with try-semantics — a full queue drops that subscriber with a loud close frame and a log line (the mirror discipline: RAM-side progress never waits on a consumer). The `/api/<type>/live` route flips from 501 to the upgrade + subscribe flow; unsubscribe and disconnect clean the registry.
- [ ] Failing tests: registry match/miss across filters; commit-to-frame flow on a rigged shard (insert/update/delete each produce the right frame); overflow drops the subscriber and only the subscriber; disconnect cleanup; the 501 fixture from plan 6 flips to expecting an upgrade.
- [ ] Implement; green under ASan/TSan (sockets and registry are shard-local — the tests prove no cross-thread traffic exists).
- [ ] Record commit draft: `feat(runtime): LIVE subscriptions — per-shard registry with filter matching, post-WAL commit taps with try-enqueue drop-loudly discipline, /live flips from 501 to upgrade+subscribe; delta frames per the pinned format.`
### Task 3: `##ui` compiles to `.htmlx`
**Concept & reason:** the compiler's side of the locked UI decision. The parser accepts the `##ui` block form the samples use (screen name, source class, projection/columns, filters) and the typechecker validates it against the class (fields exist, filter fields indexed where the live path requires it). Emission writes an `.htmlx` file per screen into the build output beside the `.wob`: bindings for projected fields, an each-block over the source rows, and the screen's live subtree wrapped in `<wo:live source="...">` carrying the subscription key the runtime will register. Hand-written `.htmlx` files in the project pass through untouched (first-class authoring alternative, per the locked decision) — the compiler only validates their `wo:live` sources against the schema.
- [ ] Failing tests: golden `.htmlx` output for a pricing screen fixture; validation diagnostics (unknown field, unfilterable live source); pass-through of a hand-written template with source validation.
- [ ] Implement; green.
- [ ] Record commit draft: `feat(compiler): ##ui emits .htmlx — screen DSL parse/typecheck, binding+each+wo:live template generation with subscription keys, hand-written .htmlx pass-through with source validation.`
### Task 4: `.htmlx` SSR renderer
**Concept & reason:** the C port of the v1 engine's render semantics, scoped to the compiled subset: parse the template once at boot into a node tree (static chunks, bindings, each-blocks, partials, live-subtree markers); render a screen by walking the tree against query results from the plan-5 select path, HTML-escaping bound values, expanding each-blocks per row, and stamping each live subtree with the ids the client runtime needs to patch later (stable per-row element ids derived from class + row id — the contract the delta patcher relies on). Rendered pages route like any handler; the screen's route comes from the service/UI declarations.
- [ ] Failing tests: render goldens (template + fixture rows → exact HTML) covering escaping, each over rows, partials, and live-subtree id stamping; a malformed-template diagnostic at boot, not at request time.
- [ ] Implement; green.
- [ ] Record commit draft: `feat(runtime): .htmlx SSR renderer — boot-time parse to node tree, escaped binding render over select results, stable per-row ids in live subtrees; render goldens.`
### Task 5: Client runtime + static assets
**Concept & reason:** the last mile. `wo-live.js` (hand-written, one file, ~20 KB budget): on load, find `wo:live` subtrees, open the WebSocket, send subscribe messages from the stamped keys, and patch on frames — update replaces bound cell contents by stable id, insert appends a row rendered from a client-side row template the SSR emitted, delete removes the row's element; reconnect with backoff; a visible stale indicator when the socket is down (honesty over silence). Static serving: the asset module serves the JS (and any project assets) with correct content types and the sendfile-doctrine path for regular files.
- [ ] Failing tests: asset serving (content type, byte-exact body, 404 miss); client runtime exercised by the Task-6 end-to-end (no separate JS test harness — stated tradeoff: the e2e is the test).
- [ ] Implement; green.
- [ ] Record commit draft: `feat: wo-live.js client runtime (subscribe from stamped keys, patch update/insert/delete by stable ids, reconnect+stale indicator) + static asset serving on the sendfile path.`
### Task 6: Pricing live demo end to end + acceptance
**Concept & reason:** the spec's driving workload, closed: the pricing project compiles to one binary; a scripted browser-less client (a test WebSocket client speaking the pinned protocol) loads the SSR page, subscribes, then a method RPC (`set_price` over plan 6's route) commits an insert — the script asserts the delta frame arrives with the new amount, and that a second subscriber sees it too (fan-out). A manual demo recipe (`just pricing-live-demo`) serves it for human eyes. Render goldens + the scripted live scenario + the asset tests join `just oop-accept`. Docs closeout: kanban (13c-equivalent milestone on the C stack), CLAUDE.md stage table amendment, the UI track doc gains a status note that the format/live layer shipped and the workspace/per-app-binary layers (ui docs 04–06) remain open.
- [ ] Wire the scenario + recipe + gate; green; docs synced.
- [ ] Record commit draft: `test: pricing live demo e2e — SSR load, subscribe, set_price RPC commit, delta fan-out asserted by scripted WS clients; just pricing-live-demo; oop-accept gains the UI gate; UI-track status synced.`
---
## Plan self-review notes
- **Spec coverage (sub-project 5 + Stage 3):** `##ui` SSR, live subscriptions with delta frames on commit, the pricing live workload, single binary serving UI + API + DB — the spec's "single binary that is the database, the web API, and the UI" sentence is fully mechanized after this plan. Non-scope, stated: cross-shard live queries, the workspace/per-app-binaries/shared-daemon layers (ui docs 04–06), auth/`me` sessions, TLS.
- **Order rationale:** transport before registry before templates before renderer before client — each is the next one's substrate; the e2e needs all five.
- **Consistency check:** delta frame shape, subscribe message, stable-id contract, and `wo:live` semantics are pinned once (Task 1 doc) and consumed by Tasks 2–5; subscription keys originate in the compiler (Task 3) and terminate in the registry (Task 2) — same key format, one doc section.

View file

@ -1,113 +0,0 @@
# writeonce-pl — the `.wo` language at a glance
writeonce is a **declarative full-stack programming language**. You write `.wo` files; the `wo` toolchain compiles them into a single binary that owns its database, serves REST, and pushes live subscriptions. The one-line pitch: **Go + Postgres + `net/http` + Phoenix LiveView, folded into one language and one binary.**
It is declarative by default — programs are built from `type`, `class`, `service`, `policy`, and `on <event>` declarations. A `type` declares a data shape; a `class` is its behavior-bearing sibling — the same fields plus `fn` methods with a `self` receiver. **There is no inheritance**: no `extends`, no overriding, no virtual dispatch — composition via `ref`/`multi`, Go-style. From either declaration the compiler derives the database schema, HTTP endpoints, triggers, and client SDKs. The imperative parts (queries, transactions, method bodies) use a hybrid SQL + Cypher sublanguage.
## The three pillars
| Pillar | Language construct | Status |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
| **Live subscriptions** | `LIVE` prefix on queries; `subscribe` in a service's `expose` list. Deltas push on every commit over WebSocket — no polling. | Stage 3 — `/api/<type>/live` is a 501 stub today |
| **Front-end development** | `##ui` blocks compile to server-rendered HTML plus a small vanilla-JS client runtime. No Node, no React. | Phase 6 — design-only ([spec](runtime/database/06-lowcode-fullstack.md)) |
| **Database DML** | Hybrid SQL + Cypher + document paths: `$name` parameters everywhere, cross-paradigm `RETURNING col AS alias`, one `BEGIN … COMMIT` block syntax. | Query layer prototyped in C++ ([`prototypes/wo-db/`](../prototypes/wo-db/README.md)); schema layer shipped in Stage 2 |
## A complete program
One file is a full application — a database table, six REST endpoints, and a live subscription:
```wo
-- article.wo
type Article {
id: Id
title: Text
body: Markdown
author: Text
created_at: Timestamp = now()
service rest "/api/articles"
expose list, get, create, update, delete, subscribe
}
```
```bash
$ wo run
[wo] listening on :8080
```
All three pillars in ~10 lines: the `type` fields are the DML schema, `expose subscribe` is the live subscription, and (from Phase 6 on) a `##ui` block alongside it renders the screen.
## Implementation status
| Stage | What | Status |
| ----- | ------------------------------------------------------------ | ----------------------------------------------------------- |
| 1 | `wo run <dir>` discovers `.wo` files | ✅ shipped |
| 2 | parser + in-memory engine + REST CRUD | ✅ shipped — `cargo run --bin wo -- run docs/examples/blog` |
| 3 | LIVE subscriptions over WebSocket | pending (501 stub) |
| 4+ | transactional `fn`, policies, triggers, `##ui`, WAL, codegen | design-only |
**Class model status:** `class` declarations parse and serve REST CRUD today (plan 13a, shipped); method execution (13b), live pricing push (13c), and the MVC UI ([plan 14](plan/14-mvc-ui-implementation.md)) follow. Demo: [`examples/pricing/`](examples/pricing/); master plan: [plan 13](plan/13-class-model-live-pricing.md).
The runtime itself targets **zero external dependencies** — all I/O driven directly by Linux kernel primitives (`epoll`, `inotify`, `sendfile`, …); see the [kernel-primitive catalogue](plan/exploration/linux/00-linux.md).
## Where to read next
- [`runtime/wo-language.md`](runtime/wo-language.md) — the full user-facing language overview: toolchain, runtime stdlib, client SDKs
- [`runtime/database.md`](runtime/database.md) — the 7-phase engineering series behind the language
- [`runtime/database/02-wo-language.md`](runtime/database/02-wo-language.md) — the two-layer language spec (schema layer + query layer)
- [`examples/blog/README.md`](examples/blog/README.md) — the canonical worked example
- [`../prototypes/wo-db/README.md`](../prototypes/wo-db/README.md) — the C++ prototype of the query-layer engine
## Understanding the basics
Every layer of writeonce ultimately reduces to one primitive operation: **ask the kernel for memory, store a value in it, read it back**. Walking that operation up the abstraction ladder shows what the `.wo` syntax is actually hiding.
### Level 0 — assembly: the kernel gives you a page
```asm
; x86-64 Linux — map one anonymous page, store 42 in it
mov rax, 9 ; syscall number: mmap
xor rdi, rdi ; addr = NULL (kernel picks)
mov rsi, 4096 ; len = one page
mov rdx, 3 ; prot = PROT_READ | PROT_WRITE
mov r10, 0x22 ; flags = MAP_PRIVATE | MAP_ANONYMOUS
mov r8, -1 ; fd = none
xor r9, r9 ; off = 0
syscall ; rax now holds the page address
mov qword [rax], 42 ; store the value
mov rbx, [rax] ; read it back
```
There is no "variable" — only an address the kernel handed back and a `mov` into it.
### Level 1 — C: the libc wrapper names the address
```c
long *p = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
*p = 42; /* store */
long v = *p; /* read */
```
Same syscall, same page — C just gives the address a typed name and lets the compiler emit the `mov`s. (`malloc` is one more layer: a userland allocator carving up pages obtained exactly this way.)
### Level 2 — `.wo`: the value gets a type, a lifetime, and durability
```wo
type Counter {
name: Text @unique
value: Int = 0
}
main {
insert Counter { name: "visits" }; -- allocate + store
update Counter{ name == "visits" }.value += 1;
let c = select Counter{ name == "visits" };
print(c.value); -- read back
}
```
The `insert` is still, underneath, "obtain memory, write bytes at an offset" — but the declaration has been folded into the language: the `type` decides the layout, the engine owns the allocation (in-RAM rows over `mmap`-backed segments), and Phase 3+ adds what raw memory never had — ACID transactions, WAL durability, and `LIVE` subscribers notified on every store.
This is the whole design in miniature: the runtime is written in Rust against raw kernel primitives (level 0–1, see [`plan/exploration/linux/00-linux.md`](plan/exploration/linux/00-linux.md) and the [assembly stance](plan/exploration/assembly/00-overview.md)), so that the `.wo` author never has to leave level 2.