Design: AppHost-anchored repository paths (#865)
Source of truth
Generated by /office-hours on 2026-10-10.
Branch: codex/make-it-so-781-structural-classifier-pilot (planning checkout).
Repo: forge-trust/AppSurface.
Status: APPROVED.
Mode: Builder, open-source maintenance.
Issue: #865.
Problem Statement
AppHost authors need repository assets to resolve independently of the process working directory, test output directory, and checkout style. AspireStartupContext currently searches from AppContext.BaseDirectory, recognizes only .git directories, and keeps the outermost match. When it finds nothing, it stores an empty root, so a documented absolute-path helper can return a relative path.
The pinned Skoolit component requests both src/Skoolit.AppHost/keycloak/skoolit-realm.json and src/Skoolit.KeycloakTheme/theme/skoolit through that helper. These are source-established defects; this session has not reproduced a live startup failure or measured a reliability improvement.
Source inspection used AppSurface checkout 5a3541cc2a3257663d80ed8ea25d4d6fc451c32d, Aspire 13.4.4, and the Skoolit commit in the link above. The issue's upstream baseline is c2f062f2a22afeff972c5c6512f1f34dd182db90. Existing unrelated #781 modifications remain outside this design.
What Makes This Cool
A developer can open a linked worktree, run a profile from an unrelated directory, or test an AppHost from a separate output tree and still select the same repository assets. A metadata-free archive remains usable for profiles without repository assets, and an explicit root supports callers that supply their own layout.
Decisions and Premises
- The nearest Git-marked ancestor of
builder.AppHostDirectoryowns repository-relative paths. The AppHost project directory is the discovery anchor, not an automatic replacement for the repository root. - Missing discovered roots fail only when a caller asks for a repository path. Explicit roots take precedence, must be nonblank fully qualified paths, and need not exist.
- Choose approach A, fixing the existing context helper, because one production entry point owns the behavior and public context tests can verify it.
- Keep the current path-combination API and semantics. This fix adds no path-containment policy, public locator, profile root option, Git subprocess, or new dependency.
- Approach B, extracting an internal locator, was declined because there is only one current consumer of this discovery contract.
- The optional second opinion was declined. The final document receives the separate required specification review.
Recommended Approach
Keep discovery private to AspireStartupContext. Its constructor chooses and stores a root once; subsequent path calls reuse that decision. Existing component caching and profile composition remain intact.
API contract and ordering
| API or state | Behavior |
|---|---|
AspireStartupContext(builder, applicationRoot: null) |
Search from builder.AppHostDirectory toward its parents. Select the first .git directory or regular file. Do not inspect marker contents or execute Git. |
Explicit applicationRoot |
Validate before any discovery. Reject empty, whitespace-only, relative, or drive-relative values with ArgumentException identifying applicationRoot. Use Path.IsPathFullyQualified, not only Path.IsPathRooted; normalize a valid root to its full path without requiring disk existence. |
| Unavailable discovered root | Store an explicit unresolved state rather than an empty string. Missing metadata, or a missing/nonabsolute search anchor, must not make ordinary context construction fail. Do not fall back to the process directory or executable location. |
GetPathFromRoot(relativePath) |
Retain the existing public signature. Reject null with ArgumentNullException for relativePath; then require a resolved root and combine it with the supplied path. Supported repository-relative input returns a fully qualified path. |
| Unresolved-root path request | Throw InvalidOperationException that names repository discovery from AppHostDirectory and tells the caller to construct a context with an explicit absolute applicationRoot. The diagnostic must not imply a new profile/CLI setting exists. |
| Asset existence | The helper computes paths. Callers own file/directory existence and the errors produced by realm import or bind mounts. |
Preserve Path.Combine behavior for empty paths, rooted second arguments, and ... These legacy inputs do not establish confinement under the repository; the intended input remains a trusted repository-relative asset path. A missing root still fails for a rooted second argument: callers cannot use it to bypass the new unresolved-root diagnostic. Document this compatibility boundary without adding unrelated validation.
Recognizing a .git file is marker discovery, not verification of a valid Git worktree. Nested repositories stop at the nearest marker even when an outer repository also exists. Discovery has no cancellation API; retain and regression-test the existing cooperative cancellation checkpoints in profile composition and testing.
Acceptance Proof
Use public construction and GetPathFromRoot in the Aspire context tests. Unique temporary trees and a fake builder with a configured AppHostDirectory provide deterministic fixtures. Do not use reflection or a production test-only locator seam.
| Fixture | Required observation |
|---|---|
.git directory and .git file, separately |
Both Skoolit-shaped paths equal the expected absolute paths. The Git-file case fails with the original implementation. |
| Nested markers, including an inner file and outer directory | The inner repository wins. Include a marker directly in the AppHost directory. |
AppHost fixture outside AppContext.BaseDirectory |
Executable/test output does not choose the root. |
| Two unrelated working directories | With the same AppHost tree, both results stay identical and resolution leaves each working directory unchanged. |
| No metadata | Construction and a component that requests no path succeed; the first repository-path request produces the actionable exception. |
| Missing or nonabsolute AppHost anchor | Configure missing/blank and nonabsolute AppHostDirectory values separately. Construction and path-free component resolution succeed; the first repository-path request throws the specified actionable InvalidOperationException, without falling back to the process directory. |
| Explicit absolute roots | Override discovered metadata; work without metadata and when the explicit directory does not exist. |
| Invalid overrides and path inputs | Cover blank/relative overrides, platform-specific drive-relative values, null paths, and preserved empty/rooted/parent-path combination behavior. |
The working-directory proof must actually change directories. Place the entire test class that mutates Directory.SetCurrentDirectory in an xUnit collection with DisableParallelization = true, restore the original directory in finally, and assert before/after values for each resolution. Reuse the repository's existing nonparallel collection pattern; ordinary temporary-tree unit tests stay parallel. A subprocess proof is an acceptable alternative if the implementation reuses the existing packed-consumer process harness rather than adding a general runner.
Exercise the shared profile composition path with a path-free component in a metadata-free tree and retain component deduplication, ordering, and cancellation tests. The typed testing factory already assigns the generated AppHost marker's project directory to Aspire; prove the new helper consumes that pinned directory without changing the factory contract.
Create a version-bound equivalent consumer fixture for the two exact Skoolit paths. It must materialize a realm file and theme directory, use a Git-file marker, verify the selected root and asset existence, and record the framework commit/package version plus pinned consumer identity. Prove these paths before any container startup. This fixture can satisfy the issue without a downstream version-only edit; an eventual Skoolit upgrade still follows the coordinated upgrade policy.
Documentation, Distribution, and Validation
Update constructor and path-method XML documentation, the private discovery helper's contract comments, the Aspire package guide, the Aspire index, and the repository entry point with useful links. Cover root meaning, defaults, override ordering, archives, worktrees, nested repositories, deferred errors, and the distinction between path computation and asset existence. Explain when repository assets should use this helper and when project-local assets should use Aspire's AppHost directory directly.
Add an append-only migration entry using the release authoring contract. Call out the discovery-anchor and nearest-root changes, Git-file recognition, rejection of formerly accepted relative overrides, and removal of silent relative results when no root exists. No version or publication is claimed by this design.
Distribution uses the existing coordinated ForgeTrust.AppSurface.Aspire NuGet release and release workflow; no package or channel is added. Run focused Aspire runtime/testing suites and the equivalent two-path consumer fixture, retain existing package/runtime tests, and run the existing packed-consumer check. No public signature is added, but the behavioral change still needs package proof. Run repository formatting and relevant solution coverage; cover all changed success and failure branches and resolve introduced compiler, analyzer, and documentation warnings.
The proof does not need Keycloak credentials or a running container to establish path correctness. Any later live startup proof must distinguish asset-path success from unrelated provider, import, or readiness failures.
Next Steps and The Assignment
Before changing the resolver, record a failing public regression for both pinned Skoolit asset paths in a synthetic Git-file checkout, with the AppHost outside test output and the working directory unrelated to it. Preserve the expected root, actual paths, framework identity, and consumer commit as the before/after acceptance checklist. Then implement the context change, complete the fixture matrix, document migration behavior, and verify the packed consumer.
This planning session writes only the design and its review evidence. Runtime reproduction, implementation, test execution, package publication, and downstream upgrading remain future work. The path fix is independent of #642's deterministic Aspire testing foundation and the other Skoolit review cases. Rollback reverts the framework behavior and its documentation together; keep the existing consumer until its replacement passes the acceptance proof.
What I noticed about how you think
- You chose "open-source", grounding this session in a reusable framework correction and consumer proof.
- You selected "A — Fix the existing context helper", keeping the change at its current ownership boundary while retaining the full acceptance scope.
Reviewer Concerns
Disposition: COMPLETED
Stop: PASS
No unresolved findings.