Overview#
TERSL has no separate update language. To change state, you write a declaration: the part of the shape that should be different, in the same syntax as a state file, with directives in a bracketed tail where a line needs to do more than describe. This page explains how a declaration is applied to standing state, how attribute lists work as patches, what each write directive does, and how queries inside a declaration copy and move state. After reading it you can make targeted changes to a store and predict the result.
Silence defers#
A declaration applied to an empty store becomes the state. Applied to standing state, it changes only what it mentions. The specification calls this rule silence defers: a line you do not restate does not change, restating a line does not move it, and leaving out an attribute does not clear it. Reopening a container enters it, and redeclaring an object merges into it. A redeclared line is never refused, so a document can revise its own earlier lines further down.
Applied to the state shown on What TERSL is, this declaration, adapted from the open-source README, adds an agent, adds a model at the top of the list, creates a container, and moves a model into it:
# System.Agents
Marketing(use any model)
# System.Models
Brain(Brain_5_thinking; cost_M_usd: 4.25) [FIRST] // ordering is state; place explicitly when it matters
## Retired // declaring a container creates it
? System.Models.Astra(deprecated) [MOVED] // query inside a declaration: pull Astra here, tagging it on the way
The result:
# System(online)
## Agents
Accounting
Coding(prefers @System.Models.Astra)
Marketing(use any model)
## Models
Brain(Brain_5_thinking; cost_M_usd: 4.25)
Zemma 7(Zem7; cost_M_usd: 3.0)
### Retired
Astra(Astra_v2_5; cost_M_usd: 1.5; deprecated)
# Runs(by id; in-progress)
## 5783(date: 2026-12-23; completed)
### Result
"The primary answer was 42"
Accounting, Coding, Zemma 7 and the whole of Runs were not mentioned and did not change. Coding still refers to @System.Models.Astra: references are path text, and a move does not rewrite them, so that reference now dangles.
Re-applying a declaration of plain state to the state it produced changes nothing, so you can re-send one when you are unsure whether it landed.
Attribute lists are deltas#
An attribute list on a declared line patches the attributes that stand, segment by segment, from left to right:
| Segment | Effect |
|---|---|
-x |
Removes x if present. If x is absent, nothing happens. |
x |
Declares the flag x. It is added at the end, or moved to the end if already present. |
x: v |
Declares the identifier x with value v, added at the end or moved to the end with the new value. |
() |
Clears every attribute. |
| No parentheses | Changes no attributes. |
# House(white; sq_ft: 2000) // standing
# House(-white; sq_ft: 1800; beige) // patch
# House(sq_ft: 1800; beige) // result
Attributes you name collect at the end of the list in the order you named them, and attributes you do not name keep their places at the front. Declaring names that already stand is how you reorder a list: against # House(beige; sq_ft: 1800; two storey), the patch # House(two storey; beige) gives # House(sq_ft: 1800; two storey; beige). To replace a whole list, clear it and then declare it, on two lines:
## Kell()
## Kell(wounded; hp: 25)
A multi-word name after - needs brackets, or the - applies to the first word only: status(very confident; -[under review]). A + prefix is accepted, means the same as no prefix, and raises the deprecation warning TERSE005.
The directive tail#
A line may end with one directive tail in square brackets. Its segments are separated by ;, and the first word of each segment is a keyword in capitals. Empty brackets are TERSE039, an unknown keyword is TERSE040, and a second tail on the same line is TERSE923. Keywords are ordinary text anywhere outside a tail, so a name can contain words such as NEW or MOVED.
The README groups the directives as placement (FIRST, LAST, BEFORE x, AFTER x), rename (WAS), removal (REMOVED), attribute merging (MERGED), read filters and slices (described on Reading with queries), and command modes (NEW, FILL, MOVED, AS). APPEND joins text to a container's TEXT.
Placement#
Moving a line takes a placement directive. [FIRST] and [LAST] put a line at the start or end of its run, and [BEFORE name] and [AFTER name] put it next to a named sibling. Placement works within a kind: objects move among objects and containers among containers, and an anchor must be a sibling of the same kind.
# Steps
fetch(status: done)
compile(status: queued)
publish(status: queued)
# Steps
lint(status: queued) [AFTER fetch]
typecheck(status: queued) [AFTER fetch]
The steps are now fetch, lint, typecheck, compile, publish. Lines that share a placement target land together in the order written, so several [FIRST] lines become the new head of the run in that order.
A placement states a position, so applying it again changes nothing. The anchor must already stand at the line that names it. An anchor that is unknown, of the other kind, or declared only further down the document raises the warning TERSE032, and the node is not moved: a new node lands where it would have without the directive, and an existing node stays where it was. A line may carry only one placement (TERSE034). A new line with no placement goes after the same-kind sibling written directly above it, or otherwise at the end of its run.
Renaming#
[WAS old name] renames a standing node in place, so the line updates that node instead of creating a new sibling. Its children are kept, and any attribute list on the line applies to it as a delta.
# Teams
## New Team Name [WAS Old Team Name]
# Armory
Sword of Hope(blessed; damage: 15) [WAS Sword of Pain]
If no sibling with the old name stands, the line raises the warning TERSE901 and creates nothing. References to the old name elsewhere in the state are not rewritten.
Removing#
[REMOVED] deletes a node and its whole subtree. On an object line, a leading - is a shorthand for it.
# Inventory
broken shield [REMOVED]
-old map
# Places
## Abandoned Camp [REMOVED]
A removal that finds nothing is a silent no-op with no diagnostic, so removals are always safe to send again. [REMOVED] takes no other directives, and combining it with a placement is TERSE035. # [REMOVED] on root's header empties the whole store at that line; a following # (schema: v3) sets root attributes again, and later lines rebuild the store.
Merging attributes#
[MERGED] is accepted and has no effect on a container or object line. Every attribute list already merges into what stands, so ## Kell(wounded; hp: 25) [MERGED] means the same as the line without the tail. On a ? line inside a declaration it is refused with TERSE912, and that line does nothing.
Appending text#
[APPEND] joins text to a container's TEXT with a single newline instead of replacing it. If the container has no TEXT, the appended text becomes its TEXT. There are four forms:
# Log
"first entry."
# Log [APPEND "second entry."]
# Log [APPEND]
"""
third entry
that's multiline
"""
# Log
"""
fourth entry
""" [APPEND]
# Log
"fifth entry" [APPEND]
After these declarations the TEXT of Log is:
first entry.
second entry.
third entry
that's multiline
fourth entry
fifth entry
[APPENDED] is an exact alias. APPEND is a container directive only; a ? line cannot carry it.
Queries inside declarations#
A ? line inside a declaration is a command. It resolves its path against the state as of its own line and places the matches under the enclosing container. The literal part of the path is stripped, so ? Templates.StarterGear.* under # Chars.Kell.Inventory copies the children of StarterGear into the inventory. The line does not change the navigation level, so the next header continues as if the ? line were not there.
By default the arrival merges onto whatever stands at the destination: a same-named node there is the base, and the arrival's attributes and children apply to it as a delta. The command modes change that:
| Directive | Effect |
|---|---|
| none | Merge onto the standing node; its unmentioned children stay. |
[NEW] |
Wipe the destination node's subtree and attributes, then merge. |
[FILL] |
Skip the whole block if the destination already has a node with that name. |
[MOVED] |
After a successful merge, delete the source. |
[AS name] |
Rename the arrival on the way. |
# Library
## Shelf A(label: fiction)
old book(yellowed)
# Templates
## NewShelf(label: science)
new book(crisp)
Against that state, ? Templates.NewShelf [AS Shelf A] under # Library leaves Shelf A as follows:
# Library
## Shelf A(label: science)
old book(yellowed)
new book(crisp)
With [AS Shelf A; NEW], old book is gone and only new book remains. With [AS Shelf A; FILL], nothing changes, because Shelf A already exists. [NEW] and [FILL] cannot be combined, and [MOVED] deletes the source only when the merge happened, so under a [FILL] that skips, the source stays.
Moving and renaming combine:
# Hero
? Inventory.Old Inventory.sword of pain [MOVED; AS Excalibur]
That line deletes sword of pain from Old Inventory and adds Excalibur(damage: 15) under Hero, keeping the sword's attributes. [AS] renames one node; on a query matching several, the first in document order is used and the warning TERSE910 is raised. On an ordinary declaration line [AS] is TERSE913; use [WAS] there.
An attribute list on a command line applies to the arriving copy before it lands. In the specification's example, # Couch(outside) is moved into # House with ? Couch(-outside; in the house now) [MOVED], and the result is ## Couch(in the house now) under House. Because the list patches the arrival, a -x there cannot remove an attribute that only the destination node has; to do that, declare the destination node with (-x) on its own line.
Read directives such as [WHERE] and [LAST 3] narrow a command before it writes: ? Region.Old.* [REMOVED] removes every child of Region.Old, and ? Src.Records.* [LAST 3; MOVED] moves the final three. [DEPTH] with [MOVED] is refused (TERSE038), because it would discard the descendants beyond the bound. A ? line with lines beneath it is a shape block, which copies only the parts you name; the specification's section on shaped projection describes it.
Wildcards and fan-out#
A * segment in a declaration header fans the block out over every child container that matches, and [WHERE] on the header narrows the set. A wildcard header never creates containers.
# Characters
## Kell(hp: 25)
## Anya(hp: 0)
# Characters.* [WHERE Its.hp > 0]
veteran badge
# Characters
## Kell(hp: 25)
veteran badge
## Anya(hp: 0)
# Characters.*(NPC) applies an attribute delta to every child container. The same bulk forms can be written under a plain header: *(-dirty) patches every object of the container and ## *(inspected) every sub-container. A bare * or ## * with no attribute list keeps what stands and changes nothing. A wildcard that matches nothing raises the warning TERSE907 and changes nothing. A header may contain one wildcard segment.
How a document applies#
A document applies line by line, in order, against one definite state. Each line acts where it stands:
[REMOVED]deletes at its line, so a later declaration of the same name in the same document creates it again.[NEW]and[FILL]see what earlier lines of the same document have already declared.- A query inside a declaration reads the state as of its line.
- A placement resolves against the siblings standing at its line.
# Guild
## Hall(warm)
## Hall [REMOVED]
## Hall(cold)
That document leaves Guild with one Hall(cold).
A line that is syntactically invalid is not a declaration. It and the subtree beneath it are dropped, one diagnostic is raised on that line, and the rest of the document applies:
# Guild
## Bad$Room refused with TERSE028 ('$' is not allowed in a name)
lamp(lit) part of the refused container's subtree, dropped with it
## Yard applies normally
A problem that depends on the standing state, such as an anchor that does not resolve or a rename target that is missing, is reported as a warning. The affected line does what it can, and the rest of the document applies.