Marking old posts as no longer relevant

Your gut feeling that option 2 would be more flexible is correct. Thankfully, Option 3 didn’t occur to you ( using post meta ), but Option 3 has a concerning caveat re: performance, so go with option 2

Then I thought actually, it would be more flexible to create a new category to which old posts could be added and any posts within that category (no matter how they were accessed) would have the same additional content added as above.

So you could literally create a category called “out of date” or “replaced” or something to that effect, that would show the banner in the template. Then you could manually assign it via the post edit screen.

This still leaves the question of linking to a more relevant product/post. This could be done via linking in the content, but you could also store this in post meta/custom fields. You can add a metabox and some save code, or rely on an existing library or plugin ( ACF is popular for this, as is the Field Manager plugin/library ).

Does the second option sound feasible?

More than feasible. Normally, people would add a flag in post meta to do this, but this comes with a major pitfall. As soon as you try to filter or query posts using that post meta value, your performance is crippled. The ideal solution would be to use a taxonomy, such as the category taxonomy, which is what you suggested. Taxonomies are designed to be a fast way to find posts when you know a piece of information. Post meta is designed to be a fast way to find a piece of information when you know the post.

I tried finding ready-made plugins for the first one but couldn’t find any except one that applies by age rather than date.

Note that plugin reccomendations are off topic on this site, but what you’re trying to do isn’t super difficult

Suggestions on Implementation

My first suggestion, would be that if you add a category for posts that are no longer relevant, then it would be great for performance if you add a category for posts that are still relevant. This way a post will always have one or the other, letting you filter on those posts in a query while avoiding the performance killer that is __not_in queries.

My other suggestion, would be to not use categories, and instead use a non-public custom taxonomy. Set it to non-hierarchical so it behaves like tags. This way you can re-use the taxonomy for other tags as a sort of behind the scenes post-it board/tagging system.

As for actually doing this. Lets assume you created a custom taxonomy, e.g.:

// Register Custom Taxonomy
function register_utility_tags() {

    $labels = array(
        'name'                       => _x( 'Utility Tags', 'Taxonomy General Name', 'text_domain' ),
        'singular_name'              => _x( 'Utility Tag', 'Taxonomy Singular Name', 'text_domain' ),
        'menu_name'                  => __( 'Utility Tags', 'text_domain' ),
        'all_items'                  => __( 'All Items', 'text_domain' ),
        'parent_item'                => __( 'Parent Item', 'text_domain' ),
        'parent_item_colon'          => __( 'Parent Item:', 'text_domain' ),
        'new_item_name'              => __( 'New Item Name', 'text_domain' ),
        'add_new_item'               => __( 'Add New Item', 'text_domain' ),
        'edit_item'                  => __( 'Edit Item', 'text_domain' ),
        'update_item'                => __( 'Update Item', 'text_domain' ),
        'view_item'                  => __( 'View Item', 'text_domain' ),
        'separate_items_with_commas' => __( 'Separate items with commas', 'text_domain' ),
        'add_or_remove_items'        => __( 'Add or remove items', 'text_domain' ),
        'choose_from_most_used'      => __( 'Choose from the most used', 'text_domain' ),
        'popular_items'              => __( 'Popular Items', 'text_domain' ),
        'search_items'               => __( 'Search Items', 'text_domain' ),
        'not_found'                  => __( 'Not Found', 'text_domain' ),
        'no_terms'                   => __( 'No items', 'text_domain' ),
        'items_list'                 => __( 'Items list', 'text_domain' ),
        'items_list_navigation'      => __( 'Items list navigation', 'text_domain' ),
    );
    $args = array(
        'labels'                     => $labels,
        'hierarchical'               => false,
        'public'                     => false,
        'show_ui'                    => true,
        'show_admin_column'          => true,
        'show_in_nav_menus'          => false,
        'show_tagcloud'              => false,
        'show_in_rest'               => true,
    );
    register_taxonomy( 'utility_tags', array( 'post' ), $args );

}
add_action( 'init', 'register_utility_tags', 0 );

I made this using the generatewp website. Given the above, and assuming you tagged a post as irrelevant, then we can test this with the following check:

if ( has_term( 'irrelevant', 'utility_tags' ) ) {
    // it's irrelevant
} else {
    // it isn't
}

You could also find irrelevant posts like this:

$q = new WP_Query([
    'utility_tags' => 'irrelevant'
]);