- Board renamed docs/plan/00-kanban.md -> docs/00-status.md and rebuilt: ▶ NEXT PLAN pointer (iteration 4 — emitter, corpus, `woc build`) then six buckets — stories, in progress, done, pending, discarded, learnings. It covered only the Rust runtime before, so the whole OOP track was invisible. All 16 inbound refs repointed; `Kanban:` banners renamed to `Status:`. - New discarded.md (settled rejections with reasons: inheritance, `abstract`, Money/SKU/Float, Dynamic/cast/macro/extern, AOT-to-C, Menhir, shared engine state) and learnings.md (plumbed≠enforced, vacuous goldens, exit-0-wrong- output, malloc-path ASan trick, deferred checks that never reach the VM). - RECOVERED docs/plan/exploration/blue-green-vm/00-vision.md — gone from disk, never committed (gitignored path), cited by five docs incl. principle 12. Root cause was broader: all seven forward-roadmap plans in docs/superpowers/plans/ were untracked and ignored, on one disk only. Dropped the docs ignore rules with a do-not-re-add note; added __pycache__/*.pyc. - Repaired broken links across docs/, 270 -> 36: fixes a regression from the earlier reference/ -> .dev/reference/ move (relative paths at ../../ and deeper were skipped), plus depth and reorg drift. The 36 residual point at content that does not exist and need decisions, not paths. - New spec docs/superpowers/specs/2026-08-10-logwatcher-gap-closure-design.md, applied: `and`/`or` verdict row; Part 3 gains `env` (six modules), swaps time.mono for iso/local, adds 22 bare core builtins; throw/time.mono/is cut (0 uses in the sample). Plan 8: Task 2 gains and/or, Task 5 drops throw, abstract+`is` task deleted, 8/9 renumber to 7/8. Plan 9 gains core builtins. Plan 10 gains the 307 -> 0 diagnostic gate. WO-E205 re-filed unreachable-by- design. types.ml header drops its false satisfaction-set claim. 00-code- review.md reduced to a stub — its rival Phase 1-4 roadmap retired.
7 KiB
07 — Per-app policy composition
Context sources: ./00-overview.md decision 7 (L36) and "Goal" (L26), ./04-workspace-layout.md (where app-scope policies live), ./06-shared-db-daemon.md (where composition is evaluated), docs/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 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)
- AND-only composition. App-scope can narrow, never broaden. Anchored in
./00-overview.mddecision 7 (L36). - Three carrier locations. (a) Global
policy read/writeblocks besidetypedeclarations inshared/types/<type>.wo— already in the language. (b)policyblocks insideapps/<X>/app.wo— new structured form parsed in this phase. (c)role: <RoleSet>clauses inside##uiblocks andactions:rows — already in samples (e.g.docs/examples/ecommerce/apps/admin/ui/orders/orders.woL18, L50–53). All three feed the sameEffectivePolicycomposer. - 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. role:is a set expression, not a single string.Admin | Opsparses toRoleSet::union(Admin, Ops). Anchored indocs/examples/ecommerce/apps/admin/ui/orders/orders.woL18.- Build-time domain check.
wo build apps/<X>walks everyrole: <set>referenced by the app and verifies each role is one the global type policy actually defines for that type. An app cannot mentionrole: Anonymousagainst 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)
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
cargo build -p policy -p db -p appgreen.- AND composition. Unit test
compose(global = "published == true", app_scope = "owner == $session.id", t = Article)returns anEffectivePolicywhoseallows_readis true only when both clauses hold. - Build-time domain check fires. A test workspace where
apps/storefront/app.wodeclaresrole: Anonymousagainst a type whose global policy does not defineAnonymous—wo build apps/storefrontexits non-zero withPolicyDomainError. - Cross-app integration. Two storefront customers issue the same
GET /api/ordersagainst the daemon; each sees only their own rows (storefront app-scope narrows global). Admin sees both. Test runs against the phase-06 daemon. - v1 regression.
wo run docs/examples/blogboots; the globalpolicy read for anyone when published == trueon the blogArticletype continues to gate anonymous reads as it does today. 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
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.