provisioning-platform/crates/ontoref-compute/adrs/adr-001-non-exhaustive-constructors.ncl

73 lines
4 KiB
Text
Raw Normal View History

{
id = "adr-001",
title = "Non-exhaustive structs with named constructors as the external construction contract",
status = 'accepted,
date = "2026-05-12",
context = m%"
`Instance` and `ProviderHealth` are the two core output types of the
`ComputeProvider` trait. Both are marked `#[non_exhaustive]` — a forward-
compatibility guarantee that allows adding fields without a SemVer bump.
The attribute blocks struct literal syntax in external crates (E0639), which
became concrete when `lian-build`'s `LianBuildScriptProvider` attempted to
construct `Instance { handle, provider, ... }` as the first γ-posture
external consumer.
Three remediation paths exist:
1. Remove `#[non_exhaustive]`: construction works, but every new field is a
breaking change at all destructuring and pattern-match sites.
2. Named constructors: keep the attribute, add `::new()` / `::healthy()` /
`::unhealthy()` as the stable external API.
3. Builder pattern: type-state or `Default` + field-by-field assignment.
The crate is a library for provider implementors, not application code. The
field set of `Instance` (ip, ssh_host, ssh_port, expires_at, metadata) is
expected to grow as new provider kinds surface cloud-specific properties.
Preserving `#[non_exhaustive]` is the correct API stability posture.
"%,
decision = m%"
Keep `#[non_exhaustive]` on all public output structs. Add named constructors
as the sole supported construction path for external crates:
- `Instance::new(handle, provider)` — zero-optional-field form; callers
assign optional fields (`ssh_host`, `ip`, `expires_at`, `metadata`)
after construction.
- `ProviderHealth::healthy(latency_ms)` — ok=true, latency set, detail=None.
- `ProviderHealth::unhealthy(latency_ms, reason)` — ok=false, detail set.
Builder and Default are explicitly excluded: a builder adds three abstractions
for two required fields; Default would require callers to know which fields are
meaningful versus zero-valued.
The `InstanceHandle` and `ProviderKind` enums require no change — they are
exhaustive and constructible via standard syntax.
"%,
alternatives = [
m%"Remove `#[non_exhaustive]`. Rejected: adding fields to `Instance` (e.g. `ipv6_host`) would be a breaking change at all match/destructuring sites in consumer crates."%,
m%"Builder pattern. Rejected: over-engineered for structs with 23 meaningful initialization parameters and all-optional fields beyond the required handle/provider pair."%,
m%"`Default` impl + field assignment. Rejected: requires callers to know which fields are semantically required vs truly optional; a zero-valued `provider` string is silently wrong."%,
],
consequences = m%"
Named constructors become a committed API surface. Adding a new *required*
semantic field to `Instance` now requires both a struct field and a constructor
parameter — the same cost as removing `#[non_exhaustive]` would impose, but
scoped to the constructor rather than all consumers. Adding new *optional*
fields is still backward-compatible: the struct gains the field, the constructor
initializes it to `None`/default, existing callers use the field by name after
construction if they want it.
The `lian-build` `LianBuildScriptProvider` is the canonical usage example:
`Instance::new(handle, "lian-build-script")` followed by direct field
assignment for `ssh_host`, `ssh_port`, `expires_at`.
"%,
constraints = [
{ kind = 'hard, statement = "`Instance`, `ProviderHealth`, and any future public output struct MUST remain `#[non_exhaustive]`." },
{ kind = 'hard, statement = "Public constructors MUST initialize all required semantic fields; optional fields MUST default to `None` or the zero value." },
{ kind = 'soft, statement = "Convenience constructors (`healthy`, `unhealthy`) SHOULD cover the two most common `ProviderHealth` shapes to keep provider implementations free of boilerplate." },
],
}