PROP-038: Hybrid boot linking — per-package compilation units with soft/hard static edges
01Status: IMPLEMENTED — 2026-07-15 (all five campaign phases shipped, d487d4e…381095e; see §6). Requirements captured from an owner design dialogue; ratified by the owner's directive to implement in full (2026-07-15, "реализуй всю гибридную линковку, включая спеки, код и тесты"). The §5 open questions are resolved (Phase 0 of the campaign, recorded inline in §5 with each resolution); implementation follows the HYBRID-LINKING campaign.
02Extends: PROP-009 (the loading model — the STATIC.md / INDEX.md artifacts §2.3, the static / dynamic link types §2.4), PROP-035 (the two-mode boot linker §2, #use §7.2, the @spec read-set §7.4, link tables §10).
03Input narrowed by the visibility layer (2026-08-23, PROP-050 §3): the unit table this linker compiles is built from the resolved ResolvedDep slice, which since the W2 landing carries only the consumer root's effective set E(R). Zones, static-transitive forcing, hoisting counts and surfaced dynamic edges therefore all operate within E(R) by construction — forcing can make a visible unit static, never make an invisible one visible (PROP-050 ##FORCING-NEVER-WIDENS).
04Supersedes / evolves: PROP-034 — its single global static-link graph and the precedence lattice (§2.2) are replaced by per-edge recursive linking plus hoisting (§2.2, §2.4 below). PROP-034's dedup + topological-order + cycle-rejection invariants are retained, applied per compilation unit.
05Related: PROP-017 §3 (resolvo — the single-version-per-name invariant this rests on), PROP-011 §2.4 (whole-tree boot regeneration — revised here to a dirty-subgraph), PROP-022 / PROP-014 (content_hash, the specmap/link-table index), vibevm/vibespecs/design/loading-and-boot-model.xml (the static/dynamic-linking metaphor this PROP completes).
1. Motivation — the boot must link like a real linker, per unit
- 06PROP-009 gives each dependency edge an inclusion type and emits one
STATIC.md+INDEX.mdper entry-point workspace node. PROP-034 resolves the whole closure as one global static-link graph, seeded from the root manifest. - Verified against the shipped
bootgen(2026-07-15), two limitations block the model the owner wants:
- 07Static propagates only from the root. The
static-transitiveclosure is seeded exclusively from the root manifest's direct edges;linkdeclarations inside an intermediate (dynamically-linked) package are never read for boot. So adynamic-linked packageAcannot declare "I statically link my own dependencyB" —Bfalls back todynamic. The effective-mode lattice (PROP-034 §2.2) is only half-implemented: "static wins", but only from the root.
- 08A single global
STATIC.mdcannot express local static. Modes are a global property of a node. "Bis static" means "Bis in the one rootSTATIC.md, read first, always" — even whenB's parentAisdynamicand may never load. There is no notion of "static withinA": static compiled relative to a package, loaded with that package, and only when it loads.
- 09The owner's target is local nested static linking — a dynamically-linked package that statically links its own dependencies, recursively, exactly as a real linker composes objects into a
.so(statically-linked, inside) while leaving other.sos asDT_NEEDEDdynamic references (late-bound by the loader). - This PROP makes boot a hybrid linker: it composes AOT (static, within a compilation unit) and JIT (dynamic, across unit boundaries) at every edge, and adds a soft/hard dedup axis on the static side.
2. Decisions
2.1 Every materialised package is a compilation unit
10Decision. Every package materialised under vibedeps/ carries its own boot artifacts — vibedeps/<slot>/spec/boot/STATIC.xml (what is compiled into this unit, verbatim) and .../INDEX.md (this unit's external dynamic references, resolved when the unit loads) — not only entry-point workspace nodes.
11This changes PROP-009 §2.3's "for every entry-point node" to "for every compilation unit (entry-point node or materialised package)".
12A unit's STATIC.md is self-contained and reversible (open/close markers, PROP-035 §11): reading it, an agent gets this package and everything statically linked into it, in dependency order, once each — the PROP-034 dedup + topological-order + cycle-rejection invariants, applied within the unit.
13The unit artifact serves dynamic consumers (an INDEX.md reference loads the whole zone through it) and standalone unit reads; a static consumer whose lane already compiles the unit's zone member-by-member elides the aggregate entry to a provenance stub instead of embedding the artifact — the once-each rule of PROP-009 §2.3 ##STATIC-EMITS-ONCE-EACH (B-006, owner-approved 2026-08-04), applied at compose time and distinct from §2.4's hoisting, which shares one copy across consumers rather than deduplicating within one lane.
2.2 The edge is the linker instruction; compilation is recursive and dynamic-bounded
14Decision. link is a property of the edge (consumer-side, declared in the parent's manifest), never baked into the pulled package (as PROP-034 §2.1 already states). A unit P is compiled by walking its own direct edges P→X:
- 15
static—X'sSTATIC.mdis compiled intoP'sSTATIC.md. Compilation recurses downX's own static edges; a nesteddynamicedge insideXbreaks the recursion (that target stays anINDEX.mdreference).statictherefore respects the modes below it. dynamic—Xis not compiled; it becomes an[[entry]]inP'sINDEX.md. The static zone breaks at this edge.static-transitive—Xand its entire subtree are forcedstatic, ignoring anydynamicedges inside — "rewrite the whole tree underX". This is the one mode that overrides nested breaks.
16The difference between static and static-transitive is exactly this treatment of nested dynamic edges: static honours them (breaks), static-transitive overrides them (forces).
17Worked example — root → A(dynamic) → B(static) → C(dynamic) → D(static-transitive):
| Unit artifact | Contains | Because |
|---|---|---|
root/…/STATIC.md |
— (no A, B, C, D) | root→A dynamic → break; A is a reference in root/INDEX.md |
vibedeps/A/…/STATIC.md |
A + B (no C) | A→B static → B compiled in; B→C dynamic → C breaks |
vibedeps/B/…/STATIC.md |
B | B→C dynamic → C is a reference in B/INDEX.md |
vibedeps/C/…/STATIC.md |
C + D + all under D | C→D static-transitive → forces the subtree |
2.3 Two static modes — static-soft (default) and static-hard
19Decision. The static side has two modes, differing in where duplication is deduplicated:
- 20
static-soft— the default, the meaning of a barelink = "static". Hoisting dedup at compile time: a package statically linked by more than one consumer is hoisted to a shared location (§2.4) and linked once; each consumer references it. Deterministic; does not depend on read-time behaviour. static-hard— explicit opt-in (link = "static-hard"). Pure local compilation: every consumer compiles the package into its ownSTATIC.mdindependently, with no hoisting. Duplication is deduplicated at read time by the read-set (§2.9).
21Why soft is the default (owner decision, 2026-07-15). A forgotten qualifier must fail toward correctness, not toward implicit duplication.
- 22When the same package is compiled into several units unhoisted, the model sees the same prompt several times and can be confused about which copy is authoritative — a correctness hazard the owner weighs above the "explicit-over-implicit" cost of a smart default.
static-hardremains for the deliberate case where a package must load only with its consumer (lazy locality preferred over dedup) even at the price of on-disk duplication.
2.4 Soft hoisting targets the LCA of the static-zone, not always the global root
23Decision. A hoisted package rises to the least common ancestor within a continuous static zone of its consumers, not unconditionally to the global root:
- 24Within one static zone (consumers share a static ancestor
Zreached by an unbroken chain of static edges) → hoist intoZ'sSTATIC.md. Dedup achieved and the package still loads only whenZloads: laziness is preserved. Within-zone hoisting is free and always done. - Across dynamic zones (the consumers' common static ancestor does not exist because a
dynamicedge separates them) → the only shared always-loaded location is the global rootSTATIC.md, and hoisting there makes the package eager (loaded even when its dynamic consumers are not). This is the one real cost of soft, paid only for cross-zone sharing.
25Consequences, all deliberate:
- 26Hoisting needs a global pass counting the static-consumers of each package (partially re-introducing global analysis the per-edge model otherwise avoids) — the price of compile-time dedup. Recorded as a change-detection cost in §2.7.
- Hoist transitivity. Hoisting
LhoistsL's own static sub-zone with it (else the hoistedLreferences code not present at the hoist point). - A
static-declared package can be hoisted past an edge that named itstaticonly within its static zone; adynamicedge is always a hoist barrier (crossing it is the eager cross-zone case above).
2.5 Hoist markers — the two ends of a lifted edge
27Decision. When soft hoists L out of a unit P's local STATIC.md, two markers preserve correctness — the reversible two-ended shape PROP-035 §7/§11 already defines:
- 28In
P'sSTATIC.md, whereL's text used to be: a#use spec://…/Ldirective (PROP-035 §7.2). It preserves theP→Lgraph edge locally and tells the agent "Lis part of me; its text is lifted and already read above — do not duplicate." The read-set (§2.9) gates the re-read, so no duplication reaches context. - In the hoist target (
Z's or the root'sSTATIC.md), at the lifted block: a shared-by hint comment naming the consumers (shared by P, Q, R). It explains to the model whyLis here and not local, and asserts this is one shared version — not a duplicate to reconcile.
29Both markers are generated, are part of the reversible marker set, and must be regenerated on recompilation.
2.6 The single-version invariant this rests on
30Decision (recording a closed question). Soft dedup is correct because the resolver guarantees one version per (kind, name) across the workspace — resolvo enforces single-version-per-name automatically (PROP-017 §3), and genuinely incompatible constraints fail as Unsatisfiable (PROP-017 §2.4) rather than coexisting. Therefore:
- 31A hoisted package is one shared version; there is never "two versions of
Bside by side" in aSTATIC.md. - The proposed "group different versions together + a divergence hint" feature is not needed — the situation it guards against cannot occur (confirmed 2026-07-15). Should the resolver model ever change to permit coexisting majors (a large, separate decision), this section is the trigger to revisit the hint mechanism.
32Conflict resolution (how an author forces the single chosen version) is out of scope here and documented in docs/faq/version-conflicts.md ([[override]], git-source, version.var).
2.7 Change-detection — a Merkle fingerprint over the boot graph
33Decision. Each unit's STATIC.md carries a fingerprint of the inputs it was compiled from — a Merkle hash over the unit's compilation zone:
34fp(P) = hash(
content_hash(own_boot(P)), // P's own boot text
[ link_type(P→X) for each edge ], // dynamic↔static switches
[ fp(X) for each static / static-transitive edge P→X ], // recurse into the zone
[ identity(Y) = (group,name,version) for each dynamic edge P→Y ], // dyn edge: identity only
soft_hoist_inputs(P) // §2.4 global static-use counts touching P
)
35Properties:
- 36A
dynamicedge breaks fingerprint propagation — exactly as it breaks compilation. A change behind a dynamic edge changesfp(Y)but notfp(P)(onlyY's identity entersfp(P));Y's unit recompiles independently. - Any change inside a static zone — content, version, edge set, or a
link-type switch (which resolution does not see, §2.8) — flipsfpup the continuous static chain to the first dynamic break. - The soft-hoist term makes a single→multi static-use transition (a new consumer statically links
L, soLmust now hoist) flipfpfor the affected units — the nonlocal invalidation soft costs, made explicit so tests target it (§3).
37Fingerprint storage location and granularity were open here; §5 resolved both on 2026-07-15 — header storage (RES-FP-STORAGE) and per-package granularity (RES-GRANULARITY).
2.8 Incremental regeneration — the dirty subgraph
38Decision. Boot regeneration recompiles only the units whose fp changed (the dirty subgraph), replacing PROP-009's / PROP-011's whole-tree regeneration.
- 39PROP-011 §2.4 kept boot regeneration whole-tree because it was cheap (a small
INDEX.mdper node); with verbatim per-package compilation (§2.1) that rationale no longer holds — aSTATIC.mdis now real concatenated text — so the incremental path becomes load-bearing. - The fast path: an unchanged root
fp⇒ zero recompilation, zero git churn (idempotency).
40This is the standard build-system shape — a cargo-fingerprint / Bazel-action-graph dirty-subgraph. The materialisation step is already incremental (PROP-011 §2.3); this brings boot regeneration to parity.
2.9 Read-set — the read-time dedup
41Decision. The @spec/#use read-set (PROP-035 §7.4 — a persistent { specpath, content_hash } record, "read once") is the dedup mechanism for (a) static-hard duplication across units, and (b) the #use markers soft leaves in local units (§2.5).
- 42It is a load-bearing prerequisite, not optional: without it,
static-hardduplicates and lifted#usetargets would re-enter context. - Its known weakness across context compaction (PROP-035 open question #2) applies; soft's compile-time dedup is the mitigation for the common case.
3. Test obligations
43This system's central risk is losing or failing to regenerate a dependency when the graph changes. The contract:
- 44The differential oracle is mandatory and central.
incremental_regen(any mutation sequence)MUST equalfull_regen_from_scratch(), byte-for-byte. Full regeneration is the reference semantics (it cannot silently drop anything); incremental must match it. This is the AI-Native Rust differential-oracle idiom applied to bootgen. - Property-based mutation fuzzing. Generate random DAGs (packages + edges with random link modes), apply random sequences of
add-edge/remove-edge/change-link/bump-version/edit-content, assertincremental == fullafter each. Targets the combinatorial "forgot to regenerate in a rare topology" — including the §2.7 nonlocal soft invalidation. Shipped:boot/hybrid/fuzz.rsruns the proptest sweep and names this DEF-5 in its own header. - Invariants as characterization goldens: no-loss / reachability (units reachable through
STATIC.md+INDEX.md== resolved closure; nothing dropped, nothing dangling); completeness (every static child is compiled in; every dynamic child is a reference, not compiled); no-stale (recomputedfp== storedfpfor every unit); boundary isolation (a mutation behind a dynamic edge does not change the parent unit'sSTATIC.md); idempotency (a no-opvibe installrecompiles nothing, zero git diff); dedup-at-read (the read-set reads a duplicated/hoisted package once). vibe checkboot-graph integrity. The existingvibe-checkboot_directorycheck gains a boot-graph pass: fingerprints current, reachability complete — so "did everything regenerate?" is answerable in CI and by hand.
4. Compatibility and migration
- 45Evolves PROP-009 §2.3 — boot artifacts now generated per compilation unit, not only per entry-point node. Existing single-node projects are the degenerate case (one unit) and keep working.
- Retires PROP-034 §2.2 (the global precedence lattice) — the effective-mode join is unnecessary once mode is a per-edge property resolved per unit; a package may be
staticin one unit'sSTATIC.mdanddynamicin another'sINDEX.mdwith no conflict and no global join. PROP-034's dedup / topological-order / cycle-rejection survive, applied per unit. - Revises PROP-011 §2.4 — boot regeneration moves from whole-tree to dirty-subgraph (§2.8); the "boot is cheap, keep it whole-tree" decision is re-opened by the verbatim-compilation cost and its recorded trigger has fired.
- Depends on PROP-035 — the structural/JIT concepts (
#use, read-set, link tables, reversible markers) become load-bearing rather than best-effort. This PROP is the concrete evolution of PROP-035's two-mode boot linker (§2) and its emission layer (§12). - Migration is demo-corpus-first (PROP-035 §15): build and prove on throwaway fixtures before converting any real package; vibevm itself converts last, and only where a package opts into the hybrid shape.
5. Resolved questions
46The five questions opened in the design dialogue were resolved 2026-07-15 (Phase 0):
- 47
soft×static-transitive— orthogonal axes.static-transitivedecides which packages are static (it forces the subtree);soft/harddecides how duplicates are deduped (hoist vs. local). They compose: astatic-transitiveedge's forced subtree is deduped bysoft(hoisting) by default. No separatestatic-transitive-hardvariant ships in v1 — the matrix stays 2×1 (soft/hard) × (direct/transitive) with hard-transitive deferred (no use case). - Static-use counter — both direct and forced count. A package reached by a direct
static/static-hardedge and a package forced static by astatic-transitiveancestor both increment its static-use count for hoisting (§2.4).dynamicedges never count. This keeps hoisting correct across a forced subtree. - Fingerprint storage — the
STATIC.md/INDEX.mdheader (§2.7). A generated header comment carries the unit'sfp, self-describing and reversible (PROP-035 §11), with novibe.lockschema bump — avoiding an observable-contract change to the lockfile (the lighter of the RP2 options). A link-table cache (PROP-035 §10) may memoise it later; the header is the source of truth. - Granularity — per package (v1). Fingerprint and invalidation are per compilation unit (package). Section-level granularity (PROP-035 §5 IR) is deferred (plan DEF-1).
- Dynamic-boundary representation — aggregated into the unit's
INDEX.md. When a unit's static zone is compiled, everydynamicedge inside that zone is surfaced into the unit's ownINDEX.md(not left as an inline directive in the compiled text). A unit'sINDEX.mdis thus the complete "what to load dynamically once you have read mySTATIC.md" manifest — one manifest per unit, no inline resolution the agent must perform mid-text.
48Migration-safety corollary (Phase 0 finding). Per-unit artifacts (§2.1) are additive: generating STATIC.md/INDEX.md inside a vibedeps/ slot is new output, expected on migration. An entry-point node's existing artifacts stay byte-identical for a tree with no intermediate static edges (today's vibevm: static reaches the boot lane only through the root's static-transitive redbook edge, so root recursion reproduces the current root STATIC.md). P5's acceptance therefore checks root artifacts unchanged plus new per-unit artifacts appear, not "no new files".
6. Version history
- 492026-07-15 — drafted (owner-requested). Captures the hybrid-linking design dialogue: per-package compilation units (§2.1); the edge as linker instruction with recursive, dynamic-bounded compilation and the
static/dynamic/static-transitivesemantics (§2.2); thestatic-soft(default) /static-hardmodes and why soft is the default (§2.3); LCA-scoped hoisting with the within-zone/cross-zone split and hoist transitivity (§2.4); the two-ended hoist markers — local#use+ shared-by hint (§2.5); the single-version invariant the dedup rests on and the closed multi-version-hint question (§2.6); the Merkle fingerprint over the boot graph (§2.7); dirty-subgraph incremental regeneration revising PROP-011 §2.4 (§2.8); the read-set as read-time dedup (§2.9); and the differential-oracle-centred test obligations (§3). Implementation is the HYBRID-LINKING campaign. - 2026-07-15 — ACCEPTED; §5 resolved (Phase 0). Ratified by the owner's implement-in-full directive. The five open questions resolved inline (§5): soft/hard × transitive are orthogonal; both direct and forced edges increment the static-use count; the fingerprint lives in the artifact header (no lockfile bump); granularity is per-package; dynamic boundaries aggregate into the unit's
INDEX.md. The migration-safety corollary pins per-unit artifacts as additive with entry-point artifacts byte-stable for the current tree. - 2026-07-15 — IMPLEMENTED. All five phases of the HYBRID-LINKING campaign landed on
main(d487d4e…381095e), floor green throughout. Invibe-workspace: the per-unit recursive compiler (boot::hybrid—resolve_zone/topo_zone), soft hoisting (hybrid::hoist) with#usemarkers and shared-by hints, thestatic-hardopt-out onLinkType, Merkle fingerprints (hybrid::fingerprint) driving the emit-side dirty-subgraph skip (§2.8), and theverify_boot_graphintegrity check (§3). Emission lives ininstall/bootgen+bootgen/hybrid_emit. 178 tests, specmap 0 orphans. Deferred (plan §15): broad conversion of real packages (DEF-3), aproptestfuzz sweep (DEF-5), and thevibe checkCLI wiring (DEF-6). Today's tree is byte-stable —staticreaches the lane only through the root'sstatic-transitiveedge, so nothing is per-unit-emitted yet.