hosystem Engagements

ho-03 — The Materializer: markers, ingest, materialize, update, heal

created 2026-07-01
status draft
type ho-document
project sharibako
ho 03
kamae 5
shape ha
agent-tasks
  • Ho-03-AT-01.md
  • Ho-03-AT-02.md

The bridge between the vault and the user's filesystem. SharibakoCore grows a third public type alongside VaultCore and Conduit: a Materializer value type that reads and writes .sharibako markers, walks scan roots, parses .env files, merges owned-key values into .env without touching non-owned lines, reads .env back into the vault via update, retracts owned lines via clean, and reports per-key drift via heal. 2.2's per-key ownership commitment is honored throughout: sharibako owns only the keys the user selected at ingest — expressed by which files exist under vault/scopes/<id>/ — and everything else in .env is user territory that passes through byte-for-byte.

Out of scope:

Resolves deferred decisions (from the ho-overview + kamae-2.2 conversation):


Phase 1 —

Decision 1 — Public type shape: public struct Materializer

Parallel to VaultCore and Conduit. Not an extension on VaultCore, not a namespace of static functions.

public struct Materializer: Sendable {
    public let vaultCore: VaultCore
    public let vaultURL: URL

    public init(vaultCore: VaultCore, vaultURL: URL)
    // …operations…
}

The Materializer holds a reference to the VaultCore because it needs to call vault operations (list scope keys, get values, add/update secrets during ingest and update). The vaultURL is redundant with vaultCore.vaultURL but kept for interface clarity — the Materializer's identity is "the bridge for this vault."

Extension pattern matching Conduit / Conduit+Remote:

Same reasoning as Decision 1 in ho-02: keeping the seam visible at the type level, room to grow without refactoring, small enough that both files stay readable.

Decision 2 — Owned keys are derived from the vault filesystem

Per kamae-2.2. The scope's owned key set is the union of <KEY>.age and <KEY>.link files in vault/scopes/<id>/. No new scope.yaml field. VaultCore already exposes an inspect (or equivalent read) operation from ho-01 that returns the scope's keys and their link/value state; the Materializer calls that.

Consequence: adding a key to a scope's ownership is the same operation as adding a secret (VaultCore.add_secret or link). Removing ownership is the same as removing the secret. There is no separate "manage this key" toggle — ownership and existence are the same fact.

Decision 3 — Marker schema: exactly what kamae-2 says, no additions

.sharibako at the project root, plaintext YAML:

scope: kanyo-dev
materialize_to: ./.env    # optional; defaults to ./.env when omitted

Decoded into a small Codable struct:

public struct ScopeMarker: Sendable, Equatable, Codable {
    public let scope: String
    public let materializeTo: String?    // nil → default ./.env

    // Marker's own path on disk (not encoded)
    public let markerURL: URL

    // Convenience: the absolute target path for materialize/update/clean
    public var targetURL: URL {
        let raw = materializeTo ?? "./.env"
        return URL(fileURLWithPath: raw, relativeTo: markerURL.deletingLastPathComponent()).standardizedFileURL
    }
}

materialize_to remains a relative path in the file. Absolute paths would break the marker's "portable across machines" property (kamae-2 §2). The Materializer resolves the relative path against the marker's directory at read time.

Decision 4 — .env parser: line-preserving structured parse

The parser produces a list of EnvLine values, one per input line. Each preserves the original line's exact text and identifies which kind of line it is:

internal enum EnvLine: Sendable, Equatable {
    case blank(text: String)                         // whitespace-only or empty
    case comment(text: String)                       // starts with `#` (leading whitespace allowed)
    case keyValue(key: String, value: String, rawText: String)
    case malformed(text: String, reason: String)     // preserved as-is; surfaced in warnings
}

Q4 resolved: skip malformed lines with warnings, do not fail ingest.

Multi-line values (KEY="line1\nline2" or KEY="""... heredoc styles) — reject in v1 by classifying them as malformed. Warning message: "multi-line value at line N; sharibako v1 does not support these."

export FOO=bar — accept and treat as FOO=bar. The export prefix is normalized away at parse time (rawText still preserves it for non-owned lines; for owned lines materialize's canonical rewrite drops the export).

Quoting: v1 supports the three common styles — no quotes (KEY=value), double quotes (KEY="value with spaces"), single quotes (KEY='value with $vars'). Escaping inside double quotes: \\, \", \n, \t. Anything more exotic (backslash line continuations, $(...), ${...} interpolation) — rejected as malformed with a warning.

Decision 5 — materialize(scopeID:, overwriteDrift: Bool = false): line-preserving merge

Signature and return type:

public enum MaterializeResult: Sendable, Equatable {
    case wrote(path: URL, keysWritten: [String])
    case unchanged(path: URL)
    case diffPending(diff: MaterializeDiff)
}

public struct MaterializeDiff: Sendable, Equatable {
    public let scopeID: String
    public let path: URL
    public let ownedKeysDiffering: [String]  // just names; plaintext retrieved on demand
    public let ownedKeysMissingFromFile: [String]
}

public func materialize(scopeID: String, overwriteDrift: Bool = false) throws -> MaterializeResult

Algorithm:

  1. Load the scope marker (walk up from cwd to find .sharibako, or take marker URL directly — deferred to execution which interface fits better).
  2. Read the target file if present; parse to [EnvLine]. If absent, treat as empty array.
  3. Load the scope's owned keys and current vault values via VaultCore (existing operations).
  4. Compute the merge plan:
    • For each owned key present in the file: if the file value differs from the vault value, it's a drift entry.
    • For each owned key absent from the file: it's a missing entry (will be appended).
    • For each non-owned line: unchanged, will pass through.
  5. If any drift entries exist AND overwriteDrift == false, return .diffPending(MaterializeDiff(...)) without writing.
  6. Otherwise, build the output [EnvLine]:
    • For each EnvLine.keyValue(key: k, ...) where k is owned: replace with a canonical KEY=value line built from the vault value.
    • For all other lines: pass through unchanged.
    • For owned keys not present in the file: append at the end (with one blank line before them if the file was non-empty and didn't already end in a blank line).
  7. Write the composed text atomically (write to temp file, rename over target — same durability pattern used elsewhere).
  8. Return .wrote(path:, keysWritten:) listing every owned key whose line was written (drift + newly-added). If nothing needed writing, return .unchanged.

Q1 resolved: overwriteDrift: false is the default; diffPending is what a surface layer catches to render options.

Canonical rewrite quoting: when the Materializer writes an owned line, it quotes the value if the value contains spaces, special shell characters, or a #. Otherwise it writes bare KEY=value. This mirrors what dotenv writers commonly emit; users -editing to add quotes are respected on next update (their quoting choice becomes the file's, then the Materializer preserves it for non-owned lines and re-emits its own choice on next materialize for owned lines).

Decision 6 — update(scopeID:): bidirectional close

public enum UpdateResult: Sendable, Equatable {
    case updated(keysUpdated: [String])
    case noChanges
    case fileMissing(path: URL)
    case parseWarnings(warnings: [ParseWarning], keysUpdated: [String])
}

public func update(scopeID: String) throws -> UpdateResult

Algorithm:

  1. Load the scope marker + target path.
  2. If the file doesn't exist: return .fileMissing(path:) (not an error — user might not have materialized yet).
  3. Parse the file. If warnings occur, collect them.
  4. Load the scope's owned keys via VaultCore.
  5. For each owned key present in the parsed file: extract its parsed value. Compare against the current vault value.
  6. For each key that differs: call VaultCore's rotate-equivalent (from ho-01) — re-encrypts and writes <KEY>.age. Update rotated_at.
  7. Non-owned lines: ignored entirely (never even read as values — only the parser sees their raw text for preservation).
  8. Return the list of updated keys, or .noChanges if nothing differed. If parse warnings occurred, return .parseWarnings(...) even if some keys still updated successfully.

Update is a Materializer-driven convenience over VaultCore.rotate. The Materializer does not commit — surface layers call Conduit.commit afterward, matching ho-02's precedent that the Materializer never talks to git.

Decision 7 — ingest(directory:) returns four decision types

The read-path pipeline. Reads existing .env, .env.local, and .env.example from the passed directory; classifies each key; returns a proposal:

public struct ProposedScope: Sendable, Equatable {
    public let directory: URL
    public let suggestedScopeID: String        // from directory basename + collision avoidance
    public let detectedKeys: [DetectedKey]
    public let suggestedKeysNeedingValues: [String]  // from .env.example when no value present
    public let parseWarnings: [ParseWarning]
}

public struct DetectedKey: Sendable, Equatable {
    public let key: String
    public let value: String
    public let sourceFile: URL                 // which file it came from
    public let nameMatchedSharedID: String?    // set if an exact-match shared entry exists
}

public enum KeyDecision: Sendable, Equatable {
    case importAsLocal(key: String)                                  // value comes from DetectedKey
    case linkToShared(key: String, sharedID: String)
    case moveToShared(key: String, newSharedID: String)              // value comes from DetectedKey
    case leaveAlone(key: String)                                     // sharibako records nothing
    case skip(key: String)                                           // defer decision; ingest surfaces this again next run
}

public func ingest(directory: URL) throws -> ProposedScope
public func acceptIngest(_ proposal: ProposedScope, decisions: [KeyDecision]) throws

ingest is pure read — no vault writes, no marker writes. acceptIngest is the write-back: for each decision, calls the appropriate VaultCore operation and finally writes the .sharibako marker.

Q3 resolved: nameMatchedSharedID uses exact case-sensitive match only. .env has OPENAI_API_KEY; nameMatched is set iff vault/shared/OPENAI_API_KEY.age exists literally.

Q2 resolved: keys detected only in .env.example (no corresponding .env value) go to suggestedKeysNeedingValues, not detectedKeys. Surfaces render them as a "you'll need values for these" checklist. Nothing enters the vault empty.

Decision 8 — "Leave alone" is ephemeral in v1

A key marked .leaveAlone during ingest results in no vault write, no marker entry, nothing. If the user runs sharibako init again in the same directory (or via a "Rescan" GUI action), that key resurfaces as detected and offered again. The user hits "leave alone" again.

Persistent "leave alone" — remembering "sharibako, this key is definitely not mine, don't ask about it again" — would require adding a left_alone_keys: [String] field to scope.yaml. Rejected for v1:

.skip vs .leaveAlone at the library level: identical (no write). At the surface level: .leaveAlone is a confident non-choice ("this is not a secret, move on"); .skip is "come back to this one later." Surfaces render them differently; the Materializer records neither.

Decision 9 — heal(scopeID:) returns a structured DriftReport — no automatic fix

public struct DriftReport: Sendable, Equatable {
    public let scopeID: String
    public let path: URL
    public let owned: [KeyDrift]
    public let parseWarnings: [ParseWarning]
}

public enum KeyDrift: Sendable, Equatable {
    case match(key: String)                                     // vault == file
    case fileMissing(key: String)                               // vault has it; file doesn't
    case fileValueDiffers(key: String, vaultSha256: String, fileSha256: String)
    // NOTE: no case for "file has a key vault doesn't own" — non-owned lines are invisible to heal
}

public func heal(scopeID: String) throws -> DriftReport

heal never rewrites files. It only reports. Surfaces read the report and offer:

Same pattern as Conduit.pull()'s abortedConflict(conflicts:) — the Materializer detects, the surfaces resolve. SHA-256 rather than plaintext in the diff avoids surfacing secret values in log-like structures. Surfaces retrieve plaintext with VaultCore.get_value when they need to display it.

Decision 10 — clean(scopeID:) removes owned lines only; deletes file if empty

public enum CleanResult: Sendable, Equatable {
    case cleaned(path: URL, keysRemoved: [String], fileStillExists: Bool)
    case fileMissing(path: URL)                                 // nothing to clean
}

public func clean(scopeID: String) throws -> CleanResult

Algorithm:

  1. Load the scope's marker + target path.
  2. If the file doesn't exist: .fileMissing.
  3. Parse the file to [EnvLine].
  4. Load the scope's owned keys.
  5. Filter out every EnvLine.keyValue(key: k, ...) where k is owned. Preserve everything else (comments, blank lines, non-owned pairs, malformed lines).
  6. If the resulting list is empty OR contains only .blank and .comment entries: delete the file. fileStillExists = false.
  7. Otherwise: write the filtered content atomically. fileStillExists = true.

clean never asks for confirmation at the library level — surfaces (CLI, GUI) confirm before calling. The library assumes the caller made the decision.

Decision 11 — Scan uses FileManager enumeration; scope resolution walks up from cwd

scan(roots:) uses FileManager.enumerator(at:) to walk each root, filtering for .sharibako files. Returns [ScopeMarker] — one entry per marker found. Ordering is stable (breadth-first, then alphabetical within a depth).

Scope resolution helper — for materialize, update, clean, heal called without an explicit marker — walks up from cwd (or a passed-in URL) looking for a .sharibako file. Stops at the user's home directory or the filesystem root, whichever comes first. Same pattern as git's .git/ discovery.

Both helpers live in Materializer.swift (write-path file).

Decision 12 — Errors: extend VaultError with three cases

case markerNotFound(path: URL)               // Scope resolution walked up without finding a .sharibako file
case markerMalformed(path: URL, reason: String)  // .sharibako found but the YAML doesn't parse
case envParseFailed(path: URL, reason: String)   // Truly unrecoverable parse (empty warnings + no lines usable). Currently unused — every real .env failure surfaces as ParseWarnings from Decision 4. Reserved for edge cases like unreadable file.

Three new cases only. The rest of the 's failure modes live inside enum-returning operations (MaterializeResult, UpdateResult, CleanResult) or in ParseWarnings embedded in success returns — consistent with the ho-02 precedent that "state" and "error" are distinguished at the type level.

Deferred to execution


Phase 2 — Execute

Two agent tasks with a clean seam between the write path and the read path. AT-01 completes and passes before AT-02 opens.

Ho-03-AT-01 — Write path: Materializer type, markers, materialize, clean, heal

Everything in Materializer.swift. The type, initializer, marker read (ScopeMarker struct + Codable decode), scan(roots:), scope resolution helper, status(scopeID:), materialize(scopeID:, overwriteDrift:), clean(scopeID:), heal(scopeID:). Plus the .env parser (EnvLine enum, parse function) — AT-02 uses this same parser for ingest and update, so it lives with the write path where it's first needed. Plus the three new VaultError cases and the four new public return types (MaterializeResult, MaterializeDiff, DriftReport, KeyDrift, CleanResult, ScopeState).

/agent-tasks/Ho-03-AT-01.md

Ho-03-AT-02 — Read path: ingest, acceptIngest, update, four-way decision matrix

Everything in Materializer+Ingest.swift. ingest(directory:) → ProposedScope, acceptIngest(_:, decisions:) writing through to VaultCore, update(scopeID:) → UpdateResult. Plus ProposedScope, DetectedKey, KeyDecision, UpdateResult, and the ingest-specific scope-ID suggestion helper (basename + collision avoidance via VaultCore.list_scopes). Tests exercise the full four-way decision matrix plus round-trip (materialize → hand-edit non-owned + owned → update → materialize) integrity.

ho-process/agent-tasks/Ho-03-AT-02.md

Testing and iteration approach

AT-01 completes and passes before AT-02 opens. AT-02 imports AT-01's parser and marker types. Coverage floor stays at the 90% enforced in CI. No new binary dependencies — the Materializer uses Foundation and Yams (already in Package.swift from ho-01). Integration tests use the same withEphemeralGitVault fixture from ho-02, extended with helper functions for populating .env files in a temp project directory.

Done means


Phase 3 —

Executed 2026-07-01 as two agent tasks on Sonnet, in-session and interactively watched. AT-01 (330fc9a) landed clean; AT-02 (b4af23f) needed a short mid-flight conversation and a small VaultCore surface change before proceeding.

Mid-execution design refinement. AT-02's spec assumed acceptIngest had a natural ScopeType to reach for; it didn't. Rather than defaulting silently, the ingest surface now suggests .projectDev on ProposedScope.suggestedScopeType and lets the caller override via acceptIngest(_:decisions:scopeID:scopeType:). This mirrors the same "library suggests, surface can override" pattern already used for scopeID — worth naming as a repeated shape when the CLI (ho-04) and GUI (ho-05/06) inherit both suggestions.

VaultCore surface gaps surfaced by the spec. AT-02's routing needed vaultCore.addSharedEntry(...) and vaultCore.createScope(...), neither of which existed. Both were small compositions of existing operations and got added inside the AT-02 commit. Lesson for spec authoring: verify the surface the spec references actually exists before dispatching. Two ~15-line methods later, not a blocker.

Coverage audit found two things worth naming. commitNothingToCommit in ConduitLocalTests had been defined without a @Test annotation, so it silently never ran — the .nothingToCommit branch in Conduit.commit(message:) was uncovered even after ho-02 shipped. Coverage caught what test discovery didn't. Separately, the audit surfaced a real byte-preservation bug in renderEnvLines: a !output.hasSuffix("\n") guard broke round-trip for files ending in an actual blank line ("A=1\n\n"). AT-01 had 95%+ line coverage on the parser but no test exercised that specific shape. Line coverage tells you what code ran, not what edge cases you actually tested.

Dead defensive code, removed once seen. The same audit exposed two guards in the ingest apply() helper that could never fire (the caller already validated), and a redundant markerNotFound throw path in resolveMarker that overlapped with an adjacent guard. Both cleaned up (6479edd) — the operating discipline says not to write for scenarios that can't happen, and I had.

Final state. Materializer public surface: markers, scan, status, materialize, clean, heal, ingest, acceptIngest, update. 203 tests across 16 suites, all green. Line coverage 97.2% across Sources/SharibakoCore/*.swift; remaining uncovered is almost entirely catch { throw fileSystemError/gitInvocationFailed(...) } paths that need OS-level fault injection to reach. Five commits: 330fc9a, b4af23f, ebc7900, 8ef46af, 6479edd.

The Materializer is the point at which sharibako becomes usable end-to-end — a vault plus a marker plus .env file can now round-trip through ingest → materialize → hand-edit → update without dropping the kamae-2.2 byte-for-byte preservation guarantee. But nothing user-facing exists yet. The next practitioner-visible motion is ho-04 (CLI).


Followups tracked for future hos

Deliberate hand-offs to later work. Not code changes; things the later hos should read here and pick up.

For ho-04 (The Tool / CLI)

For ho-05 / ho-06 (The Workshop / GUI polish)

For ho-04.5 (sharibako run, clean, SECURITY.md)

Post-MVP


Authored: 2026-07-01. Execute and Reflect: pending.

Rendered from the corpus, verbatim · source on GitHub →

ingested: sharibako @ a97b22af9b61 · ho-system @ 79e96b801a13 · the glossary · the colophon