Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions adr/0023-lifecycle-metadata-extension.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# ADR-0023: Lifecycle Metadata as an Official Extension

## Status
Accepted

## Date
2026-08-06

## Context
[Issue #65](https://github.com/Agent-Card/ai-catalog/issues/65)
observes that the specification has no way to communicate an artifact's
software-lifecycle state at discovery time. An entry carries `version`
(which revision this is) and `updatedAt` (when the entry last changed),
but nothing that expresses *where that revision sits in its lifecycle*:
whether it is a preview, generally available, deprecated with a scheduled
end-of-life, or already retired — nor how a consumer should move off it.

Agents evolve. A v1 agent is superseded by a breaking v2; an endpoint is
scheduled for shutdown; a successor is published with a migration guide.
Without lifecycle metadata at discovery time a consumer cannot distinguish
a current artifact from a deprecated one, cannot learn an end-of-life date
before committing to an integration, and cannot discover the recommended
replacement. The core entry schema, which is intentionally closed (see
[ADR-0012](0012-extensibility-via-metadata.md)), has no place to express
this, and there is no interoperable convention for it today.

## Decision
Lifecycle metadata is defined as an **official extension**, registered at
the extension key `https://ai-catalog.org/extensions/lifecycle`, and
carried in the entry's `extensions` map. It is **not** added as a native
`lifecycle` field on the entry.

The extension value is a JSON object with these OPTIONAL members:

- `status` (string) — the lifecycle state; open text, with RECOMMENDED
values `preview`, `active`, `deprecated`, and `retired`. Every
recommended value denotes a version a consumer can actually reach: a
catalog entry exists because the artifact is discoverable, so a
not-yet-released ("planned") state is deliberately excluded — an
unreleased version has no consumable endpoint to list.
- `releaseDate` (string) — ISO 8601 date/date-time the version was
released, distinct from the entry's `updatedAt`.
- `deprecated` (object) — deprecation detail, present when `status` is
`deprecated` or `retired`. It carries:
- `replacedBy` (string) — the `identifier` of the successor artifact,
subject to the same naming rule as a Catalog Entry's `identifier`
(`urn:air` HIGHLY RECOMMENDED, and MUST be used for open or federated
systems).
- `deprecationDate` (string) — ISO 8601 date/date-time of deprecation.
- `endOfLifeDate` (string) — ISO 8601 date/date-time after which the
version is retired.
- `breakingChanges` (array of strings) — human-readable breaking-change
descriptions.
- `migrationGuide` (string) — URL to migration documentation.

Lifecycle metadata describes the artifact **version** identified by the
entry, not the logical artifact as a whole: within a multi-version listing
(see the specification's Multi-Version Entries), a v1 entry MAY be
`deprecated` while the v2 entry is `active`. A consumer that does not
understand the key MUST ignore it; a consumer that does understand it MAY
filter or rank entries by lifecycle state (e.g. exclude `retired`, warn on
`deprecated`, prefer the `replacedBy` successor) and MAY still choose a
deprecated artifact deliberately. All identifiers use the `urn:air` format
mandated by [ADR-0015](0015-agent-identifier-naming.md).

`status` is intentionally open text rather than a closed enum, consistent
with how the specification treats `type` and `identifier`. Because it is
open text, a consumer MUST NOT reject an entry for an unrecognized status,
but it also MUST NOT silently discard the value and treat the artifact as
unqualified — that fails open, since a value such as `sunset` may signal a
retirement the consumer would then miss. A consumer SHOULD interpret an
unrecognized value against the recommended states (an agent consumer can
do so semantically) and, when it cannot, treat the artifact conservatively
rather than assume it is `active`. This preserves the spec's open-text
extensibility while making unrecognized values fail safe rather than fail
open.

## Rationale
- Keeps the core entry schema closed and stable, consistent with the
closed-core philosophy of
[ADR-0012](0012-extensibility-via-metadata.md); a not-yet-universal
need does not force a change to the core.
- Consumers that do not recognize the key ignore it safely and treat the
entry as any other, guaranteed by the existing `extensions` rule.
- The extension can evolve independently without requiring a major version
bump of the specification.
- Aligns with the precedent already set by the Metadata official extension
(`https://ai-catalog.org/extensions/metadata`).

## Alternatives Considered
- **Native `lifecycle` field on the entry** (as the original issue
proposed) — Rejected. It bloats the closed core for a need that is not
universal and reopens the schema evolution problem ADR-0012 was written
to avoid.
- **Overloading `version`/`updatedAt` with a status convention** —
Rejected. Those fields have defined, distinct semantics; encoding
lifecycle state into them would be ambiguous and non-interoperable.
- **Freeform vendor metadata** — Rejected. Ad-hoc, per-vendor keys give no
interoperability, so no consumer could filter deprecated artifacts or
discover successors deterministically across producers.

## Consequences
- Deprecation and retirement become discoverable and actionable before a
consumer commits to an integration; migration paths are advertised at
discovery time.
- The metadata is advisory. It is unsigned unless carried in a Trust
Manifest's `extensions`, and MUST NOT be treated as a security or
support guarantee on its own; the dates and successor reference are
producer assertions a consumer needing assurance corroborates out of
band.
- The official extensions registry gains one entry:
`https://ai-catalog.org/extensions/lifecycle`.
183 changes: 183 additions & 0 deletions docs/examples/lifecycle-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
# Example: Lifecycle Metadata

Artifacts evolve. A v1 agent is superseded by a breaking v2, an endpoint is
scheduled for shutdown, and a successor is published with a migration guide.
An entry's `version` and `updatedAt` tell you *which* revision this is and
*when the entry changed* — but not whether that revision is a preview,
generally available, deprecated with a scheduled end-of-life, or already
retired. The [Lifecycle official extension](../specification.md) enriches an
entry with that software-lifecycle state, so a consumer can filter deprecated
or retired artifacts at discovery, plan around a known end-of-life date, and
follow a producer-supplied migration path to a successor.

## The extension

The extension key (namespace) is:

```
https://ai-catalog.org/extensions/lifecycle
```

It lives inside an entry's `extensions` map. Its value describes the lifecycle
state of the **artifact version** the entry identifies — so within a
multi-version listing, a v1 entry can be `deprecated` while the v2 entry is
`active`.

!!! note "Lifecycle is per-version"
Lifecycle metadata describes the version identified by the entry, not the
logical artifact as a whole. When a catalog lists several versions of the
same `identifier`, each versioned entry may carry its own Lifecycle
extension.

## Full example

A deprecated v1 agent, superseded by a v2, with a scheduled end-of-life:

```json
{
"identifier": "urn:air:acme.com:finance:invoice-processor",
"version": "1.0.0",
"type": "application/a2a-agent-card+json",
"url": "https://api.acme.com/agents/invoice/v1",
"tags": ["finance", "a2a"],
"extensions": {
"https://ai-catalog.org/extensions/lifecycle": {
"status": "deprecated",
"releaseDate": "2025-03-15",
"deprecated": {
"replacedBy": "urn:air:acme.com:finance:invoice-processor-v2",
"deprecationDate": "2026-09-01",
"endOfLifeDate": "2027-01-01",
"breakingChanges": [
"Authentication changed from API key to OAuth2",
"Response schema now uses ISO 8601 dates"
],
"migrationGuide": "https://docs.acme.com/invoice-v2-migration"
}
}
}
}
```

The successor entry carries its own lifecycle status:

```json
{
"identifier": "urn:air:acme.com:finance:invoice-processor-v2",
"version": "2.0.0",
"type": "application/a2a-agent-card+json",
"url": "https://api.acme.com/agents/invoice/v2",
"tags": ["finance", "a2a"],
"extensions": {
"https://ai-catalog.org/extensions/lifecycle": {
"status": "active",
"releaseDate": "2026-06-01"
}
}
}
```

## Extension fields

The extension value supports these fields:

| Field | Type | Required | Description |
|---|---|---|---|
| `status` | string | Optional | Lifecycle state. Recommended values: `preview`, `active`, `deprecated`, `retired` — each denotes a version a consumer can actually reach (a not-yet-released "planned" state is intentionally excluded) |
| `releaseDate` | string | Optional | ISO 8601 date/date-time this version was released (distinct from the entry's `updatedAt`) |
| `deprecated` | object | Optional | Deprecation detail (below); present when `status` is `deprecated` or `retired` |

The `deprecated` object supports:

| Field | Type | Required | Description |
|---|---|---|---|
| `replacedBy` | string | Optional | `identifier` of the successor artifact, following the same naming rule as a Catalog Entry `identifier` (`urn:air` highly recommended; required for open or federated systems) |
| `deprecationDate` | string | Optional | ISO 8601 date/date-time the version became (or becomes) deprecated |
| `endOfLifeDate` | string | Optional | ISO 8601 date/date-time after which the version is retired |
| `breakingChanges` | string[] | Optional | Human-readable descriptions of breaking changes to account for when migrating |
| `migrationGuide` | string | Optional | URL to migration documentation |

## Filtering and migrating at discovery

A consumer that understands the extension can read `status` to decide how to
treat an entry — hiding or de-ranking `retired` and `deprecated` artifacts,
warning an operator, and following `replacedBy` to the successor.

Because `status` is open text, its recommended values are not exhaustive. A
consumer may not reject an entry just because the status is unrecognized. A
value like `sunset` signals a retirement that failing open would miss. An
agent can do this semantically, and when it can't interpret confidently it
should apply its own policy on treating the artifact conservatively or assuming `active`:

```python
from datetime import date

LIFECYCLE_KEY = "https://ai-catalog.org/extensions/lifecycle"
RECOMMENDED_STATUSES = {"preview", "active", "deprecated", "retired"}

def lifecycle(entry):
return entry.get("extensions", {}).get(LIFECYCLE_KEY)

def normalize_status(raw):
"""Map an open-text status onto a recommended state, or None if unclear.

A rule table handles common synonyms; an agent consumer can replace this
with semantic interpretation (e.g. an LLM classifying the raw value).
"""
if raw is None or raw in RECOMMENDED_STATUSES:
return raw
synonyms = {
"sunset": "deprecated", "end-of-support": "deprecated",
"obsolete": "deprecated", "eol": "retired", "end-of-life": "retired",
"ga": "active", "generally-available": "active", "stable": "active",
"beta": "preview", "alpha": "preview", "rc": "preview",
}
return synonyms.get(str(raw).strip().lower())

def is_selectable(entry, allow_deprecated=False):
"""Whether an entry should be offered at discovery time."""
lc = lifecycle(entry)
if not lc:
return True # no lifecycle info — treat as any other entry

status = normalize_status(lc.get("status"))
if status == "retired":
return False
if status == "deprecated" and not allow_deprecated:
return False
if lc.get("status") and status is None:
return allow_deprecated # uninterpretable: fail safe, don't assume active
return True

def successor_id(entry):
"""The identifier a deprecated entry recommends migrating to, if any."""
lc = lifecycle(entry) or {}
return (lc.get("deprecated") or {}).get("replacedBy")
```

A consumer may still choose a deprecated artifact deliberately — for a
short-lived task that finishes well before the `endOfLifeDate`, the deprecated
version may be the pragmatic choice:

```python
def usable_until_eol(entry, deadline):
"""A deprecated entry is fine for work that completes before its EOL."""
lc = lifecycle(entry) or {}
eol = (lc.get("deprecated") or {}).get("endOfLifeDate")
if not eol:
return True
return deadline < date.fromisoformat(eol[:10])
```

!!! warning "Lifecycle metadata is not a support guarantee"
The dates and the `replacedBy` reference are producer assertions, not
signed claims. A consumer that needs a supported-until guarantee
corroborates them out of band, not via this extension. A consumer that
does not understand the extension key ignores it and treats the entry as
any other.

## Related

- [Full Specification](../specification.md)
- [Creating a Catalog](../guides/creating-a-catalog.md#the-lifecycle-extension)
- [Consuming Catalogs](../guides/consuming-catalogs.md#filtering-by-lifecycle-status)
69 changes: 69 additions & 0 deletions docs/guides/consuming-catalogs.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,75 @@ def latest_entries(entries):
return list(by_id.values())
```

## Filtering by lifecycle status

An entry may carry the [Lifecycle extension](../examples/lifecycle-metadata.md)
in its `extensions` map, declaring whether the artifact version is a
pre-release (`preview`), generally available (`active`), `deprecated`, or
`retired`. A consumer that understands the extension can filter deprecated or
retired artifacts at discovery and follow the recommended successor:

```python
LIFECYCLE_KEY = "https://ai-catalog.org/extensions/lifecycle"

RECOMMENDED_STATUSES = {"preview", "active", "deprecated", "retired"}

def normalize_status(raw):
"""Map a status value to a recommended state.

`status` is open text, so a producer may emit a value outside the
recommended set. Interpret it rather than discard it — an agent can do
this semantically (e.g. an LLM mapping "sunset" -> "deprecated"). Return
None only when the value can't be interpreted with confidence.
"""
if raw is None or raw in RECOMMENDED_STATUSES:
return raw
synonyms = {
"sunset": "deprecated", "end-of-support": "deprecated",
"obsolete": "deprecated", "eol": "retired", "end-of-life": "retired",
"ga": "active", "generally-available": "active", "stable": "active",
"beta": "preview", "alpha": "preview", "rc": "preview",
}
return synonyms.get(str(raw).strip().lower()) # None if uninterpretable

def is_selectable(entry, allow_deprecated=False):
lc = entry.get("extensions", {}).get(LIFECYCLE_KEY)
if not lc:
return True # no lifecycle info — treat as any other entry

status = normalize_status(lc.get("status"))
if status == "retired":
return False
if status == "deprecated" and not allow_deprecated:
return False
if lc.get("status") and status is None:
# Declared but uninterpretable: fail safe, don't assume "active".
# Surface the raw value and keep it out of long-lived commitments.
return allow_deprecated
return True

def successor_id(entry):
"""The identifier a deprecated entry recommends migrating to, if any."""
lc = entry.get("extensions", {}).get(LIFECYCLE_KEY) or {}
return (lc.get("deprecated") or {}).get("replacedBy")
```

- If you **don't** understand the extension key, ignore it and treat the entry
as any other.
- `status` is open text, so its recommended values are not exhaustive. Never
reject an entry *just* because its status is unrecognized — but don't
silently discard the value either, or you fail open: a value like `sunset`
may signal a retirement you'd then miss.
- Instead, **interpret** an unrecognized status against the recommended states.
An agent consumer can do this semantically rather than with a fixed synonym
table. When you can't interpret it confidently, surface the raw value and
treat the artifact conservatively rather than assuming it is `active`.
- You **may** still select a deprecated artifact deliberately — for a
short-lived task that completes before its `endOfLifeDate`, for example.
- The dates and `replacedBy` reference are producer assertions, not signed
claims. Corroborate them out of band when you need a supported-until
guarantee.

## TypeScript example

```typescript
Expand Down
Loading