Skip to content

Cross-experience vault facilities: direct domain crates, one registered check list

State: open/accepted

  • Git awareness ships as determined-git, a plain domain crate called directly from determined-platform/src/api/v1/git.rs through Ctx. It carries no ports::GitPort trait, no Capability::Git variant, and no Backend row: the port machinery exists to toggle a Rust arm against a Node one, and git never had a Node arm.
  • Commit-time checks run through VaultCheck, a determined-core trait. Implementors are registered once in AppState::new and carried on Ctx beside jobs, engine and vaults. Agent Pants and polish each contribute one; neither owns the hook.
  • A check answers with findings at two levels. A Refusal blocks the commit; a Warning does not. The split is not invented here — discover_components already separates run-level refusals from per-note skips, and this promotes that distinction onto the wire.
  • Silenced warnings live in the vault-checks settings namespace, keyed <vault_id>/<check-id>/<rule-id>. “Ignore once” is dialog state and is never persisted.
  • The question spans agent-pants, determined and determined-platform, so it is recorded in the root corpus rather than in solutions/determined/docs/decisions/.

Two experiences want the same kind of platform facility. Agent Pants wants a vault to know it is a git repository and to offer a validated commit. Polish already runs a frontmatter schema check and wants it to fire before that commit. Neither owns the commit path, and determined-platform has three different integration shapes in the tree with nothing saying which one a new facility takes.

ShapeWhere it livesWhat it is for
Port + Capability + Backenddetermined-platform/src/backends/mod.rs, determined-core/src/capability.rsToggling a Rust arm against an inherited Node arm during the sidecar migration. Backend is Node | Rust | Shadow; a macro emits one accessor per capability
Inline platform state, no port on purposedetermined-platform/src/openers.rs, api/v1/vault.rs, api/v1/openers.rsReads and writes of AppState’s own registry. SystemOpenerCatalog is constructed inline; the trait that exists is a test seam, not a backend selector
Direct call through Ctxdetermined-platform/src/api/v1/agent_skills.rsA Rust-native domain crate with no migration story. 25 endpoints call determined_agent_skills::* directly, with no port trait and no Capability variant

Git fits none of them by default. It has no Node predecessor, so there is nothing for Backend to choose between. It is domain logic over easy-git/fluent-git, not bare registry state, so the openers.rs carve-out does not describe it either.

The check seam has no precedent at all. Today the only check that would refuse a commit is discover_components, reachable solely through determined_agent_skills::get_component_tree, which folds a refusal into a scan_refusal: Option<String> field on a tree response. Polish’s scan_schema_validation is a whole-vault scan whose results are fetched by a second call. Neither is callable as “check these paths”.

determined-git, a new crate under solutions/determined/crates/, holds the domain logic over easy-git/fluent-git. determined-platform/src/api/v1/git.rs unpacks AppState and calls straight through, exactly as api/v1/agent_skills.rs does:

pub async fn get_git_status(state: &AppState) -> Result<GitStatus, AppError> {
Ok(determined_git::status(&state.ctx(), state.vault_dir()).await?)
}

Wire types live in determined-core/src/wire/mod.rs beside VaultAspect. determined-git depends on determined-core and never on determined-platform, so it is unit-testable against a hand-built Ctx with no host.

Explicitly not added: ports::GitPort, Capability::Git, a CAPABILITY_DEFAULTS row, a capability_accessors! entry, a SidecarApp variant, a retirement condition.

determined-core gains a checks module:

#[async_trait]
pub trait VaultCheck: Send + Sync {
/// Stable kebab-case id. The first half of an ignore-store key.
fn id(&self) -> &'static str;
/// Whether this check answers for a vault of these aspects.
fn applies(&self, aspects: &[VaultAspect]) -> bool;
/// Findings for the given vault-relative, forward-slashed paths. An
/// empty slice means the whole vault.
async fn check(&self, ctx: &Ctx, paths: &[String]) -> Result<Vec<Finding>, CoreError>;
}

Ctx gains one field, pub checks: Arc<[Arc<dyn VaultCheck>]>, cloned into every per-call Ctx by AppState::ctx(), plus a determined_core::checks::run(ctx, aspects, paths) that walks the list, skips what applies rejects, and concatenates the findings. The list is built once in AppState::new, beside where jobs, engine_probe and registry are already constructed:

checks: Arc::from(vec![
Arc::new(determined_agent_skills::VaultComponentCheck) as Arc<dyn VaultCheck>,
Arc::new(determined_polish::SchemaCheck),
]),

A plain vec! literal, not a builder. Two registrants do not pay for indirection; revisit at five.

The dependency direction is the ports pattern’s, minus the toggle: the trait is in determined-core, the implementations are in the domain crates that already depend on it, and determined-platform is the only crate that names both.

Agent Pants’ registrant is a new check_vault in determined-agent-skills, wrapping agent_adapt_core::discover_components so its refusals are callable without going through get_component_tree. Polish’s registrant wraps its existing schema validation, scoped to the supplied paths and run synchronously — scan_schema_validation is a whole-vault job whose results arrive through a second call, which is the wrong shape for a check that gates a commit.

pub struct Finding {
/// `<check-id>/<rule-id>` — the ignore-store key's tail.
pub rule: String,
pub level: FindingLevel,
/// Vault-relative and forward-slashed. `None` for a whole-run diagnosis.
pub path: Option<String>,
pub detail: String,
}
pub enum FindingLevel {
/// Blocks the commit. Not silenceable.
Refusal,
/// Reported, dismissible, silenceable.
Warning,
}

The two levels mirror what discover_components already distinguishes. Its CoreError refusals — unsafe-path, path-collision, RootNotFound — are run-level diagnoses that name more than one note, and they map to Refusal. Its SkippedNote entries carry a per-note reason and detail, and they map to Warning.

A Refusal is never silenceable. The ignore store answers “do not show me this again”, which is not an answer a blocking finding accepts.

Namespace vault-checks, on the existing SettingsStore. Keys are <vault_id>/<check-id>/<rule-id>, values true.

The vault id is in the key, not the namespace, because SettingsStore is machine-local and not per-vault: it writes one JSON file per namespace under <state_dir>/settings/, and a namespace must match ^[a-z][a-z0-9-]*$, so a vault id cannot ride there. vault_id is Ctx::vault_id, the registry-minted id job keys already qualify with. It falls back to the vault directory’s string when the active vault is unregistered, so a key is parsed from the right: the last two segments are the check id and the rule id.

SettingsStore is a determined-platform type, so determined-git never touches it. api/v1/git.rs resolves the silenced rule set from state.settings() and passes it into the commit call, the same way api/v1/openers.rs passes openers::opener_pref(state.settings()) into its helper. The commit dialog writes one key through the generated settingSetValue and clears it through settingDeleteValue. It does not go through useSettingsSection, whose zod-mode contract describes a fixed-shape settings form, not an open-ended key set.

“Ignore once” is never written. It is state in the open dialog and dies with it.

The port/Capability/Backend machinery is migration scaffolding with a stated expiry: each capability carries a retirement condition, and the node arm, its SidecarApp variant and the TypeScript app are deleted in the same PR that flips the default. Adding Capability::Git would add a row that can never retire, because it names nothing to retire from. Backend::Node for it would be an error path guarding against a configuration nobody can want.

Capability::Polish is the case that looks like a counterexample. It keeps a port trait with one implementation, and its doc comment gives the reason as acyclicity — Ctx is what keeps the domain crate off determined-platform. But agent_skills.rs demonstrates that Ctx alone buys that: determined-agent-skills depends on determined-core and agent-adapt-core, never on determined-platform, and has no port trait. The trait is not what the acyclicity rests on.

The check seam follows Ctx’s own stated rule — “if a capability needs a new shared facility, it goes here explicitly and every implementor sees it in the signature”. jobs, engine and vaults are the three boot-injected trait objects that already work this way. A fourth is the shape the file asks for. A service locator, or a registry each experience reaches into, is the shape it explicitly refuses.

Two levels rather than one because the product needs both answers and the code already computes both. A single “problem” level would force either a commit path that blocks on a half-written draft, or one that lets a path collision into history.

OptionWhy not
A. ports::GitPort + Capability::Git + a CAPABILITY_DEFAULTS rowA trait with one impl, a toggle with one answer, and a retirement condition that can never be met. Every Backend::Node request for it is a misconfiguration error, as Capability::Polish already is
B. Inline in determined-platform, the openers.rs carve-outThat carve-out is for platform registry state — which vaults exist, which apps are installed. Git status is domain logic over a git primitive crate, and inlining it puts testable domain code behind AppState where a hand-built Ctx cannot reach it
C. A domain crate called directly through Ctx, the agent_skills.rs shapeChosen. Keeps the crate off determined-platform, keeps it unit-testable, and adds no machinery that has to be deleted later

For the check seam:

OptionWhy not
A registry keyed by VaultAspectAspect is Obsidian | Git | Generic; neither registrant keys off it. Agent Pants’ check applies to a vault with plugins/, polish’s to one with contracts. applies(&[VaultAspect]) on the trait leaves that judgment with the check
The commit path calling each experience’s endpoint by namePuts the roster of experiences inside determined-git, which is the ownership this decision exists to avoid
A builder over AppState::new’s vec!Indirection with two registrants and no conditional registration to express
Arc<[Arc<dyn VaultCheck>]> on Ctx, registered in AppState::newChosen. The JobSink/EngineProbe/VaultRegistry shape, which Ctx documents as the way a shared facility is added
  • T-NS6Z-vault-git-status-service builds git.rs and determined-git with no port question left open. Its Approach step 1 is settled: Ctx-carried, not an AppState-only surface.
  • T-L7YP-git-panel-commit-with-validation adds git_commit to the same module and runs ctx.checks before it dispatches. Its Approach step 5 changes: the ignore store is read in api/v1/git.rs through state.settings() and written through settingSetValue, not through useSettingsSection.
  • Ctx gains a field, so every hand-built test Ctx gains one line. determined-parity’s shared doubles supply an empty list.
  • api/v1/git.rs joins agent_skills.rs in ARCHITECTURE.md’s “why it has no port” table.
  • A third experience wanting a commit-time check adds one impl VaultCheck and one line in AppState::new. Nothing in determined-git changes.
  • determined-backup/src/git.rs stays where it is. Its bare-repo --git-dir/--work-tree pairing is private to the backup store and is not a live vault.

Nothing to migrate. Every piece is new except check_vault, which is a new entry point onto discover_components; get_component_tree keeps its scan_refusal field and its behaviour.

  • Whether a check may report a finding against a path outside the commit’s changed set. A schema rule can be violated by a file the commit does not touch, and reporting it is either useful context or noise that blocks unrelated work.
  • Whether applies sees more than &[VaultAspect]. Both registrants really key off vault contents, and passing the aspect slice may be a shape that no implementor uses.
  • Whether the ignore store should record who silenced a rule and when. true is enough to suppress; it is not enough to review a vault’s accumulated silences.
  • solutions/determined/docs/planning/rust-backends/ARCHITECTURE.md — the port/Capability/ Backend scaffolding and its expiry.
  • solutions/determined/crates/determined-core/src/ctx.rs — Ctx, and the rule this decision follows for adding a shared facility.
  • solutions/determined/crates/determined-platform/src/api/v1/agent_skills.rs — the direct-call shape, with its own “no port trait, and why”.
  • solutions/determined/crates/determined-platform/src/openers.rs — the “no port trait, on purpose” platform-state carve-out.
  • solutions/agent-pants/crates/agent-adapt-core/src/discover.rs — the refusal/skip split the finding levels mirror.
  • solutions/determined/crates/determined-platform/src/settings.rs — the namespace rules the ignore-store key shape is derived from.

← Back to Decisions