diff --git a/src/CoreTests/Testing/RecoveryHintAttributeTests.cs b/src/CoreTests/Testing/RecoveryHintAttributeTests.cs
new file mode 100644
index 0000000..615eb17
--- /dev/null
+++ b/src/CoreTests/Testing/RecoveryHintAttributeTests.cs
@@ -0,0 +1,127 @@
+using JasperFx.Testing;
+using Shouldly;
+using Xunit;
+
+namespace CoreTests.Testing;
+
+///
+/// These attributes carry no behaviour, so what is worth testing is the contract a runner reads
+/// them through: the disposition each one declares, that resources parse, and that they can be
+/// discovered from every scope they claim to support.
+///
+public class RecoveryHintAttributeTests
+{
+ [Fact]
+ public void each_hint_declares_the_disposition_its_name_promises()
+ {
+ new ClearsOnRetryAttribute(typeof(TimeoutException)).Kind
+ .ShouldBe(DispositionKind.RetryInProcess);
+
+ new ClearsInFreshProcessAttribute(typeof(BadImageFormatException)).Kind
+ .ShouldBe(DispositionKind.RetryInFreshProcess);
+
+ new ClearsOnRecycleAttribute("rabbit", typeof(TimeoutException)).Kind
+ .ShouldBe(DispositionKind.RetryAfterRecycle);
+
+ // The counterweight: a hint that spends no attempts at all.
+ new NeverRecoversAttribute(typeof(InvalidOperationException)).Kind
+ .ShouldBe(DispositionKind.FailAndContinue);
+ }
+
+ [Fact]
+ public void the_failure_type_and_reason_are_carried_verbatim()
+ {
+ var hint = new ClearsOnRetryAttribute(typeof(TimeoutException))
+ {
+ Because = "the broker is slow to warm up"
+ };
+
+ hint.FailureType.ShouldBe(typeof(TimeoutException));
+
+ // Reaches a run report unchanged, so it must not be normalised or truncated here.
+ hint.Because.ShouldBe("the broker is slow to warm up");
+ }
+
+ [Fact]
+ public void a_hint_with_no_reason_given_simply_has_none()
+ {
+ new ClearsOnRetryAttribute(typeof(TimeoutException)).Because.ShouldBeNull();
+ }
+
+ [Theory]
+ [InlineData("rabbit", new[] { "rabbit" })]
+ [InlineData("rabbit,kafka", new[] { "rabbit", "kafka" })]
+ [InlineData(" rabbit , kafka ", new[] { "rabbit", "kafka" })]
+ [InlineData("rabbit,,kafka", new[] { "rabbit", "kafka" })]
+ public void recycle_resources_are_split_trimmed_and_compacted(string declared, string[] expected)
+ {
+ // Comma-separated to match the recycle(rabbit,kafka) tag vocabulary. Whitespace and empty
+ // entries are the author's typos, not resource names — a runner asked to recycle "" would
+ // report a wiring mistake for something nobody meant to write.
+ new ClearsOnRecycleAttribute(declared, typeof(TimeoutException)).Resources.ShouldBe(expected);
+ }
+
+ [Fact]
+ public void an_empty_recycle_list_is_empty_rather_than_a_single_blank_resource()
+ {
+ new ClearsOnRecycleAttribute("", typeof(TimeoutException)).Resources.ShouldBeEmpty();
+ new ClearsOnRecycleAttribute(" ", typeof(TimeoutException)).Resources.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void hints_other_than_recycle_name_no_resources()
+ {
+ new ClearsOnRetryAttribute(typeof(TimeoutException)).Resources.ShouldBeEmpty();
+ new NeverRecoversAttribute(typeof(TimeoutException)).Resources.ShouldBeEmpty();
+ }
+
+ // ---------------------------------------------------------------- discovery
+
+ [ClearsOnRetry(typeof(TimeoutException), Because = "warm-up")]
+ [NeverRecovers(typeof(InvalidOperationException))]
+ private class Annotated
+ {
+ [ClearsInFreshProcess(typeof(BadImageFormatException))]
+ public void Method() { }
+ }
+
+ [Fact]
+ public void several_hints_can_be_declared_on_one_target()
+ {
+ // AllowMultiple: a class routinely has more than one failure worth describing, and the
+ // whole point is that each is described separately rather than lumped into "flaky".
+ var hints = typeof(Annotated)
+ .GetCustomAttributes(typeof(RecoveryHintAttribute), inherit: true)
+ .Cast()
+ .ToList();
+
+ hints.Count.ShouldBe(2);
+ hints.Select(h => h.FailureType)
+ .ShouldBe([typeof(TimeoutException), typeof(InvalidOperationException)], ignoreOrder: true);
+ }
+
+ [Fact]
+ public void a_hint_is_discoverable_on_a_method_as_well_as_a_class()
+ {
+ // Scope is how a narrow declaration overrides a broad one, so a runner has to be able to
+ // find them at every level the attribute claims to support.
+ var hint = typeof(Annotated)
+ .GetMethod(nameof(Annotated.Method))!
+ .GetCustomAttributes(typeof(RecoveryHintAttribute), inherit: true)
+ .Cast()
+ .ShouldHaveSingleItem();
+
+ hint.Kind.ShouldBe(DispositionKind.RetryInFreshProcess);
+ }
+
+ [Fact]
+ public void the_attribute_targets_cover_assembly_class_and_method()
+ {
+ var usage = (AttributeUsageAttribute)typeof(RecoveryHintAttribute)
+ .GetCustomAttributes(typeof(AttributeUsageAttribute), inherit: false)
+ .Single();
+
+ usage.ValidOn.ShouldBe(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Assembly);
+ usage.AllowMultiple.ShouldBeTrue();
+ }
+}
diff --git a/src/JasperFx/Testing/DispositionKind.cs b/src/JasperFx/Testing/DispositionKind.cs
new file mode 100644
index 0000000..05e9f76
--- /dev/null
+++ b/src/JasperFx/Testing/DispositionKind.cs
@@ -0,0 +1,44 @@
+namespace JasperFx.Testing;
+
+///
+/// What a test runner should do about an attempt. The vocabulary a
+/// declares against.
+///
+///
+///
+/// This lives in JasperFx rather than in a particular test runner so that a test project can
+/// declare what it knows about its own failures without taking a dependency on the runner. Any
+/// suite already referencing JasperFx — directly, or through Marten, Wolverine or Polecat — can
+/// annotate itself and have a runner that understands these attributes act on them.
+///
+///
+/// One enum rather than a runner-side copy mapped at a seam: two enums meaning the same thing is
+/// how a vocabulary starts drifting.
+///
+///
+public enum DispositionKind
+{
+ /// The attempt succeeded.
+ Pass,
+
+ /// An ordinary failure: record it and keep going.
+ FailAndContinue,
+
+ /// Try again in the same process, after resources are reset.
+ RetryInProcess,
+
+ ///
+ /// Try again in a brand-new process, with this test running alone. For the tests that only
+ /// pass when nothing else shares their process.
+ ///
+ RetryInFreshProcess,
+
+ ///
+ /// Throw the named resources away, stand fresh ones up, then try again. For brokers whose
+ /// in-flight state cannot be reliably drained the way a database is truncated.
+ ///
+ RetryAfterRecycle,
+
+ /// Stop the run now — nothing downstream can pass.
+ AbortRun
+}
diff --git a/src/JasperFx/Testing/RecoveryHintAttributes.cs b/src/JasperFx/Testing/RecoveryHintAttributes.cs
new file mode 100644
index 0000000..d6c2445
--- /dev/null
+++ b/src/JasperFx/Testing/RecoveryHintAttributes.cs
@@ -0,0 +1,103 @@
+namespace JasperFx.Testing;
+
+///
+/// Declares that a named class of failure, on the tests in scope, recovers a particular way.
+///
+///
+///
+/// A tag or trait says this test is unreliable. A hint says which failure is
+/// unreliable and what fixes it — so an assertion failure on the same test is still reported as
+/// the bug it is, rather than being retried away with everything else.
+///
+///
+/// These attributes are pure declarations. They carry no behaviour and start nothing: a test
+/// runner that understands them reads them and decides, and a runner that does not is unaffected.
+/// That is why they live here rather than in a runner — a suite already referencing JasperFx,
+/// directly or through Marten, Wolverine or Polecat, can write down what it knows about its own
+/// flakiness without taking a dependency on whatever ends up running it.
+///
+///
+/// A hint is not permission to retry. How much time a run may spend is the
+/// operator's decision, expressed by whatever budget the runner exposes; what recovers is the
+/// author's knowledge, expressed here. A runner honouring these must not let a hint widen its own
+/// ceiling, or a test author could escape a limit set by whoever runs the suite.
+///
+///
+/// Applicable to a class (every test it owns), a method, or a whole assembly. A runner should
+/// treat the narrowest declaration as the winner, so an assembly-wide default can be overridden
+/// per class without either knowing about the other.
+///
+///
+[AttributeUsage(
+ AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Assembly,
+ AllowMultiple = true)]
+public abstract class RecoveryHintAttribute : Attribute
+{
+ protected RecoveryHintAttribute(Type failureType) => FailureType = failureType;
+
+ /// The exception type this hint describes. Base types match derived failures.
+ public Type FailureType { get; }
+
+ ///
+ /// Why the author believes this. Intended to reach the run report verbatim, so it should read
+ /// as an explanation to whoever is looking at the retry six months from now.
+ ///
+ public string? Because { get; set; }
+
+ /// What to do about it.
+ public abstract DispositionKind Kind { get; }
+
+ ///
+ /// Resources to recycle. Only meaningful for .
+ ///
+ public virtual IReadOnlyList Resources => [];
+}
+
+/// This failure clears by running the test again in the same process.
+/// [ClearsOnRetry(typeof(TimeoutException), Because = "the broker is slow to warm up")]
+public sealed class ClearsOnRetryAttribute(Type failureType) : RecoveryHintAttribute(failureType)
+{
+ public override DispositionKind Kind => DispositionKind.RetryInProcess;
+}
+
+///
+/// This failure clears only in a brand-new process, with the test running alone — the shape of
+/// leak that a scope reset cannot undo, like a static cached the first time anything touched it.
+///
+public sealed class ClearsInFreshProcessAttribute(Type failureType) : RecoveryHintAttribute(failureType)
+{
+ public override DispositionKind Kind => DispositionKind.RetryInFreshProcess;
+}
+
+///
+/// This failure clears only after the named resources are thrown away and stood up fresh.
+///
+/// [ClearsOnRecycle("rabbit", typeof(BrokerUnavailableException))]
+///
+/// is comma-separated, matching the recycle(rabbit,kafka) tag
+/// vocabulary it is meant to share.
+///
+public sealed class ClearsOnRecycleAttribute(string resources, Type failureType)
+ : RecoveryHintAttribute(failureType)
+{
+ public override DispositionKind Kind => DispositionKind.RetryAfterRecycle;
+
+ public override IReadOnlyList Resources { get; } =
+ string.IsNullOrWhiteSpace(resources)
+ ? []
+ : resources.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+}
+
+///
+/// This failure never clears, so do not spend attempts on it.
+///
+///
+/// The counterweight to the rest of the file, and the reason the set is usable at all. Without it,
+/// the only way to stop a broad "retry everything three times" policy from re-running a
+/// deterministic bug is to take the retry off the test — which also stops the retries that were
+/// pulling their weight.
+///
+public sealed class NeverRecoversAttribute(Type failureType) : RecoveryHintAttribute(failureType)
+{
+ public override DispositionKind Kind => DispositionKind.FailAndContinue;
+}