diff --git a/src/BabelQueue.Core/Idempotency.cs b/src/BabelQueue.Core/Idempotency.cs
new file mode 100644
index 0000000..3708a74
--- /dev/null
+++ b/src/BabelQueue.Core/Idempotency.cs
@@ -0,0 +1,99 @@
+namespace BabelQueue;
+
+///
+/// A consume handler: processes one decoded . May be async; a
+/// thrown/faulted handler leaves the message unacknowledged so the runtime redelivers it
+/// (the core is codec-only, so an adapter drives the actual consume loop).
+///
+public delegate Task Handler(Envelope envelope);
+
+///
+/// A pluggable record of message ids already processed, keyed on the envelope's
+/// meta.id. The reference is for tests / single-process
+/// consumers; production backends (Redis, a database table) implement the same three
+/// methods. "Seen-set" post-success dedupe — not exactly-once, not in-flight locking; a
+/// transactional / outbox mode is a documented future direction (ADR-0022).
+///
+public interface IIdempotencyStore
+{
+ /// Whether this message id has already been processed (remembered).
+ bool Seen(string messageId);
+
+ /// Records this message id as processed.
+ void Remember(string messageId);
+
+ /// Drops an id from the store (manual eviction; a backend may also expire ids).
+ void Forget(string messageId);
+}
+
+///
+/// Process-local, thread-safe backed by a set. For tests
+/// and single-process consumers; not shared across workers and not persistent — use a
+/// Redis- or database-backed store for production fleets.
+///
+public sealed class InMemoryStore : IIdempotencyStore
+{
+ private readonly HashSet _ids = new();
+ private readonly object _gate = new();
+
+ ///
+ public bool Seen(string messageId)
+ {
+ lock (_gate)
+ {
+ return _ids.Contains(messageId);
+ }
+ }
+
+ ///
+ public void Remember(string messageId)
+ {
+ lock (_gate)
+ {
+ _ids.Add(messageId);
+ }
+ }
+
+ ///
+ public void Forget(string messageId)
+ {
+ lock (_gate)
+ {
+ _ids.Remove(messageId);
+ }
+ }
+}
+
+///
+/// Wraps a so a message whose meta.id was already processed
+/// successfully is skipped (ADR-0022) — the .NET mirror of the PHP, Go, Python, and Node
+/// helpers. A previously-seen id returns early (so an adapter acks it); a thrown/faulted
+/// handler leaves the id unmarked so a redelivery runs it again; a message with no usable
+/// meta.id runs unchanged.
+///
+public static class Idempotency
+{
+ /// Returns guarded by dedupe on meta.id.
+ public static Handler Wrap(IIdempotencyStore store, Handler handler) =>
+ async envelope =>
+ {
+ string? id = envelope.Meta?.Id;
+
+ // No usable id → cannot dedupe; run the handler unchanged.
+ if (string.IsNullOrEmpty(id))
+ {
+ await handler(envelope).ConfigureAwait(false);
+ return;
+ }
+
+ // Already processed on an earlier delivery: return so the adapter acks it.
+ if (store.Seen(id))
+ {
+ return;
+ }
+
+ // First success wins; a throw here leaves the id unmarked → retry/DLQ apply.
+ await handler(envelope).ConfigureAwait(false);
+ store.Remember(id);
+ };
+}
diff --git a/src/BabelQueue.Core/Schema/ISchemaProvider.cs b/src/BabelQueue.Core/Schema/ISchemaProvider.cs
new file mode 100644
index 0000000..2c0db43
--- /dev/null
+++ b/src/BabelQueue.Core/Schema/ISchemaProvider.cs
@@ -0,0 +1,16 @@
+using System.Collections.Generic;
+
+namespace BabelQueue.Schema;
+
+///
+/// A source of per-URN data schemas, keyed on the message URN (ADR-0024). Returns the
+/// decoded JSON Schema for a URN's data block, or null when none is registered —
+/// in which case the caller skips validation (the feature is opt-in). The reference
+/// is in-memory; the I/O-free core ships no file-based provider, so a
+/// .NET app reads its babelqueue-registry registry.json and passes the schemas in.
+///
+public interface ISchemaProvider
+{
+ /// The decoded JSON Schema registered for , or null.
+ IReadOnlyDictionary? SchemaFor(string urn);
+}
diff --git a/src/BabelQueue.Core/Schema/InvalidPayloadException.cs b/src/BabelQueue.Core/Schema/InvalidPayloadException.cs
new file mode 100644
index 0000000..10a7f00
--- /dev/null
+++ b/src/BabelQueue.Core/Schema/InvalidPayloadException.cs
@@ -0,0 +1,26 @@
+using BabelQueue;
+
+namespace BabelQueue.Schema;
+
+///
+/// Raised when a message's data does not match the JSON Schema registered for its URN
+/// (ADR-0024). The consumer-side throws it so the adapter
+/// redelivers (and eventually dead-letters) a poison message; the recommended primary use is
+/// producer-side () so invalid data never enters the queue.
+///
+public sealed class InvalidPayloadException : BabelQueueException
+{
+ /// Create the exception for a URN whose data violated its schema.
+ public InvalidPayloadException(string urn, string violation)
+ : base($"Message data for \"{urn}\" does not match its URN schema: {violation}.")
+ {
+ Urn = urn;
+ Violation = violation;
+ }
+
+ /// The message URN whose schema was violated.
+ public string Urn { get; }
+
+ /// The first "<json-pointer>: <reason>" mismatch.
+ public string Violation { get; }
+}
diff --git a/src/BabelQueue.Core/Schema/MapProvider.cs b/src/BabelQueue.Core/Schema/MapProvider.cs
new file mode 100644
index 0000000..962f559
--- /dev/null
+++ b/src/BabelQueue.Core/Schema/MapProvider.cs
@@ -0,0 +1,31 @@
+using System.Collections.Generic;
+
+namespace BabelQueue.Schema;
+
+/// In-memory , for tests and for embedding schemas in code.
+public sealed class MapProvider : ISchemaProvider
+{
+ private readonly Dictionary> _schemas;
+
+ /// Build a provider from URN to already-decoded JSON Schema maps.
+ public MapProvider(IReadOnlyDictionary> schemas)
+ {
+ _schemas = new Dictionary>(schemas);
+ }
+
+ /// Build a provider from URN to raw JSON Schema strings, parsing each.
+ public static MapProvider FromJson(IReadOnlyDictionary raw)
+ {
+ var schemas = new Dictionary>();
+ foreach (var entry in raw)
+ {
+ schemas[entry.Key] = SchemaJson.ParseObject(entry.Value);
+ }
+
+ return new MapProvider(schemas);
+ }
+
+ ///
+ public IReadOnlyDictionary? SchemaFor(string urn) =>
+ _schemas.TryGetValue(urn, out var schema) ? schema : null;
+}
diff --git a/src/BabelQueue.Core/Schema/PayloadValidator.cs b/src/BabelQueue.Core/Schema/PayloadValidator.cs
new file mode 100644
index 0000000..e1093e2
--- /dev/null
+++ b/src/BabelQueue.Core/Schema/PayloadValidator.cs
@@ -0,0 +1,209 @@
+using System;
+using System.Collections.Generic;
+
+namespace BabelQueue.Schema;
+
+///
+/// Validates a message's data block against a per-URN JSON Schema (ADR-0024). A
+/// hand-rolled subset of Draft-07 (zero dependencies) whose verdicts match the Go, PHP,
+/// Python, Node and Java validators and babelqueue-registry's compat linter. Supported
+/// keywords: type, required, properties, additionalProperties,
+/// items, enum, const, minLength, minimum; unknown keywords
+/// are ignored. It works on the materialized object? tree the codec produces.
+///
+public static class PayloadValidator
+{
+ private static readonly IReadOnlyDictionary EmptyProps =
+ new Dictionary();
+
+ ///
+ /// The first violation of against as
+ /// "<json-pointer>: <reason>", or null when it conforms.
+ ///
+ public static string? Validate(IReadOnlyDictionary schema, object? value) =>
+ ValidateNode(schema, value, string.Empty);
+
+ private static string? ValidateNode(IReadOnlyDictionary schema, object? value, string path)
+ {
+ if (schema.TryGetValue("const", out var constValue) && !Equal(value, constValue))
+ {
+ return Violation(path, "wrong_const");
+ }
+
+ if (schema.TryGetValue("enum", out var enumObj)
+ && enumObj is IReadOnlyList