# When a custom id goes stale

Someone clicks a component your bot minted under an older custom id shape. Covers the edits that change the shape hash, what your user sees, a wire from a different id, and replacing both cards with your own.

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://docs.seedcord.org/packages/custom-id/latest/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://docs.seedcord.org/packages/gateway/latest/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://docs.seedcord.org/packages/core/latest/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://docs.seedcord.org/packages/custom-id/latest/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 card naming the command that sends a fresh menu.

The defaults are `Notice` subclasses seedcord registers when it loads. [`setCustomIdErrors`](https://docs.seedcord.org/packages/custom-id/latest/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 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 arm receives the route prefix. The invalid arm receives a detail string naming what failed. Neither arm receives the interaction. The `Notice` you return still gets a [`RenderContext`](https://docs.seedcord.org/packages/types/latest/render-context) when seedcord calls its `render()`, carrying the fault's `uuid` and the [dispatch context](/checks/dispatch-context) your gates and middleware filled, so read per-user details there. Return a `Notice` from both arms. A plain `Error` sends the generic fault card.
