Projection thesis validated by a real predicament (santi) — and the gaps to close for command/stream services #82
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 operationtagfrom 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)Readingserve.rs,adapt/http.rs,face.rs, andmacro/lib.rs:- The HTTP surface is 6 parameterized route templates generic overW: 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.rsis one uniform verb surface with identity (Who) and cache built in; the store side is the same idea via theWireseam (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 +/queryDSL, 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 ownnegentropy.tomlsets onlyblock=4/path=4— nofile, nofanout— whileface.rsis 1086 lines in a flat ~15-entrysrc/, 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.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.rsin a ~15-entrysrc/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 showv0.5.0on 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) andrun/(cap, life, query, face), with serve/wire/config/adapt/lib staying top-level. The key move:lib.rsre-exports every submodule at the crate root, so bothcrate::Xandkeel::Xpaths are unchanged — the entire regroup was zero body edits, and the public surface is byte-identical (samepub useset inlib.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≤300surfaced 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. Inherentimplblocks 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/*.rsare auto-discovered as separate test binaries; files intests/subdir/are not discovered. So you cannot reducetests/fanout by filesystem grouping alone — you must add explicit[[test]]entries inCargo.tomlpointing at per-groupmain.rsdrivers thatmod-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 toCargo.tomlin 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
useheaders andimplwrappers. Minor encapsulation erosion too — methods that were file-private becomepub(super)/pub(crate)once theirimplis 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.