Skip to content

feat(sessions): Add composability/resolution to loadouts - #348

Merged
evanspearman merged 1 commit into
mainfrom
evan/resolve
Jun 8, 2026
Merged

feat(sessions): Add composability/resolution to loadouts#348
evanspearman merged 1 commit into
mainfrom
evan/resolve

Conversation

@evanspearman

@evanspearman evanspearman commented Jun 4, 2026

Copy link
Copy Markdown
Member

Summary

Adds the composability layer that turns a [Loadout] (and, eventually, project configs and packages) into a Resolution of vars, patches, packages, and lifecycle hooks ready for the apply layer.

The core flow:

  contributors (Loadout, …)  ──Composer::add()──►  Composer
                                                      │
                                    Composer::resolve(policy, hooks, opts)
                                                      │
                                                      ▼
                                                Resolution

What's in here

New composable module (crates/sessions/src/composable.rs):

  • Composable trait: contributors implement fn contribute(self, env) -> Result<Contribution, Error>. Drops a Provenanced constraint that didn't fit (contributors aren't items).
  • Contribution: typed accumulator for vars, patches, packages, and lifecycle hooks, with builder + mutating APIs. Single canonical type — Composable impls never see Composer internals.
  • Composer: takes Composables one at a time (add) or in batches (add_all); resolves against UserPolicy. Stores a swappable env-lookup closure (Composer::with_env(...) for tests).
  • Resolution: the policy-gated output. Exposes vars(), patches(), packages(), lifecycle_hooks() (sealed fields; consume via into_parts()).

Policy resolution (var + patch domains):

  • Three-pass per domain: categorize → batched hook prompt → apply.
  • Source-aware bypass: Source::UserLoadout items skip allow/deny (ignore still applies). Documented and tested for Source::Project / Source::Package not bypassing.
  • Hooks receive an owned policy snapshot; they cannot mutate the resolver's state and must return any rule additions via HookResult::Decided.updated_policy.
  • Error variants split between configuration (PatchConfig — e.g. patterns with no walk root) and IO walk failures (PatchWalk), each accumulated rather than first-wins.

impl Composable for Loadout: materializes Source::UserLoadout, resolves inherited vars through the env closure, and emits all four primitive kinds. End-to-end test covers all four reaching Resolution.

thiserror adopted across paths and sessions error enums (replaces manual Display/Error impls). Adds thiserror = "2" workspace dep.

paths crate gains AbsPath::new_unchecked, AbsPath::root(), and RelPath::new_unchecked for the resolver's by-construction-absolute paths.

patches crate gets:

  • FileSet as a single-glob newtype (was a base + patterns table). Refused **/*-style patterns without a literal prefix would walk /; they now surface as Error::NoWalkRoot.
  • PatchPolicy::check with the source-aware bypass.
  • IntoIterator for Patches so Loadout::contribute can consume.

vars crate gets VarsPolicy::check (matching the patches one).

policy::UserPolicy gets into_parts() -> (VarsPolicy, PatchPolicy). Resolver moves narrow policies by value; no &mut threading.

Docs: crates/sessions/docs/RESOLUTION.md — data-flow diagram and invariants.

Deferred (intentional)

  • Conflict resolution. Composer::merge is pure aggregation today — two contributors setting EDITOR both survive. The single merge site is where dedup / precedence will land. Doc comment says so.
  • Hook policy gating. Lifecycle hooks run inside the isolated environment, so no allow/deny/ignore for them.
  • CLI/TUI PolicyHooks impl. Downstream of this crate.
  • Project and contributing Package types. Live in mfile / graph once they exist; both just implement Composable.

Test plan

  • cargo test -p sessions --lib (118 tests pass)
  • cargo test -p sessions (lib + doc tests)
  • cargo clippy -p paths -p sessions --all-targets -- -D warnings clean
  • Resolver paths covered: allow / deny / ignore, user-bypass, package-not-bypassing, prompt + AllowOnce / DenyOnce / UseRule, mixed-batch ordering, hook contract violations, walk failures vs. config failures
  • Composable flow covered: add, add_all, contributor error propagation, packages + hooks pass-through, with_env overriding the lookup, Loadout contributing all four kinds end-to-end
  • No downstream callers yet — surface is new

Summary by CodeRabbit

  • New Features

    • Composable session pipeline to merge loadouts, variables, patches, and lifecycle hooks; richer resolution results and APIs.
    • Loadouts now require validated names and use builder-style construction.
    • Patch handling: single-glob filesets, fan-out of sources, and improved patch destination semantics.
  • Bug Fixes

    • Stricter validation and clearer errors for loadout names, patch destinations, and variable resolution.
    • Improved variable matching, resolution outcomes, and policy evaluation order.
  • Chores

    • Workspace dependencies added to support new error/serde utilities.
  • Documentation

    • Added comprehensive session resolution documentation.

@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6edc774d-e635-4f6a-b53a-94d1de932d1d

📥 Commits

Reviewing files that changed from the base of the PR and between 399866c and 741d3b6.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (12)
  • Cargo.toml
  • crates/paths/Cargo.toml
  • crates/paths/src/lib.rs
  • crates/sessions/Cargo.toml
  • crates/sessions/docs/RESOLUTION.md
  • crates/sessions/src/composable.rs
  • crates/sessions/src/lib.rs
  • crates/sessions/src/lifecyclehook.rs
  • crates/sessions/src/loadout.rs
  • crates/sessions/src/patches.rs
  • crates/sessions/src/policy.rs
  • crates/sessions/src/vars.rs
✅ Files skipped from review due to trivial changes (2)
  • crates/sessions/src/lib.rs
  • Cargo.toml
🚧 Files skipped from review as they are similar to previous changes (8)
  • crates/sessions/src/lifecyclehook.rs
  • crates/paths/Cargo.toml
  • crates/paths/src/lib.rs
  • crates/sessions/src/policy.rs
  • crates/sessions/src/loadout.rs
  • crates/sessions/src/composable.rs
  • crates/sessions/src/vars.rs
  • crates/sessions/src/patches.rs

📝 Walkthrough

Walkthrough

Adds a composable session resolution pipeline: provenance-tagged contributions, three-pass policy-driven resolution for vars and patches, patch glob expansion and destination computation, FileSet/PatchPolicy redesign, LoadoutName validation, thiserror refactors, and extensive tests/docs.

Changes

Session Resolution Pipeline with Provenance & Policy

Layer / File(s) Summary
Dependencies & Error Refactoring
Cargo.toml, crates/paths/Cargo.toml, crates/sessions/Cargo.toml, crates/paths/src/lib.rs, crates/sessions/src/lifecyclehook.rs
Add nutype and thiserror workspace deps; convert local error enums to thiserror::Error derives and export composable module.
Provenance & Policy Framework
crates/sessions/src/composable.rs
Introduce Source, Provenanced trait and wrappers, Contribution, policy verdict types (Decision, CheckOutcome, ItemDecision, HookResult), PolicyHooks, ResolveError, session result types, and Composer API surface.
Composer API & Resolution Flows
crates/sessions/src/composable.rs
Implement Composer with add/add_all and resolve that runs resolve_vars and resolve_patches (three-pass flows), patch enumeration (enumerate_patch_files), compute_dest, and rebuilds UserPolicy; add tests.
LoadoutName & Loadout Composition
crates/sessions/src/loadout.rs
Add validated LoadoutName (nutype), require name on Loadout, provide Loadout::new, builders/accessors, implement Composable for Loadout, and update tests/fixtures.
Patches FileSet & Policy Redesign
crates/sessions/src/patches.rs
Redesign FileSet as single compiled glob with matcher; change PatchDest to SandboxRelPath; add Patches fan-out deserializer and owned iteration; refactor PatchPolicy to Vec<FileSet> with ignore-first precedence and updated serde/tests.
Variables Policy & Resolution Logic
crates/sessions/src/vars.rs
Store globset::GlobSet in VarNameGlobs, add VarsPolicy::check() with user-bypass rules, introduce ResolvedVar with resolve_with/resolve, and add TryFrom conversions and tests.
Documentation & Integration
crates/sessions/docs/RESOLUTION.md, crates/sessions/src/policy.rs, crates/paths/src/lib.rs
Add RESOLUTION.md documenting pipeline and invariants, add UserPolicy::into_parts(), and add AbsPath/RelPath unchecked/root helpers.

Sequence Diagram

sequenceDiagram
    participant User as User
    participant Composer as Composer
    participant VarsPolicy as VarsPolicy
    participant PolicyHooks as PolicyHooks
    participant PatchPolicy as PatchPolicy
    participant FileSystem as FileSystem

    User->>Composer: add(Loadout / Config)
    User->>Composer: resolve(policy, hooks, options)
    Composer->>VarsPolicy: resolve_vars (pass 1 categorize)
    VarsPolicy->>PolicyHooks: on_var_unapproved(items)
    PolicyHooks-->>Composer: decisions (+policy updates)
    Composer->>FileSystem: enumerate_patch_files (globs)
    FileSystem-->>Composer: matched files / walk errors
    Composer->>PatchPolicy: resolve_patches (pass 1 categorize)
    PatchPolicy->>PolicyHooks: on_patch_unapproved(items)
    PolicyHooks-->>Composer: patch decisions
    Composer-->>User: (Resolution, updated UserPolicy)
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • gominimal/minimal#273: Earlier work introducing sessions var/policy primitives and Loadout shapes that are further integrated here.

Suggested Reviewers

  • norrietaylor
  • twitchyliquid64

Poem

🐰 A rabbit hops through sessions bright,
With provenance tagged left and right,
Three passes of policy, decisions made,
From loadouts to globs, a tidy cascade,
Now patches and vars in harmony play!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(sessions): Add composability/resolution to loadouts' clearly and concisely summarizes the main change: introducing composability and resolution capabilities to the loadouts module within the sessions crate.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (3)
crates/sessions/src/composable.rs (1)

467-479: 💤 Low value

Consider adding doc comments to PolicyHooks trait methods.

The public trait methods on_var_unapproved and on_patch_unapproved lack documentation. Since this is a public API that implementors will need to understand, documenting the expected behavior, parameters, and return value semantics would help downstream consumers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/sessions/src/composable.rs` around lines 467 - 479, Add Rustdoc
comments to the PolicyHooks trait and both methods (PolicyHooks,
on_var_unapproved, on_patch_unapproved) describing purpose and expected
behavior, explain each parameter (policy: VarsPolicy/PatchPolicy and items:
&[Unapproved<...>]) including what the Unapproved item represents, and document
the return type HookResult<VarsPolicy>/HookResult<PatchPolicy> semantics (e.g.,
whether Ok means accept/modify policy, Err aborts processing, any side effects).
Keep docs concise, include example usage or panics only if relevant, and ensure
doc comments appear above the trait and each method signature.
crates/sessions/src/patches.rs (2)

683-702: 💤 Low value

The check method consumes item even when returning Ignored.

When the path matches ignore, the method returns Decision::Ignored but drops item. This is fine if callers don't need the item back for logging/diagnostics, but it differs from Denied and Allowed which preserve the item.

If callers might want to log ignored items with their provenance, consider returning Decision::Ignored(item) for consistency. Otherwise, the current design is acceptable if "ignored" truly means "discard without trace."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/sessions/src/patches.rs` around lines 683 - 702, The check method
currently returns CheckOutcome::Decided(Decision::Ignored) and drops the
provided item when filesets_match(&self.ignore, path) is true, which is
inconsistent with Denied/Allowed branches that preserve the item; update the
ignore branch in check (function name: check, type: impl for patches) to return
the provenance with the ignored decision (e.g.,
CheckOutcome::Decided(Decision::Ignored(item))) so callers can still access item
metadata for logging/diagnostics, ensuring the other branches (Denied/Allowed)
remain unchanged and still use filesets_match(&self.deny, path) and
filesets_match(&self.allow, path).

184-197: 💤 Low value

walk_root does not account for escaped glob metacharacters.

The scan treats \*, \?, \[, \{ the same as unescaped metacharacters, so a pattern like foo/bar\*/baz.txt (where \* is a literal asterisk) would incorrectly return Some("foo/bar") instead of Some("foo/bar*/baz.txt").

This is likely acceptable given:

  1. Escaped metacharacters in paths are rare in practice
  2. The consequence is a broader (but still correct) walk root, not incorrect matching
  3. globset::Glob handles the actual matching correctly

Consider documenting this limitation if edge-case correctness is important.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/sessions/src/patches.rs` around lines 184 - 197, The walk_root
implementation incorrectly treats escaped glob metacharacters as active; update
walk_root to skip characters that are escaped by a backslash: when iterating
pattern.bytes().enumerate() inside walk_root, if you see a backslash (b'\\')
treat the next byte as literal by advancing/skipping the subsequent byte in the
loop (so escaped '*', '?', '[', '{' are ignored for triggering the glob
handling), then continue the existing logic to record last_slash and return
HostPath::new(...) as before; ensure this uses the same pattern() value and
preserves behavior when the backslash is the final byte.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/paths/src/lib.rs`:
- Around line 200-207: The constructors AbsPath::new_unchecked and
RelPath::new_unchecked currently are public and only use debug_assert! to check
inner.is_absolute()/inner.is_relative(), allowing invalid values in release
builds; fix this by making them either non-public (change signature to
pub(crate) fn new_unchecked(...)) or mark them explicitly unsafe (pub unsafe fn
new_unchecked(...)) and add a clear # Safety doc comment plus the required //
SAFETY: comment per repo standards describing the caller responsibility to
ensure the path invariant (absolute for AbsPath, relative for RelPath); update
both functions (new_unchecked) accordingly so the invariant is enforced by the
type system rather than only debug assertions.

In `@crates/sessions/docs/RESOLUTION.md`:
- Line 13: Update the documentation to use the current API names: replace
occurrences of Composable::compose_into(&mut Composer) with
Composable::contribute(...) and update references to Composer::add/add_all where
appropriate; also update the resolve signature block to reflect the new
Contribute/Composer method names so examples and docs consistently reference
Composable::contribute and Composer::add/add_all instead of the old
compose_into/Composer naming.
- Line 7: The fenced code block opened on line 7 lacks a language tag (triggers
markdownlint MD040); update the opening fence in RESOLUTION.md (the triple
backtick block) to include a language identifier such as ```text (or another
appropriate language) so the fenced block becomes ```text and resolves the
linter warning.

In `@crates/sessions/src/vars.rs`:
- Around line 507-512: Change build_set to return Result<GlobSet, Error> instead
of unwrapping: replace the call to GlobSetBuilder::build().expect(...) with
b.build().map_err(|e| Error::GlobSetBuild(e.into()))? (or add a new
Error::GlobSetBuild(globset::Error) variant and map accordingly). Update callers
(try_new and with_pattern) to propagate the Result using ? and adapt their
signatures to return Result where needed, and add the new Error enum variant
mapping globset::Error into your crate's Error type so build failures are
returned as typed errors rather than panicking.

---

Nitpick comments:
In `@crates/sessions/src/composable.rs`:
- Around line 467-479: Add Rustdoc comments to the PolicyHooks trait and both
methods (PolicyHooks, on_var_unapproved, on_patch_unapproved) describing purpose
and expected behavior, explain each parameter (policy: VarsPolicy/PatchPolicy
and items: &[Unapproved<...>]) including what the Unapproved item represents,
and document the return type HookResult<VarsPolicy>/HookResult<PatchPolicy>
semantics (e.g., whether Ok means accept/modify policy, Err aborts processing,
any side effects). Keep docs concise, include example usage or panics only if
relevant, and ensure doc comments appear above the trait and each method
signature.

In `@crates/sessions/src/patches.rs`:
- Around line 683-702: The check method currently returns
CheckOutcome::Decided(Decision::Ignored) and drops the provided item when
filesets_match(&self.ignore, path) is true, which is inconsistent with
Denied/Allowed branches that preserve the item; update the ignore branch in
check (function name: check, type: impl for patches) to return the provenance
with the ignored decision (e.g., CheckOutcome::Decided(Decision::Ignored(item)))
so callers can still access item metadata for logging/diagnostics, ensuring the
other branches (Denied/Allowed) remain unchanged and still use
filesets_match(&self.deny, path) and filesets_match(&self.allow, path).
- Around line 184-197: The walk_root implementation incorrectly treats escaped
glob metacharacters as active; update walk_root to skip characters that are
escaped by a backslash: when iterating pattern.bytes().enumerate() inside
walk_root, if you see a backslash (b'\\') treat the next byte as literal by
advancing/skipping the subsequent byte in the loop (so escaped '*', '?', '[',
'{' are ignored for triggering the glob handling), then continue the existing
logic to record last_slash and return HostPath::new(...) as before; ensure this
uses the same pattern() value and preserves behavior when the backslash is the
final byte.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0e12e885-0c1d-4843-8034-5c4145fa702c

📥 Commits

Reviewing files that changed from the base of the PR and between a2b6393 and 1883552.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (12)
  • Cargo.toml
  • crates/paths/Cargo.toml
  • crates/paths/src/lib.rs
  • crates/sessions/Cargo.toml
  • crates/sessions/docs/RESOLUTION.md
  • crates/sessions/src/composable.rs
  • crates/sessions/src/lib.rs
  • crates/sessions/src/lifecyclehook.rs
  • crates/sessions/src/loadout.rs
  • crates/sessions/src/patches.rs
  • crates/sessions/src/policy.rs
  • crates/sessions/src/vars.rs

Comment thread crates/paths/src/lib.rs
Comment thread crates/sessions/docs/RESOLUTION.md
Comment thread crates/sessions/docs/RESOLUTION.md Outdated
Comment thread crates/sessions/src/vars.rs Outdated
@evanspearman
evanspearman force-pushed the evan/resolve branch 2 times, most recently from 8205629 to 778a0c1 Compare June 4, 2026 22:52
vars_lenient: Vec<LenientVarEntry>,
/// Patches contributed by this loadout.
#[serde(default, skip_serializing_if = "Patches::is_empty")]
patches: Patches,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iirc Patches contains the path to the file but not the file contents - that might complicate us transmitting the loadout set to minimald.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we probably don't want that actually. While the CLI will need to be the one reading the files, since the daemon might be running under a different user or in theory on a different machine, we probably don't want to read all files into memory when deserializing the loadouts. Instead we probably want to open up a tarball stream to the daemon from the CLI directly and avoid having the files completely in memory at all. For Config files it's fine, but it could become problematic if someone tries to bring in model weights or some other larger asset.

@evanspearman
evanspearman merged commit 73d133c into main Jun 8, 2026
23 checks passed
@evanspearman
evanspearman deleted the evan/resolve branch June 8, 2026 15:40
bryan-minimal added a commit that referenced this pull request Jul 14, 2026
GitLab-hosted upstreams (the fpottier trio menhir/visitors/unionFind on
gitlab.inria.fr; mesa) had no honest provenance category, so a build.ncl
couldn't declare one and they were pushed onto gs:// mirrors + Cpe workarounds.

Add a `source_provenance_gitlab` sub-contract (category = 'Gitlab, with required
host / owner / repo) and its dispatch arm. `host` is required because GitLab is
federated — the same owner/repo on gitlab.com vs gitlab.inria.fr are different
projects.

Pairs with minimal-supply-chain#347/#348, which adds the matching
`Provenance::Gitlab { host, owner, repo }` (parse + pkg:generic identity).
Verified with nickel: a valid Gitlab block exports, a block missing `host` is a
contract error, and the existing categories still validate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
bryan-minimal added a commit that referenced this pull request Jul 14, 2026
…752)

* feat(stdlib): accept 'Gitlab source_provenance category

GitLab-hosted upstreams (the fpottier trio menhir/visitors/unionFind on
gitlab.inria.fr; mesa) had no honest provenance category, so a build.ncl
couldn't declare one and they were pushed onto gs:// mirrors + Cpe workarounds.

Add a `source_provenance_gitlab` sub-contract (category = 'Gitlab, with required
host / owner / repo) and its dispatch arm. `host` is required because GitLab is
federated — the same owner/repo on gitlab.com vs gitlab.inria.fr are different
projects.

Pairs with minimal-supply-chain#347/#348, which adds the matching
`Provenance::Gitlab { host, owner, repo }` (parse + pkg:generic identity).
Verified with nickel: a valid Gitlab block exports, a block missing `host` is a
contract error, and the existing categories still validate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(stdlib): bump crate version to 0.0.17

Requested on review — the embedded stdlib changed (new 'Gitlab
source_provenance contract), so bump the crate + the workspace dep pin + lock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants