Resource and provider authoring
For separately distributed executables, use the plugin SDK, which adapts the same portable definition and runtime contracts to persistent RPC. The in-tree workflow below requires rebuilding Rivet.
An in-tree resource type has one authoring directory in rivet-resources, containing its portable definition and host runtime. No catalog enum, compiler match, engine match, or central capability list needs an edit: register the type by adding one line to each explicit list in crates/rivet-resources/src/resources/mod.rs (pub mod, builtin_definitions, and for runtimes linux_runtimes and linux_capabilities). tests/registration_contract.rs fails if a resource directory is missing from those lists.
Use a stable lowercase type name and a matching Rust module directory (lowercase letters and underscores). Keep desired models, compilation helpers, lifecycle comparison, and native primitives as sibling modules in that directory. Definitions compile on a controller; the runtime module is gated with #[cfg(all(feature = "runtime", target_os = "linux"))] in mod.rs. Keep host-independent policy (planning, comparison, verification) in a <type>_lifecycle.rs module gated only on the runtime feature, so in-memory test runtimes reuse it on every platform.
Builds and shared host infrastructure
The default rivet-resources build contains portable definitions only. Controllers use rivet-resources without extra features; Linux runners enable features = ["runtime"]. The runtime feature enables the engine contracts and, on Linux, shared host dependencies. Native runtimes and registry construction remain Linux-gated; lifecycle helpers stay available to test runtimes when the feature is enabled.
The CLI starts with rivet_resources::builtin_definitions() and rivet_resources::linux_runtimes(), then adds the definitions/runtimes of plugins approved by its PluginPolicy, including service and package backends, through the same registries. It passes rivet_resources::linux_capabilities to rivet_linux::collect_local_facts (or its custom-facts variant), so capability support comes from the same resource/provider probes used by runtime availability. rivet-linux has no resource definitions, registration, or dependency on rivet-resources; it supplies bounded processes, protected filesystem primitives, local account database access, facts, host locks, and state-directory safety. Resource-specific file staging, account-home policies, package database interpretation, and native provider operations live beside their owning resource runtimes.
Portable definition
Implement rivet_catalog::ResourceDefinition with a serde Desired structure and TYPE/SCHEMA_VERSION. Use #[serde(deny_unknown_fields)]; choose strict, deterministic field representations. Debug implementations and diagnostics must redact secret values. The envelope itself has redacted debug output, but that does not protect separately formatted typed structures.
Every definition hook is pure: no host inspection, asset download, process execution, or mutation. ResourceCompileInput exposes evaluated values, child-block values, target assumptions, logical identity, and source locations. ParameterReader evaluates static fields lazily; the compiler automatically merges dependencies and sensitivity from every read into the returned value. Transformations therefore cannot drop provenance accidentally. Omitted/null optional values must agree with compilation; avoid exporting a default that compilation interprets differently.
Attribute descriptors and ensure
DeclarationSchema.attributes contains AttributeDefinition values. For example:
Use List, Map, Union, and Enum for nested collections, alternative types, and string vocabularies. These checks never coerce input. Provider options also reject resource handles. Ranges, paths, normalization, and cross-field checks still belong to the resource. Descriptor defaults are declarations for an explicit shared resolver; the compiler does not inject defaults or erase null keys in compile maps. Default values and evaluated values must never appear in diagnostics or debug output.
Names and aliases must be unique within their field namespace. meta and child-block names cannot collide with root attributes. Names such as tags remain available to resources because common metadata lives inside meta. Source aliases are canonicalized before evaluation and compilation; supplying both spellings fails before either expression is evaluated. Export aliases are canonicalized before resolve_exported_attribute. Compiler-backed ParameterReader::read_parameter and has_parameter therefore use canonical names. Child descriptors may declare types, aliases, null policy and sensitivity, but exported() is rejected: child exports have no traversal syntax.
Compose the optional Ensurable trait when a resource has a desired condition. This complete portable definition uses the shared standard Presence enum; its omitted ensure is present, explicit null is rejected, and both compilation and lazy export use the same resolver:
For a custom vocabulary, supply a serde string enum as Ensure and override ensure_values, ensure_default, and optionally ensure_null_policy, ensure_aliases, and normalize_ensure. The vocabulary includes accepted inputs and canonical outputs. The shared resolver validates input, applies resource-owned normalization, validates the canonical result, and retains every dependency and sensitivity flag. It reads only ensure. Add its generated descriptor to your schema and call the same helpers from compilation and static export. No engine branch is required. Action resources such as exec need not implement this trait, and it does not define inverse commands or host lifecycle behavior.
Groups and users reuse standard presence. File uses file/directory/symlink/absent, defaults to file, and accepts present as a value alias whose compiled and exported value is file. Package retains present/absent/purged/latest. Service uses its own running/stopped vocabulary with no default activity; omitted or null ensure leaves activity unmanaged. Its legacy state spelling is a source and export alias for ensure, and supplying both is rejected. Omitted/null service ensure and state exports remain errors because activity cannot be discovered on a controller. stopped means inactive: it does not delete the unit, disable it, or mask it. Enablement and masking remain independent fields.
Typed receipt validation remains mandatory independently of declaration metadata: recompute capabilities, claims, assets, and intrinsic sensitivity in validate, and reject invalid deserialized desired state.
ResourceSemantics lists required capabilities, all asset digests, intrinsic sensitivity, physical ownership claims, affected paths, refresh acceptance, a positive operation timeout at most 24 hours, and required shared actions. Claims have an author-chosen physical domain/key/qualifier; "*" overlaps any qualifier. Different logical declarations cannot own the same physical object, even across provider types. Path summaries allow generic protection of runner state and locks. CatalogView::resources_of::<D>() performs strict typed decoding for catalog analysis.
Typed deferred assets
Construct source/checksum/location metadata in AssetRequest, then attach its mandatory typed setter with assign_to. CompiledResource<Desired>.assets contains AssignedAssetRequest<Desired> values; an unassigned request cannot enter that vector. For example, this function produces a request for the existing file desired model:
The setter is a function pointer or noncapturing closure, invoked once for that request. It must be pure, update only its typed desired state, and return value-free CatalogErrors on failure. Multiple fields use separate requests/setters beside their metadata. Setters and erased callbacks exist only during compilation; they never enter wire desired state or journals. The adapter strictly decodes/re-encodes this resource’s payload around the typed assignment. The compiler calls the consumed erased callback only after verifying that asset’s bytes and checksum; setter failure prevents publication.
Content can be inline text or a parsed local/cloud source with a checksum and safe diagnostic location. Whole declarations, outputs, ownership and relationships are validated before any source acquisition. The compiler confines and bounds acquisition, verifies checksums, deduplicates bytes while retaining strongest sensitivity, assigns verified digests, then validates final desired state and all asset references. Existing empty-content digest placeholders remain in draft desired values until assignment; authors must validate both draft and final state through the current protocol. Runtime code resolves only verified bundle assets through its context. The extension fixture exercises two independent asset slots without core changes.
Manifest test views
Manifest and module tests use the required ResourceDefinition::test_view(&Self::Desired) hook to inspect compiled desired state without a host runtime. Build the view with ResourceTestView::new, declaring each attribute explicitly. Wire field names, serde omission rules, and scalar or tuple payload shapes must not determine the assertion API.
Use canonical declaration names and values, such as service activity exposed as ensure, and include null for unmanaged nullable attributes. ResourceTestView::octal_mode formats an optional permission mask as a four-digit octal string, preserving null. Set ResourceTestView::content to the managed asset digest to enable UTF-8 content matchers; leave it None when content is unmanaged. The test runner resolves the asset once for both attribute and content assertions and applies sensitivity redaction.
Keep this hook pure: it must not inspect the host, execute commands, fetch assets, or change desired state. Check its attribute projection and content selection when adding a resource type or changing its desired model. Runtime convergence still needs separate disposable Linux tests.
Per-resource relationships
derive_catalog_relationships(resource, desired, catalog) receives this instance’s CatalogResource and decoded Self::Desired; it returns Vec<CatalogEdge> and defaults to empty. The registry calls it for each resource, with no resource-type list. Use the fallible CatalogView::resources_of::<D>() for strict typed peer decoding. resources_with_semantics() borrows generic peers and their semantic summaries from one freshly validated snapshot for this derivation stage. Each independent receipt validation recomputes the snapshot through registered typed validate hooks; serialized annotations are never trusted.
For example, a definition whose desired state is the existing service model can use this hook inside its ResourceDefinition implementation:
Service therefore accepts unit-definition producers of any registered type declaring affected paths; it does not depend on file’s model. User derives this user’s group ordering and contradictions against typed group peers. Relationship hooks are pure and never invoke asset setters, downloads, host inspection, or processes. Compilation and receipt use the same derivation; central validation rejects missing required edges, fabricated action edges, unknown endpoints and cycles.
Supply the registration function and add <type>::register_definition(&mut registry)?; to builtin_definitions():
builtin_definitions() rejects duplicate/reserved names. The catalog/compiler accept explicit registries for custom compositions and tests. Bundle validation and publication require the same registry; unknown or unsupported types fail before mutation.
Host runtime
Choose rivet_engine::StateResourceRuntime when success can be established by reading host state and comparing it with managed desired properties. Use the full ResourceRuntime for dynamic ownership or affected paths, prerequisites, maintenance without property changes, incomplete previews, refresh actions, or custom verification.
Both interfaces use check → durable intent → apply → verify → acknowledgement. Reads and comparisons must not write host state. Planning checks may repeat after prerequisites and their plans may be discarded. Preflight uses a separate required inspect_safety hook on both runtime interfaces; it must freshly return all dynamic ownership and affected paths without reading managed content, running guards, querying package plans, or validating calendars. Static-only runtimes return RuntimeSafety::default(). An inspection error blocks the gate rather than silently dropping claims. Application runs only after the engine commits intent and causal obligations; its OperationContext then carries the journalled staging name. Preflight inspects every resource once; before each mutation (recovery, a shared action, or application) the engine freshly re-inspects only the mutating resource and compares its claims with the latest claims of all others. Noop reads and reports expected changes without application, recovery, or journal writes.
State-based resources
Implement StateResourceRuntime with the portable Definition and an ordinary implementation-local State. The three required operations are read_state, expected_changes, and apply_state, plus the usual available_capabilities probe. State requires no serialization; an Option<State> can represent an absent object. expected_changes returns property names and decides what to manage explicitly: there are no property/parameter schema flags or automatic field-equality rules. Omitted properties remain unmanaged. Existing definition schemas, compilation, Ensurable, validation, exports and child blocks keep their contracts.
For example, the file runtime delegates native operations and managed comparison:
Register it with registry.register(StateRuntime::new(FileRuntime)). The adapter reads state, builds the plan, carries checked state into application, then reads fresh state and repeats the same comparison to verify success. A failed verification, including a read failure or expired deadline, leaves the durable intent unresolved. It retains one fixed deadline and rejects refresh requests. Expected property names become verified report names only after postconditions succeed; a backend needing different outcome reporting should use the full lifecycle.
apply_state receives both desired and current state, so the author can distinguish creation, update and removal. Split these into ordinary helper methods where useful; separate create/update/delete trait methods are not required. Providers must preserve native drift checks before writes. Optional operation_label controls the report label; optional recover_interrupted establishes safe continuation. Recovery refuses automatic replay by default. The file implementation explicitly removes only its recorded staging candidate, and the group implementation explicitly permits safe reconvergence.
See the file runtime (crates/rivet-resources/src/resources/file/runtime.rs), the group runtime (crates/rivet-resources/src/resources/group/runtime.rs), and the synthetic resource integration (tests/extensions/resource/runtime.rs). The native file runtime calls filesystem functions directly from file_operations.rs (crates/rivet-resources/src/resources/file/file_operations.rs). It has no host trait or separate provider object. In-memory test runtimes in crates/rivet-engine/tests/support implement the same engine traits and reuse each resource’s lifecycle helpers, so only host I/O is faked.
Full resource lifecycle
Implement rivet_engine::ResourceRuntime with three associated types: the portable Definition, typed Work to apply, and a temporary ApplyResult for verification. Work and application results exist only in memory, require no serialization, and are never saved in catalogs or journals. Registry type erasure is internal; authors never implement erased dispatch. Services, users, exec and the shared package resource runtime use this interface for their additional lifecycle behavior. Package providers implement backend operations rather than this lifecycle.
See the service runtime (crates/rivet-resources/src/resources/service/runtime.rs) for refresh actions and dynamic ownership, and the user runtime (crates/rivet-resources/src/resources/user/runtime.rs) for affected home paths and resource-specific verification.
RuntimeSafety from inspect_safety is the sole runtime authority for dynamic ownership claims. CheckResult retains additional affected_paths, including InSync and Skipped plans. Set prerequisite only to a declared shared action key. After executing that action, the engine discards the earlier check result, checks ownership again, and calls check again to build fresh work. Do not put host writes or maintenance in a check.
ResourcePlan distinguishes InSync, Skipped(reason), CannotPlan(reason), and NeedsApply { expected_changes, work, incomplete_preview_reason }. Expected changes contain property names for noop, not desired values. An empty list allows required maintenance without inventing resource changes. incomplete_preview_reason describes information unavailable without application. Keep omitted properties unmanaged. CannotPlan produces a deferred report with its explanation; report statuses are distinct from runtime planning decisions.
composition_conflicts receives the complete typed declaration group explicitly, before individual checks. Before a mutation it is repeated for the mutating resource’s type, so prerequisites that create accounts or change aliases are observed. Conflicts block writes before an intent is recorded. Composition safety checks use the longest declaration timeout in their group; each individual safety inspection uses its declaration timeout. Static semantics and runtime pairings are validated at run entry. Mutation gates repeat only the mutating resource’s safety inspection and its type’s composition policy; unrelated resources are not re-inspected and full planning never runs against the mutating resource’s deadline. Ordinary checks receive no catalog or implicit peer list. A direct OperationContext::new check inspects one resource; use registry preflight to validate the whole composition.
Every hook receives one OperationContext exposing asset_bytes(), deadline(), reason() and, during application only, staging_name(). OperationReason::Converge manages desired properties; Refresh is an action requested by a notification. staging_name()? is the exact recorded candidate identity and errors outside application. There is one fixed monotonic deadline across recovery, prerequisite work, checking, application, and verification; native operations must honor the remaining time. ctx.deadline() returns the shared OperationDeadline: use .check()? between native steps, .remaining()? for a remaining duration, or .instant()? for an existing primitive accepting an absolute Instant. These accessors never restart the timeout. Native inspect_file and apply_file functions each take &OperationDeadline. Named IDs and parent access policy are resolved at the runtime boundary. There are no timeout-free variants. File work retains ResolvedFileIntent separately from FileObservation so reads contain only host state. Shared-file writers use snapshot_managed_file to obtain bytes, digest, metadata and fingerprint from one verified descriptor read. OperationContext::new supports direct hook tests with already-verified assets; add with_staging_name to exercise application.
ResourceOutcome contains verified changed property names, optional safe output, executed, and handled_refresh. Execution is independent of output capture. handled_refresh means ordinary convergence also satisfied a queued refresh, such as starting a service with its new configuration. A command must not be executed again merely to verify its result.
recover_interrupted receives InterruptedOperation, which exposes only its recorded staging name and operation reason. Journal sequences and acknowledgement remain engine-owned. Returning success establishes safe continuation, not historical success. File recovery removes only its recorded candidate; resources that safely reconverge explicitly choose that policy; an interrupted exec follows its declared guard, always, or refresh-only policy.
Use named ResourceError constructors, which set its HostEffect: before_change for failures before side effects, partial_change for a known failed partial change, unknown_outcome when an operation may have run, and partial_change_unknown when some change is confirmed but the final outcome is unknown. after_change(changed) upgrades an error raised after a host change. Attach underlying errors with .because(&cause); it appends only a value-free description. Never include secret arguments, passwords, or asset bytes in messages. Verification failures leave intent unresolved.
Shared prerequisites implement ActionRuntime with a stable versioned key, report label, capability, availability probe, and execute_action(&OperationContext). The engine commits delivery before execution, coalesces requests, and repeats checks after the action.
Supply both Linux functions and list them in linux_runtimes() and linux_capabilities():
linux_runtimes() and linux_capabilities() list every runtime’s registration and probe. Both facts collection and runtime availability must call the same runtime_capabilities or provider_capabilities probe rather than maintaining separate tool lists. An in-memory runtime can be registered directly for tests without invoking native mutation.
Package providers
Package provider definitions and runtimes are also collocated in one directory:
Implement PackageProviderDefinition with serde Options, stable NAME, independent SCHEMA_VERSION, and CAPABILITY. Declare attributes() -> Vec<AttributeDefinition>, optional auto_priority(target, has_source), architecture normalization, and pure validation returning semantic claims/assets/paths. Auto selection considers only providers whose capabilities exist in the target, chooses the highest priority, and rejects tied winners. Return None when the provider is inapplicable; explicit selection still passes strict validation. Reject unsupported common package policies explicitly. Native version ordering, repository selection, archive validation and mutation commands belong to each backend.
The definition’s register_definition(&mut PackageDefinitionRegistry) -> Result<(), CatalogError> is listed in package_definitions() in providers/mod.rs. On the host, implement PackageProvider with the definition’s Options type, available, and two backend operations:
read_state must not mutate the host. It translates backend state into the common PackageObservation, resolves the target version using native semantics, and identifies pending index maintenance. apply_state receives the previously checked state and writes only after durable intent; it preserves backend drift checks and classifies side effects on error. Optional begin_run resets per-run caches; optional recover_interrupted establishes safe continuation and refuses replay by default. Provider authors do not implement plans, verification, application-result types or type-erased work.
The runtime’s register_runtime(&mut PackageProviderRegistry<'_>) -> Result<(), CatalogError> registers the provider under its definition’s descriptor (registry.register(AptDefinition::descriptor(), None, AptPackageProvider::default())); it and provider_capabilities(root) are listed in linux_package_providers() and linux_package_capabilities(). Providers have independently typed Options; one internal adapter validates and decodes them, without Any or state/result downcasts. The shared PackageRuntime owns comparison, held-package policy, planning, incomplete previews, verification and reporting. It rereads state after native application and confirms managed postconditions before acknowledging intent. Reported changes come from actual before/after installed state, so index maintenance alone does not notify dependent resources. It owns no provider-name match or closed provider union. Definitions declare ownership, assets and affected paths before runtime execution.
Service backends have the same shape: ServiceProvider with typed Options and canonical_name, inspect, apply and refresh, registered with systemd_service_descriptor() (or a plugin’s descriptor) into a ServiceProviderRegistry that the shared ServiceRuntime wraps. Systemd’s daemon reload is the SystemdDaemonReload action runtime rather than a provider method. Plugins add service and package backends through the same traits.
Package observations distinguish installed_version from target_version. The latter may be a pinned desired version, a repository-selected version, or a newer installed version retained by latest. target_version_unconfirmed means it cannot yet be relied on, even if a tentative value exists. needs_index_update is package-index maintenance, distinct from a resource notification refresh. An unconfirmed target produces an incomplete preview when updating indexes can resolve it, or CannotPlan when no such work is scheduled. Backends map partially configured packages and retained configuration into the common states rather than claiming they are installed or fully removed.
Validation and extension proof
Run workspace tests, clippy with warnings denied, and formatting:
Test defaults against static exports, null handling, strict received payloads, ownership conflicts, all asset assignments, omitted-property preservation, noop immutability, idempotence, and uncertain recovery. Host mutation tests belong in disposable Linux environments.
On Linux, run python3 tests/resource_extension_workflow.py. It copies the workspace, adds a synthetic resource and package provider (their files plus one line in each registration list), then exercises compilation, strict bundle receipt, multi-assets, provenance, planning, uncertain apply, recovery, missing runtime rejection, idempotence, provider options, and the registration contract. The script refuses other platforms so a skipped Linux test cannot masquerade as a passed extension proof.
Catalogs, the language, journals and the runner protocol each have exactly one supported version; other versions are refused, never migrated. Resource and provider payload schema versions remain independent and are checked on receipt.