Migration Guide: the 2.2.0 syntax

This page lists every change to how mlld is written in 2.2.0, for someone who
wrote mlld 2.1 and has not followed the redesign. Each group has a table of
old form → new form, with one line on why, then a short program in the new
syntax. It does not re-teach the new forms: each group links the page that
does.

Removed forms stop with an error that says what to write instead, and
usually whether mlld migrate rewrites it. Two old forms still run in 2.2.0:
the leading / on a directive (/show @x) and bare run { … }. mlld migrate rewrites both; they stop working in a later release.

mlld migrate rewrites most of this for you

mlld migrate rewrites old-syntax files into the new syntax in place, and
lists every site it cannot rewrite safely for you to fix by hand. It works
from git: it reads each file's text at a commit you name to decide what is
old, so it runs only inside a git repository.

  1. Commit everything. Files with uncommitted changes and files git does not
    track yet are listed as refused and left alone; files git ignores are
    skipped without being listed.
  2. Preview, if you like: mlld migrate . --old-ref HEAD --check writes
    nothing and lists the files it would rewrite.
  3. Run it: mlld migrate . --old-ref HEAD. It prints each file it rewrote,
    with the rules that changed it, and a manual list of sites it left for
    you (path:line:column and what to do). A parse failures list names
    files it could not read as old syntax.
  4. Fix each listed site by hand, run your scripts, and review the change with
    git diff before you commit it. A file with a manual site may be only
    partly rewritten; running it stops at the first old form left, with an
    error that names the new one.

--old-ref HEAD means "the files as last committed are the old syntax", so
run it on a commit that holds no converted files. After you commit the
rewrite, a second run over the whole tree with HEAD reads the converted files
as old syntax and lists them as parse failures. To convert files it refused,
commit them and name just those files:
mlld migrate notes.mld lib/tools.mld --old-ref HEAD. The other mode, --since <commit>, treats
every file as old and is for converting a branch that does not yet contain
<commit>; it is refused on a branch that does, so --since HEAD always
refuses.

Rows below marked with a rule id, like (var-to-const), are rewritten by that
rule; rows without one are yours to change. mlld migrate --help explains the
other options (--fences also rewrites the mlld code blocks in markdown
pages), and the tool's README explains
how it decides what is safe to change.

It runs 42 rules, in this order: strip-slash-prefix,
markdown-module-to-mld, templates, mld-md-import-paths,
drop-recursive-label, with-snake-keys, with-labels-to-slot,
merge-with-tails, with-to-using, when-to-match,
http-auth-header-to-creds, var-tools-to-tools, exe-llm-to-llm,
llm-label-to-op-llm, var-to-const, let-augment, quote-label-literal,
cmd-body-interpolation, resolver-paths, run-to-run-cmd,
resolver-path-variables, import-with-policy-to-using,
section-selector-template, refine-action-words,
fact-field-pattern-colon, fyi-shelf-to-state, known-label-to-intent,
labeled-return-manual, retired-label-spellings, cmd-body-to-env,
policy-object-to-blocks, policy-value-to-policy-expr, guard-to-bind,
arg-role-keys-to-record, record-object-to-lines,
with-passthrough-to-pass, creds-to-secret, shelf-names-to-state,
retired-shelf-attributes, shelf-to-state, var-session-to-state,
state-ref-manual.

Files and extensions

Old form New form Why
script.mld.md, script.mlld.md (markdown mode) (markdown-module-to-mld) script.mld; prose becomes >> comments One file format. Markdown mode silently dropped any line that was not a directive.
script.mlld (markdown-module-to-mld) script.mld One extension for scripts.
import { @x } from "./lib.mld.md" (mld-md-import-paths) import { @x } from "./lib.mld" Follows the renamed module.
/var @x = 1 (leading /) (strip-slash-prefix) const @x = 1 The / only marked directives inside markdown, which is gone.

Scripts are .mld; policy modules are .sss and .priv.sss; template files
are .att (${…}) and .mtt ({{…}}). In a .mld file, every line is a
directive, a comment or blank. See the introduction and
policy files.

Templates and strings

Old form New form Why
"Hello @name" (templates) `Hello ${@name}` Quotes never interpolate, so a string is exactly what it looks like.
`Hello @name` (templates) `Hello ${@name}` Only ${…} interpolates; a bare @ is ordinary text, so email addresses need no escaping.
"bob@@test.org", "bob\@test.org" (templates) "bob@test.org" @ is plain text everywhere, so the escapes are retired.
::Review @file:: (templates) ::Review ${@file}:: ::…:: is the raw template: ${…} interpolates, backslashes and backticks stay as written.
`See <README.md>` loading the file (templates) `See ${<README.md>}` A load happens only inside ${…}; <…> elsewhere is text.
missing file in a template: empty text and a warning an error; write ${<notes.md>?} to allow a missing file A silently empty prompt was a common bug.
"\d" (unknown escape, silently dropped) an error Escapes follow JavaScript; anything else is a mistake.
<doc.md # "[@v]"> (section-selector-template) <doc.md # `[${@v}]`> The section name is a string, so it takes a template to read a variable.

New in 2.2.0: any mlld expression inside ${…}, including calls, pipes and
loads such as ${<docs/*.md # API>}.

See template basics,
template files and
escaping @.

const @name = "Ada"
const @greeting = `Hello ${@name}, write to ada@example.com`
const @raw = ::Keep \n and `code` as written, ${@name}::
show @greeting
show @raw

cmd bodies and paths

Old form New form Why
run { echo hi } (run-to-run-cmd) run cmd { echo hi } Every body names its language (cmd, sh, js, py).
cmd { echo @msg } or cmd { echo ${@msg} } (cmd-body-interpolation, then cmd-body-to-env) run cmd { echo "$msg" } using { env: { msg: @msg } }; an exe's parameter is already "$msg" A cmd body is the shell's: a value arrives as an environment variable and the shell expands it as data, so it can never add an operator or pick the command. @msg and ${@msg} in a body are errors. A field is bound first (const @msg_text = @msg.text); an exe body that needs one becomes a block. A pipeline stage @v | cmd { … } reads $input and the enclosing exe's parameters. @@ and \@ are no longer escapes; the rule rewrites them so the output is unchanged. A conditional snippet, a value inside '…', and a name the body already uses are listed for you to rewrite.
<@base/notes.md> (resolver-paths) <@root/notes.md> @base was a second name for @root.
<@dir/notes.md> where @dir is a variable (resolver-paths, then resolver-path-variables) <@dir/notes.md>, unchanged <@name/…> is a resolver when name is one (@root, @local, a configured resolver) and the variable otherwise; the first rule wraps it as <${@dir}/…> and the second writes it back.
a variable named like a resolver (const @local = …) a different name A binding that shadows a resolver is an error, so a path never changes meaning with scope.
output @x to "out/@name/data.json" (resolver-paths) output @x to `out/${@name}/data.json` A quoted path is literal, except for a leading resolver such as "@root/out.json". In the old form @name.json read the field json of @name, so the rule keeps that meaning and writes ${@name.json}; write `out/${@name}.json` by hand if you meant the extension.

See run basics and
paths and URLs.

exe @say(msg) = cmd { echo "$msg" }
show @say("hi")
run cmd { echo done }
const @dir = "notes"
const @target = `out/${@dir}.json`
show @target

const, let and assignment

Old form New form Why
var @x = 1 (var-to-const) const @x = 1 A const never changes, from anywhere.
a module-level var changed later with += a for that returns the list, or a state slot Only a let changes, and only inside its block; the rule lists these for you.
let @x += 1 (let-augment) @x += 1 let always declares; assignment is a separate statement.
a let or const in a block reusing an outer name a different name Shadowing an outer binding is an error. Parameters and loop variables may reuse names.
@obj.k = v on a constant const @next = { ...@obj, k: v } Values never change in place.

let declares only inside a block (an exe body, if, match, for,
loop, try, box). See variables.

const @items = ["a", "b"]
exe @count(xs) = [
  let @n = 0
  @n += @xs.length
  => @n
]
show @count(@items)
const @base = { a: 1 }
const @more = { ...@base, b: 2 }
show @more

if and match

when is retired: a condition is if, a choice between values is match
(when-to-match rewrites every form whose behaviour it can prove the same, and
lists the rest).

Old form New form Why
when @c => show "x" if @c => show "x" A condition with an effect is if.
when @c => "x" in an exe body if @c [ => "x" ] A value leaves a block only through =>.
when [ @a => "x"; * => "y" ] match [ @a => "x"; * => "y" ] Choosing a value is match; the first matching arm wins.
when @v [ "a" => 1; * => 2 ] match @v [ "a" => 1; * => 2 ] Same patterns, with a subject.
none => x * => x One default arm, written last.
when first [ … ] match [ … ] first was already the only behaviour.
for @x in @xs when @x > 1 => @x for @x in @xs if @x > 1 => @x A filter after the source is if.
var @r = when [ … ] const @r = match [ … ] match is the value form.
@x == .secret (label literal in an expression) (quote-label-literal) @x == ".secret", or the label test @x is .secret A label where a value goes was always the string; the rule quotes it and lists each comparison for you to check.

New in 2.2.0: else if chains; a bare regex as a match pattern
(/^re/ => …); match choice, whose result is marked .chosen when outside
input picked one of your own literal arms.

See if,
match and
value-returning match.

const @n = 7
if @n > 10 [ show "big" ] else if @n > 5 [ show "medium" ] else [ show "small" ]
const @size = match @n [ < 5 => "few"; * => "many" ]
const @kind = match [
  @n == 7 => "seven"
  * => "other"
]
const @evens = for @x in [1, 2, 3, 4] if @x > 2 => @x
const .untrusted @pick = "a"
const @chosen = match choice @pick [
  "a" => "first"
  * => null
]
show @size
show @kind
show @evens
show @chosen

is

Old form New form Why
@x.mx.labels.includes(".secret") @x is .secret is matches the label hierarchy (.src:mcp matches .src:mcp:github) and every label the value contains. includes still works and stays exact; nothing rewrites it.
@cast(@v, @contact) used as a check @v is @contact Validates against a record without running its field actions.

New in 2.2.0: is not, compound patterns (@x is .secret+.untrusted), and
@input.any is .src:mcp in guards. See
conditions and operators.

const .secret @token = "t-123"
const @bundle = { key: @token }
const @isSecret = @token is .secret
const @bundleIsSecret = @bundle is .secret
const @notUntrusted = @token is not .untrusted
show @isSecret
show @bundleIsSecret
show @notUntrusted

Labels

Old form New form Why
var secret @x = …, rules: { secret: … } (bare labels) (retired-label-spellings) const .secret @x = …, .secret { … } A label always starts with ., so it cannot be mistaken for a name. A bare label, and a policy rule key without its dot, are errors.
rules: { ".secret,.pii": … } (a comma in a rule key) one rule per label, or ".secret+.pii" for a value carrying both A comma does not join labels: the rule applies to each label on its own. It loads with a warning that says so. In a pattern block, .secret,.pii { … } is an error.
const secret,pii @x = … (bare stacked labels) const .secret,.pii @x = … An error now: a stacked list is written with dotted labels.
exe @f() = … with { labels: ["pii"] } (with-labels-to-slot) (reported, not rewritten; the clause is an error until you move it) exe .pii @f() = …, moved by hand Labels go in the label slot. The clause did nothing, and in the slot the labels take effect. A label in the slot also turns off a policy's unlabeled: default for that definition, so what it loads stops counting as untrusted and a policy that blocked it can let it through. The rule leaves every such clause in place and lists it for review; move it after checking your policy.
exe recursive @f(n) = … (drop-recursive-label) exe @f(n) = … Every exe may call itself.
.llm (llm-label-to-op-llm) .op:llm An LLM call is an operation, labelled like the others.
.known, known (known-label-to-intent) .src:user:intent:literal .known was the old spelling of the user-intent label. Any code could write it, and a requirement accepted it as proof; the runtime mints the intent label only from user intent. Written as a label it is an error; in a labels.args requirement list it is read as .src:user:intent:literal with a warning.
.fact:*.email (fact-field-pattern-colon) .fact:*:email The field is its own segment.
.trust:trusted, .trust:untrusted .trusted, .untrusted One spelling in source.
.regex, .internal a { regex: … } policy entry; .fact:internal:* A regex is a type, not a security label; internal recipients come from facts.
inf, inf:mit .influenced, .influenced:mitigated One spelling, with the leading dot.
.op:fs:*, .op:net:* (retired-label-spellings) .fs:*, .net:*: .op:net:w is .net:w, .op:net:w:upload is .net:w:upload Filesystem and network effects are operation namespaces of their own, beside .op: (which operation ran) and .risk:. The old spelling is a load error naming the new one. .op:* and .op:*:w in a pattern still match .fs: and .net: labels, so a rule written against every operation or every write keeps its reach. .src:net (where data came from) is unchanged.
a file write or load passing every .fs: rule output/append to a file, file, files carry .fs:w; a <path> load carries .fs:r mlld's own file I/O now carries the effect labels, so rules on them enforce. @standard and @noUntrustedDestructive refuse writing .untrusted data to a file; @noVibeAuthority refuses writing a match choice pick to a file, and any file write under untrusted control flow; .op:*:w matches file writes and .op:* matches loads too. Nothing rewrites this: review a policy whose rules now reach your file writes.
capabilities: { danger: ["op:fs:r:~/.ssh/*"] }, .op:fs:w:<path> (retired-label-spellings) fs:r:~/.ssh/*, fs:w:<path> A path entry under the old prefix is a load error; .fs:r:<path> is read the same as fs:r:<path>.

New in 2.2.0: .pc:* labels, which mark operations that run because of
labelled data (.pc:untrusted+.op:cmd { deny: true }); .sensitive and
.ask are built in. See label basics
and label classes.

class .pii = { kind: "data", effect: "restrict", description: "personal data" }
const .pii,.untrusted @email = "ada@example.com"
policy @app = { .pii { deny: [.op:llm] } }
exe @fact(n) = [
  if @n <= 1 [ => 1 ]
  => @n * @fact(@n - 1)
]
const @isPii = @email is .pii
show @fact(4)
show @isPii

Records

Old form New form Why
record @c = { facts: [...], data: [...] }, preset @p = { … }, field @f = { … } (record-object-to-lines) record @c [ … ], preset @p [ … ], field @f [ … ], one entry per line Records are grammar, not an object.
facts: [email: string] email: string [ kind "email" ] A field carries proof because it has a kind: what the value points at.
data: { trusted: [title: string] } trusted title: string trusted / untrusted say only whether the text is clean.
data: [body: string] body: string A field with no kind is untrusted text by default.
refine facts += [...], facts.f += [...] (refine-action-words) tiers += [...], tiers.f += [...] The record's own words.
refine facts = [], facts.f = [] (refine-action-words) unproven, unproven f Same.
refine data.f = trusted (refine-action-words) trusted f Same.
read: [id, name], read: { .role:x: [id] } * { read: [id, name] }, .role:x { read: [id] } Reads and writes sit in role blocks, written as policy blocks are.
write: { .role:x: { … } } .role:x { write: { … } } Same. A write grant names its target state (see State).
write: { .role:x: { tools: { authorize: true } } } .role:x { can_authorize: [@tool] } in a policy, and .role:x { write: {} } in the record when that was the actor's only grant Nothing reads the record grant; the migrate rule drops it and says so. The empty grant keeps the record declaring write:: a record with no write lines writes freely into a plain state.
with [ … ] to derive a record record @b extends @a [ … ] Derivation is its own word.

New in 2.2.0: field and preset definitions shared between records. See
record basics and
facts.

record @contact [
  key id
  id: string [ kind "contact_id" ]
  email: string [ kind "email" ]
  trusted name: string
  notes: string?
  * { read: [id, name] }
]
record @vip extends @contact [
  inherit trusted name
  tier: string
]
show "records loaded"

Policy

Policies are written as pattern blocks: a label pattern, then { key: value }
settings for data or calls that match it. A policy written as an object
literal (policy @p = { labels: { … } }) is a parse error, which mlld migrate
rewrites. An object built as data can still become a policy through union()
(const @cfg = { … }, then policy @p = union(@cfg)), and that object shape
stays the form SDK callers pass; pattern blocks are the form to write.

Old form New form Why
labels: { rules: { secret: { deny: [".risk:exfil"] } } } .secret { deny: [.risk:exfil] } The rule reads as what it guards.
labeling: { unlabeled: "untrusted" }, credentials, capabilities inside * { … }: * { unlabeled: .untrusted }; credentials is spelled secrets there (see Credentials) Program-wide settings live in the one global block, so no label can switch them on.
labels: { alias: { ".risk:exfil": [".op:net:w"] } } .risk:exfil { includes: [.net:w] } Same; the effect label is respelled too (see Labels).
labels: { apply: { ".src:net": … } } .src:net { apply: [.untrusted] } Same.
labels: { args: { ".risk:exfil:send": { recipient: [...] } } } .risk:exfil:send { accepts: { recipient: [...] } } Same.
"untrusted+secret", "a+!b" .untrusted+.secret, .a:not(.b) One label-pattern grammar for policy, guards and is.
a rule allow that overrode a deny allow!: [...] An exception is written so it reads as one; see "deny wins" below.
a rule allow used as a whitelist only: [...] Data may reach only the listed operations.
a policy object in a var const @p = policy { … } The same pattern blocks, as a value for union() and using { policy }.

Deny wins. When a deny and an allow! both match, the deny wins unless the
allow! is more specific and comes from the same file as the deny (or from a
.priv.sss file). Nothing lifts a locked deny. By default a deny also beats
every ask; * { denies_can_ask: true } lets a denied call go to its ask
handlers instead. An allow! that excepts nothing, and a plain allow: in a
pattern block, are load errors.

New in 2.2.0: box: requirements on a pattern (.secret+.op:cmd { box: { net: "none" } }).
See policy basics and
label flow.

policy @app = {
  * { unlabeled: .untrusted }
  .secret { deny: [.risk:exfil] }
  .untrusted {
    deny: [.op:cmd]
    allow!: [.op:cmd:git:status]
  }
  .src:mcp { only: [.op:tool:r] }
  .risk:exfil { includes: [.net:w] }
}
const @strict = policy { .secret { deny: [.op:cmd] } }
show "policy loaded"

Guards

Old form New form Why
guard @g before .op:cmd = when [ … ], or with match [ … ] (guard-to-bind) guard @g = match [ … ], bound in a policy: .op:cmd { before: @g } A guard decides; the policy says when it runs, next to the rules it works with.
guard before secret = when [ … ], or with match [ … ] (anonymous) (guard-to-bind) a named guard, bound with .secret+.op:… { before: @g } A named, bound guard shows where it runs; the anonymous form goes away with the timing form.
@input.any.mx.labels.includes(".secret") in a guard @input.any is .secret See is.
allow with { addLabels, removeLabels } (with-snake-keys) allow with { add_labels, remove_labels } Keys are snake_case.

A bound guard can answer allow, allow @v (a replacement value), deny,
retry, ask or resume. See guard basics.

guard @noSecretInShell = match [
  @input.any is .secret => deny "no secrets in shell commands"
  * => allow
]
policy @app = {
  .secret+.op:cmd { before: @noSecretInShell }
}
const .secret @key = "k-1"
exe @echo(v) = cmd { echo "$v" }
const @r = match [
  denied => `blocked: ${@mx.guard.reason}`
  * => @echo(@key)
]
show @r

using and with

using lends a call authority (credentials, policy, a box, tools, a role to
read as, environment values). with says how it runs (streaming, pipelines,
timeouts). A definition's clauses go between its signature and =.

Old form New form Why
with { policy: @p }, with { tools: @t }, with { read: ".role:worker" } on a call (with-to-using) using policy:@p, using tools:@t, using read:".role:worker" Authority is using. A value that is not one reference or string keeps the braces: using { tools: [...] }.
with { creds: … } (with-to-using, creds-to-secret) using secret:name Same.
using @token as API_KEY (with-to-using) using { env: { API_KEY: @token } } One using shape; the value's labels now follow it into the result.
with { profile: … } / with { box: @b } (with-to-using) using box:@b A named box is authority.
with { streamFormat, skipDirs } (with-snake-keys) with { stream_format, skip_dirs } Keys are snake_case.
two with clauses after a stream cmd { … } definition (merge-with-tails) one with { … } A repeated clause is an error. The old parser reads two clauses only after a stream cmd body; after any other body the file fails to parse (not ported yet — tail modifiers), so merge the clauses into one by hand before migrating. The merged definition does not run in this build yet: a stream definition is refused with "exe definition stream — real streaming is not ported yet" until streaming definitions are ported.
import { @x } from "./m.mld" with { policy: @p } (import-with-policy-to-using) import { @x } from "./m.mld" using { policy: @p } Same as a call.
@http.get(@u) with { creds: "api", authHeader: "X-Api-Key" } (http-auth-header-to-creds, creds-to-secret) secret @api = { …, auth_header: "X-Api-Key" } and @http.get(@u) using secret:api The header belongs to the credential. The rule needs the call to name its credential with a string; without one the site is listed for manual work.
with { controlArgs: [...] } (and sourceArgs, updateArgs, correlateControlArgs) on an exe definition an input record: exe @f(x) with { inputs: @rec } = … Argument roles come only from the record, and the old keys are a load error. mlld migrate writes the record: each control or source arg becomes a field with a kind named for it, updateArgs an update line. With no record, a write-labelled exe gates every parameter.

See as and with and
policy composition.

const @strict = policy { .secret { deny: [.op:cmd] } }
exe @echo(v) = cmd { echo "$v" }
const @a = @echo("hi") using { policy: @strict } with { timeout: 5000 }
const @token = "t-1"
run cmd { echo $API_KEY } using { env: { API_KEY: @token } }
show @a

Credentials: creds is secret

Every credential spelling says secret now. The old ones refuse with an error
that names the new spelling: in mlld text when the file loads, in a box config,
an MCP spec or a policy built from data when it is used. The error usually
quotes your own text ("`creds` is now `secret`: write `using secret:pg`."). The
creds-to-secret rule rewrites all of them.

Old form New form Why
creds @api = { from: "env:API_KEY", as: "API_KEY" }, creds @api = "API_KEY" (creds-to-secret) secret @api = … One word for a credential everywhere.
using creds:api, using { creds: "api" } (creds-to-secret) using secret:api, using { secret: "api" } Same. A list now works too: using { secret: ["api", "db"] }.
* { credentials: { … } }, a policy object's credentials (creds-to-secret) * { secrets: { … } } Same.
an MCP import spec's "creds" and "credsHeader" (creds-to-secret) "secret" and "secretHeader" Same. The old keys refuse rather than being dropped, so a server is never reached without its credential.
a box config's creds (creds-to-secret) secret Same.

The .secret label is unchanged. See Credentials.

Tools and llm

Old form New form Why
var tools @t = { … } (var-tools-to-tools) tools @t = { … } A tool collection is its own directive.
exe llm @ask(prompt, config) = …, exe .llm @ask(…) (exe-llm-to-llm) llm @ask(prompt, config) = … An LLM call is its own directive.

See tool collections and
LLM modules.

exe .op:tool:r @lookup(q) = `found ${@q}`
tools @readTools = {
  lookup: { mlld: @lookup }
}
llm @ask(prompt, config) = `stub reply to ${@prompt}`
show @ask("hi", { tools: @readTools })

State

The state directive declares named slots that change only through slot
operations (set, append, increment, clear, update, remove,
upsert), and every write is the policy operation .op:state:w.
state session @s and state external @app are its per-call and
host-supplied forms, and state @s from @records builds one record slot per
family. See state and sessions.

state @run = { count: number?, passes: string[]? }
@run.increment("count")
@run.append("passes", "first")
show @run.count

In 2.2.0 the older state forms are refused, each with an error that names
what to write instead and what mlld migrate does with it: rewrites it,
or lists it for you to rewrite by hand.

Old form New form mlld migrate
shelf @s = {…}, shelf @s from @records, at module level or in an exe body state @s = {…}, state @s from @records rewrites (a file is converted whole or not at all; a records shelf whose record is named like a state operation is listed)
shelf @s inside a nested block (for, if, box, …) state @s … declared in the enclosing exe body, or at module level lists
shelf @s = * state @s from @records lists
shelf … with { versioned: true }, with { projection_cache: [roles] } delete the attribute: every record slot counts its writes, and the @mlld/shelf module reads the count (@version, @versions, @changedSince) and caches renders (@cacheFor, @render) rewrites: deletes the attribute, and lists a projection_cache with the @cacheFor advice
var session @s = {…} state session @s = {…} rewrites
import { @x } from @state, import @state as @s, import { @x } from "@state" state external @app = { x: <type> }, then read @app.x lists
output … to "state://x" @app.set({ x: … }) on a state external @app that declares x lists
@shelf.write(@s.slot, v), @shelf.clear(…), @shelf.remove(…), @shelve(@s.slot, v) the state's operations: @s.set({ slot: v }) for a single-value slot, @s.append("slot", v) or @s.upsert("slot", v) for a collection, @s.clear("slot"), @s.remove("slot", x) rewrites when the slot's state is declared in the same file, lists otherwise
@shelf.read(@s.slot) @s.slot rewrites when the slot's state is declared in the same file, lists otherwise
a write method on a record slot of a declared state: @s.slot.write(v), .upsert(v), .clear(), .remove(x) the state's operation, as above rewrites when the state is declared in the same file, lists otherwise
.write(v) on a record slot passed to an exe (@param.write(v)) the passed slot's own operation (@param.upsert(v)) not changed: rewrite it by hand
.role:x { write: { shelves: [upsert] } } in a record .role:x { write: { state: [upsert] } } rewrites
box { shelf: { read: [@s.slot] } } box { state: { read: [@s.slot] } } rewrites an inline scope; lists a scope held in a variable
box { shelf: { write: [ … ] } } none: an agent cannot write state yet, so a box grants no write scope. Keep the read list under state: and make the write in the program lists
.src:shelf in a rule, a guard, an is test or an attestation; .src:shelf:@s.slot in a guard or a label written as a string (@x.mx.labels.includes(".src:shelf:@s.a")), which no error reaches .src:state, .src:state:@s.slot rewrites

The names the runtime reports changed with them:

Old form New form Why
@fyi.shelf.<alias>, @fyi.shelf.<shelf>.<slot> (fyi-shelf-to-state) @fyi.state.<alias>, @fyi.state.<state>.<slot> state is the name of the agent's view of working state.
.src:fyi:shelf (fyi-shelf-to-state) .src:fyi:state The label follows the member.
push: [shelf], { shelf: false } in an fyi declaration (fyi-shelf-to-state) push: [state], { state: false } The wing follows the member.
SHELF_WRITE_DENIED, SHELF_ADDRESS_RECORD_MISMATCH (shelf-names-to-state) STATE_WRITE_DENIED, STATE_ADDRESS_RECORD_MISMATCH The error codes follow.
trace events shelf.write, shelf.read, … under category shelf state.write, state.read, … under category state No rule: a program does not spell them. Update a jq filter by hand.