ho-04.14 — fresh-install vault scaffold
Responds to a finding surfaced during ho-04.13 execution (;
ho-04.13 is closed). The -4 overview anticipated this as a "ho-04.14-shaped
insert" gating Phase 4: "if a clean production init doesn't build the vault,
fix it first." It doesn't. This fixes it.
The finding — a fresh-install catch-22. On a machine with no vault directory:
sharibako key generatewrites the age key but never creates the vault directory (scopes/+shared/).sharibako init <proj>then fails atVaultLocator.resolvewithVault not found at …/vault. Hint: Run 'sharibako key generate' to create one.- But
key generateis the command that just didn't create it. The hint points the user back at the command that can't do what the hint promises. A brand-new user is stuck in a loop.
Reproduced live this session with the installed ho-04.13 binary against a fresh
vault path. Independently flagged by the 2026-07-03 signed-install smoke as
"createVaultLayout never called in production."
Root cause — a documented contract with no public API.
VaultCore.init(vaultURL:)'s own doc comment instructs:
Does not create the vault; call
VaultLayout.createVaultLayout(at:)first when initializing a fresh vault.
But VaultLayout is internal enum and createVaultLayout is internal static.
The SharibakoCLI module cannot see it — only @testable import tests can. So
the tests scaffold vaults and pass, while every production command that resolves a
vault requires one to already exist, and no command ever creates it. The layout
code works; it was never reachable from production.
Phase 1 — · RATIFIED 2026-07-08
Decision 1 — key generate is the vault-creation command; it scaffolds the layout
key generate already is the command the "vault not found" hint names as the
way to create a vault, and a vault with no age key is useless — so coupling
"create the vault directory" to "generate the key" matches both the existing hint
and the (one bootstrap step gives you a usable vault). GenerateCommand
will resolve the intended vault path and scaffold the layout (idempotently)
before generating the key. This makes the existing hint true and breaks the
catch-22: key generate → init now works, on both the Keychain (default) and
--age-key (file) paths.
Decision 2 — expose one public creation API: VaultCore.createVault(at:)
The bug was possible because the only scaffold function is internal. Rather than
make the whole VaultLayout URL-helper enum public (it deliberately hides the
path grammar in one place), add a single focused public entry point on the public
type — public static func VaultCore.createVault(at:) — that delegates to the
internal VaultLayout.createVaultLayout. Idempotent, wraps FS failures as
VaultError.fileSystemError. VaultCore.init's doc comment is updated to name
this public API instead of the unreachable internal one.
Decision 3 — split path resolution from existence checking in VaultLocator
VaultLocator.resolve bundles "where is the vault" with "does it exist," and the
existence check is a deliberate safety net (a typo'd --vault /wrong must error,
not silently create a vault at the wrong place). Creation needs the path without
the check. Add VaultLocator.intendedVaultURL(...) — flag → env → default, no
existence check — and refactor resolve to call it and then assert existence.
key generate uses intendedVaultURL; every read/write path keeps resolve's
safety check unchanged.
Decision 4 — scope boundary: do not change init; leave non-atomic ingest to its own ho
init still resolves an existing vault up front and throws vaultNotFound if the
vault is missing — the two-step bootstrap (key generate then init) resolves
the catch-22, and the hint it prints is now true. Making init itself scaffold
- offer key generation in one shot is a larger UX change and is out of scope here.
The other owed dogfood bug — non-atomic
ingest(an interrupted ingest can leave a zombie scope) — is unrelated and keeps its own future ho.
Phase 2 — Execute
Branch ho-04.14 off main.
Code changes
Sources/SharibakoCore/VaultCore.swift — add:
public static func createVault(at vaultURL: URL) throws {
try VaultLayout.createVaultLayout(at: vaultURL)
}
and update init(vaultURL:)'s doc comment to name VaultCore.createVault(at:).
Sources/SharibakoCLI/Support/VaultLocator.swift — add intendedVaultURL(...)
(path resolution, no existence check); refactor resolve(...) to delegate to it
then assert existence. Behavior of resolve is unchanged.
Sources/SharibakoCLI/Commands/KeyCommand.swift — in GenerateCommand._run,
scaffold the vault first:
let vaultURL = VaultLocator.intendedVaultURL(globalFlag: global.vaultURL)
try VaultCore.createVault(at: vaultURL)
Tests
VaultCoreFilesystemTests—createVault(at:)createsscopes/+shared/; idempotent on a second call; both dirs exist afterward.VaultLocatorTests—intendedVaultURLreturns flag → env → default and does not throw for a nonexistent path (the split fromresolve).KeyCommandTests— the regression test:key generateagainst a fresh vault path (file key) leavesscopes/+shared/on disk. This is the test whose absence let the bug ship.
Verification and the dogfood gate
swift build(warnings-as-errors) →swift-format lint→swiftlint --strict→swift test→ coverage ≥90%.- Dogfood gate — the fresh-install path end to end on the installed binary:
from a directory with no vault, run
key generate --vault <fresh> --age-key <fresh>, confirmscopes/+shared/appear, theninita project and confirm it succeeds (novaultNotFound). Not done until a clean bootstrap works.
Phase 3 — · CLOSED 2026-07-08
-
Did the hold? Yes. On a genuinely fresh path (no vault directory),
key generate --vault <fresh> --age-key <fresh>now scaffoldsscopes/+shared/, and every vault-requiring command works afterward —listreturns "No scopes" instead of "Vault not found", andadd/run --dry-runround-trip. The catch-22 is gone; the "runkey generate" hint is now true. -
Root cause was an access level, not a logic bug. The scaffolding code (
VaultLayout.createVaultLayout) worked and was tested — but it wasinternal, so only@testabletests could reach it; no production caller inSharibakoCLIcould.VaultCore.init's doc comment even instructed callers to run it first, naming an API they couldn't see. The fix was to expose one focused public entry point (VaultCore.createVault(at:)) and wire it into the command that already claims to create the vault. Lesson worth carrying: a documented cross-module contract needs a public API to fulfill it, or the contract is a fiction that only the test target can honor. -
The missing test is the real fix.
generateScaffoldsVault(runkey generateon a fresh path, assert the vault exists) is the regression guard whose absence let this ship.VaultLocatorreached 100% andintendedVaultURLis fully covered; overall floor unchanged at 94.16%. -
Scope held.
initwas left unchanged — it still requires an existing vault and throwsvaultNotFound(now with a hint that works), so the two-step bootstrap (key generate→init) is coherent. Non-atomic ingest keeps its own owed ho. -
Followup surfaced. The dogfood re-confirmed that
initis interactive-only (it prompts for scope id/type and refuses a non-tty), so scripting or fully testing theinithalf of a fresh bootstrap isn't possible today. A smallinit --scope-id/--type(scriptable-init) ho would let the full bootstrap be end-to-end tested and unattended-installed. Noted, not done here.
Authored and executed 2026-07-08 as the forward-only response to the fresh-install catch-22 surfaced during ho-04.13 dogfooding; the Phase-4 gate the Kamae-4 overview named.
Rendered from the corpus, verbatim · source on GitHub →