Lowering Engine
Design rationale: OAM lowering engine (ADR-035)
Validates the design set out in this project’s OAM-levels research notes (decisions
D1–D7). The mechanics below were first proven on a throwaway spike branch
(spike/oam-lowering-engine, never merged, no PR); this document originally reported
what running code found against each decision there. The production implementation
this document now describes ships on feat/oam-lowering-engine (draft PR #274):
pkg/oam/lowering.go, pkg/oam/lowering_raw.go, the PlatformReserved/
enforcePlatformReserved enforcement, and expose as a registered TraitLoweringRule
are all real code on this branch today, not spike-only prototypes. Where a spike-time
question was left open, the section below states how it was actually resolved, with a
citation to the resolving code.
Summary
All seven decisions survived contact with running code, with two concrete corrections:
D3’s proof surfaced that “platform-reserved” was previously documentation, not
enforcement for a shared schema fragment (networkPolicy); the fix that shipped
enforces it uniformly across all three sharing traits, via an explicit
schemaNetworkPolicy(reserved bool) parameter rather than an implicit shared default —
see the D3 section below. D5’s four-input closure forced an
engine-level design choice — capability rendering is merged in by the engine, not the
rule — that was not spelled out in the original decisions doc. Everything else matched
the design as written. pkg/oam’s scope genuinely widened beyond its doc-comment’s
stated “model + parser + validator” — the lowering engine is now part of the
package’s production surface, not a spike-only detour that reverts before merge.
pkg/oam/doc.go’s package comment was updated in this PR to say so.
D1 — one engine for all four positions
Holds. DocumentLoweringRule / ComponentLoweringRule / TraitLoweringRule /
PolicyLoweringRule (lowering.go:180-235) share one LoweringResult type, one
fixpoint loop (lowerDocumentBody), and one LoweringContext. loweringPositionRules
(lowering.go:92-97) is the only position-specific logic — a lookup table, not a
branch per position in the engine body. The four positions did not need separate
mechanisms; they needed a shared result type wide enough to express “this rule may
touch traits, components, and policies, but not documents” declaratively.
D2 — 1→N everywhere
Holds, at a real but bounded cost. NameAllocator (lowering.go:140-172) is the whole
naming mechanism: deterministic <base>-<suffix> generation plus collision detection
keyed by authored Origin, so two rules independently choosing the same generated name
fail loudly and name both origins rather than silently overwriting. Origin provenance
(lowering.go:46-52) rides as unexported origin *Origin / sealed bool fields on
Trait/Component/ApplicationPolicy/Application — yaml.v3 ignores unexported
fields, so this cost nothing in the wire format, and value-copy semantics at existing
call sites (transform.go cluster-building) preserve it without a pointer-keyed side
table.
Origin carries one field, Rule (lowering.go:72-111), that breaks its own
“stamped once, copied verbatim” rule: every other field names the AUTHORED location
and is fixed forever once stamped, but Rule names whichever lowering rule MOST
RECENTLY produced the element, so it is deliberately re-derived at every hop rather
than copied — a verbatim copy would leave a round-1 rule’s identity on an element a
round-2 rule actually went on to produce. A component, policy, or trait a rule only
forwards verbatim (rather than constructs) is exempted from that re-stamp via
pointer-identity checks (isForwardedComponent, isForwardedPolicy,
isForwardedTrait) and keeps whatever Rule it already carried — possibly "", if
never itself the direct output of a rule invocation.
Document-level 1→N (one authored document lowering into several) ships only at the
raw entry point today: testRawRule (pkg/oam/lowering_raw_test.go:41-139) emits
n sibling Application documents from one raw document when its emit field is set,
proving LoweringResult.Documents with more than one entry round-trips through the
shared fixpoint (runLowering, lowering.go:439) correctly — each sibling gets its own
generated name via the shared NameAllocator, and the slot-keyed splice
(lowering_raw.go:101-121) puts every emitted document back at its raw input’s
position. This is proven with a test-only rule, not a shipped built-in — no concrete
production rule in this repo emits more than one document yet. The in-transform
document position (DocumentLoweringRule) is exercised for 1→1 renaming
(testDocRule, lowering_test.go:144-157) but not for 1→N on this branch; see
“Document-level splitting fragments cluster-wide passes” under Frictions below for what
still blocks a real multi-cluster-output rule, and TransformAll under “What this does
not resolve” for the still-unshipped API that would consume such a split.
D3 — platform-reserved precedence
Holds, but the proof attempt found a real gap: the pre-existing doc comments on
IngressHandler/HTTPRouteHandler already asserted networkPolicy was
“platform-reserved” (ingress.go:79-81, httproute.go:64), but nothing enforced it —
~18 tests in networkpolicy_auto_test.go author networkPolicy directly on those
traits, bypassing any ClusterProfile, to exercise netpol synthesis without a
capability round-trip. Marking the shared schemaNetworkPolicy() fragment
(pkg/oam/builtin/traits/schema.go) reserved broke all of them — a pre-existing,
intentional test pattern this needed to preserve, not route around. An initial fix
scoped PlatformReserved per-schema-copy (ExposeRule.PropertySchema() cloning the
fragment and overriding the flag on its own copy only), which unblocked forward
progress but left IngressHandler/HTTPRouteHandler’s copies unreserved — three schema
declarations of the same property that could silently drift out of sync, with nothing
forcing a future change to touch all three.
Decision: enforce PlatformReserved uniformly, via an explicit parameter, not an
implicit shared default. schemaNetworkPolicy() became schemaNetworkPolicy(reserved bool) (schema.go) — every call site (ExposeRule, IngressHandler,
HTTPRouteHandler) now states its choice at the call, and today all three pass true.
This closes the divergence risk without adding a generic reservation-scoping mechanism
that only one property currently needs — the explicit argument is the whole mechanism.
The ~18 affected tests in networkpolicy_auto_test.go (plus 3 in
domain_resolution_test.go) were migrated: tests that drive the full
TransformWithPolicy pipeline now supply networkPolicy via
ctx.Capabilities["ingress"|"httproute"].Rendering, mirroring how a real
ClusterProfile would render it, instead of authoring it inline on the trait (which
enforcePlatformReserved now rejects). Tests that call
IngressHandler.Apply/HTTPRouteHandler.Apply directly are unaffected — they bypass
applyTraits/enforcePlatformReserved entirely — and were left authoring it inline
(see TestHandlers_ReservedNetworkPolicy_StillAppliesDirectly,
pkg/oam/builtin/traits/platform_reserved_apply_test.go). This is itself a finding:
the design’s “mark it declaratively” assumption is right, and a shared schema
fragment does not need a bespoke per-caller-scoping mechanism to stay uniform — an
explicit boolean parameter is enough, so long as every caller is required to pass one
rather than relying on a default.
The same class of gap recurred once more before this branch was ready for review:
IngressHandler’s own allowedHostnameWildcard property carried the identical
“platform-reserved” doc-comment claim (ingress.go:79-81) without the
PlatformReserved: true flag on its own schema entry, even after ExposeRule’s copy of
the same key was correctly reserved — an author could bypass hostname-wildcard
enforcement entirely by authoring the ingress trait directly instead of going through
expose. Fixed the same way: PropertySchema.PlatformReserved: true added to
IngressHandler’s allowedHostnameWildcard entry (ingress.go:131), with a regression
test proving the bypass is closed
(TestIngressHandler_AllowedHostnameWildcard_InlineAuthoringRejected,
pkg/oam/builtin/traits/ingress_platform_reserved_test.go).
enforcePlatformReserved (property_validate.go:174) runs at every point that merges
capability rendering in: the trait-lowering branch (lowering.go:661-670, before
resolveCapability), applyTraits (transform.go:760-769, before
resolveCapability, inside the !trait.sealed guard), and symmetrically before a
component handler’s ToApplicationConfig (transform.go:517, createApplications) —
though no component schema declares a reserved field today, so that call site is
currently a no-op in practice. The proof: webservice-expose-ingress/app.yaml loses its
inline controllerType: ingress line; expected.yaml is byte-identical because the
capability-supplied value was already sufficient — the authored line was redundant even
before D3, and D3 makes that redundancy an error instead of a silent no-op. A third
fixture, pkg/cmd/kurel/testdata/app.yaml, carried the same now-redundant inline
controllerType: ingress line and was missed in that earlier pass; it was found and
fixed the same way — pkg/cmd/kurel/testdata/cluster.yaml already supplies
controllerType via capability rendering, so deleting the inline line is the whole fix.
flatschema.go’s flat allow-sets (kurelParamKeys, capabilityPropKeys) are untouched,
by design: a capability rendering schema describes what the platform may set, so
“platform-reserved” is meaningless at that call site, exactly like
Enum/Properties/Items already are.
RegisterTrait (transform.go:152-167) has always panicked at registration time if a
dispatchable TraitHandler implements CapabilityAware without also implementing
ValidateAndApplyDefaults, because EvaluateProfile’s dispatch needs
ValidateAndApplyDefaults to validate/default a capability-rendered binding before use.
RegisterTraitLowering (lowering.go:324-351) lacked the equivalent assertion:
EvaluateProfile’s trait-lowering-rule fallback (transform.go) has the identical
need — a TraitLoweringRule implementing CapabilityAware without
ValidateAndApplyDefaults would have its capability rendering accepted unvalidated and
undefaulted, with no signal at registration time. ExposeRule implements both
interfaces today, so this was not a live bug, but nothing structurally prevented a
future rule from reintroducing the exact class of gap Friction #2 below records as
fixed specifically for expose. Closed by adding the same guard to
RegisterTraitLowering that RegisterTrait already has, with a registration-time panic
and regression tests proving both the accept and reject paths
(TestRegisterTraitLowering_PanicsIfCapabilityAwareWithoutVAD,
TestRegisterTraitLowering_CapabilityAwareWithVAD_OK, pkg/oam/lowering_test.go).
D4 — re-validate emitted elements, plus whole-document validation
Holds, and required writing the validator the design assumed existed —
PropertySchema was published (HandlerSchemas) but nothing in launcher enforced it
before this work. validateProperties/validateObjectProperties
(property_validate.go) cover type/required/enum/nested Properties/Items/
AdditionalProperties. Two enforcement points, matching the design: emission-time
(validateEmittedComponent/validateEmittedTrait/validateEmittedPolicy, called the
moment a rule emits an element, citing the authored origin first per D7) and a
post-fixpoint whole-document pass with an empty LowerableTypes
(call site lowering.go:483, validateSettled itself at lowering.go:502-508, which
calls validateWithExtraTypes(doc, customTraitTypes, LowerableTypes{})) — any
kind/component/trait type still present once the fixpoint has settled is, by
construction, not claimed by any registered rule (LowerableTypes’s own doc comment,
lowering.go:366-370), so it is a non-terminating rule’s leftover rather than a
legitimate terminal type.
D5 — information closure, four inputs
Holds for expose, and the port surfaced a design decision the original document did
not spell out: who performs the capability-rendering merge, the engine or the rule?
The answer that shipped is the engine — lowerDocumentBody’s trait branch merges
capability rendering into the trait via resolveCapability before calling
LowerTrait (lowering.go:671-677, mirroring applyTraits’s existing pre-merge for a
dispatchable TraitHandler) — so ExposeRule.LowerTrait reads trait.Properties
exactly as the former ExposeHandler.Apply always did; the port from handler to rule is
close to mechanical (app.Name → lctx.Component.Name, the two terminal handler calls
become emitted Trait{Type:"ingress"|"httproute"}). This closes a corollary the design
implied but did not state: an emitted element cannot re-enter capability resolution,
because that would introduce a fifth input (a different key’s capability rendering,
chosen by the emitted type rather than the authored one). The fix is the sealed field
(types.go): every trait a TraitLoweringRule emits is marked sealed = true
(lowering.go:687), and applyTraits skips its entire capability-processing block for
a sealed trait (transform.go:721-727, if !trait.sealed).
TestExposeRule_SealedGuard_ExtraIngressCapabilityIgnored
(pkg/cmd/kurel/expose_sealed_test.go) proves the guard does something: it fails when
the guard is removed (verified by hand) and passes with it in place, confirming a
profile defining both expose and ingress capabilities produces byte-identical
output to one defining expose alone.
D7 — recursion bound and provenance-first errors
Both proposed defaults survived unchanged and ship as written. MaxLoweringDepth = 9
(lowering.go:40) was never approached by any rule exercised so far — the deepest
chain any test drives is three rounds. The bound exists for the pathological case (a
rule that keeps re-emitting a type another rule also claims) and LoweringError prints
the authored Origin first, the Cause second, then the full Chain of
LoweringSteps (LoweringError struct at lowering.go:248-263) — one of the negative
tests specifically asserts a depth-limit failure prints all MaxLoweringDepth chain
steps, not just the last one (TestLower_DepthLimit_PrintsFullChain,
pkg/oam/lowering_negative_test.go). No change to either default was needed.
Frictions
Three were recorded as findings in the plan rather than designed around; two of the
three resolved themselves once expose was actually implemented as a rule, and are
recorded here as resolved rather than open.
- Document-level splitting fragments cluster-wide passes (still open). Netpol
synthesis (
synthesizeNetworkPolicieset al., post-fixpoint inTransformWithPolicy) and source dedup both operate per-cluster. A document-position rule producing more than one output document has no shipped API to turn those multiple documents into multiple*stack.Clusters in the first place — seeTransformAllunder “What this does not resolve” below — so this friction has not yet been exercised against real multi-cluster output, only against the raw entry point’stestRawRulefixture (D2 above), whose siblings are deliberately disjoint and never reach a cluster-wide pass. This stays open: no fix was attempted, since no consumer of split output exists yet to design against. - Moving
exposeout oftraitHandlerssilently drops its capability validation (resolved).EvaluateProfile(transform.go) only ever looked upt.traitHandlers[typeName]; onceExposeRulereplacedExposeHandlerthere, thegatewayName-required andgatewayNamespace-default checks would have stopped running with no failing test to catch it. Fixed by extendingEvaluateProfileto fall back tot.traitLoweringRules[typeName]and its optionalValidateAndApplyDefaults(transform.go).TestExposeRule_EvaluateProfile_GatewayValidation(pkg/oam/builtin/traits/expose_rule_evaluate_profile_test.go) is the regression guard. The D3 section above records the one place this fallback’s own guard was still incomplete:RegisterTraitLoweringdid not enforceCapabilityAware⇒ValidateAndApplyDefaultsthe wayRegisterTraitdoes — now fixed structurally, not just forexposespecifically. - An emitted trait re-entering capability resolution is a fifth D5 input
(resolved). Covered under D5 above — the
sealedfield and the!trait.sealedguard inapplyTraits.
Byte-identity cost
The C1 no-op guarantee — zero lowering rules registered, Transform/
TransformWithPolicy behave exactly as before — was verified twice:
TestLower_EmptyRegistry_ReturnsSamePointer (pkg/oam/lowering_test.go:12) asserts
pointer identity on the returned document when no rule is registered, and
UPDATE_GOLDEN=1 go test ./pkg/cmd/kurel -run TestFixtures followed by
git diff --exit-code pkg/cmd/kurel/testdata produces zero diff across every
existing golden. The cost of the engine’s presence, with zero rules registered, is
one extra pointer-identity check per Transform call — not a measurable runtime cost,
and not a single behavioral difference. The expose-as-a-rule migration re-ran the
same proof at the feature level: both expose goldens (webservice-expose-ingress,
the gateway fixture) pass without UPDATE_GOLDEN, and all five
expose_*_test.go files keep their original assertions, re-pointed through a shared
applyExpose helper (pkg/oam/builtin/traits/traits_test.go) that feeds
ExposeRule.LowerTrait’s emitted trait to the real
IngressHandler/HTTPRouteHandler.Apply — reproducing byte-for-byte what the engine’s
fixpoint plus applyTraits does end to end.
Entry-point contract: in-transform and raw-document
The engine mechanics above (D1–D7) were first proven for documents that already
unmarshal into the base Application/ApplicationSpec shape, entered from inside the
existing transform pipeline. Follow-on integration work against a real downstream
parser surfaced a case that first pass did not cover, and it changes what “the engine”
has to be reachable from. This section specifies both entry points the shipped API
exposes.
The finding
A consumer embedding this engine does not necessarily hand it an already-decoded
Application. A consumer’s own parser may gate on kind/apiVersion before any
transform runs, rejecting a higher-level kind outright — a document the engine was
meant to lower never reaches a transform call at all, because it never survives the
consumer’s own parse step. Separately, a consumer may parse the same raw document bytes
independently at several call sites in its own codebase (validation, export, one or
more command entry points, pre-generation passes, …), not through one funnel — so an
insertion point reachable from only one of those sites still leaves the others exposed
to the same rejection. A production lowering engine therefore has to be reachable
directly from raw, undecoded document bytes, ahead of a consumer’s own parsing, not
only from a point downstream of it.
This matters only for a whole-noun higher-level kind: a document authored under a
kind and apiVersion of its own, carrying fields that do not fit the base
ApplicationSpec shape at all, so it cannot be unmarshalled into an Application in
the first place — not for a document that already fits the base shape and merely needs
a trait/component/policy lowered out of it. The latter case is exactly what D1–D7 above
validated and needs no new machinery.
Entry point 1 — in-transform (shipped)
For a document whose authored YAML already unmarshals into the base
Application/ApplicationSpec shape, the engine runs downstream of Parse, reached
through Transform (pkg/oam/transform.go:369) / TransformWithPolicy
(pkg/oam/transform.go:377). DocumentLoweringRule/ComponentLoweringRule/
TraitLoweringRule/PolicyLoweringRule operate on a decoded Application, with
LoweringContext, Origin provenance, NameAllocator, and the MaxLoweringDepth
fixpoint bound all proven against it (D1–D7 above) and shipping on this branch. The
document-splitting-to-multiple-clusters case (TransformAll) is the one piece of the
original mechanics that is not implemented here — see “What this does not resolve”
below; its absence does not affect this entry point’s correctness for the
single-document case it does handle.
Entry point 2 — raw-document (shipped)
For a whole-noun higher-level kind that cannot survive the base parse, the engine is
also reachable before parsing: (*Transformer).LowerRaws operates on undecoded
document bytes, run once per raw document ahead of a consumer’s own parse fan-out,
producing raw bytes that a standard Application parse can then accept. Shipped shape
(pkg/oam/lowering_raw.go:42, differing from the pre-implementation proposal by being a
*Transformer method rather than a package function, and by taking a
TransformContext — see below for why):
backed by RawDocumentLoweringRule (lowering.go:201-217), analogous in spirit to
DocumentLoweringRule but operating on kind-sniffed raw bytes rather than a decoded
Application — it cannot assume the input unmarshals into any type this package
already knows, since the whole point is that it may not. A consumer calls LowerRaws
once, ahead of every one of its own parse call sites, rather than calling it per site;
a document it rewrites then flows through the ordinary parse path and, if applicable,
into entry point 1 above unchanged.
How the open questions were resolved
Three questions were left open when this contract was first specified, ahead of implementation. All three are answered by the shipped code:
- Can
RawDocumentLoweringRuleshareLoweringContext/Originprovenance/NameAllocator/MaxLoweringDepthwith the decoded-document rule types, or does it need its own bounded fixpoint over raw bytes? It shares them fully.runLowering(lowering.go:439) is, by its own doc comment, “the ONE fixpoint implementation in this package” — both entry points call it exactly once per invocation, so oneNameAllocator, one expansion chain, and oneMaxLoweringDepthbudget are shared across every document in the call, siblings from different raw inputs included. Round 0 for a raw-entered document (lowerRawOnce,lowering_raw.go:134-150) decodes the bytes and calls the rule’sLowerDocumentexactly aslowerDocumentOnce’s document-rule branch would; from round 1 on, every descendant is an ordinary*Applicationand follows the identical path an in-transform document does.ctx TransformContextis threaded throughLowerRawsprecisely because of this sharing: rounds after round 0 can reach an ordinaryTraitLoweringRule/ComponentLoweringRule/PolicyLoweringRule, and aCapabilityAwareone among them needsctx.Capabilitiespopulated from an already-evaluatedClusterProfileor it fails withErrMissingCapability— a caller that has not evaluated a profile yet passes what it has, and gets the same failure the in-transform path would produce for the same input (lowering_raw.go:36-41). - Should a document a raw-document rule emits be eligible to re-enter
LowerRawsitself (a raw-to-raw fixpoint), or must it always land in base shape in one step? It must land in base shape in one step; there is no raw-to-raw re-entry. Only the original seed entries built directly fromrawscarry a non-nilrawfield (loweringDoc.raw,lowering.go:420); every documentLowerDocumentemits at round 0 becomes an ordinary*Applicationinnext(runLowering’s loop,lowering.go:449-479), which subsequent rounds process vialowerDocumentOnce, neverlowerRawOnce, sot.rawDocLoweringRulesis never consulted again for it. This mirrors thesealedguard’s constraint on entry point 1 (D5 above): an emitted element does not re-enter the resolution mechanism it came from. - What is the exact registration and dispatch shape for
RawDocumentLoweringRule? A lookup keyed on the sniffed(apiVersion, kind)pair. Registration ist.rawDocLoweringRules map[rawDocRuleKey]RawDocumentLoweringRule(transform.go, populated byRegisterRawDocumentLoweringinlowering.go, with the same duplicate/cross-registrar collision guardsRegisterDocumentLoweringhas in the other direction — the cross-registrar guard stays kind-wide, so one kind string is claimed by at most one registrar regardless of group). A rule claimsSupportedAPIVersionunless it implements the optionalRawDocumentAPIVersionerhook (RawDocumentAPIVersion() string— named for the hook, not the value, so a rule’s unrelatedAPIVersion()accessor cannot opt it in structurally); a consumer that owns its own API group implements it soLowerRawsclaims that group’s documents instead of silently passing them through to the consumer’s own parser, which would reject them (the gap the original single-group gate left open). Dispatch decodes just enough of each raw input —apiVersion,kindandmetadata.name/namespace, via the lenientdocumentEnvelopeprobe (lowering_raw.go) — to look up the pair; an input whose pair matches no registered rule passes through byte-identical, never decoded or re-serialized. Pass-throughApplicationidentities are pre-reserved against generated child names only for API groups some rule claims (plusSupportedAPIVersion’s group) — compared by group, not by fullgroup/version, so anApplicationat another version of a claimed group is still reserved; anApplicationunder an unclaimed group is a foreign resource, not a collidable identity. Every other identity in the pass stays group-blind on purpose —rawDocKeyand theNameAllocatorkey on(namespace, [kind,] name)— because one call yields one output slice for one consumer, in which a triple names one resource whatever group it was authored under. Each raw seed carries the one group its rule claims (loweringDoc.apiVersion), inherited by every descendant; a settled document may carrySupportedAPIVersionor that group (validateSettledvalidates it underSupportedAPIVersionotherwise unchanged), and a rule emitting into any other group — unclaimed, or claimed by a different rule — fails with aLoweringErroragainst the authored document, whose chain names the raw step asrawdocument/<apiVersion>/<kind>. The in-transform path is unaffected: it gates onSupportedAPIVersionbefore any rule runs, never consults this registry, and its seed carries no allowed group, so aDocumentLoweringRuleemitting a raw-claimed group duringTransformis rejected exactly as before.
What this does not resolve
TransformAll(document-level 1→N producing multiple*stack.Clusters) is not implemented on this branch. It remains the one piece of the original mechanics that stayed unshipped:Transform/TransformWithPolicy’s single-*stack.Clusterreturn cannot express a document splitting into two, and no equivalent multi-cluster API exists inpkg/oam/transform.gotoday. Whether the lowering engine’s document-splitting support ever needs a productionTransformAll(or some other shape) is still undecided and unimplemented; D2’s 1→N proof above rests on the raw entry point’stestRawRulefixture alone (test-only), not on a shipped mechanism for consuming split output.- Document-level 1→N is, correspondingly, only proven for disjoint output documents (Friction #1 above, still open) — no rule shipped or tested here produces documents whose outputs need to share a cluster-wide pass (netpol synthesis, source dedup).
- No
WebApplication-style higher-level-kind builtin ships in this repo. The entry-point contract above specifies, and the shipped code implements, the mechanism (LowerRaws/RawDocumentLoweringRule); it does not include a concrete production rule built on top of it, and it does not design how any particular downstream consumer wires its own parse fan-out through entry point 2 — that remains a consumer-side integration decision, out of scope for this library. - The static type allowlists (
LowerableTypes,validComponentTypes, etc.) were proven necessary but whether they should become a real type-registry document is still undecided — unchanged from the original design doc, and extends to whatever raw-kindregistry a future higher-level-kind rule ends up needing.