hosystem Engagements

Sharibako — Ownership Decision (Kamae 2.2)

created 2026-07-01
status decided
type decision
project sharibako
stage kamae-2.2
kamae-chain seed → system-design → injection-decision → **ownership-decision** → readme → ho-overview
supersedes kamae-2 §2 (partial — the "materialize writes .env at the marker's target" line and the implicit whole-file-ownership assumption)
builds-on kamae-1-sharibako-seed, kamae-2-sharibako-system-design, kamae-2.1-sharibako-injection-decision
next reflected in kamae-1 seed, kamae-4 (ho-03 entry gains the `update` verb), README, SECURITY.md, docs/architecture.md

A decision document. 2's Materializer description read as "sharibako writes the .env file at the marker's target," which is compatible with either whole-file ownership or per-key ownership. The current document commits to a specific model: sharibako owns a per-scope set of keys made explicit by the vault's filesystem, and merges owned keys into the user's .env without touching non-owned lines. A new operation, update, closes the bidirectional loop.

This document is authoritative for the ownership model. Kamae 2 §2's Materializer description is superseded here. All other Kamae 2 commitments (age-per-secret, file-per-secret in the vault, filesystem-as-schema, git-backed, four-component slice) stand unchanged. Kamae 2.1's injection commitments also stand unchanged.


What Kamae 2 originally left ambiguous

Kamae 2 §2 described materialize(scope_id) as:

reads scope from Vault Core (decrypting all .age and resolving all .link), writes .env at the marker's target; shows a diff if there's drift

That reads as "sharibako owns the whole .env file." The README's first-session vignette reinforced the reading: bento had three secrets — OPENAI_API_KEY, DATABASE_URL, DEBUG — all three imported to the vault, all three materialized to .env. Ho-03 would have implemented it that way.

The assumption is wrong for real .env reality. Real files hold both secrets and non-secret config:

OPENAI_API_KEY=sk-...        # secret, sharibako should manage
DATABASE_URL=postgres://...  # secret, sharibako should manage
DEBUG=true                   # not a secret; a boolean toggle
PORT=3000                    # not a secret; a port number
NODE_ENV=development         # not a secret; an environment flag
LOG_LEVEL=info               # not a secret; a config knob

A whole-file-ownership model forces the user through sharibako for every edit, including edits of non-secrets. That breaks the natural workflow (open editor, toggle DEBUG=true to false, save) and puts sharibako in charge of things it doesn't need to be in charge of.

What changed

Two independent pushes surfaced the same fork:

  1. The .env reality above. Users have non-secret config mixed with secrets. Sharibako should own only the parts that need encryption and rotation discipline; the rest is the user's territory.
  2. The git-tracked-.env use case. For some practitioners' workflows, sharibako as the git-tracked home of .env (including non-secret config) is desirable — it turns .env (usually gitignored) into a versioned, syncable, encrypted-at-rest artifact. That value shouldn't disappear just because per-key ownership is the safer default.

Both are real. The decision below serves both without introducing a mode field.

The decision

Sharibako owns a per-scope set of keys, made explicit by which files exist in the scope's vault directory. Owned keys are merged into .env on materialize; non-owned lines are preserved exactly. A bidirectional update operation reads .env back into the vault when the user has -edited owned values.

Concretely:

The two workflows this serves

Both workflows use the same code path. The difference is entirely at ingest.

Workflow A — "just the secrets"

The default workflow for most projects.

  1. User runs sharibako init in a project directory.
  2. Ingest shows the keys in .env: OPENAI_API_KEY, DATABASE_URL, DEBUG, PORT, NODE_ENV.
  3. User picks the two API keys as scope-local secrets, leaves the rest alone.
  4. Vault now has OPENAI_API_KEY.age and DATABASE_URL.age under scopes/<id>/.
  5. materialize writes those two values into .env, preserves everything else.
  6. User can freely edit DEBUG=true to DEBUG=false in their editor. Sharibako doesn't notice, doesn't care.

Workflow B — "git-track the whole .env"

For practitioners who want their full project config encrypted, versioned, and syncable.

  1. User runs sharibako init in a project directory.
  2. Ingest shows the keys. User hits Import all (or clicks every checkbox).
  3. Vault now has all five keys as <KEY>.age files.
  4. materialize writes the whole .env from the vault. The file may look identical to what was there — because sharibako owns everything.
  5. User edits DEBUG=false in their editor. Runs sharibako update <scope>. Vault picks up the change. sharibako sync pushes it to the git remote. Full history preserved.
  6. On another machine, sharibako sync + sharibako materialize gets the same config.

Both workflows work end-to-end without a management_mode field, without branching in the Materializer, without a UI switch. The user's decision at ingest determines which workflow they get.

Why not the alternative shapes

Three shapes were considered. The one chosen is above. The other two are declined, with reasoning preserved for future readers.

Declined: explicit management_mode: full | partial in scope.yaml

Rejected because:

Declined: inline marker comments in .env

Sharibako-managed lines fenced by # sharibako-begin / # sharibako-end. Every marker-touched materialize edits between the fences and leaves everything else alone.

Rejected because:

Declined: separate .env.sharibako file

Sharibako owns .env.sharibako; the user's loader concatenates .env + .env.sharibako.

Rejected because:

Implementation shape

The Materializer's operations

Signatures (Swift, for SharibakoCore):

public struct Materializer: Sendable {
    public init(vaultCore: VaultCore, vaultURL: URL)

    // Filesystem-side operations
    public func scan(roots: [URL]) throws -> [ScopeMarker]
    public func status(scopeID: String) throws -> ScopeState
    public func heal(scopeID: String) throws -> DriftReport
    public func clean(scopeID: String) throws

    // Bidirectional flow
    public func materialize(scopeID: String, overwriteDrift: Bool = false) throws -> MaterializeResult
    public func update(scopeID: String) throws -> UpdateResult

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

public enum ScopeState: Sendable, Equatable {
    case liveHere(materialPath: URL)
    case liveElsewhere
    case orphaned(reason: String)
}

public struct DriftReport: Sendable, Equatable {
    public let scopeID: String
    public let materialPath: URL
    public let owned: [KeyDrift]        // one per owned key
    // Non-owned lines are absent; sharibako doesn't inspect them.
}

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)
    // We do NOT surface the plaintext values in the report — surfaces retrieve
    // them explicitly with `get` if the user wants to see them.
}

public enum MaterializeResult: Sendable, Equatable {
    case wrote(path: URL, keysWritten: Int)
    case unchanged(path: URL)
    case diffPending(diff: MaterializeDiff)                  // returned when drift would be overwritten and overwriteDrift == false
}

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

public enum UpdateResult: Sendable, Equatable {
    case updated(keysUpdated: [String])
    case noChanges
    case fileMissing(path: URL)              // marker present, `.env` absent
}

Names are provisional; ho-03 confirms them.

.env parsing and preservation

materialize's merge implementation, precisely:

  1. Read the current file at the target path. If absent, treat as empty.
  2. Parse into a list of EnvLine values, one per line, preserving each line's exact text.
  3. For each owned key (from the vault filesystem):
    • Find the first line that parses as KEY=... for that key. Replace its rendered form with the vault's value, in a canonical KEY=value shape (with escaping applied per the parser's rules).
    • If no such line exists, append it at the end (after a blank-line separator if the file already had content).
  4. Write the composed lines back atomically.

Non-owned lines pass through byte-for-byte. Comments, blank lines, and quote styles the user chose all survive.

update's implementation

  1. Read the current .env at the target path.
  2. Parse.
  3. For each owned key: extract its parsed value from the file.
  4. For each key whose file value differs from its vault value: call the Vault Core's rotate-equivalent operation. (Note: this is a value change, not a rotation-with-audit-history; it uses the same code path as sharibako rotate internally.)
  5. Return the list of updated keys.

The updated_at field on the encrypted content is updated (matching rotated_at's semantics). Every value change is git-committed by the caller of update — the Materializer itself does not touch the Conduit; commits happen at the surface layer per existing pattern.

Ingest returns four decision types, not three

public struct ProposedScope: Sendable, Equatable {
    public let scopeID: String
    public let detectedKeys: [DetectedKey]
    public let suggestedKeysNeedingValues: [String]  // keys from .env.example with no value
    // ... other fields for name-matched shared suggestions ...
}

public enum KeyDecision: Sendable, Equatable {
    case importAsLocal(key: String, value: String)
    case linkToShared(key: String, sharedID: String)
    case moveToShared(key: String, value: String, newSharedID: String)
    case leaveAlone(key: String)   // NEW — sharibako never touches this key
    case skip(key: String)         // ambiguous fourth option — user hasn't decided; ingest re-prompts
}

The distinction between leaveAlone and skip: leaveAlone is a permanent decision recorded (informally, in the user's memory or a GUI checkbox state) that this key is not sharibako's business. skip is "come back to this one later." At the library level they resolve identically (no vault write), but the surfaces present them differently — leaveAlone is a confident non-choice; skip is a deferral.

The four Q1–Q4 answers land unchanged

Locked from prior conversation (2026-07-01):

What stays the same

Everything not listed above stands:

Reflected in

Open items handed to downstream hos

None blocking. Ho-03 owns:

Ho-04 (CLI) owns:

Ho-06 (GUI polish) owns:


Decision committed. Downstream documents updated in the same session. This file is a permanent record; not to be edited except for typographical fixes.

Rendered from the corpus, verbatim · source on GitHub →

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