Root-cause option for the fanout/contract-coupling predicament: keel-style projection #31

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

Summary

The v0.5.0 fanout campaign cleared all 24 file-length faults but stalled on 5
fanout faults. Liberte ratified per-directory scoped limits (server=12,
store=16, service=20, tests/service=11) plus a regroup of web/src/components
(ruling: .task/resources/liberte-v050-fanout-ruling.md). That closes the
adoption, but it treats symptoms. Reading ../keel (PerishLab/keel — a "data
model description engine") surfaced what looks like the root cause and a
structural cure. This issue records the investigation so we can decide whether
to open a design round on it. No code proposed here — strategic direction.

The predicament, restated

Three of the five stuck directories looked like distinct problems; they are one:

  • santi-api/src/server (12) — contract-load-bearing. utoipa derives each
    OpenAPI operation tag from the handler's module path (super::effects:: effect_status → tag "super::effects"). Moving a handler changes the tag,
    which moves the exported OpenAPI digest (proven: a trial reorg turned the
    contract test red, reverted). The private source layout is a public
    contract surface.
  • santi-core/src/store (16) and santi-core/src/service (20)
    deliberately flat: one hand-written module per domain concern
    (budget/compact/effects/errors/turns/window/…), each bolting a method group
    onto the single SantiStore / Service type.

Common root: santi hand-writes per-domain and per-endpoint code, so the
module layout carries domain identity.
Once identity lives in the layout it
(a) sprawls (fanout) and (b) leaks into contracts (utoipa tags). Splitting
files and ratifying fanout limits are symptom management.

What keel does differently

keel's thesis (README): business code defines resources, fields, and
relations only; control fields and control capabilities stay in the engine;
HTTP and store adaptors are projections of the engine surface, not a business
authoring API.
Concretely:

  • serve.rs::app() is 6 route templates, all parameterized by
    {unit}/{id}/{bond}/{tie}, all generic over W: Wire. The handlers
    (list/create/one/edit/remove/attach/…) are uniform and dispatch
    to the engine by {unit}. Adding a resource adds zero routes and zero
    handlers
    — the same 6 templates, {unit} just resolves to a new table.
  • adapt/http.rs::paths(plan) enumerates concrete routes from the resource
    graph (plan.units()). The public surface = fixed verb set × declared
    #[resource] structs. The contract is derived from the declared model;
    source module organization projects nothing into it.
  • face.rs (Core/Face/Tx) is one uniform verb surface (put/set/live/query/
    ask/end/tie/ties/cut/flow) with identity (Who::{Sudo,Op,Anon}) and cache
    built in. macro/lib.rs expands #[resource] into a plan contribution.
  • Store portability is the same idea on the persistence side: one Wire
    seam, one copy of the logic, SQL dialect the only per-store difference
    (docs/conform.md).

Why this dissolves all three symptoms

Remove the identity-bearing hand-written code and the layout stops carrying
identity:

  • server contract-coupling vanishes — no per-endpoint handler modules, so
    no module path can reach an OpenAPI tag. The contract becomes a canonical
    projection of the resource graph, decoupled from file layout. The fanout law
    can never again collide with contract stability.
  • the 16 flat store modules collapse — hand-written per-domain persistence
    becomes resource declarations + one engine + one Wire seam. Not split or
    regrouped: replaced.

So the choice we framed to Liberte ("regroup vs ratify a limit") is
symptom-level; keel offers cutting the root.

Honest fit / gap / risk

  • This is a re-platform, not a refactor. Touches store, API, contract, and
    possibly the domain model.
  • santi is not CRUD. strands/turns/effects/receipts/compaction,
    send_strand/drive_strand, the window doorbell, signature-verified webhook
    ingress, SSE streams, self-upgrade — stateful workflows and streams, not
    resource+relation. keel today is resource-CRUD + query DSL + tie/cut +
    capability + trigger, still staging S0→S6 (cache/estate unlanded). santi's
    command/stream endpoints have no direct home in a pure projection.
  • The contract is live and gated. OpenAPI digest gate, production clients
    (web panel, CLI). keel projects a different contract shape (uniform REST +
    /query DSL, no association reads) and emits no OpenAPI today. Migration
    is a contract break → W-C-style staged rollout + Liberte rounds.
  • keel is young — pinned negentropy v0.4.0, single-author, weeks old.
    Betting the production edge on it is a real commitment.
  • It doesn't relieve the immediate task. F1/F2 (Liberte-ratified) close
    v0.5.0 adoption now; keel is a longer-horizon bet, not a substitute.

A second signal worth recording

keel's own negentropy.toml sets only block=4/path=4no file limit,
no fanout limit
— while face.rs is 1086 lines in a flat ~15-entry src/,
pinned v0.4.0. The same author, in a contemporaneous active project,
deliberately values cohesion (one conceptual surface = one file / one flat
namespace) over the 300-line / 10-entry caps. That reframes our fanout question:
the honest answer for flat aggregates may not be "ratify per-directory limits"
but "reconsider whether file/fanout belong in santi's constitution at all,"
with keel as the precedent. (Note: the operator is separately clearing keel's
own negentropy debt, so keel's config may evolve.)

Recommendation

Two legs, not one:

  1. Finish v0.5.0 adoption via the ratified F1/F2 to stop the bleeding
    (web regroup, then pin flip + scoped fanout stanzas). Ready now.
  2. Open a design round on moving santi's API + store onto a keel-style
    projection — starting with the genuinely resource-shaped parts (souls,
    webhooks, possibly strands-as-resource), keeping a hand-written layer for
    command/stream endpoints, and facing head-on whether keel's projected
    contract replaces utoipa and whether file/fanout then leave the
    constitution.

References

  • Fanout proposal / ruling: .task/resources/liberte-v050-fanout-{proposal, ruling}.md
  • keel: PerishLab/keel (git.perish.top); key files read —
    crates/keel/src/{serve.rs,adapt/http.rs,face.rs}, crates/macro/src/lib.rs,
    docs/conform.md, README.
## Summary The v0.5.0 fanout campaign cleared all 24 file-length faults but stalled on 5 `fanout` faults. Liberte ratified per-directory scoped limits (server=12, store=16, service=20, tests/service=11) plus a regroup of `web/src/components` (ruling: `.task/resources/liberte-v050-fanout-ruling.md`). That closes the adoption, but it treats symptoms. Reading `../keel` (PerishLab/keel — a "data model description engine") surfaced what looks like the **root cause** and a structural cure. This issue records the investigation so we can decide whether to open a design round on it. **No code proposed here — strategic direction.** ## The predicament, restated Three of the five stuck directories looked like distinct problems; they are one: - **`santi-api/src/server` (12)** — contract-load-bearing. utoipa derives each OpenAPI operation `tag` from the handler's *module path* (`super::effects:: effect_status` → tag `"super::effects"`). Moving a handler changes the tag, which moves the exported OpenAPI digest (proven: a trial reorg turned the contract test red, reverted). The private source layout **is** a public contract surface. - **`santi-core/src/store` (16)** and **`santi-core/src/service` (20)** — deliberately flat: one hand-written module per domain concern (budget/compact/effects/errors/turns/window/…), each bolting a method group onto the single `SantiStore` / `Service` type. Common root: **santi hand-writes per-domain and per-endpoint code, so the module layout carries domain identity.** Once identity lives in the layout it (a) sprawls (fanout) and (b) leaks into contracts (utoipa tags). Splitting files and ratifying fanout limits are symptom management. ## What keel does differently keel's thesis (README): *business code defines resources, fields, and relations only; control fields and control capabilities stay in the engine; HTTP and store adaptors are projections of the engine surface, not a business authoring API.* Concretely: - `serve.rs::app()` is **6 route templates**, all parameterized by `{unit}/{id}/{bond}/{tie}`, all generic over `W: Wire`. The handlers (`list`/`create`/`one`/`edit`/`remove`/`attach`/…) are uniform and dispatch to the engine by `{unit}`. **Adding a resource adds zero routes and zero handlers** — the same 6 templates, `{unit}` just resolves to a new table. - `adapt/http.rs::paths(plan)` enumerates concrete routes from the resource graph (`plan.units()`). The public surface = fixed verb set × declared `#[resource]` structs. **The contract is derived from the declared model; source module organization projects nothing into it.** - `face.rs` (Core/Face/Tx) is one uniform verb surface (put/set/live/query/ ask/end/tie/ties/cut/flow) with identity (`Who::{Sudo,Op,Anon}`) and cache built in. `macro/lib.rs` expands `#[resource]` into a plan contribution. - Store portability is the same idea on the persistence side: one `Wire` seam, one copy of the logic, SQL dialect the only per-store difference (`docs/conform.md`). ## Why this dissolves all three symptoms Remove the identity-bearing hand-written code and the layout stops carrying identity: - **server contract-coupling vanishes** — no per-endpoint handler modules, so no module path can reach an OpenAPI tag. The contract becomes a canonical projection of the resource graph, decoupled from file layout. The fanout law can never again collide with contract stability. - **the 16 flat store modules collapse** — hand-written per-domain persistence becomes resource declarations + one engine + one Wire seam. Not split or regrouped: **replaced.** So the choice we framed to Liberte ("regroup vs ratify a limit") is symptom-level; keel offers cutting the root. ## Honest fit / gap / risk - **This is a re-platform, not a refactor.** Touches store, API, contract, and possibly the domain model. - **santi is not CRUD.** strands/turns/effects/receipts/compaction, `send_strand`/`drive_strand`, the window doorbell, signature-verified webhook ingress, SSE streams, self-upgrade — stateful workflows and streams, not resource+relation. keel today is resource-CRUD + query DSL + tie/cut + capability + trigger, still staging S0→S6 (cache/estate unlanded). santi's command/stream endpoints have no direct home in a pure projection. - **The contract is live and gated.** OpenAPI digest gate, production clients (web panel, CLI). keel projects a *different* contract shape (uniform REST + `/query` DSL, no association reads) and **emits no OpenAPI today**. Migration is a contract break → W-C-style staged rollout + Liberte rounds. - **keel is young** — pinned negentropy v0.4.0, single-author, weeks old. Betting the production edge on it is a real commitment. - **It doesn't relieve the immediate task.** F1/F2 (Liberte-ratified) close v0.5.0 adoption now; keel is a longer-horizon bet, not a substitute. ## A second signal worth recording keel's own `negentropy.toml` sets only `block=4`/`path=4` — **no `file` limit, no `fanout` limit** — while `face.rs` is 1086 lines in a flat ~15-entry `src/`, pinned v0.4.0. The same author, in a contemporaneous active project, deliberately values cohesion (one conceptual surface = one file / one flat namespace) over the 300-line / 10-entry caps. That reframes our fanout question: the honest answer for flat aggregates may not be "ratify per-directory limits" but "reconsider whether `file`/`fanout` belong in santi's constitution at all," with keel as the precedent. (Note: the operator is separately clearing keel's own negentropy debt, so keel's config may evolve.) ## Recommendation Two legs, not one: 1. **Finish v0.5.0 adoption via the ratified F1/F2** to stop the bleeding (web regroup, then pin flip + scoped fanout stanzas). Ready now. 2. **Open a design round** on moving santi's API + store onto a keel-style projection — starting with the genuinely resource-shaped parts (souls, webhooks, possibly strands-as-resource), keeping a hand-written layer for command/stream endpoints, and facing head-on whether keel's projected contract replaces utoipa and whether `file`/`fanout` then leave the constitution. ## References - Fanout proposal / ruling: `.task/resources/liberte-v050-fanout-{proposal, ruling}.md` - keel: `PerishLab/keel` (`git.perish.top`); key files read — `crates/keel/src/{serve.rs,adapt/http.rs,face.rs}`, `crates/macro/src/lib.rs`, `docs/conform.md`, README.
Author
Owner

Misfiled — this is feedback for keel, not a santi decision. Refiling on PerishLab/keel and closing here.

Misfiled — this is feedback for keel, not a santi decision. Refiling on PerishLab/keel and closing here.
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
PerishFire/santi#31
No description provided.