Skip to content

.krs Syntax Reference

English (this file) · 日本語

Language version: .krs language v1.0 (frozen — ADR-1314; independent from every package’s npm version — ADR-2124). karasu --version reports the language version a build implements.

@import "default.krs.style"
@import "theme/dark.krs.style" // multiple allowed; later ones take precedence
// Domains not yet assigned to a service (top-level)
domain Payment { label "Payment" }
system ECPlatform {
label "EC Platform"
// service, user, and edge declarations
}

karasu explicitly separates logical structure and physical structure.

Keyword Meaning May contain
system Container showing the relationships between owned/external services and clients service, user, client, domain, database, queue, storage
user A user of the system (human or AI agent) —
client User-delegated software the project itself ships (mobile / web / desktop / cli / device / extension / embed) —
service An independent unit of business capability domain
domain A business-concern boundary (top-level, inside a system, or inside a service) usecase, entity
usecase A business task or operation within a domain resource
resource A target that a usecase reads or writes (table, external API, file, etc.) —
entity A conceptual data entity owned by a domain — a name and its relations, no attributes. Maps to an infra sub-resource with table —

Related TPLs: TPL-2158 — this table is generated from REFERENCE_DATA, so it cannot be used as the independent source a sync test compares that catalog against; the kind and property columns are fenced against the parser instead.

The recognized client form-factor tags are listed below.

karasu’s tag system is intentionally open — any tag is accepted and styles react via selectors. For client specifically, seven names are recognized as form-factor classifications. Icon Mode renders each with a kind-specific icon; layout hints are a future addition. Tags outside this list still parse and behave as ordinary user-defined tags; they simply do not trigger karasu’s built-in form-factor treatment.

Tag Form factor
[mobile] iOS / Android native app
[web] SPA running on the vendor’s own origin
[desktop] Desktop app (Electron, native)
[cli] Command-line tool / SDK shipped to users
[device] IoT / dedicated terminal / KIOSK
[extension] Plugin / extension hosted by another application (browser extension, IDE extension, design-tool plugin)
[embed] Widget / SDK embedded into third-party web content (Stripe Checkout, Intercom, etc.)

Recommended: pick at most one form-factor tag per client. Multiple tags go in one bracket group, comma-separated ([mobile, desktop]); repeating the group ([mobile] [desktop]) is a parse error. Combining unrelated form factors is parseable but conveys no additional architectural meaning.

client is reserved for software the project itself ships. Third-party browsers / IDEs / AI agents that consume the system are modeled as user (typically [human] or [ai]), not client.

handles property — what a client/service exposes to its callers

Section titled “handles property — what a client/service exposes to its callers”

Both client and service may declare a handles property listing domains exposed to callers, each named by a node reference path (see § Node reference path notation) — a bare id or a qualifying suffix like handles Backend.Order. It is a validated cross-reference: the reference must be reachable through a one-hop expose rule, otherwise an unresolved-handles warning is emitted, anchored on the reference that failed. A multi-match is broadcast rather than ambiguity here, so handles has no *-target-ambiguous code (see the notation section).

system Shop {
service Backend {
domain Order {} // self-owned — handles entry not required
}
service Bff {
handles Order // re-export: Order is owned by Backend, reached via the edge below
}
client WebApp [web] {
handles Order // surfaces Order to the end user via the BFF
}
WebApp -> Bff
Bff -> Backend
}

Forms accepted:

client A [web] { handles Order }
client B [web] { handles Order, Catalog, Inventory }
client C [web] {
handles Order
handles Catalog
}

See § Comma-separated value lists for the list grammar every such property shares: one line per list, with a dangling comma reported on the comma.

Expose rule (used by the validator):

A node N exposes the domain a reference D resolves to iff:

  1. N has a child domain whose full path the reference suffixes (self-owned), or
  2. N declares a handles reference naming the same domain, and at least one outgoing communication edge target also exposes it.

The rule is evaluated against the resolved node, not the reference text (#2088): handles Backend.Order and a re-exporter’s bare handles Order chain through the same domain.

delivers and other declarative properties do not count as edges. The rule expands one hop at a time, so each link in a client → BFF → backend chain must be declared explicitly — there is no implicit auto-passthrough.

Infra layer (shared data stores) — rendered on the system view

Section titled “Infra layer (shared data stores) — rendered on the system view”

Some data stores are shared by several services rather than owned by a single usecase. Declare them at the top level of a .krs file (or directly inside a system block) using one of the three infra-block keywords below; each may nest leaf sub-resources. These nodes render on the system view, in the dependency tier next to [external] services — services depend on shared infra, never the other way round. They were promoted to first-class nodes in ADR-316.

Keyword Layer Intended use May contain
database system-level infra block A database shared by services (RDBMS, document store, …) table
queue system-level infra block A message queue / topic shared by services queue-item
storage system-level infra block An object store / blob storage shared by services (S3, GCS, …) bucket
table leaf, inside a database block A table / collection in the database —
queue-item leaf, inside a queue block A message / event type carried by the queue. Written with the queue keyword inside a queue block (parsed internally as queue-item) —
bucket leaf, inside a storage block A bucket / container in the object store —
  • Only label, description, and link properties apply to infra nodes and their sub-resources; all are optional, and omission emits a warning, not an error. The operations CRUD property is not valid here — it is only meaningful on resource declarations inside a usecase (see below).
  • database / queue / storage are valid only at the top level or as a direct child of system. Nesting one inside a service, domain, or usecase is rejected with infra-not-in-context.
  • table / queue-item / bucket are leaf nodes: they accept properties and edges but no nested declarations.
  • A usecase ties one of its resources to a shared sub-resource with dot-notation — resource <InfraId>.<SubResourceId> (e.g. resource OrderDB.OrderTable). The resolver aggregates these references to derive the service → database (and service → queue / service → storage) edges shown on the system view, and may synthesize [read] / [write] tags on the usecase→resource edges — see docs/spec/tags-annotations.md.
  • [external] may be applied to database / queue / storage for a store that lives outside the system boundary (a managed third-party DB, an external event bus, …).
  • [index] may be applied to a database to mark it as a derived search / secondary index — a store derived as an index to search the system of record quickly — and adds an index badge. It denotes a role, not a technology: a vector DB / ElasticSearch that is itself the system of record stays a plain database (no [index]). The concrete engine stays in the physical layer (store { type "ElasticSearch 8"; realizes SearchIndex }). See tags-annotations.md.
  • Writing resource OrderTable without a matching database block is allowed, so you can discover resources bottom-up while sketching a usecase, then group them into a database block and switch to the dot-notation reference. For as long as the id resolves to nothing at all (no dot-notation ref, and no unique entity of the same name), it warns unassigned-resource and is drawn, but only inside its own usecase’s drill-down view: it is not promoted to a sibling node in the domain view, because promotion is what resolving the reference buys. Declaring a matching entity promotes it and clears the warning with no edit to the usecase, database block or not (see entity declaration). Sketching bottom-up does give visual feedback, then, just one level deeper until the id resolves.
  • The infra-block keyword table (a database leaf, declaring the shared node) and the shape tag [table] (a usecase resource’s draw-shape) are related, not the same. A usecase references an infra leaf with a resource via the dot-notation above, and karasu infers the shape tag from the referenced infra sub-resource kind — table → [table]/cylinder, queue-item → [queue], bucket → [storage] — so the reference is drawn in the same shape as the store it points to. The keyword declares the node’s kind; the [...] tag is a suffix that sets only a resource’s shape (and may also be written by hand). The same word in two positions never collides. See tags-annotations.md for the full guidance.
system ECPlatform {
service ECommerce {} // domains / usecases omitted for brevity
database OrderDB {
label "Order DB"
table OrderTable { label "Orders" }
table ProductTable { label "Products" }
}
queue OrderEvents {
queue OrderPlaced { label "Order placed" } // declared with `queue`, parsed as a queue-item
}
storage MediaStorage {
bucket ProductImages { label "Product images" }
}
database ProductSearch [index] { // derived search index, not the SoT
label "Product Search"
table Products { label "Indexed products" }
}
}

Related TPLs: TPL-1415 — the infra-sub-kind → shape-tag inference (INFRA_SUB_KIND_TO_TAG) and the shape-tag table are two representations of one vocabulary that must stay in sync. TPL-2200 — a claim that something “is rendered” names the view level it is rendered at, and both sides (the level it is promoted to, the level it stays in) are fenced; the unassigned-resource bullet above said only “rendered as an orphan node” and drifted for months (#2200).

Store-scoped ER view (entity relations projected onto a database canvas)

Section titled “Store-scoped ER view (entity relations projected onto a database canvas)”

Drilling into a database shows its table leaves. That canvas also draws the relations between those tables, derived at render time from the entity layer: an entity relation whose both endpoints carry a table <ThisDb>.<leaf> mapping into the same store is drawn as a leaf-to-leaf edge on that store’s canvas. Nothing is written to the .krs for this. The projected edge keeps the relation’s label and its -> / --> kind, and carries the system-assigned [projected] tag, which colours it (see tags-annotations.md); line style stays owned by [sync] / [async].

The recorded side. A table leaf may declare edges of its own (table orders { orders -> customers }), and translate --from db writes one edge per distinct source-target pair it finds, inside the emitted database block: a declared REFERENCES / FOREIGN KEY becomes an untagged edge, a Soft FK (a <stem>_id / <stem>_code column naming another table) becomes orders -> products [inferred], and under the default aggregate granularity a folded child’s foreign keys roll up to its root with no self-edge. Several foreign keys to the same target are one edge at either granularity (two customer_id columns on orders do not draw two arrows to customers), and the pair is untagged as soon as one contributing foreign key is declared. This is what makes the view useful with no entity layer at all: a schema dump gets an ER view straight out of translate. A foreign key whose target is not in the dump is not recorded.

The marks on the canvas are therefore three states on one axis, who confirmed the relation, never which tool wrote the line:

Mark Meaning Where it comes from
untagged confirmed written by hand, or emitted from a declared foreign key
[inferred] guessed from a column-name convention translate --from db (Soft FK); delete the tag once confirmed
[projected] asserted by the entity layer assigned at render time, never in .krs

Union rule. When the record and the projection produce the same ordered pair, the canvas draws one edge, the recorded one, and it takes only what it lacks: the relation’s label transfers when the record has none (a written label wins), while the record’s -> / --> kind always stands, so a --> relation over a -> record stays solid. When they disagree on direction (recorded A -> B, projected B -> A) only the recorded side is drawn and the label does not move, since "belongs to" read backwards would be false. A recorded edge whose target is a leaf of another database is not drawn on either canvas; it gets the ordinary edge-endpoint-not-at-scope warning, since a table’s peers are its own store’s leaves.

The diff is reported, not only drawn. karasu coverage compares the two sets per database and reports four lists of ordered {from, to} leaf pairs (the same shape as unmappedButReferenced, in the physical section and in --format json): recordedWithoutProjection (the store states a relation the logical model lacks: mechanically repairable), projectionWithoutRecorded (application-level integrity: a fact, not a defect), directionMismatch and kindMismatch (the two disagreements the canvas resolves toward the recorded side, kept visible only here). The axis is recorded-vs-projected, not FK-vs-app-level: the first list holds every recorded relation the entity layer lacks, [inferred] Soft-FK edges included, and the report does not compare against the DDL.

Endpoint resolution is the entity view’s: the relation must start at the entity that declares it, a bare target is intra-domain only, and a qualified DomainId.EntityId target resolves within the owning system. A relation the entity view drops is not projected either.

This view is lossy and is not a complete ER diagram of the store. It shows exactly the relations that travel through a table mapping on both ends:

  • a relation touching an entity with no table mapping does not appear (tableless entities are a legitimate state, so this is not a defect; coverage reports them as tablelessEntities);
  • a relation whose endpoints map into two different stores appears on neither canvas (it is already visible as a service → database edge on the system view);
  • a polymorphic reference (one column, several possible target tables) is whatever the entity layer chose to write.
system Shop {
service OrderService {
domain Ordering {
entity Order {
table OrderDB.orders
Order -> LineItem "has" // projected: orders -> line_items
Order --> Customers.Customer "placed by" // projected, dashed: orders --> customers
Order -> AuditEntry "audited by" // not projected: AuditEntry is tableless
}
entity LineItem { table OrderDB.line_items }
entity AuditEntry {}
}
}
service CustomerService {
domain Customers {
entity Customer { table OrderDB.customers }
}
}
database OrderDB {
table orders {}
table line_items {}
table customers {}
}
}

Related TPLs: TPL-2585 — a derived view that travels through an optional mapping counts what did not project (coverage) and states in the spec that it is not a complete diagram. TPL-510 — [projected] is colour only; the [sync] / [async] line style of the source relation is preserved. TPL-1936 — the projection resolves endpoints with the entity view’s rules, so a bare cross-domain id is not projected and a qualified one is. TPL-1944 — a recorded table edge is [inferred] only when every contributing FK is a Soft FK; one declared FK leaves it untagged.

Organizational structure (who owns what) — rendered as a separate diagram

Section titled “Organizational structure (who owns what) — rendered as a separate diagram”

An independent axis from logical/physical, describing the ownership of services and domains. organization is the root, with nested team declarations. Each team lists the nodes it owns via owns and may contain member entries.

Keyword Meaning May contain
organization Root of an organization. Multiple declarations allowed team
team A team with responsibility. May be nested team, member, owns
member An individual belonging to a team —

A related grouping overlay — boundary (experimental) — lets an author declare semantic clusters within the system view, drawn as a second “Group by” axis alongside team ownership. See § Grouping the system view (boundary).

Physical structure (how) — rendered as a separate diagram

Section titled “Physical structure (how) — rendered as a separate diagram”

Deployment units are declared inside a deploy block using a kind keyword. All properties are optional. When omitted, a warning is emitted rather than an error.

Keyword Description Properties
war WAR / EAR (Servlet / EJB container) runtime, realizes
jar Executable JAR (e.g. Spring Boot) runtime, realizes
oci Container image image, runtime, realizes
lambda AWS Lambda runtime, realizes
function Azure Functions / Google Cloud Functions runtime, realizes
assets Static files / SPA (served via CDN) runtime, realizes
job Batch job. Without schedule: one-shot; with schedule: recurring runtime, schedule, realizes
artifact Any kind not covered above type, runtime, realizes
store Managed data store realizing a logical infra node (Aurora PostgreSQL, Amazon SQS, S3, …) type, realizes

<kind> <id> [<tags>] @<annotation> [{ <properties> <child-nodes> }]

id is required. Tags, annotations, and the body block are optional.


Properties are written inside the body block { }. Properties come before child nodes and edges.

Property Syntax Applicable kinds Description
label label "<display-name>" All Display name on the diagram. Defaults to the id when omitted
description description "<text>" All Description text (use """...""" for multi-line)
role role "<role-name>" user Actor archetype, or a short one-line description of what this user does. Not an authz primitive (no requires role = ... predicate, no RBAC permission bundle) — see ADR-832 and ADR-1281
delivers delivers <ClientId>[, <ClientId>...] service Client(s) this service ships (BFF / SSR pattern). The renderer draws each entry as a distinct dashed edge from the service to the referenced client
link link "<URL>" "<label>" All Link to related documentation (multiple allowed). Label is optional
resource resource <storageKind> "<name>" client Operation-tied local storage on the client. Multiple allowed. See client resource kinds below
capability capability <name> or capability <name> { label "..." description "..." } client Device / browser capability the client requests (camera, geolocation, notification, etc.). Multiple allowed. See client capabilities below

All properties are optional. link may appear multiple times within the same node. Using a property on a kind that does not support it produces an error.

A link URL should be an absolute http: / https: / mailto: URL. Any other scheme (e.g. javascript:) or a relative path emits a link-url-scheme-not-allowed warning. The link is kept in the model (so formatting does not delete it from your source), but preview panels — which render link URLs as clickable <a href> — only show http: / https: / mailto: links, since a javascript: href would execute in the app origin.

Related TPLs: TPL-168 — 外部から来る input は trust boundary を越える前に validate / canonicalize する

A quoted string value ("...") recognises exactly three escape sequences:

Sequence Produces
\" a literal double quote
\\ a literal backslash
\n a newline

Any other \<char> yields the bare character — \t is a literal t, not a tab. Characters needing no escape (including a carriage return) may appear verbatim; a string literal is not restricted to a single line.

service Search {
label "say \"hi\""
description "first line\nsecond line"
}

A triple-quoted string ("""...""") is raw: no escape processing happens inside it, and it ends at the first """. It exists so Markdown can be written verbatim (see ADR-9008), with the indentation of the closing """ stripped from every line. A value that must itself contain """ therefore has no triple-quoted form and must use the single-line form with \n escapes — which is what karasu fmt emits for such a value.

karasu fmt and karasu translate both escape on emit, so any value the lexer accepts survives a round trip unchanged.

Related TPLs: TPL-1101 — コードを変換する機能では parse(format(x)) ≡ parse(x) の round-trip を保証する

user <id> [<human|ai>] {
label "<display-name>"
role "<role-name>"
link "<URL>" "<label>"
}
  • The tag [human] / [ai] distinguishes human users from AI agents.
  • role describes the actor archetype or what this user does within the system (a short one-line label or sentence). It is not an authz primitive: it does not represent a RBAC permission bundle, and karasu does not introduce a requires role = ... predicate or similar authz construct (see ADR-832 and ADR-1281). To document who may execute a usecase, use the usecase’s description and a link to an external policy document.
  • Properties and the body block { } are optional.
service <id> {
label "<display-name>"
link "<URL>" "<label>"
link "<URL>" "<label>"
domain <domainId> {
label "<domain-name>"
...
}
}
client <id> [<form-factor-tag>] {
label "<display-name>"
description "<text>"
resource <storageKind> "<name>"
resource <storageKind> "<name>"
}

resource <storageKind> "<name>" declares operation-tied local storage on a client (a localStorage key, an IndexedDB database, an OPFS file, etc.). Multiple resource lines are allowed and render inline on the client card.

The <storageKind> must be one of the six reserved values below. Any other kind is rejected with client-resource-invalid-kind so that authentication credentials, cookies, and device capabilities (which need stronger modeling) do not silently slip into the storage list.

Storage kind Typical surface
localStorage Browser localStorage key
sessionStorage Browser sessionStorage key
indexedDB IndexedDB database
opfs Origin Private File System file/directory
file Local filesystem file (desktop / CLI / device clients)
keychain OS keychain / Keystore entry (excluding raw credentials — modeled separately)

Cookie / session / credential storage is intentionally out of scope here and is tracked under the security parent issue (#834). Device capabilities (camera, geolocation, etc.) are tracked under #837.

client WebApp [web] {
label "Customer SPA"
resource localStorage "preferences"
resource indexedDB "outbox"
}

Rendering: the SVG card shows a single ×N count chip (package vector glyph, data-meta-glyph="package") count badge instead of one row per resource (so card height stays bounded as the list grows). The full list — kind and name in declaration order — is surfaced in the NodeDetailPanel “Storage resources” section. See AT-0069.

capability <name> declares a device or browser capability the client requests (camera, geolocation, notification, bluetooth, …). Capabilities are conceptually distinct from resource: a resource is storage the client reads/writes, a capability is a feature the OS / browser must grant permission for. The recommended capability set is documented in docs/spec/tags-annotations.md.

Two forms are supported:

client OrderClient [mobile] {
// Short form — flat 1 line for capabilities that need no annotation
capability notification
// Block form — when "why this capability" matters for review / threat
// modeling, attach a label and / or description
capability camera {
label "QR scanning"
description "Used to scan QR codes attached to inspection items"
}
capability geolocation {
description "Continuous tracking during delivery"
}
}

Capability identifier set is open: any kebab-case identifier is accepted. Names outside the recommended set parse without warning so that domain-specific capabilities (industry devices, internal-only features) can be expressed. The validator emits client-capability-duplicate when the same capability name is declared more than once on the same client.

Rendering: the SVG card shows a single ×N count chip (lock vector glyph, data-meta-glyph="capability") count badge mirroring the resource badge so the card height stays bounded. The full list (with label / description) surfaces in the NodeDetailPanel. See AT-1002.


system ECPlatform {
label "EC Platform"
user Customer [human] {
description "A general user who purchases products"
}
user Admin [human] {
description "An operator who manages the system"
}
service ECommerce {
label "EC Site"
description "Product management and order processing"
}
service Payment [external] {
label "Payment Service"
description "Credit card payment processing"
}
service Inventory [external] {
label "Inventory"
description "Inventory data management"
}
Customer -> ECommerce "Place an order"
ECommerce -> Payment "Process payment"
ECommerce --> Inventory "Sync inventory"
}

user declarations and edges are only valid inside a system block — a user actor and a relationship both belong to a system’s boundary. Writing either at the top level of a file is a parse error (top-level-declaration); the parser reports it and skips the offending construct. (By contrast, domain and the infra blocks database / queue / storage may sit at the top level — see their sections.) This rule is catalogued in the diagnostics & rules reference.

The asymmetry with top-level infra is deliberate: shared infra is a single thing referenced by many systems (one top-level identity), whereas a user models an actor’s relationship with a particular system — its role is defined within that system. So the same person interacting with two systems is two user nodes, linked only by a shared id (by convention). A cross-system shared actor / persona is intentionally left as a possible post-v1.0 extension, out of scope here (see #1639).

Related TPLs: TPL-2171 — a spec-promised placement rule must have a dedicated diagnostic code, not fall through to a generic parse error.

The May contain column of the Logical structure table is the single definition of which children a kind may hold. Nesting a logical node anywhere else emits the node-not-in-context warning: the node is kept and still renders, it simply carries no defined meaning there. Only the listed nestings have semantics — docs/concepts.md fixes the hierarchy as service → domain → usecase → resource, so a usecase written directly inside a client has nothing to mean.

It is a warning rather than an error because .krs language v1.0 is frozen (ADR-1314): a file that parses today keeps parsing. Promotion to an error is registered to .krs language v2.0 (roadmap §Syntax 2.0) — the same warning-in-v1.x / error-in-v2.0 path the tag and annotation vocabularies take.

Four nestings are rejected outright rather than warned, and the misplaced node is dropped:

Rejected nesting Diagnostic Why it is an error, not a warning
an infra block outside system infra-not-in-context the block has no system to belong to
an entity outside a domain entity-not-in-domain an entity is owned by exactly one domain
a boundary inside a kind that draws no canvas boundary-not-in-context there are no peers to frame
any node inside an entity unexpected-token-in-block an entity carries a name, its relations and a table mapping — never attributes

A domain may be written at the top level, directly inside a system, or inside a service. The first two both express a domain that is not (yet) assigned to a service, and both raise the unassigned-domain warning: the author picks the spelling, not the meaning (#2184).

Rendering is where the two forms legitimately differ. The (Unassigned) pseudo-system (ADR-681) wraps only the top-level form, because it exists to give a container to nodes that have none — a system-nested domain already renders inside its system. Diagnosing “not assigned to a service” and framing “has nowhere to be drawn” are separate concerns, so only the first is symmetric across the two placements.

Related TPLs: TPL-2165 — the containment rule has exactly one definition (canContain), and the parser is what enforces it. TPL-2184 — placements that express the same modelling state carry the same diagnostic, whichever spelling the author picked.

The internals of a service are decomposed into domains. If the same domain spans multiple services, the tool emits a warning (a design-problem signal).

service ECommerce {
label "EC Site"
domain Order {
label "Orders"
usecase PlaceOrder {
label "Accept an order"
resource OrderTable {
label "Order table"
}
resource InventoryAPI [external] {
label "Inventory API"
}
}
usecase CancelOrder {
label "Cancel an order"
}
usecase QueryOrder {
label "Query order status"
}
}
domain Purchasing {
label "Purchasing"
usecase OrderFromSupplier {
label "Place an order with a supplier"
}
usecase CheckPurchaseStatus {
label "Check purchase status"
}
}
}

A service may declare which client node(s) it ships, modeling the BFF / SSR pattern (Next.js, Rails+React, Laravel+Vue, etc.). The server-side and the browser-side bundle are different OAuth2 client types and are modeled as separate nodes joined by delivers:

service NextServer {
label "Next.js BFF"
delivers WebApp // single client
}
service Gateway {
delivers WebApp, AdminUI // comma-separated list
}
client WebApp [web] {}
client AdminUI [desktop] {}

See § Comma-separated value lists for the list grammar every such property shares: one line per list, with a dangling comma reported on the comma.

Each delivers entry synthesizes a dashed edge from the service to the referenced client on the system view. The target id must resolve to a peer client node; if it does not, the resolver emits a delivers-target-not-client warning. delivers is a declarative property — it is not a new edge kind, and regular API calls between client and service are still written with ->.

operations property — CRUD verbs a usecase performs on a resource

Section titled “operations property — CRUD verbs a usecase performs on a resource”

Inside a usecase, a resource may declare operations to record which CRUD-style verbs the usecase performs on that resource. This makes the usecase × resource matrix explicit (write vs. read-only) for domain analysis, coupling detection, and translate-adapter round-tripping.

usecase PlaceOrder {
resource OrderTable {
label "Order table"
operations create, read
}
resource InventoryAPI [external] {
operations read
}
}

Forms accepted:

operations create // single verb
operations create, read // comma-separated list
operations create
operations read, update // multiple lines accumulate

See § Comma-separated value lists for the list grammar every such property shares: one line per list, with a dangling comma reported on the comma.

The operations property is only valid for resource declarations inside a usecase. It is not meaningful on infra-side declarations (table / queue-item / bucket — see the “Infra layer (shared data stores)” section above).

Operation Meaning
create The usecase produces new items in the resource (write)
read The usecase consumes the resource non-destructively
update The usecase mutates existing items in the resource (write)
delete The usecase removes items from the resource (write)

Verbs outside this set still parse and are preserved on the AST so translate adapters (translate openapi / translate db) can round-trip non-CRUD operations such as list, search, or execute. The parser emits an unknown-resource-operation warning pointing at the offending verb. Duplicate verbs raise a duplicate-resource-operation warning and are deduplicated on the AST.

Omission semantics: when operations is omitted, behavior matches today — the dependency is opaque and no warning is emitted. This preserves the “not-yet-decided” tolerance documented under §Property requirement and omission rules.

Custom verbs that carry domain meaning can be annotated with their CRUD intent using the <verb>:<crud>[,<crud>...] decoration. This lets authors keep their natural vocabulary while still feeding the CRUD matrix view and write-dominates classifier.

operations list:read, search:read // 1:1 mapping
operations enqueue:create, dequeue:delete // queue idioms
operations replace:create,delete // physical delete-insert (1:N)
operations create, list:read // mix decorated + bare

Behavior:

  • The right-hand side accepts only recognised CRUD verbs (create / read / update / delete). Any other identifier raises invalid-crud-decoration (error).
  • An empty right-hand side (list:) raises empty-crud-decoration (error).
  • A duplicated CRUD verb on the right (replace:create,create) raises duplicate-crud-decoration-target (warning) and is deduplicated.
  • A decorated verb does not raise unknown-resource-operation, even if the verb itself is outside the recognised set — the decoration is the author’s CRUD declaration.
  • The CRUD matrix view (ADR-1062) reads decoratedAs first when computing cell letters, ΣC/R/U/D totals, and the write-dominates flag. A decorated verb never produces a ? suffix.

Disambiguation rule for 1:N + multiple verbs on one line: once the parser sees verb:, the comma-separated identifiers that follow are CRUD-RHS continuations until the next <id>: boundary. So search:read,create, list:read parses as search:[read,create] then list:[read]. To express a bare verb after a decorated one, place the bare verb earlier in the list (create, list:read).

Usage guidance — when to use 1:N: reserve verb:create,delete for genuine physical delete-insert idioms (REPLACE INTO, soft-delete + new row, Kafka tombstone + new key). For logical in-place rewrites of the same entity, use update instead. Tools do not enforce this distinction — it is a documentation convention.

Section titled “Authorization notes — write them as description + link”

ADR-832 decided that karasu does not model usecase-level authorization (role / license / plan / scope predicates) in its vocabulary. The structural language describes what exists and how it relates; who may call a usecase at runtime is left to the canonical policy doc or IAM tool (OPA, Cedar, Casbin, internal RBAC docs, etc.).

To keep that prose from drifting into ad-hoc vocabulary across teams, write authz notes on a usecase using this pattern:

usecase RefundOrder {
label "Refund an order"
description "Access: admins and billing operators only. See policy link for the exact rule."
link "https://policy.example.com/refund-order" "Authorization policy"
}

Convention:

  • Start the relevant sentence in description with Access: (English) or アクセス: (Japanese) so a reader scanning the diagram can recognise the constraint at a glance. Keep it to one short sentence — the description is a hint, not the rule.
  • Add a link whose label contains Authorization policy (or Policy) and whose URL points at the canonical policy doc / IAM rule. The link is authoritative. When the prose and the link disagree, the link wins; readers should treat the description as out-of-date.
  • Do not invent attributes (role: admin, requires: billing.write, etc.) inside description. If a constraint cannot be summarised in one sentence, that is a signal the constraint belongs in the policy doc, not in the model.

Tools do not enforce or render this convention — there is no Access: badge, no policy-link decoration, no validator. It is a prose contract between authors so the same constraint is recognisable across files and teams. If you need a machine-checkable gate, that need is explicitly out of scope (see ADR-832).

A domain can be declared at the top level of a file, not only inside a service. Domains that do not belong to any service are treated as “unassigned” and displayed on the system view. The compiler emits a warning for unassigned domains.

// Domains whose service assignment has not been decided yet
domain Payment { label "Payment" }
domain Inventory { label "Inventory" }
system ECPlatform {
service ECommerce {
// Domain assignment to be decided later
}
}

Use cases:

  • Listing domain concepts early in the design phase.
  • Temporarily “parking” domains during a service reorganization.

entity declaration — conceptual domain entities

Section titled “entity declaration — conceptual domain entities”

An entity is a conceptual data entity owned by a domain (declared as a domain child). It captures what onboarding readers want from a domain: which entities exist, how they relate, and who owns them (implied by the parent domain). It is deliberately not a schema: an entity carries a name, relations, and an optional physical mapping — never attributes (columns, types, indexes). This “no attributes” line is what keeps entities on the slowly-changing structural side of the DB-schema non-goal (see docs/concepts.md → Non-goals). Physical schema stays out of scope; the conceptual entity–relationship layer comes in.

service OrderService {
domain Ordering {
entity Order {
label "Order"
table OrderDB.orders // optional physical mapping (dot notation)
Order -> LineItem "line item" // intra-domain relation (bare id)
Order -> Customers.Customer "placed by" // cross-domain relation (qualified DomainId.EntityId)
}
entity LineItem {}
entity Payment {}
}
}

Physical mapping — table <InfraId>.<subId>. An entity may map to one infra sub-resource with dot notation (table OrderDB.orders). The mapping is optional — an entity with no mapping is the legitimate forward-design / bottom-up state. Only the dot form is accepted in v1; a bare table orders raises expected-id-after.

Relations — one association, one edge. Relations between entities use the existing edge syntax (-> sync, --> async) declared inside the reference-holding entity’s block. Unlike dependency edges between services / domains, an entity relation is a single fact read in both directions, so it is written once, on the side that holds the reference:

  • Order -> LineItem means Order holds the reference (AR: Order belongs_to :line_item; physically orders.line_item_id). The reverse navigation (LineItem has_many :orders) is implied — do not write a second edge.
  • The edge origin scope rule applies: an explicit relation inside entity Order { … } must start at Order; writing Customer -> Order there raises edge-source-mismatch. This is how the direction rule (origin = the reference holder) is enforced.
  • Cardinality tags ([n:1], [n:m]) and roll-up of entity relations into domain-level edges are out of scope for v1 — relations are label-only for now.

Intra- vs cross-domain targets — bare id vs DomainId.EntityId. A relation target is resolved by scope:

  • A bare id (Order -> LineItem) is an intra-domain relation — it must name an entity owned by the same domain.
  • A cross-domain relation references the target with a qualified DomainId.EntityId (Order -> Customers.Customer), the same dot-notation used elsewhere for crossing a boundary (table OrderDB.orders, cross-system SystemId.ServiceId). Qualification is required because entity ids are only warning-level unique (see Anchor namespace below), so a bare id cannot disambiguate a foreign entity. DomainId is error-level unique within a system, so DomainId.EntityId resolves unambiguously within the owning system. Cross-system entity references are out of scope in v1: resolution is scoped to the domain’s own system, so a DomainId.EntityId naming a domain in another system does not resolve (and is dropped from the entity view).

In the per-domain entity view, a qualified cross-domain target is drawn as a muted ghost of the foreign entity (both directions: this domain’s entities → foreign, and foreign → this domain’s entities), sub-labelled with its owning domain. A bare id that does not match a local entity is not resolved cross-domain — it is dropped from the entity view (write it qualified to surface the relation).

Related TPLs: TPL-1936 — a cross-domain entity relation must use a qualified DomainId.EntityId target; a bare id is intra-domain only and is dropped (not ghosted) across a domain boundary.

Placement. entity is valid only as a domain child. Declaring one elsewhere raises entity-not-in-domain and the stray entity is dropped.

Anchor namespace. Entity ids and domain ids share one deep-link namespace (the entity view token). An id claimed by two of them — an entity id duplicated across domains, or an entity id equal to a domain id — raises the entity-anchor-collision warning (it degrades deep-link addressability but does not stop rendering). See docs/spec/diagnostics.md.

A usecase resource resolves to an entity — the canonical logical form. A bare resource <id> inside a usecase resolves to the entity of the same id when exactly one such entity exists model-wide (ids form a flat namespace, so the match may live in another domain / service). This logical reference is the canonical form; the physical dot-notation (resource OrderDB.orders) stays valid as a bottom-up intermediate state.

  • Zero-edit promotion. A bare resource Order with no matching entity warns unassigned-resource. Declaring entity Order { table OrderDB.orders } anywhere in the model promotes the reference with no edit to the usecase — the warning disappears. The check is model-wide (the resolver, not the parser), which is what makes cross-declaration promotion possible.
  • Transitive derivation. The resolver follows usecase → entity → table → database to derive the same service → database edge (and [read] / [write] tag synthesis) it derives for a physical dot-notation reference. An entity with no table mapping resolves logically but derives no store edge (the forward-design state) — and still does not warn.
  • No double counting. A physical direct reference and an entity-mediated reference that reach the same store are counted once — derivation keys on the resolved store, the same exclusion class as an explicit edge suppressing the derived one.
  • Ambiguous stays unresolved. A bare id matching more than one entity does not resolve (it keeps the unassigned-resource warning); the root-cause collision is reported by entity-anchor-collision.

Related TPLs: TPL-1882 — an entity accepts only name / relations / physical mapping; attribute-like declarations (columns, types) must be rejected, keeping the model on the structural side of the DB-schema non-goal. TPL-1720 — the resource→store target set is consumed by several resolvers (deriveInfraEdges, detectSharedInfraFanIn, detectUnassignedResources); adding entity as a resolution target must be synchronized across all of them. TPL-2170 — an unresolved bare resource / a cross-domain entity relation must not drop the node that did resolve. TPL-510 — read/write tag synthesis on the entity-mediated usecase→resource edge preserves the source resource’s operation semantics.

Domain ownership of an infra leaf — cross-domain store access

Section titled “Domain ownership of an infra leaf — cross-domain store access”

An entity mapping (entity Order { table OrderDB.orders }) does more than derive a store edge: it makes the mapped infra leaf owned by the entity’s domain. Ownership is thus derived from the logical layer, never declared on the physical table — the physical side stays domain-free, preserving the logical/physical separation. Ownership is keyed at leaf granularity (OrderDB.orders), not the whole database, because sibling tables in one store can belong to different domains. A leaf may be owned by more than one domain (each domain whose entity maps it) — a legitimate co-ownership fact.

When a usecase in one domain reads/writes a leaf whose owner set does not include that domain, the compiler emits the cross-domain-store-access info diagnostic — a boundary-crossing fact (legitimate under a shared kernel or during migration), not a defect. It is scoped per system, excludes [external] / [index] stores, and carries the aggregated read/write mode. It is orthogonal to shared-infra-fan-in: that keys on how many services share a store; this keys on crossing an ownership boundary. A purely physical model with no entity mapping a leaf leaves the leaf unowned, so no diagnostic fires (adding an entity later promotes it with zero edits to the usecase). See the diagnostics reference.

Related TPLs: TPL-1967 — infra-leaf domain ownership is derived from the entity layer (never declared on the physical table), keyed at leaf granularity, held as a set of owning domains, and scoped per system. TPL-1415 — the same fact must not gain a second representation the two would have to keep in sync.

<from_id> -> <to_id> "<label>" // sync (solid-line arrow)
<from_id> --> <to_id> "<label>" // async (dashed-line arrow)

Edges can be written inside system, service, and domain blocks.

Edge origin scope. An edge declared inside a service or domain block originates from that block. The implicit form -> <to_id> takes the enclosing block id as its source, and an explicit <from_id> -> <to_id> must name that same enclosing id; naming any other source raises an edge-source-mismatch error (for both -> and -->). Edges inside a system block may use any declared node as their source. This rule and its diagnostic are catalogued in the diagnostics & rules reference.

A rejected declaration is kept in the AST so the rest of the block still parses, but it draws on no view — the error is the whole signal, and moving the edge to the block it starts from is what makes it appear. Drawing it would give one relation a second spelling: service S1 { S2 -> S3 } would render exactly like the S2 -> S3 the rule actually asks for, written inside S2.

Cross-boundary dependencies. The rule binds the source, not the target, so a block can still depend on things it does not own:

  • On another service’s domain — keep your block as the source: Billing -> Contract, where Contract is a domain of a different service (see Edges inside a domain block).
  • On an external service — declare it [external] and draw the edge to it: ECommerce -> Payment with service Payment [external].
  • On something not modelled — the edge is kept and the dangling endpoint is reported (unresolved-edge-endpoint, see §S6), rather than rejected.

To express an inbound dependency whose source you do not own (an external or other-team service pointing into your block), model that source as an [external] node and declare the edge at system scope, where any source is allowed — the edge stays co-located with its source.

An edge is drawn on the view where its endpoints stand side by side — the view of the block it is declared in (system), or the view that draws that block as a node (service / domain / entity). How far an endpoint may reach is decided by structure, not by spelling: a bare id names something standing beside the edge, and a path names a system and descends into it.

Write peers(C) for the nodes standing beside a block C:

  • inside a system block — that block’s own children (plus top-level domains, which the root view splices in beside them);
  • inside a service / domain / entity block — that block and its siblings.

Then:

  • a bare endpoint must be in peers(C);
  • a qualified endpoint must spell the whole path from a top-level system down to the target.

A top-level block declared beside the systems (a bare domain, an orphan service) is drawn on no system’s frame, so a path cannot be rooted at one — there would be nothing to draw the target inside.

To point inside a system you name that system and descend from it, at any depth. That is the two-segment OtherSystem.Service form generalised along depth, which is why it works from any block and why lifting the two-segment cap changed no existing model:

system Shop {
service Checkout {
domain Payment {}
}
}
system Portal {
service Web {
-> Shop.Checkout.Payment // ✓ descends from Shop, a top-level root
}
}

Peers are counted per block, after imports are merged. Reopening a system in another file unions the children into one block (§S3), so an edge may name a peer that another file declared. Two system blocks with the same id in one file are not merged — they stay separate blocks with separate peers.

Naming an endpoint that sits below that scope leaves the edge on no view at all. The most common shape is a domain dependency hoisted up to system scope:

system Shop {
service OrderSvc {
domain Ordering { usecase Place {} }
domain Billing { usecase Charge {} }
}
Ordering -> Billing // ✗ renders nowhere — reported as edge-endpoint-not-at-scope
}

Write it anchored at its source instead, which is also what the origin-scope rule above asks for:

domain Ordering {
usecase Place {}
-> Billing // ✓ renders on the OrderSvc service view
}

Three placements are deliberately not reported, because they do render: a domain → domain edge at any distance (a cross-service one is derived up to an implicit service edge, see Edges inside a domain block), a service-anchored edge naming a peer of that service (see Edges inside a service block), and a cross-domain entity relation written with a qualified DomainId.EntityId target. A bare cross-domain entity target is intra-domain only, so it is dropped and reported.

A qualified endpoint is reported under the same code when it is not anchored — Checkout.Payment names a node whose path starts at Shop, so the reference is a fragment rather than a path. The message then asks for the anchored spelling (Shop.Checkout.Payment) instead of asking for the edge to be moved, and names where the target is declared. Anchoring is what keeps every accepted form drawable: a fragment resolving inside the declaring system would name a node no ghost frame can hold, and the edge would land on no view.

A relation inside an entity block is not judged here — the entity view resolves those against the domains of one system and draws the foreign entity as a ghost.

When a qualified endpoint reaches two or more nodes that differ in kind or depth, edge-target-ambiguous lists the candidates so the author can qualify further; a multi-match uniform in (kind, depth) is intentional broadcast and stays silent. Two system blocks sharing an id in one file are not merged, so one anchored path can genuinely name two nodes.

An endpoint that resolves nowhere is a different case, reported as unresolved-edge-endpoint (bare) or cross-system-ref-unresolved (qualified). All of these diagnostics are catalogued in the diagnostics & rules reference.

Related TPLs:

  • TPL-2577 — endpoint の到達範囲は bare / qualified に共通の 1 規則で決まる。綴りが検査を免れる例外を作らない

A trailing #<id> gives an edge a stable, author-defined identifier that the .krs.style resolver can target with the edge#<id> selector.

ECommerce -> Payment "Process payment" #criticalWrite
WebApp --> Bff #liveStream
A -> B [important] #namedEdge

The #<id> token comes after the optional label and tags. Edge ids must be unique within the project; duplicates raise a duplicate-edge-id error. When the #<id> is omitted, the edge falls back to a computed canonical id of <from><arrow><to> (with -> for sync and --> for async). If two edges share the same computed base and neither has an #<id>, an ambiguous-edge-base warning is raised and per-edge style selectors do not match either of them.

The same suffix is accepted on a usecase block’s resource row to identify the synthesized usecase→resource edge:

usecase PlaceOrder {
resource OrderDB.OrderTable #placeOrderWrite { operations create, read }
}

See docs/adr/1096-edge-id-selector.md for how the id flows into the edge#<id> style selector. The selector itself is documented in docs/spec/style.md — Edge ID selector.

Section titled “Property block ({ label / description / link / facets })”

A trailing { … } block gives an edge a place to write what the positional form cannot express. It is additive: A -> B "calls" stays valid and stays the canonical spelling for an edge whose only property is its label.

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

The block accepts label, description, facets and link, each spelled exactly as it is on a node: link takes "<URL>" followed by an optional "<label>" and may repeat, and facets takes a comma-separated id list, accumulating across repeated lines with duplicate ids collapsing. Any other keyword inside the block is an unexpected-token-in-block error. Tags and #<id> stay outside the block, matching service A [external] { label "…" }.

Writing the label both positionally and in the block is a duplicate-edge-label error rather than a precedence rule. Neither spelling silently wins:

system Shop {
service A {}
service B {}
A -> B "calls" { label "invokes" }
}

karasu fmt folds on one condition: does the block carry anything besides label. A block holding only a label is rewritten to the shorthand; a block holding a description, a link or facets is kept, with label moved inside it. Both spellings parse to the same AST, so which one you type never changes the diagram.

Every property in the block is read back on the canvas. description and link open the edge detail panel on a left click; facets puts the edge in the facet overlay, so selecting that facet highlights the edge and edge[facets=<id>] in a .krs.style sheet matches it. Right-clicking still opens the direction menu, and an edge written in the shorthand behaves exactly as before.

Declaring an edge inside a service block expresses a dependency of that service. from_id is the declaring service (or omitted, which means the same thing); to_id is any peer of that service — another service, an [external] one, a client, or an infra block declared beside it.

system Shop {
service Storefront {
-> Checkout "place order"
domain Catalog { usecase Browse {} }
}
service Checkout {
Checkout --> Ledger "settlement requested"
domain Ordering { usecase Place {} }
}
service Ledger [external] {}
}

The edge renders on the view that draws the declaring service as a node — the system view, and the drill-down into that system — which is the same place the system-scope spelling of the dependency lands. Prefer this anchored form: it keeps the edge next to the source it belongs to, and it is what the edge origin scope rule asks for.

A qualified target (OtherSystem.Svc) takes the cross-system path instead: the target system is drawn as a ghost on the declaring service’s view, and the declaring service as a caller ghost on the target’s view.

An explicit service edge — in either spelling — suppresses the implicit service edge that cross-service domain edges would otherwise derive for the same pair (see below).

Related TPLs:

  • TPL-2075 — an edge the parser accepts renders on some view or is reported; the anchored spelling must not drop silently

Declaring an edge inside a domain block expresses a dependency between domains. from_id is the id of the declaring domain; to_id is the id of the dependency target.

service ECommerce {
domain Contract { label "Contract" }
}
service BillingService {
domain Billing {
label "Billing"
Billing -> Contract "Created from a contract" // sync dependency
Billing --> AuditLog "Record an audit log entry" // async dependency
}
}

Intra-service domain edges: rendered in the service view (drill-down into the service).

Cross-service domain edges: automatically derived and rendered as “implicit service-level edges” on the system view. When multiple domain edges aggregate to the same service pair, the edge label reads "N domain edges".

Implicit edges are automatically tagged with [implicit]. By default they are rendered as an amber dashed line. If an explicit service-level edge already exists in the same direction, the implicit edge is not derived.

See docs/spec/tags-annotations.md for the full list of available tags and styles.

Related TPLs:

  • TPL-2075 — a construct the parser accepts is either rendered on some view or reported; an edge endpoint that is not at the edge’s declaring scope must not drop silently (§ Endpoint scope)
  • TPL-1936 — a cross-domain entity relation must use a qualified DomainId.EntityId target
  • TPL-2542 — the shorthand and the property block are two spellings of one edge, so both must land on one AST and karasu fmt must fold them to a single canonical form (§ Property block)
  • TPL-1503 — every property the block accepts ships with its visible effect; facets on an edge must reach the overlay and the edge[facets=…] selector, not parse and vanish (§ Property block)
  • TPL-2174 — writing facets on an edge must leave the rendered SVG byte-identical while no facet is selected (§ Property block)
  • TPL-907 / TPL-2032 — an edge’s facets is a cross-reference like a node’s, validated by facet-not-declared on the import-merged model (§ Property block)
  • TPL-2161 — an edge’s membership stays 1:N, and a derived edge (aggregated "N domain edges", collapse stub) takes the union of what it folds rather than one constituent’s (§ Property block)

deploy.krs
deploy "production" {
war "order.war" {
runtime "Tomcat 9"
realizes ECommerce
}
oci "inventory-service" {
image "inventory:2.1.0"
runtime "Node.js 20"
realizes Inventory
}
assets "storefront" {
runtime "CloudFront / S3"
realizes Frontend
}
job "data-migration" { // no schedule → one-shot execution
runtime "Python 3.12"
realizes Migration
}
job "monthly-billing" { // with schedule → recurring execution
schedule "0 0 1 * *"
runtime "Java 21"
realizes Billing
}
artifact "legacy-settlement" { // when no built-in kind fits
type "mainframe-batch"
runtime "COBOL / z/OS"
realizes Settlement
}
}

realizes corresponds to UML’s Realization relationship. The arrow points from physical (concrete) to logical (abstract).

A realizes target may be a service, a domain, or a client — a client (SPA / mobile app) is a deployable logical node, so a war / assets bundle that realizes it records the client’s physical form, symmetrically to how an oci unit realizes a service (see also Realizing shared infra below for database / queue / storage targets). The target is written as a node reference path (see § Node reference path notation): a bare id or a qualifying suffix like realizes Shop.Api; a mixed-kind/depth multi-match draws realizes-target-ambiguous.

Multiple realizes entries can be listed to express that a single deployment unit realizes more than one service. In that case, the same node is drawn inside each service’s container on the deploy diagram.

deploy "production" {
oci "monolith" {
image "monolith:1.0.0"
realizes OrderService
realizes InventoryService
}
}

The targets may also be written as one comma-separated line. This is sugar: it produces exactly the model above, and the two forms may be mixed within a node — every entry accumulates in document order.

deploy "production" {
oci "monolith" {
image "monolith:1.0.0"
realizes OrderService, InventoryService
}
}

Repeated lines are the canonical form: karasu fmt emits one target per line and rewrites a comma list into it. The list itself reads on the shared grammar in § Comma-separated value lists: held to the realizes line, with a dangling comma reported as expected-id-after on the comma itself.

Naming the same target twice is idempotent, not an error. One relation is declared, so the repeat is dropped wherever it sits — later in the same comma list or on a line of its own — the first spelling is the one kept, and karasu fmt emits the single line. The unit is placed in that target’s container once; a second membership would reserve a second cell in the container’s grid for a unit that is drawn only once. Two refs that resolve to one node (realizes Api alongside realizes Shop.Api) stay two entries in the model, because each carries its own range for unresolved-realizes / realizes-target-ambiguous, and the deploy view still places the unit in that one container once.

Related TPLs: TPL-2552 — a reference list that can name one target twice reaches both a consumer that counts entries and a consumer that keys by identity; the two have to agree. TPL-2542 — adding a second accepted form for one property fixes, in the same change, that both forms land on one AST, that the formatter round-trips the non-canonical form, and that element-level diagnostics carry element-level ranges.

realizes can also point at a shared infra node (database / queue / storage), not just a service / domain. This records the physical form of a logical data store — which managed service or engine actually backs it — symmetrically to how an oci unit realizes a service. Use the dedicated store kind for managed data stores; its free-text type names the concrete technology.

deploy "production" {
store "order-db" {
type "Aurora PostgreSQL 15"
realizes OrderDB // realizes the logical `database OrderDB`
}
store "order-events" {
type "Amazon SQS"
realizes OrderEvents // realizes the logical `queue OrderEvents`
}
}

The unit is drawn inside the realized infra node’s container on the deploy diagram, the same way a service-realizing unit is. store carries type and realizes but no runtime / schedule — a managed store has no runtime form of its own. Recommended style: model managed stores with store; other kinds (oci, …) may realize an infra node too, but store keeps the intent explicit.

When a service depends on a realized infra node (a usecase references it via resource <Infra>.<Sub>) and both the service and the store are realized, the deploy diagram draws a dependency edge from the service’s container to the realized store’s container (ADR-1658).

Scope: this stays within deploy’s runtime-contract layer (which concrete form backs the store). Infrastructure topology — regions, AZs, clusters, nodes — remains out of scope (see concepts.md). Decided in ADR-1632.


facets, delivers, handles, operations and realizes each take a list of values after the keyword, and they all read it on one grammar (#2551):

  • Elements are separated by ,. The same list may instead be written as repeated keyword lines, and the two forms accumulate into one array in document order. handles Order, Catalog and handles Order above handles Catalog produce the same model.
  • A list never jumps a line on its own. Every separator, and every element after one, sits on the line where the previous element ended, so neither a trailing comma nor a comma opening the following line extends the list across the break: an id on the next line is reported where it sits rather than absorbed as an element. An element that spans lines itself carries the list with it, because a node reference path’s dots continue one element rather than the list, so handles Order with .Line, Catalog on the next line still reads both targets.
  • A separator with no element is an error. A bare keyword, a leading comma (facets ,pii) and a trailing comma (facets pii,) each raise one expected-id-after, anchored on the offending comma in either direction, and on the keyword only when the value is missing outright. The elements read before the mistake are kept, and so are the ones after a leading comma.
facet pii {}
facet pci {}
system Shop {
client WebApp [web] {}
service Backend {
domain Order {}
domain Catalog {}
}
service Api {
facets pii, pci // comma list
handles Order, Catalog // one relation, several targets
delivers WebApp
}
Api -> Backend
}

Which form karasu fmt emits is a per-property decision, stated in each property’s section; the grammar above is what the parser accepts on input.

Related TPLs: TPL-2542. A list form and a repeated-line form must land on one AST, elements must carry element-level ranges, and a dangling separator must be reported on the separator itself, symmetrically in both directions.


Everywhere a property names a node by id, the reference shares one lexical shape and one resolution rule (#2088):

  • Shape: Segment(.Segment)* — e.g. Shop.Checkout.Payment. Segments are ids (string-literal segments allowed where the site accepts them), with no depth limit at any site — edge endpoints and entity relations were capped at two until slice E of #2088 lifted the cap together with the resolution that gives the deeper form an effect.
  • Resolution — the suffix rule: a reference matches every node whose full path (root to node, ids joined by .) ends with the reference. A bare id is the length-1 case, so owns Payment claims every node with the id Payment (broadcast — unchanged behavior), while owns Shop.Payment narrows to the node that path names.
  • Ambiguity: at the sites whose resolution runs through the shared resolver (owns, contains, realizes), a reference matching two or more nodes that are not uniform in (kind, depth) reports *-target-ambiguous (warning) listing the candidate full paths, and the author can qualify with a longer path. A uniform multi-match is intentional broadcast (migration coexistence, multi-tenant naming) and stays silent. handles has no such code, and that is a verdict rather than a gap: its candidates are the domains directly under a system-level child, so every multi-match there is uniform by construction and a longer path is not an available remedy — unresolved-handles carries what can go wrong. Edge endpoints report edge-target-ambiguous for a qualified reference only: a bare endpoint keeps its peer binding, where a multi-match is the pre-existing broadcast rather than a new question.
  • Sites keep their own scope rules. The notation is shared; where a reference may point is still each site’s business: an import path walks the imported file’s tree (ADR-927), an edge endpoint is narrowed to the matches that spell a whole path from a top-level system (§ Endpoint scope), a scoped boundary … contains resolves against the declaring node’s direct children.
  • Accepting sites today: import { … }, cross-system edge endpoints, cross-domain entity relations, resource OrderDB.Orders, the entity table mapping, owns, contains (top-level and scoped), realizes, and handles — all nine sites (#2088).
  • Recovery after a dangling dot is each site’s own, deliberately: owns, contains, realizes and handles report once and record nothing, never the first segment; import { A. }, resource OrderDB. and a cross-system edge endpoint keep their pre-#2088 recovery, reporting and still recording the segments they read. What the nine sites share is the notation and the suffix rule, not what a malformed reference leaves in the model.

Related TPLs: TPL-2088 — ノード id を指す記法はサイト間で 1 つの規則を共有し、受理側と解決側が同じヘルパーを引く。TPL-1352 — path を受理する参照は path をキーに持つ索引を要求する(ownerIndex / boundaryMembership は full path キー)。


An organization block declares the hierarchy of organizations, teams, and members. It is rendered as a separate “Org view,” independent of the logical and physical diagrams.

organization TechCorp {
label "TechCorp Engineering"
team "ec-team" {
label "EC Team"
description "Team responsible for developing and operating the EC site"
owns ECommerce
owns Order
owns Catalog
member alice {
label "Alice Yamamoto"
description "Tech lead of the EC team"
slack "@alice"
github "alice-yamamoto"
}
member bob {
label "Bob Tanaka"
slack "@bob"
github "bob-tanaka"
}
}
team "platform-team" {
label "Platform Team"
team "infra" {
label "Infrastructure"
owns Kubernetes
member dave { label "Dave Suzuki" }
}
team "security" {
label "Security"
}
}
}
  • owns <ref> declares a node the team owns, where <ref> is a node reference path (see § Node reference path notation): a bare id claims every node with that id (broadcast), a longer suffix path narrows to the node it names, and a mixed-kind/depth multi-match draws owns-target-ambiguous. When the same node is owns-ed by more than one team, it is a tolerated fact (transient co-ownership during an inverse-Conway migration): the first-declared team is kept as the node’s primary owner and the overlap surfaces as the duplicate-owner-assignment info diagnostic — not an error (ADR-1566). A @migration_target team takes primary over unmarked, and @deprecated last.
  • Under Group by: team, grouping resolves per view, against the nodes rendered at the level being drawn. owns has no level restriction, so a team owning a domain nested under a service gets a team frame in that service’s drill-down view — the same per-view semantics as the boundary axis (see § Grouping the system view).
  • Teams can be nested — placing child teams under a parent team expresses organizational hierarchy.
  • Team IDs must be unique within the same organization. Duplicates produce an error.
  • During parsing, an ownerIndex (node full path → team id, #2548) is built so that a logical-diagram node can look up its owner team; each owns reference is expanded through the suffix rule at build time.
  • owns accepts service / domain / client and the infra blocks (database / queue / storage) at any depth; an infra leaf (table / queue-item / bucket) and a capability are not ownership units and are reported by invalid-owns.
  • Ownership is rendered on the owned node’s card in the system view as a team chip (a person-group vector glyph, data-meta-glyph="team"), on the logical kinds only (service / domain / client) — an owned infra block draws no chip, because the rectangular chip does not fit a cylinder / cloud corner (the same constraint the deploy button carries). Its ownership still reads on the system view under Group by: team, whose frames resolve by id, and in the org view. The chip shows the team’s declared label (falling back to its id) so a card and a Group by: team frame name the same team the same way; clicking it navigates to the org view by team id.

Ownership inheritance — the nearest owned ancestor

Section titled “Ownership inheritance — the nearest owned ancestor”

A node with no owns of its own belongs to the team that owns its nearest owned ancestor. The walk stops at the first ancestor any team owns; a node with no owned ancestor belongs to no team at all.

Inheritance is what makes ownership answerable at the granularity dependencies are actually recorded at. owns is normally written on services, while the edges that say who depends on whom are usually written between domains — so without the walk, almost every such edge would resolve to no team on either end.

system Shop {
service Checkout {
domain Cart {} // no owns of its own -> checkout-team, inherited
}
service Payments {
domain Settlement {} // owned directly below -> pci
}
service Platform {} // no owned ancestor -> no team
}
organization Shop {
team "checkout-team" {
owns Checkout
}
team "payments-team" {
owns Payments
team pci {
owns Settlement
}
}
}

The nearest declaration wins outright rather than being unioned with what encloses it — owns on a descendant is a more specific statement about that subtree. Settlement above therefore belongs to pci alone, not to pci and payments-team together. Co-ownership is the one case that yields several teams for one node, and it does so because several teams named that same node (see the duplicate-owner-assignment bullet above), not because of the walk.

Inheritance governs derived readings of ownership, not the declared index. ownerIndex still records only the nodes owns names, and the team chip still marks only those cards. The reader that walks is the team-dependency derivation (karasu team-dependencies), which joins ownership against the logical edges; an endpoint that reaches no team through the walk is reported as unowned rather than dropped, so the derivation states how much of the model it covered. user endpoints are excluded from that count — an actor is not an ownership target.

Related TPLs: TPL-2157 — 解決済みの owns を提示する側(カードのチップ・NodeMetadata・detail panel)の kind gate も、owns が許す全 kind を列挙する。TPL-1720 — realizes / owns の valid-target set は spec が許す全 kind(service / domain / client / infra)を列挙し、parser・resolver の重複した集合を同期させる。TPL-1386 — 重複 owns は tolerated fact として info 診断(duplicate-owner-assignment)で surface し error にしない(ADR-1566)。TPL-2635 — 所有を読む側は宣言だけを読むのか nearest owned ancestor まで遡るのかを宣言し、その選択を固定するテストを持つ。

member is declared directly under a team to describe an individual.

Property Syntax Description
label label "<display-name>" Display name on the diagram
description description "<text>" Description of the member
slack slack "<handle>" Slack handle
github github "<username>" GitHub username

All properties are optional. member cannot be nested.

organization, team, and member take their label as the label property (team backend { label "Backend Team" }), like every node kind (ADR-19). The legacy positional argument (team backend "Backend Team") is rejected with the positional-label-removed error (#2133 deprecated it, #2208 removed it). The string is still read as the label so the org chart keeps its names while the file is fixed, but the file does not compile clean until the property form is used. karasu fmt rewrites the form only while it parses without an error, so migrate before upgrading. When both are specified, the property form takes precedence.

Related TPLs: TPL-2133 — every form the parser accepts must be documented here; undocumented leniency is drift (this section’s positional form went unspecified-but-accepted for four months, #2133).


Grouping the system view (boundary) — experimental

Section titled “Grouping the system view (boundary) — experimental”

Experimental notation (post-v1.0 watch). boundary is retained as experimental, not frozen — backward compatibility is not yet promised, and promotion to a v1.0-stable construct is gated on real-usage evidence (the notation promotion gate, ADR-1820). See docs/roadmap.md § post-v1.0 horizon.

A boundary block declares a semantic cluster of system-view nodes — a grouping the author draws on top of the logical structure, independent of the kind tiers and of team ownership. It is the second “Group by” axis of the system view (the first is team ownership, above): with groupBy: "boundary" the renderer bands each boundary’s members as a dependency-ordered group and draws a boundary frame around it, exactly as the team axis does — both axes resolve per view, at every drill-down level (see the contains bullet below), and the two axes are mutually exclusive (you pick one at a time) and independent (a node can sit in team A under Group by: team and boundary X under Group by: boundary).

boundary payments {
label "Payments"
contains Billing
contains Wallet
}
  • Two placements: the top-level declaration shown above (like organization), and a scoped declaration inside a node block (the next subsection). The top-level form groups nodes by reference (contains <id>), not by containment, so it can gather nodes declared anywhere — including across imported files (the same file-crossing property as owns).
  • contains <ref> lists one member per line (mirroring owns), where <ref> is a node reference path (see § Node reference path notation): a bare id gathers every node with that id, a longer suffix path narrows to the node it names, and a mixed-kind/depth multi-match draws contains-target-ambiguous. The parser accepts any declared node (no kind restriction, unlike owns; system containers themselves are excluded). Grouping resolves per view, against the nodes rendered at the level being drawn: each view frames the members present at that level; members living at other levels simply do not participate in that view’s frames. A domain nested under a service is framed in that service’s drill-down view; a usecase in its domain view; an entity in the entity view; an infra leaf (a table, a queue message, a bucket) in its store’s drill-down view. One boundary may therefore produce frames on several levels (same label, disjoint frames — the same honest representation as the per-system team frames of the multi-system root view). Ghost nodes never participate in grouping. Every kind contains accepts is drawn at some level, so every resolved member has a view where its frame appears — only a reference to a nonexistent id (contains-target-not-found) stays inert. This per-view resolution is shared by both grouping axes: owns has no level restriction either, so a team owning a nested domain frames it in the same drill-down views under Group by: team.
  • Membership is 1:N. A node may be listed in any number of boundaries, and every one of those memberships is kept: boundary is a grouping the author draws, and two authors’ groupings legitimately overlap (a service can be both payments and pci-scope). Multi-membership is reported as the info diagnostic duplicate-boundary-assignment — a fact worth seeing, not an error (the same “smell is representable” register as duplicate-owner-assignment).
  • How a view resolves multi-membership: the banded Group by: boundary layout can place a node in only one band — its first-declared boundary (its primary) — but the frames are not limited to their band. A frame is widened into a rectilinear outline to enclose a member placed elsewhere, so a shared node is drawn once and sits inside every frame that can reach it, the two outlines overlapping over its card. Each boundary carries its own colour, cycled by declaration order, and fills it faintly; that is how an overlap reads as an overlap rather than as one frame nested in another.
  • The colours are settable. The cycle is a default, not a claim by the author. A .krs.style sheet can name a boundary and take its frame colour over (boundary#pci { border-color: ... }); see style.md § Boundary frame selectors.
  • When a frame cannot reach, the membership goes on the card. A frame is only widened when the corridor to the card holds no non-member: a frame must never enclose a node that is not its member, and that rule outranks showing the containment. Where it cannot, the card gets a dashed ◇ <boundary> tab in that boundary’s colour, and the view reports the info diagnostic boundary-membership-not-drawn. Expect the tab to be the common case: whether the corridor is clear depends on where the dependency flow put the cards, which the author did not choose. Both readings are of the view — the model holds every membership either way.
  • A boundary always gets a band if it can. A boundary whose members are all claimed by earlier ones would have nothing to band, and so no frame and no label at all. Rather than vanish, it claims one of its shared members on that canvas — the first declared member that is drawn there and whose current boundary keeps another member, so filling one band never empties another. When no member qualifies (the only candidate is the last member of its own band) the boundary keeps no band. Every node is still drawn exactly once, and no frame ever encloses a node that is not its member.
  • Shared members shape the layout. Boundaries that share members are banded next to each other where the dependency flow allows it, and a shared node is seated on the row of its band that touches the other boundary’s band. Neither ever costs the diagram its top-to-bottom dependency order: the band stack is still a minimum feedback-arc-set first, and a node only moves within its band when nothing inside that band depends on the move. Models without shared members lay out exactly as before (#2176).
  • Membership indexes are derived at parse time, per placement. The top-level form builds the flat boundaryMembership (node id → boundary ids), analogous to the org ownerIndex; scoped declarations build the per-scope scopedBoundaryMembership (declaring scope → (child id → boundary ids)), keyed by the scope path so a same-named child in another scope can never be confused with this one (TPL-1352). Where both name the same node on one canvas, the scoped entry wins — it is the more specific statement, written next to the node it names, and it restates that node’s membership for that canvas only. Whole-file import merges the two files’ memberships (union); a boundary declared in an imported file frames the importing model too.

Scoped declaration — boundary inside a node block

Section titled “Scoped declaration — boundary inside a node block”

A boundary block may also be declared inside a node block. Written there, it is that layer’s own concern: its members are the direct children of the declaring node, referenced by bare id, and its frame appears on the declaring node’s canvas only.

system Shop {
service Checkout {
boundary core {
label "Core domains"
contains Ledger
contains Cart
}
domain Ledger {}
domain Cart {}
domain Reporting {} // not contained — drawn outside the frame
}
}
  • Placement — a boundary may be declared inside any kind that draws a canvas of its own (a drill-down view its children render on):

    Host kind Scoped boundary allowed?
    system, service, domain, usecase yes
    database, queue, storage yes (frames the store’s drill-down view of table / queue-item / bucket leaves)
    entity, resource, user, client, infra leaves (table, queue item, bucket) no — these draw no canvas, so there would be no peers to frame; declaring one is the error boundary-not-in-context

    A boundary written directly in a system block scopes to the root system canvas — the same canvas the top-level form’s root-level members frame on.

  • Members are direct children only. contains <id> resolves against the declaring node’s direct children — nothing else, not even grandchildren. Sibling ids are already error-unique (duplicate-node-id-parent), so a bare id names exactly one node and the ambiguity the flat form has at top level (#2036) cannot arise. To group grandchildren, declare a boundary in their parent’s block — a per-layer concern is written in its layer. A contains target that is not a direct child is reported (contains-target-not-found) and stays inert. Members are direct children — but a system (or an infra block) may be reopened in another file, and the merged node’s children are what a scoped contains resolves against. So a member, and the boundary block itself, may come from a different file than the one you are reading; what stays true is that both end up on the same declaring node.

  • Identity = declaring scope + id. A same-named boundary in another scope is a different boundary: each frames its own canvas under its own group identity, is titled by its own label, and collapses independently — the frame’s group id is scope-qualified internally, so collapse state never leaks between scopes. (Contrast the top-level form: one declaration, one identity — a top-level boundary whose members span levels shares one collapse state across its frames, like a team spanning systems, ADR-1884.) Within one scope the same id may not be declared twice (duplicate-boundary-id, error); whether two scopes’ same-named boundaries mean the same concern is deliberately unspecified.

  • Top-level compatibility. The top-level form keeps today’s behavior untouched: unrestricted member kinds and levels, cross-file references, per-view fragmentation across levels (ADR-1983), merged same-id declarations. The scoped form is a new, stricter placement — adding one changes nothing about any existing top-level declaration.

Keyword Meaning May contain
boundary A named semantic cluster of system-view nodes. Multiple declarations allowed; top-level or inside a canvas-bearing node block contains
contains A member node id belonging to this boundary (one per line; a scoped block resolves it against the declaring node’s direct children) —

Diagnostics (see diagnostics.md):

  • duplicate-boundary-assignment (info) — a node belongs to more than one boundary (see “Membership is 1:N” above for how a view resolves it).
  • contains-target-not-found (warning) — a contains target does not exist (top-level: anywhere in the system hierarchy; scoped: among the declaring node’s direct children).
  • boundary-not-in-context (error) — a boundary block inside a node kind that draws no canvas of its own.
  • duplicate-boundary-id (error) — two boundary blocks in the same enclosing node declare the same id. Top-level blocks are unaffected (they keep merging).
  • positional-label-removed (error) — a positional label after the boundary id (boundary payments "Payments"). The label property is the only form (ADR-19, #2133).

Under either Group by axis the group frame is titled with the group’s declared label, falling back to the group id when no label is given (#2133).

Related TPLs: TPL-1503 — a newly-accepted keyword must have a visible effect (a declared boundary must produce a frame under Group by: boundary, not parse-and-vanish). TPL-2133 — forms the parser accepts must be documented here (the retired positional label was accepted-but-unspecified, #2133). TPL-1983 — the per-view scope promised above must hold identically on every render surface (interactive compile, the static export bundles, the entity view); a gate added or removed on one surface only ships an undocumented split (#1983). TPL-1352 — the scoped membership index and the scoped group identity key by (declaring scope, id); dropping the scope dimension fuses same-named boundaries across scopes (#2036). TPL-1101 — the scoped block must round-trip through karasu fmt; guards derived from KrsFile’s top-level arrays do not cover per-node constructs. TPL-2032 — the scoped contains-target-not-found is re-derived on the merged model, like every existence check (#2036 slice A regressed exactly this). TPL-2161 — boundary membership is 1:N in the model; the banded view’s primary is a view-side resolution, and the group order comes from the declarations rather than from the axis map’s values (#2178). TPL-1738 — placement is exactly-once across the whole band machinery: the seam bias and the band-less boundary’s claim rewrite which band and which row a node takes, and neither may drop or duplicate one (#2176). TPL-2179 — a frame widened to reach a member is measured on what it actually covers, and is widened only when the corridor holds no non-member; “no frame encloses a non-member” outranks showing the containment, and the fallback is the ◇ tab above (#2179). TPL-2316 — a declarable construct must be reachable from the in-app Reference; boundary and facet were shipped and spec’d yet absent from REFERENCE_DATA, while facet’s element-side facets property was listed on all 14 node kinds (#2316).


Cross-cutting membership (facet) — experimental

Section titled “Cross-cutting membership (facet) — experimental”

Experimental notation (post-v1.0 watch). facet lands as experimental, not frozen — backward compatibility is not yet promised, and promotion to a v1.0-stable construct is gated on real-usage evidence (the notation promotion gate, ADR-1820).

Membership is shown by the overlay, which the reader turns on. Pick facets in the preview’s Facets selector: members get a coloured ring, everything else dims, and the legend gains a colour key. Several facets can be on at once, and an element in more than one gets one ring per facet. The overlay is orthogonal to Group by — team or boundary banding stays readable at the same time — and it survives drill-down, collapse and SVG export.

The selection lives in the viewer, not in the model. Nothing about which facets are highlighted is written to .krs; a .krs file renders identically for every reader until one of them selects something.

Two surfaces sit alongside the overlay. A sheet can style by membership — [facets=<id>], the replacement for abusing an arbitrary tag selector. And Membership overview, at the bottom of the Facets menu, answers “which elements are in facet X” in one view. That list is derived from the facets properties on every compile, never authored — writing membership element-side is what costs the centralized list, and deriving it is how the cost is paid without giving up the locality.

A facet declares a set that is defined outside the architecture — by a regulation, a policy, or an audit scope — and that elements belong to. A database is a database whether or not it is in PCI scope; being in PCI scope is imposed on it from outside. That is the register split: a tag says what an element is (its archetype), a facet says what externally-defined set it belongs to. See tags-annotations.md for the four-way split between boundary / annotation / tag / facet.

facet pii {
label "Personal data"
description "Handling follows ADR-1421"
link "https://example.com/adr/1421" "ADR-1421"
}
facet requires_auth {
label "Authenticated"
description "Reachable only after login; who may call it is set by the IAM policy"
link "https://example.com/policies/iam" "IAM policy"
}
system Shop {
service Checkout {
domain Ordering {
usecase PlaceOrder {
facets requires_auth
}
entity Order {
table OrderDB.orders
facets pii
}
}
}
database OrderDB {
facets pii
}
}
  • The declaration is top level and carries only metadata: label, description, link. Writing a facet block inside a node block is an error — a facet id is a model-wide name, not a per-node one.
  • The declaration has no membership list. There is no contains; membership is written on the elements. This keeps the membership next to the thing that has it, so renaming or moving an element does not mean editing a distant list.
  • The grammar is closed and value-free — permanently. No predicates, no attribute declarations, no policy block. A facet says which behaviours a policy covers; what the policy says stays prose in description plus a link to the real policy document. This is the structural half of ADR-832’s decision not to model runtime authorization: with no value language there is no gradient from “declare a scope” to “declare a rule”.
  • facets <id>[, <id>]* is accepted on every node kind — system, service, domain, usecase, entity, resource, user, client, the infra blocks (database / queue / storage) and their leaves (table, queue item, bucket). Membership is imposed from outside the architecture, so no kind is structurally excluded. The id list reads on the grammar in § Comma-separated value lists, which also governs where repeated facets lines may sit.
  • Edges take facets too, written in the edge property block. A data flow that carries PII, or a call that sits inside PCI scope, is a fact about the edge: putting it on an endpoint would say the wrong thing about the endpoint and still not say the right thing about the flow. The spelling and the merge rules are the node’s, and a selected facet highlights the edge the way it rings a card. The one place the property is not accepted is the resource row inside a usecase: that row already takes facets, and there it means the resource node’s membership, so one line cannot also mean the synthesized edge’s.
  • A derived edge’s membership is the union of what it folds. The aggregated "N domain edges" on a service view, and the stub edge a collapsed group leaves behind, both belong to every facet their constituents belong to. There is no authoring site for either, and without the union a folded edge would go dark while a folded node in the same facet lit up.
  • Repeated properties and repeated ids merge. facets a, b and two separate facets lines are the same thing, and naming the same id twice is idempotent, not an error. karasu fmt canonicalizes them to one comma-separated line.
  • An element may belong to any number of facets (1:N). Multi-membership is a normal state — an entity can be both PII and in PCI scope — and never a diagnostic. Every declared membership is kept in the model; a view that can only show one at a time resolves that itself.
  • References name facet ids, never node ids. facets pii resolves against the flat namespace of facet declarations, so the cross-layer addressing question that boundary … contains and owns have to answer simply does not arise here.
  • Declaration and reference may live in different files. Both sides merge across imports and are validated on the merged model, so keeping the facet vocabulary in one file and importing it wholesale is a supported layout.
  • Typo detection is complete, not best-effort. Because the declarations define the correct set, a slip between two author-defined names (facets pcl for pii) is caught just as reliably as a misspelt builtin — unlike the near-miss annotation-possible-typo hint, which can only compare against a fixed vocabulary.
  • Default rendering is unchanged. Adding facets to an element never alters how the diagram is drawn; every effect of a facet is opt-in.
Keyword Meaning May contain
facet A top-level declaration of an externally-defined set, with its metadata. Top level only label, description, link
facets The facet ids an element belongs to (comma-separated; repeatable; accepted on every node kind) —

Diagnostics (see diagnostics.md):

  • facet-not-declared (warning) — a facets reference names no declared facet. Checked on the merged model, so a declaration in an imported file counts.
  • duplicate-facet-id (error) — two facet blocks declare the same id, in one file or across merged files. The first declaration is the one references resolve to.
  • positional-label-removed (error) — a positional label after the facet id (facet pii "Personal data"). The label property is the only form (ADR-19).

Related TPLs: TPL-1503 — accepted vocabulary must have an effect; the overlay above is that effect. TPL-2174 — the overlay is opt-in, so it must emit nothing at all when no facet is selected. TPL-907 — facets is a cross-reference property, so it ships with a resolver-side validator and an unresolved warning, not parser-side acceptance alone. TPL-2161 — the 1:N membership promised above is kept whole in the derived index and through every merge path; a view needing a single value resolves it on the view side. TPL-2032 — facet-not-declared and duplicate-facet-id are decided on the merged model, since declaration and reference may sit in different files. TPL-1101 — both the declaration block and the per-node facets property round-trip through karasu fmt; guards derived from KrsFile’s top-level arrays do not cover the per-node property. TPL-2133 — every kind that accepts facets is listed here, because the parser accepts it everywhere; edges accept it too and say so in § Property block (#2544). TPL-1281 — the pull from “membership” toward “rule language” is fenced by ADR-832, linked above, rather than by renaming the keyword. TPL-2316 — a declarable construct must be reachable from the in-app Reference; boundary and facet were shipped and spec’d yet absent from REFERENCE_DATA, while facet’s element-side facets property was listed on all 14 node kinds (#2316).

A legend block declares color → meaning pairs that the renderer paints as a footer band below the diagram view. Use it to document what your colors, annotations, and tags signify so the rendered SVG is self-explanatory in reviews and exports.

legend blocks live at the top level of a .krs file — alongside system, deploy, and organization. Nesting inside any block (system, service, domain, deploy, organization, team, …) is a parse error (legend-not-top-level); the parser reports it once and skips the whole nested legend block. Multiple legend blocks are allowed and stack vertically in declaration order on each view that contains them.

legend ::= "legend" view-scope? title? "{" entry* "}"
view-scope ::= "system" | "service" | "domain" | "deploy" | "org"
title ::= <string-literal>
entry ::= swatch-entry | ref-entry
swatch-entry ::= "swatch" "#" hex-digits <string-literal>
ref-entry ::= "ref" ref-target <string-literal>
ref-target ::= "@" identifier ; annotation
| "[" identifier "]" ; tag
| "." identifier ; class (forward-compat; always unresolved today)
| "#" identifier ; node id
| identifier ; node-kind type

The scope vocabulary mixes view types (system / deploy / org) and logical drill-down depths (service / domain). Matching is exact — each rendered level shows only the legends declared for precisely that scope, with no cross-depth stacking (a legend system block does not follow you into a service drill-down, and a legend service block never appears at the top level).

<view-scope> Where the legend appears
omitted the top level of the system, deploy, and org views
system the top level of the system view only
service drill-down views whose root is a service only
domain drill-down views whose root is a domain only
deploy deploy view only
org org view only

Drill-down levels rooted at a node with no scope keyword of its own (for example a system frame or a usecase) render no legend. In the all-layers view, each stacked level band carries the legends for its own depth scope directly below the band. Legends on drill-down levels are opt-in: a file that only uses the pre-existing scopes (omitted / system / deploy / org) renders no legend below the top level.

system ECPlatform {
service ECommerce { label "EC Site" }
service Payment [external] { label "Payment" }
service Legacy @deprecated { label "Legacy" }
}
deploy Production {
oci "ec-api" { realizes ECommerce }
}
// Shown on every view.
legend "Owner team" {
swatch #2563EB "Team Backend"
swatch #16A34A "Team Frontend"
swatch #DC2626 "Third-party"
ref @deprecated "Deprecated" // color from .krs.style
ref [external] "External"
ref service "Service"
ref #ECommerce "EC site"
}
// Deploy-only legend.
legend deploy "Hosting tier" {
swatch #0EA5E9 "Cloud Run"
swatch #F59E0B "On-prem"
}
// Shown only on drill-down views rooted at a domain.
legend domain "Data access" {
swatch #3B82F6 "Read path"
swatch #F97316 "Write path"
}
  • swatch uses the literal hex color verbatim (3, 4, 6, or 8 hex digits, with #).
  • ref resolves through the same .krs.style cascade the nodes use — matching rules are merged per property, weakest first, ordered by specificity and then by declaration order across every sheet (the built-in sheet first, your sheets after it). The swatch takes the merged background-color, falling back to badge-color. A rule you write always outranks the built-in rule it ties on specificity, on the swatch exactly as on the card.
  • A fill-less kind (merged background-color: transparent, e.g. the built-in usecase) is swatched with its border-color, because the border is what identifies it on the canvas.
  • A ref whose target appears on at least one node in the file but has no painting style rule renders with a neutral fallback swatch so semantic-only annotations / tags (e.g. [human], [ai]) still surface in the legend.
  • A ref that matches no rule and no node is dropped from the rendered footer and surfaced in the warning panel as legend-ref-unresolved. Authors can then either remove the entry or add a matching style rule.
  • .class selectors are accepted by the parser for forward compatibility but always resolve as unresolved today (.krs.style has no class concept — see style.md).

Related TPLs: TPL-2234 — the swatch and the node it stands for are one appearance, so both read one cascade implementation rather than each deriving the order (Issue #2445).

Legend labels are author-supplied strings, treated the same way as name and label properties on regular nodes — the renderer embeds them verbatim into the SVG and the app’s i18n layer does not translate them. See i18n.md for the exemption list.

examples/en/feature-samples/legend.krs exercises every primitive in one self-contained file (paste into the app to try).

The following are deferred (see docs/adr/833-diagram-legend-syntax.md for rationale):

  • Shape / icon / pattern legends (only color today).
  • Interactive legends (click to filter, etc.).
  • Node-targeted legends (legend #OrderService "...") — depth scopes cover the common case; per-node targeting waits for observed demand (Issue #1513).
  • Auto-generation from used annotations / tags.
  • Rendering on diff views (compileSystemDiff / compileDeployDiff) and on org focused-team / icon-mode return paths.

Related TPLs:

  • TPL-1223 — scoped glance: each drill-down level shows only its own vocabulary (exact-match legend switching applies this to legends)
  • TPL-219 — top-level / drill-down / all-layers render paths must carry the same legend options
  • TPL-1296 — the view-scope vocabulary here must stay in sync with the built-in reference data

Write with inline nesting first, then extract into separate files as things grow.

// Inline nesting (basic form)
system ECPlatform {
label "EC Platform"
service ECommerce {
label "EC Site"
domain Order { label "Orders" }
}
}
// After extracting to an external file
import { ECommerce } from "ecommerce.krs"
system ECPlatform {
label "EC Platform"
service ECommerce
service Payment [external] {
label "Payment Service"
}
ECommerce -> Payment "Process payment"
}

Path syntax — reaching nodes nested inside a system block

Section titled “Path syntax — reaching nodes nested inside a system block”

Use dotted path form to reach a node defined deeper than the direct child of a system in another file:

import { ECPlatform.ECommerce.Order } from "./services.krs"

The path is a node reference path resolved by the suffix rule (see § Node reference path notation, #2088): the entry matches every node in the imported file whose full path ends with it. A root-anchored path resolves to exactly the node it always did; a relative suffix (import { ECommerce.Order }) is also legal and resolves through the same rule. Roots are not limited to systems — a chain under a top-level service / client / domain / infra bucket materializes into that bucket. Every match is imported (bare-id imports have always broadcast), and a multi-match not uniform in (kind, depth) additionally draws the import-target-ambiguous warning listing candidate full paths.

The importer only materializes the chain it asked for: in the example above, the merged file gains a stub of ECPlatform with a stub of ECommerce whose only child is the resolved Order (with Order’s full subtree intact). Sibling domains under ECommerce are not auto-imported. Bring more by listing them in the same import or by wildcard-importing the whole file.

Path syntax shines when the same id appears in multiple systems — system migration is the canonical case:

services.krs
system OrderSystemV1 {
service OrderService { domain Legacy {} }
}
system OrderSystemV2 {
service OrderService { domain Modern {} }
}
// migration.krs — pull only V2 without renaming
import { OrderSystemV2.OrderService } from "./services.krs"

Bare ids (import { ECommerce }) keep working — they remain the simplest form when the id is unambiguous.

A path that resolves to nothing emits an import-path-not-found diagnostic. The failing segment is found by narrowing right-to-left (the suffix analogue of walking down from the root): the reported segment is the one that eliminated every candidate, and lastResolvedId names the neighboring segment that still had matches:

import { ECPlatform.NotThere.Order } from "./services.krs"
// → Import path "ECPlatform.NotThere.Order" failed at segment "NotThere" (#1):
// no ancestor with that id above "Order"

This section defines what each import form means when a model is split across multiple .krs files. Implementation: packages/core/src/fs/import-resolver.ts. Related ADRs: ADR-281 (wildcard / two-pass), ADR-292 (directory), ADR-412 (named top-level), ADR-927 (named path syntax).

@import "theme.krs.style" // (a) style import — see §"@import scope" below
import { Foo, Bar.Baz } from "p.krs" // (b) named import — see §"Drill-down and external file references"
import "p.krs" // (c) whole-file import — defined in this section
import "dir/" // (d) directory import — defined in this section

(c) and (d) share the same merge rules. (d) is defined as: list all .krs files directly under dir/ in alphabetical order, then process each as if it were a separate import "..." declaration at the same place. Sub-directories are not recursed.

import "p.krs" brings the fully-resolved KrsFile of p.krs into the importer. “Fully-resolved” means after recursively resolving all of p.krs’s own imports. This resolved snapshot is computed once per file path and reused — the same p.krs reached through multiple paths yields the same content (see S5).

The importer absorbs:

  • all top-level nodes (system / service / client / database / queue / storage / legend / deploy / organization)
  • all children inside each system block (user / client / service / domain / usecase / resource / edges / infra)
  • all stylesheets referenced via @import in p.krs (added to the cascade)

S3. Same-id system blocks merge (system reopen)

Section titled “S3. Same-id system blocks merge (system reopen)”

When the same system id appears in more than one file (the importer’s own file and an imported file, or two imported files), the blocks are merged into one rather than treated as duplicates:

  • System body properties (label / description / tags): the declaration in the file closer to the import-graph root wins. The root is ImportResolver.resolve(entryPath)’s entryPath — in practice the file currently open in the App / VS Code extension, or the file passed to karasu render. The resolver walks the graph bottom-up; values from deeper imports fill in only where the closer file left them unset.
    • When two files declare conflicting non-empty values, the resolver picks the closer-to-root one and emits a system-property-conflict warning (chosen value + ignored value + both source locations).
  • Children: merged by id with find-or-create. Two children with the same id in the same merged system produce a duplicate-node-in-system error (existing behavior). Two different ids merge cleanly.
  • Edges: union. Exact duplicates (same from, to, kind, label) are deduplicated; otherwise both are kept.

This is the canonical way to split a large system into several files. The App / CLI’s notion of “the current file” naturally becomes the source of truth for top-level system metadata.

S4. Same-id deploy / organization blocks merge

Section titled “S4. Same-id deploy / organization blocks merge”

Same rules as S3, applied to deploy.nodes (oci / k8s / vm / …) and organization.teams (and members). realizes / owns relations are unioned. import "p.krs" therefore brings deploy and organization content alongside system content — there is no separate import form for the physical / org views.

The import graph is allowed to be a DAG. The same file may be reached through two different import chains (entry → A → C and entry → B → C) without warning. The resolver memoizes the resolved snapshot per file path so the second arrival reuses the first arrival’s result.

A circular-import warning is emitted only on a true cycle — a file is already on the currently-being-loaded stack when it is requested again. Detection uses a loading set (path stack, push on enter / pop on exit) distinct from a loaded memo. The latter does not warn.

// DAG — no warning
index.krs: import "admin.krs"
import "auth.krs"
admin.krs: import { Service } from "auth.krs" // reaches auth.krs via admin
auth.krs: // (no imports)
// True cycle — warning at the second arrival on a.krs
a.krs: import "b.krs"
b.krs: import "a.krs" // ← circular-import warning

S6. Dangling edge endpoints preserve their nodes

Section titled “S6. Dangling edge endpoints preserve their nodes”

When an edge A -> B cannot resolve one of its endpoints (because the target id is not present in the merged model), the resolver:

  • drops the edge and emits an unresolved-edge-endpoint warning naming the unresolved id and the edge’s source location;
  • keeps the node on the resolved side. A node declared in some file is part of the model regardless of whether its outbound / inbound edges resolve.

The same rule applies to realizes / owns / handles cross-references: the source node stays, the unresolved relation is reported.

The physical dot-notation references follow it too: a usecase’s resource <Infra>.<Leaf> and an entity’s table <Infra>.<Leaf> must name a database / queue / storage block, and a leaf that block declares. When they do not, the usecase or entity survives and the reference is reported (unresolved-resource-ref / unresolved-table-ref, both naming which half is missing). Two exemptions: a resource or entity tagged [external] deliberately points outside the model, and — like owns / contains — a document that still has imports to resolve does not decide the question at all, since the canonical place for a shared database block is the imported infra file of §S4.5.

An entity that carries no table mapping is a different thing entirely and is not reported here: modelling the logical side before (or without) the physical one is a supported state. Whether the physical layer is as complete as its declarations imply is a measurement, answered by karasu coverage, not a diagnostic.

S4.5. Same-id infra reopen (database / queue / storage)

Section titled “S4.5. Same-id infra reopen (database / queue / storage)”

The same rules as S3 apply when the same database, queue, or storage id is declared in more than one file (or in more than one system block within one file’s import graph):

  • Body properties (label, description, tags): root-entry-wins silently. Unlike S3 (which warns on conflicting non-empty label/description), infra body conflicts emit no diagnostic — the intentional asymmetry: shared infra is more often refactored across files than system, and a property warning would be noise during a migration.
  • Children (table declarations and other leaves): merged by id with find-or-create. DAG re-arrival (same node instance reached via two import paths) dedups silently. Two different declarations with the same (id, kind) — e.g. table users { ... cols A ... } in one file and table users { ... cols B ... } in another — keep the first one and drop the second; an infra-leaf-redeclared-silently info diagnostic surfaces the dropped declaration so the loss is visible without blocking the build.
  • Diagnostic: an infra-redeclared-across-files info diagnostic surfaces the fact that the infra was declared in multiple places — listing the id and kind — without prescribing how to fix it.

The info register (distinct from warning) is intentional: karasu visualizes shared infrastructure (one database written to by multiple services across files) but does not refuse to model it. The wording is fact-first; whether the sharing is a smell depends on the project’s style and is left to its documentation. See the canonical pattern below.

Canonical pattern — dedicated infra file

Section titled “Canonical pattern — dedicated infra file”

The recommended way to share database / queue / storage declarations across slices is to put them in a dedicated infra file and import "infra.krs" from each slice that uses them. Because of S2’s per-file memoization and S5’s DAG handling, the infra file is resolved once and reused from every importer:

infra.krs
system Blog {
database ArticleDB { table articles }
}
// reader.krs
import "infra.krs"
system Blog {
service ArticleDelivery {
domain Delivery {
usecase ReadArticle { resource ArticleDB.articles }
}
}
}
// editor.krs
import "infra.krs"
system Blog {
service Authoring {
domain Publish {
usecase Publish { resource ArticleDB.articles }
}
}
}

No infra-redeclared-across-files diagnostic fires for this pattern — each infra id is declared exactly once in infra.krs; the other slices only reference it via resource paths. The diagnostic surfaces only when the same database UserDB { ... } declaration appears literally in two files, which is the fallback the resolver accepts but does not encourage.

mergedFile order is determined by:

  1. import declarations are processed in source order within each file;
  2. directory imports expand to file names in alphabetical order;
  3. nodes are inserted into merged collections on first encounter (later merges only mutate existing entries via find-or-create).

The same project always produces the same merged AST.

Related TPLs:

  • TPL-1381 — DAG re-arrival is not a cycle (S5)
  • TPL-1383 — whole-file import preserves all top-level and nested nodes (S2)
  • TPL-2168 — reopened system merges children, root entry wins for properties (S3)
  • TPL-2169 — deploy / organization propagate through whole-file import (S4)
  • TPL-2170 — unresolved edge endpoint does not drop the surviving node (S6)
  • TPL-907 — every cross-reference form, including the physical resource / table dot-notation, carries resolver-side validation and an unresolved warning (S6)
  • TPL-1385 — same-id database / queue / storage reopens union-merge with an info diagnostic (S4.5)

  • Applies to the entire file (global scope).
  • Must be written at the top of the file.
  • When the same selector is defined in multiple files, the last one wins (a warning is emitted).

All properties are optional. When omitted, a warning is emitted rather than an error. This policy exists to tolerate a “not yet decided” state while iterating on the design.

Property Behavior when omitted
runtime ⚠ runtime is not specified warning
realizes ⚠ realizes is not specified warning (directly tied to the raison d’être of the physical diagram)
schedule Treated as one-shot execution (no warning)
image (oci only) Optional. Displayed on the diagram when specified
type (artifact only) Optional. Displayed on the diagram when specified

If the same domain id appears in multiple service blocks within the same system, the tool emits an informational domain-dispersal diagnostic (info register, not an error). The diagram still renders.

ℹ domain "Order" appears under multiple services
- ECommerce
- Legacy
DDD sometimes calls cross-service domain reuse a cohesion smell

This follows karasu’s “visualize, don’t prescribe” stance (see docs/concepts.md — “What karasu visualizes vs. what it doesn’t prescribe”): a domain shared across services is a structural fact karasu draws truthfully and surfaces, leaving the cohesion judgment to the reader. Compilation is never refused on this ground.

Domain edges (Billing -> Contract) are resolved by domain ID. When the same ID is dispersed, navigation (the nodePathIndex) keeps the first occurrence; the higher-priority entry wins when one side carries a migration annotation (see “Deprecated domain migration”).

Detection scope: per system block. The same domain name across different system blocks is treated as intentionally independent parallel modeling and produces no diagnostic.

Detection key: the id of the domain. The label (display name) is not used for detection.

Related TPLs: TPL-1386 — Diagnostic register reflects "fact vs. style"

© 2026 Hiroki Kondo · Licensed underApache-2.0

Built with Cloudflare