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.
- 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. - Preview, if you like:
mlld migrate . --old-ref HEAD --checkwrites
nothing and lists the files it would rewrite. - Run it:
mlld migrate . --old-ref HEAD. It prints each file it rewrote,
with the rules that changed it, and amanuallist of sites it left for
you (path:line:columnand what to do). Aparse failureslist names
files it could not read as old syntax. - Fix each listed site by hand, run your scripts, and review the change with
git diffbefore 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. |