Chapter 41: Building Boundaries
The runtime from Chapter 39 can route messages. The shell from Chapter 40 owns the application’s lifecycle. Neither knows anything about where an event came from in the application’s structure. A profile.saved event with no context is a fact about something, but the runtime can’t tell whether it’s about the user’s own profile, an admin editing someone else, or an automated migration.
Boundaries fill the context gap. They’re the architectural piece that names where in the application a region lives, what feature it belongs to, what entity it’s about. This chapter builds the boundary system in code — both the declarative <kit-boundary> element and the programmatic runtime.attachBoundary API, plus the context-collection algorithm that walks the DOM tree to assemble enriched events.
Two Ways to Attach a Boundary
Section titled “Two Ways to Attach a Boundary”A boundary can be attached two ways. Both produce the same runtime effect.
The declarative way is a <kit-boundary> custom element with attributes for the context it supplies:
<kit-boundary surface="user-table" feature="admin"> <kit-boundary surface="table-row" entity-type="user" entity-id="user_123"> <kit-button meta-event="user.delete_requested">Delete</kit-button> </kit-boundary></kit-boundary>This is the natural form when the application’s structure is server-rendered or hand-authored.
The programmatic way is runtime.attachBoundary(element, options), which adds the same context to a given element from JavaScript:
const boundary = runtime.attachBoundary(rowElement, { context: { surface: 'table-row', entityType: 'user', entityId: 'user_123' }})
// later:boundary.updateContext({ entityId: 'user_456' })boundary.destroy()This is the natural form when a JavaScript framework or a dynamic loop is producing the regions.
The two forms produce structurally identical results — both set data-* attributes (or meta-* for <kit-boundary>) on the target element, and both are read by the same context-collection algorithm at event time. The runtime treats them interchangeably.
The <kit-boundary> Custom Element
Section titled “The <kit-boundary> Custom Element”The declarative element is small:
class KitBoundary extends HTMLElement { static observedAttributes = [ 'surface', 'feature', 'entity-type', 'entity-id', 'route', 'mode' ]
connectedCallback() { // Boundaries don't affect layout. They're invisible context wrappers. if (!this.style.display) { this.style.display = 'contents' } }
attributeChangedCallback() { // No reactive behavior needed — attributes are read at event time // by the metadata boundary's context-walk algorithm. }}
customElements.define('kit-boundary', KitBoundary)The element is barely an element. It has no shadow DOM. It has no rendered output. Its only behavior is to declare context through its attributes and to be invisible in layout (display: contents means the element doesn’t generate a box of its own — its children render as if the boundary weren’t there).
This is intentional. The boundary is a semantic artifact, not a visual one. The DOM’s structure carries the meaning; the element is the substrate for that meaning. Putting visual styling or behavior into the boundary element would conflate two concerns and make the boundary less reusable.
A boundary’s attributes are read by the metadata-boundary listener (Chapter 42) at event time. The element itself doesn’t fire events or maintain state. It’s a passive marker that the context-walk algorithm uses to assemble events.
The Programmatic Attachment
Section titled “The Programmatic Attachment”The runtime.attachBoundary API mirrors the declarative form:
interface BoundaryOptions { context: Record<string, unknown>}
interface BoundaryHandle { updateContext(context: Record<string, unknown>): void destroy(): void}
function attachBoundary(element: Element, options: BoundaryOptions): BoundaryHandle { // Apply context as data-meta-* attributes applyContext(element, options.context)
return { updateContext(newContext) { applyContext(element, newContext) }, destroy() { clearContext(element, options.context) } }}
function applyContext(element: Element, context: Record<string, unknown>) { for (const [key, value] of Object.entries(context)) { const attrName = kebab(`data-meta-${key}`) if (value === null || value === undefined || value === '') { element.removeAttribute(attrName) } else if (typeof value === 'object') { // Objects get JSON-encoded for round-tripping element.setAttribute(attrName, JSON.stringify(value)) } else { element.setAttribute(attrName, String(value)) } }}
function clearContext(element: Element, context: Record<string, unknown>) { for (const key of Object.keys(context)) { element.removeAttribute(kebab(`data-meta-${key}`)) }}
function kebab(s: string): string { return s.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase())}The implementation does three things. Apply the context as data-meta-* attributes on the element. Return a handle that lets the caller update or remove the context later. Use data-meta-* rather than bare attribute names because attachBoundary is typically called on plain elements that aren’t <kit-boundary> custom elements — the data-* prefix is the platform’s safe extension surface (Chapter 22).
A canonical use from a framework adapter:
// A React component that establishes a boundary on its rendered element:function UserRow({ userId }: { userId: string }) { const elementRef = useRef<HTMLDivElement>(null)
useEffect(() => { if (!elementRef.current) return const boundary = runtime.attachBoundary(elementRef.current, { context: { surface: 'user-row', entityType: 'user', entityId: userId } }) return () => boundary.destroy() }, [userId])
return <div ref={elementRef}>{/* row content */}</div>}The same pattern works for Vue’s onMounted/onBeforeUnmount, Svelte’s lifecycle, Lit’s connectedCallback/disconnectedCallback, or any other framework’s component lifecycle. The boundary attaches when the component mounts and detaches when it unmounts.
The Context-Collection Algorithm
Section titled “The Context-Collection Algorithm”When an event fires, the metadata-boundary listener (Chapter 42) walks up from the event target to collect context from every boundary along the way. The walk is straightforward but the merge semantics matter.
interface CollectedContext { surfaces: string[] surface?: string feature?: string entity?: { type: string; id: string } route?: string mode?: string [key: string]: unknown}
function collectContext( target: Element, shellRoot: Element): CollectedContext { const context: CollectedContext = { surfaces: [] } let current: Element | null = target
while (current && current !== shellRoot.parentElement) { // Read boundary attributes from this element const local = readBoundaryAttributes(current)
// Merge: inner-most wins for single-value keys, surfaces accumulate mergeContext(context, local)
current = current.parentElement }
// Include shell-root context too mergeContext(context, readBoundaryAttributes(shellRoot))
// Reverse the surfaces array: we collected inner-to-outer; reverse to outer-to-inner context.surfaces.reverse() return context}
function readBoundaryAttributes(element: Element): Partial<CollectedContext> { const local: Partial<CollectedContext> = {}
// <kit-boundary> uses bare attribute names // Other elements use data-meta-* prefix const isBoundary = element.tagName === 'KIT-BOUNDARY'
function read(name: string): string | null { if (isBoundary) { return element.getAttribute(name) } return element.getAttribute(`data-meta-${name}`) }
const surface = read('surface') if (surface) local.surfaces = [surface]
const feature = read('feature') if (feature) local.feature = feature
const entityType = read('entity-type') const entityId = read('entity-id') if (entityType && entityId) { local.entity = { type: entityType, id: entityId } }
const route = read('route') if (route) local.route = route
const mode = read('mode') if (mode) local.mode = mode
return local}
function mergeContext(target: CollectedContext, local: Partial<CollectedContext>) { // Surfaces accumulate (we'll reverse at the end so the order is outer-to-inner) if (local.surfaces) { target.surfaces.push(...local.surfaces) }
// For other fields, only set if not already set (inner-most wins because // we encounter the inner-most boundary first in the walk) if (local.feature !== undefined && target.feature === undefined) { target.feature = local.feature } if (local.entity !== undefined && target.entity === undefined) { target.entity = local.entity } if (local.route !== undefined && target.route === undefined) { target.route = local.route } if (local.mode !== undefined && target.mode === undefined) { target.mode = local.mode }}The merge logic is the architecturally interesting part:
Surfaces accumulate. Every surface attribute encountered during the walk gets added to the surfaces array. The result is an ordered list from outer-most to inner-most surface — exactly the navigation breadcrumb the analytics, audit, and observability modules want.
Single-value fields use inner-most wins. The walk encounters the inner-most boundary first; if that boundary declares feature: "preferences", the walk records it. Outer boundaries that also declare feature don’t override the inner-most one. This matches the architectural intuition: the closest boundary to the event is the most specific, and specificity should win.
The walk stops at the shell root. The shell’s mount-point is the topmost boundary. The walk doesn’t escape into the document outside the shell. This prevents events from a sub-application accidentally inheriting context from another shell on the same page.
The merge semantics are simple enough to keep, debuggable enough to inspect, and consistent with how CSS cascade and DOM accessibility-tree inheritance work. The architecture leans on the platform’s structural conventions.
Updating Context Dynamically
Section titled “Updating Context Dynamically”Some contexts change after the boundary is attached. A router adapter’s route changes when the user navigates. An admin’s editing as user X mode might flip when the admin switches subjects. The boundary handle’s updateContext method handles this:
const routerBoundary = runtime.attachBoundary(document.documentElement, { context: { route: location.pathname, params: extractParams(location.pathname) }})
window.addEventListener('popstate', () => { routerBoundary.updateContext({ route: location.pathname, params: extractParams(location.pathname) })})Each call to updateContext re-applies the attribute values on the element. The next event that fires inside the boundary sees the new context. Components below the boundary don’t have to know about the change — they just emit events as usual; the new context flows through via the DOM’s structural inheritance.
updateContext is replacement, not merge. Calling it with { route: '/new' } replaces the route but preserves other previously-set attributes (because they’re still on the element). To remove a specific attribute, pass null or empty string. To clear everything the boundary supplied, call destroy().
Boundary Destruction
Section titled “Boundary Destruction”When a boundary is no longer needed, its handle’s destroy() removes the context attributes from the element:
const boundary = runtime.attachBoundary(rowElement, { context: { surface: 'row', entityType: 'user', entityId: userId }})
// When the row is unmounted:boundary.destroy()The attributes get removed. Events fired inside that element (or what’s left of it) no longer pick up the row’s context. The cleanup is symmetric with attachment.
The <kit-boundary> custom element handles its own cleanup automatically — when the element is removed from the DOM, its attributes go with it. The programmatic API requires explicit cleanup because the application is attaching to elements it doesn’t own.
Boundaries on Custom Elements
Section titled “Boundaries on Custom Elements”A custom element can be its own boundary by reading attributes the application provides:
class UserCard extends LitElement { @property({ type: String, attribute: 'user-id' }) userId = ''
connectedCallback() { super.connectedCallback() this.setAttribute('data-meta-surface', 'user-card') this.setAttribute('data-meta-entity-type', 'user') this.setAttribute('data-meta-entity-id', this.userId) }
updated(changed: PropertyValues) { if (changed.has('userId')) { this.setAttribute('data-meta-entity-id', this.userId) } }}When the component renders, it stamps the boundary attributes on itself. The component’s children inherit the context through the DOM tree. The element doesn’t need a separate boundary handle because it manages its own attributes through Lit’s lifecycle.
This is a useful pattern because it makes the component portable. A <user-card user-id="123"> placed anywhere in any application produces the same boundary context. The application using the component doesn’t have to remember to wrap each instance in a separate boundary element.
Multiple Boundary Attachments
Section titled “Multiple Boundary Attachments”A single element can have only one set of boundary attributes (since the attributes are on the element itself), but the context-collection algorithm naturally handles multiple boundaries up the tree. The chapter’s architectural commitment is one element, one boundary — if you need richer context, nest more boundary elements rather than overloading a single one.
The application typically has a clear nesting hierarchy. The shell root sets the application name. The route boundary (added programmatically by the router adapter) sets the current route. A page-level <kit-boundary> sets the surface. A section-level <kit-boundary> sets the feature. An entity-level <kit-boundary> sets the entity type and ID. Events fired from buttons inside the entity boundary inherit all five levels. The architecture is composable through nesting.
Bridge to the Metadata Boundary
Section titled “Bridge to the Metadata Boundary”The boundary system is in place. The shell knows its root. Custom elements declare context. The context-collection algorithm walks the tree. What’s still missing is the piece that observes DOM events inside the shell and uses the collected context to fire runtime events. That’s the metadata boundary, and it’s what Chapter 42 builds.
After Chapter 42, the architecture is end-to-end: a button declared with meta-event="profile.saved" produces a runtime event with full boundary context, modules respond, the diagnostic trace shows the chain. The closed loop from Chapter 38 is real code.
Exercise: Build the Boundary System
Section titled “Exercise: Build the Boundary System”Implement runtime.attachBoundary(element, options) and the <kit-boundary> custom element, along with the context-collection algorithm.
Build a small page with nested boundaries:
<kit-shell name="exercise"> <kit-boundary surface="settings-page" feature="preferences"> <section> <kit-boundary surface="profile-section" entity-type="profile" entity-id="me"> <button id="save-button">Save</button> </kit-boundary>
<kit-boundary surface="password-section" entity-type="profile" entity-id="me"> <button id="change-password">Change password</button> </kit-boundary> </section> </kit-boundary></kit-shell>Add a click listener that, for each button, calls collectContext(button, shellRoot) and logs the result.
The expected output for the Save button:
{ surfaces: ['settings-page', 'profile-section'], surface: 'profile-section', feature: 'preferences', entity: { type: 'profile', id: 'me' }}The expected output for the Change password button:
{ surfaces: ['settings-page', 'password-section'], surface: 'password-section', feature: 'preferences', entity: { type: 'profile', id: 'me' }}Then experiment:
- Use
runtime.attachBoundaryprogrammatically to add a third section with dynamic entity context. Verify the context collection works the same way as for the declarative boundaries. - Update the dynamic boundary’s context with
boundary.updateContext({ entityId: 'someone-else' })and verify the next event reflects the new context. - Destroy the dynamic boundary with
boundary.destroy(). Verify the context attributes are removed and subsequent events don’t include them. - Add a
<kit-boundary>at the very top withroute="/settings/preferences". Verify the route appears in every event’s context.
Reflect on:
- Which context was local to each button’s enclosing boundary?
- Which was inherited from parents?
- How would this reduce prop-drilling compared to a typical React component tree?
- If you renamed
profile-sectiontoprofile-form, how many places would need to change? (Just the boundary attribute; the buttons inside don’t need to change.)
The boundary system is the structural backbone of the architecture. The runtime routes; the shell hosts; the boundaries supply context. Chapter 42 adds the piece that turns DOM events into runtime events automatically, completing the wiring from button to capability module.