- 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.
105 lines
7 KiB
Markdown
105 lines
7 KiB
Markdown
# 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.
|