- Sync Specification
- Overview
- ID Mapping Strategy
- Sync Directions
- Scope Boundary Handling
- Drill-Down Navigation
- Lifted Connector Deduplication
- Placement Strategy for New Elements
- Container Handling
- View Reconciliation
- Orphaned View Page Removal
- New Page Population
- Conflict Detection
- JSONC Comment Preservation
- Sync Algorithm
- Template Application
- Layout Modes
- Status and Decision Badges
- Tag-Based Styling
- Metadata and Legend Boxes
- Edge Cases
Bausteinsicht synchronizes bidirectionally between:
-
JSON Model (source of truth for structure and metadata)
-
draw.io XML (source of truth for layout/positioning)
The sync process reads both files, detects changes relative to a last-sync state, and applies non-conflicting changes in both directions.
Before any sync cycle begins, the model is validated using model.Validate().
If validation errors are found (e.g., invalid view include/exclude patterns, dangling references), sync aborts with an error message listing all validation failures.
This prevents silent data loss from typos such as a trailing dot in an element ID ("customer.") which would otherwise silently remove elements from draw.io (#176).
Every element in the JSON model has a unique key (variable name) that serves as its ID.
Nested elements use dot notation: webshop.api.auth.
In draw.io, the ID is stored in two places for reliability:
-
idattribute on<object>— used by draw.io as the cell identifier -
bausteinsicht_idattribute on<object>— redundant copy that survives if draw.io reassigns cell IDs
<object label="REST API" id="webshop.api"
bausteinsicht_id="webshop.api"
bausteinsicht_kind="container">
<mxCell ... />
</object>When views are used, cell IDs are scoped to ensure file-wide uniqueness: <viewID>--<elementID> (e.g., context—customer).
The bausteinsicht_id attribute always stores the un-scoped element ID.
Connector cells use a derived ID: rel-<from>-<to>-<index> where <index> is the 0-based position of the relationship in the model’s relationship array.
The index disambiguates multiple relationships between the same element pair (#142).
<mxCell id="rel-customer-webshop.api-0" value="uses"
edge="1" source="customer" target="webshop.api" ... />When multiple relationships exist between the same pair (e.g., customer → webshop.api at indices 0 and 3), each gets a distinct connector ID:
<mxCell id="rel-customer-webshop.api-0" value="uses" ... />
<mxCell id="rel-customer-webshop.api-3" value="manages" ... />Triggered when the JSON model changes.
| Change Type | Action |
|---|---|
New element |
Create |
Updated element (title) |
Update the title sub-cell’s |
Updated element (description) |
Update |
Updated element (technology) |
Update |
Updated element (kind) |
Update |
Deleted element |
Remove |
New relationship |
Create |
Updated relationship (label) |
Update |
Deleted relationship |
Remove the connector |
New view |
Create new |
Deleted view |
Remove the orphaned |
Triggered when the draw.io file changes.
| Change Type | Detection Method | Action |
|---|---|---|
Element renamed |
Title sub-cell |
Update model element title. Empty titles are rejected (#150). |
Element description changed |
|
Update model element description |
Element technology changed |
|
Update model element technology |
New element (from draw.io) |
|
Generate ID via |
New relationship (from draw.io) |
|
Add relationship to model. Connector label captured from |
Element deleted in draw.io |
Known |
Remove from model. Clean stale references from view include/exclude lists. Display warning listing removed elements. |
Relationship deleted in draw.io |
Connector with known |
Remove relationship from model. |
Relationship direction swap |
Deleted(a→b) paired with Added(b→a) detected in draw.io changes |
Update the existing relationship in-place (swap |
Element moved (position only) |
|
No model change. Layout is a draw.io concern. |
New elements use grouped text sub-cells instead of a single HTML label. The parent element has label="" and three child <mxCell> elements for title, technology, and description. Each sub-cell has independent font size, color, weight, and positioning — matching PlantUML-level typography quality.
<!-- Parent element: label is empty -->
<object label="" id="containers--webshop.api" ...>
<mxCell style="...;container=1;" vertex="1" parent="1">
<mxGeometry x="200" y="150" width="240" height="150" as="geometry" />
</mxCell>
</object>
<!-- Child sub-cells (parent references the element's cell ID) -->
<mxCell id="containers--webshop.api-title" value="REST API"
style="text;html=1;fontSize=14;fontStyle=1;fontColor=#ffffff;..." vertex="1"
parent="containers--webshop.api">
<mxGeometry x="0" y="20" width="240" height="30" as="geometry" />
</mxCell>
<mxCell id="containers--webshop.api-tech" value="[Spring Boot]"
style="text;html=1;fontSize=11;fontStyle=2;fontColor=#CCCCCC;..." vertex="1"
parent="containers--webshop.api">
<mxGeometry x="0" y="55" width="240" height="20" as="geometry" />
</mxCell>
<mxCell id="containers--webshop.api-desc" value="Handles all HTTP requests"
style="text;html=1;fontSize=10;fontColor=#BBBBBB;..." vertex="1"
parent="containers--webshop.api">
<mxGeometry x="0" y="80" width="240" height="40" as="geometry" />
</mxCell>Sub-cell rules:
-
Title sub-cell: Always created. Plain text (no HTML formatting needed).
-
Technology sub-cell: Only created when technology is non-empty. Value is
[bracketed]. -
Description sub-cell: Only created when description is non-empty.
-
All sub-cells use
movable=0;resizable=0;deletable=0;editable=0;to prevent accidental manipulation.
During reverse sync, ReadElementFields extracts values from sub-cells first. If no sub-cells are found, it falls back to parsing the HTML label (backward compatibility).
Older draw.io files may use a single HTML label attribute instead of sub-cells. The HTML label parser (ParseLabel) supports these formats:
<!-- Title only -->
<b>REST API</b>
<!-- Title + Technology -->
<b>REST API</b><br><font color="#CCCCCC"><i>[Spring Boot]</i></font>
<!-- Title + Technology + Description -->
<b>REST API</b><br><font color="#CCCCCC"><i>[Spring Boot]</i></font><br><font color="#BBBBBB" style="font-size:11px">Handles all HTTP requests</font>Legacy color codes (#666666 for technology, #999999 for description) are also recognized for backward compatibility.
When a view defines a scope element, the scope element is rendered as a boundary/swimlane on the view page.
-
Look up the scope element’s kind in the model (e.g.,
system) -
Derive the boundary kind by appending
_boundary(e.g.,system_boundary) -
Look up the boundary template style in the template set
-
Create the boundary element with the scope element’s title, technology, and description
-
The scope element is included in the view’s element filter so that connectors targeting the boundary element are rendered correctly (#217)
Children of the scope element (identified by dot-notation prefix, e.g., shop.api is a child of shop) are parented to the scope boundary cell.
Their parent attribute on <mxCell> is set to the scoped cell ID of the boundary, so they appear visually inside the swimlane.
Child coordinates are relative to the boundary’s top-left.
Elements that have a detail view (a view whose scope matches the element) receive interactive drill-down navigation.
On any view page where the element appears, the link attribute is set to data:page/id,view-<viewID>.
Clicking the element in draw.io navigates to its detail view (#198).
Detail views (views with a scope) get a back-navigation button that links to the parent view.
The parent view is identified as any view whose resolved element set includes the scope element.
The button is an <object> with:
-
id="nav-back-<viewID>" -
label="← <parentViewTitle>" -
link="data:page/id,view-<parentViewID>" -
A small rounded rectangle at position (20, 20) with
width=140,height=30
Back-navigation buttons are excluded from reverse sync element import (#205).
When a view does not include an endpoint of a relationship, the endpoint is "lifted" to the nearest visible ancestor in the element hierarchy.
For example, if the model has a relationship shop.api → db but only shop and db are on the view, the connector is lifted to shop → db.
True self-loops (where from == to in the model) are rendered as connectors.
However, when lifting causes both endpoints to collapse to the same element (e.g., shop.api → shop.db lifted to shop → shop), the resulting self-reference is skipped because it does not represent a meaningful relationship at the view’s abstraction level (#111, #212).
Relationships are processed in two passes to ensure correct deduplication (#197):
-
Pass 1 — Direct relationships: Only non-lifted relationships are processed. Each direct relationship records its
from→topair in a seen-set. -
Pass 2 — Lifted relationships: Only lifted relationships are processed. A lifted relationship is skipped if:
-
Its
from→topair already exists from a direct relationship in Pass 1 (direct suppresses lifted). -
Its
from→topair already exists from another lifted relationship (first lifted wins, duplicates deduplicated).
-
This ensures that when a direct relationship (e.g., api→db) and a lifted relationship (e.g., api.catalog→db lifted to api→db) map to the same pair, only one connector is created with the direct relationship’s label (#142, #197).
New elements from forward sync are placed using a simple side-by-side algorithm:
-
Find the bounding box of all existing elements on the page
-
Place new elements in a row below the existing content
-
Use a fixed spacing (40px horizontal gap, 40px below existing)
-
Set a visual marker on new elements (
strokeColor=#FF0000;dashed=1;)
New elements from reverse sync (added in draw.io) keep their draw.io position.
When generating a view that includes a container element and its children:
-
Create the container as a
swimlanestyle element -
Create children with
parent="<container-id>" -
Child coordinates are relative to the container’s top-left (offset by
startSizeheader height) -
Connectors between elements always use
parent="1"(the layer)
After forward sync applies element and relationship changes, a reconciliation step removes elements from a view page that are no longer in the view’s resolved element set. This handles cases where view include/exclude rules change without corresponding model element changes (#102).
-
Elements with a
bausteinsicht_idthat is not in the view’s resolved set are removed along with their connectors. -
The scope boundary element is excluded from reconciliation (handled separately).
-
Elements without a
bausteinsicht_id(user-added shapes in draw.io) are preserved — they are not subject to view filtering (#115). -
In no-views mode, orphaned elements (whose
bausteinsicht_idis not in the model at all) are removed (#110).
When views are deleted from the model, their corresponding draw.io <diagram> pages are removed.
Pages are identified as view-managed if their id starts with the "view-" prefix.
Non-view pages (e.g., default template pages without the prefix) are preserved (#143).
When a new view is added to the model, forward sync creates the <diagram> page and populates it with all elements from the view’s resolved set.
This is necessary because elements that already exist in the sync state are not emitted as "Added" in the ChangeSet — they are only detected as changes relative to the last-sync state.
The population step iterates over the resolved element set and creates any element not already present on the page.
The scope boundary element is excluded (handled by createScopeBoundary separately).
Elements expected only on newly created pages are excluded from draw.io-side deletion detection to prevent false "deleted from draw.io" changes (#184, #188, #189).
A .bausteinsicht-sync file stores the state after each successful sync:
{
"timestamp": "2026-02-28T12:00:00Z",
"model_hash": "sha256:abc123...",
"drawio_hash": "sha256:def456...",
"elements": {
"webshop.api": {
"title": "REST API",
"description": "Handles all HTTP requests",
"technology": "Spring Boot",
"kind": "container"
}
},
"relationships": [
{
"from": "customer",
"to": "webshop.api",
"index": 0,
"label": "uses",
"kind": "sync"
}
]
}| Field | Description |
|---|---|
|
Element title (always present) |
|
Element description (omitted if empty) |
|
Technology label (omitted if empty) |
|
Element kind from the specification (e.g., |
| Field | Description |
|---|---|
|
Source element ID |
|
Target element ID |
|
0-based position in the model’s relationship array (for disambiguation) |
|
Relationship label (omitted if empty) |
|
Relationship kind (omitted if empty) |
This file should be committed to version control alongside the model and draw.io files.
With the last-sync state, changes are detected as:
-
Model change: model differs from last-sync, draw.io matches last-sync
-
draw.io change: draw.io differs from last-sync, model matches last-sync
-
Conflict: both model and draw.io differ from last-sync for the same field
-
No change: both match last-sync
For the initial version, conflicts produce warnings:
WARNING: Conflict detected for element "webshop.api":
Field: title
Model value: "REST API v2"
draw.io value: "Backend API"
Last sync: "REST API"
-> Keeping model value. Edit draw.io manually if needed.In v1, the model value wins on conflict. This is the safer default since the JSON model is the declared source of truth for structure.
When reverse sync writes changes back to the model file, a PatchSave approach is used to preserve JSONC comments, formatting, and key ordering.
For simple field value changes (title, description, technology), PatchSave locates the target value in the raw JSONC text by walking the JSON path and replaces only the value bytes.
The rest of the document — including // single-line comments, /* */ block comments, whitespace, and key ordering — is preserved.
For structural changes (new elements, new relationships), InsertObjectEntry and AppendArrayEntry are used to insert new JSON entries before the closing } or ] of the target container, preserving surrounding comments and detecting indentation from context.
When structural changes cannot be applied via patching (e.g., element deletion), a full Save is used which re-serializes the model and does not preserve comments.
@startuml
start
:Validate JSON model;
note right: Abort on errors (#176)
:Read JSON model;
:Read draw.io XML (uncompressed);
:Read .bausteinsicht-sync state;
partition "Parse draw.io" {
:Find all ""<object>"" elements
with bausteinsicht_id;
:Find all ""<mxCell edge=1>""
connectors;
:Find new elements
(no bausteinsicht_id);
}
partition "Detect Changes" {
:Compare model elements
to last-sync state
-> model changes (added/modified/deleted);
:Compare draw.io elements
to last-sync state
-> drawio changes (added/modified/deleted);
:Identify conflicts
(same element changed in both);
}
if (Conflicts?) then (yes)
:Display warnings;
:Model value wins;
else (no)
endif
partition "Apply Forward Sync" {
:Remove orphaned view pages (#143);
:Create scope boundaries for views with scope;
:Add new model elements to draw.io
(from template, side-by-side);
:Update modified elements in draw.io
(label, tooltip, technology, kind);
:Remove deleted elements from draw.io
(and their connectors);
:Add/update/remove connectors
(two-pass: direct then lifted);
:Populate new view pages with
all resolved elements (#184);
:Reconcile view pages:
remove elements not in resolved set (#102);
:Apply drill-down links (#198);
:Create back-navigation buttons (#198);
}
partition "Apply Reverse Sync" {
:Detect direction-swap pairs (#185);
:Add new draw.io elements to model
(sanitizeID, collision guard);
:Update modified elements in model
(title, description, technology);
:Remove deleted elements from model
(with warning, clean view references);
:Add/update/remove relationships;
:Apply direction swaps in-place;
}
:Write updated JSON model
(PatchSave for field changes);
:Write updated draw.io XML;
:Write new .bausteinsicht-sync state;
:Display summary of all changes;
stop
@enduml
During forward sync, new elements receive their visual style and sub-cell styles from a template.
-
Read the template draw.io file
-
Find elements marked with
bausteinsicht_template="<kind>"on their<object> -
Extract the
stylestring,width, andheightfrom the template element’s<mxCell> -
Find child
<mxCell>elements withparentmatching the template’sid— these define sub-cell styles for title (-title), technology (-tech), and description (-desc) -
Apply element style and sub-cell styles to the new element
<!-- In the template file -->
<object label=""
id="template-container"
bausteinsicht_template="container">
<mxCell style="rounded=1;whiteSpace=wrap;html=1;
fillColor=#438DD5;strokeColor=#3C7FC0;container=1;"
vertex="1" parent="1">
<mxGeometry x="0" y="0" width="240" height="150" as="geometry" />
</mxCell>
</object>
<!-- Sub-cell style templates -->
<mxCell id="template-container-title" value="Title"
style="text;html=1;fontSize=14;fontStyle=1;fontColor=#ffffff;..."
vertex="1" parent="template-container">
<mxGeometry x="0" y="20" width="240" height="30" as="geometry" />
</mxCell>
<mxCell id="template-container-tech" value="[Technology]"
style="text;html=1;fontSize=11;fontStyle=2;fontColor=#CCCCCC;..."
vertex="1" parent="template-container">
<mxGeometry x="0" y="55" width="240" height="20" as="geometry" />
</mxCell>
<mxCell id="template-container-desc" value="Description"
style="text;html=1;fontSize=10;fontColor=#BBBBBB;..."
vertex="1" parent="template-container">
<mxGeometry x="0" y="80" width="240" height="40" as="geometry" />
</mxCell>The bausteinsicht_template attribute identifies which element kind this template applies to.
The element style, dimensions, and sub-cell styles (font size, color, position) are all copied to new elements of that kind.
Relationship connectors also use template styles. The connector template is identified by bausteinsicht_template="relationship" on the <mxCell>:
<!-- In the template file -->
<mxCell bausteinsicht_template="relationship"
style="edgeStyle=orthogonalEdgeStyle;rounded=1;html=1;
endArrow=block;endFill=1;strokeColor=#666666;"
edge="1" parent="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>|
Note
|
Unlike element templates which use <object> wrappers, the connector template is a bare <mxCell> since connectors do not need custom metadata attributes.
|
Each view chooses how its fresh-page layout is computed via its layout field; an out-of-set value is rejected by validation (BR-014). computeLayout (internal/sync/layout.go) runs only when a page is first populated or rebuilt with --relayout; incremental additions to an existing page use cursor-based placement (computePlacement) regardless of layout. computeLayout dispatches on the field:
-
layered(the default, also used whenlayoutis empty) — elements are arranged in horizontal rows grouped by kind, in the specification’s element order. Within a tier, a view’s scoped children are ordered by relationship adjacency (BFS) so related ones sit near each other; all other elements are ordered alphabetically. -
grid— all elements are placed in a simple grid; useful for overview pages. -
none— the legacy horizontal-row placement; sync does not arrange beyond a single row, leaving layout to the user.
Sync only places elements that are new to a page; it never moves elements already positioned (see Placement Strategy for New Elements). The sync --relayout flag (ForwardOptions.Relayout) overrides this: it clears and re-lays-out all view pages from scratch, deliberately discarding the hand-tuned positions of managed elements. clearPageElements removes only Bausteinsicht-managed cells (bausteinsicht_id objects and rel-… connectors), so any shape you drew yourself is left untouched.
When an element carries decisions (linked ADR IDs), forward sync renders one decision badge per decision as a child cell near the element’s top-right corner (the X offset is derived from the element’s width attribute, which defaults to 100 when absent — as it is on generated cells whose size lives in the child <mxGeometry> — so the placement approximates the top-right rather than tracking the exact width). The entry point is synchronizeDecisionBadges, which clears stale badges and then calls AddDecisionBadges (internal/sync/). Each badge is coloured by the referenced ADR’s status (DecisionBadgeColor: blue = active, yellow = proposed, grey = superseded/deprecated) and carries a bausteinsicht_decision_id attribute.
|
Note
|
A status badge renderer (AddStatusBadge, coloured by the element’s lifecycle status via StatusColor) is implemented and unit-tested but is not currently wired into forward sync — no production code calls it. Whether to wire it up (render a lifecycle badge alongside decision badges) or remove it is an open decision; until then, element status is validated (BR-029–BR-031) but not visualised.
|
An element’s tags can drive its visual style. For each tag, applyTagStyles (internal/sync/forward.go) looks up the matching TagDefinition in specification.tags and merges its style properties into the element’s draw.io style string. Tag styles layer on top of the template style, so a tag can override fill colour, stroke, or font without changing the template. Tags that resolve to no TagDefinition, or definitions without a style, leave the base style unchanged.
Forward sync can add two informational boxes to each view page:
-
a metadata box (
createMetadata) showing the view’s title and description, the model path, the sync timestamp, and theconfig.author/config.repovalues; -
a legend (
createLegend) showing the element kinds used in the view.
Both are enabled by default and switched off per model via config.metadata: false and config.legend: false — the gate is Config.Metadata == nil || *Config.Metadata, so an unset value means on. The metadata box additionally needs the sync to supply the model path and timestamp, which the normal sync / watch path always does. The boxes are keyed by metadata-<viewID> / legend-<viewID> cell IDs and are updated in place on subsequent syncs rather than duplicated.
The same element ID may appear in multiple <diagram> pages.
Each page has its own <object> with independent position and a scoped cell ID (<viewID>--<elementID>).
Changes to metadata (title, description) in one view propagate to all views during sync.
If a connector’s source or target references an element not present on the current page, the connector is removed with a warning.