.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 --versionreports the language version a build implements.
File structure
Section titled “File structure”@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}Overview of concepts
Section titled “Overview of concepts”karasu explicitly separates logical structure and physical structure.
Logical structure (what / why)
Section titled “Logical structure (what / why)”| 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.
client form-factor tags (recognized)
Section titled “client form-factor tags (recognized)”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 domain ids exposed to callers. It is a validated cross-reference: the domain id must be reachable through a one-hop expose rule, otherwise an unresolved-handles warning is emitted.
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}Expose rule (used by the validator):
A node
Nexposes domainDiff:
Nhas a childdomain D(self-owned), orNdeclareshandles Dand at least one outgoing communication edge target also exposesD.
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, andlinkproperties apply to infra nodes and their sub-resources; all are optional, and omission emits a warning, not an error. TheoperationsCRUD property is not valid here — it is only meaningful onresourcedeclarations inside ausecase(see below). database/queue/storageare valid only at the top level or as a direct child ofsystem. Nesting one inside aservice,domain, orusecaseis rejected withinfra-not-in-context.table/queue-item/bucketare leaf nodes: they accept properties and edges but no nested declarations.- A
usecaseties one of itsresources to a shared sub-resource with dot-notation —resource <InfraId>.<SubResourceId>(e.g.resource OrderDB.OrderTable). The resolver aggregates these references to derive theservice → database(andservice → 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 todatabase/queue/storagefor a store that lives outside the system boundary (a managed third-party DB, an external event bus, …).[index]may be applied to adatabaseto mark it as a derived search / secondary index — a store derived as an index to search the system of record quickly — and adds anindexbadge. It denotes a role, not a technology: a vector DB / ElasticSearch that is itself the system of record stays a plaindatabase(no[index]). The concrete engine stays in the physical layer (store { type "ElasticSearch 8"; realizes SearchIndex }). See tags-annotations.md.- Writing
resource OrderTablewithout a matchingdatabaseblock is allowed, so you can discover resources bottom-up while sketching ausecase, then group them into adatabaseblock and switch to the dot-notation reference. For as long as the id resolves to nothing at all (no dot-notation ref, and no uniqueentityof the same name), it warnsunassigned-resourceand 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 matchingentitypromotes it and clears the warning with no edit to the usecase,databaseblock or not (seeentitydeclaration). Sketching bottom-up does give visual feedback, then, just one level deeper until the id resolves. - The infra-block keyword
table(adatabaseleaf, declaring the shared node) and the shape tag[table](a usecaseresource’s draw-shape) are related, not the same. A usecase references an infra leaf with aresourcevia 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 aresource’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-resourcebullet above said only “rendered as an orphan node” and drifted for months (#2200).
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 |
Node declaration
Section titled “Node declaration”<kind> <id> [<tags>] @<annotation> [{ <properties> <child-nodes> }]id is required. Tags, annotations, and the body block are optional.
Property block
Section titled “Property block”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 する
String values and escapes
Section titled “String values and escapes”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 node example
Section titled “user node example”user <id> [<human|ai>] { label "<display-name>" role "<role-name>" link "<URL>" "<label>"}- The tag
[human]/[ai]distinguishes human users from AI agents. roledescribes 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 arequires role = ...predicate or similar authz construct (see ADR-832 and ADR-1281). To document who may execute a usecase, use the usecase’sdescriptionand alinkto an external policy document.- Properties and the body block
{ }are optional.
service / domain node example
Section titled “service / domain node example”service <id> { label "<display-name>" link "<URL>" "<label>" link "<URL>" "<label>"
domain <domainId> { label "<domain-name>" ... }}client node example
Section titled “client node example”client <id> [<form-factor-tag>] { label "<display-name>" description "<text>" resource <storageKind> "<name>" resource <storageKind> "<name>"}client resource storage kinds
Section titled “client resource storage kinds”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.
client capability
Section titled “client capability”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.
Writing logical diagrams
Section titled “Writing logical diagrams”system block
Section titled “system block”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"}Top-level placement
Section titled “Top-level placement”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.
Nesting placement
Section titled “Nesting placement”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.
service block
Section titled “service block”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" } }}delivers (service → client)
Section titled “delivers (service → client)”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] {}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 verboperations create, read // comma-separated listoperations createoperations read, update // multiple lines accumulateThe 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.
Verb-decoration syntax (1:N CRUD mapping)
Section titled “Verb-decoration syntax (1:N CRUD mapping)”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 mappingoperations enqueue:create, dequeue:delete // queue idiomsoperations replace:create,delete // physical delete-insert (1:N)operations create, list:read // mix decorated + bareBehavior:
- The right-hand side accepts only recognised CRUD verbs (
create/read/update/delete). Any other identifier raisesinvalid-crud-decoration(error). - An empty right-hand side (
list:) raisesempty-crud-decoration(error). - A duplicated CRUD verb on the right (
replace:create,create) raisesduplicate-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
decoratedAsfirst 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.
Authorization notes — write them as description + link
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
descriptionwithAccess:(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
linkwhose label containsAuthorization policy(orPolicy) 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.) insidedescription. 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).
Top-level domain declaration
Section titled “Top-level domain declaration”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 yetdomain 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 -> LineItemmeans Order holds the reference (AR:Order belongs_to :line_item; physicallyorders.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 atOrder; writingCustomer -> Orderthere raisesedge-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 samedomain. - 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-systemSystemId.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.DomainIdis error-level unique within a system, soDomainId.EntityIdresolves 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 aDomainId.EntityIdnaming 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.EntityIdtarget; 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 Orderwith no matching entity warnsunassigned-resource. Declaringentity 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 → databaseto derive the sameservice → databaseedge (and[read]/[write]tag synthesis) it derives for a physical dot-notation reference. An entity with notablemapping 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-resourcewarning); the root-cause collision is reported byentity-anchor-collision.
Related TPLs: TPL-1882 — an
entityaccepts 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); addingentityas a resolution target must be synchronized across all of them. TPL-2170 — an unresolved bareresource/ 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
entitylayer (never declared on the physicaltable), 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.
Edge declaration
Section titled “Edge declaration”<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.
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, whereContractis 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 -> Paymentwithservice 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.
Endpoint scope
Section titled “Endpoint scope”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). Either way both of its endpoints
must be peers at that scope:
- inside a
systemblock — that block’s own children (plus top-leveldomains, which the root view splices in beside them); - inside a
service/domain/entityblock — that block and its siblings.
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.
An endpoint that resolves nowhere is a different case, reported as
unresolved-edge-endpoint (§S6). Both diagnostics are catalogued in the
diagnostics & rules reference.
Optional edge id (#<id>)
Section titled “Optional edge id (#<id>)”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" #criticalWriteWebApp --> Bff #liveStreamA -> B [important] #namedEdgeThe #<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.
Edges inside a service block
Section titled “Edges inside a service block”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
Edges inside a domain block
Section titled “Edges inside a domain block”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:
Writing physical diagrams
Section titled “Writing physical diagrams”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).
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 }}Realizing shared infra (the store kind)
Section titled “Realizing shared infra (the store kind)”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.
Writing organization diagrams
Section titled “Writing organization diagrams”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" } }}team node
Section titled “team node”owns <id>declares a logical node (service/domain/client, etc.) that the team owns. When the sameidisowns-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 theduplicate-owner-assignmentinfo diagnostic — not an error (ADR-1566). A@migration_targetteam takes primary over unmarked, and@deprecatedlast.- Under Group by: team, grouping resolves per view, against the nodes rendered at the level being drawn.
ownshas no level restriction, so a team owning adomainnested under aservicegets a team frame in that service’s drill-down view — the same per-view semantics as theboundaryaxis (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 id → team id) is built so that a logical-diagram node can look up its owner team. ownsacceptsservice/domain/clientand the infra blocks (database/queue/storage) at any depth; an infra leaf (table/queue-item/bucket) and acapabilityare not ownership units and are reported byinvalid-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 declaredlabel(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.
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)。
member node
Section titled “member node”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.
How to specify a label
Section titled “How to specify a label”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).
boundaryis 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). Seedocs/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 asowns). contains <id>lists one member per line (mirroringowns). The parser accepts any declared id (no kind restriction, unlikeowns). 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. Adomainnested under aserviceis framed in that service’s drill-down view; ausecasein its domain view; anentityin the entity view; an infra leaf (atable, a queue message, abucket) 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 kindcontainsaccepts 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:ownshas no level restriction either, so a team owning a nesteddomainframes 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:
boundaryis a grouping the author draws, and two authors’ groupings legitimately overlap (a service can be bothpaymentsandpci-scope). Multi-membership is reported as the info diagnosticduplicate-boundary-assignment— a fact worth seeing, not an error (the same “smell is representable” register asduplicate-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.stylesheet 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 diagnosticboundary-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 orgownerIndex; scoped declarations build the per-scopescopedBoundaryMembership(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-fileimportmerges 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
boundarymay be declared inside any kind that draws a canvas of its own (a drill-down view its children render on):Host kind Scoped boundaryallowed?system,service,domain,usecaseyes database,queue,storageyes (frames the store’s drill-down view of table/ queue-item /bucketleaves)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-contextA
boundarywritten directly in asystemblock 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 aboundaryin their parent’s block — a per-layer concern is written in its layer. Acontainstarget that is not a direct child is reported (contains-target-not-found) and stays inert. Members are direct children — but asystem(or an infra block) may be reopened in another file, and the merged node’s children are what a scopedcontainsresolves against. So a member, and theboundaryblock 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
boundaryin another scope is a different boundary: each frames its own canvas under its own group identity, is titled by its ownlabel, 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 oneboundary(see “Membership is 1:N” above for how a view resolves it).contains-target-not-found(warning) — acontainstarget does not exist (top-level: anywhere in the system hierarchy; scoped: among the declaring node’s direct children).boundary-not-in-context(error) — aboundaryblock inside a node kind that draws no canvas of its own.duplicate-boundary-id(error) — twoboundaryblocks 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"). Thelabelproperty 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
boundarymust 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 throughkarasu fmt; guards derived fromKrsFile’s top-level arrays do not cover per-node constructs. TPL-2032 — the scopedcontains-target-not-foundis 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;boundaryandfacetwere shipped and spec’d yet absent fromREFERENCE_DATA, whilefacet’s element-sidefacetsproperty 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).
facetlands 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.krsfile 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 thefacetsproperties 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 afacetblock 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
policyblock. A facet says which behaviours a policy covers; what the policy says stays prose indescriptionplus alinkto 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. Edges do not takefacetsin v1.- Repeated properties and repeated ids merge.
facets a, band two separatefacetslines are the same thing, and naming the same id twice is idempotent, not an error.karasu fmtcanonicalizes them to one comma-separated line. - An element may belong to any number of facets (1:N). Multi-membership is a
normal state — an
entitycan 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 piiresolves against the flat namespace offacetdeclarations, so the cross-layer addressing question thatboundary … containsandownshave 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 pclforpii) is caught just as reliably as a misspelt builtin — unlike the near-missannotation-possible-typohint, which can only compare against a fixed vocabulary. - Default rendering is unchanged. Adding
facetsto 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) — afacetsreference names no declaredfacet. Checked on the merged model, so a declaration in an imported file counts.duplicate-facet-id(error) — twofacetblocks 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"). Thelabelproperty 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 —
facetsis 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-declaredandduplicate-facet-idare decided on the merged model, since declaration and reference may sit in different files. TPL-1101 — both the declaration block and the per-nodefacetsproperty round-trip throughkarasu fmt; guards derived fromKrsFile’s top-level arrays do not cover the per-node property. TPL-2133 — every kind that acceptsfacetsis listed here, because the parser accepts it everywhere. 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;boundaryandfacetwere shipped and spec’d yet absent fromREFERENCE_DATA, whilefacet’s element-sidefacetsproperty was listed on all 14 node kinds (#2316).
Diagram legend
Section titled “Diagram legend”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.
Top-level placement
Section titled “Top-level placement”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.
Grammar
Section titled “Grammar”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 typeView scope
Section titled “View scope”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.
Example
Section titled “Example”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"}Color resolution
Section titled “Color resolution”swatchuses the literal hex color verbatim (3, 4, 6, or 8 hex digits, with#).refresolves through the same.krs.stylecascade 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 mergedbackground-color, falling back tobadge-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-inusecase) is swatched with itsborder-color, because the border is what identifies it on the canvas. - A
refwhose 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
refthat matches no rule and no node is dropped from the rendered footer and surfaced in the warning panel aslegend-ref-unresolved. Authors can then either remove the entry or add a matching style rule. .classselectors are accepted by the parser for forward compatibility but always resolve as unresolved today (.krs.stylehas no class concept — seestyle.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).
Labels are not localized
Section titled “Labels are not localized”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.
Sample file
Section titled “Sample file”examples/en/feature-samples/legend.krs exercises every primitive in one self-contained file (paste into the app to try).
What’s not in v1
Section titled “What’s not in v1”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
Drill-down and external file references
Section titled “Drill-down and external file references”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 fileimport { 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 service / domain / usecase defined deeper than the direct child of a system in another file:
import { ECPlatform.ECommerce.Order } from "./services.krs"Each segment is matched against the previously-resolved node’s children array by id (kind is not enforced). Path resolution starts from a top-level system in the imported file.
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.
When to use path syntax
Section titled “When to use path syntax”Path syntax shines when the same id appears in multiple systems — system migration is the canonical case:
system OrderSystemV1 { service OrderService { domain Legacy {} }}system OrderSystemV2 { service OrderService { domain Modern {} }}
// migration.krs — pull only V2 without renamingimport { OrderSystemV2.OrderService } from "./services.krs"Bare ids (import { ECommerce }) keep working — they remain the simplest form when the id is unambiguous.
Failure mode
Section titled “Failure mode”A path that cannot be resolved emits an import-path-not-found diagnostic naming the failing segment and the last node walked successfully:
import { ECPlatform.NotThere.Order } from "./services.krs"// → Import path "ECPlatform.NotThere.Order" failed at segment "NotThere" (#1):// no child with that id under "ECPlatform"Multi-file import semantics
Section titled “Multi-file import semantics”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).
S1. The four import forms
Section titled “S1. The four import forms”@import "theme.krs.style" // (a) style import — see §"@import scope" belowimport { 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 sectionimport "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.
S2. Whole-file import merge rules
Section titled “S2. Whole-file import merge rules”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
systemblock (user/client/service/domain/usecase/resource/ edges / infra) - all stylesheets referenced via
@importinp.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 isImportResolver.resolve(entryPath)’sentryPath— in practice the file currently open in the App / VS Code extension, or the file passed tokarasu 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-conflictwarning (chosen value + ignored value + both source locations).
- When two files declare conflicting non-empty values, the resolver picks the closer-to-root one and emits a
- Children: merged by id with find-or-create. Two children with the same id in the same merged system produce a
duplicate-node-in-systemerror (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.
S5. DAG re-arrival vs. true cycles
Section titled “S5. DAG re-arrival vs. true cycles”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 warningindex.krs: import "admin.krs" import "auth.krs"admin.krs: import { Service } from "auth.krs" // reaches auth.krs via adminauth.krs: // (no imports)
// True cycle — warning at the second arrival on a.krsa.krs: import "b.krs"b.krs: import "a.krs" // ← circular-import warningS6. 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-endpointwarning 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.
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-emptylabel/description), infra body conflicts emit no diagnostic — the intentional asymmetry: shared infra is more often refactored across files thansystem, and a property warning would be noise during a migration. - Children (
tabledeclarations 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 andtable users { ... cols B ... }in another — keep the first one and drop the second; aninfra-leaf-redeclared-silentlyinfo diagnostic surfaces the dropped declaration so the loss is visible without blocking the build. - Diagnostic: an
infra-redeclared-across-filesinfo 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:
system Blog { database ArticleDB { table articles }}
// reader.krsimport "infra.krs"system Blog { service ArticleDelivery { domain Delivery { usecase ReadArticle { resource ArticleDB.articles } } }}
// editor.krsimport "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.
S7. Deterministic order
Section titled “S7. Deterministic order”mergedFile order is determined by:
- import declarations are processed in source order within each file;
- directory imports expand to file names in alphabetical order;
- 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
systemmerges children, root entry wins for properties (S3)- TPL-2169 —
deploy/organizationpropagate through whole-file import (S4)- TPL-2170 — unresolved edge endpoint does not drop the surviving node (S6)
- TPL-1385 — same-id
database/queue/storagereopens union-merge with an info diagnostic (S4.5)
@import scope
Section titled “@import scope”- 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).
Property requirement and omission rules
Section titled “Property requirement and omission rules”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 |
Domain dispersal
Section titled “Domain dispersal”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 smellThis 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