Cross-experience vault facilities: direct domain crates, one registered check list
State: open/accepted
Summary
Section titled “Summary”- Git awareness ships as
determined-git, a plain domain crate called directly fromdetermined-platform/src/api/v1/git.rsthroughCtx. It carries noports::GitPorttrait, noCapability::Gitvariant, and noBackendrow: 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, adetermined-coretrait. Implementors are registered once inAppState::newand carried onCtxbesidejobs,engineandvaults. Agent Pants and polish each contribute one; neither owns the hook. - A check answers with findings at two levels. A
Refusalblocks the commit; aWarningdoes not. The split is not invented here —discover_componentsalready separates run-level refusals from per-note skips, and this promotes that distinction onto the wire. - Silenced warnings live in the
vault-checkssettings 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/.
Context
Section titled “Context”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.
| Shape | Where it lives | What it is for |
|---|---|---|
Port + Capability + Backend | determined-platform/src/backends/mod.rs, determined-core/src/capability.rs | Toggling 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 purpose | determined-platform/src/openers.rs, api/v1/vault.rs, api/v1/openers.rs | Reads 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 Ctx | determined-platform/src/api/v1/agent_skills.rs | A 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”.
Decision
Section titled “Decision”1. The git facility
Section titled “1. The git facility”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.
2. The check seam
Section titled “2. The check seam”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.
3. Finding levels
Section titled “3. Finding levels”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.
4. The ignore store
Section titled “4. The ignore store”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.
Options considered
Section titled “Options considered”| Option | Why not |
|---|---|
A. ports::GitPort + Capability::Git + a CAPABILITY_DEFAULTS row | A 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-out | That 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 shape | Chosen. Keeps the crate off determined-platform, keeps it unit-testable, and adds no machinery that has to be deleted later |
For the check seam:
| Option | Why not |
|---|---|
A registry keyed by VaultAspect | Aspect 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 name | Puts 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::new | Chosen. The JobSink/EngineProbe/VaultRegistry shape, which Ctx documents as the way a shared facility is added |
Consequences
Section titled “Consequences”- T-NS6Z-vault-git-status-service builds
git.rsanddetermined-gitwith no port question left open. Its Approach step 1 is settled:Ctx-carried, not anAppState-only surface. - T-L7YP-git-panel-commit-with-validation adds
git_committo the same module and runsctx.checksbefore it dispatches. Its Approach step 5 changes: the ignore store is read inapi/v1/git.rsthroughstate.settings()and written throughsettingSetValue, not throughuseSettingsSection. Ctxgains a field, so every hand-built testCtxgains one line.determined-parity’s shared doubles supply an empty list.api/v1/git.rsjoinsagent_skills.rsinARCHITECTURE.md’s “why it has no port” table.- A third experience wanting a commit-time check adds one
impl VaultCheckand one line inAppState::new. Nothing indetermined-gitchanges. determined-backup/src/git.rsstays where it is. Its bare-repo--git-dir/--work-treepairing is private to the backup store and is not a live vault.
Migration
Section titled “Migration”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.
Open questions
Section titled “Open questions”- 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
appliessees 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.
trueis enough to suppress; it is not enough to review a vault’s accumulated silences.
References
Section titled “References”solutions/determined/docs/planning/rust-backends/ARCHITECTURE.md— the port/Capability/Backendscaffolding 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.