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:
- Type axis. A subtype shares structure with its parent type. A specific service type stamps shared defaults onto every instance.
- 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.
| Axis | Mechanism | When it runs | Operates on |
|---|---|---|---|
| Type → subtype/instance | Template stamping (mechanism A) | Parse time | Type-inheritance chain |
| Parent module → children/facets/interfaces | Structural cascade (mechanism B) | Resolution time | Containment 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:
| Modifier | Behavior |
|---|---|
| (none) — local | Field describes only the thing it’s declared on. Doesn’t flow to descendants. |
cascade | Value flows to descendants; descendants override by setting their own. |
append | Value 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 root —
drop widgetremoves 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 cascadeThe 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.
dropis 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.