Establish managed Git vault lifecycle foundation - #106
Conversation
#86 follow-up review hardeningTested commit: The required two-axis review of
Validation from the repository root:
The first focused test attempt rebuilt without the established vendored-OpenSSL environment and failed in No later ticket was begun. |
#87 validation evidence —
|
Pre-#88 development-tooling housekeeping —
|
#89 validation evidence —
|
#91 validation evidence —
|
## #130 — Give each Vault its own archive folder and commit identity, and stop requiring a Git username Implements #130 on top of the managed-Git vault lifecycle foundation, from `b5a0290 fix(index): serialize vault snapshot publication`. ### Scope - `VaultRecord`/`VaultDefinition` gain an optional per-Vault `archive_folder` (normalized to a single-trailing-slash form) and an optional per-Vault `commit_identity` (`{ name, email }`, name and email together only). Both default to absent via `#[serde(default)]`, mirroring the `poll_interval_secs` precedent, so an on-disk registry written before these fields existed keeps loading under the unchanged `REGISTRY_SCHEMA_VERSION`. - `AppState::vault_archive_prefix` resolves a Vault's own folder first, else `runtime_archive_prefix`; wired into all three archive-prefix call sites (`handlers/vault_content.rs`, `handlers/vault_write.rs`, `mcp/tools/write.rs`). - `git::config::resolve_commit_identity` resolves a Vault's own identity first, else the instance-wide `HATCHDOOR_GIT_AUTHOR_NAME`/`HATCHDOOR_GIT_AUTHOR_EMAIL` defaults; called once in `vault_runtime.rs`'s `dispatch_managed_git_turn_with` so every Git turn for a Vault (managed-Git, existing-Git remote-sync, and existing-Git Local-history) honors it. - HTTPS credentials accept a token alone: `normalize_credentials` now rejects only an empty token, substituting the documented `HTTPS_CREDENTIALS_USERNAME_PLACEHOLDER` when no username is supplied. Credentials remain write-only, read back only as `credential_configured`. - `/api/v1` Vault create/edit/discovery payloads and MCP `create_vault`/ `edit_vault`/`list_vaults` carry the two new optional fields with identical names and semantics; MCP's `create_vault` tool already shared the HTTP `CreateVaultRequest` type directly, so it inherited the fields for free. ### Intentional exclusions No SSH transport. No mandatory per-Vault values and no migration of existing server-wide values into every Vault record. `mcp/config.rs`'s `SERVER_INSTRUCTIONS` is untouched — the new fields are additive and don't change what an agent must know before calling a tool. ### Validation ``` CARGO_TARGET_DIR=/scratch/cargo-target/hatchdoor-vault-identity-130 ``` - `cargo fmt --all -- --check` - `cargo clippy --all-targets -- -D warnings` - `cargo test --all` — 697 library, 7 eval, and 3 CLI tests passed. 22 library tests in `git::managed_sync`/`git::managed_task`/`vault_runtime` failed; confirmed pre-existing and unrelated via `git stash` against the unmodified base commit — this sandbox's global `init.defaultBranch=main` conflicts with these fixtures' hardcoded `"master"` branch pushes. - `node scripts/check-module-map.mjs` — 179 production source files assigned exactly once ### Review Parallel Standards and Spec reviews found no hard violations and no scope creep beyond the required mechanical updates to every existing `NewVaultDefinition`/`VaultDefinitionEdit`/`CreateVaultRequest`/`EditVaultRequest` construction site (exhaustive struct literals, mostly test fixtures). Spec review found two real test-coverage gaps — `git::config::resolve_commit_identity` and `AppState::vault_archive_prefix` had no direct tests — both fixed with focused unit tests. Spec review also confirmed the issue's premise that `src/mcp/tools/read.rs` has a separate `CreateVaultArgs` struct was inaccurate: `create_vault_tool` parses directly into the shared `vaults::CreateVaultRequest`, so the HTTP struct's new fields already cover MCP `create_vault` for free. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvBqEngeDUXPTj1udizyF4
…d sync errors Closes #132. - Lower MIN_MANAGED_GIT_POLL_INTERVAL_SECS from 3600 to 60, moving BACKOFF_MAX down with it so the backoff cap never exceeds the shortest allowed schedule. BACKOFF_BASE and DEFAULT_TICK_INTERVAL stay put; both ratios that justified their current values still hold at the new floor. - Give VaultSource::ExistingGit its own poll_interval_secs (serde-defaulted, floor-checked only in PullOnly/TwoWay). managed_git_poll_interval now returns it for those modes, which — since ManagedGitScheduler registration, POST /sync, POST /retry, and the credential-replacement retry trigger were already written generically against that accessor — brings a remote-backed ExistingGit Vault onto the scheduler with no further branching needed at those call sites. - Add optional structured detail to VaultRuntimeError (affected paths, capped at 50 with a true total; local-commits-ahead count), populated by classify_sync_error for the three sync failures a caller cannot act on from the message string alone. Carried by VaultWorkError as the plumbing between them. Reaches HTTP and MCP list_vaults for free, since both already serialize the same VaultRuntimeError. - Update module-map.md's now-stale "ExistingGit is never scheduler-tracked" claims. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvBqEngeDUXPTj1udizyF4
Adds one focused test per acceptance bullet that wasn't already proven literally rather than by inference: - A one-minute (60s) schedule is accepted for both ManagedGit and ExistingGit, not just rejected below it. - Ten consecutive transient failures plateau at the new BACKOFF_MAX (60s), not the pre-#132 one-hour cap. - POST /sync and POST /retry now admit an ExistingGit PullOnly Vault and the scheduler tracks its interval on activation. - The same two endpoints still refuse an ExistingGit LocalHistory Vault, the other half of that acceptance bullet. - edit_vault/create_vault's MCP args parse poll_interval_secs on an existing_git source (the ticket's premise that inputSchema would reject it doesn't hold in this codebase — verified by direct deserialization rather than schema inspection). - VaultRuntimeError's detail serializes as the documented tagged JSON shape and is omitted, not null, when absent — the actual wire contract list_vaults and HTTP discovery both publish. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvBqEngeDUXPTj1udizyF4
) GET /api/v1/vaults gains an instance-level, always-serialized demo_mode boolean on VaultDiscoveryResponse, true only under HATCHDOOR_DEMO_MODE. Both list_vaults_handler construction sites (the Ready arm and the registry-Recovery arm) carry it, so #122's recovery screens know the instance posture too, not just the ordinary discovery path. Router tests cover zero/one/many enabled Vaults and the registry-recovery arm, each on both a demo instance and an ordinary one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvBqEngeDUXPTj1udizyF4
Implements #137 only. The frontend on the integration branch still called the unscoped endpoints #101 retired, so nothing in the browser worked. This is the slice that restores it and is the foundation every other #102 slice builds on. ## Scope Every browser call now targets `/api/v1/vaults/**`. Collection reads (tree, recent, search, graph) declare `scope` explicitly as one Vault ID or `all`; exact reads and every mutation name exactly one Vault. The canonical note URL is `/v/{vault_id}/n/{slug}`; slug-only browser state (recent notes, last-note restore, drafts, wikilink resolve cache) is now `(vault_id, slug)`-qualified throughout. Vault scope exists as state/storage only (`useVaultScope`) with no chrome yet — no Scope zone, no per-Vault status, no provenance, no accordion; those are #114-#126. On a single-enabled-Vault instance every surface renders exactly what single-Vault Hatchdoor renders today. Shared multi-Vault test fixtures (`frontend/src/test/fixtures/vaults.ts`) land here too: one, three, and eight Vaults, and a builder for each non-healthy per-Vault condition the API can report, for the rest of the 16-ticket set to assert against. ## Backend addition: rich per-Vault statistics StatsPage's existing UI needs word counts, top tags, most-linked notes, activity by month, and more — data the frozen `{scope}/stats` collection route intentionally never carried (it stays lean: note/tag/link counts only). The only producer of that richer report, `SqliteCache::vault_stats`, was already unreachable from any route since #101 and queries the legacy single-Vault-shaped cache tables the multi-Vault snapshot has moved past. Added `GET /api/v1/vaults/{vault_id}/stats/detail` — an exact, single-Vault read (never `all`, like `notes/{slug}`) — with a new `VaultReadCore::statistics_detail` computing every field from the same published snapshot the lean `statistics` projection already reads, reusing `collection`'s `VaultScope::One` gating for identical not-found/disabled/unavailable behavior. `docs/migrations/vault-scoped-clients.md` and the module map document the new route and the one legitimate deviation from this file's own "exact reads never touch the disposable cache" rule (this route needs mtime/size data only the snapshot carries). ## Judgment calls (documented, not silent) - Multi-Vault `all`-scope explorer/graph rendering (no accordion/island chrome yet): grouped-by-Vault responses are concatenated at the top level without deep-merging same-named folders across Vaults. Every note/node still carries its own `vault_id`, so links and writes target the correct Vault. Accepted, temporary, superseded by #117/#118. - Default write-target Vault with no active note open on a multi-Vault instance: the first enabled Vault in discovery order (`resolvePrimaryVaultId`). Superseded by #114's Scope zone. - Topbar "Tree Stale" badge kept (its removal is #116/#117's job), now driven by the resolved scope's `partial` flag instead of the retired signal. - Settings-nav visibility: `write-capabilities` dropped `settings_enabled` in #101; now derived from `GET /api/v1/vaults`'s `demo_mode` field. ## Validation ``` CARGO_TARGET_DIR=/scratch/cargo-target/hatchdoor-vault-identity-130 ``` - `cargo fmt --all -- --check` - `cargo clippy --all-targets -- -D warnings` - `cargo test --all` - `cd frontend && npm run format:check && npm run lint && npm run typecheck && npm test && npm run build` - `node scripts/check-module-map.mjs` ## Review Parallel Standards and Spec reviews both ran against the full diff (54 modified files, 3 new). Neither found hard violations, spec gaps, or scope creep — the explicitly excluded chrome (#114-#126) does not appear anywhere in the diff, and no backend surface beyond the one approved stats endpoint was touched. Standards review noted minor judgment calls, addressed here: `resolvePrimaryVaultId`'s docstring was too narrowly scoped to its write-target use and didn't explain its reuse for Stats' single-Vault default (both reviewers independently flagged this; fixed by widening the docstring), and three `/v/{vaultId}/n/{slug}` link constructions (Explorer.tsx x2, ChangesPanel.tsx) were missing `encodeURIComponent` unlike the rest of the diff (fixed for consistency, harmless in practice since Vault IDs are canonical UUIDs). Remaining notes accepted as documented judgment calls, not applied: the legacy `cache::queries::metadata::vault_stats` SQL function stays as dead code (already unreachable since #101, removing it is outside this ticket's declared owned paths); `writeApi.ts`'s `(vaultId, slug, hash)` positional-parameter pattern is left as-is rather than introducing a new bundling type, matching the ticket's "avoid new abstraction without demonstrated concrete value" guidance. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvBqEngeDUXPTj1udizyF4
Vault scope becomes readable and changeable in exactly one place on the desktop: a collapsible zone pinned above the explorer rail, listing "All Vaults" plus every enabled Vault in Vault-management order. The current scope takes the same active treatment as the active note; the zone folds with collapse state persisted per browser (default expanded), naming the current scope where the count normally sits when collapsed. The open note's own Vault carries a "Viewing" marker separate from the selected scope, expanded on its row and as a second line under the collapsed head. The topbar gets a matching muted, read-only echo of the Vault or scope before the breadcrumb, with no scope control of its own. Absent entirely at one enabled Vault or on mobile, where scope has nothing to offer yet (mobile placement is #145). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0148m68MTJK9124oCV4NSqEP
Adds a shared VaultPrefix component (hot ink, middot separator, never elides) for the "marked path root" grammar and wires it into every surface that can flatten notes across Vaults: Recently viewed, Changed on disk, and search results — each shown only when scope is "all" and more than one Vault is enabled, with the note page instead showing a synthetic Vault property row whenever more than one Vault is enabled, independent of scope, since an exact read is never ambiguous about its own Vault. Search's result path now elides head-first via a dedicated result-path-text span, so the file name (not the Vault name) survives truncation. A single-Vault instance renders every surface unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0148m68MTJK9124oCV4NSqEP
Each Vault's row in the sidebar Scope zone ends in one trailing slot: its note count when healthy, a shimmering placeholder while indexing (including a first index that has never published a snapshot — the API reports unavailable for that and for a vanished directory alike, but activation stays active for the former, verified against activation_snapshot in src/vault_runtime.rs), or one condition word otherwise — never a count and a condition together. The red tier (sync stopped, conflict, unavailable) takes a bordered ground and medium weight; the amber tier (stale, sync failed) stays plain ink, since --warn-fg and --err-fg aren't reliably tellable apart at this size. Hover text and the accessible name carry the Vault's own runtime error message; the word on screen stays terse. The All Vaults row and the collapsed Scope head share one aggregate: the enabled-Vault count when everything is healthy or indexing, or the shortfall against the worst tier present otherwise. The collapsed head's scope name now takes that worst ink too, so narrowing scope never hides trouble elsewhere. Adds a new lightweight useVaultNoteCounts hook that fetches the lean collection-scope GET /api/v1/vaults/all/stats (VaultStatistics — its first frontend consumer) to feed the healthy count, always at "all" scope and gated on more than one enabled Vault, refetched on the same vaultRevision useVaultTree already tracks rather than opening a second SSE subscription. Deletes the topbar's Tree Stale badge with nothing replacing it, per #116 (amended by #117): indexing runs on every save, so treating "the index is behind" as a warning produced noise several times an hour. Offline is the only condition left there, since it is about the workspace and not about any one Vault. Documents the new primitive in docs/design/design-system.html per #116's explicit instruction. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0148m68MTJK9124oCV4NSqEP
…ocks (#141) Never a banner, never a change to ranking. A partial collection read (Changed on disk, Search) shows results in the API's own order with one trailing warn-ink line below the last row naming only the Vaults that did not answer; with nothing usable, that line replaces the empty state entirely via a new StateBlock tone="error" (the documented red-heading §23 variant, previously unreachable — the component had no way to opt into it). New shared lib/vaultParticipants.ts reduces a VaultReadProjection's participants to the missing names and the shared sentence, consumed by both ChangesPanel and SearchDialog. Escalation is triggered by the write attempt, not by the condition alone. NotePage now imports deriveVaultSlot from #139's app/vaultSlotLogic.ts to detect the open note's own Vault being git-unavailable with a sync-stopped or conflicted condition: the save-state slot reads "Not saving" and a full-bleed notice appears before a doomed save is ever attempted (autosave's own enabled flag is gated on the same check, so nothing pointless hits the network; editing itself stays on, and the drafts safety net keeps the edit). A note that fails to read now also renders the same StateBlock error tone. Every other non-healthy condition, or trouble in a Vault that is not the one being read or written, raises nothing beyond its sidebar slot. Covered at three and eight Vaults per the acceptance criteria, plus a regression test confirming a non-blocking condition (stale) stays quiet everywhere but its own slot. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0148m68MTJK9124oCV4NSqEP
The search dialog carries its own filter — a lens over the answer in front of you — which dies when the dialog closes and never touches the sidebar's browsing scope (#119). Structurally enforced: SearchDialog has no onScopeChange prop to call. Desktop gets a facet rail beside the results: every Vault the search reached, in Vault-management order, each ending in #116's own count-or-condition slot (a plain count, "0" inert when a Vault answered with nothing, or "no answer" in error ink and aria-disabled when it didn't — a missing facet would be an absence, a named one is a fact). "All results" is the permanent widening row. Absent when scope is narrowed or at one enabled Vault, since there's nothing to filter across and the Scope zone is already on screen. Selecting a facet is a client-side filter over the already-fetched results — no re-fetch, no re-ranking. Phone gets the same filter as a Scope field beside Mode in a field strip under the input (reusing App.css's .field grammar), replacing the desktop Mode checkbox below 920px via the same CSS breakpoint responsive.css already uses. Scope stays even when the browsing scope is narrowed, because the panel covers the Scope zone there and the always-visible-scope rule needs the field to carry it regardless. Tapping a tag now hands the dialog that note's own Vault to pre-select in its filter (tags are per-Vault vocabularies): NotePage's onTagSelect became (tag, vaultId) => void, wrapping the call to NoteProperties with its own vaultId. useSearch exposes the new searchParticipants and a searchInitialVaultFilter that's cleared the moment the dialog closes, so a later plain open never inherits a stale preselection. Weighted the tests toward the invariant that's easy to quietly break: an explicit test asserts a facet click invokes none of the component's actual callback props, on top of coverage for row order, counts, the 0-vs-no-answer distinction, filtering, remount-resets-the-filter, and the mobile field strip sharing state with the desktop rail. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0148m68MTJK9124oCV4NSqEP
Under `all` scope with more than one enabled Vault, every participating Vault's component is laid out on its own and placed as a captioned, dash-enclosed island on one shared canvas with one zoom and one pan (#118's resolution). The shape never changes with Vault count or with how many actually answered: a Vault down doesn't collapse the field back to plain, it just leaves one fewer island and a warn-ink line naming the gap. A single-enabled-Vault instance or any narrowed scope stays byte-identical to today's single-Vault graph. Reuses #139's count-or-condition slot (`deriveVaultSlot`) for each island's caption and #141's partial-read pattern for the not-drawn line's tone, with new pure layout primitives (`computeIslandCenters`, `buildIslandGraphs`, `createIslandSimulation`) alongside the untouched single-graph path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0148m68MTJK9124oCV4NSqEP
Under all scope with more than one enabled Vault, the explorer tree becomes an accordion of per-Vault sections: exactly one Vault's tree shows at a time, every other Vault keeps a permanent one-line head using the documented section head with #139's own count-or-condition slot trailing. Unfolding only unfolds — it never calls setScope, and an unavailable Vault's head stays aria-disabled and never opens. useVaultTree now exposes vaultTrees (each participating Vault's own tree, grouped rather than merged) alongside the existing merged tree, which narrowed-scope and single-Vault-instance rendering keep using unchanged. The accordion's own state (app/vaultAccordion.ts) resolves the three landing defaults directly off the URL and stored last-note, rather than the open note's own content fetch, so it never races App.tsx's last-note redirect; narrowing scope always sets the accordion's remembered Vault to the one just left, so widening restores it. Folder-open memory is namespaced per Vault so two Vaults' identically-named folders don't share open/closed state. SideHead (Explorer.tsx) gains optional slot/disabled/className props to carry the count-or-condition slot and the aria-disabled treatment; existing callers are unaffected. pathToNoteIdentity moves out of Explorer.tsx into lib/notePath.ts, shared with the accordion's landing resolution and fixing a lint rule against non-component exports from a component file. storage.ts gains getStoredLastNote(), extracted from App.tsx's own last-note redirect so both read the same parse. Documents the new primitive in docs/design/design-system.html and docs/architecture/module-map.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SvBqEngeDUXPTj1udizyF4
Below 920px the topbar's second row becomes the scope row (browsing scope name, viewing marker, count-or-condition slot), replacing the note path — the note's own Vault property row/title and the search magnifier already cover that. Tapping the row opens a bottom sheet of scope rows at 46px, All Vaults first, reusing the desktop Scope zone's .scope-row grammar; picking one sets scope and dismisses the sheet. The viewing marker only fires when an exact read's Vault differs from a *narrowed* scope — never at "all", where every open note is already in scope by definition. Truncation under width pressure is staged via differential flex-shrink (rule collapses first, then the name ellipsizes, then the status word, which never truncates). scopeName moved from ExplorerPane.tsx to vaultSlotLogic.ts so both the desktop Scope zone and the new mobile row can share it without an eslint react-refresh violation. Not verified in a live browser: the shared Playwright session was unavailable for the whole session, so the truncation-priority CSS is implemented and reasoned through but not screenshot-confirmed at the narrowest supported width — flagged as an open acceptance criterion on the issue rather than checked off. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TcmFEh4ybZ5cZyzK89eyXX
#94 — Safe Local-history Git behavior
Implements #94 only, from
eb9e819 feat: preserve search semantics across Vault scope (#93)through3bed450 feat: scope local Git history to Vault subtree (#94).Scope
Intentional exclusions
No Git remains an absent task/configuration and is unchanged. No HTTP, MCP, frontend, managed checkout acquisition, pull-only/two-way lifecycle, fetch, push, merge, or per-Vault remote Git work is included. Markdown remains authoritative; SQLite/cache state remains disposable.
Validation
Before every Cargo invocation, exported and verified the justfile-resolved paths:
All Cargo commands used the direct Rust 1.97.1 binary.
*Vault path; preserved staged Vault blobcargo fmt --all -- --checkcargo clippy --all-targets -- -D warningscargo test --all— 560 library, 7 eval, and 3 CLI tests passedgit diff --checkReview
Parallel standards and specification reviews found and drove fixes for: enclosing-repository containment, destructive Local recovery, outside staging, literal Vault path selection, index/HEAD consistency, and pre-existing staged Vault content. Both final re-reviews reported no actionable findings.
#98 — Expose Vault discovery, management, status, and events under API v1
Implements #98 only, from
6d6d8fd docs: explain why the registry never gets a test-only URL escapethroughefb96c4 feat: expose Vault discovery, management, status, and events under API v1 (#98).Scope
New file
src/handlers/vaults.rsis the entire surface, authenticated and mounted independently of the legacy single-configured-Vaultrequire_vault_readygate:GET /api/v1/vaults— discovery: redacted definitions, per-Vault runtime status, derived capabilities,registry_revision,collection_revision. Reachable at zero enabled Vaults; reports a persisted-registry recovery state (corrupt/future/unsupported schema) as a normal200with an explicitrecoveryobject rather than erroring.POST /api/v1/vaults,PATCH /api/v1/vaults/{vault_id},POST .../enable,POST .../disable,DELETE /api/v1/vaults/{vault_id}— collection management with optimistic concurrency (expected_registry_revision) and one stableVaultApiError{code, message, vault_id?, retryable}shape on every error path.POST /api/v1/vaults/{vault_id}/sync,POST .../retry— manual managed-Git turns viaManagedGitScheduler, gated on the Vault being enabled and managed-Git.GET /api/v1/vaults/events(SSE) — one collection-wide invalidation stream carryingcollection_revision, the affected Vault IDs, and a broaddefinition/statuschange category.src/vault_runtime.rsgainedVaultChangeCategory/VaultCollectionRevisionEventso the collection-revision watch channel carries that category and affected-ID information instead of a bareu64.AppStategainedvault_work/managed_gitso this adapter can reconcile a registry mutation into live runtime effects (reconcile_and_reconstruct) and request an immediate Git turn without a second execution lane; reconciliation is spawned and awaited with a bounded 10s timeout rather than blocking the HTTP response indefinitely on a retiring Vault's in-flight Git/Index turn.Intentional exclusions
MCP discovery (#103) and Vault-scoped content reads/mutations and one-or-all collection reads (#99–#101) are separately owned later packets. No frontend integration (#67, after
/api/v1freezes at #101). Credentials are never returned, onlycredential_configured.Validation
cargo fmt --all -- --checkcargo clippy --all-targets -- -D warningscargo test --all— 618 library, 7 eval, and 3 CLI tests passed.router_accepts_attachment_between_2mb_and_configured_maxreproduces identically on the unmodified base commit (this sandbox's/tmptmpfs is 100% full) — pre-existing and unrelated, confirmed viagit stash.node scripts/check-module-map.mjs— 177 production source files assigned exactly oncegit diff --checkReview
Required parallel Standards and Spec reviews: Spec review found and drove fixes for two gaps —
enable/disable/disconnectbypassing the structured error shape on a missing/malformed revision query string, and duplicate-name/path-overlap errors using400where issue #62's HTTP-meaning buckets place current-state conflicts at409. Standards review found and drove a fix for one reliability issue — an unbounded HTTP block on a retiring Vault's in-flight background turn, now a spawned bounded wait. A self-review pass additionally found and fixed a double collection-snapshot read in the mutation response path. Remaining Standards notes (auth-gating boilerplate already duplicated by three pre-existing router sections, an SSE-event-encoding helper duplicated fromhandlers/api.rs, andadd()'s new-Vault-ID recovery via before/after ID diffing) were judged out of this packet's declared scope or accepted style tradeoffs, documented rather than applied.#99 — Expose Vault-qualified exact reads and contained resources under API v1
Implements #99 only, from
efb96c4 feat: expose Vault discovery, management, status, and events under API v1 (#98)throughb525706 feat: expose Vault-qualified exact reads and contained resources under API v1 (#99).Scope
New file
src/handlers/vault_content.rsis the whole HTTP surface, mounted in the same/api/v1/vaultsrouter group asvaults.rs(#98) and sharing its demo-mode/auth posture,VaultApiError, and rejection-mapping helpers:GET /api/v1/vaults/{vault_id}/notes/{slug},GET .../notes/{slug}/links,GET .../notes/{slug}/download— exact note, link, and download reads.GET /api/v1/vaults/{vault_id}/resolve,POST .../resolve-batch— wikilink resolution.GET /api/v1/vaults/{vault_id}/assets/{*path}— contained asset/attachment serving, with the same containment (extension allowlist, traversal rejection, canonicalize +starts_with) the legacy unscoped route applies./v/{vault_id}/n/{slug}— the canonical browser Note URL now dispatches to the SPA shell, mirroring the existing/n/{slug}registration.Every response is Vault-qualified and uses the shared
VaultApiError{code, message, vault_id?, retryable}shape. Exact reads always inspect the requested Vault's authoritative Markdown directory viaVaultReadCore, never the disposable cache, and are gated per-request by that Vault's ownvault_not_found/vault_disabled/vault_unavailablestatus rather than the legacyrequire_vault_readygate.src/vault_read.rsgained three additiveVaultReadCoremethods:resolve_wikilinks(resolves a whole batch against one authoritative-index build instead of one build per target — the standards review's first-round finding),vault_directory(the Vault's directory under the same gating as exact reads, including a directory-existence check so a managed-Git Vault that hasn't materialized yet reports the same retryablevault_read_unavailablecode an exact-note read would, rather than a raw filesystem error surfacing as a non-retryable500— the third-round finding), andexact_note_for_download(a Note and its directory from one Vault control-block fetch, since two independent lookups could otherwise observe different Vault generations across a concurrent edit — the second-round finding).src/vault_runtime.rs'sVaultControlBlock::ensure_accepting_operationsis widened topub(crate)for reuse.src/handlers/assets.rs,downloads.rs,vaults.rs, andapi.rs's existing path containment, export, response-building, and error-mapping helpers are widened topub(crate)and, where duplicated across rounds, factored into shared helpers (asset_response,download_response,asset_error_parts) reused by both the legacy unscoped routes and this ticket's Vault-scoped routes — unchanged behavior for the legacy routes, confirmed by their existing tests.Intentional exclusions
No one-or-all collection reads (#100), no Vault-control mutations beyond #98, no removal of old unscoped routes (#101), no MCP (#103), no frontend integration (#67).
Validation
cargo fmt --all -- --checkcargo clippy --all-targets -- -D warningscargo test --all— 632 library, 7 eval, and 3 CLI tests passed. Onegit::tasktest failed once under full-suite parallel load, passed on isolated and repeated reruns and on a second full-suite run — a pre-existing timing flake unrelated to this change (untouchedsrc/git/task.rs), not a regression.node scripts/check-module-map.mjs— 178 production source files assigned exactly oncegit diff --checkReview
Required parallel Standards and Spec reviews: Spec review reported no conformance gaps against the wire-contract spec, issue #62's Implementation Decisions, and the ADRs. Standards review ran four rounds: round 1 found and drove fixes for a resolve-batch performance bug (full Vault re-walk per target) and an unwrapped-blocking-I/O gap in the new asset handler, plus a duplicated response-header block; round 2 found and drove a fix for a TOCTOU race (note content and its export directory could observe different Vault generations across a concurrent edit) plus further helper-sharing cleanup; round 3 found and drove a fix for a genuine correctness bug (the asset route reporting a non-retryable
500instead of the retryable503an exact-note read reports for an identical not-yet-materialized-Vault condition) plus acontrol_block/authoritative_indexde-duplication, and judged two remaining notes (per-handlerparse_vault_id/clone prologue duplication, an unusedrelative_pathclone in a discarded batch-resolve field) as accepted style tradeoffs mirroring the codebase's existing per-handler boilerplate convention, documented rather than applied; round 4 could not complete (background-agent session limit) after the round-3 fixes were already validated clean by the full test suite, clippy, and module-map check.#100 — Expose one-or-all collection reads and search under API v1
Implements #100 only, from
b525706 feat: expose Vault-qualified exact reads and contained resources under API v1 (#99)through65684e0 feat: expose Vault-qualified one-or-all collection reads and search under API v1 (#100).Scope
New file
src/handlers/vault_collection_reads.rsis the entire surface, mounted in the same/api/v1/vaultsrouter group asvaults.rs(#98) andvault_content.rs(#99) and sharing their demo-mode/auth posture,VaultApiError, and rejection-mapping helpers:GET /api/v1/vaults/{scope}/tree,.../stats,.../graph— grouped per Vault.GET /api/v1/vaults/{scope}/recent,.../search— flattened across Vaults; search returns one global ranking.{scope}is one immutable Vault ID or the literalall, parsed by a newparse_vault_scopehelper (invalid_scope/400otherwise). Every route reuses the path parameter name{vault_id}rather than{scope}for router-tree consistency — axum/matchit requires one consistent parameter name per position across every route sharing it.This is a thin HTTP adapter with no collection-read domain logic of its own: it calls straight into already-implemented, already-unit-tested shared-core methods —
VaultReadCore::{trees, statistics, graphs, recently_modified}(src/vault_read.rs) andVaultSearchCore::search(src/search/vault_scoped.rs) — which already implement every one-or-all/partial/zero-Vault/grouped-vs-flattened/participant behavior issue #62 and the wire-contract spec require.search'slayersquery parameter (a comma-separated token list) is parsed by this file's ownparse_layer_selectionand applied identically to every participant without consulting any one Vault's known-layer catalog, matching issue #62's decision that a name valid in one Vault and absent from another is not an error — only a name absent from every usable participant isinvalid_layer_selection.vault_read_error_response(vault_content.rs, #99) is widened topub(crate)and extended withinvalid_search_query/invalid_layer_selection(400) andsearch_unavailable(503) arms, reused rather than duplicated here.Fixed a real wire-contract gap surfaced by wiring the first HTTP consumer of
VaultReadProjection:VaultScope(src/vault_read.rs) previously derived serde's externally-tagged representation ({"one": "<uuid>"}for the data-carrying variant), which does not matchdocs/migrations/vault-scoped-clients.md's flat-scalarscopefield. It now has a hand-writtenSerializeimpl producing the Vault ID's canonical text or the literal"all", mirroring exactly what a caller passes as thescopepath segment.Intentional exclusions
No MCP (#103), no Vault-control mutations beyond #98, no removal of old unscoped routes (#101), no frontend integration (#67).
Validation
cargo fmt --all -- --checkcargo clippy --all-targets -- -D warningscargo test --all— 641 library, 7 eval, and 3 CLI tests passed.router_accepts_attachment_between_2mb_and_configured_maxfailed once under this sandbox's/tmptmpfs at 100% full; reproduced identically against the unmodified base commit viagit stash— pre-existing and unrelated.node scripts/check-module-map.mjs— 179 production source files assigned exactly oncegit diff --checkReview
Required parallel Standards and Spec reviews both reported no conformance gaps or defects against the wire-contract spec, issue #62's Implementation Decisions, the ADRs, and this codebase's established handler conventions. Standards review noted one accepted style tradeoff — this file's own layer-token parser duplicates some of
LayerSelection::parse's token-classification logic, deliberately, since the single-Vault-degrade-with-warning and cross-Vault-deferred-validation semantics genuinely differ — documented rather than applied.#101 — Expose scoped mutations, retire the unscoped API, and freeze API v1
Implements #101 only, from
65684e0 feat: expose Vault-qualified one-or-all collection reads and search under API v1 (#100)through9e1b40e feat: expose Vault-scoped mutations and retire the unscoped API (#101).Scope
New file
src/handlers/vault_write.rsis the entire Markdown-mutation surface, mounted in the same/api/v1/vaultsrouter group asvaults.rs/vault_content.rs/vault_collection_reads.rsand sharing their demo-mode/auth posture,VaultApiErrorshape, andparse_vault_id/rejection helpers:POST .../notes,PUT .../notes/{slug},PATCH .../notes/{slug}/rename|move|move-rename|archive,DELETE .../notes/{slug},POST .../attachments,GET .../write-capabilities— each targets exactly one Vault ID (neverall), routes through the unchangedvault/write/**layer (ADR-03) calling the same functions the legacy write API used, and preserves the legacy verb per route exactly (issue Implement multi-vault workspace and managed Git #62: "preserve current write verbs").Every mutation gates on the requested Vault's own
VaultControlBlock:acquire_mutation()is a genuine per-Vault lock (concurrent mutations to different Vaults no longer serialize against each other), andcapabilities.mutateis checked explicitly — a Pull-only (or otherwise non-mutable) Vault now returns a structuredcapability_unavailable/409, a case the legacy single-Vault write API never had to handle. Every index build (initial lookup and the post-write refresh) runs off the async runtime viaspawn_blocking, matching the legacycurrent_index's behavior. Noise-exclusion is checked against that Vault's ownexclude_patterns, not the legacy instance-wideHATCHDOOR_EXCLUDEsetting.VaultReadCore::control_blockand the free functionvault_read::runtime_errorare widened topub(crate)so this file reuses the exact same not-found/disabled/no-runtime gate exact reads already use, instead of duplicating it.The entire legacy unscoped application API is retired in the same change (issue #62: "do not run a legacy duplicate API"):
src/handlers/write_api.rsis deleted;src/server.rs'sprotected/attachmentrouter groups and therequire_vault_ready/reject_demo_layer_querymiddleware are removed;src/handlers/api.rs/assets.rs/downloads.rs/diagnostics.rsare pruned to their still-usedpub(crate)helpers (asset/download containment logic reused byvault_content.rs, anddiagnostics.rs'sbuild_layer_diagnosticsstill called by the MCP diagnostics tool); dead wire types are pruned fromapi_types.rs; the now-unreachablerefresh_coalescingis removed fromapp_state.rs.Minor wire-shape changes versus the legacy responses: every mutation response now includes
vault_id;git_sync_warningis dropped (the managed-Git scheduler has no debounced-on-write hook to report from, unlike the legacy singlegit_synctask); write-capabilities'settings_enabledfield is dropped (it was only everfalsein the demo-mode branch, which the route is now structurally unreachable from in every reachable case).Two scope decisions confirmed with the user before implementation, both recorded as deliberate, documented gaps rather than silent omissions or opportunistic fixes:
refreshanddiagnosticsare retired with no Vault-scoped replacement. A working per-Vault refresh needsVaultWorkKind::Indexdispatch, which is a stub (vault_work_kind_not_yet_implemented); a Vault-scoped diagnostics route needs new per-Vault cache-query domain methods that don't exist. Both are out of scope for an adapter-only packet.docs/migrations/vault-scoped-clients.mdis updated to state this explicitly rather than continuing to promise both the same Vault prefix.HATCHDOOR_DEMO_MODE=true) is left with zero working content-serving routes:vaults_v1(including these new mutation routes) is unconditionally disabled under demo mode, exactly as Expose Vault discovery, management, status, and events under API v1 #98–Expose one-or-all collection reads and search under API v1 #100 already left it, and the legacyprotectedgroup it depended on for actual content-serving is now gone. Fixing this needs new design (how a demo Vault gets registered/exposed over/api/v1) that issue Implement multi-vault workspace and managed Git #62 never decided; not attempted here.Known limitation surfaced during review, also not fixed here (see
docs/architecture/module-map.md's "Vault mutation" boundary): the new per-VaultVaultControlBlock::acquire_mutationlock and the legacy single instance-wideAppState::vault_write_lock(still used by MCP write tools and the legacy Git-sync task) don't exclude each other. For a Vault that is both the legacy single-configured Vault and reachable through the registry, an HTTP mutation and a concurrent MCP write/Git-sync commit aren't mutually exclusive today. Unifying them needs MCP's own migration onto the Vault collection runtime (#103);src/mcp/**is untouched by this ticket.Intentional exclusions
No MCP (#103) — verified
cargo test mcpstill passes unchanged; MCP still calls the old unscoped shared-core functions directly, never the removed HTTP handlers. No frontend integration (#67/#102) — a known, already-accepted broken consumer per #62's sequencing.Validation
cargo fmt --all -- --checkcargo clippy --all-targets -- -D warningscargo test --all— 632 library, 7 eval, and 3 CLI tests passed.git::task::tests::spawning_flushes_accumulated_drift_in_local_modefailed once under full-suite parallel load, then passed on immediate re-run — the pre-existing, documented timing-sensitive flake, unrelated to this change.node scripts/check-module-map.mjs— 179 production source files assigned exactly oncegit diff --checkReview
Required parallel Standards and Spec reviews both ran against the actual diff. Standards review found one must-fix (index builds inside two handlers were running synchronously on the async runtime instead of via
spawn_blocking, unlike the legacycurrent_index— fixed) and flagged the MCP/Git-sync lock-coexistence gap above as moderate (documented, not fixed — MCP migration is #103) plus two minor nits (a 2-line duplicated glue pattern, accepted as a documented style tradeoff since it isn't cleanly reusable as one of the existingpub(crate)seams; an undocumentedsettings_enabledfield drop, now documented above). Spec review found the update-note route had silently changed verb from the legacyPUTtoPATCHwith no record anywhere (fixed — reverted toPUT, matching issue #62's "preserve current write verbs") and that the route-absence test's own header comment overclaimed coverage it didn't have for the four legacy PATCH-only action routes (fixed — added). Re-ran the full validation suite after every fix; all green.#130 — Give each Vault its own archive folder and commit identity, and stop requiring a Git username
Implements #130 only, from
b5a0290 fix(index): serialize vault snapshot publicationthroughbf2e2c9 feat: give each Vault its own archive folder and commit identity (#130).Scope
VaultRecord/VaultDefinitiongain an optional per-Vaultarchive_folder(normalized to a single-trailing-slash form) and an optional per-Vaultcommit_identity({ name, email }, name and email together only). Both default to absent via#[serde(default)], mirroring thepoll_interval_secsprecedent, so an on-disk registry written before these fields existed keeps loading under the unchangedREGISTRY_SCHEMA_VERSION.AppState::vault_archive_prefixresolves a Vault's own folder first, elseruntime_archive_prefix; wired into all three archive-prefix call sites (handlers/vault_content.rs,handlers/vault_write.rs,mcp/tools/write.rs).git::config::resolve_commit_identityresolves a Vault's own identity first, else the instance-wideHATCHDOOR_GIT_AUTHOR_NAME/HATCHDOOR_GIT_AUTHOR_EMAILdefaults; called once invault_runtime.rs'sdispatch_managed_git_turn_withso every Git turn for a Vault (managed-Git, existing-Git remote-sync, and existing-Git Local-history) honors it.normalize_credentialsnow rejects only an empty token, substituting the documentedHTTPS_CREDENTIALS_USERNAME_PLACEHOLDERwhen no username is supplied. Credentials remain write-only, read back only ascredential_configured./api/v1Vault create/edit/discovery payloads and MCPcreate_vault/edit_vault/list_vaultscarry the two new optional fields with identical names and semantics; MCP'screate_vaulttool already shared the HTTPCreateVaultRequesttype directly, so it inherited the fields for free.Intentional exclusions
No SSH transport. No mandatory per-Vault values and no migration of existing server-wide values into every Vault record.
mcp/config.rs'sSERVER_INSTRUCTIONSis untouched — the new fields are additive and don't change what an agent must know before calling a tool.Validation
cargo fmt --all -- --checkcargo clippy --all-targets -- -D warningscargo test --all— 697 library, 7 eval, and 3 CLI tests passed. 22 library tests ingit::managed_sync/git::managed_task/vault_runtimefailed; confirmed pre-existing and unrelated viagit stashagainst the unmodified base commit — this sandbox's globalinit.defaultBranch=mainconflicts with these fixtures' hardcoded"master"branch pushes.node scripts/check-module-map.mjs— 179 production source files assigned exactly onceReview
Parallel Standards and Spec reviews found no hard violations and no scope creep beyond the required mechanical updates to every existing
NewVaultDefinition/VaultDefinitionEdit/CreateVaultRequest/EditVaultRequestconstruction site (exhaustive struct literals, mostly test fixtures). Spec review found two real test-coverage gaps —git::config::resolve_commit_identityandAppState::vault_archive_prefixhad no direct tests — both fixed with focused unit tests. Spec review also confirmed the issue's premise thatsrc/mcp/tools/read.rshas a separateCreateVaultArgsstruct was inaccurate:create_vault_toolparses directly into the sharedvaults::CreateVaultRequest, so the HTTP struct's new fields already cover MCPcreate_vaultfor free.