Skip to content

18. Propagation

This is the longest chapter in the book. It earns its length. Propagation in Archlang is the topic where the language differs most from anything you’ve used before, and the topic that most rewards getting clearly into your head.

The short version up front:

  • There are two propagation mechanisms, not one.
  • They operate at different times and along different axes.
  • They use deliberately distinct vocabulary so you can tell them apart when reading.
  • They compose — the language works because they do.

This chapter introduces both, shows how they look in source, and walks through a worked example where both are in play at once. If you take only one thing from this chapter: there are two mechanisms.

Why two

Architecture descriptions have two natural propagation axes:

  1. Type axis. A subtype shares structure with its parent type. A specific service type stamps shared defaults onto every instance.
  2. Containment axis. A nested module shares context with its parent module. A team owns a service; the components inside that service are owned by the same team.

A language with only one propagation mechanism could express one of these but not the other cleanly. Archlang has both.

AxisMechanismWhen it runsOperates on
Type → subtype/instanceTemplate stamping (mechanism A)Parse timeType-inheritance chain
Parent module → children/facets/interfacesStructural cascade (mechanism B)Resolution timeContainment tree

The vocabulary is intentionally split:

  • For mechanism A we say inherit, stamp, auto-propagate.
  • For mechanism B we say cascade, flow, propagate through, receive from ancestor.

“Inherit” is reserved for the type-system meaning. When a description in source uses “inherit,” it always means mechanism A.

Mechanism A: template stamping

A type body acts as a form template. When the parser meets an instance of that type, the template’s contents — defaults, blanks, sub-declarations, labels, propagation modifiers — get stamped onto the instance as if they were literally written there.

type module service {
component metrics { command Emit } // pre-filled sub-declaration
required cascade team // mandatory blank field
}
service Payments {
team: Payments
// After parsing, Payments effectively has:
// team: Payments
// component metrics { command Emit }
// (cascade modifier on team)
}

The metrics component appears on every service instance. The cascade modifier on team ships with the instance too — it’ll matter at resolution time, but stamping put it there.

Stamping cascades through the type chain. A paymentsService subtype of service stamps its content and service’s content onto every paymentsService instance:

type module service {
component metrics { command Emit }
}
type service paymentsService {
component metrics_pci_extras { command EmitPCI }
}
paymentsService Authorize {
// After parsing, Authorize effectively has:
// component metrics { command Emit } (from service)
// component metrics_pci_extras { command EmitPCI } (from paymentsService)
}

Vocabulary: type stamping, template inheritance. When: at parse time, before any name resolution. Where: along the type-inheritance chain. Modifiers: required, default values, sub-declarations. Edits: override, drop, refinement (next chapter).

Mechanism B: structural cascade

Once parsing is done, the model is a tree of nested modules. Some fields are marked cascade or append at the type level; labels always cascade implicitly. When something looks up its team, the resolver walks up the structural tree to find the nearest set value.

service Payments {
team: Payments
component MetricsExporter {
// No 'team' set here.
// Resolution time: MetricsExporter.team — walk up.
// MetricsExporter.team unset → check parent (Payments) → "Payments"
// Effective MetricsExporter.team is "Payments".
}
}

The value flows from parent to child through containment, not through type relationship. MetricsExporter is a child module of Payments. The cascade is “down the containment tree.”

Vocabulary: cascade, flow, propagate. When: at value-resolution time (when something asks for the value). Where: along the structural-containment tree. Modifiers: cascade (override on walk), append (compose on walk), unmarked (local — does not flow). Edits: set a fresh value at the desired level; drop to break the chain.

Cascade vs. append vs. local

Fields opt into structural propagation via a modifier on their type declaration. Three behaviors:

ModifierBehavior
(none)localField describes only the thing it’s declared on. Doesn’t flow to descendants.
cascadeValue flows to descendants; descendants override by setting their own.
appendValue flows to descendants and composes per the field’s type — paths concat, lists append, objects merge.
type module service {
required cascade team // cascade: override-on-walk
append tags // append: compose-on-walk
version // local: stays where it's set
}

Cascade in action — override-on-walk:

service Orders {
team: Commerce
component Outbox {
// No 'team' set. Walks up:
// Outbox.team → "Commerce" (cascaded from Orders)
}
component Worker {
team: WorkerOps // overrides the cascade locally
// Worker.team → "WorkerOps"
}
}

Append in action — compose-on-walk:

service Orders {
team: Commerce
tags: ["pii", "audit"]
component Outbox {
// No 'tags' set. Walks up; append composes:
// Outbox.tags → ["pii", "audit"] (inherited as-is)
}
component Worker {
tags: ["batch"] // appends to inherited list
// Worker.tags → ["pii", "audit", "batch"]
}
}

Local stays put:

service Orders {
team: Commerce
version: v2
component Worker {
// No 'version' set. version is local — does NOT walk up.
// Worker.version → undefined
}
}

Cascade behavior is fixed at the type that introduces the field. Subtypes and instances cannot change a cascade field to local, or vice versa. The mode is intrinsic to the field’s meaning.

Cascade groups (cascade *)

When a field has structured sub-fields that conceptually belong together (think widget + widget.icon + widget.color + …), declaring them all individually as cascade works, but downstream overrides become noisy: if a subtype swaps the root tag, every leaf has to be drop-ped manually.

cascade * declares the field as a cascade group root. Sub-fields under that path participate in the same group, and the group as a whole reacts to root changes.

type module service {
cascade * widget: arch-module {
icon: service
color: "#60a5fa"
subheader: team
}
}

The { … } body is the group body. Each entry inside is a sub-field of the group root (widget.icon, widget.color, etc.). Sub-fields inherit cascade implicitly — you don’t repeat cascade on every line.

The group changes how downstream overrides behave:

  • Replace the root — a subtype or instance that sets the root to a different value drops the entire inherited group. The new root starts fresh.

    type service custom {
    widget: my-element // drops parent's widget.icon, widget.color, …
    }
  • Override a leaf — setting a single member leaf keeps the rest of the group.

    type service tweaked {
    widget.color: "green" // keeps icon: service, subheader: team
    }
  • Same value at the root — re-anchoring the root with the same value preserves the group; useful when you want to add new sub-fields without dropping inherited ones.

  • Drop the rootdrop widget removes the root and all its sub-fields (the group collapses when the root vanishes).

Group sub-fields can carry their own modifiers (required, append) but not cascade (that’s implicit) and not * (groups don’t nest in this iteration).

Labels always cascade-override

Labels (the things inside labels { }) always cascade, with override semantics. You don’t write cascade on a label; the cascade is intrinsic to what a label is. The reason: labels exist to be projected over (views group by them, focus on them) — a label that didn’t flow would be useless for projection.

service Orders {
labels { domain: Orders }
component Worker {
// Worker.labels.domain → "Orders" (cascaded)
labels { domain: WorkerDomain }
// Now Worker.labels.domain → "WorkerDomain" (override)
}
}

If you want accumulation instead of override (a list of tags growing from parent to child), use a field with append, not a label. Labels can’t compose; fields can.

The two together

Most non-trivial models exercise both mechanisms at once.

// (A) Type provides defaults and a cascade modifier.
type module service {
required cascade team
component metrics { command Emit }
}
// (A) Subtype stamps a fulfilled team and inherits everything else.
type service paymentsService {
team: "Payments" // fulfills the blank for descendants
}
// At parse time, mechanism A stamps onto Payments:
// - team: "Payments" (from paymentsService)
// - component metrics { command Emit } (from service)
// - cascade behavior on team (from service)
paymentsService Payments {
"Core payment processing"
component MetricsExporter {
command Forward
}
}
// At resolution time, mechanism B walks the structural tree:
// Payments.team → "Payments" (set on Payments itself by stamping)
// Payments.MetricsExporter.team → "Payments" (cascaded from Payments)
// Payments.metrics.team → "Payments" (cascaded from Payments,
// via stamping that put metrics here)
// Payments.metrics.Emit → no team field at the interface level,
// resolves to "Payments" via cascade

The instance reads naturally — no team: repeated on every nested thing — because mechanism A planted the cascade modifier and a fulfilled value, and mechanism B does the walking at lookup time.

What wins when both apply

There’s one tie-breaking rule worth memorizing:

Nested value wins over stamped default.

When a value is reachable both via type-instance stamping (a default the type provided) and via structural cascade (a nested ancestor set it), the nested value wins. Nesting is more local and more explicit; the type-stamped default is the broader, weaker source.

type module service {
cascade widget: arch-service // type default
}
service Container {
widget: arch-special // nested ancestor's value
component Inside {
// Inside.widget — both sources apply:
// stamped default: arch-service
// cascade from Container: arch-special
// Nested wins. Inside.widget → "arch-special"
}
}

Why two compose better than one

Imagine a language with only mechanism A (template stamping). To express “every nested component inherits the parent’s team,” the type would have to know about every possible nesting depth and stamp team on each level. That’s not possible because the type doesn’t know how instances will nest.

Imagine a language with only mechanism B (structural cascade). To express “every service has a metrics component by default,” there’s no place to put the default — fields cascade but sub-declarations don’t.

Each mechanism does one thing the other can’t. Together they cover the space:

  • A says what fields and sub-declarations exist on instances.
  • B says how values flow once instances are in a nesting tree.

You need both. The language gives you both with distinct vocabulary so you can tell which is happening when you read source.

drop is absolute on cascade chains

When you drop a field or label in a body, the cascade chain is broken at the drop point. Descendants do not resume reading from a deeper ancestor.

service Orders {
team: Commerce // cascades
component Special {
drop team // breaks the chain
// Special.team → undefined
// (NOT "walks up past the drop to find the next ancestor")
component Inner {
// Inner.team → undefined (chain still broken)
}
}
}

This is conscious. A drop is the model saying “no value here, on purpose.” Letting cascade leak past it would silently undo the author’s intent.

Summary

  • There are two propagation mechanisms: template stamping (A) and structural cascade (B).
  • Mechanism A operates at parse time, along the type-inheritance chain. Vocabulary: inherit, stamp.
  • Mechanism B operates at resolution time, along the structural containment tree. Vocabulary: cascade, flow.
  • cascade (override-on-walk), append (compose-on-walk), and unmarked (local) are the three field-level cascade modes.
  • Labels always cascade with override semantics. For accumulation, use a field with append.
  • The two mechanisms compose; tie-break is nested-wins-over-stamped.
  • drop is absolute — it breaks the cascade chain at that scope.

What’s next

Chapter 19: Refinement, Override, and Drop → — the uniform vocabulary for editing what types and parents provide.