ho-04.10 — EnvParser contract & ingest/link collision policy
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:
- Scan resilience (one malformed marker aborting a whole
scan) — Tier B #6, ho-04.11. - CLI trust-boundary UX (non-TTY
clean,.abortedexit codes, no-echo entry) — Tier B #6, ho-04.11. runsignal semantics and Keychain modernization — Tier B #2 + #7, ho-04.12 (-gated).
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.
Ingest/link collision policy (Tier B #5)
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
- All nine decisions implemented and each covered by at least one test that fails on the old behavior.
- A CRLF file materialized, cleaned, and updated retains its line endings on every non-owned line.
- Full suite green;
swift-format lint --strictandswiftlint lint --strictclean; warning-free build; coverage at or above the CI floor with ho-04.8's named-exclusion list. - Ho document's filled in.
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:
- Decisions 2 and 5 interact. The
\$fallback output (D2) would have tripped the new unknown-escape warning (D5) on its own round-trip.\$had to become a known escape in the double-quoted parser — the canonical writer and the warning set are one contract, not two. - Post-quote junk needed a trailing-comment exemption.
KEY="value" # noteis a common, unambiguous idiom; warning on it would be noise that trains users to ignore warnings. The junk warning fires only on non-comment text after the closing quote. - The CRLF final-line corner. A CRLF file without a trailing newline must not gain a stray
\ron an appended final line — the terminator choice for appends depends on whether the render emits a final newline, which is whyAppendStylecarries both facts.
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.
- ho-04.11 (scan resilience + CLI trust-boundary UX) proceeds as planned; nothing here changed its scope.
- The ingest dashboard surfaces
sharedEntryExiststhrough the standard error path with a remediation hint; whether the dashboard should offer an inline "link instead?" recovery belongs to the trust-boundary UX work, not here. - The real-vault smoke owed since ho-04.8 (quick
add/rotate/geton a throwaway key) is still owed, and now also coversrotate --shared.
Authored: 2026-07-03 (Think phase ratified in session). Executed: 2026-07-03, same session.
Rendered from the corpus, verbatim · source on GitHub →