Skip to content

feat(api): deprecation channel, CLI surface gate, ADR-022 emit guards - #2436

Merged
mchmarny merged 5 commits into
mainfrom
fix/adr022-emitter-selection-and-metadata-gate
Aug 28, 2026
Merged

feat(api): deprecation channel, CLI surface gate, ADR-022 emit guards#2436
mchmarny merged 5 commits into
mainfrom
fix/adr022-emitter-selection-and-metadata-gate

Conversation

@mchmarny

@mchmarny mchmarny commented Aug 28, 2026

Copy link
Copy Markdown
Member

Summary

Closes the four ADR-022 / API-freeze follow-ups scheduled for v0.21: aligns the two RecipeMetadata header gates, adds an emit-site guard before the v0.22 emitter switch, defines and implements the deprecation channel, and baselines the CLI surface. Three focused commits, reviewable independently.

Motivation / Context

With ADR-022 merged, v0.21 is Release N of its three-release migration (readers accept both tracks, emitters still write alpha). Two of these issues have to land before the v0.22 emitter switch (#2416) to be useful at all; the other two are v1 API-freeze prerequisites that do not depend on it.

One sequencing note worth flagging for the epics: epic #2370 lists the three ungated-surface baselines (#2111 CLI, #2112 REST, #2113 bundle/schemas) as Phase 1, ahead of #2114/#2115. For REST and bundle/schemas that ordering is inverted — ADR-022 deliberately breaks the artifact surface at N+1 and N+2, and #2417 removes alpha values from every apiVersion enum in server.yaml. A REST or schema baseline committed before v0.23 would fire on our own planned removal. The CLI is the one surface ADR-022 does not touch, which is why #2111 is in this PR and #2112/#2113 are not.

Fixes: #2421
Fixes: #2423
Fixes: #2111

#2115 and #2114 were closed manually alongside this PR. #2115's five implementable criteria all ship here; its sixth ("exercise the channel once on a real deprecation before v1.0.0") was judged theater and will be satisfied for real by #2112 / #2416 / #2417. #2114's decision was already recorded in ADR-022; its remaining execution is #2416 and #2417.

Related: #2416, #2417, #2112, #2113, #2370, #1812

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Build/CI/tooling

Component(s) Affected

  • CLI (cmd/aicr, pkg/cli)
  • API server (cmd/aicrd, pkg/server)
  • Recipe engine / data (pkg/recipe)
  • Docs/examples (docs/, examples/)
  • Other: new pkg/deprecation; RELEASING.md; pkg/header tests

Implementation Notes

1. fix(recipe)closes #2421 and #2423

#2421validateRecipeInputAPIVersion short-circuited on an empty apiVersion before it inspected the kind, so the same headerless RecipeMetadata was rejected by the catalog scanner and silently hydrated when passed with -r. The empty-value tolerance is now scoped to RecipeResult inputs, which genuinely predate the field.

The issue offered three options and leaned toward deferring to v0.22 with a deprecation warning. We took option 1 (close it now) because the blast radius is nil: no committed RecipeMetadata in the tree lacks an apiVersion, verified by scanning every kind: RecipeMetadata file. Deferring would have left the two paths disagreeing across the exact release where #2416 rewrites every committed header. ADR-022 §3 records which clause governs a catalog kind arriving via the direct-input path, so the next reader does not re-derive it.

#2423adr022_map_test.go pins that each track constant routes to the right gate and target, but cannot see which constant an individual emit site chose. StableGroupVersion and AuthoringGroupVersion carry the same string until v0.22, so a catalog emitter reaching for the stable constant is invisible today and would quietly emit aicr.run/v1 on an authoring document at the switch.

adr022_emit_test.go closes that: it asserts the observed apiVersion on a real artifact against its track's constant. Expectations are written as constants, never literals, so they retarget in lockstep at the switch. Chained with the map, the contract is covered end to end: emit site → track constant → §2 target → read gate.

Coverage is five code emitters (Snapshot via NodeSnapshotter.Measure, default/profile/configuration-bearing RecipeResult, BundleProvenance via WriteProvenance) plus a scan of all 115 committed catalog documents, which directly guards #2416's "no committed artifact carries an alpha apiVersion" criterion.

Finding for #2416's inventory: recipe.RecipeCriteriaAPIVersion and config.APIVersion have no emit sites — they appear only in validation error messages. RecipeCriteria is a read-only input kind and AICRConfig is user-authored. The issue's emitter table lists both as emitters; they need the authored-file treatment, not an emitter flip.

2. feat(server)closes #2115 except its scheduling criterion

RELEASING.md gains a per-surface policy: breaking vs. additive for CLI, REST, Go SDK, and bundle/schemas, plus the notice owed — two minor releases before v1.0, the next vMAJOR after. Artifact apiVersion keeps its maturity-scoped window and takes precedence for that surface.

New pkg/deprecation carries the runtime half. Recorder.Warn emits one slog warning per distinct subject, so a loader walking 100 deprecated files does not emit 100 identical lines. SetHTTPHeaders sets Sunset (RFC 8594), Deprecation, and Link rel="deprecation".

Three deliberate calls worth review:

  • Warnings route through slog, not raw stderr. This honors AICR_LOG_LEVEL, NO_COLOR, and TTY detection like every other diagnostic. The tradeoff is real: AICR_LOG_LEVEL=error suppresses deprecation warnings. Silencing warnings is treated as an explicit opt-out, and the release notes plus the docs page do not depend on log level.
  • Deprecation is not RFC 8594 — it is RFC 9745. Only Sunset is RFC 8594. Deprecation is RFC 9745 (Standards Track, March 2025), whose §2.1 requires a Structured Field Date: @<unix-seconds>. The shipped code emits only that form, and omits the header entirely when no date is recorded — it never emits true. RFC 9745 admits no placeholder, and an unparseable Deprecation is worse than an absent one. (An earlier revision of this description described a true fallback; that reflected a pre-review draft of the code, not what merged.)
  • DocsURL points at GitHub, not docs.nvidia.com. The published URL is derived by Fern from docs/index.yml and versioned per release, so it is neither stable nor verifiable from this repo. A dead link inside a deprecation warning defeats the warning.

deprecationMiddleware is ordered outside rateLimitMiddleware, which writes a 429 and returns without calling next. A client backing off a deprecated endpoint is exactly the one that needs to know it is going away.

No route is marked deprecated. The /v1/* disposition is #2112. Per the policy added here, /v1 retirement is also named as the exercise of record the channel owes before v1.0.0 — the ADR-022 alpha migration warns and removes across v0.22/v0.23, but alpha owes no window under §4, so it cannot alone demonstrate the channel honoring an obligation it actually had.

3. test(cli)closes #2111

cli-surface.golden pins all 21 commands and 206 flags with aliases, types, defaults, required/hidden state, and env vars. Aliases are pinned in urfave's order, so promoting --gpu over --accelerator registers as a change rather than a reordering.

The gate classifies its own failure: an added command or flag reports as additive with "regenerate"; a removed or renamed one reports as BREAKING and points at the deprecation policy. Reporting both identically would train everyone to run -update reflexively, which is the reflex that lets a rename reach main.

Usage strings are deliberately excluded — they are prose, and pinning them would make the gate fail on every wording fix.

Testing

go test -race ./pkg/... ./cmd/...          # all pass
golangci-lint run -c .golangci.yaml ./...  # 0 issues
make lint-yaml check-docs-filenames check-docs-mdx check-docs-mdx-parse  # all OK

Every behavioral change was mutation-tested, not just asserted:

Mutation Result
Restore the apiVersion == "" short-circuit in loader.go Both new #2421 tests fail
Set recipes/overlays/aks.yaml to v1alpha2 (wrong track) Catalog scan fails, naming file + expected value
Nest deprecationMiddleware inside rateLimitMiddleware Ordering test fails with the intended message
Add a line to the CLI golden the tree lacks Reports BREAKING, cites the deprecation policy
Remove a line from the golden the tree has Reports Additive, says regenerate

An earlier version of the middleware ordering test used a panicking handler; that passes either way, because the panic happens after the inner layers have already run. It was rewritten against a zero-token rate limiter, which is the short-circuit that actually discriminates.

Coverage: pkg/deprecation 97%+ (new package); pkg/server 83.9% → 84.0%. Every new exported func is at 100% (WithDeprecatedRoutes, deprecationMiddleware, Warn, SetHTTPHeaders, Message).

make qualify was not run end to end. It aborts locally at license-check and check-agents-sync, both failing with operation not permitted on .licenses-cache and /dev/fd/63 — sandbox restrictions, not real failures — which prevents e2e, scan, api-diff, tuning-check, and bom from running. Those need a clean local run or CI before merge. pkg/client/v1 is untouched, so api-diff is expected clean.

Risk Assessment

  • Medium — Touches multiple components and contains one intentional behavior change

Rollout notes: The one behavior change is #2421: a RecipeMetadata overlay with a missing or empty apiVersion passed via aicr recipe -r / aicr bundle -r is now rejected instead of silently hydrated. No in-tree file is affected (all 115 committed catalog documents carry a header). External users authoring headerless overlays outside a catalog tree are affected; this is documented in docs/integrator/data-extension.md, docs/user/api-reference.md, and docs/user/deprecations.md. Hydrated RecipeResult inputs keep the empty tolerance until v0.23 (#2417).

Everything else is additive: a new package, a new opt-in server option, a new test, and docs.

Checklist

  • Tests pass locally (make test with -race)
  • Linter passes (make lint) — golangci-lint 0 issues; see the make qualify caveat above
  • I did not skip/disable tests to make CI green
  • I added/updated tests for new functionality
  • I updated docs if user-facing behavior changed
  • Changes follow existing patterns in the codebase
  • Commits are cryptographically signed (git commit -S)

@mchmarny mchmarny added the theme/ci-dx CI pipelines, developer experience, and build tooling label Aug 28, 2026
@mchmarny mchmarny self-assigned this Aug 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

@github-actions

Copy link
Copy Markdown
Contributor

Recipe evidence check

No leaf overlays affected by this PR.

This gate is warning-only and never blocks merge.

@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: 14ad0abd-4698-4259-a257-34c4c48d0cc3

📥 Commits

Reviewing files that changed from the base of the PR and between 2290267 and 77299a7.

📒 Files selected for processing (1)
  • pkg/header/adr022_emit_test.go

Included review availability: Your plan provides up to 12 included reviews per hour; 9 remain after this review.


📝 Walkthrough

Walkthrough

The changes define deprecation policy and documentation for frozen public surfaces. They add a CLI surface golden test and baseline. They add structured deprecation notices and REST response headers for configured routes. They make direct RecipeMetadata inputs reject empty apiVersion values while preserving legacy RecipeResult tolerance. They add ADR-022 emitter round-trip tests and loader validation coverage.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to 77299

The PR is merge-ready after normal checks and review; no actionable merge-blocking risk remains.

Suggested reviewers: almaslennikov

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main changes: deprecation support, the CLI surface gate, and ADR-022 emit guards.
Description check ✅ Passed The description is directly related to the changeset and explains the four follow-ups, implementation details, testing, risks, and qualification caveat.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/adr022-emitter-selection-and-metadata-gate

Comment @coderabbitai help to get the list of available commands.

coderabbitai[bot]

This comment was marked as resolved.

@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Coverage Report ✅

Metric Value
Coverage 84.2%
Threshold 80%
Status Pass
Coverage Badge
![Coverage](https://img.shields.io/badge/coverage-84.2%25-brightgreen)

Merging this branch will increase overall coverage

Impacted Packages Coverage Δ 🤖
github.com/NVIDIA/aicr/pkg/deprecation 100.00% (+100.00%) 🌟
github.com/NVIDIA/aicr/pkg/recipe 90.32% (ø)
github.com/NVIDIA/aicr/pkg/server 84.05% (+0.09%) 👍

Coverage by file

Changed files (no unit tests)

Changed File Coverage Δ Total Covered Missed 🤖
github.com/NVIDIA/aicr/pkg/deprecation/deprecation.go 100.00% (+100.00%) 31 (+31) 31 (+31) 0 🌟
github.com/NVIDIA/aicr/pkg/recipe/loader.go 88.57% (ø) 105 93 12
github.com/NVIDIA/aicr/pkg/server/config.go 100.00% (ø) 27 27 0
github.com/NVIDIA/aicr/pkg/server/middleware.go 99.17% (+0.03%) 121 (+4) 120 (+4) 1 👍
github.com/NVIDIA/aicr/pkg/server/server.go 81.08% (+0.26%) 148 (+2) 120 (+2) 28 👍

Please note that the "Total", "Covered", and "Missed" counts above refer to code statements instead of lines of code. The value in brackets refers to the test coverage of that file in the old version of the code.

@mchmarny
mchmarny force-pushed the fix/adr022-emitter-selection-and-metadata-gate branch from f217428 to fb2c0a3 Compare August 28, 2026 15:32
@mchmarny

This comment was marked as resolved.

@mchmarny
mchmarny force-pushed the fix/adr022-emitter-selection-and-metadata-gate branch from fb2c0a3 to cec7c19 Compare August 28, 2026 15:47
@mchmarny

This comment was marked as resolved.

@coderabbitai

This comment was marked as resolved.

@mchmarny
mchmarny force-pushed the fix/adr022-emitter-selection-and-metadata-gate branch from b235299 to 2290267 Compare August 28, 2026 16:19
@mchmarny

This comment was marked as resolved.

@mchmarny
mchmarny marked this pull request as ready for review August 28, 2026 16:36
@mchmarny
mchmarny requested a review from a team as a code owner August 28, 2026 16:36
@mchmarny
mchmarny force-pushed the fix/adr022-emitter-selection-and-metadata-gate branch from 2290267 to 77299a7 Compare August 28, 2026 16:42
@mchmarny

This comment was marked as resolved.

Closes #2421: validateRecipeInputAPIVersion short-circuited on an empty
apiVersion before it inspected the kind, so a headerless RecipeMetadata
was rejected by the catalog scanner but silently hydrated when passed
with -r. The empty-value tolerance is now scoped to RecipeResult inputs,
which genuinely predate the field; ADR-022 3 retires that at N+2 (#2417).

Closes #2423: adds the emit-site half of the ADR-022 contract.
adr022_map_test.go pins that each track constant routes to the right
gate and target, but cannot see which constant a given emit site chose
-- stable and authoring carry the same string until the v0.22 switch.
adr022_emit_test.go asserts the observed apiVersion on a real artifact
against its track constant, so a mislabeled site diverges at the switch
instead of shipping a wrong-version artifact. Covers the five code
emitters plus the 115 committed catalog documents.

Signed-off-by: Mark Chmarny <mark@chmarny.com>
Closes #2115 except its scheduling criterion.

RELEASING.md gains a per-surface deprecation policy: what counts as
breaking and additive on each of the four frozen surfaces, and the notice
a removal owes -- two minor releases before v1.0, the next vMAJOR after.
Artifact apiVersion keeps its maturity-scoped window and takes precedence
for that surface.

New pkg/deprecation carries the runtime half. Recorder.Warn emits one
slog warning per distinct subject, so a loader walking 100 deprecated
files does not emit 100 identical lines. SetHTTPHeaders sets Sunset (RFC
8594), Deprecation, and Link rel=deprecation. The Go SDK needs no runtime
support: a // Deprecated: marker reaches consumers through staticcheck at
their build time, which is documented in the integrator guide.

pkg/server gains WithDeprecatedRoutes and a deprecationMiddleware ordered
outside rateLimitMiddleware, so a throttled client still learns the
endpoint is going away. No route is marked yet -- the /v1 disposition is
#2112, which is also the exercise of record the channel owes before
v1.0.0.

docs/user/deprecations.md is the durable register, seeded with the two
active ADR-022 artifact deprecations.

Signed-off-by: Mark Chmarny <mark@chmarny.com>
Closes #2111.

pkg/cli/testdata/cli-surface.golden pins all 21 commands and 205 flags
with their aliases, types, defaults, required/hidden state, and env vars.
TestCLISurface walks the live urfave/cli tree and fails on drift. It runs
under make test, so it is already inside the merge gate.

The gate classifies its own failure rather than reporting every diff
identically. An added command or flag is additive and the message says to
regenerate; a removed or renamed one is reported as BREAKING and points
at the deprecation policy in RELEASING.md. Reporting both the same way
would train everyone to run -update reflexively, which is the reflex that
lets a rename reach main.

Aliases are pinned in urfave's order, so promoting --gpu over
--accelerator registers as a change rather than a reordering. Usage
strings are deliberately excluded: they are prose and would make the gate
fail on every wording fix.

TestCLISurfaceIsNotEmpty guards the walker itself, so a refactor that
collapsed the tree cannot pass by regenerating an empty golden.

Signed-off-by: Mark Chmarny <mark@chmarny.com>
main's 1743160 replaced ROADMAP's "### 2. Stability" with
"## 1. Defensible API stability", which broke every inbound
ROADMAP.md#2-stability anchor. Two of the four were pre-existing in
ADR-022 and are fixed here too, since lychee runs on any PR touching
docs/** and would otherwise fail this one for main's breakage.

The rewrite also dropped the "deprecation channel exercised at least
once in practice before tagging v1.0.0" acceptance criterion. RELEASING.md
no longer claims ROADMAP requires a rehearsal; it names the two real
deprecations already scheduled (#2112 and the ADR-022 alpha migration) as
the exercise, which is also why #2115 could close without one.

Signed-off-by: Mark Chmarny <mark@chmarny.com>
@mchmarny
mchmarny force-pushed the fix/adr022-emitter-selection-and-metadata-gate branch from 6472065 to bb23fcc Compare August 28, 2026 17:01
@mchmarny

This comment was marked as resolved.

@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@mchmarny Thanks for the detailed verification.

The RFC 9745 correction is correct. Deprecation now carries the deprecation timestamp as @<unix-seconds>. Sunset independently carries the removal timestamp. Omitting a zero Deprecated value while retaining Link: ...; rel="deprecation" is conforming behavior.

The synthetic alias test closes the coverage gap that an all-empty production baseline could not detect. The SDK documentation now correctly describes its build-time deprecation channel.

I accept the established same-line //nolint:gosec // reason convention. No change is needed for the rationale-placement finding.

🐇 ✅


✏️ Learnings added
Learnt from: mchmarny
URL: https://github.com/NVIDIA/aicr/pull/2436

Timestamp: 2026-08-28T18:02:47.376Z
Learning: In `pkg/deprecation`, RFC 9745 requires the HTTP `Deprecation` header to use a Structured Header Date encoded as `@<unix-seconds>` and to represent when the resource became deprecated. RFC 8594 `Sunset` independently represents the planned removal time with an IMF-fixdate. When `Notice.Deprecated` is zero, omit `Deprecation` but retain the deprecation `Link` header.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: mchmarny
URL: https://github.com/NVIDIA/aicr/pull/2436

Timestamp: 2026-08-28T15:58:35.648Z
Learning: In the Go code under `pkg/`, the repository convention is to put golangci-lint suppression rationales on the same line as the directive, for example `//nolint:gosec // reason`. This format is documented by golangci-lint and has established use across the package, so review comments should not request a preceding rationale line without a repository-wide convention change.

You are interacting with an AI system.

@njhensley njhensley left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multi-persona review — Approve with comments

Method: four independent persona reviewers (Correctness · API-contract/ADR-022 · Security-Operability/CI-DX · Docs) fanned out in parallel, then every finding adjudicated against the resolved code by an adversarial senior pass, with the crux claims independently reproduced.

Legend: 🔴 Blocker · 🟠 Major · 🟡 Minor · 🔵 Nitpick

Overall assessment

High-quality, unusually well-documented PR. The load-bearing correctness claims all hold up under independent verification:

  • The #2421 loader fix is correct. Moving the apiVersion == "" short-circuit below the RecipeMetadata branch (pkg/recipe/loader.go) makes an empty header on a RecipeMetadata fall through to rejection (IsSupportedAuthoringAPIVersion("") and IsSupportedProfileAPIVersion("") both return false). The reordered gate is now behavior-identical to the catalog scanner classifyRecipeMetadataCatalogHeader across the empty/alpha/target/unknown matrix, and TestRecipeMetadataHeaderGatesAgree proves that directly, error codes included. The fail-open seam is genuinely closed.
  • The ADR-022 emit/map tripwire is armed, not vacuous. The "passes trivially today" behavior is deliberately disclosed (stable/authoring constants share the alpha string until the v0.22 switch); expectations are written as constants that retarget in lockstep. The "no emit site for RecipeCriteriaAPIVersion / config.APIVersion" finding for #2416 checks out — both appear only in read/validation gates.
  • The deprecation runtime half is concurrency- and RFC-correct. Recorder.Warn's seen map is read/written entirely under the mutex with the slog I/O outside the lock; SetHTTPHeaders emits RFC 9745 @unixseconds / RFC 8594 IMF-fixdate, omits on zero-time, and uses Add (not Set) for the multi-valued Link. No attacker-controlled string reaches any header — Subject/Replacement go only to the log message.
  • The CLI freeze gate is reproducible. No flag default reads env/$HOME/cwd/time; the one version-derived default (--image:latest) is guarded by TestCLISurfaceDefaultsAreBuildIndependent.

No blocker or major issues survived adjudication. What remains is doc polish (2 minor) and two forward-looking nitpicks, left inline.

Confirmed non-issues (examined, no change needed)

  • WithDeprecatedRoutes stores the caller's map by reference — matches the existing WithHandler pattern; construction-time config, read-only at request time.
  • AICR_LOG_LEVEL=error suppresses CLI deprecation warnings — disclosed and deliberate; the HTTP and Go-SDK arms are log-independent, and release notes + the durable page are the log-independent channels.
  • deprecationMiddleware / WithDeprecatedRoutes unused by any live route — exported (no U1000), and the populated path is covered by TestDeprecationMiddleware, TestDeprecationHeadersSurviveRateLimitRejection, and TestWithDeprecatedRoutes. Scaffold for #2112.
  • No HTTP header-injection vector in SetHTTPHeaders — only the DocsURL const and formatted dates are written.

One note for the PR description (not a code change)

The description's framing of the Deprecation header ("the widely-implemented earlier form is the literal true… we emit a date when we have one and true when we do not") does not match the shipped code, which emits only RFC 9745 @unixseconds or omits the header entirely — never true. The code and all three docs are internally consistent and correct; only the description is stale. Worth a one-line edit so a future reader doesn't trust the narrative over the artifact.

Summary

🔴 Blocker 0 | 🟠 Major 0 | 🟡 Minor 2 | 🔵 Nitpick 2     Recommendation: Approve with comments

Comment thread RELEASING.md
another: release notes are read once, the durable page is read later by someone
debugging, and the runtime warning reaches the user who never read either.

1. A `## Deprecations` section in the release notes for the release that

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor — RELEASING.md says release notes carry a ## Deprecations (h2) section, but the aicr-release-notes skill requires ### Deprecations (h3)

This item says "A ## Deprecations section in the release notes," but the authoritative generator (.agents/skills/aicr-release-notes/SKILL.md:108) adds ### Deprecations as an h3 sibling of ### Highlights. Someone following RELEASING.md literally would emit an h2 that breaks the heading hierarchy the skill enforces.

Fix: Change this to ### Deprecations to match the skill's h3 level.

Comment thread docs/user/deprecations.md
### Empty `apiVersion` on artifacts

**Surface:** bundle and artifact schemas ·
**Deprecated in:** v0.22 · **Removed in:** v0.23

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor — "Empty apiVersion" entry header (Deprecated v0.22 / Removed v0.23) is contradicted by its own body, which says the RecipeMetadata narrowing landed in v0.21

The header labels this Deprecated in v0.22 / Removed in v0.23, but the body two lines down (lines 71-76) says that for the RecipeMetadata direct-overlay path the empty-header tolerance was already removed in v0.21 (#2421 — what this PR ships); only the RecipeResult tolerance survives to v0.23. The single-line summary contradicts its own paragraph and reads as forward-dated for the change it describes.

Fix: Qualify the header, e.g. Removed in: v0.23 (RecipeResult); v0.21 for RecipeMetadata overlays, mirroring the clean scoping already in api-reference.md:604 ("The tolerance is scoped to RecipeResult").

Comment thread pkg/cli/surface_test.go
// contract change, not a reordering.
rendered := make([]string, 0, len(names))
for i, n := range names {
if i == 0 || len(n) > 1 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Nitpick — flagFacts renders a single-char primary flag as --x rather than -x

flagFacts prefixes names[0] with -- unconditionally; only aliases (i>0) get the single-char - treatment. A flag whose primary name is a single character would be recorded as --x. No such flag exists in the tree today, so this is latent and cosmetic — the golden stays deterministic and internally consistent.

Fix: Apply the same len(n) > 1 check to i == 0 to future-proof the rendering.

Comment thread pkg/server/middleware.go
// made here.
func (s *Server) deprecationMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if notice, ok := s.config.DeprecatedRoutes[r.URL.Path]; ok {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Nitpick — Deprecation route matching is exact-path only

s.config.DeprecatedRoutes[r.URL.Path] is an exact-path lookup, consistent with AICR's exact-path mux registration and correct today (no route is deprecated). When #2112 deprecates a /v1/* family, exact-match won't cover sub-paths.

Fix: Revisit prefix semantics when #2112 lands. Flagging only so it is a conscious choice, not a defect here.

@mchmarny
mchmarny merged commit 54f31b4 into main Aug 28, 2026
70 checks passed
@mchmarny
mchmarny deleted the fix/adr022-emitter-selection-and-metadata-gate branch August 28, 2026 19:03
mchmarny added a commit that referenced this pull request Aug 28, 2026
Three findings from the approving review on #2436, all landing before
v0.21 cuts so none of them reach a release.

RELEASING.md advertised a "## Deprecations" release-notes section while
the generator that actually emits it (.agents/skills/aicr-release-notes)
uses "### Deprecations" as an h3 sibling of "### Highlights". Following
the prose literally would have broken the heading hierarchy the skill
enforces.

The deprecations ledger entry for the empty-apiVersion tolerance was
headed "Deprecated in v0.22, Removed in v0.23" while its own body
disclosed that RecipeMetadata overlays lost the tolerance in v0.21. The
header now scopes both dates by kind, mirroring the scoping already used
in docs/user/api-reference.md.

flagFacts chose the dash prefix by position rather than name length, so a
flag whose *primary* name was a single character would have been recorded
as "--x" instead of "-x". No flag in the tree has one, which is exactly
why the golden could never catch it: a positional implementation produces
a byte-identical baseline forever. The rendering is now keyed on length
and TestFlagFactsRendersDashPrefixByNameLength pins it against a synthetic
flag. Regenerating the golden after the change is a no-op, confirming no
current flag is affected.

Signed-off-by: Mark Chmarny <mark@chmarny.com>

@yuanchen8911 yuanchen8911 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Post-merge review of this PR. Verified against current main at b6b242026; findings already fixed by that commit are excluded. Five findings, four of them mechanical.

1. Migration guidance names a command that does not exist. Four published-doc locations and one test comment tell users to pass an overlay with aicr recipe -r. The recipe command has no --recipe/-r flag — the CLI surface baseline committed here shows --snapshot,-s and no --recipe under aicr recipe — and a live probe returns flag provided but not defined: -r. The flag exists on bundle, validate, mirror list, and evidence digest. This is the remediation path for exactly the users this change breaks. Replace with aicr bundle -r and aicr validate -r.

2. A required flag added to an existing command is reported as compatible. diffLines routes every new baseline line into the additive bucket, including lines carrying required=true, and the failure text reads "Additions are compatible. Regenerate the golden." Adding a required flag to an existing command makes previously valid invocations fail, which the policy table in RELEASING.md classifies as breaking — only a new flag whose default preserves behavior is additive. A required flag on an entirely new command remains additive, so the check must be scoped to commands already present in the baseline.

3. The baseline omits framework-injected surface. renderSurface walks RootCommand() before urfave/cli v3.11.0 performs setup, so none of the injected surface reaches the golden: the completion command and its bash, zsh, fish, and pwsh subcommands, --help across the command tree, and root --version. root.go sets EnableShellCompletion: true and then uses ConfigureShellCompletionCommand to un-hide the completion command and give it a category, so it is deliberately public surface. Removing any of it would pass the gate silently. Render from a post-setup tree; enumerating specific injected entries would close the gap only partly.

4. The empty-subject assertion cannot detect the regression it guards. The condition requires both that the malformed message is present and that --real-flag is absent, but the preceding call always logs --real-flag, so that branch is unreachable. The newline anchor is a second problem: slog's text handler does not place a newline immediately after the message, because structured attributes intervene. The assertion below still catches a suppressed real warning, so the test is not entirely inert — but the regression it is named for would pass. The production guard is correct.

5. Reconcile the early closure with the central policy. Rejecting a headerless RecipeMetadata on the direct-input path is reachable for externally authored overlays, while the deprecation policy added in the same commit requires two minor releases of notice before a pre-1.0 breaking removal. The decision is documented — ADR-022 records the early closure and its rationale, and the deprecations ledger calls it an early narrowing — so this is not undisclosed behavior. What is missing is reconciliation: RELEASING.md should classify it explicitly as the one-time v0.21 exception the ADR and ledger already imply.

Suggested split: one mechanical follow-up for 1–4, and a separate policy item for 5. Findings 2 and 3 are gaps in a gate added to prevent this exact class of drift, so they are worth closing before the gate accumulates trust it has not earned. Nothing here reaches a released artifact.

Comment thread docs/user/deprecations.md
empty. That tolerance retires alongside the alpha values.

One narrowing landed earlier than the rest: as of v0.21, a `RecipeMetadata`
overlay passed directly (`aicr recipe -r overlay.yaml`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aicr recipe -r overlay.yaml is not a valid invocation — the recipe command has no --recipe/-r flag, and running it returns flag provided but not defined: -r. This is the remediation path for the users this change breaks, so it needs to be right. Use aicr bundle -r overlay.yaml and aicr validate -r overlay.yaml.

`RecipeResult` inputs through v0.22, and v0.23 stops admitting it along with the
alpha values. The tolerance is scoped to `RecipeResult`, which predates the
field: a `RecipeMetadata` overlay is a catalog document however it arrives, so
`aicr recipe -r` and `aicr bundle -r` reject a headerless one exactly as a

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same invalid command: aicr recipe -r does not exist. Should read aicr bundle -r and aicr validate -r.

outside this contract.

This gate follows the document, not the entry point. Passing a single overlay
directly — `aicr recipe -r overlay.yaml`, `aicr bundle -r overlay.yaml` —

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same invalid command. aicr recipe -r overlay.yaml should be aicr validate -r overlay.yaml, alongside the aicr bundle -r already cited here.


**A catalog kind is governed by §8 on every path it can arrive by.** The
tolerance above is scoped by wire kind, not by entry point. A `RecipeMetadata`
reaching AICR as a direct recipe input (`aicr recipe -r overlay.yaml`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same invalid command in the clause defining which paths the gate governs. The direct-input paths are aicr bundle -r and aicr validate -r.

Comment thread pkg/recipe/loader_test.go
// TestRecipeMetadataHeaderGatesAgree pins the #2421 invariant: the same
// RecipeMetadata header is accepted or rejected identically whether the
// document reaches AICR through the catalog scanner (`--data`) or through the
// direct recipe input path (`aicr recipe -r`, `aicr bundle -r`).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment cites aicr recipe -r as a direct recipe input path. That flag does not exist; the paths are aicr bundle -r and aicr validate -r. Worth correcting so the test's stated scope matches the code it guards.

Comment thread pkg/cli/surface_test.go
"intentional and the window has passed, regenerate the golden.\n\n")
}

if len(added) > 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every added line lands here and is reported as additive, including a line with required=true. Adding a required flag to a command that already exists breaks previously valid invocations, which the policy table in RELEASING.md classifies as breaking. Suggest splitting added lines: those with required=true whose command already appears in the baseline belong in the BREAKING section. A required flag on a brand-new command is genuinely additive and should stay where it is.

Comment thread pkg/cli/surface_test.go

func renderSurface() string {
var lines []string
collectSurface(RootCommand(), "", &lines)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This walks the pre-setup command tree, so urfave-injected surface never reaches the baseline. In v3.11.0 that is the completion command plus its bash, zsh, fish, and pwsh subcommands, --help across the command tree, and root --version — none appear in the golden. root.go enables shell completion and explicitly un-hides the completion command through ConfigureShellCompletionCommand, so it is intentional public surface, and Version is set. Disabling any of it would not fail this gate. Prefer rendering from a post-setup tree over asserting a fixed list of injected entries, which would leave the gap only partly closed.

r.Warn(Notice{RemovedIn: "v0.25"})
r.Warn(Notice{Subject: "--real-flag", RemovedIn: "v0.25"})

if strings.Contains(buf.String(), "is deprecated and will be removed in v0.25\n") &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The empty-subject branch is unreachable: the call on the line above always logs --real-flag, so the negated second operand is always false. The newline anchor is a second problem — slog's text handler does not place a newline immediately after the message, because structured attributes intervene. The assertion below still catches a suppressed real warning, so the test is not entirely inert, but the regression it is named for would pass. Assert that the message appears exactly once instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/api area/cli area/docs size/XL theme/ci-dx CI pipelines, developer experience, and build tooling

Projects

None yet

4 participants