Authoring Guide

Action-owned Observed State

Declare pure normalization, typed collections, matching and difference policies in action files.

Action-owned observed state

Development-branch contract. Actions define normalization, matching and difference policies. The controller provides durable raw admission, typed collections, approval or automatic dispatch, shared GraphQL/REST/UI reads and independent execution verification. Synthetic runtime/server/DNS workflows are verified; native execution, real-provider and release qualification remain separate. Unsupported persisted state refuses startup without conversion.

Observation acquisition uses POST /api/v1/observation-inputs only. A runtime or app must submit the raw envelope explicitly. Uploading a result file—even from a producer action, with a valid raw envelope inside—does not publish observations. runtime.state_file remains execution bookkeeping, not observation evidence.

One authoring location

Put observation definitions in actions/*.toml. Use module.toml for module identity, parameters and dependencies.

An action file may be:

  • An observation-only producer, with inline observe.normalize and fixed scope.
  • An executable subscriber, with observe.from referencing a producer directly.
  • An executable producer that also declares matching and comparisons.

Producers do not need a fake runtime or no-op command. One producer can return several named collections, even when no desired resources exist. Its identity is module:actions/path.toml, not an expanded action name or graph UUID.

Desired intent

The verified offline fixture defines one desired server:

name,cpu
server-a,8

Observations never overwrite this intent. Provider acquisition belongs in a module runtime or app. The controller normalizer transforms submitted JSON; it does not contact the provider.

Producer

Create data/actions/inventory.toml:

name = "server-inventory"
description = "Normalize synthetic server inventory; no provider calls"

[observe]
normalize = '''
local p = std.extVar("payload");
{
  server: [{
    key: instance.id,
    name: instance.name,
    values: {cpu: instance.vcpus, network_ids: instance.network_ids},
  } for instance in p.instances],
}
'''

[observe.scope]
provider = "synthetic-cloud"
account = "documentation"
region = "example-region"

[observe.types.server]
cpu = "integer"
network_ids = "array"

The transform returns named collections of rows with key, optional name, and values. Keep provider IDs as strings. Dependency IDs such as network_ids remain ordinary normalized values; they need not identify desired graph resources.

The producer owns a nonempty scope string map and type overrides. Scope permits literal strings and explicit params.key interpolation only. Subscribers inherit scope and types. Raw payloads cannot override scope, params, lookups or authority.

Jsonnet receives payload, frozen params, controller context containing producer_id and scope, and explicit lookups. Imports, environment access, application native functions, network and Vault acquisition are unavailable. Evaluation runs in an owned child with fixed resource limits; see the reference.

Subscriber and comparisons

Create data/actions/resize-server.toml:

name = "resize-{{ origin_resource.name }}"
description = "Preview approved server intent without changing a provider"
origin_resource = "server"
run_mode = "job"
auto_execute = false
conflict_scope = { kind = "resource" }
exec = [["python3", "preview_server.py", "desired-server.json"]]

[runtime]
engine = "nix"
isolation = "sandbox"
shell = "./shell.nix"
files = ["preview_server.py"]

[observe]
from = "cloud-servers:actions/inventory.toml"
collection = "server"
match = { desired = "name", observed = "name" }

[trigger]
on = "difference"
mode = "approval"

[[trigger.compare]]
property = "cpu"

[execution_finality]
producer = "cloud-servers:actions/inventory.toml"
collection = "server"
settle_timeout = "30s"
timeout = "5m"
max_age = "60s"

[[mount]]
resource_type = "server_configuration"
filename = "server-{{ origin_resource.name }}.json"
mount_path = "desired-server.json"

This command previews frozen intent only. It does not resize a provider. The runtime and mounts remain ordinary action settings.

observe.from must point directly to a producer. Chains, cycles, aliases and subscriber scope/type overrides are rejected. origin_resource selects desired graph vertices; ordinary match_on predicates further restrict that selection, using the same predicate engine as executable expansion. Only matching, watched and name properties enter the canonical desired projection. A selector cannot make a duplicate logical resource name safe to bind. Matching uses exact typed values; ambiguous matches do not yield usable comparisons. Observed name and key refer to row metadata, never a fallback inside values.

Each comparison watches a desired property. observed_property defaults to that property; operator defaults to equal. Its stable id defaults to the property. Repeated properties require explicit distinct IDs. Null is a value; missing is unknown. Incompatible logical types are unknown, not coerced differences. Arrays use ordered equality: normalize DNS sets explicitly on both sides before comparing them. There is no implicit adoption, deletion, or presence-triggered action. Desired-only and observed-only rows remain visible without creating work. Ambiguous matches retain every candidate provider key without choosing one arbitrarily.

mode defaults to approval; auto uses durable automatic decisions. The deployment ceiling defaults to report_only; approval caps auto policy at approval, and automatic permits action-declared automatic dispatch. Startup auto_execute and calendar schedules retain their meaning and are not restricted by this difference-only ceiling. Approval/automatic dispatch can run infrastructure-changing code.

Qualified policy execution also requires explicit resource conflict scope, a supported protected runtime and action-local finality. The system-runtime preview fixture above is not a qualified policy mutation. New configuration/material invalidates unsent approvals. Equivalent reads/restart do not duplicate work. Rejection persists through changed observations. Rearming requires fresh valid convergence and prior possible-work closure; failed/uncertain possible work never automatically retries. Competing automatic offers for one scope are held rather than choosing a winner.

Action-local finality

[execution_finality] declares producer, collection, settle_timeout, timeout, and max_age in the executable action itself. Durations use positive whole seconds; timeout must exceed settling. The producer reference must resolve directly. Parsing these terms does not qualify a runtime, prove independence, grant approval, or make historical execution state safe to discard.

An ordinary scoped job can subscribe without a difference trigger:

name = "apply-{{ origin_resource.name }}"
description = "Preview ordinary scoped server intent without a drift workflow"
origin_resource = "server"
run_mode = "job"
auto_execute = false
conflict_scope = { kind = "resource" }
exec = [["python3", "preview_server.py", "desired-server.json"]]

[runtime]
engine = "nix"
isolation = "sandbox"
shell = "./shell.nix"
files = ["preview_server.py"]

[observe]
from = "cloud-servers:actions/inventory.toml"
collection = "server"
match = { desired = "name", observed = "name" }

[execution_finality]
producer = "cloud-servers:actions/inventory.toml"
collection = "server"
settle_timeout = "30s"
timeout = "5m"
max_age = "60s"

[[mount]]
resource_type = "server_configuration"
filename = "server-{{ origin_resource.name }}.json"
mount_path = "desired-server.json"

Preserve explicit conflict scopes and protected runner context. Possible work cannot be released by equality, timeout, cancellation, disconnection or exit zero. Closure requires qualifying independent same-read state plus provider and runner/root/ descendant/resumable terminal assertions, followed by durable journal acknowledgement.

An explicit Pull can update desired assets through a mutation contract. Original execution verification can continue after a desired-only change if captured observation/static material and software remain unchanged. It retains the original identity, recipe, scope, barriers and window; it does not select a new target. Approval and delivery still require the current full configuration. Changed producer/runtime material blocks new proof. Do not retry possible work or treat the intent write as terminal evidence.

Validate without provider execution

rescile-ce --data-dir ./data validate --strict --build

Use a development binary implementing these declarations. Version 0.1.226 alone does not establish support: the refactor is not released. Validation/save do not run preview commands or acquire provider inventory.

Existing execution stores are not automatically migrated. Keep the complete old state and its compatible controller available; never delete journals, queues or observation state to make a new workspace start. See operations and the raw input contract.