# When a custom id goes stale

Learn why old buttons and select menus show an Outdated card after a deploy, and choose what your user sees when one does.

A deploy can leave the buttons and select menus on your bot's older messages broken. Clicking one shows an **Outdated** card.

A message with a button or a select menu on it stays in the channel until someone deletes it. Your shape changes on the next deploy. Decoded against the new shape, an old button's values would fill the wrong fields, and your handler would use them.

seedcord stops that with a three-character shape hash in every id it mints. An old button's hash doesn't match the new shape, so seedcord refuses its values before your handler reads them.

```ts title="src/components/TicketActions.ts"
import { CustomId } from '@seedcord/gateway';

export const TicketAction = new CustomId('ticket')
    .snowflake('ownerId')
    .oneOf('action', ['close', 'reopen']);
```

`TicketAction.routeKey` reads `ticketVrl`, the prefix `ticket` followed by the hash `Vrl`. Every id [`encode`](https://seedcord.org/docs/packages/custom-id/latest/classes/custom-id#encode) mints starts with it.

## What changes the hash

The hash covers each field's name, its position in the chain, its kind, whether it's nullable, the choices on a `oneOf` or a `someOf`, and an `int`'s bounds. Change any one of those and every id already in Discord belongs to the old shape.

{/* prettier-ignore-start */}

| the edit                        | new routeKey |
| ------------------------------- | ------------ |
| the shape above                 | `ticketVrl`  |
| rename `ownerId` to `userId`    | `ticketbdx`  |
| add `'escalate'` to the choices | `ticketY1t`  |
| swap the two fields around      | `ticketpXF`  |
| append `.bool('notify')`        | `ticketuFh`  |

{/* prettier-ignore-end */}

Bounds count the same way. `int('n', 0, 100)` and `int('n', 0, 200)` produce different hashes, so widening a page range invalidates the buttons you already sent.

Weigh an edit against how long its messages stay in use. A reply someone clicks once and scrolls past can change shape on any deploy. A role menu pinned in a rules channel stops working after the same edit, so plan to send that message again when you ship it.

## What your user sees

Routing reads the prefix *alone*, so an interaction from an old message still reaches your handler. Reading [`this.params`](https://seedcord.org/docs/packages/gateway/latest/classes/component-handler#params) decodes the wire, compares its route key against the current one, and refuses when they differ.

That refusal is a [`Notice`](https://seedcord.org/docs/packages/core/latest/classes/notice), so the boundary catches it and replies with a card.

```txt output
### Outdated
This button or menu is from an older version. Please run the command again.
```

The card is ephemeral, and nothing reaches your logs or the `handledException` bus. seedcord doesn't report a stale click, because it's the normal result of editing a shape.

A modal and a select menu route by prefix the same way, so both go stale on the same rule.

## A wire from a different id

[`decode`](https://seedcord.org/docs/packages/custom-id/latest/classes/custom-id#decode) separates two failures. A matching prefix with a different hash is the stale case above. A wire minted by some other `CustomId` refuses with a message naming both route keys.

```txt output
Invalid customId. routeKey "pollM4x" is not "ticketVrl"
```

That one does report, since a working bot never produces it. seedcord logs it and publishes it to the `handledException` bus, where the report carries the name `InvalidCustomId`. The user sees the generic card.

```txt output
### Cannot Proceed
Something went wrong. Please try again.
```

`decode` also throws on a corrupt string, or on one you wrote by hand.

## Replacing both cards

Both cards above use seedcord's wording. Replace them when your bot needs its own, like a refusal that points to the command that sends a fresh menu.

The defaults are `Notice` subclasses seedcord registers when it loads. [`setCustomIdErrors`](https://seedcord.org/docs/packages/custom-id/latest/functions/set-custom-id-errors) replaces them with yours.

```ts title="src/bot.ts"
class OutdatedCard extends BuilderComponent<'container'> {
    public constructor(message: string) {
        super('container');
        this.instance.addTextDisplayComponents(
            new TextDisplayBuilder().setContent(message)
        );
    }
}

class Outdated extends Notice {
    public constructor(message: string) {
        super(message);
    }

    public override render(): ReplyResponse {
        return {
            components: [
                new OutdatedCard(
                    '### Try again\nThat button is from an older deploy.'
                ).component
            ]
        };
    }
}

setCustomIdErrors({
    stale: (prefix) => new Outdated(`stale customId for ${prefix}`),
    invalid: (detail) => new Outdated(detail)
});
```

Call it once, before your bot starts. A click that arrives before the call still gets the built-in card. One registration covers every `CustomId` in the process, and a later call replaces the earlier one.

The `stale` callback receives the route prefix. The `invalid` callback receives a detail string saying what failed. Neither callback receives the interaction. The `Notice` you return still gets a [`RenderContext`](https://seedcord.org/docs/packages/types/latest/interfaces/render-context) when seedcord calls its `render()`, carrying the fault's `uuid` and the [dispatch context](https://next.seedcord.org/guide/checks/dispatch-context/) your gates and middleware filled, so read per-user details there. Return a `Notice` from both callbacks. A plain `Error` sends the generic fault card.
