Skip to content

Notation cookbook — idioms for modeling with karasu

English(this file) · 日本語

docs/spec/syntax.md is the grammar — every keyword and rule. This cookbook is the missing companion: a compact idiom catalog that answers “how do I express X” for patterns that are reachable from the grammar but not obvious from it. Each entry is a worked snippet you can copy, adapt, and learn from.

It is deliberately short so you can feed it to an LLM alongside syntax.md when reverse-engineering a project (see Reverse-engineering with an LLM), and it doubles as human onboarding. Like any generated model, treat the output as a map, not a spec — verify against the code.

  • With an LLM: paste this file after syntax.md. It shows the model idioms, not just grammar, so it picks the karasu-idiomatic shape instead of inventing one.
  • As a human: skim for the pattern you need; each entry stands alone.

Every entry follows the same shape: When you reach for it, the Pattern (the rule in one line), a minimal .krs, and Why it’s modeled this way.


1. Key/value store (Redis, etcd) — a leaf-less database

Section titled “1. Key/value store (Redis, etcd) — a leaf-less database”

When — you have a store with no meaningful table/collection structure to model: a session cache, a KV/config store, a lock service.

Pattern — declare a database with no leaves, connect a service to it with a node-level edge (Service --> Store), and name the concrete engine in the physical layer with a store unit. Do not freeze a @kv annotation or a new kv kind — a KV store is a database you simply don’t decompose.

system Web {
service ApiGateway {
label "API Gateway"
}
database SessionStore [cache] { // leaf-less: no `table` inside
label "Session store"
}
ApiGateway --> SessionStore "Reads/writes session tokens" // node-level edge
}
deploy "production" {
store "session-kv" {
type "Redis 7" // the concrete engine lives here
realizes SessionStore
}
}

Why

  • Reference at node granularity with an edge, not a resource dot-path. A resource <Db>.<Leaf> reference needs a declared leaf; a KV store has no leaves to name, so writing resource SessionStore leaves it unassigned (a warning, and a node drawn only inside its own usecase, never reaching the system view where the store you meant actually lives). The idiomatic connection is a direct Service --> SessionStore edge — the same way the hato example wires its leaf-less D1 / R2 / Tasks stores.
  • Engine in the physical layer. “Redis 7” is a technology choice. The logical layer stays technology-agnostic; the store { type … } unit records which engine realizes the logical store, so swapping Redis for etcd never disturbs the logical model.
  • No new vocabulary. Annotations (@…) are lifecycle markers (deprecated, experimental), not kinds — @kv is rejected on purpose. A leaf-less database already expresses “a store with no interesting sub-structure”.
  • [cache] says it is not the system of record. Losing the session store logs everyone out; it does not lose business data, which is exactly the test the tag encodes. Tag the role, never the technology — [kv] is rejected for the same reason @kv is (see store role tags).

2. A store that is not the system of record — [index] / [cache] / [analytics]

Section titled “2. A store that is not the system of record — [index] / [cache] / [analytics]”

When — you have a search / vector index (ElasticSearch, OpenSearch, pgvector) that is derived from a system of record, not the source of truth itself.

Pattern — tag the database with [index]; keep the concrete engine in the physical layer.

database SearchIndex [index] {
table documents
}
// physical layer
deploy "production" {
store "search" {
type "ElasticSearch 8"
realizes SearchIndex
}
}

Why — [index] marks a role (a secondary index over the SoR), not a technology, and adds an index badge. A vector DB or ElasticSearch that is itself the system of record stays a plain database (no [index]); a single Postgres that is both SoR and index also stays plain. This is the same “role via tag, technology in the physical layer” discipline as idiom #1 — it avoids minting a vector-store / search kind for every engine. See ADR-1718.

[index] has two siblings on the same axis, both usable on storage as well as database: [cache] for a store you could rebuild (a session store, a CDN origin cache — if losing it loses business data, it is the system of record and takes no tag) and [analytics] for a warehouse / data lake ingested for analysis. No tag means the store is the system of record. See store role tags.

3. Something outside the boundary — the [external] tag

Section titled “3. Something outside the boundary — the [external] tag”

When — a third-party API, a managed SaaS, or a store your system depends on but does not own.

Pattern — suffix the node id with [external]. It applies to service and to the infra kinds database / queue / storage.

service PaymentGateway [external] {
label "Payment gateway"
}
database AnalyticsDB [external] { // a managed third-party store
label "Vendor analytics DB"
}

Why — [external] draws the node with a dashed, gray-toned border so a reader instantly sees the system boundary. External stores are also excluded from the shared-infra-fan-in diagnostic (idiom #4) — sharing a vendor API across services is expected, not a design smell.

When — several services read/write the same datastore.

Pattern — declare the store once; each service’s usecase references the shared leaf with a resource <Db>.<Leaf> dot-path. The resolver aggregates these into service → database edges automatically.

database ArticleDB {
table articles
}
service ArticleDelivery {
domain Delivery {
usecase "Fetch an article" {
resource ArticleDB.articles
}
}
}
service Authoring {
domain Publishing {
usecase "Publish an article" {
resource ArticleDB.articles
}
}
}

Why — two services fanning into one ArticleDB raises the shared-infra-fan-in info diagnostic (never an error — karasu surfaces the coupling without prescribing against it). Note this uses the resource dot-path because the store has a modeled leaf (articles); contrast idiom #1, where a leaf-less store uses a node-level edge instead. See diagnostics.

5. Cross-domain and cross-system references

Section titled “5. Cross-domain and cross-system references”

When — one domain depends on something owned by another domain, service, or system.

Pattern — declare the edge inside the source block; the origin-scope rule binds the source, not the target, so a block may depend on things it does not own. For another system, use System.Node dot-notation (the referenced system renders as a ghost).

system Shop {
service BillingService {
domain Billing {
label "Billing"
Billing -> Contract "Created from a contract" // Contract lives in another service
}
}
// cross-system: PaymentGateway is a separate `system` (imported elsewhere)
OrderService -> PaymentGateway.PaymentService "Request payment"
}

Why — cross-service domain edges are auto-derived into implicit service-level edges on the system view, so the high-level picture stays readable while the detail lives where you wrote it. An endpoint you never modeled is kept and reported as unresolved-edge-endpoint rather than dropped.

When — one system grows too large for a single file, or different teams own different slices.

Pattern — split by facet (per-service files + a shared infra.krs), and stitch them with import. Same-id system / deploy / organization blocks merge (reopen). Import a whole file with import "x.krs", or a single node with import { Node } from "x.krs".

// index.krs — the entry point
import "infra.krs" // shared database / queue / storage
import "reader.krs" // one service per file
import "editor.krs"
system Blog {
label "Blog Platform Demo"
}

Why — the file is not a grouping unit in the model: splitting is purely for authoring ergonomics, and the merged result renders identically to a single file. Keep shared stores in one infra.krs that each slice imports, so every slice also renders standalone. See the multi-file-system example.

7. Cloudflare Workers — from wrangler.toml

Section titled “7. Cloudflare Workers — from wrangler.toml”

When — a serverless Cloudflare Workers app whose physical layer lives in a wrangler.toml. There is no compose / k8s file, so hand-modeling the bindings risks leaking the concrete tech (“D1 (SQLite)”) into logical labels.

Pattern — let karasu translate --from wrangler extract it deterministically. The adapter emits a logical system (engine-neutral infra + the Worker service

  • edges) and a physical deploy where the concrete Cloudflare technology lands in store { type ... }, never in a logical label:
system Hato {
service Hato { label "hato" }
database DB { } // D1
storage EXPORTS { } // R2
queue TASKS { } // Queues
database SEARCH [index] { } // Vectorize — a derived vector index (idiom #2)
database CACHE [cache] { } // KV — a store you can rebuild (idiom #2)
service AI [external] { } // Workers AI — an external model service (idiom #3)
service SessionActor [external] { } // Durable Object — opaque stateful actor
Hato --> DB // owned infra uses -->
Hato -> AI // external / other Workers use ->
Hato -> AuthWorker // service binding = Worker→Worker RPC edge
}
deploy "hato" {
function "hato" { runtime "cloudflare-workers"; realizes Hato }
store DBStore { type "Cloudflare D1"; realizes DB }
store SEARCHStore { type "Cloudflare Vectorize"; realizes SEARCH }
}

Why — the binding→karasu mapping reuses existing idioms rather than minting new syntax: Vectorize → database [index] (idiom #2, a derived index), Workers AI and Durable Objects → service [external] (idiom #3, opaque to this adapter), and a service binding → a -> communication edge, and KV → database [cache] (idiom #2’s sibling role — a store the Worker can rebuild). Unknown binding kinds are skipped with a warning — never guessed. Run karasu translate --from wrangler wrangler.toml > index.krs.

8. Cross-cutting concerns (PCI, PII, auth) — pick the right register

Section titled “8. Cross-cutting concerns (PCI, PII, auth) — pick the right register”

When — you want to mark elements with a compliance scope, a data-classification, or a policy (“these services are in PCI scope”, “this entity is PII”, “this usecase requires login”) and are tempted to invent a tag ([pci]) or an annotation (@requires_auth).

Pattern — labels live in four registers; membership in an externally defined set is not a tag and not an annotation:

You are saying… Register Construct
what the element is (architecturally) archetype builtin tag — [external], [index]
what development state it is in lifecycle builtin annotation — @deprecated, @new
how peers group in a view view grouping boundary
which externally defined set it belongs to membership facet — a top-level declaration plus a facets property on the element

facet is experimental: backward compatibility is not promised yet, and membership shows up through an overlay the reader turns on, so a file renders identically until someone selects a facet.

.krs — the scope goes in a facet; the rule content stays prose + link:

facet pci_scope {
label "PCI DSS scope"
description "In scope for the yearly assessment"
link "https://wiki.example.com/pci/scope" "PCI scope doc"
}
facet requires_auth {
label "Authenticated"
description "Reachable only after login; who may call it is defined by the IAM policy"
link "https://wiki.example.com/policies/iam" "IAM policy"
}
system Shop {
service Checkout {
facets pci_scope
domain Ordering {
usecase PlaceOrder {
facets requires_auth
}
}
}
}

Why — tag / annotation vocabularies are tool-owned: a non-builtin name draws a tag-not-builtin / annotation-not-builtin deprecation warning in v1.x, and syntax v2.0 accepts tool vocabulary only. Membership also semantically misfits tags: a database is a database whether or not it is in PCI scope, and a nine-out-of-ten [pci] diagram reads as a false audit guarantee. The rule content (roles, plans, conditions) stays prose + link permanently (ADR-832) — above it sits in the facet’s description and link. What facet makes first-class is the scope: which elements the rule reaches. If what you actually need is a missing archetype, request a builtin tag addition instead — see tags-annotations.md.

9. An edge that needs more than a verb — the property block

Section titled “9. An edge that needs more than a verb — the property block”

When — the arrow itself carries something worth writing down: a delivery guarantee, a retry rule, a runbook. The label slot is one string and it is already spent on the verb, so the knowledge ends up in a comment nobody renders or in a wiki nobody finds.

Pattern — keep writing A -> B "verb" everywhere it is enough, and open a property block only on the edges that need more. description and link are spelled exactly as they are on a node.

.krs

system Shop {
service OrderSvc {}
service PaymentSvc {}
OrderSvc -> PaymentSvc "charges"
OrderSvc --> PaymentSvc [async] #orderPlaced {
label "places an order"
description "At-least-once delivery. Retries are idempotent on orderId."
link "https://runbook.example.com/order-placed" "Runbook"
}
}

Left-clicking an edge that carries a description or a link opens the edge detail panel with that prose and those links. A label is not enough to open it, so the plain "charges" edge behaves as it always did.

Why — the shorthand is the most-typed construct in the language and stays canonical: karasu fmt folds a block that holds nothing but a label straight back to A -> B "label", so the two spellings cannot drift into two ways of saying one thing. Writing the label both positionally and inside the block is a duplicate-edge-label error rather than a precedence rule. Note what does not belong here: protocol, cardinality and retry counts stay prose inside description, because karasu models structure, not runtime configuration.

© 2026 Hiroki Kondo · Licensed underApache-2.0

Built with Cloudflare