VibeVM
Contents
On this page
en
Publisher
org.vibevm.core
Version
1.0.0latest
Audiences
Reading time
13 min
Rendered
Read aloud
never

Typed seams — where obligation moves into the type, and where it deliberately does not

01Companion to: BACKLOG.md B-040 (closed by 1f048058) (the owner's ruling and the census pointer), the census itself (harvest/g1-b040-seams-census.md), and the guide anchor the finding hangs on — GUIDE-AI-NATIVE-RUST.xml ##SCAFFOLD-B-TYPED-BUILDERS. This document is lore: it records why the refactor is shaped this way, and every contract it touches wins wherever they disagree.

1. The measured basis, and the one thing it does not say

02The census measured five idioms and found two of them absent outright. 24 pub trait declarations, none sealed; one runtime-validating builder (ActionBuilder), zero typestate, PhantomData absent from the whole perimeter; 146 #[must_use], 82 % of them on vibe-cli's TUI setter chains; mature validating newtypes on the vibe-core identity seam; ~140 pub cross-crate serde types of which 7 validate at load. (Re-measured 2026-08-05 after this design's own landings: pub trait 24, PhantomData 0 and sealing constructs 0 are unchanged; #[must_use] is now 145, because the builder landing removed the invoke setter, and validate-at-load is now 11 in the same perimeter, because the wire landing converted four newtypes. Both moved for the reason this document exists, which is the only kind of staleness a design's basis is allowed.)

03An idiom's absence is a question, not a verdict. The census is a count of constructs, and a count cannot say whether a construct was needed. PhantomData: 0 is a defect exactly where a wrong call is representable today and the type could have forbidden it — and is a non-event everywhere else. So this design does not schedule the five idioms; it looks for calls that can be made wrongly today and asks, one at a time, whether a type can refuse them.

04The question that produced this build's list: where does the tree state an obligation on a caller or an implementor, in prose, with nothing checking it? That question crosses the census's five categories rather than following them, and it found four sites — one of them security-relevant and, until this design, invisible in every count the census took.

2. The scope rule is a request, and that is the strongest finding

05Measured before landing (1); the landing is what changed it. RepoCreator stated an implementor obligation in a doc comment and enforced it nowhere. crates/vibe-publish/src/creator.rs:153–155: «Default impl uses expected_org. Concrete impls call this from repo_exists / create_repo before any side-effecting work.» validate_scope is a default method with a correct body — and nothing makes any implementation call it.

06The rule it protects is not stylistic. vibevm/vibespecs/boot/90-user.xml ##SCOPE-DISCIPLINE and PROP-002 §2.10 («Never escalate scope») bind the publisher to the org its project declares: create only inside vibespecs, never in another org, never in a user namespace. An implementation that forgets the call compiles, passes review as «looks like the others», and escalates scope on its first real run.

07Measured before landing (1) was specified, and the measurement narrowed the claim: every implementation honoured the obligation by convention. (Today the convention is the type: github and gitverse no longer call the check at all, because their callers cannot reach them without having called it.) github.rs and gitverse.rs open both repo_exists and create_repo with self.validate_scope(org)? as their first statement; direct_git.rs overrides validate_scope to a deliberate no-op — a direct-push target has no org to escalate into — and its repo_exists returns true unconditionally, so create_repo is unreachable by construction. So this is a latent hole, not a live one, and that makes the landing stronger rather than weaker: the convention is already universal, so encoding it in the type is a zero-behaviour-change refactor whose whole yield is that the next implementation cannot depart from it. A design that had claimed a live escalation would have been wrong, and the difference was one awk over three files.

08Put the check in the type: a ValidatedOrg only validate_scope can mint. repo_exists and create_repo stop taking org: &str and take &ValidatedOrg; the newtype's field is private and its only constructor is the scope check. Forgetting to validate then does not compile, because there is no other way to obtain the argument. This is scaffold-B's own sentence — make the wrong call unrepresentable — applied to the one place in the tree where the wrong call is both possible and dangerous.

09The cost is bounded and known. Three production implementations (github.rs:147, gitverse.rs:126, direct_git.rs:63), one cross-crate test mock (vibe-cli/src/commands/workspace/tests.rs:84), and the orchestrator's call sites. The signature change is what forces each one through the mint — that is the point, not a side effect.

10The same seam also carries an uneven test. Scope assertions per implementation file: github.rs 8 mentions, gitverse.rs 3, direct_git.rs 3 — and direct_git's is assert!(c.validate_scope("anything").is_ok()), which is the absence of a scope, not its enforcement. What no test asserts anywhere is the obligation itself — that a side-effecting method REFUSES an out-of-org argument — as distinct from validate_scope returning the right answer when called directly. After the newtype lands that test is unnecessary for the type-checked half and becomes one table-driven test over three implementations for the half a type cannot hold.

3. One digest, two spellings — and the law that forbids the obvious fix

11The same digest is a validated newtype in one crate and a bare String in another — and measurement makes the gap wider than «two spellings». progress-core's hasher returns format!("{:x}", …), bare hex with no sha256: prefix, while vibe-core::ContentHash::parse requires that prefix and rejects a value without it. The two are not one value written two ways; they are different formats, which is a second and independent reason a shared type was never on the table. vibe-core ships ContentHash(String) with parse() checking the algorithm prefix and hex shape (content_hash.rs:36–62). progress-core carries the same digest as pub content_hash: String on the cache record (cache.rs:39) and on ParsedDoc/unit (doc.rs:82, :140), returns it bare from pub fn content_hash(s: &str) -> String (parse/mod.rs:60), and holds it twice more as now: String / was: Option<String> on SealClaim (seal.rs:38, :40).

12The first design of this landing was «progress-core imports vibe_core::ContentHash», and a comment in a manifest refuted it before a line was written. crates/progress-core/Cargo.toml carries the rule inline — «Separability law (PROP-043 §2): NO vibe- crates here, ever» — and it is not a preference: ##SEP-CORE makes the facts core a standalone layer hosted inside vibevm, extractable at any moment, whose core depends on no vibevm subsystem. Two spellings of one digest is therefore the price of a deliberate architecture*, not an oversight to consolidate.

13What survives the refutation is smaller and still real: inside progress-core the digest is interchangeable with any other string. The crate compares processed_hash against content_hash to decide staleness — the comparison the whole campaign's re-judgement queue is derived from — and at the type level that is String == String, which would be just as happy comparing a path or a batch id. A crate-local newtype (progress_core::Digest, minted by the crate's own hasher, parsed at the cache boundary) would restore the distinction without importing anything — and was nonetheless declined, for the reason two facts below. The diagnosis stands; the prescription did not survive it.

14And the guarantee stops exactly where the dangerous case begins. The first cut of the sentence above also claimed the newtype would catch comparing a file's hash against ANOTHER file's hash. It would not: both sides are then Digest, and the swap type-checks as happily as before. What a newtype forbids is a hash confused with a different KIND of string; what it cannot forbid is two values of the same kind used in the wrong roles — and the wrong-roles mistake is the plausible one here, since processed_hash and content_hash sit on one record and mean different things. Saying so is what demotes this landing below the builder: it buys a real distinction and not the one the comparison most needs, and the tool that would catch the role swap is a test over the staleness predicate, not a type.

15Two newtypes over one concept in two separable crates is parity, not duplication — the same invariant, never the same code, which is this project's standing reading of parity. The duplication that would matter is two hash calculators; there is one per format, and each crate owns its own boundary. The argument is sound and was not acted on — the tree carries one newtype and one bare String, not two newtypes, because the landing that would have made the pair was declined below.

16NOT BUILT, and the measurement that settled it is the comparison the landing was justified by. The staleness read is baseline/project.rs: it takes processed_hash out of the campaign record as an untyped serde_json::Value, and_then(as_str), and compares that &str against doc.content_hash. A newtype on the ParsedDoc side cannot type-check that comparison at all — the other side is JSON, and typing it too would put Digest on both sides, which is the role-swap case a newtype was already shown not to catch. So at the one site that carried the argument the yield is not small, it is zero; what remains is forbidding a hash confused with a path or a batch id inside the crate, for which the census found no instance, at a cost of roughly sixty sites in progress-core plus twenty-nine in vibe-cli and a serde form that must keep the committed cache.json byte-identical. Recorded as a decision, not deferred as work. Newtypes were never the census's gap either: vibe-core's identity seam is mature and the two absent idioms were sealing and typestate, both answered above.

17Reading that comparison for the newtype's sake found what it did owe, and it was five lines. The read was is_some_and(|h| h != content_hash), so a record carrying verdicts and no processed_hash came back false — reported neither stale nor undated, and projected as fresh. The sibling guard three lines above does the opposite for verified_at, reporting the absence rather than defaulting through it, which is what makes this an oversight rather than a policy. Absence is now not a match, with a test pinning that it is reported as stale and not as undated — the two missing fields stay distinguishable. A landing declined on measurement paid for itself by the reading that declined it.

4. Validation at the wire boundary — a docblock that reads like a constraint

18Measured before landing (2). Of the identity newtypes that validate, exactly one validated on the wire. Group is serde(try_from = "String", into = "String") (package_ref.rs:106) and rejects a malformed value at deserialize. ContentHash, PackageName, CapabilityNamespace, CapabilityName are serde(transparent) and accept any string a lockfile or manifest offers; their parse() runs only when a caller thinks to call it.

19The recorded reason for transparent justifies the wire SHAPE and reads as if it justified the missing check. content_hash.rs:20–22: «serde(transparent): the wire form is the bare sha256:… string the lockfile already carries.» True — and try_from = "String", into = "String" emits the same bare string, which Group demonstrates in this very tree. The stated constraint survives the change untouched; what changes is only whether a malformed value is noticed at the boundary or carried inward.

20Four attributes, and the breakage is the finding. The four newtypes adopt Group's spelling. If existing fixtures or lockfiles then fail to load, that is a malformed value the tree was already carrying silently — the outcome to want, recorded rather than smoothed. from_validated stays: it is the in-process trusted path from vibe-index's hasher, not a wire path.

5. ActionBuilder — the one builder, and its three runtime obligations

21Measured before landing (3). The tree's only real builder enforced its required fields at runtime. ActionBuilder (vibe-actions/src/action.rs:333) keeps name, description and invoke as Option<…> (:335, :336, :341) and turns their absence into ActionBuildError::MissingName / EmptyPresentation in build() (:449–:460). The obligation is a Result, and every caller writes .unwrap() or propagates.

22The three obligations move into the type, and the encoding is NOT typestate. What stays a runtime error is emptiness — a supplied-but-blank string is a value check, not a presence check, and no type sees it. Saying which half moves is the design decision here; claiming the whole error goes away would be false.

23Typestate answers ordering; this is presence, and presence has a cheaper encoding. Three independent obligations need three type parameters and Set/Unset markers, which makes every one of the eleven setters generic and forces each to rebuild a ten-field struct so the other two parameters survive — a large amount of machinery for a guarantee the constructor gives for free. The guide's own phrase is «builders with mandatory fields in the type», and the literal reading is the right one: put the mandatory fields where they cannot be skipped. Measured against the call sites, the shape falls out: all seventeen chains supply name and description through the _en pair and every one ends in invoke, so the two presentation obligations become constructor arguments and the third becomes the argument of build. action(addr, "Name", "Description").icon(…).params(…).build(|ctx| …) — every presence checked by the compiler, no state parameters, and the Msg-explicit setters stay for the caller who needs to override.

24Measured, and smaller than the idiom's reputation suggests — but one clause of the first measurement was wrong. ActionBuilder appears 12 times across crates/, ActionBuildError 7 (it was 12 before the landing removed three absence variants), and the chains are 15 inside vibe-actions plus 2 in vibe-cli — the claim that they were all inside one crate was false when written and was caught by the worker before it edited anything, which is where a wrong count should be caught. The private constructor already fixes the address before chaining, so the obligation move starts from one entry point rather than from every caller.

25#[must_use]'s distribution is already correct and gets no landing. It pays on -> Self setter chains, where dropping the return is the bug; that is exactly where the 145 sit (146 before the builder landing removed the invoke setter) (the 11 ActionBuilder setters, the KeyMeta setters, the TUI widgets). The seams that lack it — traits, wire types, newtypes — return Result or a value whose drop is legitimate. A count of 146 concentrated in one crate looks like an imbalance and is a correct distribution.

6. Watcher — a specified seam with no production implementation

26One pub trait in the perimeter has zero production implementations anywhere. Watcher (vibe-settings/src/events/mod.rs:432) carries specmark::spec(implements = "…/PROP-040#file-watch") on the trait and on its method, and the only implementations in the tree are a test Noop (events/tests.rs:250) and a doc-comment example.

27This is «Specified, not built», and the tree should say so where a reader meets it. The trait is not dead — it is the declared shape of a promised capability — so the landing is an explicit annotation at the seam plus a backlog row carrying the build, not a deletion and not a silent leave-as-is. A specified seam that reads like a live one is the same silence this project treats as the disease.

7. Sealed traits — deliberately not adopted, and the reason is architectural

28None of the 24 pub traits is sealed, and the census found no sealed-pattern construct anywhere — no private supertrait, no module-private gate; the word «seal» in this tree is the domain concept of verdict sealing.

29The seams are extension points by construction, and the extensions live in another crate. vibe-cli implements SearchProvider five times, plus PlanObserver, VendorObserver, RedirectSyncObserver, InstallSource and EvidenceProvider; vibe-publish's test RepoCreator mock lives there too. Sealing that family does not harden a contract — it deletes the wiring that makes the CLI a consumer of its own crates.

30The usual payoff does not exist here. A sealed trait buys non-breaking evolution against external implementors; these crates are workspace-internal and published nowhere, so adding a method is a compile error the same commit fixes. Adopting the idiom would trade a real capability for a benefit measured at zero.

31The single seam with a written implementor obligation is RepoCreator, and §2 serves it better than sealing would. Sealing would forbid a foreign implementation; it would not make an in-crate implementation call validate_scope. The newtype forbids the actual mistake, and it keeps the test mock legal. Where the obligation is «call this», the fix is an argument only that call can produce — not a lock on who may implement.

32Recorded as a decision, not deferred as work. This is the «задокументировать где сознательно нет» half of the owner's ruling, and re-judging ##SCAFFOLD-B-TYPED-BUILDERS against practice is part of the closing step: the guide's claim is right about builders and typestate and does not fit a workspace-internal trait surface.

8. Build order — five landings, each standing alone

33Ordered by payoff over cost, each landable and green alone — and the order held except where measurement moved it. (1) ValidatedOrg in vibe-publish plus the conformance test — the security-relevant one, and the only landing that closes an unchecked obligation. BUILT. (2) The four serde(transparent) identity newtypes adopt try_from/into; whatever breaks is a find, and five values in the tree turned out not to be hashes. BUILT. (3) The builder's three presence obligations into the signature. BUILT. (4) progress_core::Digest — first demoted, then declined on measurement (##digest-not-built-and-the-reason-is-the-comparison-itself), with the five-line defect that reading found built instead. (5) Watcher annotated «Specified, not built» with a backlog row. BUILT.

34Every landing carries the same gate set. cargo check + cargo test + cargo clippy --all-targets -D warnings for its crate; every touched or created .rs under the 600-line budget measured after cargo fmt; the boss's panel is the real gate, and cargo xtask sync-engines does not apply — none of these crates is a vendored engine, verified against sync-engines.toml, whose sources are the core-ai-native-* crates only.

35What this build deliberately leaves alone. The RelPath/PathBuf/String path-and-URL asymmetry (a newtype there touches every seam constructor and buys distinction, not safety); the ~133 serde types that derive Deserialize plainly (their fields are structural, and post-load validate() methods already exist where semantics matter); and the #[must_use] distribution. Naming them here is what keeps the next census from reading their absence as an oversight.

For an agent

This page has a machine mirror. The citation carries the version rather than latest, so what an agent quotes does not move under it.

spec://org.vibevm.core/vibevm@1.0.0/design/typed-seams

.md.xmlllms.txt