Migrating to Dancing Wren (5)
Language version 5, Dancing Wren, follows Luminous Owl (4). The changelog entry lists each change in a line; this guide carries the full detail. Every rule here that says "under Dancing Wren (5)" applies only to an automation pinned to version 5. Quiet Heron (1), Bright Otter (2), Steady Lynx (3) and Luminous Owl (4) keep their old behaviour exactly (proved by the version 4 conformance corpus).
Section 2 respells the whole surface; nothing in it changes what a program means. The four changes after it share one idea: there is no engine-side store and there are no meta edges. Everything a movement reads or writes is an edge or a record that a system declares, and a plugin is a function.
Preview. Dancing Wren ships as a preview in the 0.12.0 pre-release. Existing automations keep their version 1 to 4 pins and run unchanged; version 5 is opt-in, through the explicit upgrade only.
Contents #
- 1. How the pin moves
- 2. The everyday spelling
- 3. undefined is retired
- 4. undefined hops are retired; a plugin is a function
- 5. undefined only where the system declares it
- 6. undefined only on an extraction's result
- 7. A file is never joined into text
- 8. A declared return takes what the body may return
- 9. A value that may be a record is no record parameter
- 10. A record a combinator arm hands back reads as itself
- 11. A write handle's unsent file is not a file
- 12. A closure is not data
- 13. A value that is a closure is called
- 14. A closure's type is written undefined
- 15. undefined is retired for undefined; combinators take arguments and no undefined
- 16. The extraction keyword is retired
- 17. A value that may be one of several records reads only what every one offers
- 18. Arithmetic needs a number that is there
- 19. Diagnostic code index
1. How the pin moves #
- A new automation is written in Dancing Wren (5). An existing automation moves up to it only when someone upgrades it; a release's deploy check reports which ones validate cleanly there, and moves none.
- Every change below is a refusal under version 5, not a move-up warning: an automation that uses a retired form has an error under 5, so its pin cannot move. It stays on its version, shows Review, lists the diagnostics, and runs exactly as before. Rewrite each form as below, then upgrade (
lf upgrade, orupgradeAutomationthrough the API). The respellings of section 2 carry their rewrite, so the upgrade applies them itself.
2. The everyday spelling #
- Under Dancing Wren (5) a program has two kinds of code, and each reads like what it is:
- a walk's brackets (
-[…]->) and a write'sunique byare a query the source system may run itself, so they read like SQL: CAPS words,AND/OR/NOT; - everything else runs here, step by step, so it reads like TypeScript:
&&,? :, lower-case core words, functions in namespaces, exact letter case.
- a walk's brackets (
- Why: a model or a person who writes TypeScript writes the second kind right first time, and the first kind is honest about what the source system runs.
- Every old spelling is
MOV_V5_SPELLINGunder 5, and the diagnostic carries the exact rewrite as its fix. It changes spelling, never meaning: the new spelling reads into the same program the old one did under 4. - Versions 1 to 4 read exactly as before. Under them the new spelling is not read at all.
Upgrading #
- An upgrade runs only when someone asks for it:
lf upgrade <file>(the CLI), the API's upgrade (upgradeAutomation), or the agent tool. It applies the fixes marked safe (they provably keep the meaning), checks again, and repeats until no fix is left. Every other fix is listed with its rewrite;lf upgrade --all(the API'sapplyAllFixes) applies those too. An outer rewrite is written from the original text, so a fix inside it comes round on the next pass. - The result is checked under 5. The pin moves only when it checks clean; otherwise the automation stays on its version, runs as before, and lists what is left.
- A release's deploy check never rewrites an automation and never moves a pin. It reports an automation whose text, as written, is already clean under 5 as ready to upgrade; for one in an older spelling, the team is told that an upgrade can rewrite it.
- Check a few rewrites by hand, because the right one depends on what the value is:
LENGTH(x)is rewritten toText.length(x). On a list, writecount(x). Under 5 a text's length counts characters: an emoji is 1 (under 4 and earlier it was 2, its UTF-16 units).Text.split(t, "")andText.chunkscount and cut the same way, so an emoji is never cut in two.x CONTAINS voutside brackets is rewritten toText.includes(x, v)whenvis a text literal, else toList.includes(x, v). Check that it matches whatxis.SORT(xs, key)whose key is anything but one field has no fix yet (MOV_V5_NOT_YET). Writesort(xs, (r) => …).- A WHERE that calls out (
AI(…)per record, a plugin, your own function) moves tofilter(walk, (r) => …), thenmapover what it kept.
Core words, lower case #
| Before | After |
|---|---|
MAP(xs, { concurrency: 2 }, (r) => …) | map(xs, (r) => …, { concurrency: 2 }) (settings last) |
FILTER(xs, (t) => …) | filter(xs, (t) => …) |
REDUCE(xs, 0, (acc, t) => …) | reduce(xs, (acc, t) => …, 0) (TypeScript's order) |
COUNT FIRST ONLY COALESCE MEMBERS | count first only coalesce members |
SORT(rows, `score`, DESC) | sort(rows, (r) => r.score, { order: "desc" }) |
AI(prompt, "careful") | ai(prompt, { tier: "careful" }) |
ERROR("…") | fail("…") |
callback(f(x)) | callback(() => f(x)) |
await until(c, every: 1h) | await until(c, { every: 1h }) |
movement f() { … } | function f() { … } |
exists, extract, extractOne, all, race, sleep keep their spelling.
Namespaces #
| Before | After |
|---|---|
TRIM LOWER UPPER LENGTH SPLIT CONCAT JOIN | Text.trim Text.lower Text.upper Text.length Text.split Text.concat Text.join |
TOSTRING(v), CHUNKS(t, …) | Text.from(v), Text.chunks(t, …) |
TEXT.REGEX_EXTRACT TEXT.SLUG TEXT.PAIRS | Text.regexExtract Text.slug Text.pairs |
TEXT.SERIALISE(v, "JSON") | Json.stringify(v) |
ABS ROUND FLOOR CEIL, NUMBER.FORMAT | Number.abs Number.round Number.floor Number.ceil, Number.format |
TONUMBER(t), NUMBER(v) | Number.parse(v) |
DATE(v), DATE.PARSE DATE.ADD_DAYS DATE.FORMAT DATE.TODAY | Date.from(v), Date.parse Date.addDays Date.format Date.today |
DATETIME(v), DATETIME.AT, DATE.FORMAT_ISO | DateTime.from(v), DateTime.at, DateTime.toIso |
CURRENCY.GET_NUMBER_FROM_FIGURE and its family | Currency.numberFromFigure Currency.codeFromFigure Currency.formatFigure |
URL.HOST(t) | Url.host(t) |
SUM AVG MIN MAX LAST COLLECT MULTI GROUPBY KEYBY | List.sum List.avg List.min List.max List.last List.from List.compact List.groupBy List.keyBy |
x IN [..], xs CONTAINS v | List.includes(xs, v) |
FILE(content, "text"), READ(f) | File.from(content, "text"), File.read(f) |
FILE(content, "pdf") | pdf.render(content), from the @lf/pdf plugin (import { `@lf/pdf` as pdf } from plugins); File.from(content, "pdf") is refused with this rewrite (not marked safe: the import is yours to add) |
AT(xs, 0), AT(d, "k"), AT(d, key) | xs[0], d["k"], d[key] (the upgrade writes d["k"]: on a record, d.k would read the field where AT refused; on a dict, d.k reads the same key) |
- A built-in run in a record's scope (
o.UPPER(n)) is gone: a dot reads a field, or a namespace's function. - Letter case is exact outside brackets. A name in the wrong case is refused with a did-you-mean:
Upper(x),Text.Upper(x)andText.uper(x)each nameText.upper.
Operators, conditionals and presence #
# before
if NOT ISNULL(x) AND (a OR b) { … }
line = IF EXISTS(domain) THEN "https://${domain}" ELSE "" END
if ISNULL(ch) { ERROR("no channel") }
if EXISTS(msg-[:Attachments]->) { … }
ok = TRUE
# after
if (x != null && (a || b)) { … }
line = domain != null ? "https://${domain}" : ""
if (ch == null) { fail("no channel") }
if (exists(msg-[:Attachments]->)) { … }
ok = true
- An
ifcondition is parenthesised.rec IS <T>isrec is <T>outside brackets. exists(…)takes a walk only. On a value it is refused with the!= nullrewrite.x != nullnarrowsxinside the arm, andif (x == null) { fail(…) }narrows it after, as before.count(xs)counts every item of a list, an empty one (null) included, as TypeScript's.lengthdoes; under 4 and earlierCOUNTskipped empty items. A move up across 5 warns a count of a list that may hold an empty item (MOV_COUNT_COUNTS_EMPTY). To count only the items that are there:count(filter(xs, (x) => x != null)).- One answer changes.
found != nullon the records a block returned (found = c-[m:Messages]-> { return m }) says whether the block returned any, asEXISTS(found)did. Under 4 and earlier those records always counted as present, sofound != NULLwas always true. A move up across 5 warns each such test (MOV_PRESENCE_OF_RETURNED): if the program relied on the old answer, change it, then upgrade. - Unchanged:
== != < <= > >=, arithmetic,"${…}",[a, ...xs],{ k: v }, durations,@fields, andOwner ?: xin a write.
Walk brackets #
- The words inside
-[…]->stay CAPS and ignore letter case:WHERE AND OR NOT CONTAINS IN IS WITHIN TRUE FALSE NULL ORDER BY LIMIT, and the walk functionsLOWER UPPER TRIM LENGTH ABS ROUND FLOOR CEIL DATE DATETIME NUMBER COALESCE EXISTS. The everyday spelling there is refused with the CAPS word. - A field is read through the hop's alias. A bare name inside the brackets is an outer variable, as it is outside. A hop that filters needs an alias:
# before
crm-[c:Companies WHERE `Categories` == "Customer" ORDER BY `Created At` DESC LIMIT 10]->
exists(m-[:Attachments WHERE `Name` CONTAINS "pdf"]->)
# after
crm-[c:Companies WHERE c.Categories == "Customer" ORDER BY c.`Created At` DESC LIMIT 10]->
exists(m-[a:Attachments WHERE a.Name CONTAINS "pdf"]->)
- One answer changes.
ORDER BYandLIMITon a hop over records the run already holds (an extraction's entities, anode { … }orgraph { … }literal's entries, a run-built node's landings) are read from 5. Under 4 and earlier they were ignored there: every record came back, in the order held. A move up across 5 warns each such hop (MOV_IN_MEMORY_HOP_ORDER): if the program relied on the old answer, change it, then upgrade. - Writes keep their statement words and modifiers.
unique by (Domain)andFUZZYfollow walk rules. Field values are everyday expressions:Name: Text.upper(m.Subject).
Systems and imports #
# before
import { attio, email } from adapters
import { acme_main as acme } from credentials
crm = attio(credentials: acme, dry_run: true)
inbox = email()
# after
import { `@lf/attio` as attio, `@lf/email` as email } from adapters
import { acme_main as acme } from credentials
crm = connect attio using acme { dryRun: true }
inbox = connect email
connectis grammar, not a call: a credential is legal only afterusing, and a connection is made at file scope. Settings keys are camelCase (dryRun).- A built-in adapter is imported by its package tag, whole in backticks. Credentials and your own files are imported as before. A plugin is imported by its name in camelCase (
fetch_url→fetchUrl) and called as a function is: its required inputs by position, any other in one settings record last (fetchUrl(u, { email: e })). A package of plugins is imported under a name (import { `@lf/web` as web } from plugins) and its members called through it (web.fetchUrl(u)); without the name it isMOV_V5_IMPORT_NEEDS_ALIAS.
3. write … bind is retired #
- Under Dancing Wren (5) a
bindclause on a write isMOV_WRITE_BIND_RETIRED. Why:bindkept the correspondence between two systems' records in a store the engine owned and no system held, so nothing outside a run could see it, and the next system to need the link could not find it. - Store the link explicitly, in the system that holds it: a field carrying the other record's identity, which the next write finds the record by (
unique by), or an edge there.
# before
write crm-[:Companies]-> bind deal { Name: deal.Name }
# after: the CRM holds the link, and the next write finds the record by it
write crm-[:Companies]-> { unique by (`Deal ID`), `Deal ID`: deal.id, Name: deal.Name }
- The version 4 rules on a bound write (and
MOV_TARGET_WHERE_BIND) are quiet under 5: the retirement is the one refusal. The retired#linkedhop's message no longer suggestsbindunder 5.
4. # hops are retired; a plugin is a function #
- Under Dancing Wren (5) a hop whose edge name starts with
#is refused:-[:#name]->,-[#name]->and-[:`#name`]->, on a walk, a block head, a write or match target, or in a type's address. Why: there are no meta edges, only the edges a system declares. The engine handed a#name to the system as one of its own references; on a system that described its edges it was an unknown edge, and on one that did not, or in a parameter's type, it said nothing at all.
| Form | Under 5 | Write instead |
|---|---|---|
-[:#name]->, -[#name]->, -[:`#name`]-> | MOV_META_HOP_RETIRED | an edge the record has, or the plugin called as a function |
-[f:#resources]-> | MOV_META_HOP_RETIRED | -[f:_resources]-> off an extraction's result, or the system's file edge (section 6) |
-[t:#transform { plugin: "…" }]-> | MOV_TRANSFORM_HOP_RETIRED (since 3) | the plugin imported and called |
-[#linked …]-> | MOV_LINKED_HOP_RETIRED (since 3) | the write's handle, or a link stored in the system |
- A plugin is an externally provided function. Import it and call it, and bind its result like any function's. Its declared output is the call's return type:
# before
pages = msg-[u:#transform { plugin: "vc_url_retrieval" }]->
# after
import { vcUrlRetrieval } from plugins
pages = vcUrlRetrieval(msg.Body)
- A plugin that only an extraction can feed (one with no call form, such as
linkedinEnrichment) stays a stage:extract from [ … ] through [linkedinEnrichment] { … }. Thethrough [ … ]stage is not a hop; under 5 it takes its arguments by position, as a call does. - Not retired: the
#prefix on the engine's own binding names (MOV_RESERVED_NAME), which keeps#returnuncollidable.-[:#extract]->was never accepted.
5. delete only where the system declares it #
- A system's
describenow says, per type, whether itsdeleteRecordremoves a record of that type (deletable: true; absent means no). The in-process adapters declare it where they delete: Attio objects; Affinity organisations, people, notes and a named list's entries; Airtable rows; Evertrace searches, lists and list entries; every knowledge-graph node; the Valuations entities. Slack, Telegram, Dropbox, Google Drive, Sheets, Ask, email, Gmail and WhatsApp declare none. - Under Dancing Wren (5) the checker refuses
delete x:MOV_DELETE_NOT_DECLAREDwherex's type does not declare deletion. A union is deletable only when every member is.MOV_DELETE_NOT_RECORDwherexis not one stored record: a value, a name that may hold one (a list of records,c ? r : "a"), an extraction's root or an extracted node (a transient graph nothing stores), a record on a node the run built, the instance itself, a closure or a local node.
- Why: under version 4 each of these passed the save and failed (or did nothing) at run time, after everything before the
deletehad already run. - A name bound to a conditional (
c ? a : b) or acoalesceis checked as the union of its operands: every operand a deletable record passes, an operand whose type does not declare deletion isMOV_DELETE_NOT_DECLARED, and an operand that is not a record (null, a value) isMOV_DELETE_NOT_RECORD. - A system that describes nothing, or a type not yet described, says nothing, so nothing is refused there. Change the record instead (
write x { … }), or delete it in a system that can. - A remote (protocol) adapter written before
deletableexisted declares no type deletable, so under version 5 everydeleteof its records is refused. This is accepted: the field is additive and the protocol version is unchanged, but version 5 requires it. The adapter's author adds"deletable": trueto the types itsdeleteRecordremoves (see the adapter handbook's protocol chapter); movements pinned to version 4 or earlier are unaffected.
6. _resources only on an extraction's result #
- Under Dancing Wren (5)
x-[f:_resources]->isMOV_RESOURCES_NOT_EXTRACTEDunlessxis provably an extraction's result: its root or an extracted node, where_resourcesis the source content the extraction read. The rule needs no schema, so a root nothing proves to be one is refused too: a record of a system that describes nothing, or a name bound to a conditional whose arms are not all extractions' results. A rootless-[:_resources]->(the event's record) is refused too. Why: off a system's record,_resourceswas a reserved reference the engine resolved through the event's adapter, whatever system the record belonged to. Email, Gmail, WhatsApp and Slack had already retired the bundle it read, so there it read nothing useful; on the knowledge graph it duplicated an edge the graph declares. - A record reaches its files over an edge its system declares, and the diagnostic names it:
| System | Write instead of x-[f:_resources]-> |
|---|---|
| Email, Gmail, WhatsApp | x-[f:Attachments]-> { … f.File … } |
| Slack | x-[f:Files]-> { … f.File … } |
| Knowledge graph | x-[f:Resources]-> { … f.URL … } (fields Name, URL, Type) |
- The edge in the message is derived from the system's schema, not from a list: the edge the system declares as its resources (the knowledge graph's
Resources), else every readable edge whose landing has a file-typed field. A record that holds a file itself (an email's attachment) is pointed at its own file field (a.File). Where there is neither the message says the system exposes no files. - The knowledge graph's
Resourcesedge is matched by its reserved address, never by its name: a team's own ontology edge namedResourcesis that edge, in every version.
# before (an extraction's result: unchanged)
deck = extract from [msg-[:Attachments]->.File] { … }
deck-[f:_resources]-> { write drive-[:files]-> { name: f.name, data: f.file } }
# before (a record: refused under 5)
msg-[f:_resources]-> { … }
# after
msg-[f:Attachments]-> { … f.File … }
- The engine follows the checker: under version 5 the movement engine never resolves the reserved reference for a system's record. Versions 1 to 4 keep it, including its quirk of reading another system's record through the event's adapter.
7. A file is never joined into text #
- Under Dancing Wren (5) a file (or a value that may be one, such as
coalesce(f, "none")) inside an interpolated text isMOV_FILE_IN_TEXT:"Attached: ${f}". - Why: a file is a byte channel, not text, and has no text form but the one
File.readgives. Under version 4 and earlier the engine joined the file's description in, and the text readAttached: [object Object]. A record in the same place has been refused on every version (MOV_RECORD_NOT_A_VALUE). - Versions 1 to 4 keep the old behaviour. The error under 5 keeps such an automation on its version until the text is rewritten.
# before (refused under 5)
write crm-[:notes]-> { title: "Attached: ${f}" }
# after: the file's text
write crm-[:notes]-> { title: "Attached: ${coalesce(File.read(f), "")}" }
# after: the file itself, into a field that holds files
write crm-[:notes]-> { title: "Attached", upload: f }
8. A declared return takes what the body may return #
- Under Dancing Wren (5) a closure's declared return type that does not take what a return may hand back is
MOV_RETURN_TYPEat the declaration:c = (): <text> => coalesce(org, "x")(a record may come back where text was declared; TypeScript'sOrg | stringis nostring). - A named function's declaration is held to the same rule (
function f(): <text> { return coalesce(org, "x") }), and under 3 and 4 its call holds what the body may return, a call back into it from its own body carrying nothing for that edge. - Why: a call is typed by what it declares, so the declaration is the last place the record or file is seen. Versions 3 and 4 ran such a program while the call was unused or only compared, so the refusal is new under 5 only. Under 3 and 4 the declaration is clean, and the call still holds what the body may return: a use that reaches data (a graph entry, a written field, a plugin argument) is refused at the use, on every version.
- Return a field off the record (
org.name), or declare the type the body returns.
9. A value that may be a record is no record parameter #
- Under Dancing Wren (5) a value that MAY be a record and may be something else, handed to a parameter that takes a record position, is
MOV_CALL_ARG_MAYBE_RECORD:v = coalesce(org, "x")thenhp(v),hp(cond ? org : "x"), or a function or closure whose returns are a record on one path and text on another. - Why: TypeScript's
Org | stringis noOrg. The value that is not a record has nowhere to go, and the engine refuses it by name at run time, which a checked program could reach. - Narrow it to the record with
isbefore passing it, handle the case where it is not one, or fill the other arm with a record. A union of records alone (coalesce(org, other)) is one record and passes. Versions 1 to 4 are unchanged. - The same value handed to a parameter typed as a VALUE (
function f(n: <number>), thenf(coalesce(org, 1)),f(cond ? org : 1), or a name bound to either) isMOV_CALL_ARG_MAYBE_RECORDtoo, through a function by name, a closure in a variable, or a list handed to<number[]>. - Why:
Org | numberis nonumber. Under versions 3 and 4 the check said nothing and the call ran: the record arrived where the number was typed, and a sum over it came outNaN. So under 3 and 4 it is a warning that blocks the move up, and the refusal is new under 5. - Narrow it to the record with
isand pass a field off it (f(coalesce(org.size, 1))), or fill every arm with a value. - A built-in whose argument takes a value is held to the same rule:
Number.round(coalesce(org, 1))andText.upper(coalesce(org, "b"))areMOV_CALL_ARG_MAYBE_RECORD. Under 3 and 4 the built-in ran on the record as it was (ROUNDgaveNaN,UPPERgave"[OBJECT OBJECT]"), so there it is a warning that blocks the move up. - A plugin's text input is held to it for a record value (
{ … }) as well:research({ context: s }), or a value that may be one, isMOV_CALL_ARG_MAYBE_RECORD. Under 3 and 4 the record was passed through as it was, so there it is a warning that blocks the move up. - A collection op calls its function with each member, so each member is an argument too:
map(["a"], (n: <number>) => n)isMOV_CALL_ARG_TYPE(TypeScript refuses["a"].map((n: number) => n)), and a member that may be a record, handed to a value parameter, isMOV_CALL_ARG_MAYBE_RECORD. The same holds forfilter,List.groupBy,List.keyBy, a function passed by name, andreduce, whose starting value is held to its accumulator. Under 3 and 4 the op ran with each member as it was (a sum over text came outNaN), so there it is a warning that blocks the move up. Type the parameter as the members are, or leave it untyped to take their type.
10. A record a combinator arm hands back reads as itself #
- Under Dancing Wren (5) a slot of a
racereceipt that holds the record an arm returned reads as that record:r[0].name,h = r[0]thenh.name, andr[0] == null(false). It is the reading[a, 2][0]already had on a list.parallelis retired under 5 (section 15); theallthat replaces it reads its slots the same way. - Why: the check accepted the field read, and the run read null. A program that checked clean silently read nothing.
- Under Luminous Owl (4) and earlier the slot still reads null. A move up across 5 warns every read of a receipt one of whose arms returns a record (
MOV_RECEIPT_RECORD_SLOT): if the program relied on the null, change it, then upgrade.
r = await parallel([
() => {
a = write crm-[:companies]-> { name: "Acme" }
return a
},
() => 2
])
write crm-[:notes]-> { title: AT(r, 0).name } # 4 and earlier: null
Under 5 the same join is r = all(() => { … }, () => 2), and r[0].name reads "Acme".
11. A write handle's unsent file is not a file #
- Under Dancing Wren (5) a write handle's file field that the write did not send, used as a file, is
MOV_HANDLE_FILE_NOT_SENT:File.read(h.doc),ocr(doc: h.doc), orh.docinto another write's file field, afterh = write d-[:docs]-> { name: "a" }. - Why: the system answers a write with its own description of the file, a dict, not a file the run can read.
File.readfailed with "reads a file, and this is object" though the program had validated. A file field the write did send (doc: f) is the program's own file and stays usable. - Versions 1 to 4 keep the old behaviour. Read the record back through its edge to get the file, as a walk read does, or send the file in the write.
# before (refused under 5)
h = write d-[:docs]-> { name: "a" }
text = File.read(h.doc)
# after: the file off the record's edge
text = File.read(first(h-[:Files]->).File)
12. A closure is not data #
- Under Dancing Wren (5) a closure is a value, as TypeScript's functions are, but it is never data. It lives in exactly three places:
- a variable:
g = label,g = () => { … }, or a call that answers one (h = mk()); - a parameter, including one typed as a closure (
handler: <(c: <Company>) => <text>>, section 14); - an argument to a parameter that takes one:
map(xs, (x) => …),race(a, b),all(a, b),run(label).
- a variable:
- It is never an operand of a value expression (
MOV_CLOSURE_NOT_DATA):c ? a : borcoalesce(a, b)choosing between closures, a comparison (a == b), interpolation ("${a}"), arithmetic,!/&&/||, a condition (if (a) { … }), or a built-in's argument that is not its function (Text.upper(a)). Choose with a statement instead:if (c) { v = a() } else { v = b() }. - Anywhere a value would be kept as data it is
MOV_CLOSURE_NOT_DATA: a list's element ([label],[() => { … }]), a record or map's value ({ g: label },{ g: () => 1 }), a field written, a spread, amapwhose function returns a closure,List.groupByorList.keyByover closures, araceorallarm that returns a closure, and a type whose list element or record key is a closure (<() => <text>>[],<{ run: <() => <text>> }>). - Why: a closure stored as data is a function that outlives the scope that made it, travels through records and lists, and has to be called back through whatever holds it. Kept in a variable or passed as an argument, every call of it is visible where it is written.
- Under Luminous Owl (4) and earlier a closure is no value at all: written in a list it stays
MOV_EXPR_CLOSURE_POSITION(bind it to a name first).
# refused under 5: closures kept as data
fs = [label, () => { return "x" }] # MOV_CLOSURE_NOT_DATA
box = { g: label } # MOV_CLOSURE_NOT_DATA
# instead: a variable, or arguments
g = label
v = g()
r = all(label, () => { return "x" })
13. A value that is a closure is called #
- Under Dancing Wren (5) a name whose VALUE is a closure is called as TypeScript calls one. The value may come from anywhere a closure may be (section 12):
- a bound closure (
g = () => { … }theng()); - a function declared with a name, read as a value: bound (
g = madetheng()) or handed back (() => made,return made, a function that returns one, thenh = mk()andh()); - a closure a closure returns in place (
mk = () => { return () => { … } }); - a parameter whose type is a closure type (section 14). Under 4 such a call is refused (
MOV_CONSTRUCT_NOT_ADAPTER/MOV_CALL_NOT_MOVEMENT).
- a bound closure (
- The call's type is what the closure returns, records included, so the rules for a record in data (a written field, a graph entry, a plugin argument) see a record it hands back.
- A value that may not be a closure (
coalesce(c, 2)), or whose type nothing shows, is refused where it is called (MOV_CALL_NOT_MOVEMENT), never run on the chance. - A value that may be any of several closures (a function or closure whose returns hand back different ones,
return madeon one path andreturn labelon another) is called as TypeScript calls a union of function types:- every closure's arity (
MOV_CALL_ARITY, naming each); - an argument that fits every closure's parameter; two closures whose parameters share no argument are named (
MOV_CALL_ARG_TYPE); - the call's value is any of their returns, so a record a later one hands back is refused where it is written (
MOV_WRITE_FIELD_TYPE). A declared return type (function mk(): <() => <text>>) narrows the closures to it. Why: under 3 and 4 the call was typed by the first closure alone, and ran on whichever it reached: a record written into a text field, a number handed to a text parameter. So under 3 and 4 each is a warning that blocks the move up (MOV_CALL_ARITY,MOV_CALL_ARG_TYPE,MOV_CALL_CLOSURE_RETURNS).
- every closure's arity (
- A body that is one call,
(f) => f(), returns the call, as(f) => { return f() }does: a callee that waits parks the run where the call is, and a callee that returns nothing makes the closure return nothing (TypeScript's() => g()whengreturnsvoid). A body that is a combinator,() => all(f, g), returns its answer.
a = () => { return made() }
g = a
v = g()
write crm-[:notes]-> { title: v } # a record: MOV_WRITE_FIELD_TYPE
No methods #
- There are no methods: a dot on a record or map always reads a field, and a closure is never under a key (section 12). Why: a closure under a key made
box.g()a second way to call a function, andbox.gmean two things. - Under Dancing Wren (5), a dotted call of a function that is no built-in (
o.nope(),box.g()) isMOV_FUNCTION_UNKNOWN. The engine readso.f(…)asfrun ino's scope, and outside a write field there is no such function to run, so the run always failed where it reached the call; the checker now says so where it is written. Versions 1–4 are unchanged (a saved movement never breaks). A function inside a write field, which the target may advertise, is unchanged; under 5 it is a member of the adapter's package. A built-in in a record's scope (o.UPPER(name)) is gone under 5 (section 2).
14. A closure's type is written <(params) => <return>> #
- Under Dancing Wren (5) a closure's type is written as TypeScript's function type, in angle brackets:
<(params) => <return>>. Each parameter is typed as a function's is, and the return is written as a function's return annotation is.
function run(handler: <(c: <crm-[:company]->>) => <text>>) { … } # a parameter
function pick(): <() => <text>> { … } # a declared return
- A closure type is never a list's element or a record type's key (
<() => <text>>[]and<{ run: <() => <text>> }>areMOV_CLOSURE_NOT_DATA): a closure is not data. - A closure that returns a list is
<() => <text[]>>;<() => <text>[]>is ambiguous and refused. Every parameter needs a type: a caller of the closure is checked against it. - A parameter of closure type is called (
handler(c)), typed by its return and held to its parameters (MOV_CALL_ARITY). - A callback's closure is held to the same rule:
callback(() => f(x))takes nothing, so it passes every argument offitself, and a parameter left open isMOV_CALL_ARITY. What the platform sends when it fires is a parameter the closure declares:callback((d: <date>) => f(x, d)). A function named bare (callback(f)) still leaves every parameter to the platform. - A closure fits a closure type as TypeScript's assignability says. Parameters are contravariant: each of the closure's parameters takes what the type's hands it. Returns are covariant: what the closure returns, what it may hold included, is what the type's returns. A closure that takes fewer parameters fits, and the extra arguments are dropped; one that takes more does not. A misfit is
MOV_CALL_ARG_TYPE(orMOV_RETURN_TYPEfor a declared return), naming what the closure returns. - A closure written in place is an argument to a closure-typed parameter:
run((c) => c.name). Its untyped parameters are typed by the closure type (contextual typing). - Under Quiet Heron (1) to Luminous Owl (4) the syntax is a parse error that names version 5, because a closure is not a value there.
15. parallel is retired for all; combinators take arguments and no await #
- Under Dancing Wren (5) every
parallelform isMOV_PARALLEL_RETIRED.all(f, g, …)is the same join, as TypeScript'sPromise.allis: every arm runs, at once, and the answer is the list of what each returned, in argument order. Why: a closure is not data (section 12), so the arms are passed as arguments, not in a list. - For arms written down the diagnostic carries the exact rewrite, which a tool applies:
# before
r = await parallel([a, () => { … }])
# after
r = all(a, () => { … })
- Later reads stay clean: the answer is a tuple, slot by slot, so
r[i]reads exactly what slotiof the receipt did: a value arm's value, a record arm's record (r[0].name), and an arm that returns nothing reads absent. alltakesmap's settings where they mean something for a join, as a record written last:all(a, b, { concurrency: 2, onError: "warn" }).concurrencyis how many arms run at once (all of them without it); an arm that parks is waiting, not running, so it frees its place for the next.onError: "warn"or"ignore"leaves the slot of an arm that fails null (and its slot type may be absent),"warn"saying so on the trace; the default fails the run, as before.initialConcurrencyis refused.racetakes its arms as arguments too:race([q, t])isMOV_COMBINATOR_ARMS_ARE_ARGUMENTS(all([…])as well), and the fix drops the brackets:race(q, t).racetakes no settings.- No
awaitonraceorall:awaitmarks only the suspension itself, where the run parks (await sleep(…),await until(…), reading an answer withawait first(q-[:Response]->)). A combinator does not park by itself; its arms may, and the run parks where they do.await race(…)andawait all(…)areMOV_COMBINATOR_NO_AWAIT, and the fix removes theawait. An arrow body that awaits one,g = () => await parallel([…]), gets the same fixes:g = () => all(…). - Arms built at run time (
parallel(fs)) have no fix: name each function as an argument,all(f, g). - The refusal of the
parallel { … }block points atallunder 5. Under 4 and earlierawait race([…])andawait parallel([…])are unchanged, andallis still a retired name.
16. The extraction keyword is retired #
- Under Dancing Wren (5) the keyword
x = extract from […] { … }isMOV_EXTRACT_KEYWORD_RETIRED, with its inline node shapes and itsthrough […]stages. An extraction is the call over a declared node shape:extract(content, Shape, { tier })hands back each record the content holds, andextractOne(content, Shape, { tier })the one it describes, or nothing. A plugin is a function you call. Why: one way to extract, whose shape is an ordinary declaration you can reuse, spread into a graph and pass around. - Where the rewrite is mechanical the diagnostic carries it. It declares the tree as a node shape named after the binding (
foundisFoundShape; a name the file already holds gets the next number,FoundShape2), and callsextractOneover the same content and tier:
# before
found = extract "careful" from [m.Body] {
node company: "each company named" {
name: <text> "its name"
website: "its website"
}
}
# after
node FoundShape: "the relevant information in the content" {
node company: "each company named" {
name: <text> "its name"
website: <text> "its website"
}
}
found = extractOne([m.Body], FoundShape, { tier: "careful" })
foundis the one record the keyword's result was: its fields read off it (found.summary) and its nodes walk from it as before, inside a guard (if (found != null) { found-[c:company]-> { … } }): the call's record may be absent, and a block's walk off a record that is not there fails the run, so from 5 the unguarded walk is refused (MOV_ABSENT_REQUIRED). The same holds for amatchthat may find nothing and an unguarded pick (only(…),rows[0]); a move up across 5 is an upgrade warning. A walk inside an expression is refused the same way (count(found-[c:company]->),exists(found-[c:company]->), a hop read in a write field);exists(found)has no hop and is allowed. A node that took a declaration (node lead: <Lead>) is written out in place, its words the use site's. A declaration whose words interpolate a value of the body ("the topic of ${m.Subject}") stays in the body, where they are read; any other goes before the function at file level.- The fix is offered, never applied on its own. Check three things: the record may be absent where the keyword's result never was, so a field read off it where a value is needed is
MOV_ABSENT_REQUIRED(writecoalesce(found.summary, ""), which that diagnostic offers); a field written without a type becomes<text>; and the model is asked through the call's prompt, which lays out the content for the prompt cache. - A
through […]stage has no fix. It hands each record's plugin answers to a further extraction of that record and merges what that finds into it; plugins called as functions hand back values of their own, so no rewrite is provably the same. Write the composition out: declare the shape,extractit,mapover the records calling each plugin,extractOneover the content plus what the plugin returned, and merge the two with a spread intograph<Shape> { ...c, ...details }. The handbook's enrichment example is exactly this. - A null test on a
<text>field of the call's record is refused (MOV_PRESENCE_TEST_ON_TEXT), as it was on the keyword's result:if (found.website != null)always answers true, since a text the model did not find is"". Annotate the field<text | null>to keep the test, or testfound.website != "". Under 3 and 4 the call's test runs as before; a move up across 5 is an upgrade warning. - The Rust engine does not run the keyword on any version, and runs no version below 5. A Quiet Heron (1) to Luminous Owl (4) automation that holds one runs on the TypeScript engine by its version, exactly as before.
lfruns only version 5 and refuses such a project; the platform upgrades a pin below 5 to version 5.
17. A value that may be one of several records reads only what every one offers #
One rule, TypeScript's union, for every place a value can be one of several records: an IF, a COALESCE, a function's returns, a list literal's members, MULTI.
who = COALESCE(lead, co) # People | Companies
who.Name # both have Name → typed (union of the field types)
who.Domains # only Companies → refused, naming the member that lacks it
write who { Name: "Acme" } # allowed, value checked against Name in every member
write who { Domains: ["a.com"] } # refused: People has no Domains
- Records of one type are that type, as
A | AisA:COALESCE(pick, org)of two Companies a write returned is a Company. - Records of different types are a union. This includes one type read two ways, such as a Company found by a walk and one a write returned.
- A field read off a union is typed when every member has it, as the union of its types (
number | text). A field one member lacks isMOV_UNKNOWN_PROPERTY, naming that member. A walk off it takes only an edge every member has. - No arm is preferred. Only what every arm proved counts: a field a guard proved present (
if a.Email == "x"), a field a write set. Before, anIFof two records of one type took itsTHENarm's proof. - A function returning a Person on one path and a Company on another is their union. Before, it was its first return's type, so a field only People have checked clean and read null when the call returned the Company.
- A list
[lead, co]holds the union. Before, a member was a record nobody could name, and nothing was checked. - Why: each of these ran unchecked under 4 and earlier.
.noperead null;.name + 1was NaN; anIFwhoseELSEarm had no Email wrote a missing value into a required field. - Under 3 and 4 each refusal the rule adds is an upgrade warning; the program runs as before.
- The fix: read the field where you know which record you hold (inside the arm, or after an
IStest), or read a field every member has. - Writing in place to a union (
write who { … }) is held to every member on every version: a field one member lacks isMOV_WRITE_UNKNOWN_FIELD, said of that member. Before, it was an internal error, so it never ran.
18. Arithmetic needs a number that is there #
A number computed from a value that may be absent is refused (MOV_ABSENT_REQUIRED), as TypeScript refuses n + 1 on number | undefined.
n = Number.parse(t) # number | absent
m = n + 1 # refused: n may be absent
m = coalesce(n, 0) + 1 # a fallback
if (n != null) { m = n + 1 } # a guard (or a guard clause that leaves)
m = n != null ? n + 1 : 0 # a conditional
s = List.sum(List.compact(ys)) # a list that may hold absent members: drop them first
s = List.sum(filter(ys, (y) => y != null)) # or keep only the present ones
- It covers
+ - * / %, unary-, a built-in's number parameter (Number.round(n),Date.addDays(d, n)), andList.sum,List.avg,List.minandList.maxover a list that may hold an absent member. - Why: the engine reads an absent value as 0 there.
n + 1answered 1 for a number that was never found, andList.avgcounted an absent member as 0. Nothing said so. - The fix: guard the value, give it a fallback with
coalesce, or drop (List.compact, or afilterwhose test keeps only present members) or fill (map(xs, (x) => coalesce(x, 0))) a list's absent members. The diagnostic writes the guard and the fallback from your own operand. - Under 1 to 4 the program runs as before; a move up across 5 is an upgrade warning.
19. Diagnostic code index #
| Code | Where |
|---|---|
MOV_CALL_ARG_MAYBE_RECORD | A value that may be a record is no record parameter (a value parameter, a collection op's member and a built-in's argument included) |
MOV_CALL_ARITY | A value that is a closure is called (a call of one of several closures names every arity) |
MOV_CALL_ARG_TYPE | A closure's type is written undefined (a closure that does not fit a closure type); A value that is a closure is called (two of several closures whose parameters share no argument); A value that may be a record is no record parameter (a collection op's member against its function's parameter) |
MOV_CALL_CLOSURE_RETURNS | A value that is a closure is called (warning under 3 and 4: a later closure may hand back a record the first does not) |
MOV_CALL_NOT_MOVEMENT | A value that is a closure is called (a value that may not be a closure, or whose type nothing shows) |
MOV_CLOSURE_NOT_DATA | A closure is not data (a closure as a list element, a record or map value, a field, an op's or a combinator's answer, or a type's list element or key; since 5) |
MOV_COMBINATOR_ARM_NOT_FUNCTION | undefined and undefined (an argument that is no function) |
MOV_COMBINATOR_ARMS_ARE_ARGUMENTS | undefined and undefined (arms in a list; the fix drops the brackets; since 5) |
MOV_COMBINATOR_NO_AWAIT | undefined and undefined (await race(…), await all(…); the fix drops the await; since 5) |
MOV_DELETE_NOT_DECLARED | undefined only where the system declares it |
MOV_COUNT_COUNTS_EMPTY | Operators, conditionals and presence (warning, on a move up across 5: a count of a list that may hold an empty item) |
MOV_DELETE_NOT_RECORD | undefined only where the system declares it |
MOV_EXPR_CLOSURE_POSITION | A closure is not data (under 4 and earlier; under 5 a closure written in a list or map is MOV_CLOSURE_NOT_DATA) |
MOV_EXTRACT_KEYWORD_RETIRED | The extraction keyword is retired (the rewrite to extractOne over a declared shape where it is mechanical; none for a through […] stage) |
MOV_FUNCTION_UNKNOWN | No methods (a dotted call of no built-in, box.g() included; version 5) |
MOV_FILE_IN_TEXT | A file is never joined into text |
MOV_HANDLE_FILE_NOT_SENT | A write handle's unsent file is not a file |
MOV_META_HOP_RETIRED | undefined hops are retired |
MOV_PARALLEL_RETIRED | undefined is retired for undefined (with the all rewrite for arms written down) |
MOV_ABSENT_REQUIRED | The extraction keyword is retired (a block's walk off a record that may be absent: an extractOne answer, a match, an unguarded pick; since 5, an upgrade warning on a move up across 5); arithmetic on a value that may be absent |
MOV_PRESENCE_TEST_ON_TEXT | The extraction keyword is retired (a null test on a <text> field of an extract or extractOne record; since 5, an upgrade warning on a move up across 5) |
MOV_PRESENCE_OF_RETURNED | Operators, conditionals and presence (warning, on a move up across 5: a null test on a block's returned records) |
MOV_IN_MEMORY_HOP_ORDER | Walk brackets (warning, on a move up across 5: ORDER BY or LIMIT on a hop over records the run already holds, which 4 ignored) |
MOV_RECEIPT_RECORD_SLOT | A record a combinator arm hands back reads as itself (warning, on a move up across 5) |
MOV_RETURN_TYPE | A declared return takes what the body may return (a body's returns that may hold a record or file; since 5) |
MOV_RESOURCES_NOT_EXTRACTED | undefined only on an extraction's result |
MOV_UNKNOWN_PROPERTY | A value that may be one of several records reads only what every one offers (a field the type does not have, read off one; warning under 3 and 4) |
MOV_TRANSFORM_HOP_RETIRED | undefined hops are retired (since 3; under 5 the message shows the function form) |
MOV_V5_IMPORT_NEEDS_ALIAS | The everyday spelling (a package of plugins imported without as; the fix adds the name) |
MOV_V5_NOT_YET | The everyday spelling (an old spelling whose rewrite needs a person, such as a SORT key that is not one field) |
MOV_V5_SPELLING | The everyday spelling (an old spelling under 5; the fix is the rewrite) |
MOV_V5_UNKNOWN_MEMBER | The everyday spelling (a namespace member that does not exist, with a did-you-mean) |
MOV_WRITE_BIND_RETIRED | undefined is retired |