hosystem Engagements

ho-04.10 — EnvParser contract & ingest/link collision policy

created 2026-07-03
status complete
type ho-document
project sharibako
ho 04.10
kamae 5
shape ha

Third post-sweep hardening (fable sweep, 2026-07-03; Tier B candidates #4 and #5). Two subsystems, one theme: contracts the code claims but doesn't keep. The EnvParser half closes the gaps between kamae-2.2's byte-for-byte ownership contract and what the parser/renderer actually do; the collision half closes the write paths where a name collision silently destroys or corrupts vault state.

A pre-session scout confirmed the sweep's findings and sharpened two: the parser already rejects unquoted $ as unsupported interpolation while canonicalizeEnvLine emits raw $ inside double quotes — the writer contradicts its own parser's posture; and a shared entry with zero links cannot be rotated from the CLI at all (rotate <scope> <key> reaches rotateShared only through a .link).

Out of scope:


Phase 1 —

Nine decisions, ratified in session.

EnvParser contract (Tier B #4)

Decision 1 — CRLF preservation: per-line. The parser strips \r per line and the renderer joins with \n, so any write rewrites every line ending in a CRLF file — non-owned lines included, violating kamae-2.2. Fix at the line level: each EnvLine keeps its original terminator with its raw text (content parses without it); the renderer is unchanged. Untouched lines round-trip byte-for-byte by construction. Owned rewrites keep the terminator of the line they replace; appended keys adopt the file's dominant style. File-level EOL detection was rejected — it still rewrites non-owned bytes in mixed-ending files.

Decision 2 — $ in canonical output: single-quote first. A value containing $ currently ships double-quoted, and double quotes are exactly where docker-compose interpolates — the file is corrupt in the consumer's eyes and heal can't see it (it re-reads with our non-interpolating parser). New rule: value contains $ and no ' → single-quoted (literal in compose, the dotenv family, and our own parser). Value contains both $ and ' → double-quoted with \$ (our parser folds \$$; the corner is rare and carries the residual uncertainty).

Decision 3 — duplicate keys: last-wins everywhere. Ingest merged last-wins while materialize/update/heal read first-wins — the same file meant different things per verb. Last-wins matches shell and compose semantics and picks up -edits appended at the bottom of a file on update. Materialize still collapses duplicates into the first occurrence's position; position and value are separate questions.

Decision 4 — malformed owned lines: corruption doesn't transfer ownership. A malformed line whose text up to the first = names an owned key (e.g. API_KEY="unterminated) is a corrupted owned line, not a pass-through line. It counts as drift — the diffPending/--overwrite gate protects a hand-edit in progress — and materialize rewrites it in place instead of passing it through and appending a duplicate. heal reports it as differing, not missing. Closes the two-lines-one-key state materialize previously wrote itself.

Decision 5 — unknown escapes and post-quote junk: warn, keep parsing. \x silently became x; KEY="abc"junk silently dropped junk from the value. Both are typo-weight — surface each as a ParseWarning (heal and ingest already display them) rather than rejecting the line.

Decision 6 — empty-valued keys route to needs-a-value. KEY= landed in detectedKeys, so the dashboard offered to import an empty string as a secret, contra the documented intent. Empty merged values go to suggestedKeysNeedingValues instead. Accepted cost: no link-to-shared option for an empty key in the dashboard.

Decision 7 — addSharedEntry throws on an existing entry. New VaultError.sharedEntryExists. Add means create: ingest's moveToShared with a colliding ID silently overwrote the shared entry, and every linked scope vault-wide resolved the new value. The dashboard already flags name-matches and steers toward link; deliberate replacement stays available via rotate. Scope-local addSecret keeps its overwrite semantics — blast radius is one scope, the caller's own.

Decision 8 — link() requires an existing target. Throws sharedEntryNotFound when the shared entry is absent. A dangling link does exactly one thing — brick every read of the key until the target appears — and no legitimate creation path needs it. Supersedes ho-01's dangling-link creation spec (): getValueDanglingLink and unlinkDanglingLink in VaultCoreEncryptionTests created the dangling state through core.link() and are reworked to write the .link file directly (the git-synced dangling state remains real, and the read-path linkTargetMissing semantics they specify remain in force). VaultCoreSecretsTests.danglingLink already writes the file directly and stands unchanged. A write-side rejection test replaces the old creation path.

Decision 9 — direct shared rotation ships as rotate --shared <id>. Closes the zero-link gap and matches what the pre-sweep README implied. Lives on the existing RotateCommand, reuses ValueInput, mutually exclusive with the scope/key argument form.


Phase 2 — Execute

Single-session execution, no agent-task children — same posture as ho-04.9. Two work areas with a clean seam:

Parser/materializer contract (Decisions 1–6). EnvParser.swift carries most of the change: per-line terminator storage, single-quote-first canonicalization, new warnings. Materializer.swift and Materializer+Ingest.swift pick up last-wins reads, corrupted-owned-line drift/rewrite, and the empty-value ingest routing.

Collision policy and CLI surface (Decisions 7–9). VaultCore+Encryption.swift (addSharedEntry existence check), VaultCore.swift (link() target check), VaultError.swift (sharedEntryExists), RotateCommand.swift (--shared), plus the ingest moveToShared path inheriting Decision 7's throw.

Testing and iteration approach

Extend the existing suites in place: EnvParser round-trip tables gain CRLF and mixed-ending cases; canonicalization gains $/quote matrices; materialize/update/heal gain duplicate-key and corrupted-owned-line specs; VaultCore tests gain sharedEntryExists and write-side link rejection; CLI tests cover rotate --shared and its mutual exclusion. The two superseded tests are reworked as named in Decision 8, not deleted — the read-path behavior they pin still holds.

Done means


Phase 3 — Reflect

Did the hold? Yes — all nine decisions implemented as ratified, none reversed mid-execution. Three interactions surfaced that the Think phase hadn't named:

Decision review. The tightened link() (D8) rippled wider than the two named tests: five other fixtures created links against absent targets incidentally (the StatusCommand table fixture, both filesystem link tests) — evidence the permissive spec was being leaned on silently, which reinforces the tightening rather than arguing against it. Read-path dangling-link tests stand unchanged; they now simulate the git-synced dangling state by writing the .link file directly.

What broke that the tests didn't catch? Nothing at runtime. The lint layer caught structural debt the compiler and tests couldn't: two type-body-length ceilings (the ErrorReporter mapping switches moved to an extension; the link tests and the ho-04.10 parser specs split into VaultCoreLinkTests and EnvParserContractTests).

Numbers. 563 tests in 68 suites (533 after ho-04.9); coverage 93.77% by CI's exact computation (named-exclusion list from ho-04.8); swift-format and swiftlint --strict clean; warning-free build.

Followups.


Authored: 2026-07-03 (Think phase ratified in session). Executed: 2026-07-03, same session.

Rendered from the corpus, verbatim · source on GitHub →

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