L5 Workflow 관리형 클라우드ing Rules
This document defines the rendering invariants for the L5 workflow map. Changes to TaskFlowMap.tsx or workflowLayout.ts must preserve these rules.
Three-zone canvas
Every L5 map uses one shared workflow canvas with three visual zones:
- External inbound connections: entities entering the current L5 from another L5.
- Main workflow: the current L5's internal entities, transitions, gateways, conditions, START, and END nodes.
- External outbound connections: existing L5 entities that are the terminal point of the current map and continue to another L5. An L5 proxy that still has a successor in this map is not a terminal point — it is a mid-flow call and stays inline in the main workflow (see "Entry vs return"). A terminal proxy that a called L5 returns to is still a call card (
CALL L5+↖ 복귀 후 종료) inside zone 3 — it is an outbound endpoint and a return target at the same time.
The zones are separated by horizontal divider lines. A divider is a visual boundary, not an entity and not an additional workflow node. The three zones must share the same canvas coordinate system and width.
Cross-L5 direction
The same cross-L5 relationship is shown from two perspectives:
Source L5 ──▶ Target L5
Source L5 map: the target-L5 entity is an external outbound endpoint.
Target L5 map: the source-L5 entity is an external inbound endpoint.Inbound and outbound rendering must use the persisted workflow_transitions.relationshipType. Only EXTERNAL_L5_HANDOFF relationships explicitly created by a user through the external-inbound flow may be rendered as cross-L5 dashed connections or projected into inbound/outbound zones. A different source and target L5, a missing successor, or a legacy row is not sufficient evidence and must never be inferred as an external relationship. Existing development rows are migrated to INTERNAL unless explicitly re-registered by a user.
Legacy rows whose target is an L5 proxy (migration
0018/0029). A row whose target step points at another L5's proxy entity is a handoff by construction — the proxy exists only to represent that L5 — but rows written by the old editor were stored asINTERNAL, so they disappeared from the counterpart L5's inbound derivation while still showing on the source map (which reads topology, not the mark). The earlier repairs (0003/0010,0025) only handled rows whose target step belonged to a different L5, so proxy targets were missed. The one-time repair stamps exactly those rows (target step's process node level = 'L5'and not alreadyEXTERNAL_L5_HANDOFF); runtime still never infers an unmarked row. Re-registering the connection in the editor (delete the successor row and add it again) performs the same stamping through the write path.
Direct handoff data invariant
An external L5 relationship is a direct handoff, not a summary of the entire preceding chain. The source of an L5 → L5 relationship must be the current L5's last, directly handing-off L6. A source L6 that already has an internal successor must not be stored as the external handoff source. For example, in BO등록 → BO확정 → Validated, the Validated relationship source is BO확정; BO등록 belongs only to the Identified workflow and must never be propagated into Validated's inbound metadata. Each L5 map and each downstream consumer must use only the immediately preceding entity, without traversing or aggregating earlier ancestors.
Entry vs return
A cross-L5 link into this workflow is one of two kinds and they must never be mixed, otherwise a call-return flow (A calls B, B finishes, A continues) reads as "started at B and ended at B".
- entry — a new start point coming from another L5. It is drawn as an inbound card in zone 1 (blue), with a dashed arrow whose colour follows the outcome (
A/B/… purple,YESgreen,NOred,DEFAULTslate — see the connection-style table indocs/workflow-common-rules.md). - return — the calling L5 comes back after finishing its own flow. It is not a new start point and it is not a separate band: it returns to the call site, and the call site is this L5's other-L5 proxy entity, which already sits inline in the main workflow because it has a successor (that is exactly what makes it a mid-flow call). The return is therefore drawn on that inline node: a
CALL L5card in the same blue box every L5 card uses (only the label differs —CALL L5), carrying a↖ 복귀 · <completing step>badge (workflow-call-return-<stepId>, red-toned text), and the node's outgoing edge — this L5's continuation after the call — is labelled복귀. The call/return meaning lives in the label and badge, never in a different box colour. A call site has two shapes: with a successor it is an inline card, and without one (the call is the last thing this L5 does) the same proxy sits in zone 3 as an outbound endpoint — it still renders as aCALL L5card, and its badge reads↖ 복귀 후 종료because this L5 ends after the call returns. Drawing it as a plainTO L5card would erase the return.
Never clone the returning L5 into a second card (a separate band) and never draw a long arrow from the canvas top down to the inline node: one L5 must appear once, at the call site.
The server marks a link as a return only when both hold: the resolved target is the call-site proxy (this L5's other-L5 proxy entity), and this L5 has evidence that it called that L5. The evidence is one of two persisted facts.
- The call-site proxy has a successor (
… → [call] → …= this L5 continues after the call). - A handoff row of this L5 specifies its landing inside that L5 (an explicit
… → <other L5's step>target).
Evidence is required because both directions of a call-return pair are stored in the same shape (a source L6 → the other L5's card), so without it a call and a return are indistinguishable. When a pair carries neither form of evidence (for example Qualified ↔ 입찰수전위, where neither row specifies a landing), the incoming link stays a genuine entry — flipping it blindly would turn the callee's call into a return and delete its entry card. Specifying the landing of the calling row (… → <callee's entry step>) makes the return appear with no other change, and the calling map then draws the call edge instead of leaving a dead-end step (see "Call edge in the calling L5's map").
resolveInboundRow owns the decision (isReturn); the client groups returns by their resolved call-site step (returnsByCallSiteId) and draws the badge on that call card — inline when it has a successor, in zone 3 when it does not.
The target of an entry arrow is always the target L5's own entry step (condition → gateway → first process step). A terminal proxy of the source L5 in that map is never the entry target: it belongs to zone 3 (external outbound), so resolving to it would draw the forbidden long arrow from the top inbound card straight down to the bottom outbound card — a map that reads "entered at A and ended at A" instead of "entered from A, ran the internal entities, then handed off to A". The same holds in the calling L5's map: when its call site is a one-way handoff rather than a mid-flow call, the counterpart stays an entry, and that entry points at the caller's own entry step. This fallback is also what keeps a callee's entry card when a pair carries no caller evidence at all (see above).
Call edge in the calling L5's map
A call has two ends and both must be drawn: the callee's map gets the entry/return card, and the caller's map must draw the edge from the handing-off L6 (or condition) to this L5's other-L5 proxy card.
That edge is not always in the data. A handoff whose landing is a concrete step inside the callee (… → <other L5's step>) is filtered out by normalizeWorkflowMap (the step is not in this map), so the caller's map used to show a dead-end L6 plus a floating proxy card. useWorkflowCanvasLayout therefore synthesizes the missing edge from the handoff relationship (handoffEdges: source step → this L5's proxy of the target L5) and feeds it through the same pipeline as real rows — outbound-card detection, zone-3 placement, routing, and WorkflowEdgesCanvas. Synthesized edges carry a negative id, so they can never collide with a row id and are identifiable in the DOM (workflow-handoff-edge-<handoff id>). When the handoff already targets the proxy (no landing specified) the edge exists, so nothing is synthesized.
The relationship must also stay visible in the editor, for the same reason: the relation editor receives outboundTransitions and lists a handoff whose landing is outside this map as → <target L5> · landing <step> (return form: ↖ <target L5> · return), deleting it through the same row id (flow.deleteTransition). Without that list the 「현재 후행 연결」 block of the handing-off entity looks empty even though the relationship exists, and the natural reading is "my relationship disappeared".
The branch detail panel (decision / automatic condition, WorkflowGatewayEntityDetail) shows the same relationship rows inside its own per-outcome successor list — one list, not a separate block. The target cell is locked (🔒 타 L5 · Qualified · 착지 RFP접수) because that panel cannot express a landing inside another L5, while the outcome and label stay editable: the save sends them as handoffBranches (partial patch of outcome/label only) alongside the replaced branch list. The server therefore keeps EXTERNAL_L5_HANDOFF rows out of the branch reconcile — a branch save never deletes them — rejects an unknown row id, and rejects an outcome that collides with an untouched handoff (CONFLICT). A branch whose target is an L5 is always stored as a handoff (EXTERNAL_L5_HANDOFF), so the counterpart L5 derives its inbound; storing it as an internal transition was why a target-L5 branch created from this panel never appeared on the other L5's screen.
Editor sections follow one reading order: confirmed connections first (each with a delete button), then the input form, then the add button. WorkflowGatewayEntityDetail lists 「등록된 선행 엔티티」 (workflow-gateway-predecessor-list) above the predecessor form with 「선행 연결 추가」 (workflow-gateway-predecessor-add) below it, and its successor list shows the confirmed handoff rows first, the outcome form next, and 「결과 상태 추가」 (workflow-gateway-successor-add) last. Both add buttons use the same outlined style with a plus icon — a differently shaped "save" button buried inside the form (the old predecessor button) made the two flows read as different actions. Both headers also carry a registered count (등록 N) and nothing else: the registered cards already show every value (outcome, target, label, landing), so a separate summary strip would only repeat them. The count includes only rows persisted on the server, never an unsaved input form. The input form itself is always visible above the add button and that button commits it into the list once outcome, target (and condition expression for auto conditions) are filled — the button never injects a blank row between the confirmed rows.
Card size standard
Every L5 card — the inline mid-flow call site, the zone-3 outbound endpoint, and the zone-1 inbound card — uses one box: L5_CARD_SIZE (166×88). L6 document cards are 166×66, gateways/conditions 142×82, and START/END 126×38.
An L5 card carries up to two relationship lines (FROM L6 · … and TO L6 · … on an outbound card, ↖ 복귀 · <completing step> on a call node), so it is taller than an L6 card. Placement (dimensions(), the placeOutboundCards defaults, the inbound card height) and the render must read the same constant, and the row step plus the bottom reservation (rows × L5_CARD_ROW_STEP + 58) derive from it — otherwise a card overflows the slot the layout reserved for it.
Loop-type L5 (per-usage embed)
A cross-L5 entity added to a map may be declared a loop type for that map only: the host step carries embedKind = 'LOOP' (default HANDOFF, which keeps every rule in "Entry vs return" unchanged, including the caller-evidence requirement). This is a per-usage declaration, not a property of the L5 node — the same L5 used in another map keeps that map's own value.
A loop-type L5 is fully embedded instead of being represented by a proxy card alone:
- its internal flow (L6 entities, decision diamonds, automatic conditions, START/END and its internal transitions) is drawn inside the host canvas in its own full-width band, at the same entity scale as the source L5 map — never shrunk; horizontal scroll is used when the band is wider than the viewport (the geometry the L4 integrated map already uses for an expanded L5,
docs/l4-integrated-workflow-map-rules.md§5·§6); - the host flow is drawn as
predecessor → loop container → (embedded entry step)…(embedded return step) → host successor. The return is therefore a structural edge, not a derived badge: theisReturninference does not apply to a loop-type entity, and the embedded area's exit is not a second card for the same L5. Concretely: the server does still report return evidence for a loop container (the predecessor relation is unchanged — measured: Qualified153receives one row from inner step17), so the renderer must drop it —returnsByCallSiteIdexcludes loop containers and no red↖ 복귀 · <step>line is drawn. Neither host edge of a loop container carries a label (2026-09-28): the incoming arrow already points at the LOOP box ("루프 안으로" was duplication) and the outgoing line is already past the return (so "복귀" was wrong; its forced label also used the label-less fallback(midX, routeStart.y)and was measured 40 px off the line — Qualified153 → 146). Both edges keep only their markers (workflow-loop-entry-edge-<transitionId>/workflow-loop-return-edge-<transitionId>); "goes and returns" is read from the box's↺ 갔다 온다badge and, when expanded, from the entry/return arrows inside the box; loopEntryStepId/loopReturnStepIdpin the anchors inside the target L5. When null, the fallback is START → first step, and END → reachable terminus; if more than one terminus is reachable the map fails validation instead of guessing — an order that is not stored is never inferred;- nesting is limited to one level: a loop inside a loop stays a collapsed call box. Instance keys are namespaced by the host step id, so the same L5 can be embedded twice in one map;
- embedded content is read-only (the source L5 owns its entities and relations) and a click opens the source L5 map — the same boundary as "L6 entities in an L5 map carry connection information only";
- validation failures — a loop declared without a return edge or anchor, an anchor that is not a step of the target L5, a self loop, nesting past one level, a target L5 that no longer exists — are listed in the design-quality panel. A loop-type declaration must never silently disappear from the picture;
- a one-way handoff stays a
TO L5card and keeps every rule in "Entry vs return".
BPMN exchange maps the loop type to a standard subProcess (expanded) or callActivity (collapsed); no new custom node kind is introduced (docs/workflow-map-bpmn-subset-design.md).
Implementation status (2026-09-28). Implemented end to end for one level of nesting:
- declaration columns
workflow_steps.embedKind/loopEntryStepId/loopReturnStepId(PostgreSQL migration0021, declared in bothdrizzle/schema.pg.tsanddrizzle/schema.mysql.ts); flow.getreturnsloopEmbeds(target L5's visible steps and transitions plus the resolved anchors and the anchor errors),flow.loopTargetOptionsfeeds the anchor pickers andflow.setStepEmbedKindwrites the declaration — the anchors are validated against the target L5 before they are stored;- collapsed by default: a loop entity is drawn as one L5 box (
workflow-loop-container-<stepId>,LOOP L5+↺ 갔다 온다) with an expand/collapse toggle (workflow-loop-toggle-<stepId>,aria-expanded). Its internals are never expanded on first paint — otherwise another L5's L6s read as steps of this map; - expanding grows the container box itself (
workflow-loop-container-<stepId>withdata-loop-expanded="true") and draws the target L5's internal flow inside that box (workflow-loop-frame-<stepId>is the box's inner layer,inset-0), at the source entity scale, with read-only cards (workflow-loop-inner-<hostStepId>-<innerStepId>; clicking opens the source L5) plus an entry arrow from the box head into the first step and a return arrow from the last step to the box's bottom centre (workflow-loop-connector-in-<stepId>/-out-<stepId>). The old behaviour — keeping the 88 px box and painting a frame under it — is what made the expansion overlap the entities that follow; - the expanded size is reserved by the host layout:
layout()takes a per-step size override (sizeOverrides), so the enlarged box claims its row space and every later step is moved down/around. Reserved space is what removes the overlap — a frame painted on top of the canvas cannot; - the expanded size is derived from the inner flow's bounding box, not from the layout canvas:
layout()always reports a 980 px canvas, which used to make the frame 1012 px wide.loopFrameContentBoundsmeasures the real content bounds (it does not move the coordinates) andloopFrameSizeadds the padding, clamped below byLOOP_FRAME_MIN_WIDTH(166) so the head text still fits;placeLoopFramepulls that bounding box inside the box by offsetting the frame origin. Measured on Qualified: expand makes the box 198×474 (X grows by 32 px) instead of 1012 px wide, with zero overlaps and zero routing failures, moving the following steps down by 386 px; - the inner flow runs through the same routing pipeline as the host (
routeWorkflowWithHorizontalReflow: box↔box X alignment, bend minimisation, port rules). Its coordinates must therefore not be pre-shifted — routing shifted coordinates at x≈0 made the alignment pass clamp the successors toALIGN_SIDE_MARGIN(28) and break the axis (measured: centre 83 → 111 with an extra staircase bend). Only the frame origin moves; measured inner chain after the fix: centres490 / 490 / 490, pathsM 490 66 L 490 142andM 490 208 L 490 284(zero bends); - the expand/collapse control is an icon button in the card's top-right corner (absolutely positioned, so it is not part of the head height calculation: collapsed
▸/ expanded▾,aria-label"루프 펼치기 / 루프 접기",aria-expanded) — the card's label already reserves that corner (pr-14), and the same chevron pair is the convention used by the directory trees; - a container that has no successor (it is drawn as a bottom-band outbound card, which contradicts "goes and returns") cannot be expanded: its toggle is disabled and carries the reason, because the band places cards at the standard size and has no place to reserve the expanded rect;
- inside the frame, nodes and transitions carry screen-only synthetic ids (
hostStepId * 1000000 + innerStepId; edges live in a negative range) so they can never collide with host ids, and the host edges entering/leaving the container keep their markers (workflow-loop-entry-edge-<transitionId>/workflow-loop-return-edge-<transitionId>) but no labels (the arrow into the box needs no "into loop" text, and the outgoing line is past the return); - a self-referential proxy is removed from the frame: when the callee hands back to the L5 being drawn, that card is not drawn inside the frame — the step whose edge entered it becomes the return point, so the picture never shows "Qualified inside Qualified";
- a declaration whose anchors cannot be resolved (after removing a self proxy) is not expanded — the box stays, and the reason is listed with the box and in the design-quality panel;
loopAnchorMissingis reported in the design-quality panel when an anchor cannot be resolved;.bpmnexchange writes a loop proxy asbpmn:callActivity(standard element,calledElement= the target L5's collapsed pool) withpc:embedKind/pc:loopEntryStepId/pc:loopReturnStepIdextensions, and import restores them;- the catalog transfer payload carries the declaration per step (
embedKind,loopEntryRef,loopReturnRef) so a transfer cannot drop it; - the write paths that silently lost the handoff marker are fixed (
WorkflowEntityRelationshipEditor.savePredecessorsuses the canonical handoff mutation,flow.connectpromotes an L5 proxy target, and PostgreSQL migration0022repairs rows written before that).
Not implemented: nesting past one level (a loop inside a loop stays a collapsed call box, by design) and an expanded bpmn:subProcess form of the loop (the exchange always emits the collapsed callActivity). Implementation notes live in client/src/features/workflow-map/README.md.
Single rendering rule
An entity is rendered exactly once. Moving an outbound endpoint to zone 3 means excluding that existing step from the main layout and assigning it a final position in zone 3; it must never be cloned as a second box. Internal predecessor routes must be recalculated against the relocated position.
Inbound source cards are grouped by source L5, but each relationship remains a separate arrow. When one source L5 has multiple inbound relationships, every arrow must identify its source entity so that the viewer can distinguish, for example, Solutioned entity A → Won entity A from Solutioned entity B → Won entity B.
Inbound spacing and dividers
External inbound cards are content-bearing entities, not fixed-size decorations. Their row footprint must reserve the card's full rendered height plus a minimum 100 px vertical connector clearance before the next inbound row or the main workflow. A later card or row must never overlap a preceding card, its labels, or its connector path. If card content becomes taller, the row and canvas must grow accordingly; do not compensate with clipping, negative offsets, or CSS overlays.
The inbound/main divider must be positioned below the complete inbound card footprint, including the reserved clearance. It must never pass through, align to, or overlap the bottom edge of an inbound card. Divider placement must be derived from the same measured or reserved card geometry used by the arrows and main-flow top offset, so the divider, cards, and routes cannot drift apart.
The minimum-clearance invariant is:
next row/card top − preceding card bottom >= 100 px
main workflow top − inbound card bottom >= 100 px
divider y > inbound card bottomAny fixed spacing constant is only a lower-bound reservation. When content can vary, prefer measured card heights or a conservative reserved height over a hard-coded row height that can be exceeded by text.
Routing priority
The ordered priority list, the bend limit, the recovery-pass order and failure visibility are defined once in the shared doc: docs/workflow-common-rules.md (「라우팅 우선순위와 복구 순서」). This document does not repeat them — it lists only the L5-specific consequences.
Box-to-box hops are an invariant, not an optimisation. When a box entity (L5 or L6) follows box predecessors, its X centre must equal the average of those predecessors' centres, and the connector between them is a vertical straight line. A dedicated pass runs before routing (alignBoxesToBoxCenters, fed by the kinds in workflowEdges), so the result does not depend on whether moving the box also reduces bends — the later optimisation pass (alignSuccessorsToPredecessorCenters) keeps only the cases this invariant does not cover (diamond predecessors, mixed predecessors). Because the score-driven passes that finish the layout (decision-node optimisation, failure recovery) can move a box again, the axis is re-asserted afterwards (reassertBoxAlignment): the successor follows its predecessor's axis, and the re-assert only yields when it would increase routing failures. Measured case: in Qualified the score pass pushed 152 입찰수전위요청 to x=716 while the loop card 153 입찰수전위 stayed at 490 — a 226 px deviation with a staircase connector; after the re-assert both sit at 716 and the connector is the straight line M 716 530 L 716 606. Safety still outranks alignment: a move that would overlap another entity or leave the canvas is not made, and when parallel branches cannot all sit on one axis (several siblings sharing a box predecessor) the first one takes the axis and the others keep their row placement. Verified on the live data through the app pipeline: every L5 map has zero box↔box centre deviations and zero routing failures.
Call nodes are axis-aligned by default. An inline mid-flow call node (this L5's other-L5 proxy) starts on its predecessor L6's center axis — a call-return reads as "left this L6, came back to this point", so a node floating at its row's center breaks the axis. alignCallSitesToPredecessorCenter applies this before routing (routeWorkflowWithHorizontalReflow's callSiteStepIds option, fed by useWorkflowCanvasLayout), and it never moves a node into an occupied slot. The recovery passes above can still move it afterwards — safety still outranks alignment, and the "excessive movement" baseline for those passes is the aligned layout, so they do not quietly undo the alignment.
A box hands off to a diamond below it from its bottom centre. When a decision or automatic-condition node is the next step (its top edge is at or below the source box's bottom edge), the connector leaves the source box at its bottom centre even when the horizontal offset is large, and enters the diamond's top vertex. Leaving from the side face would read as a sideways move and bend into the vertex from the side; leaving from the bottom reads as "goes down to the next step". Other directions (target above, or level with the source) keep the direction-priority rule directionalSourceAnchor. Measured case: in Qualified the loop card 153 입찰수전위 → 146 입찰참여여부 left from the left face (633,650); it now leaves from the bottom centre (716,694).
A diamond leaves toward a box from its side, and toward a bypass from its bottom. Bend count comes first: for a two-branch decision or automatic-condition node, if one branch's target sits on the node's vertical axis (centre offset ≤ ROUTE_CLEARANCE 18), the nearest such branch takes the bottom vertex straight down (zero bends) and the other branch turns out of a side vertex (planDecisionExitPort) — this outranks the YES/NO and box/bypass conventions. When neither branch is axis-aligned the conventions below decide. When both outcomes continue into box entities (L5/L6), each branch leaves from its own side vertex — the left/right split is what makes the two results readable, and it is decided per branch by the target's kind and position (the YES=right / NO=left convention still applies where it matches). Left versus right is chosen by the side that does not cross another connector: a box branch leaves toward the side its target actually sits on, and the convention is only the tie-breaker for a target that is aligned with the diamond's axis (where either side is equally non-crossing). When only one outcome continues into a box and the other is a bypass (it flows into another decision or a merge point without visiting a box), the bypass leaves from the bottom vertex and the box-going branch keeps a side vertex — so "which result went into a box" is visible from the port, not only from the label. Decisions with three or more branches keep the port-reuse and branch-spacing rules instead of the bend verdict. Measured case: in Qualified, condition 145 사업규모 [A]→152 입찰수전위요청 (a box) and [ELSE]→146 입찰참여여부 (a decision) — with the bend verdict the axis-aligned [ELSE] branch drops straight from the bottom vertex and [A] turns out of a side vertex, taking the map from 7 bends to 3–4 with zero crossings. Inputs whose target kind is unknown keep the previous convention.
Sibling branches never share a horizontal lane. Branches leaving the same diamond avoid each other's lanes entirely: a point where two sibling connectors merely touch (a T-junction) reads as a crossing, so the second branch is routed through a different lane instead (segmentsTouchOrConflict, applied only between branches of the same source node so ordinary node-port adjacency stays allowed). Measured case: in Qualified the 145 사업규모 branches [A] → 152 and [ELSE] → 146 both took the lane y=446 and met at (490,446); the bypass now uses y=428 instead.
For IF RULE/gateway results, the result entities are not fixed to the center. Candidate horizontal positions must be evaluated by routing the final source and target positions, rejecting collisions and paths over the bend limit, then selecting the safest candidate. Source ports and target ports must be recalculated after every accepted repositioning.
The bounded four-bend exception for a dense binary decision is allowed only where the shared routing code explicitly documents and tests it. It must not become a reason to force overlapping paths or to keep an entity centered.
A failed edge still shows. A transition whose route cannot be found within the bend budget is drawn as a dashed grey fallback (buildFallbackRoute, workflow-edge-fallback-<transition id>) instead of disappearing, so a branch missing from the picture is always a data question, never a silent layout drop. The failure panel keeps listing it. Two L5-specific consequences of the shared rules: the last-resort pass allows one extra turn for failed edges only (MAX_DETOUR_BENDS), and a label never occupies the departure band of a branch that is still unrouted (LABEL_DEPARTURE_BAND_HEIGHT) — that band is how the second result of a decision leaves its node.
Implementation checklist
When changing the map renderer or layout utility, verify that:
- alignment criteria are single-sourced: the shared calculation is
alignedCenterXwithALIGN_SIDE_MARGIN/ALIGN_MIN_SEPARATION/SAFETY_MIN_SEPARATIONat the top ofworkflowLayout.ts(the three passes — call card, ordinary successor, outbound band — call it instead of repeating the clamp), and the per-target table (box, call card, outbound band, inbound band, loop frame, diamond, START/END, safety net) lives indocs/workflow-common-rules.md「정렬 기준」; - the expanded loop's geometry is single-sourced in
loopEmbedPlan.ts:loopFrameContentBounds(content bounds; coordinates stay where routing put them) →loopFrameSize(expanded box,LOOP_FRAME_MIN_WIDTHfloor) →layout'ssizeOverrides(the reservation that re-lays out the host) →placeLoopFrame(the frame inside that box, pulling the content bounds in through its origin). The inbound band's margin (INBOUND_CARD_SIDE_MARGIN16) is intentionally different from the box margin (28); the reasons are in that table. There is no loop-frame canvas margin any more — the frame lives inside the box; - all three zones remain inside the same transformed canvas;
- divider lines use the common canvas bounds and are not derived from padded content boxes;
- zone labels and descriptions may have padding, but that padding must not change divider width;
- moved outbound entities retain their original identity and are present once;
- multiple inbound arrows carry per-source-entity labels;
- inbound entry candidates use the same visibility rule as the map (
normalizeWorkflowMap): a process step whose node is gone (orphan) is never chosen as an entry target, so an unresolved landing can only fall back to a step the canvas actually draws — the map never shows an entry row whose target has no position or name; - inbound rows and the inbound/main divider expand with card content and preserve at least 100 px of vertical connector clearance;
- the inbound band height and the divider y come from the layout hook (
useWorkflowCanvasLayout) and are drawn — not recomputed — by the card component, so the divider cannot drift from the canvas height or the main-flow top offset; - a map with no main-flow steps still gets a canvas that covers the full inbound/return band footprint;
- horizontal reflow is allowed before accepting a crossing or an over-bent route;
- a handoff whose landing is outside this map still shows its call edge (synthesized
handoffEdges), and its proxy card is placed in zone 3 instead of floating without edges; - a return whose call card is a zone-3 outbound endpoint is drawn as
CALL L5with the↖ 복귀 후 종료badge — not as a plainTO L5card and not as an extra zone-1 card; - return classification requires caller evidence (a call-site successor or a landing specified inside the called L5); without evidence the incoming link stays an entry, so a callee's entry card is never lost;
- L4 integrated maps remain read-only compositions and do not gain L5-only inbound/outbound zones.