ADR-085: Template-Parameter Default Arguments Are a Recorded Fact, and a Header-Level Default Change Is an API Break¶
Date: 2026-10-21 Status: Accepted (2026-10-21). Implementation: see the Implementation slices below and G28 Phase 5.
Governed by: ADR-009 (verdict and exit codes), ADR-028 (artifact evidence is authoritative), ADR-061 (owner of each stage), ADR-063 (the Fact availability model that the schema history cites for field conversion), ADR-084 (API_BREAK buckets and the binary-depth gate).
Context¶
A template parameter may carry a default argument: template <typename T, typename U = T> struct Box;. Changing that default changes which type Box<char> names. Whether the change breaks a consumer depends on what the library exports and what its public signatures name.
Three shapes are relevant, measured on both header backends (compile.frontend: castxml and clang) in G28 Phase 5:
- A. An exported instantiation carries the default (
template class descriptor<float>;,case87_default_template_arg_changed). The default-substituted symbol changes. The current detectordetect_default_template_arg_changed(abicheck/compare/template_param_defaults.py) pairs the removed and added instantiation and reportsDEFAULT_TEMPLATE_ARG_CHANGED. This isBREAKINGon both backends. It is binary evidence, soBREAKINGis correct. - B. Header-only, nothing public names the template. No symbol and no public signature depends on the default. CastXML emits no record at all for an uninstantiated class template, so the change reads
NO_CHANGE. The clang AST has the template pattern andTemplateTypeParmDecl.defaultArg, butdumper_clang.pydoes not record the default, so it also readsNO_CHANGE. A consumer's ownBox<char>compiles against a different type, which is a source-level change that the L4 source replay reports as a risk (public_dependency_role_changed). - C. Default-substituted type reaches a public signature (
void take(const Box<char>&)). The mangled name changes, and the change is reported asfunc_removedplusfunc_addedwith no default label on either backend. The verdict is correct and only the explanation is missing.
Two facts are in tension:
DEFAULT_TEMPLATE_ARG_CHANGEDis registered asBREAKING. Shape B is a source-level consequence with no exported symbol behind it, so it has nowhere to land. Today it readsNO_CHANGEand is recorded as a known gap, not as a verdict.- The clang dumper has the default available and discards it. castxml cannot provide it at all. A detector that fires only where clang can see the default would make the same header produce a different verdict depending on
compile.frontend. That is the producer-dependent verdict the parity gate exists to prevent.
The current known-gaps entry (A header-only default template-argument change reads NO_CHANGE at L2 on both backends) is deliberately open with no detector change. This ADR decides how to close it.
Decision¶
D1. The snapshot records template-parameter defaults as an availability-carrying fact¶
Add the default to the ABI model, not the dumper. model/ owns ABI entity values shared across stages (ADR-061), so the field lives on the template pattern RecordType (the entity with is_template_pattern) and is not a dumper-local attribute.
- Field:
RecordType.template_param_defaults, typed as aFact(Fact[tuple[str | None, ...]]), one entry per template parameter in declaration order. An entry is the spelled default argument, orNonewhen that parameter has no default. - Three states, decided by the fact's availability rather than by a sentinel value:
- Recorded, with defaults: the producer observed the template pattern and its parameters. Each entry is a spelling or
None. - UNKNOWN / not collected: the producer did not record the field. This is the state for every castxml snapshot and every snapshot written before this schema.
- Never "unchanged" from absence. An UNKNOWN side never compares equal to a recorded side.
- Persisted through
storage/fact_codec.pywith its own availability family, so it follows the same rules as the otherFactfields. - Schema bump:
SCHEMA_VERSION60 → 61. A pre-61 reader would silently drop the field and read the pattern as defaultless, which is the failure the bump exists to prevent. The version history instorage/snapshot_schema_versions.pyrecords the bump the same way as v60.
Scope of the first slice: class templates only. Function-template default arguments are a follow-on under the same D1 contract.
D2. A header-level default change is a new API_BREAK kind; BREAKING stays for exported-symbol evidence¶
Introduce TEMPLATE_PARAM_DEFAULT_CHANGED (name to be checked against model/change_catalog/kind_names_*.py at implementation), with default_verdict = API_BREAK. It is emitted when:
- both sides carry recorded defaults (D1) for the same template pattern,
- a parameter's default spelling differs, and
- no
DEFAULT_TEMPLATE_ARG_CHANGEDfinding already covers the same template. Shape A keeps itsBREAKINGfinding and does not also emit this kind, so one root cause is not counted twice. The covered relation is recorded asDEDUPLICATEDwith its rule, per the "record before disposing" rule inAGENTS.md.
DEFAULT_TEMPLATE_ARG_CHANGED keeps BREAKING and its exported-instantiation meaning. It is unchanged by this ADR.
Bucket (ADR-084). The new kind is produced only from header data, so it is HEADER_OR_SOURCE_GATED, with a NON_BINARY_REASONS entry naming the public header input. A binary-only run never produces it.
Rejected: one kind with evidence-tiered verdict. Keep DEFAULT_TEMPLATE_ARG_CHANGED for both shapes and let an effective_verdict drop it to API_BREAK when no exported symbol changed. Rejected because the registry's default_verdict would stop describing the kind. Every consumer that reads a kind's verdict from the registry, including the *_KINDS sets derived from it, would be wrong for one of its two evidence tiers. ADR-084's gate writes no verdict and only caps API_BREAK findings, so using it to demote a BREAKING kind would invert its contract. The rejected option is also the one that would weaken the "BREAKING means symbol evidence" meaning this ADR is trying to keep.
Rejected: keep shape B BREAKING at header level. This is what the registry says today. Rejected because a header-only edit with no exported symbol and no public signature is not an ABI break. It is a source-compatibility break in the API_BREAK sense that ADR-084 defines.
Rejected: COMPATIBLE_WITH_RISK. A risk-level verdict would let a changed default pass a default gate. The user's contract for source-level changes is API_BREAK, which fails the gate the same way other source-compatibility breaks do.
D3. Backend coverage is an explicit assurance gap, not a silent no-change¶
compile.frontend: castxml cannot populate D1's fact. Its snapshots therefore carry the UNKNOWN state, and the run reports reduced coverage for this fact:
- a castxml run where a template pattern's defaults are UNKNOWN on either side emits a coverage/assurance gap naming
template-parameter defaults (castxml cannot record them), and does not produceNO_CHANGEfor that template; - a clang run where both sides are recorded decides the verdict from D2;
- the verdict must not differ between backends for the same observed evidence. When both backends observe the same defaults, they agree. Where one backend observed nothing (castxml), the result is a coverage gap, not a clean verdict. The two outcomes are different claims, so "different verdict" is not a parity violation here. A parity violation would be the same observed defaults producing different verdicts, and this design rules that out.
This resolves the Phase 5 rationale against a clang-only detector. The earlier objection was to a BREAKING verdict with no binary evidence that castxml could reproduce. Under D2 the verdict is API_BREAK, and under D3 castxml does not claim the opposite. It reports that it cannot see the fact.
Consequence for shape B: once implemented, a clang run returns API_BREAK where it returned NO_CHANGE, and a castxml run returns a coverage gap where it returned NO_CHANGE. The NO_CHANGE answer in G28 Phase 5 is superseded by this ADR when it is accepted.
Amendment (2026-10-23): the castxml verdict stays NO_CHANGE. The bullet
"does not produce NO_CHANGE for that template" and the castxml half of
"Consequence for shape B" above are amended. This was the open question in
Remaining item 1, and the maintainer's delegate decided it as follows:
- A castxml run whose headers declare a template-parameter default, where the
defaults are not recorded, reads
partial. The analysis-assurance status names the gaptemplate-parameter defaults (castxml cannot record them)for each castxml side whose header scan found one (ADR-085 D3, slice 4). The compare verdict for that template staysNO_CHANGE. castxml cannot observe the change, so the verdict does not claim one. - Only the clang answer is superseded: a clang run on shape B reads
API_BREAK(TEMPLATE_PARAM_DEFAULT_CHANGED) instead ofNO_CHANGE.
Rationale, from the product rules in AGENTS.md: weaker evidence narrows
conclusions and is reported, but it never fabricates a finding. The verdict
reports what was observed; assurance reports what could not be observed. A
castxml verdict of anything but NO_CHANGE would be a finding with no observed
evidence behind it, and it would break the same-evidence-same-verdict parity
that the test plan requires. A castxml run that reads NO_CHANGE with
partial assurance is not a clean pass: the gap is named, and the run is not
reported as complete.
The test plan's shape-B castxml expectation is amended the same way: the castxml
run reads NO_CHANGE with partial assurance and the named gap, pinned by
tests/test_default_template_arg_l2_matrix.py (including on
catalog/cases/case224_header_only_template_default_added).
Consequences¶
- Behaviour change (after acceptance). A clang run on shape B changes from
NO_CHANGEtoAPI_BREAK. This is a new finding on a header-only default change. It needs a changelog fragment and is reviewed as a policy change, not a fix. - Coverage now says something. A castxml run on a template with recorded-elsewhere defaults reports the gap instead of a clean result. This is the intended direction (ADR-028: weaker evidence narrows the conclusion) but it will show up in existing castxml reports.
- Schema v61. Stored baselines written before this version load the fact as UNKNOWN. A pre-61 reader rejects the document rather than dropping the field.
- Known-gap closure. The known-gaps entry stops reading as "deliberate, no detector change" and points here. It closes when D1–D3 land.
- Kind count. One new
ChangeKind. The<N> ChangeKindcounts in the docs must followlen(ChangeKind)(check_ai_readiness.pydoc-count-sync).
Migration¶
- Stored snapshots. Pre-v61 snapshots load the field as UNKNOWN. They are not re-classified. Comparing a pre-v61 baseline with a v61 dump of the same headers reports a coverage gap for the template, not
NO_CHANGEand notAPI_BREAK. The fix for the user is to re-dump the baseline. Nothing silently changes. - Mixed producers. A castxml baseline against a clang candidate is UNKNOWN on the castxml side, so it reports the gap. It is not compared as if both sides carried defaults.
- Existing
DEFAULT_TEMPLATE_ARG_CHANGED. No change in kind, verdict or detector behaviour for shape A. - Readers. The v61 bump follows the existing
_MIN_SCHEMA_VERSION_REQUIRING_HARD_REJECTIONpath, so an older reader fails loudly.
Test plan¶
Each claim below is tested through the public workflow (abicheck dump then abicheck compare), and the rendered report is checked, not only the internal detector.
- Both backends, shapes A/B/C. Run
case87_default_template_arg_changed(A) and the two inline headers (B and C) withcompile.frontend: castxmlandclang. Pin: - A:
BREAKING,DEFAULT_TEMPLATE_ARG_CHANGEDon both backends (unchanged); the new kind is not emitted for it. - B: clang
API_BREAK,TEMPLATE_PARAM_DEFAULT_CHANGED; castxmlNO_CHANGEwithpartialassurance and the named gap (amended 2026-10-23, see D3). - C:
BREAKINGviafunc_removed/func_addedon both backends (unchanged); the new kind is not emitted when the mangled change already reports the break. - Parity. For each shape where both backends observe the same defaults, the verdict and kind set are equal. A parametrised test asserts this, so a backend that drops the fact fails it.
- Old-snapshot comparison. A v60 fixture snapshot (from before the bump) compared with a v61 clang dump of the same header yields a coverage gap for the template and no
NO_CHANGEand noAPI_BREAK. A v61 snapshot round-trips the fact including theNone-per-parameter entries. - Schema. A pre-61 reader's rejection path is exercised by the existing version-history test pattern; a v61 document with the key absent decodes as UNKNOWN, not as no-defaults.
- Classification gates. The new kind is classified in
tests/canonical_identity_contract.py(exactly one ofTYPE_BEARING,VALUE_INSENSITIVE,UNVERIFIED), andADR-084bucket membership is asserted inpolicy/binary_gated_contract.py's tests. - Surface traceability.
docs/contribute/adr/adr-surface-registry.yamllists this ADR;scripts/check_adr_surfaces.pypasses; the baseline is rewritten only if a surface is deliberately downgraded.
Implementation slices¶
Each slice is independently reviewable and is not started until this ADR is accepted.
- Model and schema.
RecordType.template_param_defaultsas aFact,storage/fact_codec.pyencode/decode with availability family,SCHEMA_VERSION61 with its history note, and the old-snapshot UNKNOWN load path. No detector, no behaviour change. - Clang records defaults.
dumper_clang.pypopulates the fact fromTemplateTypeParmDecl.defaultArgfor class templates. castxml writes nothing, so its snapshots stay UNKNOWN. - New kind and detector.
TEMPLATE_PARAM_DEFAULT_CHANGEDin the registry and kind names, a detector incompare/template_param_defaults.pygated on both sides being recorded, the shape-A suppression,HEADER_OR_SOURCE_GATEDmembership with its non-binary reason, and the canonical-identity classification. - Assurance gap for castxml. The UNKNOWN-on-either-side coverage/assurance gap, its rendering in the report, and the header-backend capability reference (
docs/reference/header-backend-capabilities.md, Known gaps section) updated to the new behaviour. - Closure. Update the known-gaps entry to closed, the G28 Phase 5 plan, the ADR index status, the surface registry, and a changelog fragment.
Implementation status (2026-10-23)¶
Landed on feat/adr-085-template-default-args. Deviations from the text above
are recorded here rather than edited into the decision.
- Schema version is 62, not 61.
SCHEMA_VERSIONwas already 61: theRecordType.specialization_declared_factchange (ADR-063 follow-up, #1684) took it. The new field is v62, with its history note instorage/snapshot_schema_versions.pyand its availability constant instorage/fact_schema_versions.py. - Slice 1 (model and schema): landed.
RecordType.template_param_defaults_fact, codec instorage/fact_codec.pywith its validator instorage/fact_payload_validation.py, encode-drop instorage/snapshot_encode.py, registry entry inmodel/fact_registry_entries_types.py. - Slice 2 (clang records defaults): landed, for type-parameter templates only.
The clang walk reads
TemplateTypeParmDecl.defaultArgthrough the merged defaults index. A template with any non-type or template-template parameter is not recorded (UNKNOWN), because its default spelling is not read. This is a conservative limit, not a decision, and it is listed under Remaining. - Slice 3 (kind and detector): landed.
TEMPLATE_PARAM_DEFAULT_CHANGED(API_BREAK,HEADER_OR_SOURCE_GATED,UNVERIFIEDidentity bucket beside its shape-A sibling). Detector incompare/template_param_defaults.py; the pipeline entry inpolicy/template_default_changes.pyrecords each shape-A relation asDEDUPLICATEDin the disposition ledger. - Slice 4 (castxml assurance gap): landed (ADR-085 D3). castxml cannot
record the defaults, so its run scans the public headers it parsed:
extract/headers/template_default_scan.pylooks for a template parameter list with a top-level default argument, and the result is persisted asAbiSnapshot.template_default_header_scan(schema v63;Nonewhen no scan ran). The rollup inpolicy/analysis_assurance/_template_defaults.pyreports the gaptemplate-parameter defaults (castxml cannot record them)for each castxml side whose scan isTrue, and the run readspartial. The compare verdict staysNO_CHANGE: castxml still cannot see the change, so only the status reports the gap. The rollup also reports a gap for any class-template pattern whose defaults are not recorded, which covers pre-v62 snapshots and non-type-parameter templates on clang. - Slice 5 (closure): partial. The known-gap entry is PARTIAL, not closed. The G28 Phase 5 section reflects the same state.
-
Remaining 3 (non-type and template-template defaults): landed. The all-type gate is removed.
dumper_clangrecords the spelled default of every parameter of a class-template pattern (extract/headers/clang/templates.py,_template_param_default_spellings), soTEMPLATE_PARAM_DEFAULT_CHANGEDfires for them on clang. Spelling is the normalized form, not the source text: a non-type default is its expression (a literal by value, a named constant by name, an enumerator asEnum::NAME, an operator as(lhs op rhs)); a template-template default is the plain name read from its source span, since clang's JSON carries no name for atemplateargument. Any default outside that set (sizeof(T), a cast, a call) leaves its whole pattern unrecorded rather than recording a spelling that could be wrong. No schema change. The fact is stillFact[tuple[str | None, ...]]under the v62 availability, soSCHEMA_VERSIONis not bumped. A stored clang baseline with a non-type pattern was UNKNOWN and stays UNKNOWN until it is re-dumped: comparing it with a new dump reports the coverage gap, not a change. castxml is unchanged. Case 228's castxml expectation staysNO_CHANGE; its clang result is nowAPI_BREAK, recorded in its README. -
Function-template slice (2026-10-23): landed.
Function.template_param_defaults_fact(schema v64,storage/snapshot_schema_versions.py) is the D1 fact on the function pattern. A function-template pattern is already aFunctionentry, and itsentity_idcarries each template parameter's kind, so two overloads that share a name are paired by identity and never by name. The clang dumper maps every pattern declaration to its enclosingFunctionTemplateDecl's defaults in one pass over the AST (extract/headers/clang/function_template_defaults.py), andparse_functionsattaches the fact. The detector isdetect_function_template_param_default_changedincompare/template_param_defaults.py. No new kind: the header-level break is the sameAPI_BREAKTEMPLATE_PARAM_DEFAULT_CHANGEDas for a class template, since D2's reasoning does not depend on whether the template is a class or a function. Shape-A dedup applies unchanged, by qualified name. The castxml verdict staysNO_CHANGEand readspartial, as for class templates. Residual: a function template with an unspelled default records nothing, and the rollup does not count it (see Not done).
Remaining¶
- Verdict decision (resolved 2026-10-23). The castxml verdict stays
NO_CHANGE, and the run readspartialwith the gap named. D3 is amended above. Rationale: weaker evidence narrows the conclusion and is reported; it does not fabricate a finding, and this keeps same-evidence-same-verdict parity. Pinned bytests/test_default_template_arg_l2_matrix.py. - Parity test. Landed for the header-only shape: clang reports
template_param_default_changed, castxml reports the gap and no kind (tests/test_default_template_arg_l2_matrix.py). - ~~Non-type and template-template parameters.~~ Done (see Implementation
status). The spelling rule and the
sizeof-style exclusion are recorded there and inheader-backend-capabilities.md. - ~~Function-template defaults.~~ Done (see Implementation status, function slice). The clang backend records them; the castxml header scan already covered function templates.
Not done¶
- Source-replay (L4) wording for shape B beyond the existing
public_dependency_role_changedrisk. - Function-template defaults the clang walk cannot spell (a default expression
outside the spelling set, such as
sizeof(T)): the pattern records nothing and the assurance rollup, which counts class-template patterns only, reports no gap for it. Recorded in known-gaps.
Index: ADR index