Katomic script — .kat — version 1.0

The rules of the file type. A Katomic script is a plain-text spelling of a v2 deal policy: every line is one node of the JSON, and the JSON — never the script — is what a dealId hashes.

Specification · first version, 20 Sep 2026 (KATOMIC1) · implemented by dealkit/katomic/parse.js and print.js in hbarlive_hts · try it in the editor

media typetext/vnd.katomic; charset=utf-8 — interim text/x-katomic until registered
extension.kat
encodingUTF-8, no BOM; LF line endings, CRLF accepted
compiles toa v2 deal policy (dealkit/v2/policy.js), the document a KEA signer evaluates
identitythe dealId = SHA-256 of the compiled policy in JCS (RFC 8785) form. The script is never hashed.
conformanceparse(print(P)) canonicalises to the same bytes as P for every valid v2 policy P that print accepts, and print(parse(S)) is stable. Held over every published deal by qa/katomic/roundtrip-tests.js.

1. What it is

A projection of the v2 policy JSON, not a format with semantics of its own:

Two scripts that differ only in comments, spacing or the order of their sections compile to the same bytes and are the same deal.

2. Lexical rules

3. Sections

#deal

name     TEXT
network  TEXT

→ name, network (strings, trimmed). v is never written; the compiler emits "v": 2. Both lines are required.

#constants

NAME is VALUE

→ constants[NAME] = VALUE. VALUE is a JSON literal if it begins with ", [ or {, or is exactly true, false or null; a number if it matches -?\d+(\.\d+)?([eE][+-]?\d+)?; otherwise the trimmed text as a string. So 0.0.7718 is a string, 31 is a number, "296" is the string "296". A name may not be defined twice.

#inputs

NAME TYPE

→ inputs[NAME] = { "type": T }. TYPE is one of account (hedera.account), txid (hedera.txid), timestamp (hedera.timestamp), integer, string; the long spellings are also accepted. There is no other input type, because the engine has no other input type.

#observe

NAME = SOURCE PATH [-> SELECT]
    pin: JMESPATH
    attest: JMESPATH

→ observe[NAME] = { source, get, select?, pin?, attest? }. SOURCE is a registry name ([a-z][a-z0-9.]*, e.g. hedera.mirror, xproof.xtxt) — never a URL; the verifier's registry decides where a name resolves. PATH is one token without whitespace and may contain {NAME} substitutions, which are the engine's own. SELECT is a JMESPath taken verbatim to the end of the line. Indented continuation lines: -> … sets or extends select; pin: and attest: set those keys; other indented text extends select if present, else get.

#derive

NAME = query JMESPATH
NAME = EXPRESSION

→ derive[NAME]: with query, the rest of the line verbatim as a JMESPath string; otherwise the EXPRESSION compiled to a JSONLogic object (§4). An indented line continues the previous entry, joined by one space. query exists because the JSON holds two expression languages side by side and nothing about a line's shape reliably tells them apart.

#require

REASON
    EXPRESSION

→ one element { "reason": REASON, "rule": EXPRESSION } of require, in order. REASON is the margin line, verbatim and trimmed; the rule is the next line, indented. A reason without a rule, or a rule without a reason, is an error. Reasons are inside the JSON and inside the hash: a signer's refusal quotes them back.

#transaction

linecompiles to
transfer"type": "TransferTransaction"
type NAME"type": NAME
A sends EXPR TOKENtokenTransfers ← { token: TOKEN, account: A, amount: {"-": [0, EXPR]} }
A receives EXPR TOKENtokenTransfers ← { token: TOKEN, account: A, amount: EXPR }
A sends EXPR hbarhbarTransfers ← { account: A, amount: {"-": [0, EXPR]} }
A receives EXPR hbarhbarTransfers ← { account: A, amount: EXPR }
A sends TOKEN #SERIAL to BnftTransfers ← { token: TOKEN, serial: SERIAL, from: A, to: B }
memo TEXT"memo": TEXT — verbatim to the end of the line, trimmed
operator NAME"operator": NAME
valid from +Ns, for Ms"schedule": { "validStartOffset": N, "validDuration": M }
KEY JSONany other key, its value as one JSON literal

A, B, TOKEN and NAME are symbolic names the engine resolves (constants or inputs). hbar is case-insensitive. SERIAL is a number if all digits, else a name. Legs keep their order within each of the three arrays. #transaction is required.

4. Expressions

scriptJSON
name{"var": "name"}
12 · -3.5 · "text" · true · false · nullthe JSON literal
a OP b OP c (same OP){"OP": [a, b, c]} — one n-ary node
(a OP b) OP c{"OP": [{"OP": [a, b]}, c]} — parentheses nest
not x · !x{"!": [x]}
!!x{"!!": [x]}
-x (x not a number literal){"-": [x]}
f(a, b){"f": [a, b]}

Infix operators, lowest precedence first, all left-associative: or · and · == != · < <= > >= · + - · * / %. Unary binds tighter than any infix. A - directly before a digit, where an operand is expected, is part of a negative number literal; elsewhere it is the operator.

f must be an operation the engine has: digitAfterPoint, tsSeconds, hederaAccount, sha256 (the engine's own), or a json-logic-js built-in (substr in cat if min max merge missing missing_some log). Any other call is a compile error, so a script cannot name an operation no signer could evaluate. The compiler does not check that a name is defined — that is the engine's business at evaluation — though an editor may warn.

5. What the script cannot say

print refuses, rather than mis-print, a policy carrying: a top-level key other than the nine; an input with keys other than type; an observation with keys other than source get select pin attest, or a get containing whitespace; a rule with keys other than reason rule, or a reason that is empty, has leading/trailing whitespace, contains // or starts with #; a transfer leg with extra keys, or a symbolic name containing whitespace; a memo that is not a single-line string; a var whose path is not a plain dotted name; a JSONLogic node with more than one key, or a non-array argument list. None of these occur in any deal published as of this version.

6. Versioning

This is version 1.0 of the script. The version is not written in the file: a .kat file is version 1.0 if it compiles under these rules. A change that lets a valid 1.0 script compile to a different policy is a new major version and gets a new media type parameter (text/vnd.katomic; version=2). Adding a line form for something the v2 policy JSON already holds is a minor version and needs no marking. The policy JSON is versioned separately (v: 2); a v3 policy would need its own script rules.

7. Example

The live testnet klotto ticket deal, dealId 0c516f051d7e…, is exactly this script — open it in the editor:

#deal
name     KLOTTO ticket — COP$ (testnet CO)
network  testnet

#constants
POOL_ACCOUNT  is 0.0.7718
POOL_TOKEN    is 0.0.2340687
LOCK_TOKEN    is 0.0.3626892
LOCK_SERIAL   is 31
STAKE_MIN     is 50000     // smallest units: COP$ has 2 dp, so 500.00
STAKE_MAX     is 1000000

#inputs
player  account
stake   integer
ref     string

#observe
lock_now = hedera.mirror /tokens/{LOCK_TOKEN}/nfts/{LOCK_SERIAL} -> account_id

#require
the pot is open (lock NFT at the pool, no draw in progress)
    lock_now == POOL_ACCOUNT
stake is at least the minimum
    stake >= STAKE_MIN
stake is at most the maximum
    stake <= STAKE_MAX
the player is not the pool
    player != POOL_ACCOUNT

#transaction
transfer
player        sends     stake POOL_TOKEN
POOL_ACCOUNT  receives  stake POOL_TOKEN
memo      klotto ticket CO {ref}
operator  player
valid     from +15s, for 120s

The canonical copy of this document is dealkit/katomic/SPEC.md in hbarlive_hts, next to the implementation. Where the two differ, the one beside the code is right.