Projection thesis validated by a real predicament (santi) — and the gaps to close for command/stream services #82

Open
opened 2026-07-20 06:25:16 +00:00 by PerishFire · 1 comment
Owner

Context — a real adopter-candidate validating keel's thesisFeedback from the santi project (an audit-surface service: souls, strands,turns, effects, receipts, webhooks, an embedded web panel). santi hit aconcrete predicament that keel's core thesis appears to solve at the root. Thisissue records the validation and the specific gaps keel would need to closeto actually serve as the cure — offered as roadmap input, not a santi decision.## The predicament keel diagnosessanti hand-writes one HTTP handler per endpoint, each in its own module, eachannotated with #[utoipa::path]. utoipa derives every OpenAPI operation tagfrom the handler's module path (super::effects::effect_status → tag"super::effects"). Consequence: the private source layout is a publiccontract surface. Moving a handler changes the tag → changes the exportedOpenAPI digest → breaks the contract (we proved this with a trial reorg thatturned the contract test red). The same root — identity living in the modulelayout — also makes the persistence tree sprawl into ~16 flat hand-writtenper-domain modules (budget/compact/effects/turns/window/…), each bolting amethod group onto one aggregate type.Root cause: hand-written per-thing code makes the module layout carry domainidentity, which then both sprawls and leaks into contracts.## What keel gets right (the part that solves it)Reading serve.rs, adapt/http.rs, face.rs, and macro/lib.rs:- The HTTP surface is 6 parameterized route templates generic over W: Wire; handlers dispatch by {unit}. Adding a resource adds zero routes and zero handlers.- adapt/http.rs::paths(plan) derives concrete routes from the resource graph. The public surface = fixed verb set × declared #[resource] structs. Source module organization projects nothing into the contract.- face.rs is one uniform verb surface with identity (Who) and cache built in; the store side is the same idea via the Wire seam (docs/conform.md).Because no per-endpoint module exists, no module path can reach a contract, andthe "layout is contract-load-bearing" class of problem cannot occur. This isexactly the cure. keel's projection thesis is validated against a concreteproduction predicament — filing so the design intent has an external witness.## Gaps keel would need to close to be adoptable by a service like santi1. Non-CRUD: commands and streams. santi's surface is not resource CRUD — send_strand, drive_strand, compact_exec, the window doorbell POST, signature-verified webhook ingress, and server-sent event streams (strand events, error_events), plus a self-upgrade flow. Pure resource+relation projection has no home for domain commands or SSE. How far do @pulse/trigger (docs/trigger.md) and capability cover this? Is there an intended projection for (a) named domain commands beyond put/set/end and (b) long-lived read streams?2. Machine contract emission. keel emits no OpenAPI/JSON-schema today (the README table is the informal contract). A service with external clients and a digest gate needs a machine-readable contract — and, per the whole point, it must be a projection of the declared model, not of module paths, so layout stays free. Is a generated OpenAPI (or equivalent) on the roadmap? It would make keel a literal drop-in answer to the utoipa-tag coupling above.3. Contract-migration story. Adopting keel changes the contract shape (uniform REST + /query DSL, no association reads). For a live surface with deployed clients, what's the staged-migration guidance (shadow surface, dual-contract window, etc.)?4. Constitution stance (clarifying question). keel's own negentropy.toml sets only block=4/path=4 — no file, no fanout — while face.rs is 1086 lines in a flat ~15-entry src/, pinned v0.4.0. Is that a deliberate recommended stance (cohesion over 300-line / 10-entry caps), or simply not yet adopted? It directly informs whether adopters should keep those laws. (Noted the maintainer is separately clearing keel's own negentropy debt, so this config may move.)## Offersanti is a willing first real adopter for the genuinely resource-shaped parts(souls, webhooks, possibly strands-as-resource), keeping a hand-written layerfor command/stream endpoints during migration. If keel wants a concreteexternal pressure-test for gaps 1–3, santi is that workload.## ProvenanceInvestigation and the santi-side fanout ruling that prompted it live in thesanti task record (liberte-v050-fanout-{proposal,ruling}.md). keel filesread: crates/keel/src/{serve.rs,adapt/http.rs,face.rs},crates/macro/src/lib.rs, docs/conform.md, README.

## Context — a real adopter-candidate validating keel's thesisFeedback from the **santi** project (an audit-surface service: souls, strands,turns, effects, receipts, webhooks, an embedded web panel). santi hit aconcrete predicament that keel's core thesis appears to solve at the root. Thisissue records the validation **and** the specific gaps keel would need to closeto actually serve as the cure — offered as roadmap input, not a santi decision.## The predicament keel diagnosessanti hand-writes one HTTP handler per endpoint, each in its own module, eachannotated with `#[utoipa::path]`. utoipa derives every OpenAPI operation `tag`from the handler's **module path** (`super::effects::effect_status` → tag`"super::effects"`). Consequence: the private source layout **is** a publiccontract surface. Moving a handler changes the tag → changes the exportedOpenAPI digest → breaks the contract (we proved this with a trial reorg thatturned the contract test red). The same root — identity living in the modulelayout — also makes the persistence tree sprawl into ~16 flat hand-writtenper-domain modules (budget/compact/effects/turns/window/…), each bolting amethod group onto one aggregate type.Root cause: **hand-written per-thing code makes the module layout carry domainidentity, which then both sprawls and leaks into contracts.**## What keel gets right (the part that solves it)Reading `serve.rs`, `adapt/http.rs`, `face.rs`, and `macro/lib.rs`:- The HTTP surface is **6 parameterized route templates** generic over `W: Wire`; handlers dispatch by `{unit}`. Adding a resource adds zero routes and zero handlers.- `adapt/http.rs::paths(plan)` derives concrete routes from the **resource graph**. The public surface = fixed verb set × declared `#[resource]` structs. Source module organization projects **nothing** into the contract.- `face.rs` is one uniform verb surface with identity (`Who`) and cache built in; the store side is the same idea via the `Wire` seam (`docs/conform.md`).Because no per-endpoint module exists, no module path can reach a contract, andthe "layout is contract-load-bearing" class of problem cannot occur. This isexactly the cure. **keel's projection thesis is validated against a concreteproduction predicament — filing so the design intent has an external witness.**## Gaps keel would need to close to be adoptable by a service like santi1. **Non-CRUD: commands and streams.** santi's surface is not resource CRUD — `send_strand`, `drive_strand`, `compact_exec`, the window doorbell POST, **signature-verified webhook ingress**, and **server-sent event streams** (`strand events`, `error_events`), plus a self-upgrade flow. Pure resource+relation projection has no home for domain commands or SSE. How far do `@pulse`/trigger (`docs/trigger.md`) and capability cover this? Is there an intended projection for (a) named domain commands beyond put/set/end and (b) long-lived read streams?2. **Machine contract emission.** keel emits no OpenAPI/JSON-schema today (the README table is the informal contract). A service with external clients and a digest gate needs a machine-readable contract — and, per the whole point, it must be a **projection of the declared model**, not of module paths, so layout stays free. Is a generated OpenAPI (or equivalent) on the roadmap? It would make keel a literal drop-in answer to the utoipa-tag coupling above.3. **Contract-migration story.** Adopting keel changes the contract *shape* (uniform REST + `/query` DSL, no association reads). For a live surface with deployed clients, what's the staged-migration guidance (shadow surface, dual-contract window, etc.)?4. **Constitution stance (clarifying question).** keel's own `negentropy.toml` sets only `block=4`/`path=4` — no `file`, no `fanout` — while `face.rs` is 1086 lines in a flat ~15-entry `src/`, pinned v0.4.0. Is that a **deliberate recommended stance** (cohesion over 300-line / 10-entry caps), or simply not yet adopted? It directly informs whether adopters should keep those laws. (Noted the maintainer is separately clearing keel's own negentropy debt, so this config may move.)## Offersanti is a willing first real adopter for the genuinely resource-shaped parts(souls, webhooks, possibly strands-as-resource), keeping a hand-written layerfor command/stream endpoints during migration. If keel wants a concreteexternal pressure-test for gaps 1–3, santi is that workload.## ProvenanceInvestigation and the santi-side fanout ruling that prompted it live in thesanti task record (`liberte-v050-fanout-{proposal,ruling}.md`). keel filesread: `crates/keel/src/{serve.rs,adapt/http.rs,face.rs}`,`crates/macro/src/lib.rs`, `docs/conform.md`, README.
Author
Owner

Answering the clarifying question directly, since it was load-bearing for whether adopters should keep those laws: the flat 1086-line face.rs in a ~15-entry src/ at pin v0.4.0 was unadopted debt, not a deliberate cohesion-over-caps stance. We've now adopted negentropy v0.5.0 (file≤300, fanout≤10) across the whole fleet (keel, and the two downstreams) and cleared keel's own violations. So: keep those laws. The config will show v0.5.0 on the pin; there is no per-repo exemption.

What the refactor round actually taught us — offered because santi hit its own fanout ruling and some of this transfers:

The layout split validated cleanly. src/ regroups by layer — model/ (atom, bond, spec, graph, ddl, plan) and run/ (cap, life, query, face), with serve/wire/config/adapt/lib staying top-level. The key move: lib.rs re-exports every submodule at the crate root, so both crate::X and keel::X paths are unchanged — the entire regroup was zero body edits, and the public surface is byte-identical (same pub use set in lib.rs). This is the same projection discipline as the HTTP surface: the layout carries no contract. Reorganizing module paths here changes nothing an adopter can observe, which is the property this whole issue is about.

file≤300 surfaced real seams, not arbitrary chops. The three giants split along genuine cohesion lines: query → parse/scan/check/run/sift; face → core/deed/tx/grant/verb/link; life → make/edit/link/util. Inherent impl blocks fan across files (Rust allows this within a crate). The law paid for itself in navigability.

The one sharp friction — and it's the one relevant to your fanout ruling: Rust's integration-test model fights fanout≤10. tests/*.rs are auto-discovered as separate test binaries; files in tests/subdir/ are not discovered. So you cannot reduce tests/ fanout by filesystem grouping alone — you must add explicit [[test]] entries in Cargo.toml pointing at per-group main.rs drivers that mod-include the group. We regrouped 14 flat suites into 5 domain binaries (cap/query/data/bond/pulse) this way; it works and stays green, but it couples the layout to Cargo.toml in a way the source tree doesn't need. If santi's fanout ruling touches its test tree, budget for that — the law assumes filesystem grouping is free, and for Rust binaries it isn't.

Cost: ~+320 net lines, almost entirely repeated per-file use headers and impl wrappers. Minor encapsulation erosion too — methods that were file-private become pub(super)/pub(crate) once their impl is split across files. Both are real but small; neither changed our conclusion.

No better taxonomy emerged from the round — the layer split held up under the hard case (5186-line engine crate), which is itself the useful result: the ruling was sound, adopt as-is.

Gaps 1–3 (domain commands + SSE streams, machine contract emission, staged migration) are separate roadmap items and tracked as such — this note is scoped to the constitution question. The adopter offer is noted and appreciated; the resource-shaped parts (souls, webhooks) are exactly where keel is ready today.

## Gap 4 — resolved: file≤300 / fanout≤10 are now the recommended stance (v0.5.0, fleet-wide) Answering the clarifying question directly, since it was load-bearing for whether adopters should keep those laws: the flat 1086-line `face.rs` in a ~15-entry `src/` at pin v0.4.0 was **unadopted debt, not a deliberate cohesion-over-caps stance.** We've now adopted negentropy v0.5.0 (`file≤300`, `fanout≤10`) across the whole fleet (keel, and the two downstreams) and cleared keel's own violations. So: **keep those laws.** The config will show `v0.5.0` on the pin; there is no per-repo exemption. What the refactor round actually taught us — offered because santi hit its own fanout ruling and some of this transfers: **The layout split validated cleanly.** `src/` regroups by layer — `model/` (atom, bond, spec, graph, ddl, plan) and `run/` (cap, life, query, face), with serve/wire/config/adapt/lib staying top-level. The key move: `lib.rs` re-exports every submodule at the crate root, so **both `crate::X` and `keel::X` paths are unchanged** — the entire regroup was zero body edits, and the public surface is byte-identical (same `pub use` set in `lib.rs`). This is the same projection discipline as the HTTP surface: the layout carries no contract. Reorganizing module paths here changes nothing an adopter can observe, which is the property this whole issue is about. **`file≤300` surfaced real seams, not arbitrary chops.** The three giants split along genuine cohesion lines: `query` → parse/scan/check/run/sift; `face` → core/deed/tx/grant/verb/link; `life` → make/edit/link/util. Inherent `impl` blocks fan across files (Rust allows this within a crate). The law paid for itself in navigability. **The one sharp friction — and it's the one relevant to your fanout ruling: Rust's integration-test model fights `fanout≤10`.** `tests/*.rs` are auto-discovered as separate test binaries; files in `tests/subdir/` are *not* discovered. So you cannot reduce `tests/` fanout by filesystem grouping alone — you must add explicit `[[test]]` entries in `Cargo.toml` pointing at per-group `main.rs` drivers that `mod`-include the group. We regrouped 14 flat suites into 5 domain binaries (cap/query/data/bond/pulse) this way; it works and stays green, but it couples the layout to `Cargo.toml` in a way the source tree doesn't need. If santi's fanout ruling touches its test tree, budget for that — the law assumes filesystem grouping is free, and for Rust binaries it isn't. **Cost:** ~+320 net lines, almost entirely repeated per-file `use` headers and `impl` wrappers. Minor encapsulation erosion too — methods that were file-private become `pub(super)`/`pub(crate)` once their `impl` is split across files. Both are real but small; neither changed our conclusion. No better taxonomy emerged from the round — the layer split held up under the hard case (5186-line engine crate), which is itself the useful result: the ruling was sound, adopt as-is. Gaps 1–3 (domain commands + SSE streams, machine contract emission, staged migration) are separate roadmap items and tracked as such — this note is scoped to the constitution question. The adopter offer is noted and appreciated; the resource-shaped parts (souls, webhooks) are exactly where keel is ready today.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
PerishLab/keel#82
No description provided.